]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Enhancement: delete pages PDF action (#6772)
authorshamoon <4887959+shamoon@users.noreply.github.com>
Wed, 22 May 2024 23:01:15 +0000 (16:01 -0700)
committerGitHub <noreply@github.com>
Wed, 22 May 2024 23:01:15 +0000 (23:01 +0000)
16 files changed:
docs/api.md
docs/usage.md
src-ui/messages.xlf
src-ui/src/app/app.module.ts
src-ui/src/app/components/common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component.html [new file with mode: 0644]
src-ui/src/app/components/common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component.scss [new file with mode: 0644]
src-ui/src/app/components/common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component.ts [new file with mode: 0644]
src-ui/src/app/components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component.html
src-ui/src/app/components/document-detail/document-detail.component.html
src-ui/src/app/components/document-detail/document-detail.component.spec.ts
src-ui/src/app/components/document-detail/document-detail.component.ts
src/documents/bulk_edit.py
src/documents/serialisers.py
src/documents/tests/test_api_bulk_edit.py
src/documents/tests/test_bulk_edit.py

index 160b7c07e5566082b4518f1c3721358f6ee7c5f5..07714e690afb4f3de71e37fadac29b4d5f076aff 100644 (file)
@@ -424,6 +424,10 @@ The following methods are supported:
 - `rotate`
   - Requires `parameters`:
     - `"degrees": DEGREES`. Must be an integer i.e. 90, 180, 270
+- `delete_pages`
+  - Requires `parameters`:
+    - `"pages": [..]` The list should be a list of integers e.g. `"[2,3,4]"`
+  - The delete_pages operation only accepts a single document.
 
 ### Objects
 
index 52713fb86b26fd89a2e6b5b4b5e33f85ef2818f4..8706a7911ddaf44ecdb83f2f9775c04b0d00222c 100644 (file)
@@ -462,15 +462,16 @@ Paperless-ngx added the ability to create shareable links to files in version 2.
 
 ## PDF Actions
 
-Paperless-ngx supports 3 basic editing operations for PDFs (these operations cannot be performed on non-PDF files):
+Paperless-ngx supports four basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files):
 
-- Merging documents: available when selecting multiple documents for 'bulk editing'
+- 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
+- Splitting documents: available from an individual document's details page.
+- Deleting pages: available from an individual document's details page.
 
 !!! important
 
-    Note that rotation alters the Paperless-ngx _original_ file, which would, for example, invalidate a digital signature.
+    Note that rotation and deleting pages alter the Paperless-ngx _original_ file, which would, for example, invalidate a digital signature.
 
 ## Document History
 
index 502b29e30e754d872b46decfae45c7d32359f7fb..9af66a45dda6130c5c345eb77258a380046c57c9 100644 (file)
         </context-group>
         <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">95</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1241348629231510663" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">333</context>
+          <context context-type="linenumber">337</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3768927257183755959" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">325</context>
+          <context context-type="linenumber">329</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>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">342</context>
+          <context context-type="linenumber">346</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</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">301</context>
+          <context context-type="linenumber">305</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</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">113</context>
+          <context context-type="linenumber">117</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7877440816920439876" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">809</context>
+          <context context-type="linenumber">818</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</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">811</context>
+          <context context-type="linenumber">820</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">1113</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">1104</context>
+          <context context-type="linenumber">1150</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">1142</context>
+          <context context-type="linenumber">1191</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">579</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/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component.html</context>
+          <context context-type="linenumber">11</context>
+        </context-group>
+        <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">5</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/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component.html</context>
+          <context context-type="linenumber">13</context>
+        </context-group>
+        <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">7,8</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="6903610408081711391" datatype="html">
+        <source>Pages to remove</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component.html</context>
+          <context context-type="linenumber">16</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="994016933065248559" datatype="html">
         <source>Documents:</source>
         <context-group purpose="location">
         <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">5</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">7,8</context>
+          <context context-type="linenumber">25</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6567555383934959967" datatype="html">
           <context context-type="linenumber">114</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="4399672576012609374" datatype="html">
+        <source>Delete page(s)</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
+          <context context-type="linenumber">65</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">85</context>
+          <context context-type="linenumber">89</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">1160</context>
+          <context context-type="linenumber">1168</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/guards/dirty-saved-view.guard.ts</context>
         <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">88</context>
