Merge branch 'dev' into feature-remote-ocr-2

This commit is contained in:
shamoon
2025-08-11 10:48:36 -07:00
committed by GitHub
29 changed files with 1548 additions and 912 deletions

View File

@@ -282,6 +282,18 @@ The following methods are supported:
- `"merge": true or false` (defaults to false) - `"merge": true or false` (defaults to false)
- The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including - The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including
removing them) or be merged with existing permissions. removing them) or be merged with existing permissions.
- `edit_pdf`
- Requires `parameters`:
- `"doc_ids": [DOCUMENT_ID]` A list of a single document ID to edit.
- `"operations": [OPERATION, ...]` A list of operations to perform on the documents. Each operation is a dictionary
with the following keys:
- `"page": PAGE_NUMBER` The page number to edit (1-based).
- `"rotate": DEGREES` Optional rotation in degrees (90, 180, 270).
- `"doc": OUTPUT_DOCUMENT_INDEX` Optional index of the output document for split operations.
- Optional `parameters`:
- `"delete_original": true` to delete the original documents after editing.
- `"update_document": true` to update the existing document with the edited PDF.
- `"include_metadata": true` to copy metadata from the original document to the edited document.
- `merge` - `merge`
- No additional `parameters` required. - No additional `parameters` required.
- The ordering of the merged document is determined by the list of IDs. - The ordering of the merged document is determined by the list of IDs.

View File

@@ -1282,6 +1282,30 @@ within your documents.
Defaults to false. Defaults to false.
## Workflow webhooks
#### [`PAPERLESS_WEBHOOKS_ALLOWED_SCHEMES=<str>`](#PAPERLESS_WEBHOOKS_ALLOWED_SCHEMES) {#PAPERLESS_WEBHOOKS_ALLOWED_SCHEMES}
: A comma-separated list of allowed schemes for webhooks. This setting
controls which URL schemes are permitted for webhook URLs.
Defaults to `http,https`.
#### [`PAPERLESS_WEBHOOKS_ALLOWED_PORTS=<str>`](#PAPERLESS_WEBHOOKS_ALLOWED_PORTS) {#PAPERLESS_WEBHOOKS_ALLOWED_PORTS}
: A comma-separated list of allowed ports for webhooks. This setting
controls which ports are permitted for webhook URLs. For example, if you
set this to `80,443`, webhooks will only be sent to URLs that use these
ports.
Defaults to empty list, which allows all ports.
#### [`PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS=<bool>`](#PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS) {#PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS}
: If set to false, webhooks cannot be sent to internal URLs (e.g., localhost).
Defaults to true, which allows internal requests.
### Polling {#polling} ### Polling {#polling}
#### [`PAPERLESS_CONSUMER_POLLING=<num>`](#PAPERLESS_CONSUMER_POLLING) {#PAPERLESS_CONSUMER_POLLING} #### [`PAPERLESS_CONSUMER_POLLING=<num>`](#PAPERLESS_CONSUMER_POLLING) {#PAPERLESS_CONSUMER_POLLING}

View File

@@ -499,6 +499,10 @@ The following workflow action types are available:
- Encoding for the request body, either JSON or form data - Encoding for the request body, either JSON or form data
- The request headers as key-value pairs - The request headers as key-value pairs
For security reasons, webhooks can be limited to specific ports and disallowed from connecting to local URLs. See the relevant
[configuration settings](configuration.md#workflow-webhooks) to change this behavior. If you are allowing non-admins to create workflows,
you may want to adjust these settings to prevent abuse.
#### Workflow placeholders #### Workflow placeholders
Some workflow text can include placeholders but the available options differ depending on the type of Some workflow text can include placeholders but the available options differ depending on the type of
@@ -576,12 +580,14 @@ The following custom field types are supported:
## PDF Actions ## PDF Actions
Paperless-ngx supports four basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files): Paperless-ngx supports basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files). When viewing an individual document you can
open the 'PDF Editor' to use a simple UI for re-arranging, rotating, deleting pages and splitting documents.
- Merging documents: available when selecting multiple documents for 'bulk editing'. - Merging documents: available when selecting multiple documents for 'bulk editing'.
- Rotating documents: available when selecting multiple documents for 'bulk editing' and from an individual document's details page. - Rotating documents: available when selecting multiple documents for 'bulk editing' and via the pdf editor on an individual document's details page.
- Splitting documents: available from an individual document's details page. - Splitting documents: via the pdf editor on an individual document's details page.
- Deleting pages: available from an individual document's details page. - Deleting pages: via the pdf editor on an individual document's details page.
- Re-arranging pages: via the pdf editor on an individual document's details page.
!!! important !!! important

View File

