From 4af8070450b0aa08b15b6c4ad7e795d364385447 Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Mon, 25 Mar 2024 18:41:24 -0700
Subject: [PATCH] Feature: PDF actions - merge, split & rotate (#6094)

---
 docs/api.md                                   |  12 +
 docs/usage.md                                 |  12 +
 src-ui/messages.xlf                           | 434 +++++++++++++-----
 src-ui/src/app/app.module.ts                  |  14 +
 .../merge-confirm-dialog.component.html       |  39 ++
 .../merge-confirm-dialog.component.scss       |   3 +
 .../merge-confirm-dialog.component.spec.ts    |  73 +++
 .../merge-confirm-dialog.component.ts         |  51 ++
 .../rotate-confirm-dialog.component.html      |  48 ++
 .../rotate-confirm-dialog.component.scss      |   3 +
 .../rotate-confirm-dialog.component.spec.ts   |  60 +++
 .../rotate-confirm-dialog.component.ts        |  34 ++
 .../split-confirm-dialog.component.html       |  55 +++
 .../split-confirm-dialog.component.scss       |   9 +
 .../split-confirm-dialog.component.spec.ts    |  81 ++++
 .../split-confirm-dialog.component.ts         |  66 +++
 .../document-detail.component.html            |  12 +-
 .../document-detail.component.spec.ts         |  56 +++
 .../document-detail.component.ts              |  81 ++++
 .../bulk-editor/bulk-editor.component.html    |  36 +-
 .../bulk-editor/bulk-editor.component.spec.ts |  79 ++++
 .../bulk-editor/bulk-editor.component.ts      |  62 ++-
 src/documents/bulk_edit.py                    | 146 ++++++
 src/documents/caching.py                      |  18 +-
 src/documents/data_models.py                  |  40 ++
 src/documents/serialisers.py                  |  50 ++
 src/documents/signals/handlers.py             |   5 +-
 src/documents/tasks.py                        |   4 +
 src/documents/tests/test_api_bulk_edit.py     | 150 ++++++
 src/documents/tests/test_bulk_edit.py         | 261 +++++++++++
 src/documents/views.py                        |   3 +-
 31 files changed, 1847 insertions(+), 150 deletions(-)
 create mode 100644 src-ui/src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.html
 create mode 100644 src-ui/src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.scss
 create mode 100644 src-ui/src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.spec.ts
 create mode 100644 src-ui/src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.ts
 create mode 100644 src-ui/src/app/components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component.html
 create mode 100644 src-ui/src/app/components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component.scss
 create mode 100644 src-ui/src/app/components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component.spec.ts
 create mode 100644 src-ui/src/app/components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component.ts
 create mode 100644 src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.html
 create mode 100644 src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.scss
 create mode 100644 src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.spec.ts
 create mode 100644 src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.ts

diff --git a/docs/api.md b/docs/api.md
index ed00ab276..c2a83938d 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -376,6 +376,18 @@ The following methods are supported:
     - `"merge": true or false` (defaults to false)
   - The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including
     removing them) or be merged with existing permissions.
+- `merge`
+  - No additional `parameters` required.
+  - The ordering of the merged document is determined by the list of IDs.
+  - Optional `parameters`:
+    - `"metadata_document_id": DOC_ID` apply metadata (tags, correspondent, etc.) from this document to the merged document.
+- `split`
+  - Requires `parameters`:
+    - `"pages": [..]` The list should be a list of pages and/or a ranges, separated by commas e.g. `"[1,2-3,4,5-7]"`
+  - The split operation only accepts a single document.
+- `rotate`
+  - Requires `parameters`:
+    - `"degrees": DEGREES`. Must be an integer i.e. 90, 180, 270
 
 ### Objects
 
diff --git a/docs/usage.md b/docs/usage.md
index 4db1c94de..e5ef3b2c0 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -456,6 +456,18 @@ Paperless-ngx added the ability to create shareable links to files in version 2.
 
     If your paperless-ngx instance is behind a reverse-proxy you may want to create an exception to bypass any authentication layers that are part of your setup in order to make links truly publicly-accessible. Of course, do so with caution.
 
+## PDF Actions
+
+Paperless-ngx supports 3 basic editing operations for PDFs (these operations cannot be performed on non-PDF files):
+
+- Merging documents: available when selecting multiple documents for 'bulk editing'
+- Rotating documents: available when selecting multiple documents for 'bulk editing' and from an individual document's details page.
+- Splitting documents: available from an individual document's details page
+
+!!! important
+
+    Note that rotation alters the Paperless-ngx original file.
+
 ## Best practices {#basic-searching}
 
 Paperless offers a couple tools that help you organize your document
diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf
index ae34385a1..95adeb28b 100644
--- a/src-ui/messages.xlf
+++ b/src-ui/messages.xlf
@@ -287,7 +287,7 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">81</context>
+          <context context-type="linenumber">89</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1241348629231510663" datatype="html">
@@ -447,7 +447,7 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">312</context>
+          <context context-type="linenumber">320</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3768927257183755959" datatype="html">
@@ -506,7 +506,7 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">304</context>
+          <context context-type="linenumber">312</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html</context>
@@ -636,7 +636,7 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">321</context>
+          <context context-type="linenumber">329</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
@@ -977,7 +977,7 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">280</context>
+          <context context-type="linenumber">288</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
@@ -1464,7 +1464,7 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
-          <context context-type="linenumber">140</context>
+          <context context-type="linenumber">142</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.html</context>
@@ -2032,15 +2032,15 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">766</context>
+          <context context-type="linenumber">768</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">580</context>
+          <context context-type="linenumber">591</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">619</context>
+          <context context-type="linenumber">630</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.ts</context>
@@ -2075,11 +2075,27 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">768</context>
+          <context context-type="linenumber">770</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
+          <context context-type="linenumber">1052</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
+          <context context-type="linenumber">1090</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">621</context>
+          <context context-type="linenumber">632</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
+          <context context-type="linenumber">665</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
+          <context context-type="linenumber">684</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.ts</context>
@@ -2522,19 +2538,19 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">356</context>
+          <context context-type="linenumber">367</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">396</context>
+          <context context-type="linenumber">407</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">434</context>
+          <context context-type="linenumber">445</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">472</context>
+          <context context-type="linenumber">483</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2159130950882492111" datatype="html">
@@ -2604,6 +2620,74 @@
           <context context-type="linenumber">20</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="994016933065248559" datatype="html">
+        <source>Documents:</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.html</context>
+          <context context-type="linenumber">9</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="7508164375697837821" datatype="html">
+        <source>Use metadata from:</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.html</context>
+          <context context-type="linenumber">22</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="2020403212524346652" datatype="html">
+        <source>Regenerate all metadata</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.html</context>
+          <context context-type="linenumber">24</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="5138283234724909648" datatype="html">
+        <source>Note that only PDFs will be included.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.html</context>
+          <context context-type="linenumber">30</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="8157388568390631653" datatype="html">
+        <source>Note that only PDFs will be rotated.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component.html</context>
+          <context context-type="linenumber">35</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="1407560924967345762" datatype="html">
+        <source>Page</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.html</context>
+          <context context-type="linenumber">11</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
+          <context context-type="linenumber">4</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
+          <context context-type="linenumber">11</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="2266163016683537825" datatype="html">
+        <source>of <x id="INTERPOLATION" equiv-text="{{totalPages}}"/></source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.html</context>
+          <context context-type="linenumber">13</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
+          <context context-type="linenumber">6,7</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="6567555383934959967" datatype="html">
+        <source>Add Split</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.html</context>
+          <context context-type="linenumber">28</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="3972154626835212608" datatype="html">
         <source>Create New Field</source>
         <context-group purpose="location">
@@ -4709,7 +4793,7 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">94</context>
+          <context context-type="linenumber">102</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
@@ -4732,7 +4816,7 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">98</context>
+          <context context-type="linenumber">106</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
@@ -4770,7 +4854,7 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
-          <context context-type="linenumber">106</context>
+          <context context-type="linenumber">114</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
@@ -4903,7 +4987,7 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">301</context>
+          <context context-type="linenumber">312</context>
         </context-group>
         <note priority="1" from="description">this string is used to separate processing, failed and added on the file upload widget</note>
       </trans-unit>
@@ -4949,24 +5033,6 @@
           <context context-type="linenumber">1</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="1407560924967345762" datatype="html">
-        <source>Page</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">4</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
-          <context context-type="linenumber">11</context>
-        </context-group>
-      </trans-unit>
-      <trans-unit id="2266163016683537825" datatype="html">
-        <source>of <x id="INTERPOLATION" equiv-text="{{previewNumPages}}"/></source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">6,7</context>
-        </context-group>
-      </trans-unit>
       <trans-unit id="8590109102084543521" datatype="html">
         <source>-</source>
         <context-group purpose="location">
@@ -4996,7 +5062,7 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
-          <context context-type="linenumber">91</context>
+          <context context-type="linenumber">92</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1418444397960583910" datatype="html">
@@ -5010,11 +5076,33 @@
           <context context-type="linenumber">50</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="2434944824726929798" datatype="html">
+        <source>Split</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
+          <context context-type="linenumber">55</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="1050269006235116171" datatype="html">
+        <source>Rotate</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
+          <context context-type="linenumber">59</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
+          <context context-type="linenumber">95</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="7819314041543176992" datatype="html">
         <source>Close</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">75</context>
+          <context context-type="linenumber">83</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
+          <context context-type="linenumber">1108</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/guards/dirty-saved-view.guard.ts</context>
@@ -5025,35 +5113,35 @@
         <source>Previous</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">78</context>
+          <context context-type="linenumber">86</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5028777105388019087" datatype="html">
         <source>Details</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">91</context>
+          <context context-type="linenumber">99</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1379170675585571971" datatype="html">
         <source>Archive serial number</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">95</context>
+          <context context-type="linenumber">103</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5114742157723900905" datatype="html">
         <source>Date created</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">96</context>
+          <context context-type="linenumber">104</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5066119607229701477" datatype="html">
         <source>Document type</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">100</context>
+          <context context-type="linenumber">108</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
@@ -5076,7 +5164,7 @@
         <source>Storage path</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">102</context>
+          <context context-type="linenumber">110</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
@@ -5095,21 +5183,21 @@
         <source>Default</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">103</context>
+          <context context-type="linenumber">111</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6205355627445317276" datatype="html">
         <source>Content</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">187</context>
+          <context context-type="linenumber">195</context>
         </context-group>
       </trans-unit>
       <trans-unit id="218403386307979629" datatype="html">
         <source>Metadata</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">196</context>
+          <context context-type="linenumber">204</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/metadata-collapse/metadata-collapse.component.ts</context>
@@ -5120,190 +5208,190 @@
         <source>Date modified</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">203</context>
+          <context context-type="linenumber">211</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6392918669949841614" datatype="html">
         <source>Date added</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">207</context>
+          <context context-type="linenumber">215</context>
         </context-group>
       </trans-unit>
       <trans-unit id="146828917013192897" datatype="html">
         <source>Media filename</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">211</context>
+          <context context-type="linenumber">219</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4500855521601039868" datatype="html">
         <source>Original filename</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">215</context>
+          <context context-type="linenumber">223</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7985558498848210210" datatype="html">
         <source>Original MD5 checksum</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">219</context>