+          <context context-type="linenumber">92</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">101</context>
+          <context context-type="linenumber">105</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5701618810648052610" datatype="html">
         <source>Title</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">104</context>
+          <context context-type="linenumber">108</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
         <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">105</context>
+          <context context-type="linenumber">109</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">106</context>
+          <context context-type="linenumber">110</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2691296884221415710" datatype="html">
         <source>Correspondent</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">108</context>
+          <context context-type="linenumber">112</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
         <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">110</context>
+          <context context-type="linenumber">114</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
         <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">112</context>
+          <context context-type="linenumber">116</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
         <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">197</context>
+          <context context-type="linenumber">201</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">206</context>
+          <context context-type="linenumber">210</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/metadata-collapse/metadata-collapse.component.ts</context>
         <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">213</context>
+          <context context-type="linenumber">217</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">217</context>
+          <context context-type="linenumber">221</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">221</context>
+          <context context-type="linenumber">225</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">225</context>
+          <context context-type="linenumber">229</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">229</context>
+          <context context-type="linenumber">233</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">233</context>
+          <context context-type="linenumber">237</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">237</context>
+          <context context-type="linenumber">241</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">242</context>
+          <context context-type="linenumber">246</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">248</context>
+          <context context-type="linenumber">252</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">257</context>
+          <context context-type="linenumber">261</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">260</context>
+          <context context-type="linenumber">264</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">267</context>
+          <context context-type="linenumber">271</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">279,282</context>
+          <context context-type="linenumber">283,286</context>
         </context-group>
       </trans-unit>
       <trans-unit id="186236568870281953" datatype="html">
         <source>History</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">290</context>
+          <context context-type="linenumber">294</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">327</context>
+          <context context-type="linenumber">331</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">330</context>
+          <context context-type="linenumber">334</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">381</context>
+          <context context-type="linenumber">385</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">330,332</context>
+          <context context-type="linenumber">339,341</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">353</context>
+          <context context-type="linenumber">362</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">354</context>
+          <context context-type="linenumber">363</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">355</context>
+          <context context-type="linenumber">364</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">357</context>
+          <context context-type="linenumber">366</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6142395741265832184" datatype="html">
         <source>Next document</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">464</context>
+          <context context-type="linenumber">473</context>
         </context-group>
       </trans-unit>
       <trans-unit id="651985345816518480" datatype="html">
         <source>Previous document</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">474</context>
+          <context context-type="linenumber">483</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2885986061416655600" datatype="html">
         <source>Close document</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">482</context>
+          <context context-type="linenumber">491</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
         <source>Save document</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">489</context>
+          <context context-type="linenumber">498</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">531</context>
+          <context context-type="linenumber">540</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">556</context>
+          <context context-type="linenumber">565</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">678</context>
+          <context context-type="linenumber">687</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">692</context>
+          <context context-type="linenumber">701</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">696</context>
+          <context context-type="linenumber">705</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">737</context>
+          <context context-type="linenumber">746</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">764</context>
+          <context context-type="linenumber">773</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
         <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">765</context>
+          <context context-type="linenumber">774</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">766</context>
+          <context context-type="linenumber">775</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">768</context>
+          <context context-type="linenumber">777</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">787</context>
+          <context context-type="linenumber">796</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">807</context>
+          <context context-type="linenumber">816</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
         <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">808</context>
+          <context context-type="linenumber">817</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">819</context>
+          <context context-type="linenumber">828</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">830</context>
+          <context context-type="linenumber">839</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">899</context>
+          <context context-type="linenumber">908</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">1102</context>
+          <context context-type="linenumber">1111</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">1103</context>
+          <context context-type="linenumber">1112</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">1118</context>
+          <context context-type="linenumber">1127</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">1127</context>
+          <context context-type="linenumber">1136</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">1139</context>
+          <context context-type="linenumber">1148</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
         <source>This operation will permanently rotate the original version of 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">1140</context>