@@ -385,7 +385,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">117</context> <context context-type="linenumber">109</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1241348629231510663" datatype="html"> <trans-unit id="1241348629231510663" datatype="html">
@@ -534,7 +534,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">370</context> <context context-type="linenumber">362</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3768927257183755959" datatype="html"> <trans-unit id="3768927257183755959" datatype="html">
@@ -593,7 +593,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">363</context> <context context-type="linenumber">355</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.html</context>
@@ -739,7 +739,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">383</context> <context context-type="linenumber">375</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
@@ -1197,7 +1197,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">339</context> <context context-type="linenumber">331</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
@@ -2544,19 +2544,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">997</context> <context context-type="linenumber">998</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1359</context> <context context-type="linenumber">1360</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">1398</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">1439</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -3164,7 +3156,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">950</context> <context context-type="linenumber">951</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -3191,47 +3183,6 @@
<context context-type="linenumber">747</context> <context context-type="linenumber">747</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1407560924967345762" datatype="html">
<source>Page</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component.html</context>
<context context-type="linenumber">11</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.html</context>
<context context-type="linenumber">11</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">5</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
<context context-type="linenumber">11</context>
</context-group>
</trans-unit>
<trans-unit id="2266163016683537825" datatype="html">
<source>of <x id="INTERPOLATION" equiv-text="{{totalPages}}"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component.html</context>
<context context-type="linenumber">13</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.html</context>
<context context-type="linenumber">13</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">7,8</context>
</context-group>
</trans-unit>
<trans-unit id="6903610408081711391" datatype="html">
<source>Pages to remove</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component.html</context>
<context context-type="linenumber">16</context>
</context-group>
</trans-unit>
<trans-unit id="994016933065248559" datatype="html"> <trans-unit id="994016933065248559" datatype="html">
<source>Documents:</source> <source>Documents:</source>
<context-group purpose="location"> <context-group purpose="location">
@@ -3281,20 +3232,6 @@
<context context-type="linenumber">25</context> <context context-type="linenumber">25</context>
</context-group> </context-group>
</trans-unit> </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="492847770415850840" datatype="html">
<source>Delete original document after successful 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">51</context>
</context-group>
</trans-unit>
<trans-unit id="2509141182388535183" datatype="html"> <trans-unit id="2509141182388535183" datatype="html">
<source>View</source> <source>View</source>
<context-group purpose="location"> <context-group purpose="location">
@@ -3409,11 +3346,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">111</context> <context context-type="linenumber">103</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">1416</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/guards/dirty-saved-view.guard.ts</context> <context context-type="sourcefile">src/app/guards/dirty-saved-view.guard.ts</context>
@@ -4354,7 +4287,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">305</context> <context context-type="linenumber">297</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8057014866157903311" datatype="html"> <trans-unit id="8057014866157903311" datatype="html">
@@ -4458,7 +4391,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">96</context> <context context-type="linenumber">88</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5342432350421167093" datatype="html"> <trans-unit id="5342432350421167093" datatype="html">
@@ -5528,6 +5461,104 @@
<context context-type="linenumber">9</context> <context context-type="linenumber">9</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5034217198277582100" datatype="html">
<source>Select all pages</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context>
<context context-type="linenumber">9</context>
</context-group>
</trans-unit>
<trans-unit id="234610397929376642" datatype="html">
<source>Deselect all pages</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context>
<context context-type="linenumber">12</context>
</context-group>
</trans-unit>
<trans-unit id="2530246103796817298" datatype="html">
<source>Rotate selected pages counter-clockwise</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context>
<context context-type="linenumber">17</context>
</context-group>
</trans-unit>
<trans-unit id="4787219034890830544" datatype="html">
<source>Rotate selected pages clockwise</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context>
<context context-type="linenumber">20</context>
</context-group>
</trans-unit>
<trans-unit id="3441043765105475130" datatype="html">
<source>Delete selected pages</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context>
<context context-type="linenumber">23</context>
</context-group>
</trans-unit>
<trans-unit id="3873740163706409154" datatype="html">
<source>Rotate page counter-clockwise</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context>
<context context-type="linenumber">33</context>
</context-group>
</trans-unit>
<trans-unit id="3450236521040548507" datatype="html">
<source>Rotate page clockwise</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context>
<context context-type="linenumber">36</context>
</context-group>
</trans-unit>
<trans-unit id="7647925464077975347" datatype="html">
<source>Delete page</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context>
<context context-type="linenumber">41</context>
</context-group>
</trans-unit>
<trans-unit id="2480952115552020422" datatype="html">
<source>Add / remove document split here</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context>
<context context-type="linenumber">44</context>
</context-group>
</trans-unit>
<trans-unit id="35277754987868961" datatype="html">
<source>Split here</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context>
<context context-type="linenumber">70</context>
</context-group>
</trans-unit>
<trans-unit id="7273640930165035289" datatype="html">
<source>Create new document(s)</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context>
<context context-type="linenumber">82</context>
</context-group>
</trans-unit>
<trans-unit id="8035757452478567832" datatype="html">
<source>Update existing document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context>
<context context-type="linenumber">87</context>
</context-group>
</trans-unit>
<trans-unit id="7248454234750442816" datatype="html">
<source>Copy metadata</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context>
<context context-type="linenumber">93</context>
</context-group>
</trans-unit>
<trans-unit id="6684403463658676119" datatype="html">
<source>Delete original</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context>
<context context-type="linenumber">97</context>
</context-group>
</trans-unit>
<trans-unit id="7940755769131903278" datatype="html"> <trans-unit id="7940755769131903278" datatype="html">
<source>Merge with existing permissions</source> <source>Merge with existing permissions</source>
<context-group purpose="location"> <context-group purpose="location">
@@ -5977,7 +6008,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">92</context> <context context-type="linenumber">84</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3429210839568770054" datatype="html"> <trans-unit id="3429210839568770054" datatype="html">
@@ -6479,6 +6510,24 @@
<context context-type="linenumber">1</context> <context context-type="linenumber">1</context>
</context-group> </context-group>
</trans-unit> </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">5</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
<context context-type="linenumber">11</context>
</context-group>
</trans-unit>
<trans-unit id="2266163016683537825" datatype="html">
<source>of <x id="INTERPOLATION" equiv-text="{{previewNumPages}}"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">7,8</context>
</context-group>
</trans-unit>
<trans-unit id="8590109102084543521" datatype="html"> <trans-unit id="8590109102084543521" datatype="html">
<source>-</source> <source>-</source>
<context-group purpose="location"> <context-group purpose="location">
@@ -6522,57 +6571,43 @@
<context context-type="linenumber">69</context> <context context-type="linenumber">69</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2434944824726929798" datatype="html"> <trans-unit id="5084275925647254161" datatype="html">
<source>Split</source> <source>PDF Editor</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">62</context> <context context-type="linenumber">62</context>
</context-group> </context-group>
</trans-unit>
<trans-unit id="1050269006235116171" datatype="html">
<source>Rotate</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">66</context> <context context-type="linenumber">1359</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">110</context>
</context-group>
</trans-unit>
<trans-unit id="4399672576012609374" datatype="html">
<source>Delete page(s)</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">70</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6490688569532630280" datatype="html"> <trans-unit id="6490688569532630280" datatype="html">
<source>Send</source> <source>Send</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">88</context> <context context-type="linenumber">80</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4452427314943113135" datatype="html"> <trans-unit id="4452427314943113135" datatype="html">
<source>Previous</source> <source>Previous</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">114</context> <context context-type="linenumber">106</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5028777105388019087" datatype="html"> <trans-unit id="5028777105388019087" datatype="html">
<source>Details</source> <source>Details</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">127</context> <context context-type="linenumber">119</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5701618810648052610" datatype="html"> <trans-unit id="5701618810648052610" datatype="html">
<source>Title</source> <source>Title</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">130</context> <context context-type="linenumber">122</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
@@ -6595,21 +6630,21 @@
<source>Archive serial number</source> <source>Archive serial number</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">131</context> <context context-type="linenumber">123</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5114742157723900905" datatype="html"> <trans-unit id="5114742157723900905" datatype="html">
<source>Date created</source> <source>Date created</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">132</context> <context context-type="linenumber">124</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2691296884221415710" datatype="html"> <trans-unit id="2691296884221415710" datatype="html">
<source>Correspondent</source> <source>Correspondent</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">134</context> <context context-type="linenumber">126</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
@@ -6636,7 +6671,7 @@
<source>Document type</source> <source>Document type</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">136</context> <context context-type="linenumber">128</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
@@ -6663,7 +6698,7 @@
<source>Storage path</source> <source>Storage path</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">138</context> <context context-type="linenumber">130</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
@@ -6686,7 +6721,7 @@
<source>Default</source> <source>Default</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">139</context> <context context-type="linenumber">131</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.html</context> <context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.html</context>
@@ -6697,14 +6732,14 @@
<source>Content</source> <source>Content</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">235</context> <context context-type="linenumber">227</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="218403386307979629" datatype="html"> <trans-unit id="218403386307979629" datatype="html">
<source>Metadata</source> <source>Metadata</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">244</context> <context context-type="linenumber">236</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/metadata-collapse/metadata-collapse.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/metadata-collapse/metadata-collapse.component.ts</context>
@@ -6715,175 +6750,175 @@
<source>Date modified</source> <source>Date modified</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">251</context> <context context-type="linenumber">243</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6392918669949841614" datatype="html"> <trans-unit id="6392918669949841614" datatype="html">
<source>Date added</source> <source>Date added</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">255</context> <context context-type="linenumber">247</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="146828917013192897" datatype="html"> <trans-unit id="146828917013192897" datatype="html">
<source>Media filename</source> <source>Media filename</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">259</context> <context context-type="linenumber">251</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4500855521601039868" datatype="html"> <trans-unit id="4500855521601039868" datatype="html">
<source>Original filename</source> <source>Original filename</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">263</context> <context context-type="linenumber">255</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7985558498848210210" datatype="html"> <trans-unit id="7985558498848210210" datatype="html">
<source>Original MD5 checksum</source> <source>Original MD5 checksum</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">267</context> <context context-type="linenumber">259</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5888243105821763422" datatype="html"> <trans-unit id="5888243105821763422" datatype="html">
<source>Original file size</source> <source>Original file size</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">271</context> <context context-type="linenumber">263</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2696647325713149563" datatype="html"> <trans-unit id="2696647325713149563" datatype="html">
<source>Original mime type</source> <source>Original mime type</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">275</context> <context context-type="linenumber">267</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="342875990758166588" datatype="html"> <trans-unit id="342875990758166588" datatype="html">
<source>Archive MD5 checksum</source> <source>Archive MD5 checksum</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">280</context> <context context-type="linenumber">272</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6033581412811562084" datatype="html"> <trans-unit id="6033581412811562084" datatype="html">
<source>Archive file size</source> <source>Archive file size</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">286</context> <context context-type="linenumber">278</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6992781481378431874" datatype="html"> <trans-unit id="6992781481378431874" datatype="html">
<source>Original document metadata</source> <source>Original document metadata</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">295</context> <context context-type="linenumber">287</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2846565152091361585" datatype="html"> <trans-unit id="2846565152091361585" datatype="html">
<source>Archived document metadata</source> <source>Archived document metadata</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">298</context> <context context-type="linenumber">290</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7206723502037428235" datatype="html"> <trans-unit id="7206723502037428235" datatype="html">
<source>Notes <x id="START_BLOCK_IF" equiv-text="@if (document?.notes.length) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;badge text-bg-secondary ms-1&quot;&gt;"/><x id="INTERPOLATION" equiv-text="ngth}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source> <source>Notes <x id="START_BLOCK_IF" equiv-text="@if (document?.notes.length) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;badge text-bg-secondary ms-1&quot;&gt;"/><x id="INTERPOLATION" equiv-text="ngth}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">317,320</context> <context context-type="linenumber">309,312</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="186236568870281953" datatype="html"> <trans-unit id="186236568870281953" datatype="html">
<source>History</source> <source>History</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">328</context> <context context-type="linenumber">320</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5129524307369213584" datatype="html"> <trans-unit id="5129524307369213584" datatype="html">
<source>Save &amp; next</source> <source>Save &amp; next</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">365</context> <context context-type="linenumber">357</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4910102545766233758" datatype="html"> <trans-unit id="4910102545766233758" datatype="html">
<source>Save &amp; close</source> <source>Save &amp; close</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">368</context> <context context-type="linenumber">360</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1309556917227148591" datatype="html"> <trans-unit id="1309556917227148591" datatype="html">
<source>Document loading...</source> <source>Document loading...</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">378</context> <context context-type="linenumber">370</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8191371354890763172" datatype="html"> <trans-unit id="8191371354890763172" datatype="html">
<source>Enter Password</source> <source>Enter Password</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">432</context> <context context-type="linenumber">424</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2218903673684131427" datatype="html"> <trans-unit id="2218903673684131427" datatype="html">
<source>An error occurred loading content: <x id="PH" equiv-text="err.message ?? err.toString()"/></source> <source>An error occurred loading content: <x id="PH" equiv-text="err.message ?? err.toString()"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">411,413</context> <context context-type="linenumber">412,414</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3200733026060976258" datatype="html"> <trans-unit id="3200733026060976258" datatype="html">
<source>Document changes detected</source> <source>Document changes detected</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">434</context> <context context-type="linenumber">435</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2887155916749964" datatype="html"> <trans-unit id="2887155916749964" datatype="html">
<source>The version of this document in your browser session appears older than the existing version.</source> <source>The version of this document in your browser session appears older than the existing version.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">435</context> <context context-type="linenumber">436</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="237142428785956348" datatype="html"> <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> <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-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">436</context> <context context-type="linenumber">437</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8720977247725652816" datatype="html"> <trans-unit id="8720977247725652816" datatype="html">
<source>Ok</source> <source>Ok</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">438</context> <context context-type="linenumber">439</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6142395741265832184" datatype="html"> <trans-unit id="6142395741265832184" datatype="html">
<source>Next document</source> <source>Next document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">554</context> <context context-type="linenumber">555</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="651985345816518480" datatype="html"> <trans-unit id="651985345816518480" datatype="html">
<source>Previous document</source> <source>Previous document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">564</context> <context context-type="linenumber">565</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2885986061416655600" datatype="html"> <trans-unit id="2885986061416655600" datatype="html">
<source>Close document</source> <source>Close document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">572</context> <context context-type="linenumber">573</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context> <context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
@@ -6894,67 +6929,67 @@
<source>Save document</source> <source>Save document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">579</context> <context context-type="linenumber">580</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1784543155727940353" datatype="html"> <trans-unit id="1784543155727940353" datatype="html">
<source>Save and close / next</source> <source>Save and close / next</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">588</context> <context context-type="linenumber">589</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5758784066858623886" datatype="html"> <trans-unit id="5758784066858623886" datatype="html">
<source>Error retrieving metadata</source> <source>Error retrieving metadata</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">640</context> <context context-type="linenumber">641</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3456881259945295697" datatype="html"> <trans-unit id="3456881259945295697" datatype="html">
<source>Error retrieving suggestions.</source> <source>Error retrieving suggestions.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">669</context> <context context-type="linenumber">670</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2194092841814123758" datatype="html"> <trans-unit id="2194092841814123758" datatype="html">
<source>Document &quot;<x id="PH" equiv-text="newValues.title"/>&quot; saved successfully.</source> <source>Document &quot;<x id="PH" equiv-text="newValues.title"/>&quot; saved successfully.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">844</context> <context context-type="linenumber">845</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">868</context> <context context-type="linenumber">869</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6626387786259219838" datatype="html"> <trans-unit id="6626387786259219838" datatype="html">
<source>Error saving document &quot;<x id="PH" equiv-text="this.document.title"/>&quot;</source> <source>Error saving document &quot;<x id="PH" equiv-text="this.document.title"/>&quot;</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">874</context> <context context-type="linenumber">875</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="448882439049417053" datatype="html"> <trans-unit id="448882439049417053" datatype="html">
<source>Error saving document</source> <source>Error saving document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">919</context> <context context-type="linenumber">920</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8410796510716511826" datatype="html"> <trans-unit id="8410796510716511826" datatype="html">
<source>Do you really want to move the document &quot;<x id="PH" equiv-text="this.document.title"/>&quot; to the trash?</source> <source>Do you really want to move the document &quot;<x id="PH" equiv-text="this.document.title"/>&quot; to the trash?</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">951</context> <context context-type="linenumber">952</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="282586936710748252" datatype="html"> <trans-unit id="282586936710748252" datatype="html">
<source>Documents can be restored prior to permanent deletion.</source> <source>Documents can be restored prior to permanent deletion.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">952</context> <context context-type="linenumber">953</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -6965,7 +7000,7 @@
<source>Move to trash</source> <source>Move to trash</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">954</context> <context context-type="linenumber">955</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -6976,14 +7011,14 @@
<source>Error deleting document</source> <source>Error deleting document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">973</context> <context context-type="linenumber">974</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="619486176823357521" datatype="html"> <trans-unit id="619486176823357521" datatype="html">
<source>Reprocess confirm</source> <source>Reprocess confirm</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">993</context> <context context-type="linenumber">994</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -6994,141 +7029,67 @@
<source>This operation will permanently recreate the archive file for this document.</source> <source>This operation will permanently recreate the archive file for this document.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">994</context> <context context-type="linenumber">995</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="302054111564709516" datatype="html"> <trans-unit id="302054111564709516" datatype="html">
<source>The archive file will be re-generated with the current settings.</source> <source>The archive file will be re-generated with the current settings.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">995</context> <context context-type="linenumber">996</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8251197608401006898" datatype="html"> <trans-unit id="8251197608401006898" datatype="html">
<source>Reprocess operation for &quot;<x id="PH" equiv-text="this.document.title"/>&quot; will begin in the background. Close and re-open or reload this document after the operation has completed to see new content.</source> <source>Reprocess operation for &quot;<x id="PH" equiv-text="this.document.title"/>&quot; 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-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1005</context> <context context-type="linenumber">1006</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4409560272830824468" datatype="html"> <trans-unit id="4409560272830824468" datatype="html">
<source>Error executing operation</source> <source>Error executing operation</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1016</context> <context context-type="linenumber">1017</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6030453331794586802" datatype="html"> <trans-unit id="6030453331794586802" datatype="html">
<source>Error downloading document</source> <source>Error downloading document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1065</context> <context context-type="linenumber">1066</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4458954481601077369" datatype="html"> <trans-unit id="4458954481601077369" datatype="html">
<source>Page Fit</source> <source>Page Fit</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1142</context> <context context-type="linenumber">1143</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1217563727923422413" datatype="html"> <trans-unit id="4663705961777238777" datatype="html">
<source>Split confirm</source> <source>PDF edit operation for &quot;<x id="PH" equiv-text="this.document.title"/>&quot; will begin in the background.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1357</context> <context context-type="linenumber">1378</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2805304563009985503" datatype="html"> <trans-unit id="9043972994040261999" datatype="html">
<source>This operation will split the selected document(s) into new documents.</source> <source>Error executing PDF edit operation</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1358</context> <context context-type="linenumber">1390</context>
</context-group>
</trans-unit>
<trans-unit id="7638681545012641321" datatype="html">
<source>Split operation for &quot;<x id="PH" equiv-text="this.document.title"/>&quot; 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">1374</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">1383</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">1396</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">823</context>
</context-group>
</trans-unit>
<trans-unit id="857641176955257111" datatype="html">
<source>This operation will permanently rotate the original version of the current document.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1397</context>
</context-group>
</trans-unit>
<trans-unit id="3802852336439815451" datatype="html">
<source>Rotation of &quot;<x id="PH" equiv-text="this.document.title"/>&quot; 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">1413</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">1425</context>
</context-group>
</trans-unit>
<trans-unit id="3539261415918606512" datatype="html">
<source>Delete pages confirm</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1437</context>
</context-group>
</trans-unit>
<trans-unit id="5854352498125813866" datatype="html">
<source>This operation will permanently delete the selected pages from the original document.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1438</context>
</context-group>
</trans-unit>
<trans-unit id="1138505464360427037" datatype="html">
<source>Delete pages operation for &quot;<x id="PH" equiv-text="this.document.title"/>&quot; will begin in the background. Close and re-open or reload this document after the operation has completed to see the changes.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1453</context>
</context-group>
</trans-unit>
<trans-unit id="1249139200486584973" datatype="html">
<source>Error executing delete pages operation</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1462</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6085793215710522488" datatype="html"> <trans-unit id="6085793215710522488" datatype="html">
<source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source> <source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1522</context> <context context-type="linenumber">1450</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1526</context> <context context-type="linenumber">1454</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4958946940233632319" datatype="html"> <trans-unit id="4958946940233632319" datatype="html">
@@ -7225,6 +7186,13 @@
<context context-type="linenumber">86</context> <context context-type="linenumber">86</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1050269006235116171" datatype="html">
<source>Rotate</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">110</context>
</context-group>
</trans-unit>
<trans-unit id="3206542606001340679" datatype="html"> <trans-unit id="3206542606001340679" datatype="html">
<source>Merge</source> <source>Merge</source>
<context-group purpose="location"> <context-group purpose="location">
@@ -7478,6 +7446,13 @@
<context context-type="linenumber">791</context> <context context-type="linenumber">791</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6555329262222566158" datatype="html">
<source>Rotate 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">823</context>
</context-group>
</trans-unit>
<trans-unit id="6390006284731990222" datatype="html"> <trans-unit id="6390006284731990222" datatype="html">
<source>This operation will permanently rotate the original version of <x id="PH" equiv-text="this.list.selected.size"/> document(s).</source> <source>This operation will permanently rotate the original version of <x id="PH" equiv-text="this.list.selected.size"/> document(s).</source>
<context-group purpose="location"> <context-group purpose="location">