+          <context context-type="linenumber">227</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5888243105821763422" datatype="html">
         <source>Original file size</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">223</context>
+          <context context-type="linenumber">231</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2696647325713149563" datatype="html">
         <source>Original mime type</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">227</context>
+          <context context-type="linenumber">235</context>
         </context-group>
       </trans-unit>
       <trans-unit id="342875990758166588" datatype="html">
         <source>Archive MD5 checksum</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">232</context>
+          <context context-type="linenumber">240</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6033581412811562084" datatype="html">
         <source>Archive file size</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">238</context>
+          <context context-type="linenumber">246</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6992781481378431874" datatype="html">
         <source>Original document metadata</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">247</context>
+          <context context-type="linenumber">255</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2846565152091361585" datatype="html">
         <source>Archived document metadata</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">250</context>
+          <context context-type="linenumber">258</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1295614462098694869" datatype="html">
         <source>Preview</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">257</context>
+          <context context-type="linenumber">265</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7206723502037428235" datatype="html">
         <source>Notes <x id="START_BLOCK_IF" equiv-text="@if (document?.notes.length) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;badge text-bg-secondary ms-1&quot;&gt;"/><x id="INTERPOLATION" equiv-text="ngth}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">269,272</context>
+          <context context-type="linenumber">277,280</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5129524307369213584" datatype="html">
         <source>Save &amp; next</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">306</context>
+          <context context-type="linenumber">314</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4910102545766233758" datatype="html">
         <source>Save &amp; close</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">309</context>
+          <context context-type="linenumber">317</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8191371354890763172" datatype="html">
         <source>Enter Password</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">360</context>
+          <context context-type="linenumber">368</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2218903673684131427" datatype="html">
         <source>An error occurred loading content: <x id="PH" equiv-text="err.message ?? err.toString()"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">325,327</context>
+          <context context-type="linenumber">327,329</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3200733026060976258" datatype="html">
         <source>Document changes detected</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">348</context>
+          <context context-type="linenumber">350</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2887155916749964" datatype="html">
         <source>The version of this document in your browser session appears older than the existing version.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">349</context>
+          <context context-type="linenumber">351</context>
         </context-group>
       </trans-unit>
       <trans-unit id="237142428785956348" datatype="html">
         <source>Saving the document here may overwrite other changes that were made. To restore the existing version, discard your changes or close the document.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">350</context>
+          <context context-type="linenumber">352</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8720977247725652816" datatype="html">
         <source>Ok</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">352</context>
+          <context context-type="linenumber">354</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5758784066858623886" datatype="html">
         <source>Error retrieving metadata</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">492</context>
+          <context context-type="linenumber">494</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3456881259945295697" datatype="html">
         <source>Error retrieving suggestions.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">517</context>
+          <context context-type="linenumber">519</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8348337312757497317" datatype="html">
         <source>Document saved successfully.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">638</context>
+          <context context-type="linenumber">640</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">649</context>
+          <context context-type="linenumber">651</context>
         </context-group>
       </trans-unit>
       <trans-unit id="448882439049417053" datatype="html">
         <source>Error saving document</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">653</context>
+          <context context-type="linenumber">655</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">694</context>
+          <context context-type="linenumber">696</context>
         </context-group>
       </trans-unit>
       <trans-unit id="9021887951960049161" datatype="html">
         <source>Confirm delete</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">721</context>
+          <context context-type="linenumber">723</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
@@ -5318,67 +5406,138 @@
         <source>Do you really want to delete document &quot;<x id="PH" equiv-text="this.document.title"/>&quot;?</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">722</context>
+          <context context-type="linenumber">724</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6691075929777935948" datatype="html">
         <source>The files for this document will be deleted permanently. This operation cannot be undone.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">723</context>
+          <context context-type="linenumber">725</context>
         </context-group>
       </trans-unit>
       <trans-unit id="719892092227206532" datatype="html">
         <source>Delete document</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">725</context>
+          <context context-type="linenumber">727</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7295637485862454066" datatype="html">
         <source>Error deleting document</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">744</context>
+          <context context-type="linenumber">746</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7362691899087997122" datatype="html">
         <source>Redo OCR confirm</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">764</context>
+          <context context-type="linenumber">766</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">617</context>
+          <context context-type="linenumber">628</context>
         </context-group>
       </trans-unit>
       <trans-unit id="9197453786953646058" datatype="html">
         <source>This operation will permanently redo OCR for this document.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">765</context>
+          <context context-type="linenumber">767</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5729001209753056399" datatype="html">
         <source>Redo OCR operation will begin in the background. Close and re-open or reload this document after the operation has completed to see new content.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">776</context>
+          <context context-type="linenumber">778</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4409560272830824468" datatype="html">
         <source>Error executing operation</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">787</context>
+          <context context-type="linenumber">789</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4458954481601077369" datatype="html">
         <source>Page Fit</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">856</context>
+          <context context-type="linenumber">858</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="1217563727923422413" datatype="html">
+        <source>Split confirm</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
+          <context context-type="linenumber">1050</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="2805304563009985503" datatype="html">
+        <source>This operation will split the selected document(s) into new documents.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
+          <context context-type="linenumber">1051</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="4158171846914923744" datatype="html">
+        <source>Split operation will begin in the background.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
+          <context context-type="linenumber">1066</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="3235014591864339926" datatype="html">
+        <source>Error executing split operation</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
+          <context context-type="linenumber">1075</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="6555329262222566158" datatype="html">
+        <source>Rotate confirm</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
+          <context context-type="linenumber">1087</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
+          <context context-type="linenumber">661</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="1012437160148058675" datatype="html">
+        <source>This operation will permanently rotate the current document.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
+          <context context-type="linenumber">1088</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="4233432423256408453" datatype="html">
+        <source>This will alter the original copy.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
+          <context context-type="linenumber">1089</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
+          <context context-type="linenumber">663</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="4069543875319587651" datatype="html">
+        <source>Rotation will begin in the background. Close and re-open the document after the operation has completed to see the changes.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
+          <context context-type="linenumber">1105</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="2962674215361798818" datatype="html">
+        <source>Error executing rotate operation</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
+          <context context-type="linenumber">1117</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6857598786757174736" datatype="html">
@@ -5439,57 +5598,64 @@
           <context context-type="linenumber">65</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="3206542606001340679" datatype="html">
+        <source>Merge</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
+          <context context-type="linenumber">98</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="1015374532025907183" datatype="html">
         <source>Include:</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
-          <context context-type="linenumber">112</context>
+          <context context-type="linenumber">120</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="1208547554603365604" datatype="html">
-        <source> Archived files </source>
+      <trans-unit id="1537670659786159738" datatype="html">
+        <source>Archived files</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
-          <context context-type="linenumber">116,118</context>
+          <context context-type="linenumber">124</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="6791570188945688785" datatype="html">
-        <source> Original files </source>
+      <trans-unit id="2520291319362448498" datatype="html">
+        <source>Original files</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
-          <context context-type="linenumber">122,124</context>
+          <context context-type="linenumber">128</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="3608345051493493574" datatype="html">
-        <source> Use formatted filename </source>
+      <trans-unit id="8009862506882713059" datatype="html">
+        <source>Use formatted filename</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
-          <context context-type="linenumber">129,131</context>
+          <context context-type="linenumber">133</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1215215387232313677" datatype="html">
         <source>Error executing bulk operation</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">218</context>
+          <context context-type="linenumber">229</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7894972847287473517" datatype="html">
         <source>&quot;<x id="PH" equiv-text="items[0].name"/>&quot;</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">293</context>
+          <context context-type="linenumber">304</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">299</context>
+          <context context-type="linenumber">310</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8639884465898458690" datatype="html">
         <source>&quot;<x id="PH" equiv-text="items[0].name"/>&quot; and &quot;<x id="PH_1" equiv-text="items[1].name"/>&quot;</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">295</context>
+          <context context-type="linenumber">306</context>
         </context-group>
         <note priority="1" from="description">This is for messages like &apos;modify &quot;tag1&quot; and &quot;tag2&quot;&apos;</note>
       </trans-unit>
@@ -5497,7 +5663,7 @@
         <source><x id="PH" equiv-text="list"/> and &quot;<x id="PH_1" equiv-text="items[items.length - 1].name"/>&quot;</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">303,305</context>
+          <context context-type="linenumber">314,316</context>
         </context-group>
         <note priority="1" from="description">this is for messages like &apos;modify &quot;tag1&quot;, &quot;tag2&quot; and &quot;tag3&quot;&apos;</note>
       </trans-unit>
@@ -5505,14 +5671,14 @@
         <source>Confirm tags assignment</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">320</context>
+          <context context-type="linenumber">331</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6619516195038467207" datatype="html">
         <source>This operation will add the tag &quot;<x id="PH" equiv-text="tag.name"/>&quot; to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">326</context>
+          <context context-type="linenumber">337</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1894412783609570695" datatype="html">
@@ -5521,14 +5687,14 @@
         )"/> to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">331,333</context>
+          <context context-type="linenumber">342,344</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7181166515756808573" datatype="html">
         <source>This operation will remove the tag &quot;<x id="PH" equiv-text="tag.name"/>&quot; from <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">339</context>
+          <context context-type="linenumber">350</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3819792277998068944" datatype="html">
@@ -5537,7 +5703,7 @@
         )"/> from <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">344,346</context>
+          <context context-type="linenumber">355,357</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2739066218579571288" datatype="html">
@@ -5548,98 +5714,126 @@
         )"/> on <x id="PH_2" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">348,352</context>
+          <context context-type="linenumber">359,363</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2996713129519325161" datatype="html">
         <source>Confirm correspondent assignment</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">389</context>
+          <context context-type="linenumber">400</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6900893559485781849" datatype="html">
         <source>This operation will assign the correspondent &quot;<x id="PH" equiv-text="correspondent.name"/>&quot; to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">391</context>
+          <context context-type="linenumber">402</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1257522660364398440" datatype="html">
         <source>This operation will remove the correspondent from <x id="PH" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">393</context>
+          <context context-type="linenumber">404</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5393409374423140648" datatype="html">
         <source>Confirm document type assignment</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">427</context>
+          <context context-type="linenumber">438</context>
         </context-group>
       </trans-unit>
       <trans-unit id="332180123895325027" datatype="html">
         <source>This operation will assign the document type &quot;<x id="PH" equiv-text="documentType.name"/>&quot; to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">429</context>
+          <context context-type="linenumber">440</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2236642492594872779" datatype="html">
         <source>This operation will remove the document type from <x id="PH" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">431</context>
+          <context context-type="linenumber">442</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6386555513013840736" datatype="html">
         <source>Confirm storage path assignment</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">465</context>
+          <context context-type="linenumber">476</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8750527458618415924" datatype="html">
         <source>This operation will assign the storage path &quot;<x id="PH" equiv-text="storagePath.name"/>&quot; to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">467</context>
+          <context context-type="linenumber">478</context>
         </context-group>
       </trans-unit>
       <trans-unit id="60728365335056946" datatype="html">
         <source>This operation will remove the storage path from <x id="PH" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">469</context>
+          <context context-type="linenumber">480</context>
         </context-group>
       </trans-unit>
       <trans-unit id="749430623564850405" datatype="html">
         <source>Delete confirm</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">578</context>
