- `"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
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
</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">
</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">
</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>
</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>
</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>
</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>
</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>
</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">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">621</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>
</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">
<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">
</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>
</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>
</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>
</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>
<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">
</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">
<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>
<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>
<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>
<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>
<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>
<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">
<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>
<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>
<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">
)"/> 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">
)"/> 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">
)"/> 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">
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,
arrowRightShort,
arrowUpRight,
asterisk,
+ bodyText,
boxArrowUp,
boxArrowUpRight,
boxes,
hddStack,
house,
infoCircle,
+ journals,
link,
listTask,
listUl,
plus,
plusCircle,
questionCircle,
+ scissors,
search,
slashCircle,
sliders2Vertical,
const icons = {
airplane,
archive,
+ arrowClockwise,
arrowCounterclockwise,
arrowDown,
arrowLeft,
arrowRightShort,
arrowUpRight,
asterisk,
+ bodyText,
boxArrowUp,
boxArrowUpRight,
boxes,
hddStack,
house,
infoCircle,
+ journals,
link,
listTask,
listUl,
plus,
plusCircle,
questionCircle,
+ scissors,
search,
slashCircle,
sliders2Vertical,
ConfirmButtonComponent,
MonetaryComponent,
SystemStatusDialogComponent,
+ RotateConfirmDialogComponent,
+ MergeConfirmDialogComponent,
+ SplitConfirmDialogComponent,
],
imports: [
BrowserModule,
--- /dev/null
+<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>
--- /dev/null
+.list-group-item {
+ cursor: move;
+}
--- /dev/null
+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' })
+ })
+})
--- /dev/null
+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)
+ }
+}
--- /dev/null
+<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>
--- /dev/null
+img {
+ transition: all 0.25s ease;
+}
--- /dev/null
+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)
+ })
+})
--- /dev/null
+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
+ }
+}
--- /dev/null
+<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>
--- /dev/null
+.pdf-viewer-container {
+ background-color: gray;
+ height: 300px;
+
+ pngx-pdf-viewer {
+ width: 100%;
+ height: 100%;
+ }
+ }
--- /dev/null
+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)
+ })
+})
--- /dev/null
+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
+ }
+}
</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>
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,
ShareLinksDropdownComponent,
CustomFieldsDropdownComponent,
PdfViewerComponent,
+ SplitConfirmDialogComponent,
+ RotateConfirmDialogComponent,
],
providers: [
DocumentTitlePipe,
).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')
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,
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
+ )
+ },
+ })
+ })
+ }
}
<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()">
<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>
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: [
PermissionsGroupComponent,
PermissionsUserComponent,
SwitchComponent,
+ RotateConfirmDialogComponent,
+ IsNumberPipe,
+ MergeConfirmDialogComponent,
],
providers: [
PermissionsService,
) // 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
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,
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',
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: () => {
}
)
}
+
+ 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.`
+ )
+ })
+ }
}
+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:
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"
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),
+ ],
+ )
from typing import Optional
import magic
+from guardian.shortcuts import get_groups_with_perms
+from guardian.shortcuts import get_users_with_perms
@dataclasses.dataclass
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):
"""
"delete",
"redo_ocr",
"set_permissions",
+ "rotate",
+ "merge",
+ "split",
],
label="Method",
write_only=True,
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.")
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"]
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
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
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}")
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
ix = index.open_index()
for doc in documents:
+ clear_document_caches(doc.pk)
document_updated.send(
sender=None,
document=doc,
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})",
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)
+import shutil
+from pathlib import Path
from unittest import mock
from django.contrib.auth.models import Group
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")
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