+          <context context-type="linenumber">1149</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="4233432423256408453" datatype="html">
-        <source>This will alter the original copy.</source>
+      <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">1141</context>
+          <context context-type="linenumber">1165</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-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">786</context>
+          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
+          <context context-type="linenumber">1177</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>
+      <trans-unit id="3539261415918606512" datatype="html">
+        <source>Delete pages confirm</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1157</context>
+          <context context-type="linenumber">1189</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="2962674215361798818" datatype="html">
-        <source>Error executing rotate operation</source>
+      <trans-unit id="5854352498125813866" datatype="html">
+        <source>This operation will permanently delete the selected pages from the original document.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
+          <context context-type="linenumber">1190</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="8617528702531167646" datatype="html">
+        <source>Delete pages operation will begin in the background. Close and re-open or reload this 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">1169</context>
+          <context context-type="linenumber">1205</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="1249139200486584973" datatype="html">
+        <source>Error executing delete pages operation</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
+          <context context-type="linenumber">1214</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4958946940233632319" datatype="html">
           <context context-type="linenumber">785</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-list/bulk-editor/bulk-editor.component.ts</context>
+          <context context-type="linenumber">786</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="7910756456450124185" datatype="html">
         <source>Merge confirm</source>
         <context-group purpose="location">
index bb5a62249cc3627fc5f7e16ffcb47cfa7cb85c80..f9e04b069f89851a3a8b3388e9ba892940b5b61f 100644 (file)
@@ -124,6 +124,7 @@ import { DragDropSelectComponent } from './components/common/input/drag-drop-sel
 import { CustomFieldDisplayComponent } from './components/common/custom-field-display/custom-field-display.component'
 import { GlobalSearchComponent } from './components/app-frame/global-search/global-search.component'
 import { HotkeyDialogComponent } from './components/common/hotkey-dialog/hotkey-dialog.component'