View File

@@ -121,6 +121,26 @@ if (!URL.revokeObjectURL) {
} }
Object.defineProperty(window, 'ResizeObserver', { value: mock() }) Object.defineProperty(window, 'ResizeObserver', { value: mock() })
if (typeof IntersectionObserver === 'undefined') {
class MockIntersectionObserver {
constructor(
public callback: IntersectionObserverCallback,
public options?: IntersectionObserverInit
) {}
observe = jest.fn()
unobserve = jest.fn()
disconnect = jest.fn()
takeRecords = jest.fn()
}
Object.defineProperty(window, 'IntersectionObserver', {
writable: true,
configurable: true,
value: MockIntersectionObserver,
})
}
HTMLCanvasElement.prototype.getContext = < HTMLCanvasElement.prototype.getContext = <
typeof HTMLCanvasElement.prototype.getContext typeof HTMLCanvasElement.prototype.getContext
>jest.fn() >jest.fn()

View File

@@ -1,54 +0,0 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
</button>
</div>
<div class="modal-body">
<div class="row">
<div class="col">
<div class="btn-toolbar flex-nowrap">
<div class="input-group input-group-sm">
<div class="input-group-text" i18n>Page</div>
<input class="form-control mw-60" type="number" min="1" [(ngModel)]="currentPage" />
<div class="input-group-text" i18n>of {{totalPages}}</div>
</div>
<div class="input-group input-group-sm ms-auto">
<span class="input-group-text" i18n>Pages to remove</span>
<input [ngModel]="pagesString" class="form-control" disabled />
</div>
</div>
<div class="pdf-viewer-container w-100 mt-3">
<pdf-viewer #pdfViewer [src]="pdfSrc" [(page)]="currentPage"
[original-size]="false"
[zoom]="1"
zoom-scale="page-fit"
[render-text]="false"
(pagerendered)="pageRendered($event)"
(after-load-complete)="pdfPreviewLoaded($event)">
</pdf-viewer>
</div>
</div>
</div>
</div>
<div class="modal-footer flex-nowrap">
<div>
@if (message) {
<p [innerHTML]="message | safeHtml"></p>
}
@if (messageBold) {
<p class="mb-0 small"><b [innerHTML]="messageBold | safeHtml"></b></p>
}
</div>
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
{{btnCaption}}
</button>
</div>
<ng-template #pageCheckOverlay let-page="page" let-pages="pages">
<div class="position-absolute top-0 start-0 w-100 h-100 p-2" (click)="pageCheckChanged(page)">
<input type="checkbox" class="form-check-input" />
</div>
</ng-template>

View File

@@ -1,28 +0,0 @@
.pdf-viewer-container {
background-color: gray;
height: 550px;
pdf-viewer {
width: 100%;
height: 100%;
}
}
.mw-60 {
max-width: 60px;
}
div.position-absolute:has(.form-check-input:checked) {
background-color: rgba(var(--bs-dark-rgb), 0.4);
}
.form-check-input {
&:checked {
background-color: var(--bs-danger);
border-color: var(--bs-danger);
}
&:focus {
box-shadow: 0 0 0 0.25rem rgba(var(--bs-danger-rgb), var(--pngx-focus-alpha));
border-color: var(--bs-danger);
}
}

View File

@@ -1,60 +0,0 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { DeletePagesConfirmDialogComponent } from './delete-pages-confirm-dialog.component'
describe('DeletePagesConfirmDialogComponent', () => {
let component: DeletePagesConfirmDialogComponent
let fixture: ComponentFixture<DeletePagesConfirmDialogComponent>
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [],
imports: [
NgxBootstrapIconsModule.pick(allIcons),
FormsModule,
ReactiveFormsModule,
DeletePagesConfirmDialogComponent,
],
providers: [
NgbActiveModal,
SafeHtmlPipe,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
}).compileComponents()
fixture = TestBed.createComponent(DeletePagesConfirmDialogComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should return a string with comma-separated pages', () => {
component.pages = [1, 2, 3, 4]
expect(component.pagesString).toEqual('1, 2, 3, 4')
})
it('should update totalPages when pdf is loaded', () => {
component.pdfPreviewLoaded({ numPages: 5 } as any)
expect(component.totalPages).toEqual(5)
})
it('should update checks when page is rendered', () => {
const event = {
target: document.createElement('div'),
detail: { pageNumber: 1 },
} as any
component.pageRendered(event)
expect(component['checks'].length).toEqual(1)
})
it('should update pages when page check is changed', () => {
component.pageCheckChanged(1)
expect(component.pages).toEqual([1])
component.pageCheckChanged(1)
expect(component.pages).toEqual([])
})
})

View File