+          <context context-type="linenumber">589</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4303174930844518780" datatype="html">
         <source>This operation will permanently delete <x id="PH" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">579</context>
+          <context context-type="linenumber">590</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6734339521247847366" datatype="html">
         <source>Delete document(s)</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">582</context>
+          <context context-type="linenumber">593</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8968869182645922415" datatype="html">
         <source>This operation will permanently redo OCR for <x id="PH" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">618</context>
+          <context context-type="linenumber">629</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="945321812966882943" datatype="html">
+        <source>This operation will permanently rotate <x id="PH" equiv-text="this.list.selected.size"/> selected document(s).</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
+          <context context-type="linenumber">662</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="7910756456450124185" datatype="html">
+        <source>Merge confirm</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
+          <context context-type="linenumber">682</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="7643543647233874431" datatype="html">
+        <source>This operation will merge <x id="PH" equiv-text="this.list.selected.size"/> selected documents into a new document.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
+          <context context-type="linenumber">683</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="7869008840945899895" datatype="html">
+        <source>Merged document will be queued for consumption.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
+          <context context-type="linenumber">696</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8076495233090006322" datatype="html">
diff --git a/src-ui/src/app/app.module.ts b/src-ui/src/app/app.module.ts
index 568b2bc0e..f990122dd 100644
--- a/src-ui/src/app/app.module.ts
+++ b/src-ui/src/app/app.module.ts
@@ -116,9 +116,13 @@ import { ConfirmButtonComponent } from './components/common/confirm-button/confi
 import { MonetaryComponent } from './components/common/input/monetary/monetary.component'
 import { SystemStatusDialogComponent } from './components/common/system-status-dialog/system-status-dialog.component'
 import { NgxFilesizeModule } from 'ngx-filesize'