+import { DeletePagesConfirmDialogComponent } from './components/common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component'
 import {
   airplane,
   archive,
@@ -160,6 +161,7 @@ import {
   clipboardCheckFill,
   clipboardFill,
   dash,
+  dashCircle,
   diagram3,
   dice5,
   doorOpen,
@@ -174,6 +176,7 @@ import {
   fileEarmarkCheck,
   fileEarmarkFill,
   fileEarmarkLock,
+  fileEarmarkMinus,
   files,
   fileText,
   filter,
@@ -259,6 +262,7 @@ const icons = {
   clipboardCheckFill,
   clipboardFill,
   dash,
+  dashCircle,
   diagram3,
   dice5,
   doorOpen,
@@ -273,6 +277,7 @@ const icons = {
   fileEarmarkCheck,
   fileEarmarkFill,
   fileEarmarkLock,
+  fileEarmarkMinus,
   files,
   fileText,
   filter,
@@ -491,6 +496,7 @@ function initializeApp(settings: SettingsService) {
     CustomFieldDisplayComponent,
     GlobalSearchComponent,
     HotkeyDialogComponent,
+    DeletePagesConfirmDialogComponent,
   ],
   imports: [
     BrowserModule,
diff --git a/src-ui/src/app/components/common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component.html b/src-ui/src/app/components/common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component.html
new file mode 100644 (file)
index 0000000..01bf5d3
--- /dev/null
@@ -0,0 +1,54 @@
+<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">
+            <div class="btn-toolbar flex-nowrap">
+                <div class="input-group input-group-sm">
+                    <div class="input-group-text" i18n>Page</div>
+                    <input class="form-control mw-60" type="number" min="1" [(ngModel)]="currentPage" />
+                    <div class="input-group-text" i18n>of {{totalPages}}</div>
+                </div>
+                <div class="input-group input-group-sm ms-auto">
+                    <span class="input-group-text" i18n>Pages to remove</span>
+                    <input [ngModel]="pagesString" class="form-control" disabled />
+                </div>
+            </div>
+            <div class="pdf-viewer-container w-100 mt-3">
+                <pdf-viewer #pdfViewer [src]="pdfSrc" [(page)]="currentPage"
+                [original-size]="false"
+                [zoom]="1"
+                zoom-scale="page-fit"
+                [render-text]="false"
+                (pagerendered)="pageRendered($event)"
+                (after-load-complete)="pdfPreviewLoaded($event)">
+                </pdf-viewer>
+            </div>
+        </div>
+    </div>
+</div>
+<div class="modal-footer flex-nowrap">
+    <div>
+        @if (message) {
+            <p [innerHTML]="message | safeHtml"></p>
+        }
+        @if (messageBold) {
+            <p class="mb-0 small"><b [innerHTML]="messageBold | safeHtml"></b></p>
+        }
+    </div>
+    <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>
+
+<ng-template #pageCheckOverlay let-page="page" let-pages="pages">
+    <div class="position-absolute top-0 start-0 w-100 h-100 p-2" (click)="pageCheckChanged(page)">
+        <input type="checkbox" class="form-check-input" />
+    </div>
+</ng-template>
diff --git a/src-ui/src/app/components/common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component.scss b/src-ui/src/app/components/common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component.scss
new file mode 100644 (file)
index 0000000..f74de97
--- /dev/null
@@ -0,0 +1,28 @@
+.pdf-viewer-container {
+  background-color: gray;
+  height: 350px;
+
+  pdf-viewer {
+    width: 100%;
+    height: 100%;
+  }
+}
+
+.mw-60 {
+  max-width: 60px;
+}
+
+div.position-absolute:has(.form-check-input:checked) {
+  background-color: rgba(var(--bs-dark-rgb), 0.4);
+}
+
+.form-check-input {
+  &:checked {
+    background-color: var(--bs-danger);
+    border-color: var(--bs-danger);
+  }
+  &:focus {
+    box-shadow: 0 0 0 0.25rem rgba(var(--bs-danger-rgb), var(--pngx-focus-alpha));
+    border-color: var(--bs-danger);
+  }
+}
diff --git a/src-ui/src/app/components/common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component.spec.ts b/src-ui/src/app/components/common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component.spec.ts
new file mode 100644 (file)
index 0000000..78fab8c
--- /dev/null
@@ -0,0 +1,55 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { DeletePagesConfirmDialogComponent } from './delete-pages-confirm-dialog.component'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
+import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
+import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { PdfViewerComponent } from 'ng2-pdf-viewer'
+
+describe('DeletePagesConfirmDialogComponent', () => {
+  let component: DeletePagesConfirmDialogComponent
+  let fixture: ComponentFixture<DeletePagesConfirmDialogComponent>
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [DeletePagesConfirmDialogComponent, PdfViewerComponent],
+      providers: [NgbActiveModal, SafeHtmlPipe],
+      imports: [
+        HttpClientTestingModule,
+        NgxBootstrapIconsModule.pick(allIcons),
+        FormsModule,
+        ReactiveFormsModule,
+      ],
+    }).compileComponents()
+    fixture = TestBed.createComponent(DeletePagesConfirmDialogComponent)
+    component = fixture.componentInstance
+    fixture.detectChanges()
+  })
+
+  it('should return a string with comma-separated pages', () => {
+    component.pages = [1, 2, 3, 4]
+    expect(component.pagesString).toEqual('1, 2, 3, 4')
+  })
+
+  it('should update totalPages when pdf is loaded', () => {
+    component.pdfPreviewLoaded({ numPages: 5 } as any)
+    expect(component.totalPages).toEqual(5)
+  })
+
+  it('should update checks when page is rendered', () => {
+    const event = {
+      target: document.createElement('div'),
+      detail: { pageNumber: 1 },
+    } as any
+    component.pageRendered(event)
+    expect(component['checks'].length).toEqual(1)
+  })
+
+  it('should update pages when page check is changed', () => {
+    component.pageCheckChanged(1)
+    expect(component.pages).toEqual([1])
+    component.pageCheckChanged(1)
+    expect(component.pages).toEqual([])
+  })
+})
diff --git a/src-ui/src/app/components/common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component.ts b/src-ui/src/app/components/common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component.ts
new file mode 100644 (file)
index 0000000..c47dea0
--- /dev/null
@@ -0,0 +1,64 @@
+import { Component, TemplateRef, ViewChild } from '@angular/core'
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
+import { DocumentService } from 'src/app/services/rest/document.service'
+import { ConfirmDialogComponent } from '../confirm-dialog.component'
+import { PDFDocumentProxy, PdfViewerComponent } from 'ng2-pdf-viewer'
+
+@Component({
+  selector: 'pngx-delete-pages-confirm-dialog',
+  templateUrl: './delete-pages-confirm-dialog.component.html',
+  styleUrl: './delete-pages-confirm-dialog.component.scss',
+})
+export class DeletePagesConfirmDialogComponent extends ConfirmDialogComponent {
+  public documentID: number
+  public pages: number[] = []
+  public currentPage: number = 1
+  public totalPages: number
+
+  @ViewChild('pdfViewer') pdfViewer: PdfViewerComponent
+  @ViewChild('pageCheckOverlay') pageCheckOverlay!: TemplateRef<any>
+  private checks: HTMLElement[] = []
+
+  public get pagesString(): string {
+    return this.pages.join(', ')
+  }
+
+  public get pdfSrc(): string {
+    return this.documentService.getPreviewUrl(this.documentID)
+  }
+
+  constructor(
+    activeModal: NgbActiveModal,
+    private documentService: DocumentService
+  ) {
+    super(activeModal)
+  }
+
+  public pdfPreviewLoaded(pdf: PDFDocumentProxy) {
+    this.totalPages = pdf.numPages
+  }
+
+  pageRendered(event: CustomEvent) {
+    const pageDiv = event.target as HTMLDivElement
+    const check = this.pageCheckOverlay.createEmbeddedView({
+      page: event.detail.pageNumber,
+    })
+    this.checks[event.detail.pageNumber - 1] = check.rootNodes[0]
+    pageDiv?.insertBefore(check.rootNodes[0], pageDiv.firstChild)
+    this.updateChecks()
+  }
+
+  pageCheckChanged(pageNumber: number) {
+    if (!this.pages.includes(pageNumber)) this.pages.push(pageNumber)
+    else if (this.pages.includes(pageNumber))
+      this.pages.splice(this.pages.indexOf(pageNumber), 1)
+    this.updateChecks()
+  }
+
+  private updateChecks() {
+    this.checks.forEach((check, i) => {
+      const input = check.getElementsByTagName('input')[0]
+      input.checked = this.pages.includes(i + 1)
+    })
+  }
+}
index 00e8996d02cd037f2110279cecffaa7935d73f92..e996ecb44b74b1cf255a4a8f1f0904e18d360b73 100644 (file)
             </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">
+<div class="modal-footer flex-nowrap">
+    <div class="col">
+        @if (message) {
+            <p [innerHTML]="message | safeHtml"></p>
+        }
+        @if (messageBold) {
+          <p class="mb-0 small"><b [innerHTML]="messageBold | safeHtml"></b></p>
+        }
+    </div>
     <button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
         <span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
     </button>
index b226e97a27536287823a17b8084f28ad2b0b6a4c..86e6398ec6454699464291425a5c59f5aa93970d 100644 (file)
@@ -1,5 +1,5 @@
 <pngx-page-header [(title)]="title">
-  @if (contentRenderType === ContentRenderType.PDF && !useNativePdfViewer) {
+  @if (archiveContentRenderType === ContentRenderType.PDF && !useNativePdfViewer) {
     @if (previewNumPages) {
       <div class="input-group input-group-sm d-none d-md-flex">
         <div class="input-group-text" i18n>Page</div>
         <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 === 1">
+      <button ngbDropdownItem (click)="splitDocument()" [disabled]="archiveContentRenderType !== ContentRenderType.PDF || previewNumPages === 1">
         <i-bs width="1em" height="1em" name="scissors"></i-bs>&nbsp;<span i18n>Split</span>
       </button>
 
-      <button ngbDropdownItem (click)="rotateDocument()" [disabled]="!userIsOwner || metadata?.original_mime_type !== 'application/pdf'">
+      <button ngbDropdownItem (click)="rotateDocument()" [disabled]="!userIsOwner || originalContentRenderType !== ContentRenderType.PDF">
         <i-bs name="arrow-clockwise"></i-bs>&nbsp;<ng-container i18n>Rotate</ng-container>
       </button>
+
+      <button ngbDropdownItem (click)="deletePages()" [disabled]="!userIsOwner || originalContentRenderType !== ContentRenderType.PDF || previewNumPages === 1">
+        <i-bs name="file-earmark-minus"></i-bs>&nbsp;<ng-container i18n>Delete page(s)</ng-container>
+      </button>
     </div>
   </div>
 
       </div>
     </div>
   } @else {
-    @switch (contentRenderType) {
+    @switch (archiveContentRenderType) {
       @case (ContentRenderType.PDF) {
         @if (!useNativePdfViewer) {
           <div class="preview-sticky pdf-viewer-container">
index d27c13ef18f98bc91f44155cc6f2e3a8e8cf90cf..b8a6389f2611c9d7003f1e1c700896e8e7a232b6 100644 (file)
@@ -81,6 +81,7 @@ 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'
 import { PdfViewerModule } from 'ng2-pdf-viewer'
+import { DeletePagesConfirmDialogComponent } from '../common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component'
 
 const doc: Document = {
   id: 3,
@@ -178,6 +179,7 @@ describe('DocumentDetailComponent', () => {
         CustomFieldsDropdownComponent,
         SplitConfirmDialogComponent,
         RotateConfirmDialogComponent,
+        DeletePagesConfirmDialogComponent,
       ],
       providers: [
         DocumentTitlePipe,
@@ -1035,7 +1037,9 @@ describe('DocumentDetailComponent', () => {
     component.metadata = { has_archive_version: true }
     initNormally()
     fixture.detectChanges()
-    expect(component.contentRenderType).toEqual(component.ContentRenderType.PDF)
+    expect(component.archiveContentRenderType).toEqual(
+      component.ContentRenderType.PDF
+    )
     expect(
       fixture.debugElement.query(By.css('pdf-viewer-container'))
     ).not.toBeUndefined()
@@ -1045,7 +1049,7 @@ describe('DocumentDetailComponent', () => {
       original_mime_type: 'text/plain',
     }
     fixture.detectChanges()
-    expect(component.contentRenderType).toEqual(
+    expect(component.archiveContentRenderType).toEqual(
       component.ContentRenderType.Text
     )
     expect(
@@ -1057,7 +1061,7 @@ describe('DocumentDetailComponent', () => {
       original_mime_type: 'image/jpg',
     }
     fixture.detectChanges()
-    expect(component.contentRenderType).toEqual(
+    expect(component.archiveContentRenderType).toEqual(
       component.ContentRenderType.Image
     )
     expect(
@@ -1070,7 +1074,7 @@ describe('DocumentDetailComponent', () => {
         'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
     }
     fixture.detectChanges()
-    expect(component.contentRenderType).toEqual(
+    expect(component.archiveContentRenderType).toEqual(
       component.ContentRenderType.Other
     )
     expect(
@@ -1130,6 +1134,31 @@ describe('DocumentDetailComponent', () => {
     req.flush(true)
   })
 
+  it('should support delete pages', () => {
+    let modal: NgbModalRef
+    modalService.activeInstances.subscribe((m) => (modal = m[0]))
+    initNormally()
+    component.deletePages()
+    expect(modal).not.toBeUndefined()
+    modal.componentInstance.documentID = doc.id
+    modal.componentInstance.pages = [1, 2]
+    modal.componentInstance.confirm()
+    let req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/bulk_edit/`
+    )
+    expect(req.request.body).toEqual({
+      documents: [doc.id],
+      method: 'delete_pages',
+      parameters: { pages: [1, 2] },
+    })
+    req.error(new ProgressEvent('failed'))
+    modal.componentInstance.confirm()
+    req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}documents/bulk_edit/`
+    )
+    req.flush(true)
+  })
+
   it('should support keyboard shortcuts', () => {
     initNormally()
 
index 820d7fbd588952abf18f27b8cc16e937e989b4a2..23753f55bcf02c14840563902878469764cf98df 100644 (file)
@@ -68,6 +68,7 @@ import { CustomFieldInstance } from 'src/app/data/custom-field-instance'
 import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
 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'
+import { DeletePagesConfirmDialogComponent } from '../common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component'
 import { HotKeyService } from 'src/app/services/hot-key.service'
 import { PDFDocumentProxy } from 'ng2-pdf-viewer'
 
@@ -216,19 +217,27 @@ export class DocumentDetailComponent
     return this.settings.get(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER)
   }
 
-  get contentRenderType(): ContentRenderType {
-    if (!this.metadata) return ContentRenderType.Unknown
-    const contentType = this.metadata?.has_archive_version
-      ? 'application/pdf'
-      : this.metadata?.original_mime_type
+  get archiveContentRenderType(): ContentRenderType {
+    return this.getRenderType(
+      this.metadata?.has_archive_version
+        ? 'application/pdf'
+        : this.metadata?.original_mime_type
+    )
+  }
 
-    if (contentType === 'application/pdf') {
+  get originalContentRenderType(): ContentRenderType {
+    return this.getRenderType(this.metadata?.original_mime_type)
+  }
+
+  private getRenderType(mimeType: string): ContentRenderType {
+    if (!mimeType) return ContentRenderType.Unknown
+    if (mimeType === 'application/pdf') {
       return ContentRenderType.PDF
     } else if (
-      ['text/plain', 'application/csv', 'text/csv'].includes(contentType)
+      ['text/plain', 'application/csv', 'text/csv'].includes(mimeType)
     ) {
       return ContentRenderType.Text
-    } else if (contentType?.indexOf('image/') === 0) {
+    } else if (mimeType?.indexOf('image/') === 0) {
       return ContentRenderType.Image
     }
     return ContentRenderType.Other
@@ -1138,7 +1147,6 @@ export class DocumentDetailComponent
     })
     modal.componentInstance.title = $localize`Rotate confirm`
     modal.componentInstance.messageBold = $localize`This operation will permanently rotate the original version of 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
@@ -1173,4 +1181,41 @@ export class DocumentDetailComponent
           })
       })
   }
+
+  deletePages() {
+    let modal = this.modalService.open(DeletePagesConfirmDialogComponent, {
+      backdrop: 'static',
+    })
+    modal.componentInstance.title = $localize`Delete pages confirm`
+    modal.componentInstance.messageBold = $localize`This operation will permanently delete the selected pages from the original document.`
+    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], 'delete_pages', {
+            pages: modal.componentInstance.pages,
+          })
+          .pipe(first(), takeUntil(this.unsubscribeNotifier))
+          .subscribe({
+            next: () => {
+              this.toastService.showInfo(
+                $localize`Delete pages operation will begin in the background. Close and re-open or reload this document after the operation has completed to see the changes.`
+              )
+              modal.close()
+            },
+            error: (error) => {
+              if (modal) {
+                modal.componentInstance.buttonsEnabled = true
+              }
+              this.toastService.showError(
+                $localize`Error executing delete pages operation`,
+                error
+              )
+            },
+          })
+      })
+  }
 }
index f59ef1af3dfdc6655b1b43d2450c61233e25c8c4..8dbdbc2dd5c20045e56a7df2c3b5d8d2e8d0576d 100644 (file)
@@ -325,3 +325,29 @@ def split(doc_ids: list[int], pages: list[list[int]]):
         logger.exception(f"Error splitting document {doc.id}: {e}")
 
     return "OK"
+
+
+def delete_pages(doc_ids: list[int], pages: list[int]):
+    logger.info(
+        f"Attempting to delete pages {pages} from {len(doc_ids)} documents",
+    )
+    doc = Document.objects.get(id=doc_ids[0])
+    pages = sorted(pages)  # sort pages to avoid index issues
+    import pikepdf
+
+    try:
+        with pikepdf.open(doc.source_path, allow_overwriting_input=True) as pdf:
+            offset = 1  # pages are 1-indexed
+            for page_num in pages:
+                pdf.pages.remove(pdf.pages[page_num - offset])
+                offset += 1  # remove() changes the index of the pages
+            pdf.remove_unreferenced_resources()
+            pdf.save()
+            doc.checksum = hashlib.md5(doc.source_path.read_bytes()).hexdigest()
+            doc.save()
+            update_document_archive_file.delay(document_id=doc.id)
+            logger.info(f"Deleted pages {pages} from document {doc.id}")
+    except Exception as e:
+        logger.exception(f"Error deleting pages from document {doc.id}: {e}")
+
+    return "OK"
index 9d722ca5d4e8684ff31a223863045e4442474891..c92765e69bec0aa46efbe295f4d2e96e891f5858 100644 (file)
@@ -944,6 +944,7 @@ class BulkEditSerializer(
             "rotate",
             "merge",
             "split",
+            "delete_pages",
         ],
         label="Method",
         write_only=True,
@@ -1000,6 +1001,8 @@ class BulkEditSerializer(
             return bulk_edit.merge
         elif method == "split":
             return bulk_edit.split
+        elif method == "delete_pages":
+            return bulk_edit.delete_pages
         else:
             raise serializers.ValidationError("Unsupported method.")
 
@@ -1128,6 +1131,14 @@ class BulkEditSerializer(
         except ValueError:
             raise serializers.ValidationError("invalid pages specified")
 
+    def _validate_parameters_delete_pages(self, parameters):
+        if "pages" not in parameters:
+            raise serializers.ValidationError("pages not specified")
+        if not isinstance(parameters["pages"], list):
+            raise serializers.ValidationError("pages must be a list")
+        if not all(isinstance(i, int) for i in parameters["pages"]):
+            raise serializers.ValidationError("pages must be a list of integers")
+
     def validate(self, attrs):
         method = attrs["method"]
         parameters = attrs["parameters"]
@@ -1154,6 +1165,12 @@ class BulkEditSerializer(
                     "Split method only supports one document",
                 )
             self._validate_parameters_split(parameters)
+        elif method == bulk_edit.delete_pages:
+            if len(attrs["documents"]) > 1:
+                raise serializers.ValidationError(
+                    "Delete pages method only supports one document",
+                )
+            self._validate_parameters_delete_pages(parameters)
 
         return attrs
 
index c38ed8cfdcae4c1507ed4be683010e7b56564c81..7078aca12dc0a22336d49130720dc85a749dd857 100644 (file)
@@ -1065,3 +1065,95 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
 
         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
         self.assertIn(b"Split method only supports one document", response.content)
+
+    @mock.patch("documents.serialisers.bulk_edit.delete_pages")
+    def test_delete_pages(self, m):
+        m.return_value = "OK"
+
+        response = self.client.post(
+            "/api/documents/bulk_edit/",
+            json.dumps(
+                {
+                    "documents": [self.doc2.id],
+                    "method": "delete_pages",
+                    "parameters": {"pages": [1, 2, 3, 4]},
+                },
+            ),
+            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])
+
+    def test_delete_pages_invalid_params(self):
+        response = self.client.post(
+            "/api/documents/bulk_edit/",
+            json.dumps(
+                {
+                    "documents": [
+                        self.doc1.id,
+                        self.doc2.id,
+                    ],  # only one document supported
+                    "method": "delete_pages",
+                    "parameters": {
+                        "pages": [1, 2, 3, 4],
+                    },
+                },
+            ),
+            content_type="application/json",
+        )
+
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+        self.assertIn(
+            b"Delete pages method only supports one document",
+            response.content,
+        )
+
+        response = self.client.post(
+            "/api/documents/bulk_edit/",
+            json.dumps(
+                {
+                    "documents": [self.doc2.id],
+                    "method": "delete_pages",
+                    "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": "delete_pages",
+                    "parameters": {"pages": "1-3"},  # not a list
+                },
+            ),
+            content_type="application/json",
+        )
+
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+        self.assertIn(b"pages must be a list", response.content)
+
+        response = self.client.post(
+            "/api/documents/bulk_edit/",
+            json.dumps(
+                {
+                    "documents": [self.doc2.id],
+                    "method": "delete_pages",
+                    "parameters": {"pages": ["1-3"]},  # not ints
+                },
+            ),
+            content_type="application/json",
+        )
+
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+        self.assertIn(b"pages must be a list of integers", response.content)
index 831fa94612916e9ce253ca57949409e9a2e3b75f..16579c8875efb5c3bd0804b6d74253d6d66e5245 100644 (file)
@@ -585,3 +585,46 @@ class TestPDFActions(DirectoriesMixin, TestCase):
             mock_update_documents.assert_called_once()
             mock_chord.assert_called_once()
             self.assertEqual(result, "OK")
+
+    @mock.patch("documents.tasks.update_document_archive_file.delay")
+    @mock.patch("pikepdf.Pdf.save")
+    def test_delete_pages(self, mock_pdf_save, mock_update_archive_file):
+        """
+        GIVEN:
+            - Existing documents
+        WHEN:
+            - Delete pages action is called with 1 document and 2 pages
+        THEN:
+            - Save should be called once
+            - Archive file should be updated once
+        """
+        doc_ids = [self.doc2.id]
+        pages = [1, 3]
+        result = bulk_edit.delete_pages(doc_ids, pages)
+        mock_pdf_save.assert_called_once()
+        mock_update_archive_file.assert_called_once()
+        self.assertEqual(result, "OK")
+
+    @mock.patch("documents.tasks.update_document_archive_file.delay")
+    @mock.patch("pikepdf.Pdf.save")
+    def test_delete_pages_with_error(self, mock_pdf_save, mock_update_archive_file):
+        """
+        GIVEN:
+            - Existing documents
+        WHEN:
+            - Delete pages action is called with 1 document and 2 pages
+            - PikePDF raises an error
+        THEN:
+            - Save should be called once
+            - Archive file should not be updated
+        """
+        mock_pdf_save.side_effect = Exception("Error saving PDF")
+        doc_ids = [self.doc2.id]
+        pages = [1, 3]
+
+        with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
+            bulk_edit.delete_pages(doc_ids, pages)
+            error_str = cm.output[0]
+            expected_str = "Error deleting pages from document"
+            self.assertIn(expected_str, error_str)
+            mock_update_archive_file.assert_not_called()