@@ -1,69 +0,0 @@
import { Component, TemplateRef, ViewChild, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import {
PDFDocumentProxy,
PdfViewerComponent,
PdfViewerModule,
} from 'ng2-pdf-viewer'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { DocumentService } from 'src/app/services/rest/document.service'
import { ConfirmDialogComponent } from '../confirm-dialog.component'
@Component({
selector: 'pngx-delete-pages-confirm-dialog',
templateUrl: './delete-pages-confirm-dialog.component.html',
styleUrl: './delete-pages-confirm-dialog.component.scss',
imports: [PdfViewerModule, FormsModule, ReactiveFormsModule, SafeHtmlPipe],
})
export class DeletePagesConfirmDialogComponent extends ConfirmDialogComponent {
private documentService = inject(DocumentService)
public documentID: number
public pages: number[] = []
public currentPage: number = 1
public totalPages: number
@ViewChild('pdfViewer') pdfViewer: PdfViewerComponent
@ViewChild('pageCheckOverlay') pageCheckOverlay!: TemplateRef<any>
private checks: HTMLElement[] = []
public get pagesString(): string {
return this.pages.join(', ')
}
public get pdfSrc(): string {
return this.documentService.getPreviewUrl(this.documentID)
}
constructor() {
super()
}
public pdfPreviewLoaded(pdf: PDFDocumentProxy) {
this.totalPages = pdf.numPages
}
pageRendered(event: CustomEvent) {
const pageDiv = event.target as HTMLDivElement
const check = this.pageCheckOverlay.createEmbeddedView({
page: event.detail.pageNumber,
})
this.checks[event.detail.pageNumber - 1] = check.rootNodes[0]
pageDiv?.insertBefore(check.rootNodes[0], pageDiv.firstChild)
this.updateChecks()
}
pageCheckChanged(pageNumber: number) {
if (!this.pages.includes(pageNumber)) this.pages.push(pageNumber)
else if (this.pages.includes(pageNumber))
this.pages.splice(this.pages.indexOf(pageNumber), 1)
this.updateChecks()
}
private updateChecks() {
this.checks.forEach((check, i) => {
const input = check.getElementsByTagName('input')[0]
input.checked = this.pages.includes(i + 1)
})
}
}

View File

@@ -1,59 +0,0 @@
<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-7">
<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">
<pdf-viewer [src]="pdfSrc" [(page)]="page"
[original-size]="false"
[zoom]="1"
zoom-scale="page-fit"
(after-load-complete)="pdfPreviewLoaded($event)">
</pdf-viewer>
</div>
</div>
<div class="col-5">
<div class="d-grid">
<button class="btn btn-sm btn-primary" (click)="addSplit()" [disabled]="!canSplit">
<i-bs name="plus-circle"></i-bs>&nbsp;
<span i18n>Add Split</span>
</button>
</div>
<ul class="list-group mt-3">
@for (pageStr of pagesString.split(','); track pageStr; let i = $index) {
<li class="list-group-item d-flex align-items-center">
{{pageStr}}
@if (pagesString.split(',').length > 1) {
&nbsp;
<button class="btn btn-sm btn-danger ms-auto" (click)="removeSplit(i)">
<i-bs name="trash"></i-bs>
</button>
}
</li>
}
</ul>
</div>
</div>
</div>
<div class="modal-footer">
<div class="form-check form-switch me-auto">
<input class="form-check-input" type="checkbox" role="switch" id="deleteOriginalSwitch" [(ngModel)]="deleteOriginal" [disabled]="!userOwnsDocument">
<label class="form-check-label" for="deleteOriginalSwitch" i18n>Delete original document after successful split</label>
</div>
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
{{btnCaption}}
</button>
</div>

View File

@@ -1,9 +0,0 @@
.pdf-viewer-container {
background-color: gray;
height: 500px;
pdf-viewer {
width: 100%;
height: 100%;
}
}

View File

@@ -1,107 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { PdfViewerModule } from 'ng2-pdf-viewer'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of } from 'rxjs'
import { DocumentService } from 'src/app/services/rest/document.service'
import { SplitConfirmDialogComponent } from './split-confirm-dialog.component'
describe('SplitConfirmDialogComponent', () => {
let component: SplitConfirmDialogComponent
let fixture: ComponentFixture<SplitConfirmDialogComponent>
let documentService: DocumentService
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
NgxBootstrapIconsModule.pick(allIcons),
ReactiveFormsModule,
FormsModule,
PdfViewerModule,
SplitConfirmDialogComponent,
],
providers: [
NgbActiveModal,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
}).compileComponents()
fixture = TestBed.createComponent(SplitConfirmDialogComponent)
documentService = TestBed.inject(DocumentService)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should load document on init', () => {
const getSpy = jest.spyOn(documentService, 'get')
component.documentID = 1
getSpy.mockReturnValue(of({ id: 1 } as any))
component.ngOnInit()
expect(documentService.get).toHaveBeenCalledWith(1)
})
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)
})
it('should correctly disable split button', () => {
component.totalPages = 5
component.page = 1
expect(component.canSplit).toBeTruthy()
component.page = 5
expect(component.canSplit).toBeFalsy()
component.page = 4
expect(component.canSplit).toBeTruthy()
component['pages'] = new Set([1, 2, 3, 4])
expect(component.canSplit).toBeFalsy()
})
})

View File

@@ -1,98 +0,0 @@
import { Component, OnInit, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Document } from 'src/app/data/document'
import { PermissionsService } from 'src/app/services/permissions.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import { ConfirmDialogComponent } from '../confirm-dialog.component'
@Component({
selector: 'pngx-split-confirm-dialog',
templateUrl: './split-confirm-dialog.component.html',
styleUrl: './split-confirm-dialog.component.scss',
imports: [
FormsModule,
ReactiveFormsModule,
NgxBootstrapIconsModule,
PdfViewerModule,
],
})
export class SplitConfirmDialogComponent
extends ConfirmDialogComponent
implements OnInit
{
private documentService = inject(DocumentService)
private permissionService = inject(PermissionsService)
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
private document: Document
public page: number = 1
public totalPages: number
public deleteOriginal: boolean = false
public get canSplit(): boolean {
return (
this.page < this.totalPages &&
this.pages.size < this.totalPages - 1 &&
!this.pages.has(this.page)
)
}
public get pdfSrc(): string {
return this.documentService.getPreviewUrl(this.documentID)
}
constructor() {
super()
this.confirmButtonEnabled = this.pages.size > 0
}
ngOnInit(): void {
this.documentService.get(this.documentID).subscribe((r) => {
this.document = r
})
}
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((a, b) => a - b))
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
}
get userOwnsDocument(): boolean {
return this.permissionService.currentUserOwnsObject(this.document)
}
}

View File

@@ -0,0 +1,103 @@
<pdf-viewer [src]="pdfSrc" [render-text]="false" zoom="0.4" (after-load-complete)="pdfLoaded($event)"></pdf-viewer>
<div class="modal-header">
<h4 class="modal-title">{{ title }}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button>
</div>
<div class="modal-body">
<div class="btn-toolbar mb-2">
<div class="btn-group me-3">
<button class="btn btn-sm btn-secondary" (click)="selectAll()" title="Select all pages" i18n-title>
<i-bs name="check-all"></i-bs>
</button>
<button class="btn btn-sm btn-secondary" (click)="deselectAll()" [disabled]="!hasSelection()" title="Deselect all pages" i18n-title>
<i-bs name="x"></i-bs>
</button>
</div>
<div class="btn-group">
<button class="btn btn-sm btn-secondary" (click)="rotateSelected(-90)" [disabled]="!hasSelection()" title="Rotate selected pages counter-clockwise" i18n-title>
<i-bs name="arrow-counterclockwise"></i-bs>
</button>
<button class="btn btn-sm btn-secondary" (click)="rotateSelected(90)" [disabled]="!hasSelection()" title="Rotate selected pages clockwise" i18n-title>
<i-bs name="arrow-clockwise"></i-bs>
</button>
<button class="btn btn-sm btn-danger" (click)="deleteSelected()" [disabled]="!hasSelection()" title="Delete selected pages" i18n-title>
<i-bs name="trash"></i-bs>
</button>
</div>
</div>
<div cdkDropList (cdkDropListDropped)="drop($event)" cdkDropListOrientation="mixed" class="d-flex flex-wrap row-cols-5">
@for (p of pages; track p.page; let i = $index) {
<div class="page-item rounded p-2" cdkDrag (click)="toggleSelection(i)" [class.selected]="p.selected">
<div class="btn-toolbar hover-actions z-10">
<div class="btn-group me-2">
<button class="btn btn-sm btn-dark" (click)="rotate(i); $event.stopPropagation()" title="Rotate page counter-clockwise" i18n-title>
<i-bs name="arrow-counterclockwise"></i-bs>
</button>
<button class="btn btn-sm btn-dark" (click)="rotate(i); $event.stopPropagation()" title="Rotate page clockwise" i18n-title>
<i-bs name="arrow-clockwise"></i-bs>
</button>
</div>
<div class="btn-group">
<button class="btn btn-sm btn-dark text-danger" (click)="remove(i); $event.stopPropagation()" title="Delete page" i18n-title>
<i-bs name="trash"></i-bs>
</button>
<button class="btn btn-sm btn-dark" (click)="toggleSplit(i); $event.stopPropagation()" title="Add / remove document split here" i18n-title>
<i-bs name="scissors"></i-bs>
</button>
</div>
</div>
<div class="border-end border-bottom bg-light py-1 px-2 document-check z-10">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="page{{i}}" [checked]="p.selected" (click)="toggleSelection(i); $event.stopPropagation()">
<label class="form-check-label" for="page{{i}}"></label>
</div>
</div>
<div class="pdf-viewer-container w-100" [class.selected]="p.selected">
@defer (on viewport) {
@if (!p.loaded) {
<div class="placeholder-glow w-100 h-100 z-10">
<span class="placeholder w-100 h-100"></span>
</div>
}
<pdf-viewer class="fade" [class.show]="p.loaded" [src]="pdfSrc" [page]="p.page" [rotation]="p.rotate" [original-size]="false" [show-all]="false" [render-text]="false" (page-rendered)="p.loaded = true"></pdf-viewer>
} @placeholder {
<div class="placeholder-glow w-100 h-100 z-10">
<span class="placeholder w-100 h-100"></span>
</div>
}
</div>
@if (p.splitAfter) {
<div class="split-after rounded position-absolute top-0 end-0 bg-dark text-uppercase text-center h-100 px-1 small fw-bold">&mdash; <span i18n>Split here</span> &mdash;</div>
}
</div>
}
</div>
</div>
<div class="modal-footer flex-column">
<div class="d-flex w-100 justify-content-between align-items-center">
<div class="btn-group" role="group">
<input type="radio" class="btn-check" [(ngModel)]="editMode" [value]="PdfEditorEditMode.Create" id="editModeCreate" name="editmode">
<label for="editModeCreate" class="btn btn-outline-primary btn-sm">
<i-bs name="plus"></i-bs>
<span class="form-check-label ms-1" i18n>Create new document(s)</span>
</label>
<input type="radio" class="btn-check" [(ngModel)]="editMode" [value]="PdfEditorEditMode.Update" id="editModeUpdate" name="editmode" [disabled]="hasSplit()">
<label for="editModeUpdate" class="btn btn-outline-primary btn-sm">
<i-bs name="pencil"></i-bs>
<span class="form-check-label ms-2" i18n>Update existing document</span>
</label>
</div>
@if (editMode === PdfEditorEditMode.Create) {
<div class="form-check ms-3">
<input class="form-check-input" type="checkbox" id="copyMeta" [(ngModel)]="includeMetadata">
<label class="form-check-label" for="copyMeta" i18n>Copy metadata</label>
</div>
<div class="form-check ms-3">
<input class="form-check-input" type="checkbox" id="deleteOriginal" [(ngModel)]="deleteOriginal">
<label class="form-check-label" for="deleteOriginal" i18n>Delete original</label>
</div>
}
<button type="button" class="btn ms-auto me-2" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">{{ cancelBtnCaption }}</button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="pages.length === 0">{{ btnCaption }}</button>
</div>
</div>

View File

@@ -0,0 +1,70 @@
.page-item {
position: relative;
cursor: pointer;
border: 1px solid transparent;
background-origin: border-box;
&.selected {
background-color: var(--pngx-primary-darken-5);
}
}
.pdf-viewer-container {
background-color: gray;
height: 240px;
pdf-viewer {
width: 100%;
height: 100%;
}
}
::ng-deep .ng2-pdf-viewer-container {
overflow: hidden;
}
.hover-actions {
position: absolute;
top: 0;
right: 0;
display: none;
}
.page-item:hover .hover-actions {
display: block;
}
.document-check {
display: none;
position: absolute;
top: 0;
left: 0;
padding: 0.5rem;
border-top-left-radius: 0.25rem;
border-bottom-right-radius: 0.25rem;
pointer-events: none;
.form-check {
padding: 0;
min-height: 0;
margin-bottom: 0;
.form-check-input {
margin-left: 0;
}
}
}
.page-item:hover .document-check, .selected .document-check {
display: block;
}
.z-10 {
z-index: 10;
}
.split-after {
writing-mode: vertical-rl;
}