+import { RotateConfirmDialogComponent } from './components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
+import { MergeConfirmDialogComponent } from './components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
+import { SplitConfirmDialogComponent } from './components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component'
 import {
   airplane,
   archive,
+  arrowClockwise,
   arrowCounterclockwise,
   arrowDown,
   arrowLeft,
@@ -127,6 +131,7 @@ import {
   arrowRightShort,
   arrowUpRight,
   asterisk,
+  bodyText,
   boxArrowUp,
   boxArrowUpRight,
   boxes,
@@ -174,6 +179,7 @@ import {
   hddStack,
   house,
   infoCircle,
+  journals,
   link,
   listTask,
   listUl,
@@ -188,6 +194,7 @@ import {
   plus,
   plusCircle,
   questionCircle,
+  scissors,
   search,
   slashCircle,
   sliders2Vertical,
@@ -209,6 +216,7 @@ import {
 const icons = {
   airplane,
   archive,
+  arrowClockwise,
   arrowCounterclockwise,
   arrowDown,
   arrowLeft,
@@ -217,6 +225,7 @@ const icons = {
   arrowRightShort,
   arrowUpRight,
   asterisk,
+  bodyText,
   boxArrowUp,
   boxArrowUpRight,
   boxes,
@@ -264,6 +273,7 @@ const icons = {
   hddStack,
   house,
   infoCircle,
+  journals,
   link,
   listTask,
   listUl,
@@ -278,6 +288,7 @@ const icons = {
   plus,
   plusCircle,
   questionCircle,
+  scissors,
   search,
   slashCircle,
   sliders2Vertical,
@@ -458,6 +469,9 @@ function initializeApp(settings: SettingsService) {
     ConfirmButtonComponent,
     MonetaryComponent,
     SystemStatusDialogComponent,
+    RotateConfirmDialogComponent,
+    MergeConfirmDialogComponent,
+    SplitConfirmDialogComponent,
   ],
   imports: [
     BrowserModule,
diff --git a/src-ui/src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.html b/src-ui/src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.html
new file mode 100644
index 000000000..0da291c94
--- /dev/null
+++ b/src-ui/src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.html
@@ -0,0 +1,39 @@
+<div class="modal-header">
+    <h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
+    <button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
+    </button>
+</div>
+<div class="modal-body">
+    <p>{{message}}</p>
+    <div class="form-group">
+        <label class="form-label" for="metadataDocumentID" i18n>Documents:</label>
+        <ul class="list-group"
+            cdkDropList
+            (cdkDropListDropped)="onDrop($event)">
+            @for (documentID of documentIDs; track documentID) {
+                <li class="list-group-item" cdkDrag>
+                    <i-bs name="grip-vertical" class="me-2"></i-bs>
+                    {{getDocument(documentID)?.title}}
+                </li>
+            }
+        </ul>
+        </div>
+    <div class="form-group mt-4">
+        <label class="form-label" for="metadataDocumentID" i18n>Use metadata from:</label>
+        <select class="form-select" [(ngModel)]="metadataDocumentID">
+            <option [ngValue]="-1" i18n>Regenerate all metadata</option>
+            @for (document of documents; track document.id) {
+              <option [ngValue]="document.id">{{document.title}}</option>
+            }
+        </select>
+    </div>
+    <p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be included.</p>
+</div>
+<div class="modal-footer">
+    <button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
+        <span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
+    </button>
+    <button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
+        {{btnCaption}}
+    </button>
+</div>
diff --git a/src-ui/src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.scss b/src-ui/src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.scss
new file mode 100644
index 000000000..c780e5a35
--- /dev/null
+++ b/src-ui/src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.scss
@@ -0,0 +1,3 @@
+.list-group-item {
+    cursor: move;
+}
diff --git a/src-ui/src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.spec.ts b/src-ui/src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.spec.ts
new file mode 100644
index 000000000..8b9bf3898
--- /dev/null
+++ b/src-ui/src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.spec.ts
@@ -0,0 +1,73 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
+import { MergeConfirmDialogComponent } from './merge-confirm-dialog.component'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
+import { of } from 'rxjs'
+import { DocumentService } from 'src/app/services/rest/document.service'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+
+describe('MergeConfirmDialogComponent', () => {
+  let component: MergeConfirmDialogComponent
+  let fixture: ComponentFixture<MergeConfirmDialogComponent>
+  let documentService: DocumentService
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [MergeConfirmDialogComponent],
+      providers: [NgbActiveModal],
+      imports: [
+        HttpClientTestingModule,
+        NgxBootstrapIconsModule.pick(allIcons),
+        ReactiveFormsModule,
+        FormsModule,
+      ],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(MergeConfirmDialogComponent)
+    documentService = TestBed.inject(DocumentService)
+    component = fixture.componentInstance
+    fixture.detectChanges()
+  })
+
+  it('should fetch documents on ngOnInit', () => {
+    const documents = [
+      { id: 1, name: 'Document 1' },
+      { id: 2, name: 'Document 2' },
+      { id: 3, name: 'Document 3' },
+    ]
+    jest.spyOn(documentService, 'getCachedMany').mockReturnValue(of(documents))
+
+    component.ngOnInit()
+
+    expect(component.documents).toEqual(documents)
+    expect(documentService.getCachedMany).toHaveBeenCalledWith(
+      component.documentIDs
+    )
+  })
+
+  it('should move documentIDs on drop', () => {
+    component.documentIDs = [1, 2, 3]
+    const event = {
+      previousIndex: 1,
+      currentIndex: 2,
+    }
+
+    component.onDrop(event as any)
+
+    expect(component.documentIDs).toEqual([1, 3, 2])
+  })
+
+  it('should get document by ID', () => {
+    const documents = [
+      { id: 1, name: 'Document 1' },
+      { id: 2, name: 'Document 2' },
+      { id: 3, name: 'Document 3' },
+    ]
+    jest.spyOn(documentService, 'getCachedMany').mockReturnValue(of(documents))
+
+    component.ngOnInit()
+
+    expect(component.getDocument(2)).toEqual({ id: 2, name: 'Document 2' })
+  })
+})
diff --git a/src-ui/src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.ts b/src-ui/src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.ts
new file mode 100644
index 000000000..fd52459e0
--- /dev/null
+++ b/src-ui/src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.ts
@@ -0,0 +1,51 @@
+import { Component, OnInit } from '@angular/core'
+import { ConfirmDialogComponent } from '../confirm-dialog.component'
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
+import { DocumentService } from 'src/app/services/rest/document.service'
+import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'
+import { Subject, takeUntil } from 'rxjs'
+import { Document } from 'src/app/data/document'
+
+@Component({
+  selector: 'pngx-merge-confirm-dialog',
+  templateUrl: './merge-confirm-dialog.component.html',
+  styleUrl: './merge-confirm-dialog.component.scss',
+})
+export class MergeConfirmDialogComponent
+  extends ConfirmDialogComponent
+  implements OnInit
+{
+  public documentIDs: number[] = []
+  private _documents: Document[] = []
+  get documents(): Document[] {
+    return this._documents
+  }
+
+  public metadataDocumentID: number = -1
+
+  private unsubscribeNotifier: Subject<any> = new Subject()
+
+  constructor(
+    activeModal: NgbActiveModal,
+    private documentService: DocumentService
+  ) {
+    super(activeModal)
+  }
+
+  ngOnInit() {
+    this.documentService
+      .getCachedMany(this.documentIDs)
+      .pipe(takeUntil(this.unsubscribeNotifier))
+      .subscribe((documents) => {
+        this._documents = documents
+      })
+  }
+
+  onDrop(event: CdkDragDrop<number[]>) {
+    moveItemInArray(this.documentIDs, event.previousIndex, event.currentIndex)
+  }
+
+  getDocument(documentID: number): Document {
+    return this.documents.find((d) => d.id === documentID)
+  }
+}
diff --git a/src-ui/src/app/components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component.html b/src-ui/src/app/components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component.html
new file mode 100644
index 000000000..8a6eacef4
--- /dev/null
+++ b/src-ui/src/app/components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component.html
@@ -0,0 +1,48 @@
+<div class="modal-header">
+    <h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
+    <button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
+    </button>
+</div>
+<div class="modal-body">
+    <div class="row">
+        <div class="col-2 d-flex justify-content-end">
+            <button class="btn btn-secondary mt-auto" (click)="rotate(false)">
+                <i-bs name="arrow-counterclockwise"></i-bs>
+            </button>
+        </div>
+        <div class="col-8 d-flex align-items-center">
+            @if (documentID) {
+                <img class="w-50 m-auto" [ngStyle]="{'transform': 'rotate('+rotation+'deg)'}" [src]="documentService.getThumbUrl(documentID)" />
+            }
+        </div>
+        <div class="col-2 d-flex">
+            <button class="btn btn-secondary mt-auto" (click)="rotate()">
+                <i-bs name="arrow-clockwise"></i-bs>
+            </button>
+        </div>
+    </div>
+    <div class="row mt-4">
+        <div class="col">
+            @if (messageBold) {
+              <p><b>{{messageBold}}</b></p>
+            }
+            @if (message) {
+              <p class="mb-0" [innerHTML]="message | safeHtml"></p>
+            }
+        </div>
+    </div>
+    @if (showPDFNote) {
+        <p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be rotated.</p>
+    }
+</div>
+<div class="modal-footer">
+    <button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
+        <span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
+    </button>
+    <button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled || degrees === 0">
+      {{btnCaption}}
+      @if (!confirmButtonEnabled) {
+          <ngb-progressbar style="height: 1px;" type="dark" [max]="secondsTotal" [value]="seconds"></ngb-progressbar>
+      }
+    </button>
+</div>
diff --git a/src-ui/src/app/components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component.scss b/src-ui/src/app/components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component.scss
new file mode 100644
index 000000000..93e950ac1
--- /dev/null
+++ b/src-ui/src/app/components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component.scss
@@ -0,0 +1,3 @@
+img {
+    transition: all 0.25s ease;
+}
diff --git a/src-ui/src/app/components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component.spec.ts b/src-ui/src/app/components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component.spec.ts
new file mode 100644
index 000000000..d70e73747
--- /dev/null
+++ b/src-ui/src/app/components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component.spec.ts
@@ -0,0 +1,60 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { RotateConfirmDialogComponent } from './rotate-confirm-dialog.component'
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
+import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
+
+describe('RotateConfirmDialogComponent', () => {
+  let component: RotateConfirmDialogComponent
+  let fixture: ComponentFixture<RotateConfirmDialogComponent>
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [RotateConfirmDialogComponent, SafeHtmlPipe],
+      providers: [NgbActiveModal, SafeHtmlPipe],
+      imports: [
+        HttpClientTestingModule,
+        NgxBootstrapIconsModule.pick(allIcons),
+      ],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(RotateConfirmDialogComponent)
+    component = fixture.componentInstance
+    fixture.detectChanges()
+  })
+
+  it('should support rotating the image', () => {
+    component.documentID = 1
+    fixture.detectChanges()
+    component.rotate()
+    fixture.detectChanges()
+    expect(component.degrees).toBe(90)
+    expect(fixture.nativeElement.querySelector('img').style.transform).toBe(
+      'rotate(90deg)'
+    )
+    component.rotate()
+    fixture.detectChanges()
+    expect(fixture.nativeElement.querySelector('img').style.transform).toBe(
+      'rotate(180deg)'
+    )
+  })
+
+  it('should normalize degrees', () => {
+    expect(component.degrees).toBe(0)
+    component.rotate()
+    expect(component.degrees).toBe(90)
+    component.rotate()
+    expect(component.degrees).toBe(180)
+    component.rotate()
+    expect(component.degrees).toBe(270)
+    component.rotate()
+    expect(component.degrees).toBe(0)
+    component.rotate()
+    expect(component.degrees).toBe(90)
+    component.rotate(false)
+    expect(component.degrees).toBe(0)
+    component.rotate(false)
+    expect(component.degrees).toBe(270)
+  })
+})
diff --git a/src-ui/src/app/components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component.ts b/src-ui/src/app/components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component.ts
new file mode 100644
index 000000000..7cef2b72e
--- /dev/null
+++ b/src-ui/src/app/components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component.ts
@@ -0,0 +1,34 @@
+import { Component } from '@angular/core'
+import { ConfirmDialogComponent } from '../confirm-dialog.component'
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
+import { DocumentService } from 'src/app/services/rest/document.service'
+
+@Component({
+  selector: 'pngx-rotate-confirm-dialog',
+  templateUrl: './rotate-confirm-dialog.component.html',
+  styleUrl: './rotate-confirm-dialog.component.scss',
+})
+export class RotateConfirmDialogComponent extends ConfirmDialogComponent {
+  public documentID: number
+  public showPDFNote: boolean = true
+
+  // animation is better if we dont normalize yet
+  public rotation: number = 0
+
+  public get degrees(): number {
+    let degrees = this.rotation % 360
+    if (degrees < 0) degrees += 360
+    return degrees
+  }
+
+  constructor(
+    activeModal: NgbActiveModal,
+    public documentService: DocumentService
+  ) {
+    super(activeModal)
+  }
+
+  rotate(clockwise: boolean = true) {
+    this.rotation += clockwise ? 90 : -90
+  }
+}
diff --git a/src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.html b/src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.html
new file mode 100644
index 000000000..36bd7796d
--- /dev/null
+++ b/src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.html
@@ -0,0 +1,55 @@
+<div class="modal-header">
+    <h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
+    <button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
+    </button>
+</div>
+<div class="modal-body">
+    <p>{{message}}</p>
+    <div class="row mb-2">
+        <div class="col-6">
+            <div class="input-group input-group-sm">
+                <div class="input-group-text" i18n>Page</div>
+                <input class="form-control" type="number" min="1" [(ngModel)]="page" />
+                <div class="input-group-text" i18n>of {{totalPages}}</div>
+            </div>
+            <div class="pdf-viewer-container w-100 mt-3">
+                <pngx-pdf-viewer [src]="pdfSrc" [(page)]="page"
+                [original-size]="false"
+                [zoom]="1"
+                zoom-scale="page-fit"
+                (after-load-complete)="pdfPreviewLoaded($event)">
+                </pngx-pdf-viewer>
+            </div>
+        </div>
+        <div class="col-6">
+            <div class="d-grid">
+                <button class="btn btn-sm btn-primary" (click)="addSplit()">
+                    <i-bs name="plus-circle"></i-bs>&nbsp;
+                    <span i18n>Add Split</span>
+                </button>
+            </div>
+
+            <ul class="list-group mt-3">
+                @for (pageStr of pagesString.split(','); track pageStr; let i = $index) {
+                    <li class="list-group-item">
+                        {{pageStr}}
+                        @if (pagesString.split(',').length > 1) {
+                            &nbsp;
+                            <button class="btn btn-sm btn-danger" (click)="removeSplit(i)">
+                                <i-bs name="trash"></i-bs>
+                            </button>
+                        }
+                    </li>
+                }
+            </ul>
+        </div>
+    </div>
+</div>
+<div class="modal-footer">
+    <button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
+            <span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
+        </button>
+    <button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
+        {{btnCaption}}
+    </button>
+</div>
diff --git a/src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.scss b/src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.scss
new file mode 100644
index 000000000..c2fc99d55
--- /dev/null
+++ b/src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.scss
@@ -0,0 +1,9 @@
+.pdf-viewer-container {
+    background-color: gray;
+    height: 300px;
+
+    pngx-pdf-viewer {
+      width: 100%;
+      height: 100%;
+    }
+  }
diff --git a/src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.spec.ts b/src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.spec.ts
new file mode 100644
index 000000000..b88835895
--- /dev/null
+++ b/src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.spec.ts
@@ -0,0 +1,81 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+
+import { SplitConfirmDialogComponent } from './split-confirm-dialog.component'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { ReactiveFormsModule, FormsModule } from '@angular/forms'
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
+import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
+import { DocumentService } from 'src/app/services/rest/document.service'
+import { PdfViewerComponent } from '../../pdf-viewer/pdf-viewer.component'
+
+describe('SplitConfirmDialogComponent', () => {
+  let component: SplitConfirmDialogComponent
+  let fixture: ComponentFixture<SplitConfirmDialogComponent>
+  let documentService: DocumentService
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [SplitConfirmDialogComponent, PdfViewerComponent],
+      providers: [NgbActiveModal],
+      imports: [
+        HttpClientTestingModule,
+        NgxBootstrapIconsModule.pick(allIcons),
+        ReactiveFormsModule,
+        FormsModule,
+      ],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(SplitConfirmDialogComponent)
+    documentService = TestBed.inject(DocumentService)
+    component = fixture.componentInstance
+    fixture.detectChanges()
+  })
+
+  it('should update pagesString when pages are added', () => {
+    component.totalPages = 5
+    component.page = 2
+    component.addSplit()
+    expect(component.pagesString).toEqual('1-2,3-5')
+    component.page = 4
+    component.addSplit()
+    expect(component.pagesString).toEqual('1-2,3-4,5')
+  })
+
+  it('should update pagesString when pages are removed', () => {
+    component.totalPages = 5
+    component.page = 2
+    component.addSplit()
+    component.page = 4
+    component.addSplit()
+    expect(component.pagesString).toEqual('1-2,3-4,5')
+    component.removeSplit(0)
+    expect(component.pagesString).toEqual('1-4,5')
+  })
+
+  it('should enable confirm button when pages are added', () => {
+    component.totalPages = 5
+    component.page = 2
+    component.addSplit()
+    expect(component.confirmButtonEnabled).toBeTruthy()
+  })
+
+  it('should disable confirm button when all pages are removed', () => {
+    component.totalPages = 5
+    component.page = 2
+    component.addSplit()
+    component.removeSplit(0)
+    expect(component.confirmButtonEnabled).toBeFalsy()
+  })
+
+  it('should not add split if page is the last page', () => {
+    component.totalPages = 5
+    component.page = 5
+    component.addSplit()
+    expect(component.pagesString).toEqual('1-5')
+  })
+
+  it('should update totalPages when pdf is loaded', () => {
+    component.pdfPreviewLoaded({ numPages: 5 } as any)
+    expect(component.totalPages).toEqual(5)
+  })
+})
diff --git a/src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.ts b/src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.ts
new file mode 100644
index 000000000..42b574b93
--- /dev/null
+++ b/src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.ts
@@ -0,0 +1,66 @@
+import { Component } from '@angular/core'
+import { ConfirmDialogComponent } from '../confirm-dialog.component'
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
+import { DocumentService } from 'src/app/services/rest/document.service'
+import { PDFDocumentProxy } from '../../pdf-viewer/typings'
+
+@Component({
+  selector: 'pngx-split-confirm-dialog',
+  templateUrl: './split-confirm-dialog.component.html',
+  styleUrl: './split-confirm-dialog.component.scss',
+})
+export class SplitConfirmDialogComponent extends ConfirmDialogComponent {
+  public get pagesString(): string {
+    let pagesStr = ''
+
+    let lastPage = 1
+    for (let i = 1; i <= this.totalPages; i++) {
+      if (this.pages.has(i) || i === this.totalPages) {
+        if (lastPage === i) {
+          pagesStr += `${i},`
+          lastPage = Math.min(i + 1, this.totalPages)
+        } else {
+          pagesStr += `${lastPage}-${i},`
+          lastPage = Math.min(i + 1, this.totalPages)
+        }
+      }
+    }
+
+    return pagesStr.replace(/,$/, '')
+  }
+
+  private pages: Set<number> = new Set()
+
+  public documentID: number
+  public page: number = 1
+  public totalPages: number
+
+  public get pdfSrc(): string {
+    return this.documentService.getPreviewUrl(this.documentID)
+  }
+
+  constructor(
+    activeModal: NgbActiveModal,
+    private documentService: DocumentService
+  ) {
+    super(activeModal)
+    this.confirmButtonEnabled = this.pages.size > 0
+  }
+
+  pdfPreviewLoaded(pdf: PDFDocumentProxy) {
+    this.totalPages = pdf.numPages
+  }
+
+  addSplit() {
+    if (this.page === this.totalPages) return
+    this.pages.add(this.page)
+    this.pages = new Set(Array.from(this.pages).sort())
+    this.confirmButtonEnabled = this.pages.size > 0
+  }
+
+  removeSplit(i: number) {
+    let page = Array.from(this.pages)[Math.min(i, this.pages.size - 1)]
+    this.pages.delete(page)
+    this.confirmButtonEnabled = this.pages.size > 0
+  }
+}
diff --git a/src-ui/src/app/components/document-detail/document-detail.component.html b/src-ui/src/app/components/document-detail/document-detail.component.html
index 5b27a51ac..f3a616fef 100644
--- a/src-ui/src/app/components/document-detail/document-detail.component.html
+++ b/src-ui/src/app/components/document-detail/document-detail.component.html
@@ -44,11 +44,19 @@
     </button>
     <div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow">
       <button ngbDropdownItem (click)="redoOcr()" [disabled]="!userCanEdit">
-        <i-bs width="1em" height="1em" name="arrow-counterclockwise"></i-bs><span class="ps-1" i18n>Redo OCR</span>
+        <i-bs width="1em" height="1em" name="arrow-counterclockwise"></i-bs>&nbsp;<span i18n>Redo OCR</span>
       </button>
 
       <button ngbDropdownItem (click)="moreLike()">
-        <i-bs width="1em" height="1em" name="diagram-3"></i-bs><span class="ps-1" i18n>More like this</span>
+        <i-bs width="1em" height="1em" name="diagram-3"></i-bs>&nbsp;<span i18n>More like this</span>
+      </button>
+
+      <button ngbDropdownItem (click)="splitDocument()" [disabled]="contentRenderType !== ContentRenderType.PDF || previewNumPages < 2">
+        <i-bs width="1em" height="1em" name="scissors"></i-bs>&nbsp;<span i18n>Split</span>
+      </button>
+
+      <button ngbDropdownItem (click)="rotateDocument()" [disabled]="!userIsOwner || contentRenderType !== ContentRenderType.PDF">
+        <i-bs name="arrow-clockwise"></i-bs>&nbsp;<ng-container i18n>Rotate</ng-container>
       </button>
     </div>
   </div>
diff --git a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts
index e0da11a3e..b26ad9024 100644
--- a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts
+++ b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts
@@ -75,6 +75,8 @@ import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service
 import { PdfViewerComponent } from '../common/pdf-viewer/pdf-viewer.component'
 import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
 import { environment } from 'src/environments/environment'
+import { RotateConfirmDialogComponent } from '../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
+import { SplitConfirmDialogComponent } from '../common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component'
 
 const doc: Document = {
   id: 3,
@@ -171,6 +173,8 @@ describe('DocumentDetailComponent', () => {
         ShareLinksDropdownComponent,
         CustomFieldsDropdownComponent,
         PdfViewerComponent,
+        SplitConfirmDialogComponent,
+        RotateConfirmDialogComponent,
       ],
       providers: [
         DocumentTitlePipe,
@@ -1070,6 +1074,58 @@ describe('DocumentDetailComponent', () => {
     ).not.toBeUndefined()
   })
 
+  it('should support split', () => {
+    let modal: NgbModalRef
+    modalService.activeInstances.subscribe((m) => (modal = m[0]))
+    initNormally()
+    component.splitDocument()
+    expect(modal).not.toBeUndefined()
+    modal.componentInstance.documentID = doc.id
+    modal.componentInstance.totalPages = 5
+    modal.componentInstance.page = 2
+    modal.componentInstance.addSplit()
+    modal.componentInstance.confirm()
+    let req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/bulk_edit/`
+    )
+    expect(req.request.body).toEqual({
+      documents: [doc.id],
+      method: 'split',
+      parameters: { pages: '1-2,3-5' },
+    })
+    req.error(new ProgressEvent('failed'))
+    modal.componentInstance.confirm()
+    req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/bulk_edit/`
+    )
+    req.flush(true)
+  })
+
+  it('should support rotate', () => {
+    let modal: NgbModalRef
+    modalService.activeInstances.subscribe((m) => (modal = m[0]))
+    initNormally()
+    component.rotateDocument()
+    expect(modal).not.toBeUndefined()
+    modal.componentInstance.documentID = doc.id
+    modal.componentInstance.rotate()
+    modal.componentInstance.confirm()
+    let req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/bulk_edit/`
+    )
+    expect(req.request.body).toEqual({
+      documents: [doc.id],
+      method: 'rotate',
+      parameters: { degrees: 90 },
+    })
+    req.error(new ProgressEvent('failed'))
+    modal.componentInstance.confirm()
+    req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/bulk_edit/`
+    )
+    req.flush(true)
+  })
+
   function initNormally() {
     jest
       .spyOn(activatedRoute, 'paramMap', 'get')
diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts
index 4eae47615..5c5efdc9f 100644
--- a/src-ui/src/app/components/document-detail/document-detail.component.ts
+++ b/src-ui/src/app/components/document-detail/document-detail.component.ts
@@ -67,6 +67,8 @@ import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
 import { CustomFieldInstance } from 'src/app/data/custom-field-instance'
 import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
 import { PDFDocumentProxy } from '../common/pdf-viewer/typings'
+import { SplitConfirmDialogComponent } from '../common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component'
+import { RotateConfirmDialogComponent } from '../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
 
 enum DocumentDetailNavIDs {
   Details = 1,
@@ -1040,4 +1042,83 @@ export class DocumentDetailComponent
     this.updateFormForCustomFields(true)
     this.documentForm.updateValueAndValidity()
   }
+
+  splitDocument() {
+    let modal = this.modalService.open(SplitConfirmDialogComponent, {
+      backdrop: 'static',
+    })
+    modal.componentInstance.title = $localize`Split confirm`
+    modal.componentInstance.messageBold = $localize`This operation will split the selected document(s) into new documents.`
+    modal.componentInstance.btnCaption = $localize`Proceed`
+    modal.componentInstance.documentID = this.document.id
+    modal.componentInstance.confirmClicked
+      .pipe(takeUntil(this.unsubscribeNotifier))
+      .subscribe(() => {
+        modal.componentInstance.buttonsEnabled = false
+        this.documentsService
+          .bulkEdit([this.document.id], 'split', {
+            pages: modal.componentInstance.pagesString,
+          })
+          .pipe(first(), takeUntil(this.unsubscribeNotifier))
+          .subscribe({
+            next: () => {
+              this.toastService.showInfo(
+                $localize`Split operation will begin in the background.`
+              )
+              modal.close()
+            },
+            error: (error) => {
+              if (modal) {
+                modal.componentInstance.buttonsEnabled = true
+              }
+              this.toastService.showError(
+                $localize`Error executing split operation`,
+                error
+              )
+            },
+          })
+      })
+  }
+
+  rotateDocument() {
+    let modal = this.modalService.open(RotateConfirmDialogComponent, {
+      backdrop: 'static',
+    })
+    modal.componentInstance.title = $localize`Rotate confirm`
+    modal.componentInstance.messageBold = $localize`This operation will permanently rotate the current document.`
+    modal.componentInstance.message = $localize`This will alter the original copy.`
+    modal.componentInstance.btnCaption = $localize`Proceed`
+    modal.componentInstance.documentID = this.document.id
+    modal.componentInstance.showPDFNote = false
+    modal.componentInstance.confirmClicked
+      .pipe(takeUntil(this.unsubscribeNotifier))
+      .subscribe(() => {
+        modal.componentInstance.buttonsEnabled = false
+        this.documentsService
+          .bulkEdit([this.document.id], 'rotate', {
+            degrees: modal.componentInstance.degrees,
+          })
+          .pipe(first(), takeUntil(this.unsubscribeNotifier))
+          .subscribe({
+            next: () => {
+              this.toastService.show({
+                content: $localize`Rotation will begin in the background. Close and re-open the document after the operation has completed to see the changes.`,
+                delay: 8000,
+                action: this.close.bind(this),
+                actionName: $localize`Close`,
+              })
+              modal.close()
+            },
+            error: (error) => {
+              if (modal) {
+                modal.componentInstance.buttonsEnabled = true
+              }
+              this.toastService.showError(
+                $localize`Error executing rotate operation`,
+                error
+              )
+            },
+          })
+      })
+  }
 }
diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html
index 686c07bb3..865502569 100644
--- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html
+++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html
@@ -80,18 +80,26 @@
 
             <button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || !userCanEditAll">
               <i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Permissions</ng-container></div>
-              </button>
+            </button>
 
-              <div ngbDropdown>
-                <button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle>
-                  <i-bs name="three-dots"></i-bs>
-                  <div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Actions</ng-container></div>
+            <div ngbDropdown>
+              <button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle>
+                <i-bs name="three-dots"></i-bs>
+                <div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Actions</ng-container></div>
+              </button>
+              <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
+                <button ngbDropdownItem (click)="redoOcrSelected()" [disabled]="!userCanEditAll">
+                  <i-bs name="body-text"></i-bs>&nbsp;<ng-container i18n>Redo OCR</ng-container>
+                </button>
+                <button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll">
+                  <i-bs name="arrow-clockwise"></i-bs>&nbsp;<ng-container i18n>Rotate</ng-container>
+                </button>
+                <button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanEditAll || list.selected.size < 2">
+                  <i-bs name="journals"></i-bs>&nbsp;<ng-container i18n>Merge</ng-container>
                 </button>
-                <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
-                  <button ngbDropdownItem (click)="redoOcrSelected()" [disabled]="!userCanEditAll" i18n>Redo OCR</button>
-                </div>
               </div>
             </div>
+          </div>
 
             <div class="btn-group btn-group-sm">
               <button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()">
@@ -113,22 +121,16 @@
                     <div class="form-group ps-3 mb-2">
                       <div class="form-check">
                         <input type="checkbox" class="form-check-input" id="downloadFileType_archive" formControlName="downloadFileTypeArchive" />
-                        <label class="form-check-label" for="downloadFileType_archive" i18n>
-                  Archived files
-                </label>
+                        <label class="form-check-label" for="downloadFileType_archive" i18n>Archived files</label>
                       </div>
                       <div class="form-check">
                         <input type="checkbox" class="form-check-input" id="downloadFileType_originals" formControlName="downloadFileTypeOriginals" />
-                        <label class="form-check-label" for="downloadFileType_originals" i18n>
-                  Original files
-                </label>
+                        <label class="form-check-label" for="downloadFileType_originals" i18n>Original files</label>
                       </div>
                     </div>
                     <div class="form-check">
                       <input type="checkbox" class="form-check-input" id="downloadUseFormatting" formControlName="downloadUseFormatting" />
-                      <label class="form-check-label" for="downloadUseFormatting" i18n>
-                Use formatted filename
-              </label>
+                      <label class="form-check-label" for="downloadUseFormatting" i18n>Use formatted filename</label>
                     </div>
                   </form>
                 </div>
diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts
index 4da9f36df..e38138df1 100644
--- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts
+++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts
@@ -52,6 +52,9 @@ import { StoragePath } from 'src/app/data/storage-path'
 import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
 import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
 import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
+import { IsNumberPipe } from 'src/app/pipes/is-number.pipe'
+import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
+import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
 
 const selectionData: SelectionData = {
   selected_tags: [
@@ -97,6 +100,9 @@ describe('BulkEditorComponent', () => {
         PermissionsGroupComponent,
         PermissionsUserComponent,
         SwitchComponent,
+        RotateConfirmDialogComponent,
+        IsNumberPipe,
+        MergeConfirmDialogComponent,
       ],
       providers: [
         PermissionsService,
@@ -818,6 +824,79 @@ describe('BulkEditorComponent', () => {
     ) // listAllFilteredIds
   })
 
+  it('should support rotate', () => {
+    let modal: NgbModalRef
+    modalService.activeInstances.subscribe((m) => (modal = m[0]))
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    jest
+      .spyOn(documentListViewService, 'documents', 'get')
+      .mockReturnValue([{ id: 3 }, { id: 4 }])
+    jest
+      .spyOn(documentListViewService, 'selected', 'get')
+      .mockReturnValue(new Set([3, 4]))
+    jest
+      .spyOn(permissionsService, 'currentUserHasObjectPermissions')
+      .mockReturnValue(true)
+    fixture.detectChanges()
+    component.rotateSelected()
+    expect(modal).not.toBeUndefined()
+    modal.componentInstance.rotate()
+    modal.componentInstance.confirm()
+    let req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/bulk_edit/`
+    )
+    req.flush(true)
+    expect(req.request.body).toEqual({
+      documents: [3, 4],
+      method: 'rotate',
+      parameters: { degrees: 90 },
+    })
+    httpTestingController.match(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
+    ) // list reload
+    httpTestingController.match(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
+    ) // listAllFilteredIds
+  })
+
+  it('should support merge', () => {
+    let modal: NgbModalRef
+    modalService.activeInstances.subscribe((m) => (modal = m[0]))
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    jest
+      .spyOn(documentListViewService, 'documents', 'get')
+      .mockReturnValue([{ id: 3 }, { id: 4 }])
+    jest
+      .spyOn(documentService, 'getCachedMany')
+      .mockReturnValue(of([{ id: 3 }, { id: 4 }]))
+    jest
+      .spyOn(documentListViewService, 'selected', 'get')
+      .mockReturnValue(new Set([3, 4]))
+    jest
+      .spyOn(permissionsService, 'currentUserHasObjectPermissions')
+      .mockReturnValue(true)
+    fixture.detectChanges()
+    component.mergeSelected()
+    expect(modal).not.toBeUndefined()
+    modal.componentInstance.metadataDocumentID = 3
+    modal.componentInstance.confirm()
+    let req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/bulk_edit/`
+    )
+    req.flush(true)
+    expect(req.request.body).toEqual({
+      documents: [3, 4],
+      method: 'merge',
+      parameters: { metadata_document_id: 3 },
+    })
+    httpTestingController.match(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
+    ) // list reload
+    httpTestingController.match(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
+    ) // listAllFilteredIds
+  })
+
   it('should support bulk download with archive, originals or both and file formatting', () => {
     jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
     jest
diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts
index 0bfb287cb..46a4980a6 100644
--- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts
+++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts
@@ -6,7 +6,7 @@ import { TagService } from 'src/app/services/rest/tag.service'
 import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
 import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
 import { DocumentListViewService } from 'src/app/services/document-list-view.service'
-import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
 import {
   DocumentService,
   SelectionDataItem,
@@ -39,6 +39,8 @@ import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
 import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
 import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
 import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
+import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
+import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
 
 @Component({
   selector: 'pngx-bulk-editor',
@@ -192,12 +194,21 @@ export class BulkEditorComponent
     this.unsubscribeNotifier.complete()
   }
 
-  private executeBulkOperation(modal, method: string, args) {
+  private executeBulkOperation(
+    modal: NgbModalRef,
+    method: string,
+    args: any,
+    overrideDocumentIDs?: number[]
+  ) {
     if (modal) {
       modal.componentInstance.buttonsEnabled = false
     }
     this.documentService
-      .bulkEdit(Array.from(this.list.selected), method, args)
+      .bulkEdit(
+        overrideDocumentIDs ?? Array.from(this.list.selected),
+        method,
+        args
+      )
       .pipe(first())
       .subscribe({
         next: () => {
@@ -641,4 +652,49 @@ export class BulkEditorComponent
       }
     )
   }
+
+  rotateSelected() {
+    let modal = this.modalService.open(RotateConfirmDialogComponent, {
+      backdrop: 'static',
+    })
+    const rotateDialog = modal.componentInstance as RotateConfirmDialogComponent
+    rotateDialog.title = $localize`Rotate confirm`
+    rotateDialog.messageBold = $localize`This operation will permanently rotate ${this.list.selected.size} selected document(s).`
+    rotateDialog.message = $localize`This will alter the original copy.`
+    rotateDialog.btnClass = 'btn-danger'
+    rotateDialog.btnCaption = $localize`Proceed`
+    rotateDialog.documentID = Array.from(this.list.selected)[0]
+    rotateDialog.confirmClicked
+      .pipe(takeUntil(this.unsubscribeNotifier))
+      .subscribe(() => {
+        rotateDialog.buttonsEnabled = false
+        this.executeBulkOperation(modal, 'rotate', {
+          degrees: rotateDialog.degrees,
+        })
+      })
+  }
+
+  mergeSelected() {
+    let modal = this.modalService.open(MergeConfirmDialogComponent, {
+      backdrop: 'static',
+    })
+    const mergeDialog = modal.componentInstance as MergeConfirmDialogComponent
+    mergeDialog.title = $localize`Merge confirm`
+    mergeDialog.messageBold = $localize`This operation will merge ${this.list.selected.size} selected documents into a new document.`
+    mergeDialog.btnCaption = $localize`Proceed`
+    mergeDialog.documentIDs = Array.from(this.list.selected)
+    mergeDialog.confirmClicked
+      .pipe(takeUntil(this.unsubscribeNotifier))
+      .subscribe(() => {
+        const args = {}
+        if (mergeDialog.metadataDocumentID > -1) {
+          args['metadata_document_id'] = mergeDialog.metadataDocumentID
+        }
+        mergeDialog.buttonsEnabled = false
+        this.executeBulkOperation(modal, 'merge', args, mergeDialog.documentIDs)
+        this.toastService.showInfo(
+          $localize`Merged document will be queued for consumption.`
+        )
+      })
+  }
 }
diff --git a/src/documents/bulk_edit.py b/src/documents/bulk_edit.py
index ba001fd14..6b676733d 100644
--- a/src/documents/bulk_edit.py
+++ b/src/documents/bulk_edit.py
@@ -1,15 +1,27 @@
+import hashlib
 import itertools
+import logging
+import os
+from typing import Optional
 
+from celery import chord
+from django.conf import settings
 from django.db.models import Q
 
+from documents.data_models import ConsumableDocument
+from documents.data_models import DocumentMetadataOverrides
+from documents.data_models import DocumentSource
 from documents.models import Correspondent
 from documents.models import Document
 from documents.models import DocumentType
 from documents.models import StoragePath
 from documents.permissions import set_permissions_for_object
 from documents.tasks import bulk_update_documents
+from documents.tasks import consume_file
 from documents.tasks import update_document_archive_file
 
+logger = logging.getLogger("paperless.bulk_edit")
+
 
 def set_correspondent(doc_ids, correspondent):
     if correspondent:
@@ -146,3 +158,137 @@ def set_permissions(doc_ids, set_permissions, owner=None, merge=False):
     bulk_update_documents.delay(document_ids=affected_docs)
 
     return "OK"
+
+
+def rotate(doc_ids: list[int], degrees: int):
+    logger.info(
+        f"Attempting to rotate {len(doc_ids)} documents by {degrees} degrees.",
+    )
+    qs = Document.objects.filter(id__in=doc_ids)
+    affected_docs = []
+    import pikepdf
+
+    rotate_tasks = []
+    for doc in qs:
+        if doc.mime_type != "application/pdf":
+            logger.warning(
+                f"Document {doc.id} is not a PDF, skipping rotation.",
+            )
+            continue
+        try:
+            with pikepdf.open(doc.source_path, allow_overwriting_input=True) as pdf:
+                for page in pdf.pages:
+                    page.rotate(degrees, relative=True)
+                pdf.save()
+                doc.checksum = hashlib.md5(doc.source_path.read_bytes()).hexdigest()
+                doc.save()
+                rotate_tasks.append(
+                    update_document_archive_file.s(
+                        document_id=doc.id,
+                    ),
+                )
+                logger.info(
+                    f"Rotated document {doc.id} by {degrees} degrees",
+                )
+                affected_docs.append(doc.id)
+        except Exception as e:
+            logger.exception(f"Error rotating document {doc.id}: {e}")
+
+    if len(affected_docs) > 0:
+        bulk_update_task = bulk_update_documents.s(document_ids=affected_docs)
+        chord(header=rotate_tasks, body=bulk_update_task).delay()
+
+    return "OK"
+
+
+def merge(doc_ids: list[int], metadata_document_id: Optional[int] = None):
+    logger.info(
+        f"Attempting to merge {len(doc_ids)} documents into a single document.",
+    )
+    qs = Document.objects.filter(id__in=doc_ids)
+    affected_docs = []
+    import pikepdf
+
+    merged_pdf = pikepdf.new()
+    version = merged_pdf.pdf_version
+    # use doc_ids to preserve order
+    for doc_id in doc_ids:
+        doc = qs.get(id=doc_id)
+        try:
+            with pikepdf.open(str(doc.source_path)) as pdf:
+                version = max(version, pdf.pdf_version)
+                merged_pdf.pages.extend(pdf.pages)
+            affected_docs.append(doc.id)
+        except Exception as e:
+            logger.exception(
+                f"Error merging document {doc.id}, it will not be included in the merge: {e}",
+            )
+    if len(affected_docs) == 0:
+        logger.warning("No documents were merged")
+        return "OK"
+
+    filepath = os.path.join(
+        settings.SCRATCH_DIR,
+        f"{'_'.join([str(doc_id) for doc_id in doc_ids])[:100]}_merged.pdf",
+    )
+    merged_pdf.remove_unreferenced_resources()
+    merged_pdf.save(filepath, min_version=version)
+    merged_pdf.close()
+
+    if metadata_document_id:
+        metadata_document = qs.get(id=metadata_document_id)
+        if metadata_document is not None:
+            overrides = DocumentMetadataOverrides.from_document(metadata_document)
+            overrides.title = metadata_document.title + " (merged)"
+    else:
+        overrides = DocumentMetadataOverrides()
+
+    logger.info("Adding merged document to the task queue.")
+    consume_file.delay(
+        ConsumableDocument(
+            source=DocumentSource.ConsumeFolder,
+            original_file=filepath,
+        ),
+        overrides,
+    )
+
+    return "OK"
+
+
+def split(doc_ids: list[int], pages: list[list[int]]):
+    logger.info(
+        f"Attempting to split document {doc_ids[0]} into {len(pages)} documents",
+    )
+    doc = Document.objects.get(id=doc_ids[0])
+    import pikepdf
+
+    try:
+        with pikepdf.open(doc.source_path) as pdf:
+            for idx, split_doc in enumerate(pages):
+                dst = pikepdf.new()
+                for page in split_doc:
+                    dst.pages.append(pdf.pages[page - 1])
+                filepath = os.path.join(
+                    settings.SCRATCH_DIR,
+                    f"{doc.id}_{split_doc[0]}-{split_doc[-1]}.pdf",
+                )
+                dst.remove_unreferenced_resources()
+                dst.save(filepath)
+                dst.close()
+
+                overrides = DocumentMetadataOverrides().from_document(doc)
+                overrides.title = f"{doc.title} (split {idx + 1})"
+                logger.info(
+                    f"Adding split document with pages {split_doc} to the task queue.",
+                )
+                consume_file.delay(
+                    ConsumableDocument(
+                        source=DocumentSource.ConsumeFolder,
+                        original_file=filepath,
+                    ),
+                    overrides,
+                )
+    except Exception as e:
+        logger.exception(f"Error splitting document {doc.id}: {e}")
+
+    return "OK"
diff --git a/src/documents/caching.py b/src/documents/caching.py
index a1f20d086..9abd3bc65 100644
--- a/src/documents/caching.py
+++ b/src/documents/caching.py
@@ -189,13 +189,21 @@ def refresh_metadata_cache(
     cache.touch(doc_key, timeout)
 
 
-def clear_metadata_cache(document_id: int) -> None:
-    doc_key = get_metadata_cache_key(document_id)
-    cache.delete(doc_key)
-
-
 def get_thumbnail_modified_key(document_id: int) -> str:
     """
     Builds the key to store a thumbnail's timestamp
     """
     return f"doc_{document_id}_thumbnail_modified"
+
+
+def clear_document_caches(document_id: int) -> None:
+    """
+    Removes all cached items for the given document
+    """
+    cache.delete_many(
+        [
+            get_suggestion_cache_key(document_id),
+            get_metadata_cache_key(document_id),
+            get_thumbnail_modified_key(document_id),
+        ],
+    )
diff --git a/src/documents/data_models.py b/src/documents/data_models.py
index 6bf3f4f96..4922b72dd 100644
--- a/src/documents/data_models.py
+++ b/src/documents/data_models.py
@@ -5,6 +5,8 @@ from pathlib import Path
 from typing import Optional
 
 import magic
+from guardian.shortcuts import get_groups_with_perms
+from guardian.shortcuts import get_users_with_perms
 
 
 @dataclasses.dataclass
@@ -88,6 +90,44 @@ class DocumentMetadataOverrides:
 
         return self
 
+    @staticmethod
+    def from_document(doc) -> "DocumentMetadataOverrides":
+        """
+        Fills in the overrides from a document object
+        """
+        overrides = DocumentMetadataOverrides()
+        overrides.title = doc.title
+        overrides.correspondent_id = doc.correspondent.id if doc.correspondent else None
+        overrides.document_type_id = doc.document_type.id if doc.document_type else None
+        overrides.storage_path_id = doc.storage_path.id if doc.storage_path else None
+        overrides.owner_id = doc.owner.id if doc.owner else None
+        overrides.tag_ids = list(doc.tags.values_list("id", flat=True))
+
+        overrides.view_users = get_users_with_perms(
+            doc,
+            only_with_perms_in=["view_document"],
+        ).values_list("id", flat=True)
+        overrides.change_users = get_users_with_perms(
+            doc,
+            only_with_perms_in=["change_document"],
+        ).values_list("id", flat=True)
+        overrides.custom_field_ids = list(
+            doc.custom_fields.values_list("id", flat=True),
+        )
+
+        groups_with_perms = get_groups_with_perms(
+            doc,
+            attach_perms=True,
+        )
+        overrides.view_groups = [
+            group.id for group, perms in groups_with_perms if "view_document" in perms
+        ]
+        overrides.change_groups = [
+            group.id for group, perms in groups_with_perms if "change_document" in perms
+        ]
+
+        return overrides
+
 
 class DocumentSource(IntEnum):
     """
diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py
index 1c2c6a095..bdac7660e 100644
--- a/src/documents/serialisers.py
+++ b/src/documents/serialisers.py
@@ -869,6 +869,9 @@ class BulkEditSerializer(DocumentListSerializer, SetPermissionsMixin):
             "delete",
             "redo_ocr",
             "set_permissions",
+            "rotate",
+            "merge",
+            "split",
         ],
         label="Method",
         write_only=True,
@@ -906,6 +909,12 @@ class BulkEditSerializer(DocumentListSerializer, SetPermissionsMixin):
             return bulk_edit.redo_ocr
         elif method == "set_permissions":
             return bulk_edit.set_permissions
+        elif method == "rotate":
+            return bulk_edit.rotate
+        elif method == "merge":
+            return bulk_edit.merge
+        elif method == "split":
+            return bulk_edit.split
         else:
             raise serializers.ValidationError("Unsupported method.")
 
@@ -984,6 +993,39 @@ class BulkEditSerializer(DocumentListSerializer, SetPermissionsMixin):
         if "merge" not in parameters:
             parameters["merge"] = False
 
+    def _validate_parameters_rotate(self, parameters):
+        try:
+            if (
+                "degrees" not in parameters
+                or not float(parameters["degrees"]).is_integer()
+            ):
+                raise serializers.ValidationError("invalid rotation degrees")
+        except ValueError:
+            raise serializers.ValidationError("invalid rotation degrees")
+
+    def _validate_parameters_split(self, parameters):
+        if "pages" not in parameters:
+            raise serializers.ValidationError("pages not specified")
+        try:
+            pages = []
+            docs = parameters["pages"].split(",")
+            for doc in docs:
+                if "-" in doc:
+                    pages.append(
+                        [
+                            x
+                            for x in range(
+                                int(doc.split("-")[0]),
+                                int(doc.split("-")[1]) + 1,
+                            )
+                        ],
+                    )
+                else:
+                    pages.append([int(doc)])
+            parameters["pages"] = pages
+        except ValueError:
+            raise serializers.ValidationError("invalid pages specified")
+
     def validate(self, attrs):
         method = attrs["method"]
         parameters = attrs["parameters"]
@@ -1000,6 +1042,14 @@ class BulkEditSerializer(DocumentListSerializer, SetPermissionsMixin):
             self._validate_storage_path(parameters)
         elif method == bulk_edit.set_permissions:
             self._validate_parameters_set_permissions(parameters)
+        elif method == bulk_edit.rotate:
+            self._validate_parameters_rotate(parameters)
+        elif method == bulk_edit.split:
+            if len(attrs["documents"]) > 1:
+                raise serializers.ValidationError(
+                    "Split method only supports one document",
+                )
+            self._validate_parameters_split(parameters)
 
         return attrs
 
diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py
index 85e8126c1..cdfedcb4c 100644
--- a/src/documents/signals/handlers.py
+++ b/src/documents/signals/handlers.py
@@ -23,7 +23,7 @@ from filelock import FileLock
 from guardian.shortcuts import remove_perm
 
 from documents import matching
-from documents.caching import clear_metadata_cache
+from documents.caching import clear_document_caches
 from documents.classifier import DocumentClassifier
 from documents.consumer import parse_doc_title_w_placeholders
 from documents.file_handling import create_source_path_directory
@@ -439,7 +439,8 @@ def update_filename_and_move_files(sender, instance: Document, **kwargs):
                 archive_filename=instance.archive_filename,
                 modified=timezone.now(),
             )
-            clear_metadata_cache(instance.pk)
+            # Clear any caching for this document.  Slightly overkill, but not terrible
+            clear_document_caches(instance.pk)
 
         except (OSError, DatabaseError, CannotMoveFilesException) as e:
             logger.warning(f"Exception during file handling: {e}")
diff --git a/src/documents/tasks.py b/src/documents/tasks.py
index a83c2e6cd..0ab55ac45 100644
--- a/src/documents/tasks.py
+++ b/src/documents/tasks.py
@@ -18,6 +18,7 @@ from whoosh.writing import AsyncWriter
 from documents import index
 from documents import sanity_checker
 from documents.barcodes import BarcodePlugin
+from documents.caching import clear_document_caches
 from documents.classifier import DocumentClassifier
 from documents.classifier import load_classifier
 from documents.consumer import Consumer
@@ -213,6 +214,7 @@ def bulk_update_documents(document_ids):
     ix = index.open_index()
 
     for doc in documents:
+        clear_document_caches(doc.pk)
         document_updated.send(
             sender=None,
             document=doc,
@@ -305,6 +307,8 @@ def update_document_archive_file(document_id):
             with index.open_index_writer() as writer:
                 index.update_document(writer, document)
 
+            clear_document_caches(document.pk)
+
     except Exception:
         logger.exception(
             f"Error while parsing document {document} (ID: {document_id})",
diff --git a/src/documents/tests/test_api_bulk_edit.py b/src/documents/tests/test_api_bulk_edit.py
index 10093eb44..d659c82e8 100644
--- a/src/documents/tests/test_api_bulk_edit.py
+++ b/src/documents/tests/test_api_bulk_edit.py
@@ -781,3 +781,153 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
         self.assertEqual(response.status_code, status.HTTP_200_OK)
 
         m.assert_called_once()
+
+    @mock.patch("documents.serialisers.bulk_edit.rotate")
+    def test_rotate(self, m):
+        m.return_value = "OK"
+
+        response = self.client.post(
+            "/api/documents/bulk_edit/",
+            json.dumps(
+                {
+                    "documents": [self.doc2.id, self.doc3.id],
+                    "method": "rotate",
+                    "parameters": {"degrees": 90},
+                },
+            ),
+            content_type="application/json",
+        )
+
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+        m.assert_called_once()
+        args, kwargs = m.call_args
+        self.assertCountEqual(args[0], [self.doc2.id, self.doc3.id])
+        self.assertEqual(kwargs["degrees"], 90)
+
+    @mock.patch("documents.serialisers.bulk_edit.rotate")
+    def test_rotate_invalid_params(self, m):
+        response = self.client.post(
+            "/api/documents/bulk_edit/",
+            json.dumps(
+                {
+                    "documents": [self.doc2.id, self.doc3.id],
+                    "method": "rotate",
+                    "parameters": {"degrees": "foo"},
+                },
+            ),
+            content_type="application/json",
+        )
+
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+        response = self.client.post(
+            "/api/documents/bulk_edit/",
+            json.dumps(
+                {
+                    "documents": [self.doc2.id, self.doc3.id],
+                    "method": "rotate",
+                    "parameters": {"degrees": 90.5},
+                },
+            ),
+            content_type="application/json",
+        )
+
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+        m.assert_not_called()
+
+    @mock.patch("documents.serialisers.bulk_edit.merge")
+    def test_merge(self, m):
+        m.return_value = "OK"
+
+        response = self.client.post(
+            "/api/documents/bulk_edit/",
+            json.dumps(
+                {
+                    "documents": [self.doc2.id, self.doc3.id],
+                    "method": "merge",
+                    "parameters": {"metadata_document_id": self.doc3.id},
+                },
+            ),
+            content_type="application/json",
+        )
+
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+        m.assert_called_once()
+        args, kwargs = m.call_args
+        self.assertCountEqual(args[0], [self.doc2.id, self.doc3.id])
+        self.assertEqual(kwargs["metadata_document_id"], self.doc3.id)
+
+    @mock.patch("documents.serialisers.bulk_edit.split")
+    def test_split(self, m):
+        m.return_value = "OK"
+
+        response = self.client.post(
+            "/api/documents/bulk_edit/",
+            json.dumps(
+                {
+                    "documents": [self.doc2.id],
+                    "method": "split",
+                    "parameters": {"pages": "1,2-4,5-6,7"},
+                },
+            ),
+            content_type="application/json",
+        )
+
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+        m.assert_called_once()
+        args, kwargs = m.call_args
+        self.assertCountEqual(args[0], [self.doc2.id])
+        self.assertEqual(kwargs["pages"], [[1], [2, 3, 4], [5, 6], [7]])
+
+    def test_split_invalid_params(self):
+        response = self.client.post(
+            "/api/documents/bulk_edit/",
+            json.dumps(
+                {
+                    "documents": [self.doc2.id],
+                    "method": "split",
+                    "parameters": {},  # pages not specified
+                },
+            ),
+            content_type="application/json",
+        )
+
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+        self.assertIn(b"pages not specified", response.content)
+
+        response = self.client.post(
+            "/api/documents/bulk_edit/",
+            json.dumps(
+                {
+                    "documents": [self.doc2.id],
+                    "method": "split",
+                    "parameters": {"pages": "1:7"},  # wrong format
+                },
+            ),
+            content_type="application/json",
+        )
+
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+        self.assertIn(b"invalid pages specified", response.content)
+
+        response = self.client.post(
+            "/api/documents/bulk_edit/",
+            json.dumps(
+                {
+                    "documents": [
+                        self.doc1.id,
+                        self.doc2.id,
+                    ],  # only one document supported
+                    "method": "split",
+                    "parameters": {"pages": "1-2,3-7"},  # wrong format
+                },
+            ),
+            content_type="application/json",
+        )
+
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+        self.assertIn(b"Split method only supports one document", response.content)
diff --git a/src/documents/tests/test_bulk_edit.py b/src/documents/tests/test_bulk_edit.py
index f73835302..aca492649 100644
--- a/src/documents/tests/test_bulk_edit.py
+++ b/src/documents/tests/test_bulk_edit.py
@@ -1,3 +1,5 @@
+import shutil
+from pathlib import Path
 from unittest import mock
 
 from django.contrib.auth.models import Group
@@ -275,3 +277,262 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
             self.doc1,
         )
         self.assertEqual(groups_with_perms.count(), 2)
+
+
+class TestPDFActions(DirectoriesMixin, TestCase):
+    def setUp(self):
+        super().setUp()
+        sample1 = self.dirs.scratch_dir / "sample.pdf"
+        shutil.copy(
+            Path(__file__).parent
+            / "samples"
+            / "documents"
+            / "originals"
+            / "0000001.pdf",
+            sample1,
+        )
+        sample1_archive = self.dirs.archive_dir / "sample_archive.pdf"
+        shutil.copy(
+            Path(__file__).parent
+            / "samples"
+            / "documents"
+            / "originals"
+            / "0000001.pdf",
+            sample1_archive,
+        )
+        sample2 = self.dirs.scratch_dir / "sample2.pdf"
+        shutil.copy(
+            Path(__file__).parent
+            / "samples"
+            / "documents"
+            / "originals"
+            / "0000002.pdf",
+            sample2,
+        )
+        sample2_archive = self.dirs.archive_dir / "sample2_archive.pdf"
+        shutil.copy(
+            Path(__file__).parent
+            / "samples"
+            / "documents"
+            / "originals"
+            / "0000002.pdf",
+            sample2_archive,
+        )
+        sample3 = self.dirs.scratch_dir / "sample3.pdf"
+        shutil.copy(
+            Path(__file__).parent
+            / "samples"
+            / "documents"
+            / "originals"
+            / "0000003.pdf",
+            sample3,
+        )
+        self.doc1 = Document.objects.create(
+            checksum="A",
+            title="A",
+            filename=sample1,
+            mime_type="application/pdf",
+        )
+        self.doc1.archive_filename = sample1_archive
+        self.doc1.save()
+        self.doc2 = Document.objects.create(
+            checksum="B",
+            title="B",
+            filename=sample2,
+            mime_type="application/pdf",
+        )
+        self.doc2.archive_filename = sample2_archive
+        self.doc2.save()
+        self.doc3 = Document.objects.create(
+            checksum="C",
+            title="C",
+            filename=sample3,
+            mime_type="application/pdf",
+        )
+        img_doc = self.dirs.scratch_dir / "sample_image.jpg"
+        shutil.copy(
+            Path(__file__).parent / "samples" / "simple.jpg",
+            img_doc,
+        )
+        self.img_doc = Document.objects.create(
+            checksum="D",
+            title="D",
+            filename=img_doc,
+            mime_type="image/jpeg",
+        )
+
+    @mock.patch("documents.tasks.consume_file.delay")
+    def test_merge(self, mock_consume_file):
+        """
+        GIVEN:
+            - Existing documents
+        WHEN:
+            - Merge action is called with 3 documents
+        THEN:
+            - Consume file should be called
+        """
+        doc_ids = [self.doc1.id, self.doc2.id, self.doc3.id]
+        metadata_document_id = self.doc1.id
+
+        result = bulk_edit.merge(doc_ids)
+
+        expected_filename = (
+            f"{'_'.join([str(doc_id) for doc_id in doc_ids])[:100]}_merged.pdf"
+        )
+
+        mock_consume_file.assert_called()
+        consume_file_args, _ = mock_consume_file.call_args
+        self.assertEqual(
+            Path(consume_file_args[0].original_file).name,
+            expected_filename,
+        )
+        self.assertEqual(consume_file_args[1].title, None)
+
+        # With metadata_document_id overrides
+        result = bulk_edit.merge(doc_ids, metadata_document_id=metadata_document_id)
+        consume_file_args, _ = mock_consume_file.call_args
+        self.assertEqual(consume_file_args[1].title, "A (merged)")
+
+        self.assertEqual(result, "OK")
+
+    @mock.patch("documents.tasks.consume_file.delay")
+    @mock.patch("pikepdf.open")
+    def test_merge_with_errors(self, mock_open_pdf, mock_consume_file):
+        """
+        GIVEN:
+            - Existing documents
+        WHEN:
+            - Merge action is called with 2 documents
+            - Error occurs when opening both files
+        THEN:
+            - Consume file should not be called
+        """
+        mock_open_pdf.side_effect = Exception("Error opening PDF")
+        doc_ids = [self.doc2.id, self.doc3.id]
+
+        with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
+            bulk_edit.merge(doc_ids)
+            error_str = cm.output[0]
+            expected_str = (
+                "Error merging document 2, it will not be included in the merge"
+            )
+            self.assertIn(expected_str, error_str)
+
+        mock_consume_file.assert_not_called()
+
+    @mock.patch("documents.tasks.consume_file.delay")
+    def test_split(self, mock_consume_file):
+        """
+        GIVEN:
+            - Existing documents
+        WHEN:
+            - Split action is called with 1 document and 2 pages
+        THEN:
+            - Consume file should be called twice
+        """
+        doc_ids = [self.doc2.id]
+        pages = [[1, 2], [3]]
+        result = bulk_edit.split(doc_ids, pages)
+        self.assertEqual(mock_consume_file.call_count, 2)
+        consume_file_args, _ = mock_consume_file.call_args
+        self.assertEqual(consume_file_args[1].title, "B (split 2)")
+
+        self.assertEqual(result, "OK")
+
+    @mock.patch("documents.tasks.consume_file.delay")
+    @mock.patch("pikepdf.Pdf.save")
+    def test_split_with_errors(self, mock_save_pdf, mock_consume_file):
+        """
+        GIVEN:
+            - Existing documents
+        WHEN:
+            - Split action is called with 1 document and 2 page groups
+            - Error occurs when saving the files
+        THEN:
+            - Consume file should not be called
+        """
+        mock_save_pdf.side_effect = Exception("Error saving PDF")
+        doc_ids = [self.doc2.id]
+        pages = [[1, 2], [3]]
+
+        with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
+            bulk_edit.split(doc_ids, pages)
+            error_str = cm.output[0]
+            expected_str = "Error splitting document 2"
+            self.assertIn(expected_str, error_str)
+
+        mock_consume_file.assert_not_called()
+
+    @mock.patch("documents.tasks.bulk_update_documents.s")
+    @mock.patch("documents.tasks.update_document_archive_file.s")
+    @mock.patch("celery.chord.delay")
+    def test_rotate(self, mock_chord, mock_update_document, mock_update_documents):
+        """
+        GIVEN:
+            - Existing documents
+        WHEN:
+            - Rotate action is called with 2 documents
+        THEN:
+            - Rotate action should be called twice
+        """
+        doc_ids = [self.doc1.id, self.doc2.id]
+        result = bulk_edit.rotate(doc_ids, 90)
+        self.assertEqual(mock_update_document.call_count, 2)
+        mock_update_documents.assert_called_once()
+        mock_chord.assert_called_once()
+        self.assertEqual(result, "OK")
+
+    @mock.patch("documents.tasks.bulk_update_documents.s")
+    @mock.patch("documents.tasks.update_document_archive_file.s")
+    @mock.patch("pikepdf.Pdf.save")
+    def test_rotate_with_error(
+        self,
+        mock_pdf_save,
+        mock_update_archive_file,
+        mock_update_documents,
+    ):
+        """
+        GIVEN:
+            - Existing documents
+        WHEN:
+            - Rotate action is called with 2 documents
+            - PikePDF raises an error
+        THEN:
+            - Rotate action should be called 0 times
+        """
+        mock_pdf_save.side_effect = Exception("Error saving PDF")
+        doc_ids = [self.doc2.id, self.doc3.id]
+
+        with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
+            bulk_edit.rotate(doc_ids, 90)
+            error_str = cm.output[0]
+            expected_str = "Error rotating document"
+            self.assertIn(expected_str, error_str)
+            mock_update_archive_file.assert_not_called()
+
+    @mock.patch("documents.tasks.bulk_update_documents.s")
+    @mock.patch("documents.tasks.update_document_archive_file.s")
+    @mock.patch("celery.chord.delay")
+    def test_rotate_non_pdf(
+        self,
+        mock_chord,
+        mock_update_document,
+        mock_update_documents,
+    ):
+        """
+        GIVEN:
+            - Existing documents
+        WHEN:
+            - Rotate action is called with 2 documents, one of which is not a PDF
+        THEN:
+            - Rotate action should be performed 1 time, with the non-PDF document skipped
+        """
+        with self.assertLogs("paperless.bulk_edit", level="INFO") as cm:
+            result = bulk_edit.rotate([self.doc2.id, self.img_doc.id], 90)
+            output_str = cm.output[1]
+            expected_str = "Document 4 is not a PDF, skipping rotation"
+            self.assertIn(expected_str, output_str)
+            self.assertEqual(mock_update_document.call_count, 1)
+            mock_update_documents.assert_called_once()
+            mock_chord.assert_called_once()
+            self.assertEqual(result, "OK")
diff --git a/src/documents/views.py b/src/documents/views.py
index 5fa0f7eb1..3e1996215 100644
--- a/src/documents/views.py
+++ b/src/documents/views.py
@@ -891,7 +891,8 @@ class BulkEditView(GenericAPIView, PassUserMixin):
             document_objs = Document.objects.filter(pk__in=documents)
             has_perms = (
                 all((doc.owner == user or doc.owner is None) for doc in document_objs)
-                if method == bulk_edit.set_permissions
+                if method
+                in [bulk_edit.set_permissions, bulk_edit.delete, bulk_edit.rotate]
                 else all(
                     has_perms_owner_aware(user, "change_document", doc)
                     for doc in document_objs