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="<span class="badge text-bg-secondary ms-1">"/><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 & 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 & 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 "<x id="PH" equiv-text="this.document.title"/>"?</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>"<x id="PH" equiv-text="items[0].name"/>"</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>"<x id="PH" equiv-text="items[0].name"/>" and "<x id="PH_1" equiv-text="items[1].name"/>"</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 'modify "tag1" and "tag2"'</note> </trans-unit> @@ -5497,7 +5663,7 @@ <source><x id="PH" equiv-text="list"/> and "<x id="PH_1" equiv-text="items[items.length - 1].name"/>"</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 'modify "tag1", "tag2" and "tag3"'</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 "<x id="PH" equiv-text="tag.name"/>" 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 "<x id="PH" equiv-text="tag.name"/>" 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 "<x id="PH" equiv-text="correspondent.name"/>" 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 "<x id="PH" equiv-text="documentType.name"/>" 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 "<x id="PH" equiv-text="storagePath.name"/>" 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> + <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) { + + <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> <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> <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> <span i18n>Split</span> + </button> + + <button ngbDropdownItem (click)="rotateDocument()" [disabled]="!userIsOwner || contentRenderType !== ContentRenderType.PDF"> + <i-bs name="arrow-clockwise"></i-bs> <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"> <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"> <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"> <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> <ng-container i18n>Redo OCR</ng-container> + </button> + <button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll"> + <i-bs name="arrow-clockwise"></i-bs> <ng-container i18n>Rotate</ng-container> + </button> + <button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanEditAll || list.selected.size < 2"> + <i-bs name="journals"></i-bs> <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