View File

@@ -0,0 +1,142 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { PDFEditorComponent } from './pdf-editor.component'
describe('PDFEditorComponent', () => {
let component: PDFEditorComponent
let fixture: ComponentFixture<PDFEditorComponent>
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PDFEditorComponent, NgxBootstrapIconsModule.pick(allIcons)],
providers: [
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
{ provide: NgbActiveModal, useValue: {} },
],
}).compileComponents()
fixture = TestBed.createComponent(PDFEditorComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should return correct operations with no changes', () => {
component.pages = [
{ page: 1, rotate: 0, splitAfter: false },
{ page: 2, rotate: 0, splitAfter: false },
{ page: 3, rotate: 0, splitAfter: false },
]
const ops = component.getOperations()
expect(ops).toEqual([
{ page: 1, rotate: 0, doc: 0 },
{ page: 2, rotate: 0, doc: 0 },
{ page: 3, rotate: 0, doc: 0 },
])
})
it('should rotate, delete and reorder pages', () => {
component.pages = [
{ page: 1, rotate: 0, splitAfter: false, selected: false },
{ page: 2, rotate: 0, splitAfter: false, selected: false },
]
component.toggleSelection(0)
component.rotateSelected(90)
expect(component.pages[0].rotate).toBe(90)
component.toggleSelection(0) // deselect
component.toggleSelection(1)
component.deleteSelected()
expect(component.pages.length).toBe(1)
component.pages.push({ page: 2, rotate: 0, splitAfter: false })
component.drop({ previousIndex: 0, currentIndex: 1 } as any)
expect(component.pages[0].page).toBe(2)
component.rotate(0)
expect(component.pages[0].rotate).toBe(90)
})
it('should handle empty pages array', () => {
component.pages = []
expect(component.getOperations()).toEqual([])
})
it('should increment doc index after splitAfter', () => {
component.pages = [
{ page: 1, rotate: 0, splitAfter: true },
{ page: 2, rotate: 0, splitAfter: false },
{ page: 3, rotate: 0, splitAfter: true },
{ page: 4, rotate: 0, splitAfter: false },
]
const ops = component.getOperations()
expect(ops).toEqual([
{ page: 1, rotate: 0, doc: 0 },
{ page: 2, rotate: 0, doc: 1 },
{ page: 3, rotate: 0, doc: 1 },
{ page: 4, rotate: 0, doc: 2 },
])
})
it('should include rotations in operations', () => {
component.pages = [
{ page: 1, rotate: 90, splitAfter: false },
{ page: 2, rotate: 180, splitAfter: true },
{ page: 3, rotate: 270, splitAfter: false },
]
const ops = component.getOperations()
expect(ops).toEqual([
{ page: 1, rotate: 90, doc: 0 },
{ page: 2, rotate: 180, doc: 0 },
{ page: 3, rotate: 270, doc: 1 },
])
})
it('should handle remove operation', () => {
component.pages = [
{ page: 1, rotate: 0, splitAfter: false, selected: false },
{ page: 2, rotate: 0, splitAfter: false, selected: true },
{ page: 3, rotate: 0, splitAfter: false, selected: false },
]
component.remove(1) // remove page 2
expect(component.pages.length).toBe(2)
expect(component.pages[0].page).toBe(1)
expect(component.pages[1].page).toBe(3)
})
it('should toggle splitAfter correctly', () => {
component.pages = [
{ page: 1, rotate: 0, splitAfter: false },
{ page: 2, rotate: 0, splitAfter: false },
]
component.toggleSplit(0)
expect(component.pages[0].splitAfter).toBeTruthy()
component.toggleSplit(1)
expect(component.pages[1].splitAfter).toBeTruthy()
})
it('should select and deselect all pages', () => {
component.pages = [
{ page: 1, rotate: 0, splitAfter: false, selected: false },
{ page: 2, rotate: 0, splitAfter: false, selected: false },
]
component.selectAll()
expect(component.pages.every((p) => p.selected)).toBeTruthy()
expect(component.hasSelection()).toBeTruthy()
component.deselectAll()
expect(component.pages.every((p) => !p.selected)).toBeTruthy()
expect(component.hasSelection()).toBeFalsy()
})
it('should handle pdf loading and page generation', () => {
const mockPdf = {
numPages: 3,
getPage: (pageNum: number) => Promise.resolve({ pageNumber: pageNum }),
}
component.pdfLoaded(mockPdf as any)
expect(component.totalPages).toBe(3)
expect(component.pages.length).toBe(3)
expect(component.pages[0].page).toBe(1)
expect(component.pages[1].page).toBe(2)
expect(component.pages[2].page).toBe(3)
})
})

View File

@@ -0,0 +1,133 @@
import {
CdkDragDrop,
DragDropModule,
moveItemInArray,
} from '@angular/cdk/drag-drop'
import { Component, inject } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { DocumentService } from 'src/app/services/rest/document.service'
import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component'
interface PageOperation {
page: number
rotate: number
splitAfter: boolean
selected?: boolean
loaded?: boolean
}
export enum PdfEditorEditMode {
Update = 'update',
Create = 'create',
}
@Component({
selector: 'pngx-pdf-editor',
templateUrl: './pdf-editor.component.html',
styleUrl: './pdf-editor.component.scss',
imports: [
DragDropModule,
FormsModule,
PdfViewerModule,
NgxBootstrapIconsModule,
],
})
export class PDFEditorComponent extends ConfirmDialogComponent {
public PdfEditorEditMode = PdfEditorEditMode
private documentService = inject(DocumentService)
activeModal: NgbActiveModal = inject(NgbActiveModal)
documentID: number
pages: PageOperation[] = []
totalPages = 0
editMode: PdfEditorEditMode = PdfEditorEditMode.Create
deleteOriginal: boolean = false
includeMetadata: boolean = true
get pdfSrc(): string {
return this.documentService.getPreviewUrl(this.documentID)
}
pdfLoaded(pdf: PDFDocumentProxy) {
this.totalPages = pdf.numPages
this.pages = Array.from({ length: this.totalPages }, (_, i) => ({
page: i + 1,
rotate: 0,
splitAfter: false,
selected: false,
loaded: false,
}))
}
toggleSelection(i: number) {
this.pages[i].selected = !this.pages[i].selected
}
rotate(i: number) {
this.pages[i].rotate = (this.pages[i].rotate + 90) % 360
}
rotateSelected(dir: number) {
for (let p of this.pages) {
if (p.selected) {
p.rotate = (p.rotate + dir + 360) % 360
}
}
}
remove(i: number) {
this.pages.splice(i, 1)
}
toggleSplit(i: number) {
this.pages[i].splitAfter = !this.pages[i].splitAfter
if (this.pages[i].splitAfter) {
// force create mode
this.editMode = PdfEditorEditMode.Create
}
}
selectAll() {
this.pages.forEach((p) => (p.selected = true))
}
deselectAll() {
this.pages.forEach((p) => (p.selected = false))
}
deleteSelected() {
this.pages = this.pages.filter((p) => !p.selected)
}
hasSelection(): boolean {
return this.pages.some((p) => p.selected)
}
hasSplit(): boolean {
return this.pages.some((p) => p.splitAfter)
}
drop(event: CdkDragDrop<PageOperation[]>) {
moveItemInArray(this.pages, event.previousIndex, event.currentIndex)
}
getOperations() {
return this.pages.map((p, idx) => ({
page: p.page,
rotate: p.rotate,
doc: this.computeDocIndex(idx),
}))
}
private computeDocIndex(index: number): number {
let docIndex = 0
for (let i = 0; i <= index; i++) {
if (this.pages[i].splitAfter && i < index) docIndex++
}
return docIndex
}
}

View File

