Compare commits

..
Author SHA1 Message Date
shamoon 3508877a33 Update api.md 2026-11-14 12:31:08 -08:00
shamoon fea012d5c0 Apply to the rest of the operations 2026-11-14 12:30:48 -08:00
shamoon e2aa45da4e Fix a couple dispatched renames 2026-11-14 12:21:25 -08:00
shamoon 5ad0773446 Buld this parent task into the model, dispatched. Hook up for reprocess 2026-11-14 12:16:02 -08:00
shamoon 13202f52ad Raise reprocess errors 2026-11-14 11:54:40 -08:00
shamoon 9a9d4284d7 Couple more things for sonar 2026-11-14 11:51:42 -08:00
shamoon 6043dd1c81 Be more careful about how we handle errors wrt open docs 2026-11-14 11:51:42 -08:00
shamoon 058e27e3ef Reprot more accurate notification for tasks with follow-ups 2026-11-14 11:51:42 -08:00
shamoon 6f5a192967 Propagate these errors now so they make it to the task 2026-11-14 11:51:41 -08:00
shamoon bb775a16c1 Move the merge as version heirarchy checks to the bulk edit method 2026-11-14 11:51:41 -08:00
shamoon 6d26d3f5ca Sonar loves a readonly class member 2026-11-14 10:53:49 -08:00
shamoon 2131feac73 Random test cleanup stuff 2026-11-14 09:36:10 -08:00
shamoon e2705768b4 Make the popups more helpful 2026-11-14 09:34:01 -08:00
shamoon ce3c4e0855 Yay make the task list useful 2026-11-14 09:34:01 -08:00
shamoon 3a241e51d9 Better info for bulk tasks 2026-11-14 09:34:01 -08:00
shamoon 020a2c8fc0 Dont show view task on success popups 2026-11-14 09:34:00 -08:00
shamoon b533bfe4b5 We need a tasks notification service 2026-11-14 09:34:00 -08:00
shamoon 813dfe487f We have to make this generic-er 2026-11-14 09:34:00 -08:00
shamoon 557afa6454 Move doc detail actions over 2026-11-14 09:33:59 -08:00
shamoon c72473f7bc Fix delete and undo 2026-11-14 09:33:59 -08:00
shamoon 7d79b544ae use the queued operation pathway across teh frontend 2026-11-14 09:33:58 -08:00
shamoon a9fef2b931 Update the api docs 2026-11-14 09:33:58 -08:00
shamoon adbd7561d4 Tests for the actions 2026-11-14 09:33:58 -08:00
shamoon 9f694cdd2d queue the dedicated document actions 2026-11-14 09:33:57 -08:00
shamoon 7d78716069 and add some 2026-11-14 09:33:57 -08:00
shamoon 9523d158a8 Fix the old tests 2026-11-14 09:33:57 -08:00
shamoon 91316df9b7 Move the other bulk edit actions through the new taks tracking route 2026-11-14 09:33:56 -08:00
shamoon 7e67013c42 Ensure from_webui 2026-11-14 09:33:56 -08:00
shamoon 2a0c1249fa Raise error on trash deletion so it can be tracked 2026-11-14 09:33:56 -08:00
shamoon 4610171a94 Chore (dev): add tsconfig missing types (#14771) 2026-11-14 09:33:39 -08:00
GitHub Actions cf2e2bde71 Auto translate strings 2026-11-14 07:33:25 +00:00
shamoon aa669aca89 Fix: get users for saved view create dialog (#14769) 2026-11-13 23:31:27 -08:00
GitHub Actions b42bf50b7e Auto translate strings 2026-11-13 21:15:07 +00:00
shamoon d31110278b Fix: ensure ownership for manual WF runs (#14762) 2026-11-13 21:13:34 +00:00
GitHub Actions 5d98a5e5c8 Auto translate strings 2026-11-13 19:11:36 +00:00
shamoon fc8c4e91ec Fix: dont expect task ID for manual WF run in bulk dialog (#14763) 2026-11-13 11:10:06 -08:00
GitHub Actions e746e008a0 Auto translate strings 2026-11-13 18:15:40 +00:00
shamoon 897f6a9e5a Tweak: alter wording, force default for single manual wf dialog (#14760) 2026-11-13 10:14:23 -08:00
shamoon 694619c33e Fix: add missing css from notes markdown PR 2026-11-13 09:57:16 -08:00
github-actions[bot]andCrowdin Bot 1f2faaec86 New Crowdin translations by GitHub Action (#14683)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-11-13 09:36:19 -08:00
GitHub Actions 6b15d625ed Auto translate strings 2026-11-13 17:10:14 +00:00
192 changed files with 49795 additions and 47425 deletions

No files matched your search

+8 -2
View File
@@ -285,8 +285,14 @@ The API supports various bulk-editing operations which are executed asynchronous
### Documents ### Documents
For bulk operations on documents, use the endpoint `/api/documents/bulk_edit/` which accepts Document bulk operations return `{"result":"OK","task_id":"…"}` when queued.
a json payload of the format: Check `/api/tasks/?task_id={uuid}` for the outcome, including any follow-up processing,
deletion, or recovery. Failed operations may leave partial changes applied.
For operations with child tasks, `result_data` includes `succeeded_count` and
`failed_count` (task counts). Merges report skipped inputs in `failed_document_ids`.
The `/api/documents/bulk_edit/` endpoint accepts:
```json ```json
{ {
+5 -85
View File
@@ -109,7 +109,7 @@ Think of versions as **file history** for a document.
Paperless-ngx includes management lists for tags, correspondents, document types Paperless-ngx includes management lists for tags, correspondents, document types
and more. These areas allow you to view, add, edit, delete and manage permissions and more. These areas allow you to view, add, edit, delete and manage permissions
for these objects. You can also manage saved views, mail accounts, mail rules, for these objects. You can also manage saved views, mail accounts, mail rules,
workflows, [OCR templates](#ocr-templates) and more from the management sections. workflows and more from the management sections.
### Nested Tags ### Nested Tags
@@ -587,9 +587,10 @@ documents from the web UI:
- From the document list, select one or more documents and choose **Run workflow** from the "Actions" menu. - From the document list, select one or more documents and choose **Run workflow** from the "Actions" menu.
- From an individual document, choose **Run workflow** from the "Actions" menu. - From an individual document, choose **Run workflow** from the "Actions" menu.
You will be asked which workflow to run. By default the workflow's trigger filters still apply, so documents that do You will be asked which workflow to run. From the document list, the workflow's trigger filters apply by default, so
not match are skipped. You can instead choose to **run on all selected documents**, which ignores the trigger filters documents that do not match are skipped. You can choose to **run on all selected documents** to ignore the filters.
and runs the workflow's actions on every selected document. From an individual document, the option to ignore trigger filters is selected by default. Clear it to run the workflow
only if the document matches its trigger filters.
Manual runs happen in the background. As with scheduled workflows, the document list is not refreshed automatically when Manual runs happen in the background. As with scheduled workflows, the document list is not refreshed automatically when
they finish, so you may need to reload the list to see the results. they finish, so you may need to reload the list to see the results.
@@ -820,87 +821,6 @@ The following custom field types are supported:
- `Document Link`: reference(s) to other document(s) displayed as links, automatically creates a symmetrical link in reverse - `Document Link`: reference(s) to other document(s) displayed as links, automatically creates a symmetrical link in reverse
- `Select`: a pre-defined list of strings from which the user can choose - `Select`: a pre-defined list of strings from which the user can choose
## OCR Templates {#ocr-templates}
OCR templates read text from fixed areas ("zones") of a document and write it to a
[custom field](#custom-fields), or to the document's title, archive serial number or created date. They are
useful for documents that always share the same layout, such as bills from a particular utility. They allow you
to draw a zone around the account number once, and every new bill gets its account number filled in.
Like tags or document types, each template has a [matching algorithm](advanced_usage.md#matching) that
decides which documents it automatically applies to (or not), based on their content.
### Creating a template
1. Open **OCR Templates** under "Manage" and choose **Add Template**.
2. Give the template a name and set up its matching, for example **Any word** with the utility's name. Choose
**None** for a template that should only ever be run manually.
3. Search for a sample document with the layout the template is for. Its pages are shown next to the template
settings.
4. Draw zones on the document. Click a zone to select and edit it.
5. Choose the field each zone fills.
6. Use **Test** to run OCR on the selected zone of the sample document and see the text it finds and the value
that would be written.
Zones are positioned relative to the sample document's page, so templates work best when the documents they
match have an identical layout. Keep the matching specific enough that it only catches documents with that
layout.
### Zone settings
- **Page**: the page the zone is on. Turn on **Last page** for zones that are always on the final page,
regardless of how many pages a document has.
- **OCR language**: the Tesseract languages used to read the zone. Languages other than the defaults must be
installed, see [`PAPERLESS_OCR_LANGUAGES`](configuration.md#PAPERLESS_OCR_LANGUAGES).
- **Transform**: post-processing applied to the text, such as stripping whitespace, keeping only numbers,
parsing a date or reading a QR code or barcode instead of text.
- **Date format**: for the "Parse date" transform, the format of the date using
[Python date codes](https://docs.python.org/3/library/datetime.html#format-codes), e.g. `%d.%m.%Y`.
Leave it empty to detect the format automatically.
- **Validation regex**: a regular expression the whole value must match. Values that do not match are not
written.
Zones can fill custom fields of type text, long text, URL, date, integer, number, monetary or boolean.
Document link and select fields are not supported.
### Combining zones
When several zones fill the same field, a **Combined value** setting appears for that field. Use it to arrange
the zones' values with `{Zone name}` tokens and any text in between, for example `{Start} - {End}`. If it
is left empty, the values are joined with spaces in zone order.
### When templates run
When a document is added, every enabled template that matches its content runs automatically. A template is
only used for a document if the document's owner can view the template, so users' templates apply to their own
documents, and unowned templates apply to everyone's. Templates run in name order, so if two templates fill the
same field, the later one wins.
Turn off **Enabled** to pause a template without deleting it. Disabled templates never run automatically, but
can still be run manually.
### Running templates manually
To run templates on documents that are already in Paperless-ngx, open the **Actions** menu on the document
details page:
- **Run Zone OCR** runs every enabled template that matches the document.
- The button next to it opens a dialog where you can choose one template to run
instead. A template you choose runs even if it does not match the document or is disabled.
Both actions use the file version selected in document details. The latest version is selected by default.
Extracted values update the document's metadata, which is shared across its versions.
The bulk editor's **Run Zone OCR** action opens the same dialog for the selected documents. Bulk runs use
the latest version of each document and are processed in the background.
!!! warning
Zone OCR overwrites the fields it fills. A zone that finds no value leaves its field unchanged.
Managing OCR templates requires the OCR template permissions. Running zone OCR on documents requires
permission to change them, and choosing a template requires permission to view it.
## PDF Actions ## PDF Actions
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 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
+149 -88
View File
@@ -1366,7 +1366,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">1971</context> <context context-type="linenumber">1973</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1577733187050997705" datatype="html"> <trans-unit id="1577733187050997705" datatype="html">
@@ -1940,6 +1940,10 @@
<context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.ts</context>
<context context-type="linenumber">45</context> <context context-type="linenumber">45</context>
</context-group> </context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.ts</context>
<context context-type="linenumber">59</context>
</context-group>
</trans-unit> </trans-unit>
<trans-unit id="3066660568529853846" datatype="html"> <trans-unit id="3066660568529853846" datatype="html">
<source>Error retrieving groups</source> <source>Error retrieving groups</source>
@@ -3282,6 +3286,10 @@
<context context-type="sourcefile">src/app/components/common/input/permissions/permissions-form/permissions-form.component.html</context> <context context-type="sourcefile">src/app/components/common/input/permissions/permissions-form/permissions-form.component.html</context>
<context context-type="linenumber">55,56</context> <context context-type="linenumber">55,56</context>
</context-group> </context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-notes/document-notes.component.html</context>
<context context-type="linenumber">25,26</context>
</context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context> <context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
<context context-type="linenumber">31,32</context> <context context-type="linenumber">31,32</context>
@@ -3416,23 +3424,23 @@
</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">1972</context> <context context-type="linenumber">1974</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>
<context context-type="linenumber">1019</context> <context context-type="linenumber">1034</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>
<context context-type="linenumber">1058</context> <context context-type="linenumber">1073</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>
<context context-type="linenumber">1081</context> <context context-type="linenumber">1096</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>
<context context-type="linenumber">1117</context> <context context-type="linenumber">1132</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.ts</context> <context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.ts</context>
@@ -4195,27 +4203,27 @@
</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>
<context context-type="linenumber">637</context> <context context-type="linenumber">652</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>
<context context-type="linenumber">677</context> <context context-type="linenumber">692</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>
<context context-type="linenumber">715</context> <context context-type="linenumber">730</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>
<context context-type="linenumber">753</context> <context context-type="linenumber">768</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>
<context context-type="linenumber">815</context> <context context-type="linenumber">830</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>
<context context-type="linenumber">953</context> <context context-type="linenumber">968</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6088768251338567957" datatype="html"> <trans-unit id="6088768251338567957" datatype="html">
@@ -4335,7 +4343,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">2025</context> <context context-type="linenumber">2027</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6661109599266152398" datatype="html"> <trans-unit id="6661109599266152398" datatype="html">
@@ -4346,7 +4354,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">2026</context> <context context-type="linenumber">2028</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5162686434580248853" datatype="html"> <trans-unit id="5162686434580248853" datatype="html">
@@ -4357,7 +4365,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">2027</context> <context context-type="linenumber">2029</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6665634854532231106" datatype="html"> <trans-unit id="6665634854532231106" datatype="html">
@@ -4409,18 +4417,32 @@
<context context-type="linenumber">13,14</context> <context context-type="linenumber">13,14</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="261810320774538615" datatype="html">
<source>Run on this document regardless of trigger filters</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component.html</context>
<context context-type="linenumber">22,23</context>
</context-group>
</trans-unit>
<trans-unit id="2322522720854680528" datatype="html">
<source>Ignore the workflow&apos;s trigger filters for this document.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component.html</context>
<context context-type="linenumber">23,24</context>
</context-group>
</trans-unit>
<trans-unit id="4512110365422311821" datatype="html"> <trans-unit id="4512110365422311821" datatype="html">
<source>Run on all selected documents</source> <source>Run on all selected documents</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component.html</context>
<context context-type="linenumber">21,22</context> <context context-type="linenumber">25,26</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6575910678008555513" datatype="html"> <trans-unit id="6575910678008555513" datatype="html">
<source>Ignore the workflow&apos;s trigger filters and run on every selected document.</source> <source>Ignore the workflow&apos;s trigger filters and run on every selected document.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component.html</context>
<context context-type="linenumber">22,23</context> <context context-type="linenumber">26,27</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2509141182388535183" datatype="html"> <trans-unit id="2509141182388535183" datatype="html">
@@ -5763,6 +5785,10 @@
<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">358,359</context> <context context-type="linenumber">358,359</context>
</context-group> </context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-notes/document-notes.component.html</context>
<context context-type="linenumber">27,28</context>
</context-group>
</trans-unit> </trans-unit>
<trans-unit id="8057014866157903311" datatype="html"> <trans-unit id="8057014866157903311" datatype="html">
<source>Path test failed</source> <source>Path test failed</source>
@@ -6646,7 +6672,7 @@
</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>
<context context-type="linenumber">957</context> <context context-type="linenumber">972</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1616074213645042277" datatype="html"> <trans-unit id="1616074213645042277" datatype="html">
@@ -8423,7 +8449,7 @@
</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>
<context context-type="linenumber">582</context> <context context-type="linenumber">597</context>
</context-group> </context-group>
<note priority="1" from="description">this string is used to separate processing, failed and added on the file upload widget</note> <note priority="1" from="description">this string is used to separate processing, failed and added on the file upload widget</note>
</trans-unit> </trans-unit>
@@ -8752,7 +8778,7 @@
</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>
<context context-type="linenumber">318</context> <context context-type="linenumber">320</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7049887240439736400" datatype="html"> <trans-unit id="7049887240439736400" datatype="html">
@@ -9239,7 +9265,7 @@
</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>
<context context-type="linenumber">955</context> <context context-type="linenumber">970</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5992101894268263750" datatype="html"> <trans-unit id="5992101894268263750" datatype="html">
@@ -9264,7 +9290,7 @@
</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>
<context context-type="linenumber">1015</context> <context context-type="linenumber">1030</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2951161989614003846" datatype="html"> <trans-unit id="2951161989614003846" datatype="html">
@@ -9296,7 +9322,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">1607</context> <context context-type="linenumber">1609</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="838042392151392247" datatype="html"> <trans-unit id="838042392151392247" datatype="html">
@@ -9314,88 +9340,88 @@
</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>
<context context-type="linenumber">320</context> <context context-type="linenumber">322</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="523213570140181690" datatype="html"> <trans-unit id="523213570140181690" datatype="html">
<source>Workflow will run on &quot;<x id="PH" equiv-text="this.document().title"/>&quot; in the background.</source> <source>Workflow will run on &quot;<x id="PH" equiv-text="this.document().title"/>&quot; 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">1600</context> <context context-type="linenumber">1602</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">1671</context> <context context-type="linenumber">1673</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">1749</context> <context context-type="linenumber">1751</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4663705961777238777" datatype="html"> <trans-unit id="4663705961777238777" datatype="html">
<source>PDF edit operation for &quot;<x id="PH" equiv-text="this.document().title"/>&quot; will begin in the background.</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">1992</context> <context context-type="linenumber">1994</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="9043972994040261999" datatype="html"> <trans-unit id="9043972994040261999" datatype="html">
<source>Error executing PDF edit operation</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">2004</context> <context context-type="linenumber">2006</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6172690334763056188" datatype="html"> <trans-unit id="6172690334763056188" datatype="html">
<source>Please enter the current password before attempting to remove it.</source> <source>Please enter the current password before attempting to remove it.</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">2015</context> <context context-type="linenumber">2017</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="968660764814228922" datatype="html"> <trans-unit id="968660764814228922" datatype="html">
<source>Password removal operation for &quot;<x id="PH" equiv-text="this.document().title"/>&quot; will begin in the background.</source> <source>Password removal 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">2049</context> <context context-type="linenumber">2051</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2282118435712883014" datatype="html"> <trans-unit id="2282118435712883014" datatype="html">
<source>Error executing password removal operation</source> <source>Error executing password removal 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">2063</context> <context context-type="linenumber">2065</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3740891324955700797" datatype="html"> <trans-unit id="3740891324955700797" datatype="html">
<source>Print failed.</source> <source>Print failed.</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">2113</context> <context context-type="linenumber">2115</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6457245677384603573" datatype="html"> <trans-unit id="6457245677384603573" datatype="html">
<source>Error loading document for printing.</source> <source>Error loading document for printing.</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">2122</context> <context context-type="linenumber">2124</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">2205</context> <context context-type="linenumber">2207</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">2211</context> <context context-type="linenumber">2213</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4958946940233632319" datatype="html"> <trans-unit id="4958946940233632319" datatype="html">
@@ -9679,7 +9705,7 @@
</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>
<context context-type="linenumber">1115</context> <context context-type="linenumber">1130</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7251533806673754434" datatype="html"> <trans-unit id="7251533806673754434" datatype="html">
@@ -9714,46 +9740,53 @@
<source>Run a workflow on <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</source> <source>Run a workflow on <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<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>
<context context-type="linenumber">319</context> <context context-type="linenumber">321</context>
</context-group>
</trans-unit>
<trans-unit id="8161885405686762788" datatype="html">
<source>Workflow will run on the selected documents in the background.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">339</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3401633333450192408" datatype="html"> <trans-unit id="3401633333450192408" datatype="html">
<source>Bulk operation queued.</source> <source>Bulk operation queued.</source>
<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>
<context context-type="linenumber">358</context> <context context-type="linenumber">373</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5709555629190115111" datatype="html"> <trans-unit id="5709555629190115111" datatype="html">
<source>View task</source> <source>View task</source>
<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>
<context context-type="linenumber">362</context> <context context-type="linenumber">377</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1215215387232313677" datatype="html"> <trans-unit id="1215215387232313677" datatype="html">
<source>Error executing bulk operation</source> <source>Error executing bulk operation</source>
<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>
<context context-type="linenumber">414</context> <context context-type="linenumber">429</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7894972847287473517" datatype="html"> <trans-unit id="7894972847287473517" datatype="html">
<source>&quot;<x id="PH" equiv-text="items[0].name"/>&quot;</source> <source>&quot;<x id="PH" equiv-text="items[0].name"/>&quot;</source>
<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>
<context context-type="linenumber">574</context> <context context-type="linenumber">589</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>
<context context-type="linenumber">580</context> <context context-type="linenumber">595</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8639884465898458690" datatype="html"> <trans-unit id="8639884465898458690" datatype="html">
<source>&quot;<x id="PH" equiv-text="items[0].name"/>&quot; and &quot;<x id="PH_1" equiv-text="items[1].name"/>&quot;</source> <source>&quot;<x id="PH" equiv-text="items[0].name"/>&quot; and &quot;<x id="PH_1" equiv-text="items[1].name"/>&quot;</source>
<context-group purpose="location"> <context-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>
<context context-type="linenumber">576</context> <context context-type="linenumber">591</context>
</context-group> </context-group>
<note priority="1" from="description">This is for messages like &apos;modify &quot;tag1&quot; and &quot;tag2&quot;&apos;</note> <note priority="1" from="description">This is for messages like &apos;modify &quot;tag1&quot; and &quot;tag2&quot;&apos;</note>
</trans-unit> </trans-unit>
@@ -9761,7 +9794,7 @@
<source><x id="PH" equiv-text="list"/> and &quot;<x id="PH_1" equiv-text="items[items.length - 1].name"/>&quot;</source> <source><x id="PH" equiv-text="list"/> and &quot;<x id="PH_1" equiv-text="items[items.length - 1].name"/>&quot;</source>
<context-group purpose="location"> <context-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>
<context context-type="linenumber">584,586</context> <context context-type="linenumber">599,601</context>
</context-group> </context-group>
<note priority="1" from="description">this is for messages like &apos;modify &quot;tag1&quot;, &quot;tag2&quot; and &quot;tag3&quot;&apos;</note> <note priority="1" from="description">this is for messages like &apos;modify &quot;tag1&quot;, &quot;tag2&quot; and &quot;tag3&quot;&apos;</note>
</trans-unit> </trans-unit>
@@ -9769,14 +9802,14 @@
<source>Confirm tags assignment</source> <source>Confirm tags assignment</source>
<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>
<context context-type="linenumber">601</context> <context context-type="linenumber">616</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6619516195038467207" datatype="html"> <trans-unit id="6619516195038467207" datatype="html">
<source>This operation will add the tag &quot;<x id="PH" equiv-text="tag.name"/>&quot; to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source> <source>This operation will add the tag &quot;<x id="PH" equiv-text="tag.name"/>&quot; to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<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>
<context context-type="linenumber">607</context> <context context-type="linenumber">622</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1894412783609570695" datatype="html"> <trans-unit id="1894412783609570695" datatype="html">
@@ -9785,14 +9818,14 @@
)"/> to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source> )"/> to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<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>
<context context-type="linenumber">612,614</context> <context context-type="linenumber">627,629</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7181166515756808573" datatype="html"> <trans-unit id="7181166515756808573" datatype="html">
<source>This operation will remove the tag &quot;<x id="PH" equiv-text="tag.name"/>&quot; from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source> <source>This operation will remove the tag &quot;<x id="PH" equiv-text="tag.name"/>&quot; from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<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>
<context context-type="linenumber">620</context> <context context-type="linenumber">635</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3819792277998068944" datatype="html"> <trans-unit id="3819792277998068944" datatype="html">
@@ -9801,7 +9834,7 @@
)"/> from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source> )"/> from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<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>
<context context-type="linenumber">625,627</context> <context context-type="linenumber">640,642</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2739066218579571288" datatype="html"> <trans-unit id="2739066218579571288" datatype="html">
@@ -9812,84 +9845,84 @@
)"/> on <x id="PH_2" equiv-text="this.getSelectionSize()"/> selected document(s).</source> )"/> on <x id="PH_2" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<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>
<context context-type="linenumber">629,633</context> <context context-type="linenumber">644,648</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2996713129519325161" datatype="html"> <trans-unit id="2996713129519325161" datatype="html">
<source>Confirm correspondent assignment</source> <source>Confirm correspondent assignment</source>
<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>
<context context-type="linenumber">670</context> <context context-type="linenumber">685</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6900893559485781849" datatype="html"> <trans-unit id="6900893559485781849" datatype="html">
<source>This operation will assign the correspondent &quot;<x id="PH" equiv-text="correspondent.name"/>&quot; to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source> <source>This operation will assign the correspondent &quot;<x id="PH" equiv-text="correspondent.name"/>&quot; to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<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>
<context context-type="linenumber">672</context> <context context-type="linenumber">687</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1257522660364398440" datatype="html"> <trans-unit id="1257522660364398440" datatype="html">
<source>This operation will remove the correspondent from <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</source> <source>This operation will remove the correspondent from <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<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>
<context context-type="linenumber">674</context> <context context-type="linenumber">689</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5393409374423140648" datatype="html"> <trans-unit id="5393409374423140648" datatype="html">
<source>Confirm document type assignment</source> <source>Confirm document type assignment</source>
<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>
<context context-type="linenumber">708</context> <context context-type="linenumber">723</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="332180123895325027" datatype="html"> <trans-unit id="332180123895325027" datatype="html">
<source>This operation will assign the document type &quot;<x id="PH" equiv-text="documentType.name"/>&quot; to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source> <source>This operation will assign the document type &quot;<x id="PH" equiv-text="documentType.name"/>&quot; to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<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>
<context context-type="linenumber">710</context> <context context-type="linenumber">725</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2236642492594872779" datatype="html"> <trans-unit id="2236642492594872779" datatype="html">
<source>This operation will remove the document type from <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</source> <source>This operation will remove the document type from <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<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>
<context context-type="linenumber">712</context> <context context-type="linenumber">727</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6386555513013840736" datatype="html"> <trans-unit id="6386555513013840736" datatype="html">
<source>Confirm storage path assignment</source> <source>Confirm storage path assignment</source>
<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>
<context context-type="linenumber">746</context> <context context-type="linenumber">761</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8750527458618415924" datatype="html"> <trans-unit id="8750527458618415924" datatype="html">
<source>This operation will assign the storage path &quot;<x id="PH" equiv-text="storagePath.name"/>&quot; to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source> <source>This operation will assign the storage path &quot;<x id="PH" equiv-text="storagePath.name"/>&quot; to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<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>
<context context-type="linenumber">748</context> <context context-type="linenumber">763</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="60728365335056946" datatype="html"> <trans-unit id="60728365335056946" datatype="html">
<source>This operation will remove the storage path from <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</source> <source>This operation will remove the storage path from <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<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>
<context context-type="linenumber">750</context> <context context-type="linenumber">765</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4187352575310415704" datatype="html"> <trans-unit id="4187352575310415704" datatype="html">
<source>Confirm custom field assignment</source> <source>Confirm custom field assignment</source>
<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>
<context context-type="linenumber">779</context> <context context-type="linenumber">794</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7966494636326273856" datatype="html"> <trans-unit id="7966494636326273856" datatype="html">
<source>This operation will assign the custom field &quot;<x id="PH" equiv-text="customField.name"/>&quot; to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source> <source>This operation will assign the custom field &quot;<x id="PH" equiv-text="customField.name"/>&quot; to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<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>
<context context-type="linenumber">785</context> <context context-type="linenumber">800</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5789455969634598553" datatype="html"> <trans-unit id="5789455969634598553" datatype="html">
@@ -9898,14 +9931,14 @@
)"/> to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source> )"/> to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<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>
<context context-type="linenumber">790,792</context> <context context-type="linenumber">805,807</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5648572354333199245" datatype="html"> <trans-unit id="5648572354333199245" datatype="html">
<source>This operation will remove the custom field &quot;<x id="PH" equiv-text="customField.name"/>&quot; from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source> <source>This operation will remove the custom field &quot;<x id="PH" equiv-text="customField.name"/>&quot; from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<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>
<context context-type="linenumber">798</context> <context context-type="linenumber">813</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6666899594015948817" datatype="html"> <trans-unit id="6666899594015948817" datatype="html">
@@ -9914,7 +9947,7 @@
)"/> from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source> )"/> from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<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>
<context context-type="linenumber">803,805</context> <context context-type="linenumber">818,820</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8050047262594964176" datatype="html"> <trans-unit id="8050047262594964176" datatype="html">
@@ -9925,105 +9958,105 @@
)"/> on <x id="PH_2" equiv-text="this.getSelectionSize()"/> selected document(s).</source> )"/> on <x id="PH_2" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<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>
<context context-type="linenumber">807,811</context> <context context-type="linenumber">822,826</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8615059324209654051" datatype="html"> <trans-unit id="8615059324209654051" datatype="html">
<source>Move <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s) to the trash?</source> <source>Move <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s) to the trash?</source>
<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>
<context context-type="linenumber">954</context> <context context-type="linenumber">969</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="135114644550399974" datatype="html"> <trans-unit id="135114644550399974" datatype="html">
<source>Moved <x id="PH" equiv-text="count"/> document(s) to the trash</source> <source>Moved <x id="PH" equiv-text="count"/> document(s) to the trash</source>
<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>
<context context-type="linenumber">981</context> <context context-type="linenumber">996</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8585195717323764335" datatype="html"> <trans-unit id="8585195717323764335" datatype="html">
<source>This operation will permanently recreate the archive files for <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</source> <source>This operation will permanently recreate the archive files for <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<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>
<context context-type="linenumber">1016</context> <context context-type="linenumber">1031</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7366623494074776040" datatype="html"> <trans-unit id="7366623494074776040" datatype="html">
<source>The archive files will be re-generated with the current settings.</source> <source>The archive files 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-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">1017</context> <context context-type="linenumber">1032</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6555329262222566158" datatype="html"> <trans-unit id="6555329262222566158" datatype="html">
<source>Rotate confirm</source> <source>Rotate confirm</source>
<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>
<context context-type="linenumber">1055</context> <context context-type="linenumber">1070</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5203024009814367559" datatype="html"> <trans-unit id="5203024009814367559" datatype="html">
<source>This operation will add rotated versions of the <x id="PH" equiv-text="this.getSelectionSize()"/> document(s).</source> <source>This operation will add rotated versions of the <x id="PH" equiv-text="this.getSelectionSize()"/> document(s).</source>
<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>
<context context-type="linenumber">1056</context> <context context-type="linenumber">1071</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7910756456450124185" datatype="html"> <trans-unit id="7910756456450124185" datatype="html">
<source>Merge confirm</source> <source>Merge confirm</source>
<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>
<context context-type="linenumber">1079</context> <context context-type="linenumber">1094</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7643543647233874431" datatype="html"> <trans-unit id="7643543647233874431" datatype="html">
<source>This operation will merge <x id="PH" equiv-text="this.getSelectionSize()"/> selected documents into a new document.</source> <source>This operation will merge <x id="PH" equiv-text="this.getSelectionSize()"/> selected documents into a new document.</source>
<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>
<context context-type="linenumber">1080</context> <context context-type="linenumber">1095</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7869008840945899895" datatype="html"> <trans-unit id="7869008840945899895" datatype="html">
<source>Merged document will be queued for consumption.</source> <source>Merged document will be queued for consumption.</source>
<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>
<context context-type="linenumber">1103</context> <context context-type="linenumber">1118</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1549333914563613027" datatype="html"> <trans-unit id="1549333914563613027" datatype="html">
<source>The selected documents will become versions of the root document.</source> <source>The selected documents will become versions of the root document.</source>
<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>
<context context-type="linenumber">1116</context> <context context-type="linenumber">1131</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7099667366799629283" datatype="html"> <trans-unit id="7099667366799629283" datatype="html">
<source>Documents merged as versions.</source> <source>Documents merged as versions.</source>
<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>
<context context-type="linenumber">1131</context> <context context-type="linenumber">1146</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3873496751167944011" datatype="html"> <trans-unit id="3873496751167944011" datatype="html">
<source>Error updating custom fields.</source> <source>Error updating custom fields.</source>
<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>
<context context-type="linenumber">1161</context> <context context-type="linenumber">1176</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6144801143088984138" datatype="html"> <trans-unit id="6144801143088984138" datatype="html">
<source>Share link bundle creation requested.</source> <source>Share link bundle creation requested.</source>
<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>
<context context-type="linenumber">1209</context> <context context-type="linenumber">1224</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="46019676931295023" datatype="html"> <trans-unit id="46019676931295023" datatype="html">
<source>Share link bundle creation is not available yet.</source> <source>Share link bundle creation is not available yet.</source>
<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>
<context context-type="linenumber">1216</context> <context context-type="linenumber">1231</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6307402210351946694" datatype="html"> <trans-unit id="6307402210351946694" datatype="html">
@@ -10830,50 +10863,78 @@
<context context-type="linenumber">23</context> <context context-type="linenumber">23</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1044349881182559852" datatype="html"> <trans-unit id="4388879716045736175" datatype="html">
<source>Enter note</source> <source>Note</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-notes/document-notes.component.html</context> <context context-type="sourcefile">src/app/components/document-notes/document-notes.component.html</context>
<context context-type="linenumber">5</context> <context context-type="linenumber">5</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8039873301454218547" datatype="html">
<source>Enter note (Markdown is supported)</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-notes/document-notes.component.html</context>
<context context-type="linenumber">5</context>
</context-group>
</trans-unit>
<trans-unit id="2713764221056344068" datatype="html">
<source>Note preview</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-notes/document-notes.component.html</context>
<context context-type="linenumber">7</context>
</context-group>
</trans-unit>
<trans-unit id="7604794618690171820" datatype="html">
<source>Nothing to preview</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-notes/document-notes.component.html</context>
<context context-type="linenumber">11,12</context>
</context-group>
</trans-unit>
<trans-unit id="7770536883443596194" datatype="html"> <trans-unit id="7770536883443596194" datatype="html">
<source> Please enter a note. </source> <source> Please enter a note. </source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-notes/document-notes.component.html</context> <context context-type="sourcefile">src/app/components/document-notes/document-notes.component.html</context>
<context context-type="linenumber">7</context> <context context-type="linenumber">16,17</context>
</context-group>
</trans-unit>
<trans-unit id="3124852678467222928" datatype="html">
<source>Note input mode</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-notes/document-notes.component.html</context>
<context context-type="linenumber">23</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8433732438274024544" datatype="html"> <trans-unit id="8433732438274024544" datatype="html">
<source>Add note</source> <source>Add note</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-notes/document-notes.component.html</context> <context context-type="sourcefile">src/app/components/document-notes/document-notes.component.html</context>
<context context-type="linenumber">14,15</context> <context context-type="linenumber">29,30</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8428006099054244235" datatype="html"> <trans-unit id="8428006099054244235" datatype="html">
<source>Delete note</source> <source>Delete note</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-notes/document-notes.component.html</context> <context context-type="sourcefile">src/app/components/document-notes/document-notes.component.html</context>
<context context-type="linenumber">25</context> <context context-type="linenumber">46</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-notes/document-notes.component.html</context> <context context-type="sourcefile">src/app/components/document-notes/document-notes.component.html</context>
<context context-type="linenumber">27,28</context> <context context-type="linenumber">48,49</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2054854290951060833" datatype="html"> <trans-unit id="2054854290951060833" datatype="html">
<source>Error saving note</source> <source>Error saving note</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-notes/document-notes.component.ts</context> <context context-type="sourcefile">src/app/components/document-notes/document-notes.component.ts</context>
<context context-type="linenumber">83</context> <context context-type="linenumber">94</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4144411010137688084" datatype="html"> <trans-unit id="4144411010137688084" datatype="html">
<source>Error deleting note</source> <source>Error deleting note</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-notes/document-notes.component.ts</context> <context context-type="sourcefile">src/app/components/document-notes/document-notes.component.ts</context>
<context context-type="linenumber">97</context> <context context-type="linenumber">108</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7103632680753685326" datatype="html"> <trans-unit id="7103632680753685326" datatype="html">
-13
View File
@@ -13,7 +13,6 @@ import { DocumentDetailComponent } from './components/document-detail/document-d
import { DocumentListComponent } from './components/document-list/document-list.component' import { DocumentListComponent } from './components/document-list/document-list.component'
import { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.component' import { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.component'
import { MailComponent } from './components/manage/mail/mail.component' import { MailComponent } from './components/manage/mail/mail.component'
import { OcrTemplatesComponent } from './components/manage/ocr-templates/ocr-templates.component'
import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component' import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component'
import { ShareLinksComponent } from './components/manage/share-links/share-links.component' import { ShareLinksComponent } from './components/manage/share-links/share-links.component'
import { WorkflowsComponent } from './components/manage/workflows/workflows.component' import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
@@ -276,18 +275,6 @@ export const routes: Routes = [
componentName: 'WorkflowsComponent', componentName: 'WorkflowsComponent',
}, },
}, },
{
path: 'ocr-templates',
component: OcrTemplatesComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.OcrTemplate,
},
componentName: 'OcrTemplatesComponent',
},
},
{ {
path: 'mail', path: 'mail',
component: MailComponent, component: MailComponent,
@@ -113,7 +113,6 @@ const sidebarItemLabels: Record<HideableSidebarItemID, string> = {
[HideableSidebarItemID.SavedViews]: $localize`Saved Views`, [HideableSidebarItemID.SavedViews]: $localize`Saved Views`,
[HideableSidebarItemID.ShareLinks]: $localize`Share Links`, [HideableSidebarItemID.ShareLinks]: $localize`Share Links`,
[HideableSidebarItemID.Workflows]: $localize`Workflows`, [HideableSidebarItemID.Workflows]: $localize`Workflows`,
[HideableSidebarItemID.OcrTemplates]: $localize`OCR Templates`,
[HideableSidebarItemID.Mail]: $localize`Mail`, [HideableSidebarItemID.Mail]: $localize`Mail`,
[HideableSidebarItemID.Documentation]: $localize`Documentation`, [HideableSidebarItemID.Documentation]: $localize`Documentation`,
} }
@@ -175,7 +175,13 @@
</div> </div>
</td> </td>
<td class="overflow-auto name-col"> <td class="overflow-auto name-col">
<div>{{ taskDisplayName(task) }}</div> @let documentCount = taskDocumentCount(task);
<div>
{{ taskDisplayName(task) }}
@if (documentCount !== null && documentCount > 1) {
<small class="text-muted ms-1" i18n>({documentCount, plural, =1 {1 document} other {{{ documentCount }} documents}})</small>
}
</div>
<div class="small text-muted"> <div class="small text-muted">
@if (taskShowsSeparateTypeLabel(task)) { @if (taskShowsSeparateTypeLabel(task)) {
<span class="me-2">{{ task.task_type_display }}</span> <span class="me-2">{{ task.task_type_display }}</span>
@@ -895,6 +895,117 @@ describe('TasksComponent', () => {
expect(sanityTaskRow.nativeElement.textContent).toContain('System') expect(sanityTaskRow.nativeElement.textContent).toContain('System')
}) })
it.each([
['modify_tags', 12, 'Bulk operation: Update tags', '(12 documents)'],
['run_workflow', 1, 'Run workflow', null],
['delete', 3, 'Bulk operation: Move to trash', '(3 documents)'],
['reprocess', 2, 'Bulk operation: Document reprocessing', '(2 documents)'],
])(
'shows the operation, document count and outcome for %s',
(operation, count, label, summary) => {
const task: PaperlessTask = {
...tasks[4],
task_type:
operation === 'delete'
? PaperlessTaskType.BulkDelete
: PaperlessTaskType.BulkUpdate,
task_type_display:
operation === 'delete' ? 'Bulk Delete' : 'Bulk Update',
trigger_source: PaperlessTaskTriggerSource.WebUI,
trigger_source_display: 'Web UI',
input_data: { operation, document_count: count },
result_data: { operation, document_count: count },
}
component.setSection(TaskSection.Completed)
flushTaskLists(25, 1, [task])
fixture.detectChanges()
const name = fixture.nativeElement.querySelector('tbody .name-col')
expect(name.textContent).toContain(label)
const countLabel = name.querySelector('small.text-muted')
if (summary) {
expect(countLabel.textContent).toContain(summary)
} else {
expect(countLabel).toBeNull()
expect(name.textContent).not.toContain('Bulk operation:')
}
expect(name.textContent).not.toContain('·')
expect(name.textContent).toContain('Web UI')
expect(name.textContent).not.toContain(task.task_type_display)
expect(
name.closest('tr').querySelector('.results-col').textContent
).toContain(
[
'reprocess',
'rotate',
'merge',
'split',
'delete_pages',
'edit_pdf',
'remove_password',
].includes(operation)
? 'Submitted for processing'
: 'Completed'
)
}
)
it('shows operation context while work is pending without reporting completion', () => {
const task = {
...tasks[2],
task_type: PaperlessTaskType.BulkUpdate,
task_type_display: 'Bulk Update',
input_data: { operation: 'reprocess', document_count: 1 },
}
component.setSection(TaskSection.InProgress)
flushTaskLists(25, 1, [task])
fixture.detectChanges()
const name = fixture.nativeElement.querySelector('tbody .name-col')
expect(name.textContent).toContain('Document reprocessing')
expect(name.textContent).not.toContain('Bulk operation:')
expect(name.querySelector('small')).toBeNull()
expect(component.taskResultMessage(task)).toBeNull()
})
it.each([{}, { operation: 'future_operation' }])(
'preserves the fallback for legacy or unknown operation data: %j',
(input_data) => {
const task = {
...tasks[4],
task_type: PaperlessTaskType.BulkUpdate,
task_type_display: 'Bulk Update',
input_data,
result_data: {},
}
expect(component.taskDisplayName(task)).toBe('Bulk Update')
expect(component.taskDocumentCount(task)).toBeNull()
expect(component.taskResultMessage(task)).toBeNull()
}
)
it.each([-1, 1.5, '2', null, undefined])(
'does not display an invalid document count: %s',
(document_count) => {
expect(
component.taskDocumentCount({
...tasks[4],
input_data: { operation: 'modify_tags', document_count },
})
).toBeNull()
}
)
it('retains error details for failed operation tasks', () => {
expect(
component.taskResultMessage({
...tasks[0],
input_data: { operation: 'modify_tags', document_count: 12 },
result_data: { error_message: 'Insufficient permissions' },
})
).toBe('Insufficient permissions')
})
it('should filter tasks by result', () => { it('should filter tasks by result', () => {
component.setSection(TaskSection.NeedsAttention) component.setSection(TaskSection.NeedsAttention)
component.filterTargetID = 1 component.filterTargetID = 1
@@ -33,6 +33,10 @@ import { IfPermissionsDirective } from 'src/app/directives/if-permissions.direct
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { TasksService } from 'src/app/services/tasks.service' import { TasksService } from 'src/app/services/tasks.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import {
documentOperationHasFollowUpTasks,
getDocumentOperationLabel,
} from 'src/app/utils/document-operation'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component' import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
@@ -414,11 +418,30 @@ export class TasksComponent
} }
taskDisplayName(task: PaperlessTask): string { taskDisplayName(task: PaperlessTask): string {
return task.input_data?.filename?.toString() || task.task_type_display const filename = task.input_data?.filename?.toString()
if (filename) return filename
const operation = this.taskOperationLabel(task)
const count = this.taskDocumentCount(task)
if (operation && count !== null && count > 1) {
return $localize`Bulk operation: ${operation}`
}
return operation || task.task_type_display
} }
taskShowsSeparateTypeLabel(task: PaperlessTask): boolean { taskShowsSeparateTypeLabel(task: PaperlessTask): boolean {
return this.taskDisplayName(task) !== task.task_type_display return (
!this.taskOperationLabel(task) &&
this.taskDisplayName(task) !== task.task_type_display
)
}
private taskOperationLabel(task: PaperlessTask): string | null {
return getDocumentOperationLabel(task.input_data?.operation)
}
taskDocumentCount(task: PaperlessTask): number | null {
const count = task.input_data?.document_count
return Number.isInteger(count) && count >= 0 ? count : null
} }
taskResultMessage(task: PaperlessTask): string | null { taskResultMessage(task: PaperlessTask): string | null {
@@ -446,6 +469,15 @@ export class TasksComponent
return errorMessage return errorMessage
} }
if (
task.status === PaperlessTaskStatus.Success &&
this.taskOperationLabel(task)
) {
return documentOperationHasFollowUpTasks(task.input_data?.operation)
? $localize`Submitted for processing`
: $localize`Completed`
}
return null return null
} }
@@ -289,17 +289,6 @@
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Workflows" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Workflows, $event)"></pngx-input-switch> <pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Workflows" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Workflows, $event)"></pngx-input-switch>
} }
</li> </li>
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.OcrTemplates) && !settingsService.organizingSidebarItems()"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.OcrTemplate }">
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.OcrTemplates)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="ocr-templates" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="OCR Templates" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<pngx-bs-icon class="me-2" name="bounding-box-circles"></pngx-bs-icon><span class="nav-link-label"><ng-container i18n>OCR Templates</ng-container></span>
</a>
@if (settingsService.organizingSidebarItems()) {
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="OCR Templates" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.OcrTemplates)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.OcrTemplates, $event)"></pngx-input-switch>
}
</li>
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail) && !settingsService.organizingSidebarItems()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }" <li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail) && !settingsService.organizingSidebarItems()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
tourAnchor="tour.mail"> tourAnchor="tour.mail">
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail" <a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
@@ -33,6 +33,7 @@ import { RemoteVersionService } from 'src/app/services/rest/remote-version.servi
import { SavedViewService } from 'src/app/services/rest/saved-view.service' import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SearchService } from 'src/app/services/rest/search.service' import { SearchService } from 'src/app/services/rest/search.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { TaskNotificationService } from 'src/app/services/task-notification.service'
import { TasksService } from 'src/app/services/tasks.service' import { TasksService } from 'src/app/services/tasks.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service' import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
@@ -198,14 +199,14 @@ describe('AppFrameComponent', () => {
fixture.detectChanges() fixture.detectChanges()
}) })
it('refreshes and notifies on completion without client-side task tracking', () => { it('refreshes and updates the task notification on completion', () => {
const list = TestBed.inject(DocumentListViewService) const list = TestBed.inject(DocumentListViewService)
const reload = jest.spyOn(list, 'reload').mockImplementation(() => {}) const reload = jest.spyOn(list, 'reload').mockImplementation(() => {})
jest.spyOn(list, 'reduceSelectionToFilter').mockImplementation(() => {}) jest.spyOn(list, 'reduceSelectionToFilter').mockImplementation(() => {})
const tasksReload = jest const tasksReload = jest
.spyOn(tasksService, 'reload') .spyOn(tasksService, 'reload')
.mockImplementation(() => {}) .mockImplementation(() => {})
const success = jest.spyOn(toastService, 'showSuccess') const success = jest.spyOn(toastService, 'show')
const refresh = jest const refresh = jest
.spyOn(openDocumentsService, 'refreshDocument') .spyOn(openDocumentsService, 'refreshDocument')
.mockImplementation(() => {}) .mockImplementation(() => {})
@@ -230,7 +231,12 @@ describe('AppFrameComponent', () => {
expect(refresh).toHaveBeenCalledWith(document.id) expect(refresh).toHaveBeenCalledWith(document.id)
expect(maybeRefreshSpy).toHaveBeenCalledTimes(1) expect(maybeRefreshSpy).toHaveBeenCalledTimes(1)
expect(tasksReload).toHaveBeenCalledTimes(1) expect(tasksReload).toHaveBeenCalledTimes(1)
expect(success).toHaveBeenCalledWith('Bulk operation completed.') expect(success).toHaveBeenCalledWith(
expect.objectContaining({
id: 'task-bulk-task',
content: "Operation 'Set permissions' completed.",
})
)
fixture.destroy() fixture.destroy()
bulkEvents.next({ bulkEvents.next({
task_id: 'bulk-task', task_id: 'bulk-task',
@@ -245,14 +251,14 @@ describe('AppFrameComponent', () => {
const reload = jest.spyOn(list, 'reload').mockImplementation(() => {}) const reload = jest.spyOn(list, 'reload').mockImplementation(() => {})
jest.spyOn(list, 'reduceSelectionToFilter').mockImplementation(() => {}) jest.spyOn(list, 'reduceSelectionToFilter').mockImplementation(() => {})
jest.spyOn(tasksService, 'reload').mockImplementation(() => {}) jest.spyOn(tasksService, 'reload').mockImplementation(() => {})
const error = jest.spyOn(toastService, 'showError') const error = jest.spyOn(toastService, 'show')
const success = jest.spyOn(toastService, 'showSuccess') const success = jest.spyOn(toastService, 'showSuccess')
for (const status of [ for (const status of [
PaperlessTaskStatus.Failure, PaperlessTaskStatus.Failure,
PaperlessTaskStatus.Revoked, PaperlessTaskStatus.Revoked,
]) { ]) {
bulkEvents.next({ bulkEvents.next({
task_id: 'bulk-task', task_id: `${status}-task`,
operation: 'set_permissions', operation: 'set_permissions',
status, status,
error: error:
@@ -262,11 +268,36 @@ describe('AppFrameComponent', () => {
}) })
} }
expect(reload).toHaveBeenCalledTimes(2) expect(reload).toHaveBeenCalledTimes(2)
expect(error).toHaveBeenCalledWith(expect.any(String), 'Could not update')
expect(error).toHaveBeenCalledWith( expect(error).toHaveBeenCalledWith(
'Bulk operation revoked.', expect.objectContaining({
'Task expired before execution.' content:
"Operation 'Set permissions' failed. Some changes may already have been applied.",
error: 'Could not update',
})
) )
expect(error).toHaveBeenCalledWith(
expect.objectContaining({
content: "Operation 'Set permissions' revoked.",
error: 'Task expired before execution.',
})
)
expect(success).not.toHaveBeenCalled()
})
it('passes delete completion to the shared task notification handler', () => {
const success = jest.spyOn(toastService, 'showSuccess')
const notifications = TestBed.inject(TaskNotificationService)
const handleStatus = jest.spyOn(notifications, 'complete')
bulkEvents.next({
task_id: 'delete-1',
operation: 'delete',
status: PaperlessTaskStatus.Success,
})
expect(handleStatus).toHaveBeenCalledWith({
task_id: 'delete-1',
operation: 'delete',
status: PaperlessTaskStatus.Success,
})
expect(success).not.toHaveBeenCalled() expect(success).not.toHaveBeenCalled()
}) })
@@ -373,7 +404,6 @@ describe('AppFrameComponent', () => {
HideableSidebarItemID.Dashboard, HideableSidebarItemID.Dashboard,
HideableSidebarItemID.Workflows, HideableSidebarItemID.Workflows,
HideableSidebarItemID.ShareLinks, HideableSidebarItemID.ShareLinks,
HideableSidebarItemID.OcrTemplates,
]) ])
fixture.detectChanges() fixture.detectChanges()
@@ -389,10 +419,6 @@ describe('AppFrameComponent', () => {
fixture.nativeElement.querySelector('[routerLink="share-links"]') fixture.nativeElement.querySelector('[routerLink="share-links"]')
.parentElement.classList .parentElement.classList
).toContain('d-none') ).toContain('d-none')
expect(
fixture.nativeElement.querySelector('[routerLink="ocr-templates"]')
.parentElement.classList
).toContain('d-none')
expect( expect(
fixture.nativeElement.querySelector('[routerLink="mail"]').parentElement fixture.nativeElement.querySelector('[routerLink="mail"]').parentElement
.classList .classList
@@ -405,21 +431,12 @@ describe('AppFrameComponent', () => {
]) ])
settingsService.sidebarHiddenItemsEditing.set([ settingsService.sidebarHiddenItemsEditing.set([
HideableSidebarItemID.Dashboard, HideableSidebarItemID.Dashboard,
HideableSidebarItemID.OcrTemplates,
]) ])
fixture.detectChanges() fixture.detectChanges()
expect( expect(
fixture.nativeElement.querySelectorAll('pngx-input-switch').length fixture.nativeElement.querySelectorAll('pngx-input-switch').length
).toBe(7) ).toBe(6)
const ocrTemplatesLink = fixture.nativeElement.querySelector(
'[routerLink="ocr-templates"]'
)
expect(ocrTemplatesLink.parentElement.classList).not.toContain('d-none')
expect(ocrTemplatesLink.classList).toContain('opacity-50')
expect(
ocrTemplatesLink.parentElement.querySelector('pngx-input-switch')
).not.toBeNull()
expect( expect(
fixture.nativeElement.querySelector('[routerLink="dashboard"]') fixture.nativeElement.querySelector('[routerLink="dashboard"]')
.parentElement.classList .parentElement.classList
@@ -57,6 +57,7 @@ import {
} from 'src/app/services/rest/remote-version.service' } from 'src/app/services/rest/remote-version.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service' import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { TaskNotificationService } from 'src/app/services/task-notification.service'
import { TasksService } from 'src/app/services/tasks.service' import { TasksService } from 'src/app/services/tasks.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service' import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
@@ -115,6 +116,7 @@ export class AppFrameComponent
settingsService = inject(SettingsService) settingsService = inject(SettingsService)
tasksService = inject(TasksService) tasksService = inject(TasksService)
private readonly toastService = inject(ToastService) private readonly toastService = inject(ToastService)
private readonly taskNotifications = inject(TaskNotificationService)
private modalService = inject(NgbModal) private modalService = inject(NgbModal)
permissionsService = inject(PermissionsService) permissionsService = inject(PermissionsService)
private djangoMessagesService = inject(DjangoMessagesService) private djangoMessagesService = inject(DjangoMessagesService)
@@ -182,7 +184,7 @@ export class AppFrameComponent
), ),
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
) )
.subscribe(({ status, error }) => { .subscribe((result) => {
this.documentList.reload(null, false) this.documentList.reload(null, false)
this.documentList.reduceSelectionToFilter() this.documentList.reduceSelectionToFilter()
// maybe someday we can optimize to only refresh affected documents // maybe someday we can optimize to only refresh affected documents
@@ -198,16 +200,7 @@ export class AppFrameComponent
) { ) {
this.tasksService.reload() this.tasksService.reload()
} }
if (status === PaperlessTaskStatus.Success) { this.taskNotifications.complete(result)
this.toastService.showSuccess($localize`Bulk operation completed.`)
} else if (status === PaperlessTaskStatus.Revoked) {
this.toastService.showError($localize`Bulk operation revoked.`, error)
} else {
this.toastService.showError(
$localize`Bulk operation failed. Some changes may already have been applied.`,
error
)
}
}) })
this.lastScrollY = window.scrollY this.lastScrollY = window.scrollY
@@ -16,7 +16,6 @@ import basket from 'bootstrap-icons/icons/basket.svg'
import bell from 'bootstrap-icons/icons/bell.svg' import bell from 'bootstrap-icons/icons/bell.svg'
import bodyText from 'bootstrap-icons/icons/body-text.svg' import bodyText from 'bootstrap-icons/icons/body-text.svg'
import bookmark from 'bootstrap-icons/icons/bookmark.svg' import bookmark from 'bootstrap-icons/icons/bookmark.svg'
import boundingBoxCircles from 'bootstrap-icons/icons/bounding-box-circles.svg'
import boxArrowInRight from 'bootstrap-icons/icons/box-arrow-in-right.svg' import boxArrowInRight from 'bootstrap-icons/icons/box-arrow-in-right.svg'
import boxArrowUpRight from 'bootstrap-icons/icons/box-arrow-up-right.svg' import boxArrowUpRight from 'bootstrap-icons/icons/box-arrow-up-right.svg'
import boxArrowUp from 'bootstrap-icons/icons/box-arrow-up.svg' import boxArrowUp from 'bootstrap-icons/icons/box-arrow-up.svg'
@@ -78,7 +77,6 @@ import fileEarmarkMinus from 'bootstrap-icons/icons/file-earmark-minus.svg'
import fileEarmarkPerson from 'bootstrap-icons/icons/file-earmark-person.svg' import fileEarmarkPerson from 'bootstrap-icons/icons/file-earmark-person.svg'
import fileEarmarkPlus from 'bootstrap-icons/icons/file-earmark-plus.svg' import fileEarmarkPlus from 'bootstrap-icons/icons/file-earmark-plus.svg'
import fileEarmarkRichtext from 'bootstrap-icons/icons/file-earmark-richtext.svg' import fileEarmarkRichtext from 'bootstrap-icons/icons/file-earmark-richtext.svg'
import fileEarmarkRuled from 'bootstrap-icons/icons/file-earmark-ruled.svg'
import fileEarmarkSpreadsheet from 'bootstrap-icons/icons/file-earmark-spreadsheet.svg' import fileEarmarkSpreadsheet from 'bootstrap-icons/icons/file-earmark-spreadsheet.svg'
import fileEarmark from 'bootstrap-icons/icons/file-earmark.svg' import fileEarmark from 'bootstrap-icons/icons/file-earmark.svg'
import fileText from 'bootstrap-icons/icons/file-text.svg' import fileText from 'bootstrap-icons/icons/file-text.svg'
@@ -133,7 +131,6 @@ import search from 'bootstrap-icons/icons/search.svg'
import send from 'bootstrap-icons/icons/send.svg' import send from 'bootstrap-icons/icons/send.svg'
import shop from 'bootstrap-icons/icons/shop.svg' import shop from 'bootstrap-icons/icons/shop.svg'
import slashCircle from 'bootstrap-icons/icons/slash-circle.svg' import slashCircle from 'bootstrap-icons/icons/slash-circle.svg'
import sliders2 from 'bootstrap-icons/icons/sliders2.svg'
import sliders2Vertical from 'bootstrap-icons/icons/sliders2-vertical.svg' import sliders2Vertical from 'bootstrap-icons/icons/sliders2-vertical.svg'
import sortAlphaDown from 'bootstrap-icons/icons/sort-alpha-down.svg' import sortAlphaDown from 'bootstrap-icons/icons/sort-alpha-down.svg'
import sortAlphaUpAlt from 'bootstrap-icons/icons/sort-alpha-up-alt.svg' import sortAlphaUpAlt from 'bootstrap-icons/icons/sort-alpha-up-alt.svg'
@@ -182,7 +179,6 @@ export const icons: Record<string, string> = {
braces, braces,
bodyText, bodyText,
bookmark, bookmark,
boundingBoxCircles,
boxArrowInRight, boxArrowInRight,
boxArrowUp, boxArrowUp,
boxArrowUpRight, boxArrowUpRight,
@@ -244,7 +240,6 @@ export const icons: Record<string, string> = {
fileEarmarkPerson, fileEarmarkPerson,
fileEarmarkPlus, fileEarmarkPlus,
fileEarmarkRichtext, fileEarmarkRichtext,
fileEarmarkRuled,
fileEarmarkSpreadsheet, fileEarmarkSpreadsheet,
files, files,
fileText, fileText,
@@ -297,7 +292,6 @@ export const icons: Record<string, string> = {
search, search,
send, send,
slashCircle, slashCircle,
sliders2,
shop, shop,
sliders2Vertical, sliders2Vertical,
sortAlphaDown, sortAlphaDown,
@@ -18,8 +18,13 @@
</div> </div>
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="runWorkflowForce" [(ngModel)]="force" /> <input class="form-check-input" type="checkbox" id="runWorkflowForce" [(ngModel)]="force" />
@if (singleDocument) {
<label class="form-check-label" for="runWorkflowForce" i18n>Run on this document regardless of trigger filters</label>
<div class="form-text" i18n>Ignore the workflow's trigger filters for this document.</div>
} @else {
<label class="form-check-label" for="runWorkflowForce" i18n>Run on all selected documents</label> <label class="form-check-label" for="runWorkflowForce" i18n>Run on all selected documents</label>
<div class="form-text" i18n>Ignore the workflow's trigger filters and run on every selected document.</div> <div class="form-text" i18n>Ignore the workflow's trigger filters and run on every selected document.</div>
}
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
@@ -39,4 +39,25 @@ describe('RunWorkflowConfirmDialogComponent', () => {
expect(options).toHaveLength(2) expect(options).toHaveLength(2)
expect(options[1].textContent).toContain('Manual workflow') expect(options[1].textContent).toContain('Manual workflow')
}) })
it('should describe the checkbox for a selection or a single document', async () => {
const checkbox = fixture.nativeElement.querySelector('#runWorkflowForce')
expect(checkbox.checked).toBe(false)
expect(fixture.nativeElement.textContent).toContain(
'Run on all selected documents'
)
component.singleDocument = true
component.force = true
fixture.detectChanges()
await fixture.whenStable()
fixture.detectChanges()
expect(checkbox.checked).toBe(true)
expect(fixture.nativeElement.textContent).toContain(
'Run on this document regardless of trigger filters'
)
expect(fixture.nativeElement.textContent).not.toContain(
'Run on all selected documents'
)
})
}) })
@@ -13,6 +13,8 @@ export class RunWorkflowConfirmDialogComponent extends ConfirmDialogComponent {
public workflowId: number = null public workflowId: number = null
public singleDocument: boolean = false
// i.e. ignore the workflow's trigger filters // i.e. ignore the workflow's trigger filters
public force: boolean = false public force: boolean = false
@@ -1,30 +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">
@if (messageBold) {
<p class="text-break"><b>{{messageBold}}</b></p>
}
@if (message) {
<p class="text-break" [innerHTML]="message"></p>
}
<pngx-input-select
[items]="templates()"
[(ngModel)]="template"
[allowNull]="true"
title="OCR template"
i18n-title
placeholder="All matching templates"
i18n-placeholder
></pngx-input-select>
</div>
<div class="modal-footer">
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()">
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled()">
{{btnCaption}}
</button>
</div>
@@ -1,52 +0,0 @@
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 { of } from 'rxjs'
import { MATCH_NONE } from 'src/app/data/matching-model'
import { OcrTemplate } from 'src/app/data/ocr-template'
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
import { ZoneOcrConfirmDialogComponent } from './zone-ocr-confirm-dialog.component'
const templates: OcrTemplate[] = [
{
id: 1,
name: 'Invoices',
match: '',
matching_algorithm: MATCH_NONE,
sample_document: null,
source_width: 0,
source_height: 0,
enabled: true,
zones: [],
},
]
describe('ZoneOcrConfirmDialogComponent', () => {
let component: ZoneOcrConfirmDialogComponent
let fixture: ComponentFixture<ZoneOcrConfirmDialogComponent>
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
NgbActiveModal,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
imports: [ZoneOcrConfirmDialogComponent],
})
jest
.spyOn(TestBed.inject(OcrTemplateService), 'listAll')
.mockReturnValue(of({ count: 1, all: [1], results: templates }))
fixture = TestBed.createComponent(ZoneOcrConfirmDialogComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should offer templates and default to all matching templates', () => {
expect(component.templates()).toEqual(templates)
expect(component.template).toBeNull()
expect(fixture.nativeElement.textContent).toContain('OCR template')
})
})
@@ -1,31 +0,0 @@
import { Component, OnInit, inject, signal } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { takeUntil } from 'rxjs'
import { OcrTemplate } from 'src/app/data/ocr-template'
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
import { SelectComponent } from '../../input/select/select.component'
import { ConfirmDialogComponent } from '../confirm-dialog.component'
@Component({
selector: 'pngx-zone-ocr-confirm-dialog',
templateUrl: './zone-ocr-confirm-dialog.component.html',
imports: [FormsModule, SelectComponent],
})
export class ZoneOcrConfirmDialogComponent
extends ConfirmDialogComponent
implements OnInit
{
private readonly ocrTemplateService = inject(OcrTemplateService)
public readonly templates = signal<OcrTemplate[]>([])
/** The template to run, or null for every template that matches. */
template: number | null = null
ngOnInit() {
this.ocrTemplateService
.listAll()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((r) => this.templates.set(r.results))
}
}
@@ -40,7 +40,7 @@
(change)="onChange(selectedDocumentIDs)"> (change)="onChange(selectedDocumentIDs)">
<ng-template ng-label-tmp let-document="item"> <ng-template ng-label-tmp let-document="item">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
@if (!disabled && multiple) { @if (!disabled) {
<button class="btn p-0 lh-1" (click)="unselect(document)" (mousedown)="$event.stopImmediatePropagation()" type="button" title="Remove link" i18n-title><pngx-bs-icon name="x"></pngx-bs-icon></button> <button class="btn p-0 lh-1" (click)="unselect(document)" (mousedown)="$event.stopImmediatePropagation()" type="button" title="Remove link" i18n-title><pngx-bs-icon name="x"></pngx-bs-icon></button>
} }
@if (document.title) { @if (document.title) {
@@ -81,9 +81,6 @@ export class DocumentLinkComponent
@Input() @Input()
placeholder: string = $localize`Search for documents` placeholder: string = $localize`Search for documents`
@Input()
multiple: boolean = true
/** /**
* Parent for ng-select dropdown, needed to prevent close on click. * Parent for ng-select dropdown, needed to prevent close on click.
*/ */
@@ -7,6 +7,7 @@
background-color: var(--pngx-toast-bg); background-color: var(--pngx-toast-bg);
color: var(--bs-body-color); color: var(--bs-body-color);
overflow: hidden; overflow: hidden;
transition: background-color .18s ease, border-color .18s ease;
&.toast-success { &.toast-success {
--pngx-toast-accent: var(--pngx-toast-success); --pngx-toast-accent: var(--pngx-toast-success);
@@ -23,6 +24,13 @@
.toast-icon { .toast-icon {
color: var(--pngx-toast-accent); color: var(--pngx-toast-accent);
transition: color .18s ease;
}
@media (prefers-reduced-motion: reduce) {
.toast, .toast-icon {
transition: none;
}
} }
.toast-content { .toast-content {
@@ -65,6 +65,36 @@ describe('ToastComponent', () => {
expect(component.delayRemaining()).toEqual(3000) expect(component.delayRemaining()).toEqual(3000)
}) })
it('updates its color and icon in place and restarts the countdown', () => {
jest.useFakeTimers()
fixture.componentRef.setInput('toast', {
id: 'task-1',
content: 'Queued',
delay: 5000,
})
fixture.detectChanges()
component.onShown(component.toast)
jest.advanceTimersByTime(4500)
const element = fixture.nativeElement.querySelector('ngb-toast')
fixture.componentRef.setInput('toast', {
id: 'task-1',
content: 'Completed',
delay: 5000,
level: ToastLevel.Success,
})
fixture.detectChanges()
expect(fixture.nativeElement.querySelector('ngb-toast')).toBe(element)
expect(element.classList).toContain('toast-success')
expect(element.querySelector('[name="check-circle"]')).not.toBeNull()
expect(component.delayRemaining()).toBe(5000)
const hide = jest.spyOn(component.ngbToast, 'hide')
jest.advanceTimersByTime(1000)
expect(hide).not.toHaveBeenCalled()
jest.advanceTimersByTime(4200)
expect(hide).toHaveBeenCalled()
jest.useRealTimers()
})
it('should hide the toast when the countdown ends', () => { it('should hide the toast when the countdown ends', () => {
jest.useFakeTimers() jest.useFakeTimers()
const toast = { content: 'Info', delay: 3000 } const toast = { content: 'Info', delay: 3000 }
@@ -5,8 +5,10 @@ import {
DestroyRef, DestroyRef,
EventEmitter, EventEmitter,
Input, Input,
OnChanges,
OnInit, OnInit,
Output, Output,
SimpleChanges,
ViewChild, ViewChild,
inject, inject,
signal, signal,
@@ -34,10 +36,10 @@ import { Toast, ToastLevel } from 'src/app/services/toast.service'
styleUrl: './toast.component.scss', styleUrl: './toast.component.scss',
providers: [DatePipe, CustomDatePipe], providers: [DatePipe, CustomDatePipe],
}) })
export class ToastComponent implements OnInit { export class ToastComponent implements OnInit, OnChanges {
private clipboard = inject(Clipboard) private readonly clipboard = inject(Clipboard)
private customDatePipe = inject(CustomDatePipe) private readonly customDatePipe = inject(CustomDatePipe)
private destroyRef = inject(DestroyRef) private readonly destroyRef = inject(DestroyRef)
readonly copied = signal(false) readonly copied = signal(false)
readonly ToastLevel = ToastLevel readonly ToastLevel = ToastLevel
readonly delayRemaining = signal<number>(null) readonly delayRemaining = signal<number>(null)
@@ -69,6 +71,12 @@ export class ToastComponent implements OnInit {
if (this.autohide) this.delayRemaining.set(this.toast.delay) if (this.autohide) this.delayRemaining.set(this.toast.delay)
} }
ngOnChanges(changes: SimpleChanges): void {
if (changes.toast && !changes.toast.firstChange && this.autohide) {
this.onShown(this.toast)
}
}
get relativeTimestamp(): string { get relativeTimestamp(): string {
return this.customDatePipe.transform(this.toast.timestamp, 'relative') return this.customDatePipe.transform(this.toast.timestamp, 'relative')
} }
@@ -68,9 +68,9 @@ describe('ToastsComponent', () => {
toasts.forEach((t) => toastSubject.next(t)) toasts.forEach((t) => toastSubject.next(t))
expect(component.toasts().map((t) => t.id)).toEqual(['3', '2', '1']) expect(component.toasts().map((t) => t.id)).toEqual(['3', '2', '1'])
// showing a visible toast again moves it to the top without duplicating // Updating a visible toast preserves its position without duplicating it.
toastSubject.next(toasts[1]) toastSubject.next(toasts[1])
expect(component.toasts().map((t) => t.id)).toEqual(['1', '3', '2']) expect(component.toasts().map((t) => t.id)).toEqual(['3', '2', '1'])
}) })
it('should remove a popup when it hides without closing it', () => { it('should remove a popup when it hides without closing it', () => {
@@ -32,10 +32,11 @@ export class ToastsComponent {
return return
} }
this.toasts.update((toasts) => this.toasts.update((toasts) =>
[toast, ...toasts.filter((t) => t.id !== toast.id)].slice( toasts.some((existing) => existing.id === toast.id)
0, ? toasts.map((existing) =>
MAX_VISIBLE_TOASTS existing.id === toast.id ? toast : existing
) )
: [toast, ...toasts].slice(0, MAX_VISIBLE_TOASTS)
) )
}) })
} }
@@ -67,22 +67,6 @@
<pngx-bs-icon name="pencil" class="me-2"></pngx-bs-icon><ng-container i18n>PDF Editor</ng-container> <pngx-bs-icon name="pencil" class="me-2"></pngx-bs-icon><ng-container i18n>PDF Editor</ng-container>
</button> </button>
<div class="d-flex" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.OcrTemplate }">
<button ngbDropdownItem (click)="runZoneOcr()" [disabled]="!userCanEdit || networkActive()">
<pngx-bs-icon width="1em" height="1em" name="bounding-box-circles" class="me-2"></pngx-bs-icon><span i18n>Run Zone OCR</span>
</button>
<button
ngbDropdownItem
class="w-auto flex-shrink-0 px-2"
(click)="runZoneOcrWithOptions()"
[disabled]="!userCanEdit || networkActive()"
title="Choose OCR template"
i18n-title
>
<pngx-bs-icon width="1em" height="1em" name="sliders2"></pngx-bs-icon>
</button>
</div>
@if (userIsOwner && (requiresPassword || password)) { @if (userIsOwner && (requiresPassword || password)) {
<button ngbDropdownItem (click)="removePassword()" [disabled]="!password"> <button ngbDropdownItem (click)="removePassword()" [disabled]="!password">
<pngx-bs-icon name="unlock" class="me-2"></pngx-bs-icon><ng-container i18n>Remove Password</ng-container> <pngx-bs-icon name="unlock" class="me-2"></pngx-bs-icon><ng-container i18n>Remove Password</ng-container>
@@ -112,7 +96,7 @@
<div class="row"> <div class="row">
<div class="col-md-6 col-xl-5 mb-4"> <div class="col-md-6 col-xl-5 mb-4">
<form [formGroup]='documentForm' (ngSubmit)="save()" [attr.inert]="zoneOcrRunning() ? '' : null" [attr.aria-busy]="zoneOcrRunning()"> <form [formGroup]='documentForm' (ngSubmit)="save()">
<div class="btn-toolbar mb-1 pb-3 gap-2 row-gap-2 border-bottom"> <div class="btn-toolbar mb-1 pb-3 gap-2 row-gap-2 border-bottom">
<div class="btn-group"> <div class="btn-group">
@@ -1013,48 +1013,85 @@ describe('DocumentDetailComponent', () => {
]) ])
}) })
it('should support reprocess, confirm and close modal after started', () => { it('should queue reprocess, show the task link and close the modal', () => {
jest.useFakeTimers()
initNormally() initNormally()
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments') const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
reprocessSpy.mockReturnValue(of(true)) reprocessSpy.mockReturnValue(of({ result: 'OK', task_id: 'reprocess-1' }))
let openModal: NgbModalRef let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
const modalSpy = jest.spyOn(modalService, 'open') const modalSpy = jest.spyOn(modalService, 'open')
const toastSpy = jest.spyOn(toastService, 'showInfo') const toastSpy = jest.spyOn(toastService, 'show')
component.reprocess() component.reprocess()
const modalCloseSpy = jest.spyOn(openModal, 'close') const modalCloseSpy = jest.spyOn(openModal, 'close')
openModal.componentInstance.confirmClicked.next() openModal.componentInstance.confirmClicked.next()
expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] }, false) expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] }, false)
expect(modalSpy).toHaveBeenCalled() expect(modalSpy).toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalled() expect(toastSpy).not.toHaveBeenCalled()
expect(modalCloseSpy).toHaveBeenCalled() expect(modalCloseSpy).toHaveBeenCalled()
jest.advanceTimersByTime(400)
expect(toastSpy).toHaveBeenCalledWith(
expect.objectContaining({
content: 'Document reprocessing queued.',
actionName: 'View task',
})
)
toastSpy.mock.calls.at(-1)[0].action()
expect(router.navigate).toHaveBeenCalledWith(['/tasks'], {
queryParams: { task_id: 'reprocess-1' },
})
expect(modalCloseSpy).toHaveBeenCalled()
jest.useRealTimers()
}) })
it('should run a workflow on the document, confirm and close modal', () => { it('should run a workflow on the document, confirm and close modal', () => {
jest.useFakeTimers()
initNormally() initNormally()
const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit') const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit')
bulkEditSpy.mockReturnValue(of(true)) bulkEditSpy.mockReturnValue(of({ result: 'OK', task_id: 'workflow-1' }))
let openModal: NgbModalRef let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
const toastSpy = jest.spyOn(toastService, 'showInfo') const toastSpy = jest.spyOn(toastService, 'show')
component.runWorkflow() component.runWorkflow()
expect(openModal.componentInstance.singleDocument).toBe(true)
expect(openModal.componentInstance.force).toBe(true)
const modalCloseSpy = jest.spyOn(openModal, 'close') const modalCloseSpy = jest.spyOn(openModal, 'close')
openModal.componentInstance.workflowId = 5 openModal.componentInstance.workflowId = 5
openModal.componentInstance.force = true
openModal.componentInstance.confirmClicked.next() openModal.componentInstance.confirmClicked.next()
expect(bulkEditSpy).toHaveBeenCalledWith( expect(bulkEditSpy).toHaveBeenCalledWith(
{ documents: [doc.id] }, { documents: [doc.id] },
'run_workflow', 'run_workflow',
{ workflow: 5, filter_documents: false } { workflow: 5, filter_documents: false }
) )
expect(toastSpy).toHaveBeenCalled() jest.advanceTimersByTime(400)
expect(toastSpy).toHaveBeenCalledWith(
expect.objectContaining({ content: 'Workflow queued.' })
)
expect(modalCloseSpy).toHaveBeenCalled() expect(modalCloseSpy).toHaveBeenCalled()
jest.useRealTimers()
})
it('should apply workflow filters when the single-document checkbox is cleared', () => {
initNormally()
const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit')
bulkEditSpy.mockReturnValue(of({ result: 'OK', task_id: 'workflow-2' }))
let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
component.runWorkflow()
openModal.componentInstance.workflowId = 5
openModal.componentInstance.force = false
openModal.componentInstance.confirmClicked.next()
expect(bulkEditSpy).toHaveBeenCalledWith(
{ documents: [doc.id] },
'run_workflow',
{ workflow: 5, filter_documents: true }
)
}) })
it('should pass remote OCR choice when reprocessing', () => { it('should pass remote OCR choice when reprocessing', () => {
initNormally() initNormally()
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments') const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
reprocessSpy.mockReturnValue(of(true)) reprocessSpy.mockReturnValue(of({ result: 'OK', task_id: 'reprocess-2' }))
let openModal: NgbModalRef let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
component.reprocess() component.reprocess()
@@ -1930,203 +1967,6 @@ describe('DocumentDetailComponent', () => {
expect(toastSpy).toHaveBeenCalledWith('Document reloaded.') expect(toastSpy).toHaveBeenCalledWith('Document reloaded.')
}) })
it('should run a chosen zone OCR template on the selected version and reload', () => {
component.documentId.set(doc.id)
component.document.set({ ...doc, versions: docVersions })
component.selectedVersionId.set(10)
jest.spyOn(documentService, 'runZoneOcr').mockReturnValue(
of({
results: [
{ zone: 'Total', value: '102.10' },
{ zone: 'Due', value: '' },
],
} as any)
)
const loadSpy = jest
.spyOn(component as any, 'loadDocument')
.mockImplementation(() => {})
const notifySpy = jest.spyOn(component.docChangeNotifier, 'next')
const toastSpy = jest.spyOn(toastService, 'showInfo')
const modalSpy = jest.spyOn(modalService, 'open')
component.runZoneOcrWithOptions()
const dialog = modalSpy.mock.results[0].value.componentInstance
expect(dialog.message).toBe(
'Fields filled by the template will be overwritten.'
)
dialog.template = 3
dialog.confirmClicked.emit()
expect(documentService.runZoneOcr).toHaveBeenCalledWith(doc.id, 3, 10)
expect(toastSpy).toHaveBeenCalledWith(
'Filled 1 of 2 fields. No value found for: Due'
)
expect(notifySpy).toHaveBeenCalledWith(doc.id)
expect(loadSpy).toHaveBeenCalledWith(doc.id, true, expect.any(Function))
expect(component.zoneOcrRunning()).toBe(true)
loadSpy.mock.calls[0][2]()
expect(component.zoneOcrRunning()).toBe(false)
expect(component.networkActive()).toBe(false)
})
it('should summarize zone OCR results', () => {
component.documentId.set(doc.id)
component.document.set({ ...doc, versions: docVersions })
component.selectedVersionId.set(12)
jest.spyOn(component as any, 'loadDocument').mockImplementation(() => {})
const runSpy = jest.spyOn(documentService, 'runZoneOcr')
const toastSpy = jest.spyOn(toastService, 'showInfo')
const modalSpy = jest.spyOn(modalService, 'open')
runSpy.mockReturnValueOnce(of({ status: 'completed', results: [] }))
component.runZoneOcr()
expect(modalSpy).not.toHaveBeenCalled()
expect(runSpy).toHaveBeenCalledWith(doc.id, null, null)
expect(toastSpy).toHaveBeenLastCalledWith(
'Zone OCR found no fields to fill.'
)
component.networkActive.set(false)
runSpy.mockReturnValueOnce(
of({
status: 'completed',
results: [
{ template: 'T', zone: 'Total', custom_field: 'Amount', value: 0 },
],
})
)
component.runZoneOcr()
expect(toastSpy).toHaveBeenLastCalledWith('Filled 1 of 1 fields.')
})
it('should confirm before running zone OCR over unsaved changes', () => {
component.documentId.set(doc.id)
component.document.set({ ...doc, __changedFields: ['title'] })
component.documentForm.patchValue({ title: 'Unsaved title' })
const runSpy = jest.spyOn(documentService, 'runZoneOcr')
const modalSpy = jest.spyOn(modalService, 'open')
component.runZoneOcr()
const modal = modalSpy.mock.results[0].value
expect(modal.componentInstance.message).toBe(
'Running Zone OCR will discard them.'
)
expect(runSpy).not.toHaveBeenCalled()
modal.componentInstance.cancel()
expect(runSpy).not.toHaveBeenCalled()
expect(component.documentForm.get('title').value).toBe('Unsaved title')
expect(component.zoneOcrRunning()).toBe(false)
})
it('should warn about unsaved changes in the zone OCR options dialog', () => {
component.documentId.set(doc.id)
component.document.set({ ...doc, __changedFields: ['title'] })
const runSpy = jest.spyOn(documentService, 'runZoneOcr')
const modalSpy = jest.spyOn(modalService, 'open')
component.runZoneOcrWithOptions()
const dialog = modalSpy.mock.results[0].value.componentInstance
expect(dialog.message).toBe('Running Zone OCR will discard them.')
expect(dialog.btnClass).toBe('btn-warning')
dialog.cancel()
expect(runSpy).not.toHaveBeenCalled()
component.networkActive.set(true)
component.runZoneOcrWithOptions()
expect(modalSpy).toHaveBeenCalledTimes(1)
})
it('should not allow saving or a second zone OCR run while one is running', () => {
component.documentId.set(doc.id)
component.document.set({
...doc,
versions: docVersions,
__changedFields: ['title'],
})
component.selectedVersionId.set(doc.id)
const pending = new Subject<any>()
const runSpy = jest
.spyOn(documentService, 'runZoneOcr')
.mockReturnValue(pending)
const modalSpy = jest.spyOn(modalService, 'open')
component.runZoneOcr()
modalSpy.mock.results[0].value.componentInstance.confirmClicked.emit()
expect(runSpy).toHaveBeenCalledWith(doc.id, null, doc.id)
expect(component.zoneOcrRunning()).toBe(true)
expect(component.networkActive()).toBe(true)
const saveSpy = jest.spyOn(documentService, 'patch')
component.save()
expect(saveSpy).not.toHaveBeenCalled()
component.runZoneOcr()
expect(modalSpy).toHaveBeenCalledTimes(1)
expect(runSpy).toHaveBeenCalledTimes(1)
pending.error(new Error('failed'))
expect(component.zoneOcrRunning()).toBe(false)
expect(component.networkActive()).toBe(false)
expect(component.document().__changedFields).toEqual(['title'])
})
it('should not reload after zone OCR if another document is open', () => {
component.documentId.set(doc.id)
component.document.set({ ...doc, __changedFields: [] })
const pending = new Subject<any>()
jest.spyOn(documentService, 'runZoneOcr').mockReturnValue(pending)
const loadSpy = jest.spyOn(component as any, 'loadDocument')
component.runZoneOcr()
component.documentId.set(99)
pending.next({ results: [] })
expect(loadSpy).not.toHaveBeenCalled()
expect(component.zoneOcrRunning()).toBe(false)
pending.complete()
})
it('should keep unsaved changes when zone OCR is skipped', () => {
component.documentId.set(doc.id)
component.document.set({ ...doc, __changedFields: ['title'] })
component.documentForm.patchValue({ title: 'Unsaved title' })
const modalSpy = jest.spyOn(modalService, 'open')
jest
.spyOn(documentService, 'runZoneOcr')
.mockReturnValue(of({ status: 'skipped', results: [] }))
const loadSpy = jest.spyOn(component as any, 'loadDocument')
const infoSpy = jest.spyOn(toastService, 'showInfo')
component.runZoneOcr()
modalSpy.mock.results[0].value.componentInstance.confirm()
expect(loadSpy).not.toHaveBeenCalled()
expect(component.documentForm.get('title').value).toBe('Unsaved title')
expect(component.zoneOcrRunning()).toBe(false)
expect(component.networkActive()).toBe(false)
expect(infoSpy).toHaveBeenCalledWith(
'No OCR template applies to this document.'
)
})
it('should show an error when zone OCR fails', () => {
component.document.set(doc)
jest
.spyOn(documentService, 'runZoneOcr')
.mockReturnValue(throwError(() => new Error('nope')))
const loadSpy = jest.spyOn(component as any, 'loadDocument')
const errorSpy = jest.spyOn(toastService, 'showError')
component.runZoneOcr()
expect(errorSpy).toHaveBeenCalledWith(
'Error running zone OCR.',
expect.any(Error)
)
expect(loadSpy).not.toHaveBeenCalled()
expect(component.zoneOcrRunning()).toBe(false)
expect(component.networkActive()).toBe(false)
})
it('should change preview element by render type', () => { it('should change preview element by render type', () => {
initNormally() initNormally()
component.document.update((document) => ({ component.document.update((document) => ({
@@ -2180,10 +2020,12 @@ describe('DocumentDetailComponent', () => {
}) })
it('should support pdf editor, handle error', () => { it('should support pdf editor, handle error', () => {
jest.useFakeTimers()
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 closeSpy = jest.spyOn(openDocumentsService, 'closeDocument')
const errorSpy = jest.spyOn(toastService, 'showError') const errorSpy = jest.spyOn(toastService, 'showError')
const queuedToastSpy = jest.spyOn(toastService, 'show')
initNormally() initNormally()
component.selectedVersionId.set(10) component.selectedVersionId.set(10)
component.editPdf() component.editPdf()
@@ -2202,6 +2044,7 @@ describe('DocumentDetailComponent', () => {
update_document: false, update_document: false,
include_metadata: true, include_metadata: true,
source_mode: 'explicit_selection', source_mode: 'explicit_selection',
from_webui: true,
}) })
req.error(new ErrorEvent('failed')) req.error(new ErrorEvent('failed'))
expect(errorSpy).toHaveBeenCalled() expect(errorSpy).toHaveBeenCalled()
@@ -2214,13 +2057,21 @@ describe('DocumentDetailComponent', () => {
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/edit_pdf/` `${environment.apiBaseUrl}documents/edit_pdf/`
) )
req.flush(true) req.flush({ result: 'OK', task_id: 'edit-pdf-1' })
expect(closeSpy).toHaveBeenCalled() jest.advanceTimersByTime(400)
expect(closeSpy).not.toHaveBeenCalled()
expect(queuedToastSpy).toHaveBeenCalledWith(
expect.objectContaining({ content: 'PDF edit queued.' })
)
jest.useRealTimers()
}) })
it('should support removing password protection from pdfs', () => { it('should support removing password protection from pdfs', () => {
jest.useFakeTimers()
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 queuedToastSpy = jest.spyOn(toastService, 'show')
initNormally() initNormally()
component.selectedVersionId.set(10) component.selectedVersionId.set(10)
component.password = 'secret' component.password = 'secret'
@@ -2241,8 +2092,15 @@ describe('DocumentDetailComponent', () => {
include_metadata: false, include_metadata: false,
delete_original: true, delete_original: true,
source_mode: 'explicit_selection', source_mode: 'explicit_selection',
from_webui: true,
}) })
req.flush(true) req.flush({ result: 'OK', task_id: 'remove-password-1' })
jest.advanceTimersByTime(400)
expect(closeSpy).not.toHaveBeenCalled()
expect(queuedToastSpy).toHaveBeenCalledWith(
expect.objectContaining({ content: 'Password removal queued.' })
)
jest.useRealTimers()
}) })
it('should require the current password before removing it', () => { it('should require the current password before removing it', () => {
@@ -2294,9 +2152,9 @@ describe('DocumentDetailComponent', () => {
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/remove_password/` `${environment.apiBaseUrl}documents/remove_password/`
) )
req.flush(true) req.flush({ result: 'OK', task_id: 'remove-password-2' })
expect(refreshSpy).toHaveBeenCalledWith(doc.id) expect(refreshSpy).not.toHaveBeenCalled()
}) })
it('should support keyboard shortcuts', () => { it('should support keyboard shortcuts', () => {
@@ -64,7 +64,6 @@ import {
FILTER_STORAGE_PATH, FILTER_STORAGE_PATH,
} from 'src/app/data/filter-rule-type' } from 'src/app/data/filter-rule-type'
import { ObjectWithId } from 'src/app/data/object-with-id' import { ObjectWithId } from 'src/app/data/object-with-id'
import { OcrZoneRunResult } from 'src/app/data/ocr-template'
import { StoragePath } from 'src/app/data/storage-path' import { StoragePath } from 'src/app/data/storage-path'
import { Tag } from 'src/app/data/tag' import { Tag } from 'src/app/data/tag'
import { SETTINGS_KEYS, SuggestionSource } from 'src/app/data/ui-settings' import { SETTINGS_KEYS, SuggestionSource } from 'src/app/data/ui-settings'
@@ -98,6 +97,7 @@ import { TagService } from 'src/app/services/rest/tag.service'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { WorkflowService } from 'src/app/services/rest/workflow.service' import { WorkflowService } from 'src/app/services/rest/workflow.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { TaskNotificationService } from 'src/app/services/task-notification.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { TrashService } from 'src/app/services/trash.service' import { TrashService } from 'src/app/services/trash.service'
import { WebsocketStatusService } from 'src/app/services/websocket-status.service' import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
@@ -109,7 +109,6 @@ import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component' import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
import { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component' import { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
import { RunWorkflowConfirmDialogComponent } from '../common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component' import { RunWorkflowConfirmDialogComponent } from '../common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component'
import { ZoneOcrConfirmDialogComponent } from '../common/confirm-dialog/zone-ocr-confirm-dialog/zone-ocr-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'
@@ -228,6 +227,7 @@ export class DocumentDetailComponent
private documentListViewService = inject(DocumentListViewService) private documentListViewService = inject(DocumentListViewService)
private documentTitlePipe = inject(DocumentTitlePipe) private documentTitlePipe = inject(DocumentTitlePipe)
private toastService = inject(ToastService) private toastService = inject(ToastService)
private taskNotifications = inject(TaskNotificationService)
private trashService = inject(TrashService) private trashService = inject(TrashService)
private settings = inject(SettingsService) private settings = inject(SettingsService)
private storagePathService = inject(StoragePathService) private storagePathService = inject(StoragePathService)
@@ -273,7 +273,6 @@ export class DocumentDetailComponent
readonly error = signal<any>(undefined) readonly error = signal<any>(undefined)
readonly networkActive = signal(false) readonly networkActive = signal(false)
readonly zoneOcrRunning = signal(false)
readonly documentId = signal<number>(undefined) readonly documentId = signal<number>(undefined)
readonly document = signal<Document>(undefined) readonly document = signal<Document>(undefined)
@@ -552,11 +551,7 @@ export class DocumentDetailComponent
) )
} }
private loadDocument( private loadDocument(documentId: number, forceRemote: boolean = false): void {
documentId: number,
forceRemote: boolean = false,
onComplete?: () => void
): void {
let redirectedToRoot = false let redirectedToRoot = false
this.closeIncomingUpdateModal() this.closeIncomingUpdateModal()
this.pendingIncomingUpdate = null this.pendingIncomingUpdate = null
@@ -613,8 +608,7 @@ export class DocumentDetailComponent
}), }),
first(), first(),
takeUntil(this.unsubscribeNotifier), takeUntil(this.unsubscribeNotifier),
takeUntil(this.docChangeNotifier), takeUntil(this.docChangeNotifier)
finalize(() => onComplete?.())
) )
.subscribe({ .subscribe({
next: (doc) => { next: (doc) => {
@@ -1362,7 +1356,6 @@ export class DocumentDetailComponent
} }
save(close: boolean = false, savedCallback: () => void = null) { save(close: boolean = false, savedCallback: () => void = null) {
if (this.zoneOcrRunning()) return
this.networkActive.set(true) this.networkActive.set(true)
;(document.activeElement as HTMLElement)?.dispatchEvent(new Event('change')) ;(document.activeElement as HTMLElement)?.dispatchEvent(new Event('change'))
this.documentsService this.documentsService
@@ -1567,9 +1560,11 @@ export class DocumentDetailComponent
modal.componentInstance.remoteOcr modal.componentInstance.remoteOcr
) )
.subscribe({ .subscribe({
next: () => { next: ({ task_id }) => {
this.toastService.showInfo( this.taskNotifications.showQueued(
$localize`Reprocess operation for "${this.document().title}" will begin in the background.` task_id,
$localize`Document reprocessing queued.`,
{ content: $localize`Document submitted for reprocessing.` }
) )
if (modal) { if (modal) {
modal.close() modal.close()
@@ -1596,6 +1591,8 @@ export class DocumentDetailComponent
modal.componentInstance.message = $localize`Run a workflow on "${this.document().title}".` modal.componentInstance.message = $localize`Run a workflow on "${this.document().title}".`
modal.componentInstance.btnCaption = $localize`Run` modal.componentInstance.btnCaption = $localize`Run`
modal.componentInstance.workflows = this.manualWorkflows() modal.componentInstance.workflows = this.manualWorkflows()
modal.componentInstance.singleDocument = true
modal.componentInstance.force = true
modal.componentInstance.confirmClicked.subscribe(() => { modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled.set(false)
this.documentsService this.documentsService
@@ -1604,9 +1601,13 @@ export class DocumentDetailComponent
filter_documents: !modal.componentInstance.force, filter_documents: !modal.componentInstance.force,
}) })
.subscribe({ .subscribe({
next: () => { next: ({ task_id }) => {
this.toastService.showInfo( this.taskNotifications.showQueued(
$localize`Workflow will run on "${this.document().title}" in the background.` task_id,
$localize`Workflow queued.`,
{
content: $localize`Workflow completed.`,
}
) )
modal.close() modal.close()
}, },
@@ -1621,113 +1622,6 @@ export class DocumentDetailComponent
}) })
} }
/** Runs every template that matches, confirming only over unsaved changes. */
runZoneOcr() {
if (this.networkActive()) return
const documentId = this.documentId()
const versionId = this.getSelectedNonLatestVersionId()
if (!this.hasLocalEdits(this.document())) {
this.executeZoneOcr(documentId, null, versionId)
return
}
const modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Run Zone OCR`
modal.componentInstance.messageBold = $localize`You have unsaved changes.`
modal.componentInstance.message = $localize`Running Zone OCR will discard them.`
modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Run Zone OCR`
modal.componentInstance.confirmClicked
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
modal.close()
if (this.documentId() === documentId && !this.networkActive()) {
this.executeZoneOcr(documentId, null, versionId)
}
})
}
/** Opens the dialog to pick which template to run. */
runZoneOcrWithOptions() {
if (this.networkActive()) return
const documentId = this.documentId()
const versionId = this.getSelectedNonLatestVersionId()
const modal = this.modalService.open(ZoneOcrConfirmDialogComponent, {
backdrop: 'static',
})
const dialog = modal.componentInstance as ZoneOcrConfirmDialogComponent
dialog.title = $localize`Run Zone OCR`
dialog.btnCaption = $localize`Run Zone OCR`
if (this.hasLocalEdits(this.document())) {
dialog.messageBold = $localize`You have unsaved changes.`
dialog.message = $localize`Running Zone OCR will discard them.`
dialog.btnClass = 'btn-warning'
} else {
dialog.message = $localize`Fields filled by the template will be overwritten.`
}
dialog.confirmClicked
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
modal.close()
if (this.documentId() === documentId && !this.networkActive()) {
this.executeZoneOcr(documentId, dialog.template, versionId)
}
})
}
private executeZoneOcr(
documentId: number,
templateId: number | null,
versionId: number | null
) {
this.zoneOcrRunning.set(true)
this.networkActive.set(true)
this.documentsService
.runZoneOcr(documentId, templateId, versionId)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (response) => {
if (
this.documentId() !== documentId ||
response.status === 'skipped'
) {
this.finishZoneOcr()
if (response.status === 'skipped') {
this.toastService.showInfo(
$localize`No OCR template applies to this document.`
)
}
return
}
this.toastService.showInfo(this.zoneOcrSummary(response.results))
this.docChangeNotifier.next(documentId)
this.loadDocument(documentId, true, () => this.finishZoneOcr())
},
error: (error) => {
this.finishZoneOcr()
this.toastService.showError($localize`Error running zone OCR.`, error)
},
})
}
private zoneOcrSummary(results: OcrZoneRunResult[]): string {
if (!results.length) return $localize`Zone OCR found no fields to fill.`
const missed = results
.filter((result) => !`${result.value ?? ''}`.trim())
.map((result) => result.zone)
const filled = results.length - missed.length
return missed.length
? $localize`Filled ${filled} of ${results.length} fields. No value found for: ${missed.join(', ')}`
: $localize`Filled ${filled} of ${results.length} fields.`
}
private finishZoneOcr() {
this.zoneOcrRunning.set(false)
this.networkActive.set(false)
this.flushPendingIncomingUpdate()
}
private getSelectedNonLatestVersionId(): number | null { private getSelectedNonLatestVersionId(): number | null {
const versions = this.document()?.versions ?? [] const versions = this.document()?.versions ?? []
if (!versions.length || !this.selectedVersionId()) { if (!versions.length || !this.selectedVersionId()) {
@@ -2103,14 +1997,15 @@ export class DocumentDetailComponent
}) })
.pipe(first(), takeUntil(this.unsubscribeNotifier)) .pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: () => { next: ({ task_id }) => {
this.toastService.showInfo( this.taskNotifications.showQueued(
$localize`PDF edit operation for "${this.document().title}" will begin in the background.` task_id,
$localize`PDF edit queued.`,
{
content: $localize`PDF edit submitted for processing.`,
}
) )
modal.close() modal.close()
if (modal.componentInstance.deleteOriginal) {
this.openDocumentService.closeDocument(this.document())
}
}, },
error: (error) => { error: (error) => {
if (modal) { if (modal) {
@@ -2160,17 +2055,16 @@ export class DocumentDetailComponent
}) })
.pipe(first(), takeUntil(this.unsubscribeNotifier)) .pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: () => { next: ({ task_id }) => {
this.toastService.showInfo( this.taskNotifications.showQueued(
$localize`Password removal operation for "${this.document().title}" will begin in the background.` task_id,
$localize`Password removal queued.`,
{
content: $localize`Password removal submitted for processing.`,
}
) )
this.networkActive.set(false) this.networkActive.set(false)
modal.close() modal.close()
if (!dialog.updateDocument && dialog.deleteOriginal) {
this.openDocumentService.closeDocument(this.document())
} else if (dialog.updateDocument) {
this.openDocumentService.refreshDocument(this.documentId())
}
}, },
error: (error) => { error: (error) => {
dialog.buttonsEnabled.set(true) dialog.buttonsEnabled.set(true)
@@ -2,6 +2,7 @@ import { DatePipe } from '@angular/common'
import { SimpleChange, signal } from '@angular/core' import { SimpleChange, signal } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser' import { By } from '@angular/platform-browser'
import { Router } from '@angular/router'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { Subject, of, throwError } from 'rxjs' import { Subject, of, throwError } from 'rxjs'
import { DocumentVersionInfo } from 'src/app/data/document' import { DocumentVersionInfo } from 'src/app/data/document'
@@ -33,7 +34,7 @@ describe('DocumentVersionDropdownComponent', () => {
> >
> >
let toastService: jest.Mocked< let toastService: jest.Mocked<
Pick<ToastService, 'showError' | 'showInfo' | 'showSuccess'> Pick<ToastService, 'show' | 'showError' | 'showInfo' | 'showSuccess'>
> >
let finished$: Subject<{ taskId: string }> let finished$: Subject<{ taskId: string }>
let failed$: Subject<{ taskId: string; message?: string }> let failed$: Subject<{ taskId: string; message?: string }>
@@ -54,6 +55,7 @@ describe('DocumentVersionDropdownComponent', () => {
} }
modalService = { open: jest.fn() } modalService = { open: jest.fn() }
toastService = { toastService = {
show: jest.fn(),
showError: jest.fn(), showError: jest.fn(),
showInfo: jest.fn(), showInfo: jest.fn(),
showSuccess: jest.fn(), showSuccess: jest.fn(),
@@ -88,6 +90,10 @@ describe('DocumentVersionDropdownComponent', () => {
provide: PermissionsService, provide: PermissionsService,
useValue: permissionsService, useValue: permissionsService,
}, },
{
provide: Router,
useValue: { navigate: jest.fn() },
},
{ {
provide: WebsocketStatusService, provide: WebsocketStatusService,
useValue: { useValue: {
@@ -377,7 +383,8 @@ describe('DocumentVersionDropdownComponent', () => {
expect(component.versionLabelDraft).toEqual('') expect(component.versionLabelDraft).toEqual('')
}) })
it('addExistingDocumentAsVersion should merge with a label and refresh versions', () => { it('addExistingDocumentAsVersion should queue with a label and wait to refresh versions', () => {
jest.useFakeTimers()
const confirmClicked = new Subject<number>() const confirmClicked = new Subject<number>()
const modal = { const modal = {
componentInstance: { componentInstance: {
@@ -388,14 +395,9 @@ describe('DocumentVersionDropdownComponent', () => {
close: jest.fn(), close: jest.fn(),
} }
modalService.open.mockReturnValue(modal as any) modalService.open.mockReturnValue(modal as any)
documentService.mergeDocumentsAsVersions.mockReturnValue(of({} as any)) documentService.mergeDocumentsAsVersions.mockReturnValue(
// Newest first, as the API returns them. The merged document has a lower id of({ result: 'OK', task_id: 'versions-1' })
// than the root, which is the whole point of merging an existing document. )
const versions: DocumentVersionInfo[] = [
{ id: 2, is_root: false, checksum: 'cccc' },
{ id: 3, is_root: true, checksum: 'aaaa' },
]
documentService.getVersions.mockReturnValue(of({ id: 3, versions } as any))
component.newVersionLabel = ' Imported ' component.newVersionLabel = ' Imported '
const versionsEmitSpy = jest.spyOn(component.versionsUpdated, 'emit') const versionsEmitSpy = jest.spyOn(component.versionsUpdated, 'emit')
const selectedEmitSpy = jest.spyOn(component.versionSelected, 'emit') const selectedEmitSpy = jest.spyOn(component.versionSelected, 'emit')
@@ -410,11 +412,18 @@ describe('DocumentVersionDropdownComponent', () => {
'Imported' 'Imported'
) )
expect(documentService.updateVersionLabel).not.toHaveBeenCalled() expect(documentService.updateVersionLabel).not.toHaveBeenCalled()
expect(documentService.getVersions).toHaveBeenCalledWith(3) expect(documentService.getVersions).not.toHaveBeenCalled()
expect(versionsEmitSpy).toHaveBeenCalledWith(versions) expect(versionsEmitSpy).not.toHaveBeenCalled()
expect(selectedEmitSpy).toHaveBeenCalledWith(2) expect(selectedEmitSpy).not.toHaveBeenCalled()
expect(component.newVersionLabel).toEqual('') expect(component.newVersionLabel).toEqual('')
expect(modal.close).toHaveBeenCalled() expect(modal.close).toHaveBeenCalled()
expect(toastService.showSuccess).toHaveBeenCalled() jest.advanceTimersByTime(400)
expect(toastService.show).toHaveBeenCalledWith(
expect.objectContaining({
content: 'Existing document queued to be added as a version.',
actionName: 'View task',
})
)
jest.useRealTimers()
}) })
}) })
@@ -28,6 +28,7 @@ import { DocumentVersionInfo } from 'src/app/data/document'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DocumentService } from 'src/app/services/rest/document.service' import { DocumentService } from 'src/app/services/rest/document.service'
import { TaskNotificationService } from 'src/app/services/task-notification.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { import {
UploadState, UploadState,
@@ -77,6 +78,7 @@ export class DocumentVersionDropdownComponent
private readonly toastService = inject(ToastService) private readonly toastService = inject(ToastService)
private readonly websocketStatusService = inject(WebsocketStatusService) private readonly websocketStatusService = inject(WebsocketStatusService)
private readonly modalService = inject(NgbModal) private readonly modalService = inject(NgbModal)
private readonly taskNotifications = inject(TaskNotificationService)
private readonly destroy$ = new Subject<void>() private readonly destroy$ = new Subject<void>()
private readonly documentChange$ = new Subject<void>() private readonly documentChange$ = new Subject<void>()
@@ -305,23 +307,19 @@ export class DocumentVersionDropdownComponent
versionLabel versionLabel
) )
.pipe( .pipe(
switchMap(() => this.documentsService.getVersions(this.documentId)),
first(), first(),
finalize(() => dialog.buttonsEnabled.set(true)), finalize(() => dialog.buttonsEnabled.set(true)),
takeUntil(this.destroy$), takeUntil(this.destroy$),
takeUntil(this.documentChange$) takeUntil(this.documentChange$)
) )
.subscribe({ .subscribe({
next: (document) => { next: ({ task_id }) => {
if (document?.versions?.length) {
this.versionsUpdated.emit(document.versions)
// The API returns versions newest first
this.versionSelected.emit(document.versions[0].id)
}
this.newVersionLabel = '' this.newVersionLabel = ''
modal.close() modal.close()
this.toastService.showSuccess( this.taskNotifications.showQueued(
$localize`Existing document added as a version.` task_id,
$localize`Existing document queued to be added as a version.`,
{ content: $localize`Existing document added as a version.` }
) )
}, },
error: (error) => { error: (error) => {
@@ -103,11 +103,6 @@
<pngx-bs-icon name="play-circle" class="me-2"></pngx-bs-icon><ng-container i18n>Run workflow</ng-container> <pngx-bs-icon name="play-circle" class="me-2"></pngx-bs-icon><ng-container i18n>Run workflow</ng-container>
</button> </button>
} }
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.OcrTemplate)) {
<button ngbDropdownItem (click)="runZoneOcrSelected()" [disabled]="!userCanEditAll">
<pngx-bs-icon name="bounding-box-circles" class="me-2"></pngx-bs-icon><ng-container i18n>Run Zone OCR</ng-container>
</button>
}
</div> </div>
</div> </div>
</div> </div>
@@ -14,7 +14,8 @@ import { Correspondent } from 'src/app/data/correspondent'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { DocumentType } from 'src/app/data/document-type' import { DocumentType } from 'src/app/data/document-type'
import { FILTER_TITLE } from 'src/app/data/filter-rule-type' import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
import { Results } from 'src/app/data/results' import { PaperlessTaskStatus } from 'src/app/data/paperless-task'
import { Results, SelectionData } from 'src/app/data/results'
import { StoragePath } from 'src/app/data/storage-path' import { StoragePath } from 'src/app/data/storage-path'
import { Tag } from 'src/app/data/tag' import { Tag } from 'src/app/data/tag'
import { WorkflowTriggerType } from 'src/app/data/workflow-trigger' import { WorkflowTriggerType } from 'src/app/data/workflow-trigger'
@@ -28,10 +29,7 @@ import {
import { CorrespondentService } from 'src/app/services/rest/correspondent.service' import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service' import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { import { DocumentService } from 'src/app/services/rest/document.service'
DocumentService,
SelectionData,
} from 'src/app/services/rest/document.service'
import { GroupService } from 'src/app/services/rest/group.service' import { GroupService } from 'src/app/services/rest/group.service'
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service' import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service'
@@ -39,6 +37,7 @@ import { TagService } from 'src/app/services/rest/tag.service'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { WorkflowService } from 'src/app/services/rest/workflow.service' import { WorkflowService } from 'src/app/services/rest/workflow.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { TaskNotificationService } from 'src/app/services/task-notification.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { TrashService } from 'src/app/services/trash.service' import { TrashService } from 'src/app/services/trash.service'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
@@ -686,6 +685,8 @@ describe('BulkEditorComponent', () => {
}) })
it('should run a workflow on the selection via the run workflow dialog', () => { it('should run a workflow on the selection via the run workflow dialog', () => {
const toast = jest.spyOn(toastService, 'show')
const infoToast = jest.spyOn(toastService, 'showInfo')
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0])) modalService.activeInstances.subscribe((m) => (modal = m[0]))
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
@@ -699,6 +700,8 @@ describe('BulkEditorComponent', () => {
component.runWorkflowSelected() component.runWorkflowSelected()
expect(modal).not.toBeUndefined() expect(modal).not.toBeUndefined()
expect(modal.componentInstance.workflows).toEqual(component.manualWorkflows) expect(modal.componentInstance.workflows).toEqual(component.manualWorkflows)
expect(modal.componentInstance.singleDocument).toBe(false)
expect(modal.componentInstance.force).toBe(false)
modal.componentInstance.workflowId = 5 modal.componentInstance.workflowId = 5
modal.componentInstance.force = true modal.componentInstance.force = true
modal.componentInstance.confirm() modal.componentInstance.confirm()
@@ -710,7 +713,12 @@ describe('BulkEditorComponent', () => {
workflow: 5, workflow: 5,
filter_documents: false, filter_documents: false,
}) })
req.flush(true) req.flush({ result: 'OK' })
expect(infoToast).toHaveBeenCalledWith(
'Workflow will run on the selected documents in the background.'
)
expect(toast.mock.calls.at(-1)[0].actionName).toBeUndefined()
expect(documentListViewService.selected.size).toBe(0)
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
@@ -719,6 +727,27 @@ describe('BulkEditorComponent', () => {
) // listAllFilteredIds ) // listAllFilteredIds
}) })
it('should require ownership of selected documents to run a workflow', () => {
component.manualWorkflows = [{ id: 5 }] as any
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
.mockReturnValue(true)
const ownsDocument = jest
.spyOn(permissionsService, 'currentUserOwnsObject')
.mockReturnValue(false)
jest
.spyOn(documentListViewService, 'documents', 'get')
.mockReturnValue([{ id: 3 }])
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3]))
expect(component.canRunWorkflow).toBe(false)
ownsDocument.mockReturnValue(true)
expect(component.canRunWorkflow).toBe(true)
})
it('should set modal dialog text accordingly for tag edit confirmation', () => { it('should set modal dialog text accordingly for tag edit confirmation', () => {
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1])) modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
@@ -1237,14 +1266,15 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/delete/` `${environment.apiBaseUrl}documents/delete/`
) )
const notifySpy = jest const trackSpy = jest
.spyOn(trashService, 'notifyMovedToTrash') .spyOn(trashService, 'trackMovedToTrash')
.mockImplementation(() => {}) .mockImplementation(() => {})
req.flush(true) req.flush({ result: 'OK', task_id: 'delete-1' })
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: [3, 4], documents: [3, 4],
from_webui: true,
}) })
expect(notifySpy).toHaveBeenCalledWith(expect.any(String), [3, 4]) expect(trackSpy).toHaveBeenCalledWith('delete-1', 2, [3, 4])
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
@@ -1261,9 +1291,9 @@ describe('BulkEditorComponent', () => {
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/delete/` `${environment.apiBaseUrl}documents/delete/`
) )
req.flush(true) req.flush({ result: 'OK', task_id: 'delete-2' })
// ids aren't known when all matching documents are selected, so no undo // ids aren't known when all matching documents are selected, so no undo
expect(notifySpy).toHaveBeenLastCalledWith(expect.any(String), null) expect(trackSpy).toHaveBeenLastCalledWith('delete-2', 0, null)
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
@@ -1343,10 +1373,11 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/reprocess/` `${environment.apiBaseUrl}documents/reprocess/`
) )
req.flush(true) req.flush({ result: 'OK', task_id: 'reprocess-1' })
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: [3, 4], documents: [3, 4],
remote_ocr: false, remote_ocr: false,
from_webui: true,
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
@@ -1356,54 +1387,8 @@ describe('BulkEditorComponent', () => {
) // listAllFilteredIds ) // listAllFilteredIds
}) })
it('should support run zone ocr', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0]))
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
.spyOn(documentListViewService, 'documents', 'get')
.mockReturnValue([{ id: 3 }, { id: 4 }])
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 4]))
jest
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
.mockReturnValue(true)
fixture.detectChanges()
component.runZoneOcrSelected()
expect(modal).not.toBeUndefined()
modal.componentInstance.confirm()
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/run_zone_ocr/`
)
req.flush(true)
expect(req.request.body).toEqual({
documents: [3, 4],
})
component.runZoneOcrSelected()
modal.componentInstance.template = 3
modal.componentInstance.confirm()
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/run_zone_ocr/`
)
req.flush(true)
expect(req.request.body).toEqual({
documents: [3, 4],
template: 3,
})
httpTestingController.match(
`${environment.apiBaseUrl}ocr_templates/?page=1&page_size=100000`
) // template list
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
})
it('should support rotate', () => { it('should support rotate', () => {
const toast = jest.spyOn(toastService, 'show')
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0])) modalService.activeInstances.subscribe((m) => (modal = m[0]))
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
@@ -1424,11 +1409,20 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/rotate/` `${environment.apiBaseUrl}documents/rotate/`
) )
req.flush(true) req.flush({ result: 'OK', task_id: 'rotate-1' })
TestBed.inject(TaskNotificationService).complete({
task_id: 'rotate-1',
operation: 'rotate',
status: PaperlessTaskStatus.Success,
})
expect(toast.mock.calls.at(-1)[0].content).toBe(
"Bulk operation 'Rotate documents' submitted for processing."
)
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: [3, 4], documents: [3, 4],
degrees: 90, degrees: 90,
source_mode: 'latest_version', source_mode: 'latest_version',
from_webui: true,
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
@@ -1466,10 +1460,11 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/merge/` `${environment.apiBaseUrl}documents/merge/`
) )
req.flush(true) req.flush({ result: 'OK', task_id: 'merge-1' })
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: [3, 4], documents: [3, 4],
metadata_document_id: 3, metadata_document_id: 3,
from_webui: true,
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
@@ -1484,11 +1479,12 @@ describe('BulkEditorComponent', () => {
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/merge/` `${environment.apiBaseUrl}documents/merge/`
) )
req.flush(true) req.flush({ result: 'OK', task_id: 'merge-2' })
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: [3, 4], documents: [3, 4],
metadata_document_id: 3, metadata_document_id: 3,
delete_originals: true, delete_originals: true,
from_webui: true,
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
@@ -1505,11 +1501,12 @@ describe('BulkEditorComponent', () => {
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/merge/` `${environment.apiBaseUrl}documents/merge/`
) )
req.flush(true) req.flush({ result: 'OK', task_id: 'merge-3' })
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: [3, 4], documents: [3, 4],
metadata_document_id: 3, metadata_document_id: 3,
archive_fallback: true, archive_fallback: true,
from_webui: true,
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
@@ -1521,6 +1518,7 @@ describe('BulkEditorComponent', () => {
}) })
it('should support merging documents as versions', () => { it('should support merging documents as versions', () => {
jest.useFakeTimers()
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0])) modalService.activeInstances.subscribe((m) => (modal = m[0]))
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
@@ -1548,8 +1546,8 @@ describe('BulkEditorComponent', () => {
.mockReturnValue(true) .mockReturnValue(true)
const mergeAsVersionsSpy = jest const mergeAsVersionsSpy = jest
.spyOn(documentService, 'mergeDocumentsAsVersions') .spyOn(documentService, 'mergeDocumentsAsVersions')
.mockReturnValue(of(true)) .mockReturnValue(of({ result: 'OK', task_id: 'versions-1' }))
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess') const toastSpy = jest.spyOn(toastService, 'show')
fixture.detectChanges() fixture.detectChanges()
component.mergeSelectedAsVersions() component.mergeSelectedAsVersions()
@@ -1565,9 +1563,21 @@ describe('BulkEditorComponent', () => {
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) )
expect(documentListViewService.selected.size).toEqual(0) expect(documentListViewService.selected.size).toEqual(0)
expect(toastSuccessSpy).toHaveBeenCalledWith( jest.advanceTimersByTime(400)
'Documents merged as versions.' expect(toastSpy).toHaveBeenCalledWith(
expect.objectContaining({
content: "Bulk operation 'Merge as versions' queued.",
})
) )
TestBed.inject(TaskNotificationService).complete({
task_id: 'versions-1',
operation: 'merge_as_versions',
status: PaperlessTaskStatus.Success,
})
expect(toastSpy.mock.calls.at(-1)[0].content).toBe(
"Bulk operation 'Merge as versions' completed."
)
jest.useRealTimers()
}) })
it('should not report success when merging documents as versions fails', () => { it('should not report success when merging documents as versions fails', () => {
@@ -1658,12 +1668,22 @@ describe('BulkEditorComponent', () => {
) )
}) })
it.each([true, false])( it.each<[boolean, number]>([
'shows queued feedback and gates the task link on permission (%s)', [true, 2],
(canViewTasks) => { [false, 2],
[true, 1],
])(
'names queued work and gates the task link (permission %s, documents %s)',
(canViewTasks, count) => {
jest.useFakeTimers()
jest jest
.spyOn(permissionsService, 'currentUserCan') .spyOn(permissionsService, 'currentUserCan')
.mockReturnValue(canViewTasks) .mockReturnValue(canViewTasks)
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(
new Set(Array.from({ length: count }, (_, index) => index + 3))
)
const queued = jest.spyOn(toastService, 'show') const queued = jest.spyOn(toastService, 'show')
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((instances) => { modalService.activeInstances.subscribe((instances) => {
@@ -1681,8 +1701,11 @@ describe('BulkEditorComponent', () => {
) )
req.flush({ result: 'OK', task_id: 'queued-operation' }) req.flush({ result: 'OK', task_id: 'queued-operation' })
expect(close).toHaveBeenCalledTimes(1) expect(close).toHaveBeenCalledTimes(1)
expect(queued).not.toHaveBeenCalled()
jest.advanceTimersByTime(400)
const notification = queued.mock.calls.at(-1)[0] const notification = queued.mock.calls.at(-1)[0]
expect(notification.content).toBe('Bulk operation queued.') const prefix = count > 1 ? 'Bulk operation' : 'Operation'
expect(notification.content).toBe(`${prefix} \'Set permissions\' queued.`)
if (canViewTasks) { if (canViewTasks) {
expect(notification.actionName).toBe('View task') expect(notification.actionName).toBe('View task')
notification.action() notification.action()
@@ -1696,6 +1719,15 @@ describe('BulkEditorComponent', () => {
expect(reload).not.toHaveBeenCalled() expect(reload).not.toHaveBeenCalled()
expect(documentListViewService.selected.size).toBe(0) expect(documentListViewService.selected.size).toBe(0)
expect(documentListViewService.allSelected).toBe(false) expect(documentListViewService.allSelected).toBe(false)
TestBed.inject(TaskNotificationService).complete({
task_id: 'queued-operation',
operation: 'set_permissions',
status: PaperlessTaskStatus.Success,
})
const completed = queued.mock.calls.at(-1)[0]
expect(completed.content).toBe(`${prefix} \'Set permissions\' completed.`)
expect(completed.action).toBeUndefined()
jest.useRealTimers()
} }
) )
@@ -1776,7 +1808,6 @@ describe('BulkEditorComponent', () => {
{ id: 2, name: 'Tag 2' }, { id: 2, name: 'Tag 2' },
], ],
count: 2, count: 2,
all: [1, 2],
} }
const modalInstance = { const modalInstance = {
@@ -1819,7 +1850,6 @@ describe('BulkEditorComponent', () => {
{ id: 2, name: 'Correspondent 2' }, { id: 2, name: 'Correspondent 2' },
], ],
count: 2, count: 2,
all: [1, 2],
} }
const modalInstance = { const modalInstance = {
@@ -1868,7 +1898,6 @@ describe('BulkEditorComponent', () => {
{ id: 2, name: 'Document Type 2' }, { id: 2, name: 'Document Type 2' },
], ],
count: 2, count: 2,
all: [1, 2],
} }
const modalInstance = { const modalInstance = {
@@ -1914,7 +1943,6 @@ describe('BulkEditorComponent', () => {
{ id: 2, name: 'Storage Path 2' }, { id: 2, name: 'Storage Path 2' },
], ],
count: 2, count: 2,
all: [1, 2],
} }
const modalInstance = { const modalInstance = {
@@ -1968,7 +1996,6 @@ describe('BulkEditorComponent', () => {
}, },
], ],
count: 2, count: 2,
all: [1, 2],
} }
const modalInstance = { const modalInstance = {
@@ -19,25 +19,18 @@ import {
NgbModalRef, NgbModalRef,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { saveAs } from 'file-saver' import { saveAs } from 'file-saver'
import { import { first, map, Observable, Subject, switchMap, takeUntil } from 'rxjs'
first,
map,
Observable,
Subject,
switchMap,
takeUntil,
tap,
} from 'rxjs'
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component' import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
import { BulkOperationResponse } from 'src/app/data/bulk-operation'
import { CustomField } from 'src/app/data/custom-field' import { CustomField } from 'src/app/data/custom-field'
import { MatchingModel } from 'src/app/data/matching-model' import { MatchingModel } from 'src/app/data/matching-model'
import { SelectionDataItem } from 'src/app/data/results' import { SelectionDataItem } from 'src/app/data/results'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { Workflow } from 'src/app/data/workflow' import { Workflow } from 'src/app/data/workflow'
import { WorkflowTriggerType } from 'src/app/data/workflow-trigger'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import { import {
PermissionAction, PermissionAction,
PermissionsService, PermissionsService,
@@ -52,14 +45,18 @@ import {
DocumentService, DocumentService,
MergeDocumentsRequest, MergeDocumentsRequest,
} from 'src/app/services/rest/document.service' } from 'src/app/services/rest/document.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service' import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
import { WorkflowService } from 'src/app/services/rest/workflow.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { TagService } from 'src/app/services/rest/tag.service' import { TagService } from 'src/app/services/rest/tag.service'
import { WorkflowService } from 'src/app/services/rest/workflow.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { TaskNotificationService } from 'src/app/services/task-notification.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { TrashService } from 'src/app/services/trash.service' import { TrashService } from 'src/app/services/trash.service'
import {
documentOperationHasFollowUpTasks,
getDocumentOperationLabel,
} from 'src/app/utils/document-operation'
import { flattenTags } from 'src/app/utils/flatten-tags' import { flattenTags } from 'src/app/utils/flatten-tags'
import { queryParamsFromFilterRules } from 'src/app/utils/query-params' import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
import { MergeAsVersionsConfirmDialogComponent } from '../../common/confirm-dialog/merge-as-versions-confirm-dialog/merge-as-versions-confirm-dialog.component' import { MergeAsVersionsConfirmDialogComponent } from '../../common/confirm-dialog/merge-as-versions-confirm-dialog/merge-as-versions-confirm-dialog.component'
@@ -67,7 +64,6 @@ import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-c
import { ReprocessConfirmDialogComponent } from '../../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component' import { ReprocessConfirmDialogComponent } from '../../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component' import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
import { RunWorkflowConfirmDialogComponent } from '../../common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component' import { RunWorkflowConfirmDialogComponent } from '../../common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component'
import { ZoneOcrConfirmDialogComponent } from '../../common/confirm-dialog/zone-ocr-confirm-dialog/zone-ocr-confirm-dialog.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 { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component' import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-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'
@@ -85,7 +81,6 @@ import { PermissionsDialogComponent } from '../../common/permissions-dialog/perm
import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component' import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component' import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
import { CustomFieldsBulkEditDialogComponent } from './custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component' import { CustomFieldsBulkEditDialogComponent } from './custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component'
import { WorkflowTriggerType } from 'src/app/data/workflow-trigger'
@Component({ @Component({
selector: 'pngx-bulk-editor', selector: 'pngx-bulk-editor',
@@ -111,13 +106,12 @@ export class BulkEditorComponent
private documentService = inject(DocumentService) private documentService = inject(DocumentService)
private trashService = inject(TrashService) private trashService = inject(TrashService)
private modalService = inject(NgbModal) private modalService = inject(NgbModal)
private openDocumentService = inject(OpenDocumentsService)
private settings = inject(SettingsService) private settings = inject(SettingsService)
private toastService = inject(ToastService) private toastService = inject(ToastService)
private taskNotifications = inject(TaskNotificationService)
private storagePathService = inject(StoragePathService) private storagePathService = inject(StoragePathService)
private customFieldService = inject(CustomFieldsService) private customFieldService = inject(CustomFieldsService)
public readonly permissionService = inject(PermissionsService) public readonly permissionService = inject(PermissionsService)
private savedViewService = inject(SavedViewService)
private readonly shareLinkBundleService = inject(ShareLinkBundleService) private readonly shareLinkBundleService = inject(ShareLinkBundleService)
private readonly workflowService = inject(WorkflowService) private readonly workflowService = inject(WorkflowService)
private readonly router = inject(Router) private readonly router = inject(Router)
@@ -309,7 +303,9 @@ export class BulkEditorComponent
} }
get canRunWorkflow(): boolean { get canRunWorkflow(): boolean {
return this.userCanEditAll && this.manualWorkflows.length > 0 return (
this.userCanEditAll && this.userOwnsAll && this.manualWorkflows.length > 0
)
} }
runWorkflowSelected() { runWorkflowSelected() {
@@ -323,10 +319,23 @@ export class BulkEditorComponent
modal.componentInstance.confirmClicked modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => { .subscribe(() => {
this.executeBulkEditMethod(modal, 'run_workflow', { modal.componentInstance.buttonsEnabled.set(false)
this.documentService
.bulkEdit(this.getSelectionQuery(), 'run_workflow', {
workflow: modal.componentInstance.workflowId, workflow: modal.componentInstance.workflowId,
filter_documents: !modal.componentInstance.force, filter_documents: !modal.componentInstance.force,
}) })
.pipe(first())
.subscribe({
next: () => {
modal.close()
this.list.selectNone()
this.toastService.showInfo(
$localize`Workflow will run on the selected documents in the background.`
)
},
error: (error) => this.handleOperationError(modal, error),
})
}) })
} }
@@ -343,70 +352,62 @@ export class BulkEditorComponent
.bulkEdit(overrideSelection ?? this.getSelectionQuery(), method, args) .bulkEdit(overrideSelection ?? this.getSelectionQuery(), method, args)
.pipe(first()) .pipe(first())
.subscribe({ .subscribe({
next: ({ task_id }) => this.handleOperationQueued(modal, task_id), next: ({ task_id }) =>
this.handleOperationQueued(modal, task_id, method),
error: (error) => this.handleOperationError(modal, error), error: (error) => this.handleOperationError(modal, error),
}) })
} }
private handleOperationQueued(modal: NgbModalRef, taskId: string) { private handleOperationQueued(
modal: NgbModalRef,
taskId: string,
operation: string,
onQueued?: (taskId: string) => void
) {
const bulk = this.getSelectionSize() > 1
modal?.close() modal?.close()
this.list.selectNone() this.list.selectNone()
const canViewTasks = this.permissionService.currentUserCan( if (onQueued) {
PermissionAction.View, onQueued(taskId)
PermissionType.PaperlessTask } else {
const label = getDocumentOperationLabel(operation)
const submittedMessage = bulk
? $localize`Bulk operation '${label}' submitted for processing.`
: $localize`Operation '${label}' submitted for processing.`
const completedMessage = bulk
? $localize`Bulk operation '${label}' completed.`
: $localize`Operation '${label}' completed.`
this.taskNotifications.showQueued(
taskId,
bulk
? $localize`Bulk operation '${label}' queued.`
: $localize`Operation '${label}' queued.`,
{
content: documentOperationHasFollowUpTasks(operation)
? submittedMessage
: completedMessage,
}
) )
this.toastService.show({
content: $localize`Bulk operation queued.`,
delay: 5000,
...(canViewTasks
? {
actionName: $localize`View task`,
action: () =>
this.router.navigate(['/tasks'], {
queryParams: { task_id: taskId },
}),
} }
: {}),
})
} }
private executeDocumentAction( private executeDocumentAction(
modal: NgbModalRef, modal: NgbModalRef,
request: Observable<any>, operation: string,
options: { clearSelection?: boolean; successMessage?: string } = {} request: Observable<BulkOperationResponse>,
onQueued?: (taskId: string) => void
) { ) {
if (modal) { if (modal) {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled.set(false)
} }
request.pipe(first()).subscribe({ request.pipe(first()).subscribe({
next: () => { next: ({ task_id }) => {
this.handleOperationSuccess(modal, options.clearSelection ?? false) this.handleOperationQueued(modal, task_id, operation, onQueued)
if (options.successMessage) {
this.toastService.showSuccess(options.successMessage)
}
}, },
error: (error) => this.handleOperationError(modal, error), error: (error) => this.handleOperationError(modal, error),
}) })
} }
private handleOperationSuccess(
modal: NgbModalRef,
clearSelection: boolean = false
) {
if (clearSelection) {
this.list.selected.clear()
}
this.list.reload()
this.list.reduceSelectionToFilter()
this.list.selected.forEach((id) => {
this.openDocumentService.refreshDocument(id)
})
this.savedViewService.maybeRefreshDocumentCounts()
if (modal) {
modal.close()
}
}
private handleOperationError(modal: NgbModalRef, error: any) { private handleOperationError(modal: NgbModalRef, error: any) {
if (modal) { if (modal) {
modal.componentInstance.buttonsEnabled.set(true) modal.componentInstance.buttonsEnabled.set(true)
@@ -974,16 +975,9 @@ export class BulkEditorComponent
const count = this.getSelectionSize() const count = this.getSelectionSize()
this.executeDocumentAction( this.executeDocumentAction(
modal, modal,
this.documentService 'delete',
.deleteDocuments(this.getSelectionQuery()) this.documentService.deleteDocuments(this.getSelectionQuery()),
.pipe( (taskId) => this.trashService.trackMovedToTrash(taskId, count, documents)
tap(() =>
this.trashService.notifyMovedToTrash(
$localize`Moved ${count} document(s) to the trash`,
documents
)
)
)
) )
} }
@@ -1024,6 +1018,7 @@ export class BulkEditorComponent
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled.set(false)
this.executeDocumentAction( this.executeDocumentAction(
modal, modal,
'reprocess',
this.documentService.reprocessDocuments( this.documentService.reprocessDocuments(
this.getSelectionQuery(), this.getSelectionQuery(),
modal.componentInstance.remoteOcr modal.componentInstance.remoteOcr
@@ -1032,30 +1027,6 @@ export class BulkEditorComponent
}) })
} }
runZoneOcrSelected() {
const modal = this.modalService.open(ZoneOcrConfirmDialogComponent, {
backdrop: 'static',
})
const dialog = modal.componentInstance as ZoneOcrConfirmDialogComponent
dialog.title = $localize`Run zone OCR confirm`
dialog.messageBold = $localize`This operation will run zone OCR on ${this.getSelectionSize()} selected document(s).`
dialog.message = $localize`Fields filled by the template will be overwritten.`
dialog.btnClass = 'btn-warning'
dialog.btnCaption = $localize`Proceed`
dialog.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
dialog.buttonsEnabled.set(false)
this.executeDocumentAction(
modal,
this.documentService.runZoneOcrDocuments(
this.getSelectionQuery(),
dialog.template
)
)
})
}
setPermissions() { setPermissions() {
let modal = this.modalService.open(PermissionsDialogComponent, { let modal = this.modalService.open(PermissionsDialogComponent, {
backdrop: 'static', backdrop: 'static',
@@ -1088,6 +1059,7 @@ export class BulkEditorComponent
rotateDialog.buttonsEnabled.set(false) rotateDialog.buttonsEnabled.set(false)
this.executeDocumentAction( this.executeDocumentAction(
modal, modal,
'rotate',
this.documentService.rotateDocuments( this.documentService.rotateDocuments(
this.getSelectionQuery(), this.getSelectionQuery(),
rotateDialog.degrees rotateDialog.degrees
@@ -1121,11 +1093,8 @@ export class BulkEditorComponent
mergeDialog.buttonsEnabled.set(false) mergeDialog.buttonsEnabled.set(false)
this.executeDocumentAction( this.executeDocumentAction(
modal, modal,
this.documentService.mergeDocuments(mergeDialog.documentIDs(), args), 'merge',
{ clearSelection: !!args.delete_originals } this.documentService.mergeDocuments(mergeDialog.documentIDs(), args)
)
this.toastService.showInfo(
$localize`Merged document will be queued for consumption.`
) )
}) })
} }
@@ -1147,14 +1116,11 @@ export class BulkEditorComponent
.subscribe(() => { .subscribe(() => {
this.executeDocumentAction( this.executeDocumentAction(
modal, modal,
'merge_as_versions',
this.documentService.mergeDocumentsAsVersions( this.documentService.mergeDocumentsAsVersions(
mergeDialog.documentIDs(), mergeDialog.documentIDs(),
mergeDialog.rootDocumentID() mergeDialog.rootDocumentID()
), )
{
clearSelection: true,
successMessage: $localize`Documents merged as versions.`,
}
) )
}) })
} }
@@ -1179,7 +1145,7 @@ export class BulkEditorComponent
dialog.selection = this.getSelectionQuery() dialog.selection = this.getSelectionQuery()
dialog.selectionCount = this.getSelectionSize() dialog.selectionCount = this.getSelectionSize()
dialog.succeeded.subscribe(({ task_id }) => dialog.succeeded.subscribe(({ task_id }) =>
this.handleOperationQueued(null, task_id) this.handleOperationQueued(null, task_id, 'modify_custom_fields')
) )
dialog.failed.subscribe((error) => { dialog.failed.subscribe((error) => {
this.toastService.showError( this.toastService.showError(
@@ -16,7 +16,7 @@
</pngx-input-select> </pngx-input-select>
<pngx-input-check i18n-title title="Show in sidebar" formControlName="showInSideBar"></pngx-input-check> <pngx-input-check i18n-title title="Show in sidebar" formControlName="showInSideBar"></pngx-input-check>
<pngx-input-check i18n-title title="Show on dashboard" formControlName="showOnDashboard"></pngx-input-check> <pngx-input-check i18n-title title="Show on dashboard" formControlName="showOnDashboard"></pngx-input-check>
<pngx-permissions-form accordion="true" formControlName="permissions_form"></pngx-permissions-form> <pngx-permissions-form [users]="users()" accordion="true" formControlName="permissions_form"></pngx-permissions-form>
@if (error()?.filter_rules) { @if (error()?.filter_rules) {
<div class="alert alert-danger" role="alert"> <div class="alert alert-danger" role="alert">
<h6 class="alert-heading" i18n>Filter rules error occurred while saving this view</h6> <h6 class="alert-heading" i18n>Filter rules error occurred while saving this view</h6>
@@ -3,6 +3,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { By } from '@angular/platform-browser' import { By } from '@angular/platform-browser'
import { NgbActiveModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
import { of } from 'rxjs' import { of } from 'rxjs'
import { User } from 'src/app/data/user'
import { GroupService } from 'src/app/services/rest/group.service' import { GroupService } from 'src/app/services/rest/group.service'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { CheckComponent } from '../../common/input/check/check.component' import { CheckComponent } from '../../common/input/check/check.component'
@@ -14,6 +15,7 @@ import { TextComponent } from '../../common/input/text/text.component'
import { SaveViewConfigDialogComponent } from './save-view-config-dialog.component' import { SaveViewConfigDialogComponent } from './save-view-config-dialog.component'
describe('SaveViewConfigDialogComponent', () => { describe('SaveViewConfigDialogComponent', () => {
const users = [{ id: 10, username: 'user10' }] as User[]
let component: SaveViewConfigDialogComponent let component: SaveViewConfigDialogComponent
let fixture: ComponentFixture<SaveViewConfigDialogComponent> let fixture: ComponentFixture<SaveViewConfigDialogComponent>
let modal: NgbActiveModal let modal: NgbActiveModal
@@ -25,7 +27,7 @@ describe('SaveViewConfigDialogComponent', () => {
{ {
provide: UserService, provide: UserService,
useValue: { useValue: {
listAll: () => of({ results: [] }), listAll: () => of({ results: users }),
}, },
}, },
{ {
@@ -124,6 +126,19 @@ describe('SaveViewConfigDialogComponent', () => {
}) })
}) })
it('should list users in the owner selector', () => {
fixture.debugElement
.query(By.css('button[ngbAccordionButton]'))
.nativeElement.click()
fixture.detectChanges()
const permissionsForm = fixture.debugElement.query(
By.directive(PermissionsFormComponent)
)
const ownerSelect = permissionsForm.query(By.directive(SelectComponent))
expect(ownerSelect.componentInstance.items).toEqual(users)
})
it('should support default name', () => { it('should support default name', () => {
const saveClickedSpy = jest.spyOn(component.saveClicked, 'emit') const saveClickedSpy = jest.spyOn(component.saveClicked, 'emit')
const modalCloseSpy = jest.spyOn(modal, 'close') const modalCloseSpy = jest.spyOn(modal, 'close')
@@ -6,6 +6,7 @@ import {
inject, inject,
signal, signal,
} from '@angular/core' } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { import {
FormControl, FormControl,
FormGroup, FormGroup,
@@ -13,11 +14,14 @@ import {
ReactiveFormsModule, ReactiveFormsModule,
} from '@angular/forms' } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { catchError, map, of } from 'rxjs'
import { import {
DEFAULT_SAVED_VIEW_ICON, DEFAULT_SAVED_VIEW_ICON,
SAVED_VIEW_ICONS, SAVED_VIEW_ICONS,
} from 'src/app/data/saved-view-icons' } from 'src/app/data/saved-view-icons'
import { User } from 'src/app/data/user' import { User } from 'src/app/data/user'
import { UserService } from 'src/app/services/rest/user.service'
import { ToastService } from 'src/app/services/toast.service'
import { CheckComponent } from '../../common/input/check/check.component' import { CheckComponent } from '../../common/input/check/check.component'
import { PermissionsFormComponent } from '../../common/input/permissions/permissions-form/permissions-form.component' import { PermissionsFormComponent } from '../../common/input/permissions/permissions-form/permissions-form.component'
import { SelectComponent } from '../../common/input/select/select.component' import { SelectComponent } from '../../common/input/select/select.component'
@@ -38,6 +42,8 @@ import { TextComponent } from '../../common/input/text/text.component'
}) })
export class SaveViewConfigDialogComponent implements OnInit { export class SaveViewConfigDialogComponent implements OnInit {
private modal = inject(NgbActiveModal) private modal = inject(NgbActiveModal)
private userService = inject(UserService)
private toastService = inject(ToastService)
readonly error = signal(undefined) readonly error = signal(undefined)
readonly buttonsEnabled = signal(true) readonly buttonsEnabled = signal(true)
readonly defaultName = signal('') readonly defaultName = signal('')
@@ -46,7 +52,16 @@ export class SaveViewConfigDialogComponent implements OnInit {
@Output() @Output()
public saveClicked = new EventEmitter() public saveClicked = new EventEmitter()
users: User[] readonly users = toSignal(
this.userService.listAll().pipe(
map((result) => result.results),
catchError((error) => {
this.toastService.showError($localize`Error retrieving users`, error)
return of([])
})
),
{ initialValue: undefined as User[] }
)
readonly savedViewIcons = SAVED_VIEW_ICONS readonly savedViewIcons = SAVED_VIEW_ICONS
setDefaultName(value: string) { setDefaultName(value: string) {
@@ -16,11 +16,14 @@
font-weight: bold; font-weight: bold;
} }
h1 { font-size: 1.25em; }
h2 { font-size: 1.15em; }
h3 { font-size: 1.05em; }
table { table {
margin-bottom: 1rem; margin-bottom: 1rem;
display: block; display: block;
overflow-x: auto; overflow-x: auto;
white-space: nowrap;
} }
th, td { th, td {
@@ -1,36 +0,0 @@
@if (zones.length === 0) {
<p class="text-muted" i18n>No zones yet. Drag anywhere on the sample document to create one.</p>
} @else {
<div class="list-group">
@for (zone of zones; track $index; let i = $index) {
<div class="list-group-item list-group-item-action d-flex align-items-center gap-3 position-relative">
<span class="p-2 rounded flex-shrink-0" [style.background-color]="zoneColor(i)" aria-hidden="true"></span>
<div class="flex-grow-1 text-truncate">
<button type="button" class="btn btn-link p-0 fw-semibold text-reset text-decoration-none text-truncate mw-100 stretched-link" (click)="zoneSelected.emit(i)">{{ zone.name }}</button>
@if (!hasField(zone)) {
<div class="small text-warning text-truncate">
<pngx-bs-icon name="exclamation-triangle" class="me-1" aria-hidden="true"></pngx-bs-icon><span i18n>No field selected</span>
</div>
} @else {
<div class="small text-muted text-truncate">
{{ getZoneTargetName(zone) }}
@if (isCombined(zone)) {
<pngx-bs-icon name="braces" class="ms-1" title="Combined with other zones" i18n-title></pngx-bs-icon>
}
</div>
}
</div>
<small class="text-muted text-nowrap">
@if (zone.page === -1) {
<ng-container i18n>Last page</ng-container>
} @else {
<ng-container i18n>Page {{ zone.page ?? 1 }}</ng-container>
}
</small>
<button type="button" class="btn btn-sm btn-link text-danger position-relative z-2" (click)="zoneRemoved.emit(i)" title="Delete zone" i18n-title aria-label="Delete zone" i18n-aria-label>
<pngx-bs-icon name="trash"></pngx-bs-icon>
</button>
</div>
}
</div>
}
@@ -1,115 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
import { CustomField } from 'src/app/data/custom-field'
import {
OcrTemplateZone,
OcrZoneTarget,
OcrZoneTransform,
} from 'src/app/data/ocr-template'
import { OcrTemplateEditorZoneListComponent } from './ocr-template-editor-zone-list.component'
function zone(overrides: Partial<OcrTemplateZone> = {}): OcrTemplateZone {
return {
name: 'Zone 1',
target: OcrZoneTarget.CustomField,
custom_field: 7,
x: 10,
y: 20,
width: 30,
height: 40,
page: 1,
ocr_language: 'eng',
transform: OcrZoneTransform.Strip,
validation_regex: '',
order: 0,
...overrides,
}
}
describe('OcrTemplateEditorZoneListComponent', () => {
let fixture: ComponentFixture<OcrTemplateEditorZoneListComponent>
let component: OcrTemplateEditorZoneListComponent
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [OcrTemplateEditorZoneListComponent, BootstrapIconComponent],
}).compileComponents()
fixture = TestBed.createComponent(OcrTemplateEditorZoneListComponent)
component = fixture.componentInstance
})
it('shows empty state when no zones are defined', () => {
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('No zones yet')
})
it('renders zone name, target, and page', () => {
component.zones = [zone(), zone({ name: 'Zone 2', page: -1 })]
component.customFields = [{ id: 7, name: 'Invoice Number' } as CustomField]
fixture.detectChanges()
const text = fixture.nativeElement.textContent
expect(text).toContain('Zone 1')
expect(text).toContain('Invoice Number')
expect(text).toContain('Page 1')
expect(text).toContain('Last page')
})
it('marks zones that share a field', () => {
component.zones = [
zone(),
zone({ name: 'Zone 2' }),
zone({ name: 'Zone 3', custom_field: 8 }),
]
fixture.detectChanges()
expect(component.isCombined(component.zones[0])).toBe(true)
expect(component.isCombined(component.zones[2])).toBe(false)
expect(
fixture.nativeElement.querySelectorAll(
'[title="Combined with other zones"]'
)
).toHaveLength(2)
})
it('warns only for zones without a field and clears the warning when assigned', () => {
component.zones = [
zone({ custom_field: null }),
zone({ target: OcrZoneTarget.Title, custom_field: null }),
zone(),
]
fixture.detectChanges()
const warnings = () =>
fixture.nativeElement.querySelectorAll('.text-warning')
expect(warnings()).toHaveLength(1)
expect(warnings()[0].textContent).toContain('No field selected')
component.zones[0].custom_field = 7
fixture.componentRef.setInput('zones', [...component.zones])
fixture.detectChanges()
expect(warnings()).toHaveLength(0)
})
it('emits select and remove events', () => {
component.zones = [zone()]
const selectSpy = jest.spyOn(component.zoneSelected, 'emit')
const removeSpy = jest.spyOn(component.zoneRemoved, 'emit')
fixture.detectChanges()
const buttons = fixture.nativeElement.querySelectorAll('button')
buttons[0].click()
buttons[1].click()
expect(selectSpy).toHaveBeenCalledWith(0)
expect(removeSpy).toHaveBeenCalledWith(0)
})
it('does not label an unknown built-in target', () => {
expect(
component.getZoneTargetName(zone({ target: 'unknown' as OcrZoneTarget }))
).toBe('')
})
})
@@ -1,50 +0,0 @@
import { Component, EventEmitter, Input, Output } from '@angular/core'
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
import { CustomField } from 'src/app/data/custom-field'
import {
getZoneFieldKey,
OCR_BUILTIN_TARGETS,
OcrTemplateZone,
OcrZoneTarget,
} from 'src/app/data/ocr-template'
import { getZoneColor } from '../zone-geometry'
@Component({
selector: 'pngx-ocr-template-zone-list',
imports: [BootstrapIconComponent],
templateUrl: './ocr-template-editor-zone-list.component.html',
})
export class OcrTemplateEditorZoneListComponent {
@Input() zones: OcrTemplateZone[] = []
@Input() customFields: CustomField[] = []
@Output() public readonly zoneSelected = new EventEmitter<number>()
@Output() public readonly zoneRemoved = new EventEmitter<number>()
public readonly zoneColor = getZoneColor
hasField(zone: OcrTemplateZone): boolean {
return getZoneFieldKey(zone) !== null
}
isCombined(zone: OcrTemplateZone): boolean {
const key = getZoneFieldKey(zone)
return (
key !== null &&
this.zones.filter((z) => getZoneFieldKey(z) === key).length > 1
)
}
getZoneTargetName(zone: OcrTemplateZone): string {
if (zone.target === OcrZoneTarget.CustomField) {
return (
this.customFields.find((field) => field.id === zone.custom_field)
?.name ?? ''
)
}
return (
OCR_BUILTIN_TARGETS.find((target) => target.id === zone.target)?.name ??
''
)
}
}
@@ -1,313 +0,0 @@
<div class="modal-header">
<h4 class="modal-title" id="ocr-template-editor-title">{{ dialogTitle }}</h4>
@if (template.id) {
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{ template.id }}</span>
}
<button type="button" class="btn-close" (click)="activeModal.dismiss()" [disabled]="saving()" aria-label="Close" i18n-aria-label></button>
</div>
<div class="modal-body d-flex flex-column">
<div class="row gx-3 editor-layout flex-grow-1 flex-lg-nowrap" [attr.inert]="saving() ? '' : null" [attr.aria-busy]="saving()">
<div class="col-lg-4 mb-3 mb-lg-0 overflow-auto">
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTab" class="nav-underline flex-nowrap flex-md-wrap overflow-auto">
<li ngbNavItem="template">
<a ngbNavLink i18n>Template</a>
<ng-template ngbNavContent>
<div class="row mb-3">
<div class="col-9">
<pngx-input-text [(ngModel)]="template.name" title="Template name" i18n-title></pngx-input-text>
</div>
<div class="col-3">
<pngx-input-switch [(ngModel)]="template.enabled" title="Enabled" i18n-title></pngx-input-switch>
</div>
</div>
<pngx-input-select [(ngModel)]="template.matching_algorithm" [items]="matchingAlgorithms" title="Matching algorithm" i18n-title></pngx-input-select>
@if (patternRequired) {
<pngx-input-text [(ngModel)]="template.match" title="Matching pattern" i18n-title></pngx-input-text>
<pngx-input-check [(ngModel)]="template.is_insensitive" title="Case insensitive" i18n-title></pngx-input-check>
}
<div *pngxIfOwner="object">
<hr />
<pngx-permissions-form [users]="users()" [accordion]="true" [(ngModel)]="permissionsForm"></pngx-permissions-form>
</div>
</ng-template>
</li>
<li ngbNavItem="zones">
<a ngbNavLink><ng-container i18n>Zones</ng-container> <span class="badge bg-primary ms-2">{{ template.zones.length }}</span></a>
<ng-template ngbNavContent>
@if (selectedZone; as zone) {
<div class="d-flex align-items-center gap-2 mb-3">
<button type="button" class="btn btn-sm btn-link px-0 me-auto" (click)="deselectZone()">
<pngx-bs-icon name="arrow-left" class="me-1"></pngx-bs-icon><ng-container i18n>All zones</ng-container>
</button>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="testZone()" [disabled]="!previewDocId || zoneTesting">
@if (zoneTesting) {
<span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span>
} @else {
<pngx-bs-icon name="play-fill" class="me-1"></pngx-bs-icon>
}
<ng-container i18n>Test</ng-container>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" (click)="removeZone(selectedZoneIndex)" title="Delete zone" i18n-title aria-label="Delete zone" i18n-aria-label>
<pngx-bs-icon name="trash"></pngx-bs-icon>
</button>
</div>
@if (zoneTestResult; as result) {
@if (result.error) {
<div class="alert alert-warning py-2 mb-3 small" role="alert">{{ result.error }}</div>
} @else {
<dl class="row small border rounded mx-0 py-2 mb-3">
<dt class="col-4" i18n>OCR text</dt>
<dd class="col-8">
@if (result.raw_text) {
<code>{{ result.raw_text }}</code>
} @else {
<span class="text-muted" i18n>Nothing detected</span>
}
</dd>
<dt class="col-4" i18n>Value</dt>
<dd class="col-8" [class.mb-0]="!result.regex">
@if (result.value) {
<code>{{ result.value }}</code>
} @else {
<span class="text-muted" i18n>Empty</span>
}
</dd>
@if (result.regex) {
<dt class="col-4" i18n>Validation</dt>
<dd class="col-8 mb-0">
@if (result.regex_match) {
<span class="badge bg-success" i18n>Regex matches</span>
} @else {
<span class="badge bg-danger" i18n>Regex does not match</span>
}
</dd>
}
</dl>
}
}
<pngx-input-text [ngModel]="zone.name" (ngModelChange)="renameZone(zone, $event)" title="Name" i18n-title></pngx-input-text>
<pngx-input-select
[items]="fieldItems()"
[ngModel]="zoneFieldValue(zone)"
(ngModelChange)="setZoneField(zone, $event)"
(createNew)="createField($event)"
title="Field"
i18n-title
placeholder="Choose a field"
i18n-placeholder
hint="Where the extracted value is written."
i18n-hint
></pngx-input-select>
@if (isFieldShared(zone)) {
<pngx-input-text
[ngModel]="getCombineFormat(zone)"
(ngModelChange)="setCombineFormat(zone, $event)"
[placeholder]="defaultCombineFormat(zone)"
title="Combined value"
i18n-title
hint="Several zones write to this field. Set the format or leave empty to join them with spaces."
i18n-hint
></pngx-input-text>
<div class="d-flex flex-wrap gap-1 mt-n2 mb-3">
@for (z of zonesForField(zone); track z) {
<button type="button" class="btn btn-sm btn-outline-secondary font-monospace" (click)="insertCombineToken(zone, z)">{{ '{' + z.name + '}' }}</button>
}
</div>
}
<div class="row align-items-end">
<div class="col">
@if (!isLastPageZone(zone)) {
<pngx-input-number [(ngModel)]="zone.page" [showAdd]="false" title="Page" i18n-title></pngx-input-number>
}
</div>
<div class="col-auto">
<pngx-input-switch [ngModel]="isLastPageZone(zone)" (ngModelChange)="setLastPageZone(zone, $event)" [horizontal]="true" title="Last page" i18n-title></pngx-input-switch>
</div>
</div>
<h6 class="border-top pt-3 mt-1 mb-3" i18n>Processing</h6>
<pngx-input-select
[items]="languageOptions"
[multiple]="true"
[ngModel]="ocrLanguageArray(zone)"
(ngModelChange)="setOcrLanguages(zone, $event)"
title="OCR language"
i18n-title
placeholder="Select languages"
i18n-placeholder
></pngx-input-select>
<pngx-input-select [items]="transformOptions" [(ngModel)]="zone.transform" title="Transform" i18n-title></pngx-input-select>
@if (zone.transform === OcrZoneTransform.Date) {
<pngx-input-text
[(ngModel)]="zone.date_format"
title="Date format"
i18n-title
placeholder="Auto-detect"
i18n-placeholder
hint="Python date codes, e.g. %d.%m.%Y. Leave empty to detect the format automatically."
i18n-hint
></pngx-input-text>
}
<pngx-input-text [(ngModel)]="zone.validation_regex" title="Validation regex" i18n-title placeholder="\d{2}\.\d{2}\.\d{4}" hint="The value must match this pattern to be applied." i18n-hint></pngx-input-text>
} @else {
<pngx-ocr-template-zone-list
[zones]="template.zones"
[customFields]="customFields()"
(zoneSelected)="selectZone($event)"
(zoneRemoved)="removeZone($event)"
></pngx-ocr-template-zone-list>
}
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav" class="mt-3"></div>
</div>
<div class="col-lg-8 d-flex flex-column">
<div class="d-flex flex-wrap gap-2 mb-2 flex-shrink-0">
<pngx-input-document-link
class="flex-grow-1"
[ngModel]="sampleDocumentIds"
(ngModelChange)="setSampleDocument($event)"
[minimal]="true"
[multiple]="false"
placeholder="Search for a sample document"
i18n-placeholder
></pngx-input-document-link>
@if (previewDocId) {
<div class="input-group w-auto flex-nowrap">
<input class="form-control flex-grow-0 preview-page-input" type="number" min="1" [max]="previewPageCount()" [(ngModel)]="previewPageDisplay" [disabled]="!pageLoaded()" aria-label="Page" i18n-aria-label />
<span class="input-group-text" i18n>of {{ previewPageCount() ?? '?' }}</span>
</div>
<fieldset class="input-group w-auto flex-nowrap" aria-label="Zoom" i18n-aria-label>
<button type="button" class="btn btn-outline-secondary" aria-label="Zoom out" i18n-aria-label [disabled]="!pageLoaded() || zoom <= 0.5" (click)="zoomOut()"><pngx-bs-icon name="dash"></pngx-bs-icon></button>
<button type="button" class="btn btn-outline-secondary preview-zoom" title="Reset zoom" i18n-title [disabled]="!pageLoaded()" (click)="resetZoom()">{{ zoom | percent: '1.0-0' }}</button>
<button type="button" class="btn btn-outline-secondary" aria-label="Zoom in" i18n-aria-label [disabled]="!pageLoaded() || zoom >= 4" (click)="zoomIn()"><pngx-bs-icon name="plus"></pngx-bs-icon></button>
</fieldset>
}
</div>
<div class="flex-grow-1 overflow-hidden">
<div class="h-100 overflow-auto border" [attr.aria-busy]="previewDocId && !pageLoaded()">
@if (pageFailed()) {
<div class="py-5 px-3 text-center" role="alert">
<pngx-bs-icon name="exclamation-triangle" width="32" height="32" class="text-warning"></pngx-bs-icon>
<p class="my-3" i18n>Could not load this page.</p>
<button type="button" class="btn btn-sm btn-outline-secondary" (click)="setSampleDocument(sampleDocumentIds)"><pngx-bs-icon name="arrow-clockwise" class="me-1"></pngx-bs-icon><span i18n>Retry</span></button>
</div>
} @else if (previewDocId) {
<div class="position-relative" [class.d-none]="!pageLoaded()" [style.width.%]="zoom * 100">
<img
#pageImage
[src]="pageImageUrl"
(load)="onImageLoad()"
(error)="onImageError()"
alt="Sample document page"
i18n-alt
class="d-block w-100"
crossorigin="use-credentials"
/>
@if (pageLoaded()) {
<svg
#zoneOverlay
class="zone-overlay position-absolute top-0 start-0 w-100 h-100"
[attr.viewBox]="overlayViewBox()"
preserveAspectRatio="none"
[style.cursor]="overlayCursor"
(pointerdown)="onOverlayPointerDown($event)"
(pointermove)="onOverlayPointerMove($event)"
(pointerup)="onOverlayPointerUp($event)"
(pointercancel)="onOverlayPointerCancel($event)"
(lostpointercapture)="onOverlayPointerCancel($event)"
aria-label="Zone overlay" i18n-aria-label
>
@for (zone of template.zones; track $index; let i = $index) {
@if (zoneDisplayRect(i); as rect) {
<g>
<rect
class="zone-rect"
[class.zone-rect-selected]="selectedZoneIndex === i"
[attr.x]="rect.x"
[attr.y]="rect.y"
[attr.width]="rect.w"
[attr.height]="rect.h"
[attr.stroke]="zoneColor(i)"
[attr.fill]="zoneColor(i)"
></rect>
<text
class="zone-label fw-semibold pe-none"
[attr.x]="rect.x + overlayUnitSize(6)"
[attr.y]="zoneLabelY(rect)"
[attr.font-size]="overlayFontSize()"
[attr.fill]="zoneColor(i)"
>{{ zoneLabel(zone, i) }}</text>
@if (selectedZoneIndex === i) {
@for (handle of resizeHandles(rect); track handle.handle) {
<rect
class="zone-resize-handle"
[attr.x]="handle.x - overlayHandleSize() / 2"
[attr.y]="handle.y - overlayHandleSize() / 2"
[attr.width]="overlayHandleSize()"
[attr.height]="overlayHandleSize()"
[attr.fill]="zoneColor(i)"
></rect>
}
}
</g>
}
}
@if (drawingRect(); as rect) {
<rect
class="zone-drawing-rect"
[attr.x]="rect.x"
[attr.y]="rect.y"
[attr.width]="rect.w"
[attr.height]="rect.h"
></rect>
}
</svg>
}
</div>
@if (!pageLoaded()) {
<div class="p-5 d-flex justify-content-center align-items-center">
<output class="spinner-border">
<span class="visually-hidden" i18n>Loading page...</span>
</output>
</div>
}
} @else {
<div class="py-5 px-3 text-center text-muted">
<pngx-bs-icon name="file-text" width="32" height="32"></pngx-bs-icon>
<p class="mt-3 mb-0" i18n>No sample document selected.</p>
</div>
}
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" (click)="activeModal.dismiss()" [disabled]="saving()" i18n>Cancel</button>
<button type="button" class="btn btn-primary" (click)="save()" [disabled]="saving() || !isDirty">
@if (saving()) {
<span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span>
}
<span i18n>Save</span>
</button>
</div>
@@ -1,64 +0,0 @@
:host {
display: flex;
flex-direction: column;
min-height: 0;
}
@media (min-width: 992px) {
.modal-body {
overflow: hidden;
}
.editor-layout {
min-height: 0;
}
}
.preview-page-input {
width: 3.5rem;
}
.preview-zoom {
min-width: 4rem;
}
.zone-overlay {
touch-action: none;
}
.zone-rect,
.zone-drawing-rect {
vector-effect: non-scaling-stroke;
}
.zone-rect {
fill-opacity: 0.2;
stroke-width: 2;
}
.zone-rect-selected {
stroke-width: 3;
}
.zone-label {
font-family: var(--bs-font-sans-serif);
paint-order: stroke;
stroke: #fff;
stroke-linejoin: round;
stroke-width: 4px;
vector-effect: non-scaling-stroke;
}
.zone-resize-handle {
stroke: #fff;
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.zone-drawing-rect {
fill: var(--bs-primary);
fill-opacity: 0.2;
stroke: var(--bs-primary);
stroke-dasharray: 5 5;
stroke-width: 2;
}
@@ -1,875 +0,0 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ElementRef } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
import { of, Subject, throwError } from 'rxjs'
import { PermissionsFormComponent } from 'src/app/components/common/input/permissions/permissions-form/permissions-form.component'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { Document } from 'src/app/data/document'
import {
MATCH_ANY,
MATCH_AUTO,
MATCH_LITERAL,
MATCH_NONE,
} from 'src/app/data/matching-model'
import {
OcrTemplate,
OcrTemplateZone,
OcrZoneTarget,
OcrZoneTestResult,
OcrZoneTransform,
} from 'src/app/data/ocr-template'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { User } from 'src/app/data/user'
import { PermissionsService } from 'src/app/services/permissions.service'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { OcrTemplateEditorComponent } from './ocr-template-editor.component'
const customFields: CustomField[] = [
{ id: 1, name: 'Invoice number', data_type: CustomFieldDataType.String },
{ id: 2, name: 'Status', data_type: CustomFieldDataType.Select },
]
function zone(overrides: Partial<OcrTemplateZone> = {}): OcrTemplateZone {
return {
name: 'Invoice number',
target: OcrZoneTarget.CustomField,
custom_field: 1,
x: 100,
y: 100,
width: 200,
height: 100,
page: 1,
ocr_language: 'eng',
transform: OcrZoneTransform.Strip,
validation_regex: '',
order: 0,
...overrides,
}
}
function pointer(x: number, y: number, pointerId = 1): PointerEvent {
return Object.assign(
new MouseEvent('pointermove', { clientX: x, clientY: y, button: 0 }),
{ pointerId }
) as PointerEvent
}
describe('OcrTemplateEditorComponent', () => {
let component: OcrTemplateEditorComponent
let fixture: ComponentFixture<OcrTemplateEditorComponent>
let documentService: DocumentService
let templateService: OcrTemplateService
let modalService: NgbModal
const activeModal = { close: jest.fn(), dismiss: jest.fn() }
beforeEach(() => {
TestBed.configureTestingModule({
imports: [BootstrapIconComponent, OcrTemplateEditorComponent],
providers: [
{ provide: NgbActiveModal, useValue: activeModal },
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
})
jest
.spyOn(TestBed.inject(CustomFieldsService), 'listAll')
.mockReturnValue(of({ count: 2, all: [1, 2], results: customFields }))
jest
.spyOn(TestBed.inject(UserService), 'listAll')
.mockReturnValue(of({ count: 0, all: [], results: [] }))
TestBed.inject(PermissionsService).initialize([], { id: 1 } as User)
documentService = TestBed.inject(DocumentService)
jest
.spyOn(documentService, 'get')
.mockReturnValue(of({ id: 5, page_count: 3 }))
templateService = TestBed.inject(OcrTemplateService)
modalService = TestBed.inject(NgbModal)
activeModal.close.mockReset()
fixture = TestBed.createComponent(OcrTemplateEditorComponent)
component = fixture.componentInstance
})
function prepareOverlay() {
const image = document.createElement('img')
Object.defineProperties(image, {
naturalWidth: { value: 1000 },
naturalHeight: { value: 1000 },
clientWidth: { value: 1000 },
})
const overlay = document.createElementNS(
'http://www.w3.org/2000/svg',
'svg'
)
Object.assign(overlay, {
getScreenCTM: () => ({ inverse: () => ({}) }),
createSVGPoint: () => ({
x: 0,
y: 0,
matrixTransform() {
return { x: this.x, y: this.y }
},
}),
setPointerCapture: jest.fn(),
hasPointerCapture: () => true,
releasePointerCapture: jest.fn(),
})
component.imageRef = new ElementRef(image)
component.overlayRef = new ElementRef(overlay)
component.pageLoaded.set(true)
}
function fieldDialog() {
const dialog = {
dialogMode: { set: jest.fn() },
object: null,
succeeded: new Subject<CustomField>(),
failed: new Subject(),
}
jest
.spyOn(modalService, 'open')
.mockReturnValue({ componentInstance: dialog } as any)
return dialog
}
it('updates screen-sized overlay units when the preview image resizes', () => {
let resize: ResizeObserverCallback
const observer = {
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
}
const resizeObserver = jest
.spyOn(globalThis, 'ResizeObserver')
.mockImplementation((callback) => {
resize = callback
return observer
})
const image = document.createElement('img')
const ref = new ElementRef(image)
component.pageImage = ref
expect(observer.observe).toHaveBeenCalledWith(image)
resize([], observer)
expect(component.overlayUnitSize(8)).toBe(8)
Object.defineProperties(image, {
naturalWidth: { value: 1000 },
clientWidth: { value: 500 },
})
resize([], observer)
expect(component.overlayUnitSize(8)).toBe(16)
expect(component.overlayFontSize()).toBe(24)
component.pageImage = undefined
expect(observer.disconnect).toHaveBeenCalledTimes(1)
fixture.destroy()
expect(observer.disconnect).toHaveBeenCalledTimes(2)
resizeObserver.mockRestore()
})
it('renders loaded image dimensions, zone labels, and selection handles', () => {
component.object = {
...component.template,
id: 3,
sample_document: 5,
zones: [zone()],
}
component.selectedZoneIndex = 0
fixture.detectChanges()
const image = fixture.nativeElement.querySelector('img')
Object.defineProperties(image, {
naturalWidth: { value: 1000 },
naturalHeight: { value: 1500 },
})
image.dispatchEvent(new Event('load'))
fixture.detectChanges()
expect(component.template).toMatchObject({
source_width: 1000,
source_height: 1500,
})
expect(
fixture.nativeElement
.querySelector('.zone-overlay')
.getAttribute('viewBox')
).toBe('0 0 1000 1500')
expect(
fixture.nativeElement.querySelector('.zone-label').textContent
).toContain('Invoice number')
expect(
fixture.nativeElement.querySelectorAll('.zone-resize-handle')
).toHaveLength(8)
expect(component.zoneLabel(zone({ name: '' }), 2)).toBe('Zone 3')
expect(component.zoneLabelY({ x: 0, y: 0, w: 100, h: 100 })).toBe(14)
})
it('offers the supported language and transform choices', () => {
expect(component.languageOptions.map((option) => option.id)).toContain(
'eng'
)
expect(component.transformOptions.map((option) => option.id)).toEqual(
Object.values(OcrZoneTransform)
)
})
it('clamps page navigation and supports one-based page input', () => {
component.setSampleDocument([5])
component.previewPageDisplay = 2
expect(component.previewPage).toBe(1)
expect(component.previewPageDisplay).toBe(2)
expect(component.pageImageUrl).toContain('/5/1/')
component.pageLoaded.set(true)
component.goToPage(1)
expect(component.pageLoaded()).toBe(true)
component.goToPage(Infinity)
component.goToPage(NaN)
expect(component.previewPage).toBe(1)
component.goToPage(99)
expect(component.previewPage).toBe(2)
expect(component.pageLoaded()).toBe(false)
component.goToPage(-5)
expect(component.previewPage).toBe(0)
component.previewPageCount.set(null)
component.goToPage(4.7)
expect(component.previewPage).toBe(4)
})
it('limits zoom and resets it to the original size', () => {
for (let i = 0; i < 20; i++) component.zoomIn()
expect(component.zoom).toBe(4)
for (let i = 0; i < 20; i++) component.zoomOut()
expect(component.zoom).toBe(0.5)
component.resetZoom()
expect(component.zoom).toBe(1)
})
it('moves last-page zones to the last page and can pin them to a page', () => {
component.setSampleDocument([5])
const selected = zone()
component.template.zones = [selected]
expect(component.isLastPageZone(selected)).toBe(false)
component.setLastPageZone(selected, true)
expect(selected.page).toBe(-1)
expect(component.isLastPageZone(selected)).toBe(true)
component.selectZone(0)
expect(component.previewPage).toBe(2)
component.setLastPageZone(selected, false)
expect(selected.page).toBe(3)
})
it('ignores page-count errors from a previous sample document', () => {
const pending = new Subject<Document>()
jest
.spyOn(documentService, 'get')
.mockReturnValueOnce(pending)
.mockReturnValueOnce(of({ id: 6 }))
component.setSampleDocument([5])
component.setSampleDocument([6])
pending.error(new Error('Old document failed'))
expect(component.pageFailed()).toBe(false)
expect(component.previewPageCount()).toBeNull()
})
it('ignores non-primary, unloaded, and saving pointer gestures', () => {
prepareOverlay()
component.onOverlayPointerDown(
new MouseEvent('pointerdown', { button: 2 }) as PointerEvent
)
expect(component.drawingRect()).toBeNull()
component.pageLoaded.set(false)
component.onOverlayPointerDown(pointer(100, 100))
expect(component.drawingRect()).toBeNull()
component.pageLoaded.set(true)
component.saving.set(true)
component.onOverlayPointerDown(pointer(100, 100))
expect(component.drawingRect()).toBeNull()
})
it('ignores pointer events when the SVG coordinate transform is unavailable', () => {
component.onOverlayPointerDown(pointer(100, 100))
expect(component.drawingRect()).toBeNull()
prepareOverlay()
jest
.spyOn(component.overlayRef.nativeElement, 'getScreenCTM')
.mockReturnValue(null)
component.onOverlayPointerDown(pointer(100, 100))
component.onOverlayPointerMove(pointer(200, 200))
expect(component.drawingRect()).toBeNull()
})
it('updates a drawing only for its captured pointer', () => {
prepareOverlay()
component.onOverlayPointerDown(pointer(100, 100))
component.onOverlayPointerDown(pointer(500, 500, 2))
component.onOverlayPointerMove(pointer(900, 900, 2))
expect(component.drawingRect()).toEqual({ x: 100, y: 100, w: 0, h: 0 })
component.onOverlayPointerMove(pointer(250, 200))
expect(component.drawingRect()).toEqual({ x: 100, y: 100, w: 150, h: 100 })
component.onOverlayPointerUp(pointer(250, 200))
expect(component.template.zones[0]).toMatchObject({
x: 100,
y: 100,
width: 150,
height: 100,
})
})
it('resizes a selected zone using its corner handle', () => {
prepareOverlay()
const selected = zone()
component.template.zones = [selected]
component.selectZone(0)
component.onOverlayPointerDown(pointer(100, 100))
component.onOverlayPointerMove(pointer(50, 75))
component.onOverlayPointerUp(pointer(60, 80))
expect(selected).toMatchObject({ x: 60, y: 80, width: 240, height: 120 })
expect(component.template.zones).toHaveLength(1)
})
it('shows the appropriate cursor over handles, zones, and empty space', () => {
prepareOverlay()
component.template.zones = [zone()]
component.onOverlayPointerMove(pointer(150, 150))
expect(component.overlayCursor).toBe('move')
component.selectZone(0)
component.onOverlayPointerMove(pointer(100, 100))
expect(component.overlayCursor).toBe('nw-resize')
component.onOverlayPointerMove(pointer(500, 500))
expect(component.overlayCursor).toBe('crosshair')
component.template.zones[0].page = 2
component.onOverlayPointerMove(pointer(100, 100))
expect(component.zoneDisplayRect(0)).toBeNull()
expect(component.overlayCursor).toBe('crosshair')
})
it('cancels only the captured pointer and tolerates an already-lost capture', () => {
prepareOverlay()
component.onOverlayPointerCancel(pointer(100, 100))
component.onOverlayPointerDown(pointer(100, 100))
component.onOverlayPointerCancel(pointer(150, 150, 2))
expect(component.drawingRect()).not.toBeNull()
jest
.spyOn(component.overlayRef.nativeElement, 'hasPointerCapture')
.mockReturnValue(false)
component.onOverlayPointerCancel(pointer(150, 150))
expect(component.drawingRect()).toBeNull()
expect(
component.overlayRef.nativeElement.releasePointerCapture
).not.toHaveBeenCalled()
expect(component.template.zones).toEqual([])
})
it('preserves the selected zone when an earlier or later zone is removed', () => {
component.template.zones = [zone(), zone(), zone(), zone()]
component.selectZone(1)
const selected = component.selectedZone
component.removeZone(3)
expect(component.selectedZone).toBe(selected)
component.removeZone(0)
expect(component.selectedZoneIndex).toBe(0)
expect(component.selectedZone).toBe(selected)
component.selectZone(99)
expect(component.selectedZone).toBeNull()
})
it('does not test zones without a selection or a sample document', () => {
const test = jest.spyOn(templateService, 'testZone')
component.testZone()
component.template.zones = [zone()]
component.selectZone(0)
component.testZone()
expect(test).not.toHaveBeenCalled()
})
it.each([
{
response: { error: { error: 'OCR unavailable' } },
message: 'OCR unavailable',
},
{ response: new Error('Network failure'), message: 'Test failed' },
])('shows zone-test errors: $message', ({ response, message }) => {
component.template.zones = [zone({ page: undefined })]
component.selectZone(0)
component.setSampleDocument([5])
jest
.spyOn(templateService, 'testZone')
.mockReturnValue(throwError(() => response))
component.testZone()
expect(component.zoneTestResult).toEqual({ error: message })
expect(component.zoneTesting).toBe(false)
expect(templateService.testZone).toHaveBeenCalledWith(
5,
expect.objectContaining({ page: 1 })
)
})
it('switches between built-in targets, custom fields, and an unassigned field', () => {
const selected = zone()
expect(component.zoneFieldValue(selected)).toBe(1)
component.setZoneField(selected, OcrZoneTarget.Title)
expect(component.zoneFieldValue(selected)).toBe(OcrZoneTarget.Title)
expect(selected.custom_field).toBeNull()
component.setZoneField(selected, 7)
expect(selected).toMatchObject({
target: OcrZoneTarget.CustomField,
custom_field: 7,
})
component.setZoneField(selected, null)
expect(component.zoneFieldValue(selected)).toBeNull()
expect(component.zonesForField(selected)).toEqual([])
component.setCombineFormat(selected, 'Ignored')
expect(component.template.combine_formats).toEqual({})
})
it('creates combine formats when absent and leaves implicit formats alone on rename', () => {
const selected = zone({ target: OcrZoneTarget.Title, custom_field: null })
component.template.combine_formats = undefined
component.renameZone(selected, 'Renamed')
expect(selected.name).toBe('Renamed')
expect(component.getCombineFormat(selected)).toBe('')
component.setCombineFormat(selected, '{Renamed} ')
component.insertCombineToken(selected, selected)
expect(component.template.combine_formats).toEqual({
title: '{Renamed} {Renamed}',
})
})
it('saves templates without combine formats', () => {
component.template.combine_formats = undefined
fixture.detectChanges()
expect(component.isDirty).toBe(false)
jest
.spyOn(templateService, 'create')
.mockReturnValue(of(component.template))
component.save()
expect(component.template.combine_formats).toBeUndefined()
})
it('does not assign unsupported custom field types and surfaces creation failures', () => {
component.template.zones = [zone({ custom_field: null })]
component.selectZone(0)
const dialog = fieldDialog()
const error = jest.spyOn(TestBed.inject(ToastService), 'showError')
component.createField('Status')
expect(dialog.object).toEqual({ name: 'Status' })
dialog.succeeded.next(customFields[1])
expect(component.selectedZone.custom_field).toBeNull()
const failure = new Error('Could not save')
dialog.failed.next(failure)
expect(error).toHaveBeenCalledWith('Error saving field.', failure)
})
it('refreshes field choices when a field is created without a selected zone', () => {
const dialog = fieldDialog()
component.createField('Total')
dialog.succeeded.next(customFields[0])
expect(component.customFields()).toEqual(customFields)
expect(component.selectedZone).toBeNull()
})
it('should edit a copy of the template and load its sample document', () => {
const original: OcrTemplate = {
...component.template,
id: 3,
name: 'Invoices',
match: 'Northwind',
matching_algorithm: MATCH_LITERAL,
sample_document: 5,
zones: [zone()],
combine_formats: { '1': '{Invoice number}' },
}
component.object = original
fixture.detectChanges()
expect(component.dialogTitle).toEqual('Edit OCR Template')
expect(component.previewDocId).toEqual(5)
expect(component.previewPageCount()).toEqual(3)
expect(component.pageImageUrl).toContain('document-page-image/5/0/')
expect(component.template.match).toEqual('Northwind')
expect(component.isDirty).toBeFalsy()
component.template.zones[0].x = 500
component.template.combine_formats['1'] = 'Edited'
expect(original.zones[0].x).toEqual(100)
expect(original.combine_formats['1']).toEqual('{Invoice number}')
})
it('should offer every matching algorithm except automatic', () => {
expect(component.matchingAlgorithms.map((a) => a.id)).not.toContain(
MATCH_AUTO
)
expect(component.template.matching_algorithm).toEqual(MATCH_ANY)
expect(component.patternRequired).toBeTruthy()
component.template.matching_algorithm = MATCH_NONE
expect(component.patternRequired).toBeFalsy()
})
it('should initialize new template permissions from saved defaults', () => {
const defaults = {
[SETTINGS_KEYS.DEFAULT_PERMS_OWNER]: 1,
[SETTINGS_KEYS.DEFAULT_PERMS_VIEW_USERS]: [2],
[SETTINGS_KEYS.DEFAULT_PERMS_VIEW_GROUPS]: [3],
[SETTINGS_KEYS.DEFAULT_PERMS_EDIT_USERS]: [],
[SETTINGS_KEYS.DEFAULT_PERMS_EDIT_GROUPS]: [4],
}
jest
.spyOn(TestBed.inject(SettingsService), 'get')
.mockImplementation((key) => defaults[key])
fixture.detectChanges()
expect(component.permissionsForm).toEqual({
owner: 1,
set_permissions: {
view: { users: [2], groups: [3] },
change: { users: [], groups: [4] },
},
})
expect(
fixture.debugElement.query(By.directive(PermissionsFormComponent))
).toBeTruthy()
expect(component.isDirty).toBe(false)
const create = jest
.spyOn(templateService, 'create')
.mockReturnValue(of({ ...component.template, id: 3 }))
component.save()
expect(create).toHaveBeenCalledWith(
expect.objectContaining(component.permissionsForm)
)
})
it('should copy existing permissions and track changes without mutating the list item', () => {
component.object = {
...component.template,
id: 3,
owner: 1,
permissions: {
view: { users: [2], groups: [] },
change: { users: [], groups: [3] },
},
}
fixture.detectChanges()
expect(component.permissionsForm.owner).toBe(1)
expect(component.isDirty).toBe(false)
component.permissionsForm.set_permissions.view.users.push(4)
expect(component.isDirty).toBe(true)
expect(component.object.permissions.view.users).toEqual([2])
const update = jest
.spyOn(templateService, 'update')
.mockReturnValue(of(component.template))
component.save()
expect(update).toHaveBeenCalledWith(
expect.objectContaining({
owner: 1,
set_permissions: {
view: { users: [2, 4], groups: [] },
change: { users: [], groups: [3] },
},
})
)
})
it('should hide sharing controls and omit permission changes for a shared editor', () => {
component.object = {
...component.template,
id: 3,
owner: 2,
user_can_change: true,
}
fixture.detectChanges()
expect(
fixture.debugElement.query(By.directive(PermissionsFormComponent))
).toBeNull()
component.permissionsForm = {
owner: 1,
set_permissions: { view: { users: [1] } },
}
const update = jest
.spyOn(templateService, 'update')
.mockReturnValue(of(component.template))
component.save()
expect(update.mock.calls[0][0].owner).toBe(2)
expect(update.mock.calls[0][0]).not.toHaveProperty('set_permissions')
})
it('should keep only the last picked sample document', () => {
component.setSampleDocument([5, 6])
expect(component.sampleDocumentIds).toEqual([6])
component.setSampleDocument([])
expect(component.previewDocId).toBeNull()
expect(component.pageImageUrl).toBeNull()
})
it('should ignore a stale sample document response', () => {
const pending = new Subject<Document>()
jest.spyOn(documentService, 'get').mockReturnValueOnce(pending)
component.setSampleDocument([5])
component.setSampleDocument([6])
pending.next({ id: 5, page_count: 99 })
expect(component.previewPageCount()).toEqual(3)
})
it('should show a failed page and retry it', () => {
jest
.spyOn(documentService, 'get')
.mockReturnValueOnce(throwError(() => new Error('offline')))
component.setSampleDocument([5])
expect(component.pageFailed()).toBeTruthy()
component.setSampleDocument(component.sampleDocumentIds)
expect(component.pageFailed()).toBeFalsy()
component.onImageError()
expect(component.pageFailed()).toBeTruthy()
})
it('should support create and update', () => {
const createSpy = jest
.spyOn(templateService, 'create')
.mockReturnValue(of({ ...component.template, id: 3 }))
const updateSpy = jest
.spyOn(templateService, 'update')
.mockReturnValue(of(component.template))
const succeededSpy = jest.spyOn(component.succeeded, 'emit')
component.save()
expect(createSpy).toHaveBeenCalled()
expect(succeededSpy).toHaveBeenCalledWith({ ...component.template, id: 3 })
expect(activeModal.close).toHaveBeenCalled()
component.saving.set(false)
component.template.id = 3
component.save()
expect(updateSpy).toHaveBeenCalledWith(component.template)
})
it('should emit failed and allow editing again after a failed save', () => {
prepareOverlay()
component.template.zones = [zone()]
const pending = new Subject<OcrTemplate>()
jest.spyOn(templateService, 'create').mockReturnValue(pending)
const failedSpy = jest.spyOn(component.failed, 'emit')
component.onOverlayPointerDown(pointer(150, 150))
component.save()
component.save()
expect(templateService.create).toHaveBeenCalledTimes(1)
component.onOverlayPointerMove(pointer(250, 250))
component.onOverlayPointerUp(pointer(250, 250))
expect(component.template.zones[0].x).toEqual(100)
pending.error(new Error('Save failed'))
expect(failedSpy).toHaveBeenCalled()
expect(component.saving()).toBeFalsy()
component.onOverlayPointerDown(pointer(150, 150))
component.onOverlayPointerUp(pointer(250, 250))
expect(component.template.zones[0].x).toEqual(200)
})
it('should track unsaved changes', () => {
fixture.detectChanges()
expect(component.isDirty).toBeFalsy()
component.template.combine_formats = { '1': '' }
expect(component.isDirty).toBeFalsy()
component.template.name = 'Invoices'
expect(component.isDirty).toBeTruthy()
component.template.name = ''
expect(component.isDirty).toBeFalsy()
component.setSampleDocument([5])
expect(component.isDirty).toBeTruthy()
})
it('should draw a zone with no field in empty space', () => {
prepareOverlay()
component.onOverlayPointerDown(pointer(100, 100))
component.onOverlayPointerUp(pointer(1200, 250))
expect(component.template.zones[0]).toMatchObject({
x: 100,
y: 100,
width: 900,
height: 150,
custom_field: null,
})
expect(component.selectedZoneIndex).toEqual(0)
})
it('should clear the selection on a click in empty space', () => {
prepareOverlay()
component.template.zones = [zone()]
component.selectZone(0)
component.onOverlayPointerDown(pointer(600, 600))
component.onOverlayPointerUp(pointer(602, 601))
expect(component.template.zones).toHaveLength(1)
expect(component.selectedZoneIndex).toBeNull()
})
it('should move a zone when dragging on it', () => {
prepareOverlay()
component.template.zones = [zone()]
component.onOverlayPointerDown(pointer(150, 150))
component.onOverlayPointerUp(pointer(250, 200))
expect(component.template.zones).toHaveLength(1)
expect(component.template.zones[0]).toMatchObject({ x: 200, y: 150 })
})
it('should cancel a drawing on escape and ignore other pointers', () => {
prepareOverlay()
component.onOverlayPointerDown(pointer(100, 100))
component.onOverlayPointerUp(pointer(300, 200, 2))
expect(component.drawingRect()).not.toBeNull()
component.cancelDrawing()
component.onOverlayPointerUp(pointer(300, 200))
expect(component.template.zones).toEqual([])
})
it('should open a zone in the zones tab and go back to the list', () => {
component.template.zones = [zone(), zone()]
component.selectZone(1)
expect(component.activeTab).toEqual('zones')
expect(component.selectedZone).toBe(component.template.zones[1])
component.deselectZone()
expect(component.selectedZone).toBeNull()
component.selectZone(0)
component.removeZone(0)
expect(component.template.zones).toHaveLength(1)
expect(component.selectedZone).toBeNull()
})
it('should only offer custom fields OCR can write to', () => {
fixture.detectChanges()
expect(component.fieldItems().map((item) => item.id)).toEqual([
OcrZoneTarget.Title,
OcrZoneTarget.Asn,
OcrZoneTarget.Created,
1,
])
})
it('should create a custom field and assign it to the zone', () => {
const succeeded = new Subject<CustomField>()
const dialog = {
dialogMode: { set: jest.fn() },
object: null,
succeeded,
failed: new Subject(),
}
jest
.spyOn(modalService, 'open')
.mockReturnValue({ componentInstance: dialog } as any)
component.template.zones = [zone({ custom_field: null })]
component.selectZone(0)
component.createField()
expect(dialog.object).toEqual({ name: 'Invoice number' })
succeeded.next(customFields[0])
expect(component.template.zones[0].custom_field).toEqual(1)
})
it('should build and prune combine formats', () => {
const first = zone({ name: 'First' })
const second = zone({ name: 'Second' })
const other = zone({ name: 'Other', custom_field: 2 })
component.template.zones = [first, second, other]
component.template.combine_formats = { '2': '{Other}' }
expect(component.isFieldShared(first)).toBeTruthy()
expect(component.isFieldShared(other)).toBeFalsy()
expect(component.defaultCombineFormat(first)).toEqual('{First} {Second}')
component.insertCombineToken(first, second)
component.insertCombineToken(first, first)
expect(component.getCombineFormat(second)).toEqual('{Second} {First}')
jest.spyOn(templateService, 'create').mockReturnValue(of(null))
component.save()
expect(component.template.combine_formats).toEqual({
'1': '{Second} {First}',
})
})
it('should update exact tokens when a zone is renamed', () => {
const first = zone({ name: 'First [value]' })
component.template.zones = [first, zone({ name: 'Second' })]
component.template.combine_formats = {
'1': 'First [value]: {First [value]} / { First [value] } / {Second} / {First [value] extra}',
'2': '{First [value]}',
}
component.renameZone(first, '')
component.renameZone(first, 'Invoice number')
expect(first.name).toEqual('Invoice number')
expect(component.template.combine_formats).toEqual({
'1': 'First [value]: {Invoice number} / {Invoice number} / {Second} / {First [value] extra}',
'2': '{First [value]}',
})
})
it('should keep the language array stable until the value changes', () => {
const selected = zone({ ocr_language: 'deu+eng' })
const languages = component.ocrLanguageArray(selected)
expect(languages).toEqual(['deu', 'eng'])
expect(component.ocrLanguageArray(selected)).toBe(languages)
component.setOcrLanguages(selected, ['fra'])
expect(selected.ocr_language).toEqual('fra')
expect(component.ocrLanguageArray(selected)).toEqual(['fra'])
component.setOcrLanguages(selected, null)
expect(component.ocrLanguageArray(selected)).toEqual([])
})
it('should hide a test result once the zone changes', () => {
const selected = zone()
component.template.zones = [selected]
component.selectZone(0)
component.setSampleDocument([5])
jest
.spyOn(templateService, 'testZone')
.mockReturnValue(of({ value: '123' }))
component.testZone()
expect(component.zoneTestResult.value).toEqual('123')
selected.transform = OcrZoneTransform.Numeric
expect(component.zoneTestResult).toBeNull()
})
it('should drop a pending test when the zone or sample document changes', () => {
const pending = new Subject<OcrZoneTestResult>()
component.template.zones = [zone(), zone()]
component.selectZone(0)
component.setSampleDocument([5])
jest.spyOn(templateService, 'testZone').mockReturnValue(pending)
component.testZone()
expect(component.zoneTesting).toBeTruthy()
component.selectZone(1)
pending.next({ value: 'Old zone' })
expect(component.zoneTestResult).toBeNull()
component.testZone()
component.setSampleDocument([])
pending.next({ value: 'Old document' })
expect(component.zoneTestResult).toBeNull()
})
})
@@ -1,963 +0,0 @@
import { CommonModule } from '@angular/common'
import {
Component,
computed,
ElementRef,
EventEmitter,
HostListener,
inject,
Input,
OnDestroy,
OnInit,
Output,
signal,
ViewChild,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import {
NgbActiveModal,
NgbModal,
NgbNavModule,
} from '@ng-bootstrap/ng-bootstrap'
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
import { Subject, takeUntil } from 'rxjs'
import { CustomFieldEditDialogComponent } from 'src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
import { EditDialogMode } from 'src/app/components/common/edit-dialog/edit-dialog.component'
import { DocumentLinkComponent } from 'src/app/components/common/input/document-link/document-link.component'
import { CheckComponent } from 'src/app/components/common/input/check/check.component'
import { NumberComponent } from 'src/app/components/common/input/number/number.component'
import {
PermissionsFormComponent,
PermissionsFormObject,
} from 'src/app/components/common/input/permissions/permissions-form/permissions-form.component'
import { SelectComponent } from 'src/app/components/common/input/select/select.component'
import { SwitchComponent } from 'src/app/components/common/input/switch/switch.component'
import { TextComponent } from 'src/app/components/common/input/text/text.component'
import { CustomField } from 'src/app/data/custom-field'
import {
MATCH_ANY,
MATCH_AUTO,
MATCH_NONE,
MATCHING_ALGORITHMS,
} from 'src/app/data/matching-model'
import {
DEFAULT_OCR_ZONE_LANGUAGE,
getZoneFieldKey,
OCR_BUILTIN_TARGETS,
OCR_SUPPORTED_FIELD_TYPES,
OcrTemplate,
OcrTemplateZone,
OcrZoneTarget,
OcrZoneTestResult,
OcrZoneTransform,
ZoneTestRequest,
} from 'src/app/data/ocr-template'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { User } from 'src/app/data/user'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { PermissionsService } from 'src/app/services/permissions.service'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { OcrTemplateEditorZoneListComponent } from './ocr-template-editor-zone-list/ocr-template-editor-zone-list.component'
import {
DisplayRect,
DrawingRect,
findHandleAt,
findZoneAt,
getResizeHandles,
getZoneColor,
getZoneDisplayRect,
getZonePage,
HANDLE_SIZE,
isZoneOnPage,
MIN_ZONE_SIZE,
MoveStart,
moveZone,
Point,
ResizeHandle,
resizeZone,
} from './zone-geometry'
type ActiveTab = 'template' | 'zones'
type ZoneFieldSelection = OcrZoneTarget | number | null
type OverlayInteraction =
| { kind: 'idle' }
| { kind: 'drawing'; pointerId: number; rect: DrawingRect }
| { kind: 'moving'; pointerId: number; zoneIndex: number; start: MoveStart }
| {
kind: 'resizing'
pointerId: number
zoneIndex: number
handle: ResizeHandle
}
interface ZoneTest {
zone: OcrTemplateZone
/** The sample document and zone settings the result was produced from. */
key: string
result: OcrZoneTestResult | null
}
export const ZONE_TRANSFORM_OPTIONS = [
{ id: OcrZoneTransform.None, name: $localize`None` },
{ id: OcrZoneTransform.Strip, name: $localize`Strip whitespace` },
{ id: OcrZoneTransform.Uppercase, name: $localize`Uppercase` },
{ id: OcrZoneTransform.Lowercase, name: $localize`Lowercase` },
{ id: OcrZoneTransform.Numeric, name: $localize`Numeric only` },
{
id: OcrZoneTransform.StripPunctuation,
name: $localize`Remove leading/trailing punctuation`,
},
{ id: OcrZoneTransform.Date, name: $localize`Parse date` },
{ id: OcrZoneTransform.QrCode, name: $localize`Read QR/barcode` },
]
export const ZONE_LANGUAGE_OPTIONS = [
{ id: 'eng', name: $localize`English` },
{ id: 'deu', name: $localize`German` },
{ id: 'fra', name: $localize`French` },
{ id: 'ita', name: $localize`Italian` },
{ id: 'spa', name: $localize`Spanish` },
{ id: 'por', name: $localize`Portuguese` },
{ id: 'nld', name: $localize`Dutch` },
]
const TEMPLATE_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
(algorithm) => algorithm.id !== MATCH_AUTO
)
const NO_OVERLAY_INTERACTION: OverlayInteraction = { kind: 'idle' }
const RESIZE_CURSOR: Record<ResizeHandle, string> = {
nw: 'nw-resize',
ne: 'ne-resize',
sw: 'sw-resize',
se: 'se-resize',
n: 'n-resize',
s: 's-resize',
w: 'w-resize',
e: 'e-resize',
}
@Component({
selector: 'pngx-ocr-template-editor',
imports: [
DocumentLinkComponent,
NumberComponent,
TextComponent,
CheckComponent,
SelectComponent,
SwitchComponent,
CommonModule,
FormsModule,
NgbNavModule,
BootstrapIconComponent,
OcrTemplateEditorZoneListComponent,
PermissionsFormComponent,
IfOwnerDirective,
],
templateUrl: './ocr-template-editor.component.html',
styleUrls: ['./ocr-template-editor.component.scss'],
})
export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
public readonly activeModal = inject(NgbActiveModal)
private readonly templateService = inject(OcrTemplateService)
private readonly customFieldsService = inject(CustomFieldsService)
private readonly documentService = inject(DocumentService)
private readonly toastService = inject(ToastService)
private readonly modalService = inject(NgbModal)
private readonly userService = inject(UserService)
private readonly settingsService = inject(SettingsService)
private readonly permissionsService = inject(PermissionsService)
private readonly destroy$ = new Subject<void>()
private readonly cancelZoneTest$ = new Subject<void>()
@Input() object: OcrTemplate
@Output() public readonly succeeded = new EventEmitter<OcrTemplate>()
@Output() public readonly failed = new EventEmitter()
@ViewChild('zoneOverlay') overlayRef: ElementRef<SVGSVGElement>
imageRef: ElementRef<HTMLImageElement>
private previewResizeObserver: ResizeObserver | null = null
private readonly overlayScale = signal(1)
@ViewChild('pageImage')
set pageImage(ref: ElementRef<HTMLImageElement> | undefined) {
this.previewResizeObserver?.disconnect()
this.imageRef = ref
if (!ref) return
this.previewResizeObserver = new ResizeObserver(() => {
const image = ref.nativeElement
if (image.naturalWidth && image.clientWidth) {
this.overlayScale.set(image.naturalWidth / image.clientWidth)
}
})
this.previewResizeObserver.observe(ref.nativeElement)
}
template: OcrTemplate = {
id: null,
name: '',
match: '',
matching_algorithm: MATCH_ANY,
is_insensitive: true,
sample_document: null,
source_width: 0,
source_height: 0,
enabled: true,
combine_formats: {},
zones: [],
}
public readonly customFields = signal<CustomField[]>([])
public readonly fieldItems = computed<
{ id: ZoneFieldSelection; name: string }[]
>(() => [
...OCR_BUILTIN_TARGETS,
...this.customFields()
.filter((field) => OCR_SUPPORTED_FIELD_TYPES.includes(field.data_type))
.map((field) => ({ id: field.id, name: field.name })),
])
public readonly users = signal<User[]>([])
permissionsForm: PermissionsFormObject = {}
public readonly OcrZoneTransform = OcrZoneTransform
public readonly saving = signal(false)
/** Bound to the document picker, which works with id arrays. */
sampleDocumentIds: number[] = []
previewPage = 0
public readonly previewPageCount = signal<number | null>(null)
public readonly pageLoaded = signal(false)
public readonly pageFailed = signal(false)
zoom = 1
get previewDocId(): number | null {
return this.sampleDocumentIds[0] ?? null
}
get pageImageUrl(): string | null {
return this.previewDocId
? this.templateService.getPageImageUrl(
this.previewDocId,
this.previewPage
)
: null
}
get transformOptions() {
return ZONE_TRANSFORM_OPTIONS
}
get languageOptions() {
return ZONE_LANGUAGE_OPTIONS
}
get matchingAlgorithms() {
return TEMPLATE_MATCHING_ALGORITHMS
}
get patternRequired(): boolean {
return (
this.template.matching_algorithm !== MATCH_AUTO &&
this.template.matching_algorithm !== MATCH_NONE
)
}
public get previewPageDisplay(): number {
return this.previewPage + 1
}
public set previewPageDisplay(value: number) {
this.goToPage(value - 1)
}
activeTab: ActiveTab = 'template'
selectedZoneIndex: number | null = null
private overlayInteraction: OverlayInteraction = NO_OVERLAY_INTERACTION
overlayCursor = 'crosshair'
private readonly zoneTest = signal<ZoneTest | null>(null)
get zoneTestResult(): OcrZoneTestResult | null {
return this.isCurrentZoneTest() ? this.zoneTest().result : null
}
get zoneTesting(): boolean {
return this.isCurrentZoneTest() && this.zoneTest().result === null
}
get selectedZone(): OcrTemplateZone | null {
return this.selectedZoneIndex !== null
? (this.template.zones[this.selectedZoneIndex] ?? null)
: null
}
private initialState = ''
get isDirty(): boolean {
return this.editorState() !== this.initialState
}
private editorState(): string {
const formats = Object.entries(this.template.combine_formats ?? {}).filter(
([, format]) => format
)
return JSON.stringify({
...this.template,
combine_formats: Object.fromEntries(formats),
sample_document: this.previewDocId ?? null,
permissions_form: this.permissionsForm,
})
}
get dialogTitle(): string {
return this.template.id
? $localize`Edit OCR Template`
: $localize`New OCR Template`
}
ngOnInit() {
this.userService
.listAll()
.pipe(takeUntil(this.destroy$))
.subscribe((r) => this.users.set(r.results))
this.customFieldsService
.listAll()
.pipe(takeUntil(this.destroy$))
.subscribe((r) => {
this.customFields.set(r.results)
})
if (this.object) {
this.template = {
...this.object,
zones: this.object.zones.map((zone) => ({ ...zone })),
combine_formats: { ...this.object.combine_formats },
}
if (this.template.sample_document) {
this.setSampleDocument([this.template.sample_document])
}
}
const permissions = this.object?.permissions
this.permissionsForm = this.object
? {
owner: this.object.owner,
set_permissions: {
view: {
users: [...(permissions?.view.users ?? [])],
groups: [...(permissions?.view.groups ?? [])],
},
change: {
users: [...(permissions?.change.users ?? [])],
groups: [...(permissions?.change.groups ?? [])],
},
},
}
: {
owner: this.settingsService.get(SETTINGS_KEYS.DEFAULT_PERMS_OWNER),
set_permissions: {
view: {
users: this.settingsService.get(
SETTINGS_KEYS.DEFAULT_PERMS_VIEW_USERS
),
groups: this.settingsService.get(
SETTINGS_KEYS.DEFAULT_PERMS_VIEW_GROUPS
),
},
change: {
users: this.settingsService.get(
SETTINGS_KEYS.DEFAULT_PERMS_EDIT_USERS
),
groups: this.settingsService.get(
SETTINGS_KEYS.DEFAULT_PERMS_EDIT_GROUPS
),
},
},
}
this.initialState = this.editorState()
}
setSampleDocument(ids: number[]) {
const id = ids.at(-1)
this.sampleDocumentIds = id ? [id] : []
this.previewPage = 0
this.previewPageCount.set(null)
this.resetPage()
if (!id) return
this.documentService
.get(id)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (doc) => {
if (this.previewDocId !== id) return
this.previewPageCount.set(doc.page_count ?? null)
},
error: () => {
if (this.previewDocId === id) this.pageFailed.set(true)
},
})
}
private resetPage() {
this.stopOverlayInteraction()
this.clearZoneTest()
this.pageLoaded.set(false)
this.pageFailed.set(false)
}
goToPage(page: number) {
if (!Number.isFinite(page)) return
const pageCount = this.previewPageCount()
const max = pageCount ? pageCount - 1 : page
const clamped = Math.max(0, Math.min(Math.trunc(page), max))
if (clamped === this.previewPage) return
this.previewPage = clamped
this.resetPage()
}
zoomIn() {
this.zoom = Math.min(4, Math.round((this.zoom + 0.25) * 100) / 100)
}
zoomOut() {
this.zoom = Math.max(0.5, Math.round((this.zoom - 0.25) * 100) / 100)
}
resetZoom() {
this.zoom = 1
}
zonePage(zone: OcrTemplateZone): number {
return getZonePage(zone, this.previewPage, this.previewPageCount())
}
isLastPageZone(zone: OcrTemplateZone): boolean {
return zone.page === -1
}
setLastPageZone(zone: OcrTemplateZone, lastPage: boolean) {
zone.page = lastPage ? -1 : this.previewPage + 1
}
private isOnCurrentPage(zone: OcrTemplateZone): boolean {
return isZoneOnPage(zone, this.previewPage, this.previewPageCount())
}
onImageLoad() {
this.pageLoaded.set(true)
const img = this.imageRef.nativeElement
this.template.source_width = img.naturalWidth
this.template.source_height = img.naturalHeight
}
onImageError() {
this.stopOverlayInteraction()
this.pageFailed.set(true)
}
@HostListener('document:keydown.escape')
cancelDrawing() {
this.stopOverlayInteraction()
}
onOverlayPointerDown(event: PointerEvent) {
if (this.saving()) return
if (event.button !== 0 || this.overlayInteraction.kind !== 'idle') return
const point = this.svgPointFromEvent(event)
if (!this.pageLoaded() || !point) return
event.preventDefault()
if (this.selectedZoneIndex !== null) {
const handle = this.handleAt(point, this.selectedZoneIndex)
if (handle) {
this.overlayInteraction = {
kind: 'resizing',
pointerId: event.pointerId,
zoneIndex: this.selectedZoneIndex,
handle,
}
this.overlayRef.nativeElement.setPointerCapture(event.pointerId)
return
}
}
const clickedIdx = this.zoneIndexAt(point)
if (clickedIdx !== null) {
this.selectZone(clickedIdx)
const zone = this.template.zones[clickedIdx]
this.overlayInteraction = {
kind: 'moving',
pointerId: event.pointerId,
zoneIndex: clickedIdx,
start: {
mouseX: point.x,
mouseY: point.y,
zoneX: zone.x,
zoneY: zone.y,
},
}
this.overlayRef.nativeElement.setPointerCapture(event.pointerId)
return
}
// Empty space: start drawing a new zone. A click without a drag just clears
// the selection (see onOverlayPointerUp).
this.clearZoneTest()
this.selectedZoneIndex = null
this.overlayInteraction = {
kind: 'drawing',
pointerId: event.pointerId,
rect: {
startX: point.x,
startY: point.y,
endX: point.x,
endY: point.y,
},
}
this.overlayRef.nativeElement.setPointerCapture(event.pointerId)
}
onOverlayPointerMove(event: PointerEvent) {
if (
this.overlayInteraction.kind !== 'idle' &&
this.overlayInteraction.pointerId !== event.pointerId
)
return
const point = this.svgPointFromEvent(event)
if (!point) return
if (this.overlayInteraction.kind === 'resizing') {
resizeZone(
this.template.zones[this.overlayInteraction.zoneIndex],
this.overlayInteraction.handle,
point,
this.imageNaturalSize()
)
return
}
if (this.overlayInteraction.kind === 'moving') {
moveZone(
this.template.zones[this.overlayInteraction.zoneIndex],
point,
this.overlayInteraction.start,
this.imageNaturalSize()
)
return
}
if (this.overlayInteraction.kind === 'drawing') {
this.overlayInteraction.rect.endX = point.x
this.overlayInteraction.rect.endY = point.y
return
}
this.updateOverlayCursor(point)
}
private updateOverlayCursor(point: Point) {
if (this.selectedZoneIndex !== null) {
const handle = this.handleAt(point, this.selectedZoneIndex)
if (handle) {
this.overlayCursor = RESIZE_CURSOR[handle]
return
}
}
this.overlayCursor = this.zoneIndexAt(point) !== null ? 'move' : 'crosshair'
}
onOverlayPointerUp(event: PointerEvent) {
if (this.overlayInteraction.kind === 'idle') return
if (this.overlayInteraction.pointerId !== event.pointerId) return
this.onOverlayPointerMove(event)
const interaction = this.overlayInteraction
this.stopOverlayInteraction()
if (interaction.kind !== 'drawing') return
const rect = this.sourceRectFromDrawing(interaction.rect)
// Ignore tiny accidental clicks.
if (rect.w < MIN_ZONE_SIZE || rect.h < MIN_ZONE_SIZE) {
return
}
this.template.zones.push(this.createZoneFromRect(rect))
this.selectZone(this.template.zones.length - 1)
}
private createZoneFromRect(rect: DisplayRect): OcrTemplateZone {
const imageSize = this.imageNaturalSize()
return {
name: `Zone ${this.template.zones.length + 1}`,
target: OcrZoneTarget.CustomField,
custom_field: null,
x: rect.x,
y: rect.y,
width: rect.w,
height: rect.h,
page: this.previewPageDisplay,
ocr_language: DEFAULT_OCR_ZONE_LANGUAGE,
transform: OcrZoneTransform.Strip,
date_format: '',
validation_regex: '',
order: this.template.zones.length,
zone_source_width: imageSize.width,
zone_source_height: imageSize.height,
}
}
onOverlayPointerCancel(event: PointerEvent) {
if (this.overlayInteraction.kind === 'idle') return
if (this.overlayInteraction.pointerId !== event.pointerId) return
this.stopOverlayInteraction()
}
private stopOverlayInteraction() {
const interaction = this.overlayInteraction
this.overlayInteraction = NO_OVERLAY_INTERACTION
this.overlayCursor = 'crosshair'
const overlay = this.overlayRef?.nativeElement
if (
interaction.kind !== 'idle' &&
overlay?.hasPointerCapture(interaction.pointerId)
) {
overlay.releasePointerCapture(interaction.pointerId)
}
}
drawingRect(): DisplayRect | null {
return this.overlayInteraction.kind === 'drawing'
? this.displayRectFromDrawing(this.overlayInteraction.rect)
: null
}
/** The zone's rectangle on the overlay, or null if it's on another page. */
zoneDisplayRect(zoneIndex: number): DisplayRect | null {
const zone = this.template.zones[zoneIndex]
if (!this.isOnCurrentPage(zone)) return null
return getZoneDisplayRect(zone, this.imageNaturalSize())
}
private handleAt(point: Point, zoneIndex: number): ResizeHandle | null {
const rect = this.zoneDisplayRect(zoneIndex)
return rect ? findHandleAt(point, rect, this.overlayHandleSize()) : null
}
private zoneIndexAt(point: Point): number | null {
return findZoneAt(
point,
this.template.zones,
this.previewPage,
this.previewPageCount(),
this.imageNaturalSize()
)
}
overlayViewBox(): string {
const imageSize = this.imageNaturalSize()
return `0 0 ${imageSize.width} ${imageSize.height}`
}
public readonly zoneColor = getZoneColor
zoneLabel(zone: OcrTemplateZone, index: number): string {
return zone.name || `Zone ${index + 1}`
}
zoneLabelY(rect: DisplayRect): number {
return Math.max(this.overlayUnitSize(14), rect.y - this.overlayUnitSize(4))
}
public readonly resizeHandles = getResizeHandles
overlayHandleSize(): number {
return this.overlayUnitSize(HANDLE_SIZE)
}
overlayFontSize(): number {
return this.overlayUnitSize(12)
}
overlayUnitSize(screenPixels: number): number {
return screenPixels * this.overlayScale()
}
private svgPointFromEvent(event: MouseEvent): Point | null {
const svg = this.overlayRef?.nativeElement
const matrix = svg?.getScreenCTM()
if (!svg || !matrix) return null
const point = svg.createSVGPoint()
point.x = event.clientX
point.y = event.clientY
const svgPoint = point.matrixTransform(matrix.inverse())
const size = this.imageNaturalSize()
return {
x: Math.max(0, Math.min(svgPoint.x, size.width)),
y: Math.max(0, Math.min(svgPoint.y, size.height)),
}
}
private displayRectFromDrawing(rect: DrawingRect): DisplayRect {
return {
x: Math.min(rect.startX, rect.endX),
y: Math.min(rect.startY, rect.endY),
w: Math.abs(rect.endX - rect.startX),
h: Math.abs(rect.endY - rect.startY),
}
}
private sourceRectFromDrawing(rect: DrawingRect): DisplayRect {
const displayRect = this.displayRectFromDrawing(rect)
return {
x: Math.round(displayRect.x),
y: Math.round(displayRect.y),
w: Math.round(displayRect.w),
h: Math.round(displayRect.h),
}
}
private imageNaturalSize() {
const img = this.imageRef.nativeElement
return { width: img.naturalWidth, height: img.naturalHeight }
}
removeZone(index: number) {
this.stopOverlayInteraction()
this.clearZoneTest()
this.template.zones.splice(index, 1)
if (this.selectedZoneIndex === index) {
this.selectedZoneIndex = null
} else if (this.selectedZoneIndex > index) {
this.selectedZoneIndex--
}
}
selectZone(index: number) {
this.selectedZoneIndex = index
this.activeTab = 'zones'
this.clearZoneTest()
const zone = this.template.zones[index]
if (zone) {
this.goToPage(this.zonePage(zone) - 1)
}
}
deselectZone() {
this.selectedZoneIndex = null
this.clearZoneTest()
}
testZone() {
const zone = this.selectedZone
if (!zone || !this.previewDocId) return
this.clearZoneTest()
const test: ZoneTest = { zone, key: this.zoneTestKey(zone), result: null }
this.zoneTest.set(test)
this.templateService
.testZone(this.previewDocId, this.zoneTestRequest(zone))
.pipe(takeUntil(this.destroy$), takeUntil(this.cancelZoneTest$))
.subscribe({
next: (res) => this.zoneTest.set({ ...test, result: res }),
error: (err) =>
this.zoneTest.set({
...test,
result: { error: err.error?.error || $localize`Test failed` },
}),
})
}
private clearZoneTest() {
this.cancelZoneTest$.next()
this.zoneTest.set(null)
}
/** A result is stale once its zone, settings or sample document change. */
private isCurrentZoneTest(): boolean {
const test = this.zoneTest()
return (
test?.zone === this.selectedZone &&
test.key === this.zoneTestKey(test.zone)
)
}
private zoneTestKey(zone: OcrTemplateZone): string {
return JSON.stringify([this.previewDocId, this.zoneTestRequest(zone)])
}
private zoneTestRequest(zone: OcrTemplateZone): ZoneTestRequest {
return {
name: zone.name,
x: zone.x,
y: zone.y,
width: zone.width,
height: zone.height,
page: zone.page ?? 1,
ocr_language: zone.ocr_language,
transform: zone.transform,
date_format: zone.date_format,
validation_regex: zone.validation_regex,
zone_source_width: zone.zone_source_width,
zone_source_height: zone.zone_source_height,
}
}
save() {
if (this.saving()) return
this.stopOverlayInteraction()
this.saving.set(true)
this.pruneCombineFormats()
this.template.sample_document = this.previewDocId
const payload = this.permissionsService.currentUserOwnsObject(this.object)
? { ...this.template, ...this.permissionsForm }
: this.template
const obs = this.template.id
? this.templateService.update(payload)
: this.templateService.create(payload)
obs.pipe(takeUntil(this.destroy$)).subscribe({
next: (saved) => {
this.activeModal.close()
this.succeeded.emit(saved)
},
error: (e) => {
this.saving.set(false)
this.failed.emit(e)
},
})
}
// ng-select needs a stable array between change detection runs, so the
// split language list is cached per zone until ocr_language changes.
private readonly ocrLanguageCache = new WeakMap<
OcrTemplateZone,
{ language: string; languages: string[] }
>()
ocrLanguageArray(zone: OcrTemplateZone): string[] {
const language = zone.ocr_language || ''
const cached = this.ocrLanguageCache.get(zone)
if (cached?.language === language) return cached.languages
const languages = language.split('+').filter(Boolean)
this.ocrLanguageCache.set(zone, { language, languages })
return languages
}
setOcrLanguages(zone: OcrTemplateZone, langs: string[]) {
zone.ocr_language = (langs ?? []).join('+')
}
/** The field select's value: a built-in target, or a custom field id. */
zoneFieldValue(zone: OcrTemplateZone): ZoneFieldSelection {
return zone.target === OcrZoneTarget.CustomField
? zone.custom_field
: zone.target
}
setZoneField(zone: OcrTemplateZone, value: ZoneFieldSelection) {
if (typeof value === 'string') {
zone.target = value
zone.custom_field = null
} else {
zone.target = OcrZoneTarget.CustomField
zone.custom_field = value
}
}
zonesForField(zone: OcrTemplateZone): OcrTemplateZone[] {
const key = getZoneFieldKey(zone)
if (!key) return []
return this.template.zones.filter((z) => getZoneFieldKey(z) === key)
}
isFieldShared(zone: OcrTemplateZone): boolean {
return this.zonesForField(zone).length > 1
}
renameZone(zone: OcrTemplateZone, name: string) {
const oldName = zone.name.trim()
const format = this.getCombineFormat(zone)
if (format) {
this.setCombineFormat(
zone,
format.replace(/\{([^{}]*)\}/g, (token, tokenName: string) =>
tokenName.trim() === oldName ? `{${name}}` : token
)
)
}
zone.name = name
}
getCombineFormat(zone: OcrTemplateZone): string {
return this.template.combine_formats?.[getZoneFieldKey(zone)] ?? ''
}
setCombineFormat(zone: OcrTemplateZone, value: string) {
const key = getZoneFieldKey(zone)
if (!key) return
this.template.combine_formats ??= {}
this.template.combine_formats[key] = value
}
defaultCombineFormat(zone: OcrTemplateZone): string {
return this.zonesForField(zone)
.map((z) => `{${z.name}}`)
.join(' ')
}
insertCombineToken(zone: OcrTemplateZone, tokenZone: OcrTemplateZone) {
const current = this.getCombineFormat(zone)
const sep = current && !current.endsWith(' ') ? ' ' : ''
this.setCombineFormat(zone, `${current}${sep}{${tokenZone.name}}`)
}
/** Drop formats that are empty or whose field no longer has several zones. */
private pruneCombineFormats() {
const formats = this.template.combine_formats
if (!formats) return
const keys = this.template.zones.map(getZoneFieldKey)
for (const [key, format] of Object.entries(formats)) {
if (!format || keys.filter((k) => k === key).length < 2) {
delete formats[key]
}
}
}
createField(name?: string) {
const zone = this.selectedZone
const modal = this.modalService.open(CustomFieldEditDialogComponent)
modal.componentInstance.dialogMode.set(EditDialogMode.CREATE)
modal.componentInstance.object = { name: name || zone?.name }
modal.componentInstance.succeeded
.pipe(takeUntil(this.destroy$))
.subscribe((field: CustomField) => {
this.toastService.showSuccess($localize`Saved field "${field.name}".`)
this.customFieldsService.clearCache()
this.customFieldsService
.listAll()
.pipe(takeUntil(this.destroy$))
.subscribe((r) => {
if (zone && OCR_SUPPORTED_FIELD_TYPES.includes(field.data_type)) {
this.setZoneField(zone, field.id)
}
this.customFields.set(r.results)
})
})
modal.componentInstance.failed
.pipe(takeUntil(this.destroy$))
.subscribe((e) => {
this.toastService.showError($localize`Error saving field.`, e)
})
}
ngOnDestroy() {
this.previewResizeObserver?.disconnect()
this.cancelZoneTest$.complete()
this.destroy$.next()
this.destroy$.complete()
}
}
@@ -1,197 +0,0 @@
import {
OcrTemplateZone,
OcrZoneTarget,
OcrZoneTransform,
} from 'src/app/data/ocr-template'
import {
findHandleAt,
findZoneAt,
getResizeHandles,
getZoneDisplayRect,
getZonePage,
isZoneOnPage,
moveZone,
resizeZone,
} from './zone-geometry'
function zone(overrides: Partial<OcrTemplateZone> = {}): OcrTemplateZone {
return {
name: 'Zone',
target: OcrZoneTarget.CustomField,
custom_field: 1,
x: 100,
y: 200,
width: 300,
height: 400,
page: 1,
ocr_language: 'eng',
transform: OcrZoneTransform.Strip,
validation_regex: '',
order: 0,
...overrides,
}
}
describe('OCR template editor geometry', () => {
it('normalizes zone pages', () => {
expect(getZonePage(zone({ page: 2 }), 0, 5)).toBe(2)
expect(getZonePage(zone({ page: -1 }), 0, 5)).toBe(5)
expect(getZonePage(zone({ page: -1 }), 2, null)).toBe(3)
expect(getZonePage(zone({ page: 0 }), 0, 5)).toBe(1)
expect(getZonePage(zone({ page: undefined }), 0, 5)).toBe(1)
})
it('checks whether a zone is on the current preview page', () => {
expect(isZoneOnPage(zone({ page: 2 }), 1, 5)).toBe(true)
expect(isZoneOnPage(zone({ page: 2 }), 0, 5)).toBe(false)
expect(isZoneOnPage(zone({ page: -1 }), 4, 5)).toBe(true)
})
it('uses natural image coordinates when source dimensions are unset', () => {
expect(
getZoneDisplayRect(zone({ x: 100, y: 200, width: 300, height: 400 }), {
width: 1000,
height: 2000,
})
).toEqual({ x: 100, y: 200, w: 300, h: 400 })
})
it('uses per-zone source dimensions when present', () => {
expect(
getZoneDisplayRect(
zone({
x: 100,
y: 100,
width: 100,
height: 100,
zone_source_width: 1000,
zone_source_height: 1000,
}),
{ width: 500, height: 750 }
)
).toEqual({ x: 50, y: 75, w: 50, h: 75 })
})
it('finds zones from topmost to bottommost on the current page', () => {
const zones = [
zone({ name: 'first', x: 0, y: 0, width: 100, height: 100, page: 1 }),
zone({ name: 'second', x: 0, y: 0, width: 50, height: 50, page: 1 }),
zone({ name: 'third', x: 0, y: 0, width: 50, height: 50, page: 2 }),
]
expect(
findZoneAt({ x: 25, y: 25 }, zones, 0, 2, { width: 100, height: 100 })
).toBe(1)
})
it('finds resize handles around a display rect', () => {
const rect = { x: 10, y: 20, w: 100, h: 200 }
expect(findHandleAt({ x: 10, y: 20 }, rect)).toBe('nw')
expect(findHandleAt({ x: 110, y: 220 }, rect)).toBe('se')
expect(findHandleAt({ x: 60, y: 20 }, rect)).toBe('n')
expect(findHandleAt({ x: 90, y: 160 }, rect)).toBeNull()
expect(findHandleAt({ x: 19, y: 20 }, rect, 8)).toBeNull()
expect(findHandleAt({ x: 19, y: 20 }, rect, 10)).toBe('nw')
})
it('returns no zone when the point is outside every rectangle', () => {
expect(
findZoneAt({ x: 900, y: 900 }, [zone()], 0, 1, {
width: 1000,
height: 1000,
})
).toBeNull()
})
it('resizes north-west edges while keeping the opposite corner fixed', () => {
const z = zone({ x: 100, y: 200, width: 300, height: 400 })
resizeZone(z, 'nw', { x: 50, y: 100 }, { width: 1000, height: 1000 })
expect(z).toMatchObject({ x: 50, y: 100, width: 350, height: 500 })
resizeZone(z, 'nw', { x: 900, y: 900 }, { width: 1000, height: 1000 })
expect(z).toMatchObject({ x: 390, y: 590, width: 10, height: 10 })
})
it('clamps resized north-west edges to the source image bounds', () => {
const z = zone({
x: 50,
y: 50,
width: 100,
height: 100,
zone_source_width: 200,
zone_source_height: 300,
})
resizeZone(z, 'nw', { x: -50, y: -50 }, { width: 400, height: 600 })
expect(z).toMatchObject({ x: 0, y: 0, width: 150, height: 150 })
})
it('uses the rendered handle positions for hit testing', () => {
const rect = { x: 10, y: 20, w: 100, h: 200 }
const handles = getResizeHandles(rect)
expect(handles).toEqual([
{ handle: 'nw', x: 10, y: 20 },
{ handle: 'n', x: 60, y: 20 },
{ handle: 'ne', x: 110, y: 20 },
{ handle: 'w', x: 10, y: 120 },
{ handle: 'e', x: 110, y: 120 },
{ handle: 'sw', x: 10, y: 220 },
{ handle: 's', x: 60, y: 220 },
{ handle: 'se', x: 110, y: 220 },
])
for (const marker of handles) {
expect(findHandleAt(marker, rect)).toBe(marker.handle)
}
})
it('moves zones without leaving source image bounds', () => {
const z = zone({ x: 50, y: 50, width: 100, height: 100 })
moveZone(
z,
{ x: 500, y: 500 },
{ mouseX: 50, mouseY: 50, zoneX: 50, zoneY: 50 },
{ width: 500, height: 500 }
)
expect(z.x).toBe(400)
expect(z.y).toBe(400)
})
it('resizes zones without leaving source image bounds', () => {
const z = zone({
x: 50,
y: 50,
width: 100,
height: 100,
zone_source_width: 200,
zone_source_height: 200,
})
resizeZone(z, 'se', { x: 500, y: 500 }, { width: 500, height: 500 })
expect(z.width).toBe(150)
expect(z.height).toBe(150)
})
it('moves zones in their original source coordinates on a different-size preview', () => {
const z = zone({
x: 50,
y: 50,
width: 100,
height: 100,
zone_source_width: 1000,
zone_source_height: 2000,
})
moveZone(
z,
{ x: 75, y: 75 },
{ mouseX: 25, mouseY: 25, zoneX: 50, zoneY: 50 },
{ width: 500, height: 500 }
)
expect(z.x).toBe(150)
expect(z.y).toBe(250)
})
})
@@ -1,204 +0,0 @@
import { OcrTemplateZone } from 'src/app/data/ocr-template'
export interface DrawingRect {
startX: number
startY: number
endX: number
endY: number
}
export interface Dimensions {
width: number
height: number
}
export interface Point {
x: number
y: number
}
export interface DisplayRect {
x: number
y: number
w: number
h: number
}
export interface MoveStart {
mouseX: number
mouseY: number
zoneX: number
zoneY: number
}
export type ResizeHandle = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
export interface ResizeHandleMarker extends Point {
handle: ResizeHandle
}
export const HANDLE_SIZE = 8
export const MIN_ZONE_SIZE = 10
const ZONE_COLORS = [
'#4f8ff7',
'#ff6b6b',
'#51cf66',
'#ffd43b',
'#cc5de8',
'#ff922b',
'#20c997',
'#e599f7',
]
/** Outline color for a zone, shared by the preview overlay and the zone list. */
export function getZoneColor(index: number): string {
return ZONE_COLORS[index % ZONE_COLORS.length]
}
export function getZonePage(
zone: OcrTemplateZone,
previewPage: number,
previewPageCount: number | null
): number {
const page = zone.page ?? 1
if (page === -1) return previewPageCount ?? previewPage + 1
return Math.max(page, 1)
}
export function isZoneOnPage(
zone: OcrTemplateZone,
previewPage: number,
previewPageCount: number | null
): boolean {
return getZonePage(zone, previewPage, previewPageCount) === previewPage + 1
}
function getZoneSourceSize(
zone: OcrTemplateZone,
imageSize: Dimensions
): Dimensions {
return {
width: zone.zone_source_width || imageSize.width,
height: zone.zone_source_height || imageSize.height,
}
}
export function getZoneDisplayRect(
zone: OcrTemplateZone,
imageSize: Dimensions
): DisplayRect {
const sourceSize = getZoneSourceSize(zone, imageSize)
// The SVG viewBox uses the preview image's natural dimensions.
const scaleX = imageSize.width / sourceSize.width
const scaleY = imageSize.height / sourceSize.height
return {
x: zone.x * scaleX,
y: zone.y * scaleY,
w: zone.width * scaleX,
h: zone.height * scaleY,
}
}
export function getResizeHandles(rect: DisplayRect): ResizeHandleMarker[] {
return [
{ handle: 'nw', x: rect.x, y: rect.y },
{ handle: 'n', x: rect.x + rect.w / 2, y: rect.y },
{ handle: 'ne', x: rect.x + rect.w, y: rect.y },
{ handle: 'w', x: rect.x, y: rect.y + rect.h / 2 },
{ handle: 'e', x: rect.x + rect.w, y: rect.y + rect.h / 2 },
{ handle: 'sw', x: rect.x, y: rect.y + rect.h },
{ handle: 's', x: rect.x + rect.w / 2, y: rect.y + rect.h },
{ handle: 'se', x: rect.x + rect.w, y: rect.y + rect.h },
]
}
export function findHandleAt(
point: Point,
rect: DisplayRect,
handleSize = HANDLE_SIZE
): ResizeHandle | null {
return (
getResizeHandles(rect).find(
({ x, y }) =>
Math.abs(point.x - x) <= handleSize &&
Math.abs(point.y - y) <= handleSize
)?.handle ?? null
)
}
export function findZoneAt(
point: Point,
zones: OcrTemplateZone[],
previewPage: number,
previewPageCount: number | null,
imageSize: Dimensions
): number | null {
for (let i = zones.length - 1; i >= 0; i--) {
const zone = zones[i]
if (!isZoneOnPage(zone, previewPage, previewPageCount)) continue
const rect = getZoneDisplayRect(zone, imageSize)
if (
point.x >= rect.x &&
point.x <= rect.x + rect.w &&
point.y >= rect.y &&
point.y <= rect.y + rect.h
) {
return i
}
}
return null
}
export function moveZone(
zone: OcrTemplateZone,
point: Point,
moveStart: MoveStart,
imageSize: Dimensions
) {
const sourceSize = getZoneSourceSize(zone, imageSize)
const scaleX = sourceSize.width / imageSize.width
const scaleY = sourceSize.height / imageSize.height
const dx = Math.round((point.x - moveStart.mouseX) * scaleX)
const dy = Math.round((point.y - moveStart.mouseY) * scaleY)
zone.x = clamp(moveStart.zoneX + dx, 0, sourceSize.width - zone.width)
zone.y = clamp(moveStart.zoneY + dy, 0, sourceSize.height - zone.height)
}
export function resizeZone(
zone: OcrTemplateZone,
handle: ResizeHandle,
point: Point,
imageSize: Dimensions
) {
const sourceSize = getZoneSourceSize(zone, imageSize)
const scaleX = sourceSize.width / imageSize.width
const scaleY = sourceSize.height / imageSize.height
const imageX = clamp(Math.round(point.x * scaleX), 0, sourceSize.width)
const imageY = clamp(Math.round(point.y * scaleY), 0, sourceSize.height)
if (handle.includes('w')) {
const right = Math.min(zone.x + zone.width, sourceSize.width)
zone.x = clamp(imageX, 0, right - MIN_ZONE_SIZE)
zone.width = right - zone.x
}
if (handle.includes('e')) {
zone.width = Math.max(MIN_ZONE_SIZE, imageX - zone.x)
}
if (handle.includes('n')) {
const bottom = Math.min(zone.y + zone.height, sourceSize.height)
zone.y = clamp(imageY, 0, bottom - MIN_ZONE_SIZE)
zone.height = bottom - zone.y
}
if (handle.includes('s')) {
zone.height = Math.max(MIN_ZONE_SIZE, imageY - zone.y)
}
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(value, max))
}
@@ -1,75 +0,0 @@
<pngx-page-header
title="OCR Templates"
i18n-title
info="Define extraction zones on document types to automatically populate custom fields via OCR."
i18n-info
>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="editTemplate()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.OcrTemplate }">
<pngx-bs-icon name="plus-circle" class="me-1"></pngx-bs-icon><ng-container i18n>Add Template</ng-container>
</button>
</pngx-page-header>
<ul class="list-group">
<li class="list-group-item">
<div class="row">
<div class="col" i18n>Name</div>
<div class="col d-none d-sm-flex" i18n>Matching</div>
<div class="col d-none d-sm-flex" i18n>Zones</div>
<div class="col" i18n>Status</div>
<div class="col" i18n>Actions</div>
</div>
</li>
@if (loading() && templates().length === 0) {
<li class="list-group-item">
<output class="spinner-border spinner-border-sm me-2"></output>
<ng-container i18n>Loading...</ng-container>
</li>
}
@for (template of templates(); track template.id) {
<li class="list-group-item">
<div class="row fade" [class.show]="show()">
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editTemplate(template)" [disabled]="!userCanEdit(template)">{{template.name}}</button></div>
<div class="col d-flex align-items-center d-none d-sm-flex">{{getMatching(template)}}</div>
<div class="col d-flex align-items-center d-none d-sm-flex"><code>{{template.zones.length}}</code></div>
<div class="col d-flex align-items-center">
<div class="form-check form-switch mb-0">
<input type="checkbox" class="form-check-input cursor-pointer" [id]="template.id+'_enable'" [(ngModel)]="template.enabled" (change)="toggleTemplateEnabled(template)" [disabled]="!userCanEdit(template)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.OcrTemplate }">
<label class="form-check-label cursor-pointer" [for]="template.id+'_enable'">
<code> @if(template.enabled) { <ng-container i18n>Enabled</ng-container> } @else { <span i18n class="text-muted">Disabled</span> }</code>
</label>
</div>
</div>
<div class="col">
<div class="btn-group d-block d-sm-none">
<div ngbDropdown container="body" class="d-inline-block">
<button type="button" class="btn btn-link" id="actionsMenuMobile{{template.id}}" (click)="$event.stopPropagation()" ngbDropdownToggle>
<pngx-bs-icon name="three-dots-vertical"></pngx-bs-icon>
</button>
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile{{template.id}}">
<button (click)="editTemplate(template)" [disabled]="!userCanEdit(template)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.OcrTemplate }" ngbDropdownItem i18n>Edit</button>
<button (click)="deleteTemplate(template)" [disabled]="!permissionsService.currentUserOwnsObject(template)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.OcrTemplate }" ngbDropdownItem i18n>Delete</button>
</div>
</div>
</div>
<div class="btn-toolbar d-none d-sm-flex gap-2" role="toolbar">
<div class="btn-group">
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.OcrTemplate }" [disabled]="!userCanEdit(template)" class="btn btn-sm btn-outline-secondary" type="button" (click)="editTemplate(template)">
<pngx-bs-icon width="1em" height="1em" name="pencil" class="me-1"></pngx-bs-icon><ng-container i18n>Edit</ng-container>
</button>
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.OcrTemplate }" [disabled]="!permissionsService.currentUserOwnsObject(template)" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteTemplate(template)">
<pngx-bs-icon width="1em" height="1em" name="trash" class="me-1"></pngx-bs-icon><ng-container i18n>Delete</ng-container>
</button>
</div>
</div>
</div>
</div>
</li>
}
@if (!loading() && templates().length === 0) {
<li class="list-group-item" [class.show]="show()" i18n>No OCR templates defined.</li>
}
</ul>
@@ -1,300 +0,0 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
import { of, throwError } from 'rxjs'
import { MATCH_LITERAL, MATCH_NONE } from 'src/app/data/matching-model'
import { OcrTemplate } from 'src/app/data/ocr-template'
import { User } from 'src/app/data/user'
import { DirtyFormGuard } from 'src/app/guards/dirty-form.guard'
import { PermissionsService } from 'src/app/services/permissions.service'
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
import { UserService } from 'src/app/services/rest/user.service'
import { ToastService } from 'src/app/services/toast.service'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { OcrTemplateEditorComponent } from './ocr-template-editor/ocr-template-editor.component'
import { OcrTemplatesComponent } from './ocr-templates.component'
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
const templates: OcrTemplate[] = [
{
id: 1,
name: 'Invoices',
match: 'Northwind',
matching_algorithm: MATCH_LITERAL,
sample_document: null,
source_width: 0,
source_height: 0,
enabled: true,
zones: [],
},
]
describe('OcrTemplatesComponent', () => {
let component: OcrTemplatesComponent
let fixture: ComponentFixture<OcrTemplatesComponent>
let ocrTemplateService: OcrTemplateService
let modalService: NgbModal
let toastService: ToastService
let modal: NgbModalRef
beforeEach(() => {
TestBed.configureTestingModule({
imports: [OcrTemplatesComponent, BootstrapIconComponent],
providers: [
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
})
TestBed.inject(PermissionsService).initialize(
[
'view_ocrtemplate',
'add_ocrtemplate',
'change_ocrtemplate',
'delete_ocrtemplate',
],
{ id: 1, groups: [], is_superuser: false } as User
)
jest
.spyOn(TestBed.inject(UserService), 'listAll')
.mockReturnValue(of({ count: 0, all: [], results: [] }))
ocrTemplateService = TestBed.inject(OcrTemplateService)
jest
.spyOn(ocrTemplateService, 'listAll')
.mockReturnValue(of({ count: 1, all: [1], results: templates }))
modalService = TestBed.inject(NgbModal)
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
toastService = TestBed.inject(ToastService)
fixture = TestBed.createComponent(OcrTemplatesComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
afterEach(() => modalService.dismissAll())
it('should show templates with their matching', () => {
const text = fixture.nativeElement.textContent
expect(text).toContain('Invoices')
expect(text).toContain('Exact match: Northwind')
expect(
component.getMatching({ ...templates[0], matching_algorithm: MATCH_NONE })
).toEqual('None')
expect(component.getMatching({ ...templates[0], match: '' })).toEqual('-')
})
it.each([
{
name: 'owned',
owner: 1,
user_can_change: true,
editable: true,
deletable: true,
},
{
name: 'unowned',
owner: null,
user_can_change: true,
editable: true,
deletable: true,
},
{
name: 'view-only shared',
owner: 2,
user_can_change: false,
editable: false,
deletable: false,
},
{
name: 'editable shared',
owner: 2,
user_can_change: true,
editable: true,
deletable: false,
},
])(
'should respect permissions for $name templates',
async ({ owner, user_can_change, editable, deletable }) => {
const template = { ...templates[0], owner, user_can_change }
component.templates.set([template])
fixture.detectChanges()
await fixture.whenStable()
expect(fixture.nativeElement.querySelector('.text-start').disabled).toBe(
!editable
)
expect(
fixture.nativeElement.querySelector('.form-switch input').disabled
).toBe(!editable)
const buttons = Array.from<HTMLButtonElement>(
fixture.nativeElement.querySelectorAll('button')
)
for (const button of buttons.filter(
(button) => button.textContent.trim() === 'Edit'
)) {
expect(button.disabled).toBe(!editable)
}
for (const button of buttons.filter(
(button) => button.textContent.trim() === 'Delete'
)) {
expect(button.disabled).toBe(!deletable)
}
}
)
it('should allow a superuser to manage another users template', () => {
TestBed.inject(PermissionsService).initialize([], {
id: 1,
is_superuser: true,
} as User)
component.templates.set([
{ ...templates[0], owner: 2, user_can_change: false },
])
fixture.detectChanges()
expect(fixture.nativeElement.querySelector('.text-start').disabled).toBe(
false
)
expect(
fixture.nativeElement.querySelector('.btn-outline-danger').disabled
).toBe(false)
})
it('should not open mutation dialogs or patch a view-only template', () => {
const template = { ...templates[0], owner: 2, user_can_change: false }
const open = jest.spyOn(modalService, 'open')
const patch = jest.spyOn(ocrTemplateService, 'patch')
component.editTemplate(template)
component.toggleTemplateEnabled(template)
component.deleteTemplate(template)
expect(open).not.toHaveBeenCalled()
expect(patch).not.toHaveBeenCalled()
})
it('should require global edit permission even for an owned template', () => {
TestBed.inject(PermissionsService).initialize(['view_ocrtemplate'], {
id: 1,
} as User)
fixture.destroy()
fixture = TestBed.createComponent(OcrTemplatesComponent)
component = fixture.componentInstance
fixture.detectChanges()
expect(fixture.nativeElement.querySelector('.text-start').disabled).toBe(
true
)
expect(fixture.nativeElement.querySelector('.form-switch input')).toBeNull()
})
it('should support create, show notification on error / success', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
const reloadSpy = jest.spyOn(component, 'reload')
fixture.debugElement
.queryAll(By.css('pngx-page-header button'))
.find((button) => button.nativeElement.textContent.includes('Add'))
.triggerEventHandler('click')
const editor = modal.componentInstance as OcrTemplateEditorComponent
expect(editor).toBeInstanceOf(OcrTemplateEditorComponent)
expect(editor.object).toBeUndefined()
editor.failed.emit({ error: 'error creating template' })
expect(toastErrorSpy).toHaveBeenCalled()
expect(reloadSpy).not.toHaveBeenCalled()
editor.succeeded.emit(templates[0])
expect(toastSuccessSpy).toHaveBeenCalled()
expect(reloadSpy).toHaveBeenCalled()
})
it('should pass the template to the editor', () => {
component.editTemplate(templates[0])
expect(modal.componentInstance.object).toBe(templates[0])
})
it('should confirm closing the editor with unsaved changes', async () => {
const confirmSpy = jest
.spyOn(TestBed.inject(DirtyFormGuard), 'confirmChanges')
.mockReturnValue(of(false))
component.editTemplate()
const editor = modal.componentInstance as OcrTemplateEditorComponent
editor.ngOnInit()
modal.dismiss()
await fixture.whenStable()
expect(confirmSpy).not.toHaveBeenCalled()
component.editTemplate()
const dirtyEditor = modal.componentInstance as OcrTemplateEditorComponent
dirtyEditor.ngOnInit()
dirtyEditor.template.name = 'Changed'
modal.dismiss()
await fixture.whenStable()
expect(confirmSpy).toHaveBeenCalled()
expect(modalService.hasOpenModals()).toBe(true)
dirtyEditor.saving.set(true)
confirmSpy.mockClear()
modal.dismiss()
await fixture.whenStable()
expect(confirmSpy).not.toHaveBeenCalled()
expect(modalService.hasOpenModals()).toBe(true)
})
it('should only send the enabled flag when toggling', () => {
const patchSpy = jest
.spyOn(ocrTemplateService, 'patch')
.mockReturnValueOnce(throwError(() => new Error('error toggling')))
.mockReturnValueOnce(of(templates[0]))
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
const reloadSpy = jest.spyOn(component, 'reload')
const toggle = fixture.debugElement.query(By.css('.form-switch input'))
toggle.nativeElement.click()
expect(patchSpy).toHaveBeenCalledWith({ id: 1, enabled: false })
expect(toastErrorSpy).toHaveBeenCalled()
expect(reloadSpy).toHaveBeenCalledTimes(1)
component.toggleTemplateEnabled(templates[0])
expect(toastSuccessSpy).toHaveBeenCalled()
expect(reloadSpy).toHaveBeenCalledTimes(2)
})
it('should support delete, show notification on error / success', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
const deleteSpy = jest.spyOn(ocrTemplateService, 'delete')
const reloadSpy = jest.spyOn(component, 'reload')
component.deleteTemplate(templates[0])
const dialog = modal.componentInstance as ConfirmDialogComponent
deleteSpy.mockReturnValueOnce(throwError(() => new Error('error deleting')))
dialog.confirmClicked.emit()
expect(toastErrorSpy).toHaveBeenCalled()
expect(dialog.buttonsEnabled()).toBe(true)
expect(reloadSpy).not.toHaveBeenCalled()
deleteSpy.mockReturnValueOnce(of(true))
dialog.confirmClicked.emit()
expect(toastSuccessSpy).toHaveBeenCalled()
expect(reloadSpy).toHaveBeenCalled()
})
it.each([true, false])(
'reports the enabled state %s after the update succeeds',
(enabled) => {
const template = { ...templates[0], enabled }
jest.spyOn(ocrTemplateService, 'patch').mockReturnValue(of(template))
const toast = jest.spyOn(toastService, 'showSuccess')
component.toggleTemplateEnabled(template)
expect(toast).toHaveBeenCalledWith(
`${enabled ? 'Enabled' : 'Disabled'} OCR template "${template.name}"`
)
}
)
})
@@ -1,185 +0,0 @@
import { Component, OnInit, inject, signal } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
import { firstValueFrom, takeUntil, tap } from 'rxjs'
import { MATCH_NONE, MATCHING_ALGORITHMS } from 'src/app/data/matching-model'
import { OcrTemplate } from 'src/app/data/ocr-template'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { DirtyFormGuard } from 'src/app/guards/dirty-form.guard'
import {
PermissionAction,
PermissionType,
PermissionsService,
} from 'src/app/services/permissions.service'
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
import { ToastService } from 'src/app/services/toast.service'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
import { OcrTemplateEditorComponent } from './ocr-template-editor/ocr-template-editor.component'
@Component({
selector: 'pngx-ocr-templates',
templateUrl: './ocr-templates.component.html',
imports: [
PageHeaderComponent,
IfPermissionsDirective,
FormsModule,
NgbDropdownModule,
BootstrapIconComponent,
],
})
export class OcrTemplatesComponent
extends LoadingComponentWithPermissions
implements OnInit
{
private readonly ocrTemplateService = inject(OcrTemplateService)
public readonly permissionsService = inject(PermissionsService)
private readonly modalService = inject(NgbModal)
private readonly dirtyFormGuard = inject(DirtyFormGuard)
private readonly toastService = inject(ToastService)
public readonly templates = signal<OcrTemplate[]>([])
ngOnInit() {
this.reload()
}
reload() {
this.loading.set(true)
this.ocrTemplateService
.listAll()
.pipe(
takeUntil(this.unsubscribeNotifier),
tap((r) => this.templates.set(r.results))
)
.subscribe(() => {
this.show.set(true)
this.loading.set(false)
})
}
getMatching(template: OcrTemplate): string {
if (template.matching_algorithm === MATCH_NONE) {
return $localize`None`
}
if (!template.match) {
return '-'
}
const algorithm = MATCHING_ALGORITHMS.find(
(a) => a.id === template.matching_algorithm
)
return `${algorithm?.shortName}: ${template.match}`
}
editTemplate(template?: OcrTemplate) {
if (template && !this.userCanEdit(template)) return
const modal = this.modalService.open(OcrTemplateEditorComponent, {
backdrop: 'static',
keyboard: false,
size: 'xxl',
fullscreen: 'lg',
scrollable: true,
ariaLabelledBy: 'ocr-template-editor-title',
beforeDismiss: () => {
const editor: OcrTemplateEditorComponent = modal.componentInstance
if (editor.saving()) return false
if (!editor.isDirty) return true
return firstValueFrom(
this.dirtyFormGuard.confirmChanges(undefined, $localize`Close`)
)
},
})
modal.componentInstance.object = template
modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((saved: OcrTemplate) => {
this.toastService.showSuccess(
$localize`Saved OCR template "${saved.name}".`
)
this.ocrTemplateService.clearCache()
this.reload()
})
modal.componentInstance.failed
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((e) => {
this.toastService.showError($localize`Error saving OCR template.`, e)
})
}
toggleTemplateEnabled(template: OcrTemplate) {
if (!this.userCanEdit(template)) return
this.ocrTemplateService
.patch({ id: template.id, enabled: template.enabled } as OcrTemplate)
.subscribe({
next: () => {
this.toastService.showSuccess(
template.enabled
? $localize`Enabled OCR template "${template.name}"`
: $localize`Disabled OCR template "${template.name}"`
)
this.reload()
},
error: (e) => {
this.toastService.showError(
$localize`Error toggling OCR template "${template.name}".`,
e
)
this.reload()
},
})
}
deleteTemplate(template: OcrTemplate) {
if (
!this.permissionsService.currentUserCan(
PermissionAction.Delete,
PermissionType.OcrTemplate
) ||
!this.permissionsService.currentUserOwnsObject(template)
)
return
const modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Confirm delete OCR template`
modal.componentInstance.messageBold = $localize`This operation will permanently delete this OCR template.`
modal.componentInstance.message = $localize`This operation cannot be undone.`
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
this.ocrTemplateService.delete(template).subscribe({
next: () => {
modal.close()
this.toastService.showSuccess(
$localize`Deleted OCR template "${template.name}".`
)
this.ocrTemplateService.clearCache()
this.reload()
},
error: (e) => {
modal.componentInstance.buttonsEnabled.set(true)
this.toastService.showError(
$localize`Error deleting OCR template "${template.name}".`,
e
)
},
})
})
}
userCanEdit(template: OcrTemplate): boolean {
return (
this.permissionsService.currentUserCan(
PermissionAction.Change,
PermissionType.OcrTemplate
) &&
this.permissionsService.currentUserHasObjectPermissions(
PermissionAction.Change,
template
)
)
}
}
-115
View File
@@ -1,115 +0,0 @@
import { CustomFieldDataType } from './custom-field'
import { MatchingModel } from './matching-model'
import { ObjectWithPermissions } from './object-with-permissions'
export enum OcrZoneTarget {
CustomField = 'custom_field',
Title = 'title',
Asn = 'asn',
Created = 'created',
}
export enum OcrZoneTransform {
None = 'none',
Strip = 'strip',
Uppercase = 'uppercase',
Lowercase = 'lowercase',
Numeric = 'numeric',
StripPunctuation = 'strip_punctuation',
Date = 'date',
QrCode = 'qr_code',
}
export const DEFAULT_OCR_ZONE_LANGUAGE = 'eng'
/** The field a zone writes to, keyed by OcrTemplate.combine_formats. */
export function getZoneFieldKey(zone: OcrTemplateZone): string | null {
if (zone.target !== OcrZoneTarget.CustomField) return zone.target
return zone.custom_field === null ? null : String(zone.custom_field)
}
/** Custom field types zone OCR can write to. See models.py OCR_SUPPORTED_FIELD_TYPES. */
export const OCR_SUPPORTED_FIELD_TYPES: CustomFieldDataType[] = [
CustomFieldDataType.String,
CustomFieldDataType.Url,
CustomFieldDataType.Date,
CustomFieldDataType.Integer,
CustomFieldDataType.Float,
CustomFieldDataType.Monetary,
CustomFieldDataType.LongText,
CustomFieldDataType.Boolean,
]
export const OCR_BUILTIN_TARGETS = [
{ id: OcrZoneTarget.Title, name: $localize`Title` },
{ id: OcrZoneTarget.Asn, name: $localize`Archive serial number` },
{ id: OcrZoneTarget.Created, name: $localize`Date created` },
]
export interface OcrTemplateZone {
id?: number
name: string
target: OcrZoneTarget
custom_field: number | null
page?: number
x: number
y: number
width: number
height: number
ocr_language: string
transform: OcrZoneTransform
date_format?: string
validation_regex: string
order: number
zone_source_width?: number
zone_source_height?: number
}
export interface OcrTemplate extends MatchingModel, ObjectWithPermissions {
name: string
sample_document: number | null
source_width: number
source_height: number
enabled: boolean
combine_formats?: Record<string, string>
created?: string
updated?: string
zones: OcrTemplateZone[]
}
export interface ZoneTestRequest {
name: string
x: number
y: number
width: number
height: number
page: number
ocr_language: string
transform: OcrZoneTransform
date_format?: string
validation_regex: string
zone_source_width?: number
zone_source_height?: number
}
export interface OcrZoneTestResult {
raw_text?: string | null
value?: string | null
regex?: string
regex_match?: boolean | null
error?: string
}
export interface OcrZoneRunResult {
template: string
zone: string
custom_field: string
value: string | number | boolean | null
reason?: string | null
}
export interface OcrZoneRunResponse {
status: 'completed' | 'skipped'
reason?: 'no_template'
results: OcrZoneRunResult[]
}
-1
View File
@@ -35,7 +35,6 @@ export enum HideableSidebarItemID {
SavedViews = 'saved_views', SavedViews = 'saved_views',
ShareLinks = 'share_links', ShareLinks = 'share_links',
Workflows = 'workflows', Workflows = 'workflows',
OcrTemplates = 'ocr_templates',
Mail = 'mail', Mail = 'mail',
Documentation = 'documentation', Documentation = 'documentation',
} }
+2 -6
View File
@@ -3,16 +3,12 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { DirtyCheckGuard } from '@ngneat/dirty-check-forms' import { DirtyCheckGuard } from '@ngneat/dirty-check-forms'
import { Observable, Subject } from 'rxjs' import { Observable, Subject } from 'rxjs'
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
import { ActivatedRouteSnapshot } from '@angular/router'
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class DirtyFormGuard extends DirtyCheckGuard { export class DirtyFormGuard extends DirtyCheckGuard {
private modalService = inject(NgbModal) private modalService = inject(NgbModal)
confirmChanges( confirmChanges(): Observable<boolean> {
_currentRoute?: ActivatedRouteSnapshot,
leaveLabel: string = $localize`Leave page`
): Observable<boolean> {
let modal = this.modalService.open(ConfirmDialogComponent, { let modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static', backdrop: 'static',
}) })
@@ -20,7 +16,7 @@ export class DirtyFormGuard extends DirtyCheckGuard {
modal.componentInstance.messageBold = $localize`You have unsaved changes.` modal.componentInstance.messageBold = $localize`You have unsaved changes.`
modal.componentInstance.message = $localize`Are you sure you want to leave?` modal.componentInstance.message = $localize`Are you sure you want to leave?`
modal.componentInstance.btnClass = 'btn-warning' modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = leaveLabel modal.componentInstance.btnCaption = $localize`Leave page`
modal.componentInstance.confirmClicked.subscribe(() => { modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled.set(false)
modal.close() modal.close()
@@ -240,7 +240,7 @@ describe('OpenDocumentsService', () => {
expect(refreshed.tags).toEqual([4]) expect(refreshed.tags).toEqual([4])
}) })
it('should handle error on refresh documents', () => { it('keeps documents open after a network error during refresh', () => {
subscriptions.push( subscriptions.push(
openDocumentsService.openDocument(documents[1]).subscribe() openDocumentsService.openDocument(documents[1]).subscribe()
) )
@@ -250,7 +250,63 @@ describe('OpenDocumentsService', () => {
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.error(new ErrorEvent('timeout')) req.error(new ErrorEvent('timeout'))
expect(openDocumentsService.getOpenDocument(documents[1].id)).toBe(
documents[1]
)
})
it.each([500, 403])(
'keeps documents open after HTTP %s during refresh',
(status) => {
const doc = { ...documents[0] }
openDocumentsService.openDocument(doc).subscribe()
openDocumentsService.refreshDocument(doc.id)
httpTestingController
.expectOne(
`${environment.apiBaseUrl}documents/${doc.id}/?full_perms=true`
)
.flush({}, { status, statusText: 'Error' })
expect(openDocumentsService.getOpenDocument(doc.id)).toBe(doc)
}
)
it('preserves unsaved edits when a refreshed document no longer exists', () => {
const doc = { ...documents[0], title: 'Unsaved title' }
openDocumentsService.openDocument(doc).subscribe()
openDocumentsService.setDirty(doc, true, { title: doc.title })
openDocumentsService.refreshDocument(doc.id)
httpTestingController
.expectOne(
`${environment.apiBaseUrl}documents/${doc.id}/?full_perms=true`
)
.flush({}, { status: 404, statusText: 'Not Found' })
expect(openDocumentsService.getOpenDocument(doc.id)).toBe(doc)
expect(openDocumentsService.isDirty(doc)).toBe(true)
expect(
JSON.parse(sessionStorage.getItem(OPEN_DOCUMENT_SERVICE.DOCUMENTS))[0]
.title
).toBe('Unsaved title')
})
it('removes only the missing document when tab positions change during refresh', () => {
const first = { ...documents[0] }
const second = { ...documents[1] }
openDocumentsService.openDocument(first).subscribe()
openDocumentsService.openDocument(second).subscribe()
openDocumentsService.refreshDocument(first.id)
openDocumentsService.refreshDocument(second.id)
const firstRequest = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/${first.id}/?full_perms=true`
)
const secondRequest = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/${second.id}/?full_perms=true`
)
openDocumentsService.closeDocument(second).subscribe()
firstRequest.flush({}, { status: 404, statusText: 'Not Found' })
expect(openDocumentsService.getOpenDocuments()).toHaveLength(0) expect(openDocumentsService.getOpenDocuments()).toHaveLength(0)
openDocumentsService.openDocument(first).subscribe()
secondRequest.flush({}, { status: 404, statusText: 'Not Found' })
expect(openDocumentsService.getOpenDocuments()).toEqual([first])
}) })
it('should log error on sessionStorage save', () => { it('should log error on sessionStorage save', () => {
@@ -1,3 +1,4 @@
import { HttpErrorResponse } from '@angular/common/http'
import { Injectable, inject, signal } from '@angular/core' import { Injectable, inject, signal } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { Observable, Subject, of } from 'rxjs' import { Observable, Subject, of } from 'rxjs'
@@ -61,7 +62,12 @@ export class OpenDocumentsService {
Object.assign(openDoc, doc, unsavedEdits) Object.assign(openDoc, doc, unsavedEdits)
this.save() this.save()
}, },
error: () => { error: (error: HttpErrorResponse) => {
if (error.status !== 404) return
const index = this.openDocuments.findIndex((doc) => doc.id === id)
if (index === -1 || this.isDirty(this.openDocuments[index])) return
this.openDocuments.splice(index, 1) this.openDocuments.splice(index, 1)
this.save() this.save()
}, },
@@ -283,10 +283,6 @@ describe('PermissionsService', () => {
'view_workflow', 'view_workflow',
'change_workflow', 'change_workflow',
'delete_workflow', 'delete_workflow',
'add_ocrtemplate',
'view_ocrtemplate',
'change_ocrtemplate',
'delete_ocrtemplate',
'add_customfield', 'add_customfield',
'view_customfield', 'view_customfield',
'change_customfield', 'change_customfield',
@@ -29,7 +29,6 @@ export enum PermissionType {
ShareLinkBundle = '%s_sharelinkbundle', ShareLinkBundle = '%s_sharelinkbundle',
CustomField = '%s_customfield', CustomField = '%s_customfield',
Workflow = '%s_workflow', Workflow = '%s_workflow',
OcrTemplate = '%s_ocrtemplate',
ProcessedMail = '%s_processedmail', ProcessedMail = '%s_processedmail',
GlobalStatistics = '%s_global_statistics', GlobalStatistics = '%s_global_statistics',
SystemMonitoring = '%s_system_monitoring', SystemMonitoring = '%s_system_monitoring',
@@ -290,6 +290,7 @@ describe(`DocumentService`, () => {
expect(req.request.method).toEqual('POST') expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: ids, documents: ids,
from_webui: true,
}) })
}) })
@@ -303,6 +304,7 @@ describe(`DocumentService`, () => {
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: ids, documents: ids,
remote_ocr: false, remote_ocr: false,
from_webui: true,
}) })
}) })
@@ -317,6 +319,7 @@ describe(`DocumentService`, () => {
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: ids, documents: ids,
remote_ocr: true, remote_ocr: true,
from_webui: true,
}) })
}) })
@@ -331,6 +334,7 @@ describe(`DocumentService`, () => {
documents: ids, documents: ids,
degrees: 90, degrees: 90,
source_mode: 'latest_version', source_mode: 'latest_version',
from_webui: true,
}) })
}) })
@@ -346,6 +350,7 @@ describe(`DocumentService`, () => {
documents: ids, documents: ids,
metadata_document_id: 1, metadata_document_id: 1,
delete_originals: true, delete_originals: true,
from_webui: true,
}) })
}) })
@@ -359,6 +364,7 @@ describe(`DocumentService`, () => {
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: ids, documents: ids,
root_document_id: 2, root_document_id: 2,
from_webui: true,
}) })
}) })
@@ -374,6 +380,7 @@ describe(`DocumentService`, () => {
documents: ids, documents: ids,
root_document_id: 2, root_document_id: 2,
version_label: 'Imported', version_label: 'Imported',
from_webui: true,
}) })
}) })
@@ -388,6 +395,7 @@ describe(`DocumentService`, () => {
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: ids, documents: ids,
operations: [{ page: 1, rotate: 90, doc: 0 }], operations: [{ page: 1, rotate: 90, doc: 0 }],
from_webui: true,
}) })
}) })
@@ -403,6 +411,7 @@ describe(`DocumentService`, () => {
documents: ids, documents: ids,
password: 'secret', password: 'secret',
update_document: true, update_document: true,
from_webui: true,
}) })
}) })
@@ -565,20 +574,6 @@ describe(`DocumentService`, () => {
req.flush('task-id') req.flush('task-id')
}) })
it('should call appropriate api endpoint for running zone OCR', () => {
const url = `${environment.apiBaseUrl}${endpoint}/${documents[0].id}/run-zone-ocr/`
subscription = service.runZoneOcr(documents[0].id).subscribe()
let req = httpTestingController.expectOne(url)
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({})
req.flush({ status: 'completed', results: [] })
subscription = service.runZoneOcr(documents[0].id, 3, 10).subscribe()
req = httpTestingController.expectOne(`${url}?version=10`)
expect(req.request.body).toEqual({ template: 3 })
req.flush({ status: 'completed', results: [] })
})
}) })
it('should construct sort fields respecting permissions', () => { it('should construct sort fields respecting permissions', () => {
@@ -12,7 +12,6 @@ import {
import { DocumentMetadata } from 'src/app/data/document-metadata' import { DocumentMetadata } from 'src/app/data/document-metadata'
import { DocumentSuggestions } from 'src/app/data/document-suggestions' import { DocumentSuggestions } from 'src/app/data/document-suggestions'
import { FilterRule } from 'src/app/data/filter-rule' import { FilterRule } from 'src/app/data/filter-rule'
import { OcrZoneRunResponse } from 'src/app/data/ocr-template'
import { Results, SelectionData } from 'src/app/data/results' import { Results, SelectionData } from 'src/app/data/results'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { BulkOperationResponse } from '../../data/bulk-operation' import { BulkOperationResponse } from '../../data/bulk-operation'
@@ -352,40 +351,23 @@ export class DocumentService extends AbstractPaperlessService<Document> {
} }
deleteDocuments(selection: DocumentSelectionQuery) { deleteDocuments(selection: DocumentSelectionQuery) {
return this.http.post(this.getResourceUrl(null, 'delete'), { return this.http.post<BulkOperationResponse>(
...selection, this.getResourceUrl(null, 'delete'),
}) { ...selection, from_webui: true }
)
} }
reprocessDocuments( reprocessDocuments(
selection: DocumentSelectionQuery, selection: DocumentSelectionQuery,
remoteOcr: boolean = false remoteOcr: boolean = false
) { ) {
return this.http.post(this.getResourceUrl(null, 'reprocess'), { return this.http.post<BulkOperationResponse>(
this.getResourceUrl(null, 'reprocess'),
{
...selection, ...selection,
remote_ocr: remoteOcr, remote_ocr: remoteOcr,
}) from_webui: true,
} }
runZoneOcrDocuments(
selection: DocumentSelectionQuery,
templateId: number | null = null
) {
return this.http.post(this.getResourceUrl(null, 'run_zone_ocr'), {
...selection,
...(templateId ? { template: templateId } : {}),
})
}
runZoneOcr(
id: number,
templateId: number | null = null,
versionId: number | null = null
): Observable<OcrZoneRunResponse> {
return this.http.post<OcrZoneRunResponse>(
this.getResourceUrl(id, 'run-zone-ocr'),
templateId ? { template: templateId } : {},
{ params: versionId ? { version: versionId.toString() } : {} }
) )
} }
@@ -394,18 +376,26 @@ export class DocumentService extends AbstractPaperlessService<Document> {
degrees: number, degrees: number,
sourceMode: BulkEditSourceMode = BulkEditSourceMode.LATEST_VERSION sourceMode: BulkEditSourceMode = BulkEditSourceMode.LATEST_VERSION
) { ) {
return this.http.post(this.getResourceUrl(null, 'rotate'), { return this.http.post<BulkOperationResponse>(
this.getResourceUrl(null, 'rotate'),
{
...selection, ...selection,
degrees, degrees,
source_mode: sourceMode, source_mode: sourceMode,
}) from_webui: true,
}
)
} }
mergeDocuments(ids: number[], request: MergeDocumentsRequest = {}) { mergeDocuments(ids: number[], request: MergeDocumentsRequest = {}) {
return this.http.post(this.getResourceUrl(null, 'merge'), { return this.http.post<BulkOperationResponse>(
this.getResourceUrl(null, 'merge'),
{
documents: ids, documents: ids,
...request, ...request,
}) from_webui: true,
}
)
} }
mergeDocumentsAsVersions( mergeDocumentsAsVersions(
@@ -413,28 +403,40 @@ export class DocumentService extends AbstractPaperlessService<Document> {
rootDocumentId: number, rootDocumentId: number,
versionLabel?: string versionLabel?: string
) { ) {
return this.http.post(this.getResourceUrl(null, 'merge_as_versions'), { return this.http.post<BulkOperationResponse>(
this.getResourceUrl(null, 'merge_as_versions'),
{
documents: ids, documents: ids,
root_document_id: rootDocumentId, root_document_id: rootDocumentId,
...(versionLabel ? { version_label: versionLabel } : {}), ...(versionLabel ? { version_label: versionLabel } : {}),
}) from_webui: true,
}
)
} }
editPdfDocuments(ids: number[], request: EditPdfDocumentsRequest) { editPdfDocuments(ids: number[], request: EditPdfDocumentsRequest) {
return this.http.post(this.getResourceUrl(null, 'edit_pdf'), { return this.http.post<BulkOperationResponse>(
this.getResourceUrl(null, 'edit_pdf'),
{
documents: ids, documents: ids,
...request, ...request,
}) from_webui: true,
}
)
} }
removePasswordDocuments( removePasswordDocuments(
ids: number[], ids: number[],
request: RemovePasswordDocumentsRequest request: RemovePasswordDocumentsRequest
) { ) {
return this.http.post(this.getResourceUrl(null, 'remove_password'), { return this.http.post<BulkOperationResponse>(
this.getResourceUrl(null, 'remove_password'),
{
documents: ids, documents: ids,
...request, ...request,
}) from_webui: true,
}
)
} }
getSelectionData( getSelectionData(
@@ -1,78 +0,0 @@
import { provideHttpClient } from '@angular/common/http'
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing'
import { OcrZoneTransform, ZoneTestRequest } from 'src/app/data/ocr-template'
import { environment } from 'src/environments/environment'
import { OcrTemplateService } from './ocr-template.service'
describe('OcrTemplateService', () => {
let service: OcrTemplateService
let http: HttpTestingController
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
})
service = TestBed.inject(OcrTemplateService)
http = TestBed.inject(HttpTestingController)
})
afterEach(() => http.verify())
it('builds page image URLs with zero-based page numbers', () => {
expect(service.getPageImageUrl(12, 0)).toBe(
`${environment.apiBaseUrl}ocr_templates/document-page-image/12/0/`
)
expect(service.getPageImageUrl(12, 2)).toBe(
`${environment.apiBaseUrl}ocr_templates/document-page-image/12/2/`
)
})
it('posts a zone test and returns the extraction result', () => {
const zone: ZoneTestRequest = {
name: 'Total',
x: 10,
y: 20,
width: 100,
height: 50,
page: -1,
ocr_language: 'eng',
transform: OcrZoneTransform.Numeric,
validation_regex: '\\d+',
zone_source_width: 1000,
zone_source_height: 1500,
}
const result = {
raw_text: 'Total: 123',
value: '123',
regex: '\\d+',
regex_match: true,
}
const next = jest.fn()
service.testZone(12, zone).subscribe(next)
const request = http.expectOne(
`${environment.apiBaseUrl}ocr_templates/test-zone/`
)
expect(request.request.method).toBe('POST')
expect(request.request.body).toEqual({ document: 12, zone })
request.flush(result)
expect(next).toHaveBeenCalledWith(result)
})
it('passes zone-test failures to the caller', () => {
const error = jest.fn()
service.testZone(12, {} as ZoneTestRequest).subscribe({ error })
http
.expectOne(`${environment.apiBaseUrl}ocr_templates/test-zone/`)
.flush(
{ error: 'Invalid zone' },
{ status: 400, statusText: 'Bad Request' }
)
expect(error).toHaveBeenCalledWith(
expect.objectContaining({ status: 400, error: { error: 'Invalid zone' } })
)
})
})
@@ -1,30 +0,0 @@
import { Injectable } from '@angular/core'
import { Observable } from 'rxjs'
import {
OcrTemplate,
OcrZoneTestResult,
ZoneTestRequest,
} from '../../data/ocr-template'
import { AbstractPaperlessService } from './abstract-paperless-service'
@Injectable({ providedIn: 'root' })
export class OcrTemplateService extends AbstractPaperlessService<OcrTemplate> {
constructor() {
super()
this.resourceName = 'ocr_templates'
}
getPageImageUrl(docId: number, page: number): string {
return `${this.baseUrl}${this.resourceName}/document-page-image/${docId}/${page}/`
}
testZone(
docId: number,
zone: ZoneTestRequest
): Observable<OcrZoneTestResult> {
return this.http.post<OcrZoneTestResult>(
`${this.baseUrl}${this.resourceName}/test-zone/`,
{ document: docId, zone }
)
}
}
@@ -0,0 +1,186 @@
import { TestBed } from '@angular/core/testing'
import { Router } from '@angular/router'
import { BulkOperationStatus } from '../data/bulk-operation'
import { PaperlessTaskStatus } from '../data/paperless-task'
import { PermissionsService } from './permissions.service'
import { TaskNotificationService } from './task-notification.service'
import { Toast, ToastLevel, ToastService } from './toast.service'
describe('TaskNotificationService', () => {
let service: TaskNotificationService
let toastService: ToastService
let popups: Toast[]
let notifications: Toast[]
const navigate = jest.fn()
const currentUserCan = jest.fn()
const result = (
status = PaperlessTaskStatus.Success
): BulkOperationStatus => ({
task_id: 'task-1',
operation: 'run_workflow',
status,
})
beforeEach(() => {
jest.useFakeTimers()
currentUserCan.mockReturnValue(true)
TestBed.configureTestingModule({
providers: [
{ provide: Router, useValue: { navigate } },
{ provide: PermissionsService, useValue: { currentUserCan } },
],
})
service = TestBed.inject(TaskNotificationService)
toastService = TestBed.inject(ToastService)
popups = []
notifications = []
toastService.showToast.subscribe((toast) => popups.push(toast))
toastService
.getToasts()
.subscribe((toasts) => (notifications = [...toasts]))
})
afterEach(() => {
jest.clearAllTimers()
jest.useRealTimers()
})
it('replaces a queued notification with its result, removing the task link on success', () => {
service.showQueued('task-1', 'Workflow queued.', {
content: 'Workflow completed.',
})
jest.advanceTimersByTime(399)
expect(popups).toHaveLength(0)
jest.advanceTimersByTime(1)
expect(popups[0].content).toBe('Workflow queued.')
expect(popups[0].actionName).toBe('View task')
popups[0].action()
expect(navigate).toHaveBeenCalledWith(['/tasks'], {
queryParams: { task_id: 'task-1' },
})
service.complete(result())
expect(notifications).toHaveLength(1)
expect(popups[1]).toEqual(
expect.objectContaining({
id: popups[0].id,
content: 'Workflow completed.',
level: ToastLevel.Success,
})
)
expect(popups[1].action).toBeUndefined()
expect(popups[1].actionName).toBeUndefined()
})
it('shows only the result when a task completes during the grace period', () => {
service.showQueued('task-1', 'Queued')
jest.advanceTimersByTime(100)
service.complete(result())
jest.advanceTimersByTime(1000)
expect(popups.map((toast) => toast.content)).toEqual([
"Operation 'Run workflow' completed.",
])
})
it.each([
'reprocess',
'rotate',
'merge',
'split',
'delete_pages',
'edit_pdf',
'remove_password',
])('reports %s as submitted rather than completed', (operation) => {
service.showQueued('task-1', 'Queued')
service.complete({ ...result(), operation })
expect(notifications[0].content).toContain('submitted for processing.')
expect(notifications[0].content).not.toContain('completed')
expect(notifications[0].action).toBeUndefined()
})
it('does not show a stale queued state when completion beats the HTTP response', () => {
service.complete(result())
service.showQueued('task-1', 'Workflow queued.', {
content: 'Workflow completed.',
})
jest.advanceTimersByTime(1000)
expect(popups.every((toast) => toast.level === ToastLevel.Success)).toBe(
true
)
expect(notifications).toHaveLength(1)
expect(notifications[0].content).toBe('Workflow completed.')
expect(notifications[0].action).toBeUndefined()
})
it.each([PaperlessTaskStatus.Failure, PaperlessTaskStatus.Revoked])(
'replaces the queued state with an error for %s',
(status) => {
service.showQueued('task-1', 'Queued', {
content: 'Success',
actionName: 'Undo',
})
jest.advanceTimersByTime(400)
service.complete({ ...result(status), error: 'Task failed' })
expect(notifications).toHaveLength(1)
expect(notifications[0]).toEqual(
expect.objectContaining({
level: ToastLevel.Error,
error: 'Task failed',
actionName: 'View task',
})
)
}
)
it('ignores nonterminal and duplicate outcomes', () => {
service.showQueued('task-1', 'Queued')
service.complete(result(PaperlessTaskStatus.Started))
expect(popups).toHaveLength(0)
service.complete(result())
service.complete(result())
expect(popups).toHaveLength(1)
})
it('keeps concurrent task notifications separate', () => {
service.showQueued('task-1', 'First queued')
service.showQueued('task-2', 'Second queued')
jest.advanceTimersByTime(400)
service.complete(result())
expect(notifications).toHaveLength(2)
expect(notifications.map((toast) => toast.content)).toEqual([
'Second queued',
"Operation 'Run workflow' completed.",
])
})
it('omits task links without task viewing permission', () => {
currentUserCan.mockReturnValue(false)
service.showQueued('task-1', 'Queued')
jest.advanceTimersByTime(400)
service.complete(result())
expect(popups.every((toast) => !toast.action && !toast.actionName)).toBe(
true
)
})
it('identifies concurrent operations even when completion arrives before their queue responses', () => {
service.complete({
task_id: 'tags',
operation: 'modify_tags',
status: PaperlessTaskStatus.Success,
})
service.complete({
task_id: 'correspondent',
operation: 'set_correspondent',
status: PaperlessTaskStatus.Success,
})
expect(notifications.map((toast) => toast.content)).toEqual([
"Operation 'Set correspondent' completed.",
"Operation 'Update tags' completed.",
])
})
it('keeps the generic fallback for unknown operation names', () => {
service.complete({ ...result(), operation: 'future_operation' })
expect(notifications[0].content).toBe('Operation completed.')
})
})
@@ -0,0 +1,128 @@
import { inject, Injectable, OnDestroy } from '@angular/core'
import { Router } from '@angular/router'
import { BulkOperationStatus } from '../data/bulk-operation'
import { PaperlessTaskStatus } from '../data/paperless-task'
import {
documentOperationHasFollowUpTasks,
getDocumentOperationLabel,
} from '../utils/document-operation'
import {
PermissionAction,
PermissionsService,
PermissionType,
} from './permissions.service'
import { Toast, ToastLevel, ToastService } from './toast.service'
interface PendingNotification {
success: Partial<Toast>
timer: ReturnType<typeof setTimeout>
}
@Injectable({ providedIn: 'root' })
export class TaskNotificationService implements OnDestroy {
private readonly toastService = inject(ToastService)
private readonly router = inject(Router)
private readonly permissionsService = inject(PermissionsService)
private readonly pending = new Map<string, PendingNotification>()
private readonly completed = new Map<string, BulkOperationStatus>()
ngOnDestroy() {
this.pending.forEach(({ timer }) => clearTimeout(timer))
}
showQueued(taskId: string, content: string, success: Partial<Toast> = {}) {
const completed = this.completed.get(taskId)
if (completed) {
this.showResult(completed, success)
return
}
if (this.pending.has(taskId)) return
this.pending.set(taskId, {
success,
// short delay in case the task finishes super fast
timer: setTimeout(() => this.show(taskId, { content, delay: 5000 }), 400),
})
}
complete(result: BulkOperationStatus) {
if (
![
PaperlessTaskStatus.Success,
PaperlessTaskStatus.Failure,
PaperlessTaskStatus.Revoked,
].includes(result.status) ||
this.completed.has(result.task_id)
)
return
this.completed.set(result.task_id, result)
// Retain recent outcomes in case task finishes quickly
if (this.completed.size > 100) {
this.completed.delete(this.completed.keys().next().value)
}
const pending = this.pending.get(result.task_id)
if (pending) {
clearTimeout(pending.timer)
this.pending.delete(result.task_id)
}
// Trash needs the caller's document IDs to offer Undo on success.
if (
pending ||
result.operation !== 'delete' ||
result.status !== PaperlessTaskStatus.Success
) {
this.showResult(result, pending?.success)
}
}
private showResult(
result: BulkOperationStatus,
success: Partial<Toast> = {}
) {
const succeeded = result.status === PaperlessTaskStatus.Success
const operation = getDocumentOperationLabel(result.operation)
let content: string
if (succeeded) {
let operationText = $localize`Operation`
if (operation) operationText += ` '${operation}'`
const resultText = documentOperationHasFollowUpTasks(result.operation)
? $localize`submitted for processing`
: $localize`completed`
content = `${operationText} ${resultText}.`
} else if (result.status === PaperlessTaskStatus.Revoked) {
content = operation
? $localize`Operation '${operation}' revoked.`
: $localize`Operation revoked.`
} else {
content = operation
? $localize`Operation '${operation}' failed. Some changes may already have been applied.`
: $localize`Operation failed. Some changes may already have been applied.`
}
this.show(result.task_id, {
content,
delay: succeeded ? 5000 : 10000,
level: succeeded ? ToastLevel.Success : ToastLevel.Error,
...(succeeded ? success : { error: result.error }),
})
}
private show(taskId: string, toast: Toast) {
const canViewTasks = this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.PaperlessTask
)
this.toastService.show({
id: `task-${taskId}`,
...(canViewTasks && toast.level !== ToastLevel.Success
? {
actionName: $localize`View task`,
action: () =>
this.router.navigate(['/tasks'], {
queryParams: { task_id: taskId },
}),
}
: {}),
...toast,
})
}
}
@@ -38,6 +38,29 @@ describe('ToastService', () => {
}) })
}) })
it('updates a notification by ID without duplicating or moving it', () => {
let notifications: Toast[] = []
toastService
.getToasts()
.subscribe((toasts) => (notifications = [...toasts]))
toastService.show({ id: 'task-1', content: 'Queued', delay: 5000 })
toastService.show({
id: 'other',
content: 'Another notification',
delay: 5000,
})
toastService.show({
id: 'task-1',
content: 'Completed',
delay: 5000,
level: ToastLevel.Success,
})
expect(notifications.map((toast) => toast.content)).toEqual([
'Another notification',
'Completed',
])
})
it('parses error string to object on show', () => { it('parses error string to object on show', () => {
const toast = { const toast = {
title: 'Title', title: 'Title',
+7
View File
@@ -75,7 +75,14 @@ export class ToastService {
if (persistent) { if (persistent) {
// the dropdown is open, so it's seen as it arrives // the dropdown is open, so it's seen as it arrives
toast.read = !!this._suppressPopupToasts toast.read = !!this._suppressPopupToasts
const index = this.toasts.findIndex(
(existing) => existing.id === toast.id
)
if (index === -1) {
this.toasts.unshift(toast) this.toasts.unshift(toast)
} else {
this.toasts[index] = toast
}
this.toastsSubject.next(this.toasts) this.toastsSubject.next(this.toasts)
} }
// Popups are suppressed while the dropdown is open // Popups are suppressed while the dropdown is open
+51 -1
View File
@@ -5,8 +5,10 @@ import {
HttpTestingController, HttpTestingController,
} from '@angular/common/http/testing' } from '@angular/common/http/testing'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { PaperlessTaskStatus } from '../data/paperless-task'
import { DocumentListViewService } from './document-list-view.service' import { DocumentListViewService } from './document-list-view.service'
import { Toast, ToastService } from './toast.service' import { TaskNotificationService } from './task-notification.service'
import { Toast, ToastLevel, ToastService } from './toast.service'
import { TrashService } from './trash.service' import { TrashService } from './trash.service'
describe('TrashService', () => { describe('TrashService', () => {
@@ -105,4 +107,52 @@ describe('TrashService', () => {
expect(toast.content).toEqual('Moved 500 document(s) to the trash') expect(toast.content).toEqual('Moved 500 document(s) to the trash')
expect(toast.action).toBeUndefined() expect(toast.action).toBeUndefined()
}) })
it('offers Undo only after the matching delete task succeeds', () => {
let toasts: Toast[] = []
toastService.getToasts().subscribe((t) => (toasts = [...t]))
service.trackMovedToTrash('delete-1', 2, [1, 2])
expect(toasts).toHaveLength(0)
const notifications = TestBed.inject(TaskNotificationService)
notifications.complete({
task_id: 'other-delete',
operation: 'delete',
status: PaperlessTaskStatus.Success,
})
expect(toasts).toHaveLength(0)
notifications.complete({
task_id: 'delete-1',
operation: 'delete',
status: PaperlessTaskStatus.Success,
})
expect(toasts).toHaveLength(1)
expect(toasts[0].actionName).toBe('Undo')
toasts[0].action()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}trash/`
)
expect(req.request.body).toEqual({ action: 'restore', documents: [1, 2] })
})
it('handles completion before the queue response and does not offer Undo on failure', () => {
let toasts: Toast[] = []
toastService.getToasts().subscribe((t) => (toasts = [...t]))
const notifications = TestBed.inject(TaskNotificationService)
notifications.complete({
task_id: 'delete-1',
operation: 'delete',
status: PaperlessTaskStatus.Success,
})
service.trackMovedToTrash('delete-1', 1, [1])
expect(toasts[0].actionName).toBe('Undo')
service.trackMovedToTrash('delete-2', 1, [2])
notifications.complete({
task_id: 'delete-2',
operation: 'delete',
status: PaperlessTaskStatus.Failure,
})
expect(toasts).toHaveLength(2)
expect(toasts[0].level).toBe(ToastLevel.Error)
expect(toasts[0].actionName).not.toBe('Undo')
})
}) })
+32 -5
View File
@@ -5,7 +5,8 @@ import { environment } from 'src/environments/environment'
import { Document } from '../data/document' import { Document } from '../data/document'
import { Results } from '../data/results' import { Results } from '../data/results'
import { DocumentListViewService } from './document-list-view.service' import { DocumentListViewService } from './document-list-view.service'
import { ToastLevel, ToastService } from './toast.service' import { TaskNotificationService } from './task-notification.service'
import { Toast, ToastLevel, ToastService } from './toast.service'
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@@ -14,6 +15,24 @@ export class TrashService {
private http = inject(HttpClient) private http = inject(HttpClient)
private toastService = inject(ToastService) private toastService = inject(ToastService)
private documentListViewService = inject(DocumentListViewService) private documentListViewService = inject(DocumentListViewService)
private taskNotifications = inject(TaskNotificationService)
public trackMovedToTrash(
taskId: string,
count: number,
documents?: number[]
) {
this.taskNotifications.showQueued(
taskId,
count > 1
? $localize`Bulk move to trash queued.`
: $localize`Move to trash queued.`,
this.movedToTrashToast(
$localize`Moved ${count} document(s) to the trash`,
documents
)
)
}
public getTrash(page: number = 1): Observable<Results<Document>> { public getTrash(page: number = 1): Observable<Results<Document>> {
const httpParams = new HttpParams().set('page', page.toString()) const httpParams = new HttpParams().set('page', page.toString())
@@ -43,14 +62,22 @@ export class TrashService {
* Shows a toast with an Undo action when possible * Shows a toast with an Undo action when possible
*/ */
public notifyMovedToTrash(content: string, documents?: number[]) { public notifyMovedToTrash(content: string, documents?: number[]) {
this.toastService.show(this.movedToTrashToast(content, documents))
}
private movedToTrashToast(content: string, documents?: number[]): Toast {
const canUndo = documents?.length > 0 const canUndo = documents?.length > 0
this.toastService.show({ return {
content, content,
delay: 10000, delay: 10000,
level: ToastLevel.Success, level: ToastLevel.Success,
actionName: canUndo ? $localize`Undo` : undefined, ...(canUndo
action: canUndo ? () => this.undoMoveToTrash(documents) : undefined, ? {
}) actionName: $localize`Undo`,
action: () => this.undoMoveToTrash(documents),
}
: {}),
}
} }
private undoMoveToTrash(documents: number[]) { private undoMoveToTrash(documents: number[]) {
@@ -0,0 +1,11 @@
import { getDocumentOperationLabel } from './document-operation'
describe('Document Operation Utils', () => {
it('should retrieve the document operation label correctly for an existing operation, null otherwise', () => {
const result = getDocumentOperationLabel('modify_custom_fields')
expect(result).toBe('Update custom fields')
const resultNull = getDocumentOperationLabel('non_existing_operation')
expect(resultNull).toBeNull()
})
})
@@ -0,0 +1,38 @@
const OPERATION_LABELS: Record<string, string> = {
set_correspondent: $localize`Set correspondent`,
set_document_type: $localize`Set document type`,
set_storage_path: $localize`Set storage path`,
add_tag: $localize`Add tag`,
remove_tag: $localize`Remove tag`,
modify_tags: $localize`Update tags`,
modify_custom_fields: $localize`Update custom fields`,
set_permissions: $localize`Set permissions`,
delete: $localize`Move to trash`,
reprocess: $localize`Document reprocessing`,
run_workflow: $localize`Run workflow`,
rotate: $localize`Rotate documents`,
merge: $localize`Merge documents`,
merge_as_versions: $localize`Merge as versions`,
split: $localize`Split documents`,
delete_pages: $localize`Delete pages`,
edit_pdf: $localize`Edit PDF`,
remove_password: $localize`Remove password`,
}
export function getDocumentOperationLabel(operation?: string): string | null {
const label = operation ? OPERATION_LABELS[operation] : null
return label ?? null
}
// These operations queue processing in separate tasks
export function documentOperationHasFollowUpTasks(operation?: string): boolean {
return [
'reprocess',
'rotate',
'merge',
'split',
'delete_pages',
'edit_pdf',
'remove_password',
].includes(operation ?? '')
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
Loaded 100 of 192 files, more files were not shown because too many files have changed in this diff. Show more