@@ -58,16 +58,8 @@
<i-bs width="1em" height="1em" name="diagram-3"></i-bs>&nbsp;<span i18n>More like this</span> <i-bs width="1em" height="1em" name="diagram-3"></i-bs>&nbsp;<span i18n>More like this</span>
</button> </button>
<button ngbDropdownItem (click)="splitDocument()" [disabled]="!userCanAdd || originalContentRenderType !== ContentRenderType.PDF || previewNumPages === 1"> <button ngbDropdownItem (click)="editPdf()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF">
<i-bs width="1em" height="1em" name="scissors"></i-bs>&nbsp;<span i18n>Split</span> <i-bs name="pencil"></i-bs>&nbsp;<ng-container i18n>PDF Editor</ng-container>
</button>
<button ngbDropdownItem (click)="rotateDocument()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF">
<i-bs name="arrow-clockwise"></i-bs>&nbsp;<ng-container i18n>Rotate</ng-container>
</button>
<button ngbDropdownItem (click)="deletePages()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF || previewNumPages === 1">
<i-bs name="file-earmark-minus"></i-bs>&nbsp;<ng-container i18n>Delete page(s)</ng-container>
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1158,81 +1158,43 @@ describe('DocumentDetailComponent', () => {
).not.toBeUndefined() ).not.toBeUndefined()
}) })
it('should support split', () => { it('should support pdf editor, handle error', () => {
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0])) modalService.activeInstances.subscribe((m) => (modal = m[0]))
const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument')
const errorSpy = jest.spyOn(toastService, 'showError')
initNormally() initNormally()
component.splitDocument() component.editPdf()
expect(modal).not.toBeUndefined() expect(modal).not.toBeUndefined()
modal.componentInstance.documentID = doc.id modal.componentInstance.documentID = doc.id
modal.componentInstance.totalPages = 5 modal.componentInstance.pages = [{ page: 1, rotate: 0, splitAfter: false }]
modal.componentInstance.page = 2
modal.componentInstance.addSplit()
modal.componentInstance.confirm() modal.componentInstance.confirm()
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/` `${environment.apiBaseUrl}documents/bulk_edit/`
) )
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: [doc.id], documents: [doc.id],
method: 'split', method: 'edit_pdf',
parameters: { pages: '1-2,3-5', delete_originals: false }, parameters: {
}) operations: [{ page: 1, rotate: 0, doc: 0 }],
req.error(new ProgressEvent('failed')) delete_original: false,
modal.componentInstance.confirm() update_document: false,
req = httpTestingController.expectOne( include_metadata: true,
`${environment.apiBaseUrl}documents/bulk_edit/` },
)
req.flush(true)
}) })
req.error(new ErrorEvent('failed'))
expect(errorSpy).toHaveBeenCalled()
it('should support rotate', () => { component.editPdf()
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0]))
initNormally()
component.rotateDocument()
expect(modal).not.toBeUndefined()
modal.componentInstance.documentID = doc.id modal.componentInstance.documentID = doc.id
modal.componentInstance.rotate() modal.componentInstance.pages = [{ page: 1, rotate: 0, splitAfter: true }]
modal.componentInstance.confirm() modal.componentInstance.deleteOriginal = true
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)
})
it('should support delete pages', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0]))
initNormally()
component.deletePages()
expect(modal).not.toBeUndefined()
modal.componentInstance.documentID = doc.id
modal.componentInstance.pages = [1, 2]
modal.componentInstance.confirm()
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
expect(req.request.body).toEqual({
documents: [doc.id],
method: 'delete_pages',
parameters: { pages: [1, 2] },
})
req.error(new ProgressEvent('failed'))
modal.componentInstance.confirm() modal.componentInstance.confirm()
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/` `${environment.apiBaseUrl}documents/bulk_edit/`
) )
req.flush(true) req.flush(true)
expect(closeSpy).toHaveBeenCalled()
}) })
it('should support keyboard shortcuts', () => { it('should support keyboard shortcuts', () => {

View File

@@ -82,9 +82,6 @@ import { getFilenameFromContentDisposition } from 'src/app/utils/http'
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter' import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
import * as UTIF from 'utif' import * as UTIF from 'utif'
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
import { DeletePagesConfirmDialogComponent } from '../common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component'
import { RotateConfirmDialogComponent } from '../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
import { SplitConfirmDialogComponent } from '../common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component'
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component' import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component' 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 { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
@@ -102,6 +99,10 @@ import { TagsComponent } from '../common/input/tags/tags.component'
import { TextComponent } from '../common/input/text/text.component' import { TextComponent } from '../common/input/text/text.component'
import { UrlComponent } from '../common/input/url/url.component' import { UrlComponent } from '../common/input/url/url.component'
import { PageHeaderComponent } from '../common/page-header/page-header.component' import { PageHeaderComponent } from '../common/page-header/page-header.component'
import {
PDFEditorComponent,
PdfEditorEditMode,
} from '../common/pdf-editor/pdf-editor.component'
import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-links-dialog.component' import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-links-dialog.component'
import { DocumentHistoryComponent } from '../document-history/document-history.component' import { DocumentHistoryComponent } from '../document-history/document-history.component'
import { DocumentNotesComponent } from '../document-notes/document-notes.component' import { DocumentNotesComponent } from '../document-notes/document-notes.component'
@@ -1349,13 +1350,13 @@ export class DocumentDetailComponent
this.documentForm.updateValueAndValidity() this.documentForm.updateValueAndValidity()
} }
splitDocument() { editPdf() {
let modal = this.modalService.open(SplitConfirmDialogComponent, { let modal = this.modalService.open(PDFEditorComponent, {
backdrop: 'static', backdrop: 'static',
size: 'lg', size: 'xl',
scrollable: true,
}) })
modal.componentInstance.title = $localize`Split confirm` modal.componentInstance.title = $localize`PDF Editor`
modal.componentInstance.messageBold = $localize`This operation will split the selected document(s) into new documents.`
modal.componentInstance.btnCaption = $localize`Proceed` modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.documentID = this.document.id modal.componentInstance.documentID = this.document.id
modal.componentInstance.confirmClicked modal.componentInstance.confirmClicked
@@ -1363,103 +1364,30 @@ export class DocumentDetailComponent
.subscribe(() => { .subscribe(() => {
modal.componentInstance.buttonsEnabled = false modal.componentInstance.buttonsEnabled = false
this.documentsService this.documentsService
.bulkEdit([this.document.id], 'split', { .bulkEdit([this.document.id], 'edit_pdf', {
pages: modal.componentInstance.pagesString, operations: modal.componentInstance.getOperations(),
delete_originals: modal.componentInstance.deleteOriginal, delete_original: modal.componentInstance.deleteOriginal,
update_document:
modal.componentInstance.editMode == PdfEditorEditMode.Update,
include_metadata: modal.componentInstance.includeMetadata,
}) })
.pipe(first(), takeUntil(this.unsubscribeNotifier)) .pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: () => { next: () => {
this.toastService.showInfo( this.toastService.showInfo(
$localize`Split operation for "${this.document.title}" will begin in the background.` $localize`PDF edit operation for "${this.document.title}" will begin in the background.`
) )
modal.close() modal.close()
if (modal.componentInstance.deleteOriginal) {
this.openDocumentService.closeDocument(this.document)
}
}, },
error: (error) => { error: (error) => {
if (modal) { if (modal) {
modal.componentInstance.buttonsEnabled = true modal.componentInstance.buttonsEnabled = true
} }
this.toastService.showError( this.toastService.showError(
$localize`Error executing split operation`, $localize`Error executing PDF edit operation`,
error
)
},
})
})
}
rotateDocument() {
let modal = this.modalService.open(RotateConfirmDialogComponent, {
backdrop: 'static',
size: 'lg',
})
modal.componentInstance.title = $localize`Rotate confirm`
modal.componentInstance.messageBold = $localize`This operation will permanently rotate the original version of the current document.`
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 of "${this.document.title}" 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
)
},
})
})
}
deletePages() {
let modal = this.modalService.open(DeletePagesConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Delete pages confirm`
modal.componentInstance.messageBold = $localize`This operation will permanently delete the selected pages from the original document.`
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.documentID = this.document.id
modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
modal.componentInstance.buttonsEnabled = false
this.documentsService
.bulkEdit([this.document.id], 'delete_pages', {
pages: modal.componentInstance.pages,
})
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({
next: () => {
this.toastService.showInfo(
$localize`Delete pages operation for "${this.document.title}" will begin in the background. Close and re-open or reload this document after the operation has completed to see the changes.`
)
modal.close()
},
error: (error) => {
if (modal) {
modal.componentInstance.buttonsEnabled = true
}
this.toastService.showError(
$localize`Error executing delete pages operation`,
error error
) )
}, },

View File

@@ -497,6 +497,103 @@ def delete_pages(doc_ids: list[int], pages: list[int]) -> Literal["OK"]:
return "OK" return "OK"
def edit_pdf(
doc_ids: list[int],
operations: list[dict],
*,
delete_original: bool = False,
update_document: bool = False,
include_metadata: bool = True,
user: User | None = None,
) -> Literal["OK"]:
"""
Operations is a list of dictionaries describing the final PDF pages.
Each entry must contain the original page number in `page` and may
specify `rotate` in degrees and `doc` indicating the output
document index (for splitting). Pages omitted from the list are
discarded.
"""
logger.info(
f"Editing PDF of document {doc_ids[0]} with {len(operations)} operations",
)
doc = Document.objects.get(id=doc_ids[0])
import pikepdf
pdf_docs: list[pikepdf.Pdf] = []
try:
with pikepdf.open(doc.source_path) as src:
# prepare output documents
max_idx = max(op.get("doc", 0) for op in operations)
pdf_docs = [pikepdf.new() for _ in range(max_idx + 1)]
if update_document and len(pdf_docs) > 1:
logger.error(
"Update requested but multiple output documents specified",
)
raise ValueError("Multiple output documents specified")
for op in operations:
dst = pdf_docs[op.get("doc", 0)]
page = src.pages[op["page"] - 1]
dst.pages.append(page)
if op.get("rotate"):
dst.pages[-1].rotate(op["rotate"], relative=True)
if update_document:
temp_path = doc.source_path.with_suffix(".tmp.pdf")
pdf = pdf_docs[0]
pdf.remove_unreferenced_resources()
# save the edited PDF to a temporary file in case of errors
pdf.save(temp_path)
# replace the original document with the edited one
temp_path.replace(doc.source_path)
doc.checksum = hashlib.md5(doc.source_path.read_bytes()).hexdigest()
doc.page_count = len(pdf.pages)
doc.save()
update_document_content_maybe_archive_file.delay(document_id=doc.id)
else:
consume_tasks = []
overrides = (
DocumentMetadataOverrides().from_document(doc)
if include_metadata
else DocumentMetadataOverrides()
)
if user is not None:
overrides.owner_id = user.id
for idx, pdf in enumerate(pdf_docs, start=1):
filepath: Path = (
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
/ f"{doc.id}_edit_{idx}.pdf"
)
pdf.remove_unreferenced_resources()
pdf.save(filepath)
consume_tasks.append(
consume_file.s(
ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=filepath,
),
overrides,
),
)
if delete_original:
chord(header=consume_tasks, body=delete.si([doc.id])).delay()
else:
group(consume_tasks).delay()
except Exception as e:
logger.exception(f"Error editing document {doc.id}: {e}")
raise ValueError(
f"An error occurred while editing the document: {e}",
) from e
return "OK"
def reflect_doclinks( def reflect_doclinks(
document: Document, document: Document,
field: CustomField, field: CustomField,

View File

@@ -1293,6 +1293,7 @@ class BulkEditSerializer(
"merge", "merge",
"split", "split",
"delete_pages", "delete_pages",
"edit_pdf",
], ],
label="Method", label="Method",
write_only=True, write_only=True,
@@ -1366,7 +1367,10 @@ class BulkEditSerializer(
return bulk_edit.split return bulk_edit.split
elif method == "delete_pages": elif method == "delete_pages":
return bulk_edit.delete_pages return bulk_edit.delete_pages
else: elif method == "edit_pdf":
return bulk_edit.edit_pdf
else: # pragma: no cover
# This will never happen as it is handled by the ChoiceField
raise serializers.ValidationError("Unsupported method.") raise serializers.ValidationError("Unsupported method.")
def _validate_parameters_tags(self, parameters): def _validate_parameters_tags(self, parameters):
@@ -1520,6 +1524,47 @@ class BulkEditSerializer(
else: else:
parameters["archive_fallback"] = False parameters["archive_fallback"] = False
def _validate_parameters_edit_pdf(self, parameters, document_id):
if "operations" not in parameters:
raise serializers.ValidationError("operations not specified")
if not isinstance(parameters["operations"], list):
raise serializers.ValidationError("operations must be a list")
for op in parameters["operations"]:
if not isinstance(op, dict):
raise serializers.ValidationError("invalid operation entry")
if "page" not in op or not isinstance(op["page"], int):
raise serializers.ValidationError("page must be an integer")
if "rotate" in op and not isinstance(op["rotate"], int):
raise serializers.ValidationError("rotate must be an integer")
if "doc" in op and not isinstance(op["doc"], int):
raise serializers.ValidationError("doc must be an integer")
if "update_document" in parameters:
if not isinstance(parameters["update_document"], bool):
raise serializers.ValidationError("update_document must be a boolean")
else:
parameters["update_document"] = False
if "include_metadata" in parameters:
if not isinstance(parameters["include_metadata"], bool):
raise serializers.ValidationError("include_metadata must be a boolean")
else:
parameters["include_metadata"] = True
if parameters["update_document"]:
max_idx = max(op.get("doc", 0) for op in parameters["operations"])
if max_idx > 0:
raise serializers.ValidationError(
"update_document only allowed with a single output document",
)
doc = Document.objects.get(id=document_id)
# doc existence is already validated
if doc.page_count:
for op in parameters["operations"]:
if op["page"] < 1 or op["page"] > doc.page_count:
raise serializers.ValidationError(
f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.",
)
def validate(self, attrs): def validate(self, attrs):
method = attrs["method"] method = attrs["method"]
parameters = attrs["parameters"] parameters = attrs["parameters"]
@@ -1554,6 +1599,12 @@ class BulkEditSerializer(
self._validate_parameters_delete_pages(parameters) self._validate_parameters_delete_pages(parameters)
elif method == bulk_edit.merge: elif method == bulk_edit.merge:
self._validate_parameters_merge(parameters) self._validate_parameters_merge(parameters)
elif method == bulk_edit.edit_pdf:
if len(attrs["documents"]) > 1:
raise serializers.ValidationError(
"Edit PDF method only supports one document",
)
self._validate_parameters_edit_pdf(parameters, attrs["documents"][0])
return attrs return attrs

View File

@@ -1,9 +1,12 @@
from __future__ import annotations from __future__ import annotations
import ipaddress
import logging import logging
import shutil import shutil
import socket
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from urllib.parse import urlparse
import httpx import httpx
from celery import shared_task from celery import shared_task
@@ -660,6 +663,28 @@ def run_workflows_updated(sender, document: Document, logging_group=None, **kwar
) )
def _is_public_ip(ip: str) -> bool:
try:
obj = ipaddress.ip_address(ip)
return not (
obj.is_private
or obj.is_loopback
or obj.is_link_local
or obj.is_multicast
or obj.is_unspecified
)
except ValueError: # pragma: no cover
return False
def _resolve_first_ip(host: str) -> str | None:
try:
info = socket.getaddrinfo(host, None)
return info[0][4][0] if info else None
except Exception: # pragma: no cover
return None
@shared_task( @shared_task(
retry_backoff=True, retry_backoff=True,
autoretry_for=(httpx.HTTPStatusError,), autoretry_for=(httpx.HTTPStatusError,),
@@ -674,11 +699,35 @@ def send_webhook(
*, *,
as_json: bool = False, as_json: bool = False,
): ):
p = urlparse(url)
if p.scheme.lower() not in settings.WEBHOOKS_ALLOWED_SCHEMES or not p.hostname:
logger.warning("Webhook blocked: invalid scheme/hostname")
raise ValueError("Invalid URL scheme or hostname.")
port = p.port or (443 if p.scheme == "https" else 80)
if (
len(settings.WEBHOOKS_ALLOWED_PORTS) > 0
and port not in settings.WEBHOOKS_ALLOWED_PORTS
):
logger.warning("Webhook blocked: port not permitted")
raise ValueError("Destination port not permitted.")
ip = _resolve_first_ip(p.hostname)
if not ip or (
not _is_public_ip(ip) and not settings.WEBHOOKS_ALLOW_INTERNAL_REQUESTS
):
logger.warning("Webhook blocked: destination not allowed")
raise ValueError("Destination host is not allowed.")
try: try:
post_args = { post_args = {
"url": url, "url": url,
"headers": headers, "headers": {
"files": files, k: v for k, v in (headers or {}).items() if k.lower() != "host"
},
"files": files or None,
"timeout": 5.0,
"follow_redirects": False,
} }
if as_json: if as_json:
post_args["json"] = data post_args["json"] = data
@@ -699,15 +748,6 @@ def send_webhook(
) )
raise e raise e
logger.info(
f"Webhook sent to {url}",
)
except Exception as e:
logger.error(
f"Failed attempt sending webhook to {url}: {e}",
)
raise e
def run_workflows( def run_workflows(
trigger_type: WorkflowTrigger.WorkflowTriggerType, trigger_type: WorkflowTrigger.WorkflowTriggerType,

View File

@@ -41,6 +41,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
title="B", title="B",
correspondent=self.c1, correspondent=self.c1,
document_type=self.dt1, document_type=self.dt1,
page_count=5,
) )
self.doc3 = Document.objects.create( self.doc3 = Document.objects.create(
checksum="C", checksum="C",
@@ -1369,6 +1370,218 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"pages must be a list of integers", response.content) self.assertIn(b"pages must be a list of integers", response.content)
@mock.patch("documents.serialisers.bulk_edit.edit_pdf")
def test_edit_pdf(self, m):
self.setup_mock(m, "edit_pdf")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "edit_pdf",
"parameters": {"operations": [{"page": 1}]},
},
),
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["operations"], [{"page": 1}])
self.assertEqual(kwargs["user"], self.user)
def test_edit_pdf_invalid_params(self):
# multiple documents
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id, self.doc3.id],
"method": "edit_pdf",
"parameters": {"operations": [{"page": 1}]},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"Edit PDF method only supports one document", response.content)
# no operations specified
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "edit_pdf",
"parameters": {},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"operations not specified", response.content)
# operations not a list
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "edit_pdf",
"parameters": {"operations": "not_a_list"},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"operations must be a list", response.content)
# invalid operation
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "edit_pdf",
"parameters": {"operations": ["invalid_operation"]},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"invalid operation entry", response.content)
# page not an int
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "edit_pdf",
"parameters": {"operations": [{"page": "not_an_int"}]},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"page must be an integer", response.content)
# rotate not an int
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "edit_pdf",
"parameters": {"operations": [{"page": 1, "rotate": "not_an_int"}]},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"rotate must be an integer", response.content)
# doc not an int
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "edit_pdf",
"parameters": {"operations": [{"page": 1, "doc": "not_an_int"}]},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"doc must be an integer", response.content)
# update_document not a boolean
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "edit_pdf",
"parameters": {
"update_document": "not_a_bool",
"operations": [{"page": 1}],
},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"update_document must be a boolean", response.content)
# include_metadata not a boolean
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "edit_pdf",
"parameters": {
"include_metadata": "not_a_bool",
"operations": [{"page": 1}],
},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"include_metadata must be a boolean", response.content)
# update_document True but output would be multiple documents
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "edit_pdf",
"parameters": {
"update_document": True,
"operations": [{"page": 1, "doc": 1}, {"page": 2, "doc": 2}],
},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(
b"update_document only allowed with a single output document",
response.content,
)
@mock.patch("documents.serialisers.bulk_edit.edit_pdf")
def test_edit_pdf_page_out_of_bounds(self, m):
"""
GIVEN:
- API data for editing PDF is called
- The page number is out of bounds
WHEN:
- API is called
THEN:
- The API fails with a correct error code
"""
self.setup_mock(m, "edit_pdf")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "edit_pdf",
"parameters": {"operations": [{"page": 99}]},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"out of bounds", response.content)
@override_settings(AUDIT_LOG_ENABLED=True) @override_settings(AUDIT_LOG_ENABLED=True)
def test_bulk_edit_audit_log_enabled_simple_field(self): def test_bulk_edit_audit_log_enabled_simple_field(self):
""" """

View File

@@ -909,3 +909,156 @@ class TestPDFActions(DirectoriesMixin, TestCase):
expected_str = "Error deleting pages from document" expected_str = "Error deleting pages from document"
self.assertIn(expected_str, error_str) self.assertIn(expected_str, error_str)
mock_update_archive_file.assert_not_called() mock_update_archive_file.assert_not_called()
@mock.patch("documents.bulk_edit.group")
@mock.patch("documents.tasks.consume_file.s")
def test_edit_pdf_basic_operations(self, mock_consume_file, mock_group):
"""
GIVEN:
- Existing document
WHEN:
- edit_pdf is called with two operations to split the doc and rotate pages
THEN:
- A grouped task is generated and delay() is called
"""
mock_group.return_value.delay.return_value = None
doc_ids = [self.doc2.id]
operations = [{"page": 1, "doc": 0}, {"page": 2, "doc": 1, "rotate": 90}]
result = bulk_edit.edit_pdf(doc_ids, operations)
self.assertEqual(result, "OK")
mock_group.return_value.delay.assert_called_once()
@mock.patch("documents.bulk_edit.group")
@mock.patch("documents.tasks.consume_file.s")
def test_edit_pdf_with_user_override(self, mock_consume_file, mock_group):
"""
GIVEN:
- Existing document
WHEN:
- edit_pdf is called with user override
THEN:
- Task is created with user context
"""
mock_group.return_value.delay.return_value = None
doc_ids = [self.doc2.id]
operations = [{"page": 1, "doc": 0}, {"page": 2, "doc": 1}]
user = User.objects.create(username="editor")
result = bulk_edit.edit_pdf(doc_ids, operations, user=user)
self.assertEqual(result, "OK")
mock_group.return_value.delay.assert_called_once()
@mock.patch("documents.bulk_edit.chord")
@mock.patch("documents.tasks.consume_file.s")
def test_edit_pdf_with_delete_original(self, mock_consume_file, mock_chord):
"""
GIVEN:
- Existing document
WHEN:
- edit_pdf is called with delete_original=True
THEN:
- Task group is triggered
"""
mock_chord.return_value.delay.return_value = None
doc_ids = [self.doc2.id]
operations = [{"page": 1}, {"page": 2}]
result = bulk_edit.edit_pdf(doc_ids, operations, delete_original=True)
self.assertEqual(result, "OK")
mock_chord.assert_called_once()
@mock.patch("documents.tasks.update_document_content_maybe_archive_file.delay")
def test_edit_pdf_with_update_document(self, mock_update_document):
"""
GIVEN:
- A single existing PDF document
WHEN:
- edit_pdf is called with update_document=True and a single output
THEN:
- The original document is updated in-place
- The update_document_content_maybe_archive_file task is triggered
"""
doc_ids = [self.doc2.id]
operations = [{"page": 1}, {"page": 2}]
original_checksum = self.doc2.checksum
original_page_count = self.doc2.page_count
result = bulk_edit.edit_pdf(
doc_ids,
operations=operations,
update_document=True,
delete_original=False,
)
self.assertEqual(result, "OK")
self.doc2.refresh_from_db()
self.assertNotEqual(self.doc2.checksum, original_checksum)
self.assertNotEqual(self.doc2.page_count, original_page_count)
mock_update_document.assert_called_once_with(document_id=self.doc2.id)
@mock.patch("documents.bulk_edit.group")
@mock.patch("documents.tasks.consume_file.s")
def test_edit_pdf_without_metadata(self, mock_consume_file, mock_group):
"""
GIVEN:
- Existing document
WHEN:
- edit_pdf is called with include_metadata=False
THEN:
- Tasks are created with empty metadata
"""
mock_group.return_value.delay.return_value = None
doc_ids = [self.doc2.id]
operations = [{"page": 1}]
result = bulk_edit.edit_pdf(doc_ids, operations, include_metadata=False)
self.assertEqual(result, "OK")
mock_group.return_value.delay.assert_called_once()
@mock.patch("documents.bulk_edit.group")
@mock.patch("documents.tasks.consume_file.s")
def test_edit_pdf_open_failure(self, mock_consume_file, mock_group):
"""
GIVEN:
- Existing document
WHEN:
- edit_pdf fails to open PDF
THEN:
- Task group is not called
"""
doc_ids = [self.doc2.id]
operations = [
{"page": 9999}, # invalid page, forces error during PDF load
]
with self.assertLogs("paperless.bulk_edit", level="ERROR"):
with self.assertRaises(Exception):
bulk_edit.edit_pdf(doc_ids, operations)
mock_group.assert_not_called()
mock_consume_file.assert_not_called()
@mock.patch("documents.bulk_edit.group")
@mock.patch("documents.tasks.consume_file.s")
def test_edit_pdf_multiple_outputs_with_update_flag_errors(
self,
mock_consume_file,
mock_group,
):
"""
GIVEN:
- Existing document
WHEN:
- edit_pdf is called with multiple outputs and update_document=True
THEN:
- An error is logged and task group is not called
"""
doc_ids = [self.doc2.id]
operations = [
{"page": 1, "doc": 0},
{"page": 2, "doc": 1},
]
with self.assertLogs("paperless.bulk_edit", level="ERROR"):
with self.assertRaises(ValueError):
bulk_edit.edit_pdf(doc_ids, operations, update_document=True)
mock_group.assert_not_called()
mock_consume_file.assert_not_called()

View File

@@ -1,8 +1,10 @@
import shutil import shutil
import socket
from datetime import timedelta from datetime import timedelta
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from unittest import mock from unittest import mock
import pytest
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test import override_settings from django.test import override_settings
@@ -10,6 +12,7 @@ from django.utils import timezone
from guardian.shortcuts import assign_perm from guardian.shortcuts import assign_perm
from guardian.shortcuts import get_groups_with_perms from guardian.shortcuts import get_groups_with_perms
from guardian.shortcuts import get_users_with_perms from guardian.shortcuts import get_users_with_perms
from httpx import HTTPError
from httpx import HTTPStatusError from httpx import HTTPStatusError
from pytest_httpx import HTTPXMock from pytest_httpx import HTTPXMock
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
@@ -2825,6 +2828,8 @@ class TestWorkflows(
content="Test message", content="Test message",
headers={}, headers={},
files=None, files=None,
follow_redirects=False,
timeout=5,
) )
expected_str = "Webhook sent to http://paperless-ngx.com" expected_str = "Webhook sent to http://paperless-ngx.com"
@@ -2842,6 +2847,8 @@ class TestWorkflows(
data={"message": "Test message"}, data={"message": "Test message"},
headers={}, headers={},
files=None, files=None,
follow_redirects=False,
timeout=5,
) )
@mock.patch("httpx.post") @mock.patch("httpx.post")
@@ -2962,3 +2969,164 @@ class TestWebhookSend:
as_json=True, as_json=True,
) )
assert httpx_mock.get_request().headers["Content-Type"] == "application/json" assert httpx_mock.get_request().headers["Content-Type"] == "application/json"
@pytest.fixture
def resolve_to(monkeypatch):
"""
Force DNS resolution to a specific IP for any hostname.
"""
def _set(ip: str):
def fake_getaddrinfo(host, *_args, **_kwargs):
return [(socket.AF_INET, None, None, "", (ip, 0))]
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
return _set
class TestWebhookSecurity:
def test_blocks_invalid_scheme_or_hostname(self, httpx_mock: HTTPXMock):
"""
GIVEN:
- Invalid URL schemes or hostnames
WHEN:
- send_webhook is called with such URLs
THEN:
- ValueError is raised
"""
with pytest.raises(ValueError):
send_webhook(
"ftp://example.com",
data="",
headers={},
files=None,
as_json=False,
)
with pytest.raises(ValueError):
send_webhook(
"http:///nohost",
data="",
headers={},
files=None,
as_json=False,
)
@override_settings(WEBHOOKS_ALLOWED_PORTS=[80, 443])
def test_blocks_disallowed_port(self, httpx_mock: HTTPXMock):
"""
GIVEN:
- URL with a disallowed port
WHEN:
- send_webhook is called with such URL
THEN:
- ValueError is raised
"""
with pytest.raises(ValueError):
send_webhook(
"http://paperless-ngx.com:8080",
data="",
headers={},
files=None,
as_json=False,
)
assert httpx_mock.get_request() is None
@override_settings(WEBHOOKS_ALLOW_INTERNAL_REQUESTS=False)
def test_blocks_private_loopback_linklocal(self, httpx_mock: HTTPXMock, resolve_to):
"""
GIVEN:
- URL with a private, loopback, or link-local IP address
- WEBHOOKS_ALLOW_INTERNAL_REQUESTS is False
WHEN:
- send_webhook is called with such URL
THEN:
- ValueError is raised
"""
resolve_to("127.0.0.1")
with pytest.raises(ValueError):
send_webhook(
"http://paperless-ngx.com",
data="",
headers={},
files=None,
as_json=False,
)
def test_allows_public_ip_and_sends(self, httpx_mock: HTTPXMock, resolve_to):
"""
GIVEN:
- URL with a public IP address
WHEN:
- send_webhook is called with such URL
THEN:
- Request is sent successfully
"""
resolve_to("52.207.186.75")
httpx_mock.add_response(content=b"ok")
send_webhook(
url="http://paperless-ngx.com",
data="hi",
headers={},
files=None,
as_json=False,
)
req = httpx_mock.get_request()
assert req.url.host == "paperless-ngx.com"
def test_follow_redirects_disabled(self, httpx_mock: HTTPXMock, resolve_to):
"""
GIVEN:
- A URL that redirects
WHEN:
- send_webhook is called with follow_redirects=False
THEN:
- Request is made to the original URL and does not follow the redirect
"""
resolve_to("52.207.186.75")
# Return a redirect and ensure we don't follow it (only one request recorded)
httpx_mock.add_response(
status_code=302,
headers={"location": "http://internal-service.local"},
content=b"",
)
with pytest.raises(HTTPError):
send_webhook(
"http://paperless-ngx.com",
data="",
headers={},
files=None,
as_json=False,
)
assert len(httpx_mock.get_requests()) == 1
def test_strips_user_supplied_host_header(self, httpx_mock: HTTPXMock, resolve_to):
"""
GIVEN:
- A URL with a user-supplied Host header
WHEN:
- send_webhook is called with a malicious Host header
THEN:
- The Host header is stripped and replaced with the resolved hostname
"""
resolve_to("52.207.186.75")
httpx_mock.add_response(content=b"ok")
send_webhook(
url="http://paperless-ngx.com",
data="ok",
headers={"Host": "evil.test"},
files=None,
as_json=False,
)
req = httpx_mock.get_request()
assert req.headers["Host"] == "paperless-ngx.com"
assert "evil.test" not in req.headers.get("Host", "")

View File

@@ -1321,6 +1321,7 @@ class BulkEditView(PassUserMixin):
"delete_pages": "checksum", "delete_pages": "checksum",
"split": None, "split": None,
"merge": None, "merge": None,
"edit_pdf": "checksum",
"reprocess": "checksum", "reprocess": "checksum",
} }
@@ -1339,6 +1340,7 @@ class BulkEditView(PassUserMixin):
if method in [ if method in [
bulk_edit.split, bulk_edit.split,
bulk_edit.merge, bulk_edit.merge,
bulk_edit.edit_pdf,
]: ]:
parameters["user"] = user parameters["user"] = user
@@ -1358,6 +1360,7 @@ class BulkEditView(PassUserMixin):
# check ownership for methods that change original document # check ownership for methods that change original document
if ( if (
(
has_perms has_perms
and method and method
in [ in [
@@ -1365,20 +1368,28 @@ class BulkEditView(PassUserMixin):
bulk_edit.delete, bulk_edit.delete,
bulk_edit.rotate, bulk_edit.rotate,
bulk_edit.delete_pages, bulk_edit.delete_pages,
bulk_edit.edit_pdf,
] ]
) or ( )
or (
method in [bulk_edit.merge, bulk_edit.split] method in [bulk_edit.merge, bulk_edit.split]
and parameters["delete_originals"] and parameters["delete_originals"]
)
or (method == bulk_edit.edit_pdf and parameters["update_document"])
): ):
has_perms = user_is_owner_of_all_documents has_perms = user_is_owner_of_all_documents
# check global add permissions for methods that create documents # check global add permissions for methods that create documents
if ( if (
has_perms has_perms
and method in [bulk_edit.split, bulk_edit.merge] and (
and not user.has_perm( method in [bulk_edit.split, bulk_edit.merge]
"documents.add_document", or (
method == bulk_edit.edit_pdf
and not parameters["update_document"]
) )
)
and not user.has_perm("documents.add_document")
): ):
has_perms = False has_perms = False
@@ -1416,7 +1427,6 @@ class BulkEditView(PassUserMixin):
) )
} }
# TODO: parameter validation
result = method(documents, **parameters) result = method(documents, **parameters)
if settings.AUDIT_LOG_ENABLED and modified_field: if settings.AUDIT_LOG_ENABLED and modified_field:

View File

@@ -2,7 +2,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: paperless-ngx\n" "Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-08-02 12:55+0000\n" "POT-Creation-Date: 2025-08-11 17:31+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n" "PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: English\n" "Language-Team: English\n"
@@ -1185,12 +1185,12 @@ msgstr ""
msgid "Invalid color." msgid "Invalid color."
msgstr "" msgstr ""
#: documents/serialisers.py:1649 #: documents/serialisers.py:1700
#, python-format #, python-format
msgid "File type %(type)s not supported" msgid "File type %(type)s not supported"
msgstr "" msgstr ""
#: documents/serialisers.py:1743 #: documents/serialisers.py:1794
msgid "Invalid variable detected." msgid "Invalid variable detected."
msgstr "" msgstr ""

View File

@@ -1423,10 +1423,31 @@ OUTLOOK_OAUTH_ENABLED = bool(
and OUTLOOK_OAUTH_CLIENT_SECRET, and OUTLOOK_OAUTH_CLIENT_SECRET,
) )
###############################################################################
# Webhooks
###############################################################################
WEBHOOKS_ALLOWED_SCHEMES = set(
s.lower()
for s in __get_list(
"PAPERLESS_WEBHOOKS_ALLOWED_SCHEMES",
["http", "https"],
)
)
WEBHOOKS_ALLOWED_PORTS = set(
int(p)
for p in __get_list(
"PAPERLESS_WEBHOOKS_ALLOWED_PORTS",
[],
)
)
WEBHOOKS_ALLOW_INTERNAL_REQUESTS = __get_boolean(
"PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS",
"true",
)
############################################################################### ###############################################################################
# Remote Parser # # Remote Parser #
############################################################################### ###############################################################################
REMOTE_OCR_ENGINE = os.getenv("PAPERLESS_REMOTE_OCR_ENGINE") REMOTE_OCR_ENGINE = os.getenv("PAPERLESS_REMOTE_OCR_ENGINE")
REMOTE_OCR_API_KEY = os.getenv("PAPERLESS_REMOTE_OCR_API_KEY") REMOTE_OCR_API_KEY = os.getenv("PAPERLESS_REMOTE_OCR_API_KEY")
REMOTE_OCR_ENDPOINT = os.getenv("PAPERLESS_REMOTE_OCR_ENDPOINT") REMOTE_OCR_ENDPOINT = os.getenv("PAPERLESS_REMOTE_OCR_ENDPOINT")