mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-11-15 10:26:42 +00:00
Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3508877a33 | ||
|
|
fea012d5c0 | ||
|
|
e2aa45da4e | ||
|
|
5ad0773446 | ||
|
|
13202f52ad | ||
|
|
9a9d4284d7 | ||
|
|
6043dd1c81 | ||
|
|
058e27e3ef | ||
|
|
6f5a192967 | ||
|
|
bb775a16c1 | ||
|
|
6d26d3f5ca | ||
|
|
2131feac73 | ||
|
|
e2705768b4 | ||
|
|
ce3c4e0855 | ||
|
|
3a241e51d9 | ||
|
|
020a2c8fc0 | ||
|
|
b533bfe4b5 | ||
|
|
813dfe487f | ||
|
|
557afa6454 | ||
|
|
c72473f7bc | ||
|
|
7d79b544ae | ||
|
|
a9fef2b931 | ||
|
|
adbd7561d4 | ||
|
|
9f694cdd2d | ||
|
|
7d78716069 | ||
|
|
9523d158a8 | ||
|
|
91316df9b7 | ||
|
|
7e67013c42 | ||
|
|
2a0c1249fa | ||
|
|
4610171a94 | ||
|
|
cf2e2bde71 | ||
|
|
aa669aca89 | ||
|
|
b42bf50b7e | ||
|
|
d31110278b | ||
|
|
5d98a5e5c8 | ||
|
|
fc8c4e91ec | ||
|
|
e746e008a0 | ||
|
|
897f6a9e5a | ||
|
|
694619c33e | ||
|
|
1f2faaec86 | ||
|
|
6b15d625ed |
No files matched your search
+8
-2
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
+4
-3
@@ -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.
|
||||||
|
|||||||
+149
-88
@@ -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'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's trigger filters and run on every selected document.</source>
|
<source>Ignore the workflow'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 "<x id="PH" equiv-text="this.document().title"/>" in the background.</source>
|
<source>Workflow will run on "<x id="PH" equiv-text="this.document().title"/>" 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 "<x id="PH" equiv-text="this.document().title"/>" will begin in the background.</source>
|
<source>PDF edit operation for "<x id="PH" equiv-text="this.document().title"/>" 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 "<x id="PH" equiv-text="this.document().title"/>" will begin in the background.</source>
|
<source>Password removal operation for "<x id="PH" equiv-text="this.document().title"/>" 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>"<x id="PH" equiv-text="items[0].name"/>"</source>
|
<source>"<x id="PH" equiv-text="items[0].name"/>"</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>"<x id="PH" equiv-text="items[0].name"/>" and "<x id="PH_1" equiv-text="items[1].name"/>"</source>
|
<source>"<x id="PH" equiv-text="items[0].name"/>" and "<x id="PH_1" equiv-text="items[1].name"/>"</source>
|
||||||
<context-group purpose="location">
|
<context-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 'modify "tag1" and "tag2"'</note>
|
<note priority="1" from="description">This is for messages like 'modify "tag1" and "tag2"'</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
@@ -9761,7 +9794,7 @@
|
|||||||
<source><x id="PH" equiv-text="list"/> and "<x id="PH_1" equiv-text="items[items.length - 1].name"/>"</source>
|
<source><x id="PH" equiv-text="list"/> and "<x id="PH_1" equiv-text="items[items.length - 1].name"/>"</source>
|
||||||
<context-group purpose="location">
|
<context-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 'modify "tag1", "tag2" and "tag3"'</note>
|
<note priority="1" from="description">this is for messages like 'modify "tag1", "tag2" and "tag3"'</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 "<x id="PH" equiv-text="tag.name"/>" to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
<source>This operation will add the tag "<x id="PH" equiv-text="tag.name"/>" 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 "<x id="PH" equiv-text="tag.name"/>" from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
<source>This operation will remove the tag "<x id="PH" equiv-text="tag.name"/>" 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 "<x id="PH" equiv-text="correspondent.name"/>" to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
<source>This operation will assign the correspondent "<x id="PH" equiv-text="correspondent.name"/>" 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 "<x id="PH" equiv-text="documentType.name"/>" to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
<source>This operation will assign the document type "<x id="PH" equiv-text="documentType.name"/>" 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 "<x id="PH" equiv-text="storagePath.name"/>" to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
<source>This operation will assign the storage path "<x id="PH" equiv-text="storagePath.name"/>" 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 "<x id="PH" equiv-text="customField.name"/>" to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
<source>This operation will assign the custom field "<x id="PH" equiv-text="customField.name"/>" 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 "<x id="PH" equiv-text="customField.name"/>" from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
<source>This operation will remove the custom field "<x id="PH" equiv-text="customField.name"/>" 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">
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+7
-2
@@ -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" />
|
||||||
<label class="form-check-label" for="runWorkflowForce" i18n>Run on all selected documents</label>
|
@if (singleDocument) {
|
||||||
<div class="form-text" i18n>Ignore the workflow's trigger filters and run on every selected document.</div>
|
<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>
|
||||||
|
<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">
|
||||||
|
|||||||
+21
@@ -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'
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
+2
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -1983,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()
|
||||||
@@ -2005,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()
|
||||||
@@ -2017,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'
|
||||||
@@ -2044,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', () => {
|
||||||
@@ -2097,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', () => {
|
||||||
|
|||||||
@@ -97,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'
|
||||||
@@ -226,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)
|
||||||
@@ -1558,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()
|
||||||
@@ -1587,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
|
||||||
@@ -1595,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()
|
||||||
},
|
},
|
||||||
@@ -1987,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) {
|
||||||
@@ -2044,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)
|
||||||
|
|||||||
+23
-14
@@ -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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
+7
-9
@@ -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) => {
|
||||||
|
|||||||
+104
-30
@@ -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`
|
||||||
@@ -1357,6 +1388,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
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)
|
||||||
@@ -1377,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`
|
||||||
@@ -1419,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`
|
||||||
@@ -1437,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`
|
||||||
@@ -1458,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`
|
||||||
@@ -1474,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)
|
||||||
@@ -1501,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()
|
||||||
@@ -1518,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', () => {
|
||||||
@@ -1611,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) => {
|
||||||
@@ -1634,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()
|
||||||
@@ -1649,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()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1729,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 = {
|
||||||
@@ -1772,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 = {
|
||||||
@@ -1821,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 = {
|
||||||
@@ -1867,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 = {
|
||||||
@@ -1921,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'
|
||||||
@@ -84,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',
|
||||||
@@ -110,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)
|
||||||
@@ -308,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() {
|
||||||
@@ -322,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)
|
||||||
workflow: modal.componentInstance.workflowId,
|
this.documentService
|
||||||
filter_documents: !modal.componentInstance.force,
|
.bulkEdit(this.getSelectionQuery(), 'run_workflow', {
|
||||||
})
|
workflow: modal.componentInstance.workflowId,
|
||||||
|
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),
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,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)
|
||||||
this.toastService.show({
|
const submittedMessage = bulk
|
||||||
content: $localize`Bulk operation queued.`,
|
? $localize`Bulk operation '${label}' submitted for processing.`
|
||||||
delay: 5000,
|
: $localize`Operation '${label}' submitted for processing.`
|
||||||
...(canViewTasks
|
const completedMessage = bulk
|
||||||
? {
|
? $localize`Bulk operation '${label}' completed.`
|
||||||
actionName: $localize`View task`,
|
: $localize`Operation '${label}' completed.`
|
||||||
action: () =>
|
this.taskNotifications.showQueued(
|
||||||
this.router.navigate(['/tasks'], {
|
taskId,
|
||||||
queryParams: { task_id: taskId },
|
bulk
|
||||||
}),
|
? $localize`Bulk operation '${label}' queued.`
|
||||||
}
|
: $localize`Operation '${label}' queued.`,
|
||||||
: {}),
|
{
|
||||||
})
|
content: documentOperationHasFollowUpTasks(operation)
|
||||||
|
? submittedMessage
|
||||||
|
: completedMessage,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
@@ -973,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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1023,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
|
||||||
@@ -1063,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
|
||||||
@@ -1096,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.`
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1122,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.`,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1154,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(
|
||||||
|
|||||||
+1
-1
@@ -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>
|
||||||
|
|||||||
+16
-1
@@ -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')
|
||||||
|
|||||||
+16
-1
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -351,19 +351,24 @@ 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>(
|
||||||
...selection,
|
this.getResourceUrl(null, 'reprocess'),
|
||||||
remote_ocr: remoteOcr,
|
{
|
||||||
})
|
...selection,
|
||||||
|
remote_ocr: remoteOcr,
|
||||||
|
from_webui: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
rotateDocuments(
|
rotateDocuments(
|
||||||
@@ -371,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>(
|
||||||
...selection,
|
this.getResourceUrl(null, 'rotate'),
|
||||||
degrees,
|
{
|
||||||
source_mode: sourceMode,
|
...selection,
|
||||||
})
|
degrees,
|
||||||
|
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>(
|
||||||
documents: ids,
|
this.getResourceUrl(null, 'merge'),
|
||||||
...request,
|
{
|
||||||
})
|
documents: ids,
|
||||||
|
...request,
|
||||||
|
from_webui: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
mergeDocumentsAsVersions(
|
mergeDocumentsAsVersions(
|
||||||
@@ -390,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>(
|
||||||
documents: ids,
|
this.getResourceUrl(null, 'merge_as_versions'),
|
||||||
root_document_id: rootDocumentId,
|
{
|
||||||
...(versionLabel ? { version_label: versionLabel } : {}),
|
documents: ids,
|
||||||
})
|
root_document_id: rootDocumentId,
|
||||||
|
...(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>(
|
||||||
documents: ids,
|
this.getResourceUrl(null, 'edit_pdf'),
|
||||||
...request,
|
{
|
||||||
})
|
documents: ids,
|
||||||
|
...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>(
|
||||||
documents: ids,
|
this.getResourceUrl(null, 'remove_password'),
|
||||||
...request,
|
{
|
||||||
})
|
documents: ids,
|
||||||
|
...request,
|
||||||
|
from_webui: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
getSelectionData(
|
getSelectionData(
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
this.toasts.unshift(toast)
|
const index = this.toasts.findIndex(
|
||||||
|
(existing) => existing.id === toast.id
|
||||||
|
)
|
||||||
|
if (index === -1) {
|
||||||
|
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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -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
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
@@ -18,7 +18,8 @@
|
|||||||
"es2022",
|
"es2022",
|
||||||
"dom"
|
"dom"
|
||||||
],
|
],
|
||||||
"useDefineForClassFields": false
|
"useDefineForClassFields": false,
|
||||||
|
"types": ["jest", "node"]
|
||||||
},
|
},
|
||||||
"angularCompilerOptions": {
|
"angularCompilerOptions": {
|
||||||
"enableI18nLegacyMessageIdFormat": false,
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
|||||||
+131
-47
@@ -39,6 +39,8 @@ from documents.models import Tag
|
|||||||
from documents.permissions import PermissionsSpec
|
from documents.permissions import PermissionsSpec
|
||||||
from documents.permissions import set_permissions_for_objects
|
from documents.permissions import set_permissions_for_objects
|
||||||
from documents.plugins.helpers import DocumentsStatusManager
|
from documents.plugins.helpers import DocumentsStatusManager
|
||||||
|
from documents.task_tracking import queue_consumption
|
||||||
|
from documents.task_tracking import queue_processing_tasks
|
||||||
from documents.tasks import bulk_update_documents
|
from documents.tasks import bulk_update_documents
|
||||||
from documents.tasks import consume_file
|
from documents.tasks import consume_file
|
||||||
from documents.tasks import remove_document_from_index
|
from documents.tasks import remove_document_from_index
|
||||||
@@ -427,11 +429,17 @@ def delete(doc_ids: list[int]) -> Literal["OK"]:
|
|||||||
"Detected a possible incompatible database column. See https://docs.paperless-ngx.com/troubleshooting/#convert-uuid-field",
|
"Detected a possible incompatible database column. See https://docs.paperless-ngx.com/troubleshooting/#convert-uuid-field",
|
||||||
)
|
)
|
||||||
logger.error(f"Error deleting documents: {e!s}")
|
logger.error(f"Error deleting documents: {e!s}")
|
||||||
|
raise
|
||||||
|
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
|
|
||||||
def reprocess(doc_ids: list[int], *, remote_ocr: bool = False) -> Literal["OK"]:
|
def reprocess(
|
||||||
|
doc_ids: list[int],
|
||||||
|
*,
|
||||||
|
remote_ocr: bool = False,
|
||||||
|
parent_task_id: str | None = None,
|
||||||
|
) -> Literal["OK"]:
|
||||||
"""
|
"""
|
||||||
Re-run parsing for the given documents.
|
Re-run parsing for the given documents.
|
||||||
|
|
||||||
@@ -456,6 +464,16 @@ def reprocess(doc_ids: list[int], *, remote_ocr: bool = False) -> Literal["OK"]:
|
|||||||
.values_list("source_id", flat=True)
|
.values_list("source_id", flat=True)
|
||||||
.distinct()
|
.distinct()
|
||||||
)
|
)
|
||||||
|
if parent_task_id is not None:
|
||||||
|
from documents.task_tracking import queue_reprocessing_tasks
|
||||||
|
|
||||||
|
queue_reprocessing_tasks(
|
||||||
|
parent_task_id,
|
||||||
|
list(source_ids),
|
||||||
|
remote_ocr=remote_ocr,
|
||||||
|
)
|
||||||
|
return "OK"
|
||||||
|
|
||||||
for source_id in source_ids:
|
for source_id in source_ids:
|
||||||
update_document_content_maybe_archive_file.apply_async(
|
update_document_content_maybe_archive_file.apply_async(
|
||||||
kwargs={"document_id": source_id, "remote_ocr": remote_ocr},
|
kwargs={"document_id": source_id, "remote_ocr": remote_ocr},
|
||||||
@@ -474,9 +492,9 @@ def run_workflow(
|
|||||||
"""
|
"""
|
||||||
Manually run a single workflow against the given documents.
|
Manually run a single workflow against the given documents.
|
||||||
|
|
||||||
Done asynchronously via a Celery task.
|
Called from the tracked bulk operation task.
|
||||||
"""
|
"""
|
||||||
run_workflows_for_documents.delay(
|
run_workflows_for_documents(
|
||||||
document_ids=doc_ids,
|
document_ids=doc_ids,
|
||||||
workflow_id=workflow,
|
workflow_id=workflow,
|
||||||
filter_documents=filter_documents,
|
filter_documents=filter_documents,
|
||||||
@@ -520,6 +538,7 @@ def rotate(
|
|||||||
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
|
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
|
||||||
user: User | None = None,
|
user: User | None = None,
|
||||||
trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI,
|
trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI,
|
||||||
|
parent_task_id: str | None = None,
|
||||||
) -> Literal["OK"]:
|
) -> Literal["OK"]:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Attempting to rotate {len(doc_ids)} documents by {degrees} degrees.",
|
f"Attempting to rotate {len(doc_ids)} documents by {degrees} degrees.",
|
||||||
@@ -557,22 +576,22 @@ def rotate(
|
|||||||
if user is not None:
|
if user is not None:
|
||||||
overrides.actor_id = user.id
|
overrides.actor_id = user.id
|
||||||
|
|
||||||
consume_file.apply_async(
|
queue_consumption(
|
||||||
kwargs={
|
input_doc=ConsumableDocument(
|
||||||
"input_doc": ConsumableDocument(
|
source=DocumentSource.ConsumeFolder,
|
||||||
source=DocumentSource.ConsumeFolder,
|
original_file=filepath,
|
||||||
original_file=filepath,
|
root_document_id=pair.root_doc.id,
|
||||||
root_document_id=pair.root_doc.id,
|
),
|
||||||
),
|
overrides=overrides,
|
||||||
"overrides": overrides,
|
trigger_source=trigger_source,
|
||||||
},
|
parent_task_id=parent_task_id,
|
||||||
headers={"trigger_source": trigger_source},
|
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Queued new rotated version for document {pair.root_doc.id} by {degrees} degrees",
|
f"Queued new rotated version for document {pair.root_doc.id} by {degrees} degrees",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Error rotating document {pair.root_doc.id}: {e}")
|
logger.exception(f"Error rotating document {pair.root_doc.id}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
@@ -586,13 +605,15 @@ def merge(
|
|||||||
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
|
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
|
||||||
user: User | None = None,
|
user: User | None = None,
|
||||||
trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI,
|
trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI,
|
||||||
) -> Literal["OK"]:
|
parent_task_id: str | None = None,
|
||||||
|
) -> Literal["OK"] | dict:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Attempting to merge {len(doc_ids)} documents into a single document.",
|
f"Attempting to merge {len(doc_ids)} documents into a single document.",
|
||||||
)
|
)
|
||||||
qs = Document.objects.select_related("root_document").filter(id__in=doc_ids)
|
qs = Document.objects.select_related("root_document").filter(id__in=doc_ids)
|
||||||
docs_by_id = {doc.id: doc for doc in qs}
|
docs_by_id = {doc.id: doc for doc in qs}
|
||||||
affected_docs: list[int] = []
|
affected_docs: list[int] = []
|
||||||
|
failed_ids: list[int] = []
|
||||||
handoff_asn: int | None = None
|
handoff_asn: int | None = None
|
||||||
with pdf_ops.PdfMerger() as merger:
|
with pdf_ops.PdfMerger() as merger:
|
||||||
# use doc_ids to preserve order
|
# use doc_ids to preserve order
|
||||||
@@ -618,11 +639,14 @@ def merge(
|
|||||||
if handoff_asn is None and doc.archive_serial_number is not None:
|
if handoff_asn is None and doc.archive_serial_number is not None:
|
||||||
handoff_asn = doc.archive_serial_number
|
handoff_asn = doc.archive_serial_number
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
failed_ids.append(doc.id)
|
||||||
logger.exception(
|
logger.exception(
|
||||||
f"Error merging document {doc.id}, it will not be included in the merge: {e}",
|
f"Error merging document {doc.id}, it will not be included in the merge: {e}",
|
||||||
)
|
)
|
||||||
if len(affected_docs) == 0:
|
if len(affected_docs) == 0:
|
||||||
logger.warning("No documents were merged")
|
logger.warning("No documents were merged")
|
||||||
|
if parent_task_id is not None:
|
||||||
|
raise ValueError("None of the selected documents could be merged.")
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
filepath = (
|
filepath = (
|
||||||
@@ -665,6 +689,23 @@ def merge(
|
|||||||
overrides=overrides,
|
overrides=overrides,
|
||||||
).set(headers={"trigger_source": trigger_source})
|
).set(headers={"trigger_source": trigger_source})
|
||||||
|
|
||||||
|
if parent_task_id is not None:
|
||||||
|
backup = (
|
||||||
|
release_archive_serial_numbers(affected_docs) if delete_originals else {}
|
||||||
|
)
|
||||||
|
queue_processing_tasks(
|
||||||
|
parent_task_id,
|
||||||
|
[consume_task],
|
||||||
|
delete_ids=affected_docs if delete_originals else None,
|
||||||
|
asn_backup=backup,
|
||||||
|
)
|
||||||
|
if failed_ids:
|
||||||
|
return {
|
||||||
|
"failed_document_ids": failed_ids,
|
||||||
|
"error_message": f"Documents {failed_ids} were excluded from the merge.",
|
||||||
|
}
|
||||||
|
return "OK"
|
||||||
|
|
||||||
if delete_originals:
|
if delete_originals:
|
||||||
backup = release_archive_serial_numbers(affected_docs)
|
backup = release_archive_serial_numbers(affected_docs)
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -701,7 +742,15 @@ def merge_as_versions(
|
|||||||
)
|
)
|
||||||
documents_by_id = {document.id: document for document in documents}
|
documents_by_id = {document.id: document for document in documents}
|
||||||
|
|
||||||
|
if any(document.root_document_id is not None for document in documents):
|
||||||
|
raise ValueError("Only top-level documents can be merged as versions.")
|
||||||
|
|
||||||
source_ids = [doc_id for doc_id in doc_ids if doc_id != root_document_id]
|
source_ids = [doc_id for doc_id in doc_ids if doc_id != root_document_id]
|
||||||
|
if Document.global_objects.filter(root_document_id__in=source_ids).exists():
|
||||||
|
raise ValueError(
|
||||||
|
"Documents with existing versions cannot be merged into another document.",
|
||||||
|
)
|
||||||
|
|
||||||
root_document = documents_by_id[root_document_id]
|
root_document = documents_by_id[root_document_id]
|
||||||
next_version_index = (
|
next_version_index = (
|
||||||
Document.global_objects.filter(
|
Document.global_objects.filter(
|
||||||
@@ -801,6 +850,7 @@ def split(
|
|||||||
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
|
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
|
||||||
user: User | None = None,
|
user: User | None = None,
|
||||||
trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI,
|
trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI,
|
||||||
|
parent_task_id: str | None = None,
|
||||||
) -> Literal["OK"]:
|
) -> Literal["OK"]:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Attempting to split document {doc_ids[0]} into {len(pages)} documents",
|
f"Attempting to split document {doc_ids[0]} into {len(pages)} documents",
|
||||||
@@ -844,7 +894,17 @@ def split(
|
|||||||
).set(headers={"trigger_source": trigger_source}),
|
).set(headers={"trigger_source": trigger_source}),
|
||||||
)
|
)
|
||||||
|
|
||||||
if delete_originals:
|
if parent_task_id is not None:
|
||||||
|
backup = (
|
||||||
|
release_archive_serial_numbers([doc.id]) if delete_originals else {}
|
||||||
|
)
|
||||||
|
queue_processing_tasks(
|
||||||
|
parent_task_id,
|
||||||
|
consume_tasks,
|
||||||
|
delete_ids=[doc.id] if delete_originals else None,
|
||||||
|
asn_backup=backup,
|
||||||
|
)
|
||||||
|
elif delete_originals:
|
||||||
backup = release_archive_serial_numbers([doc.id])
|
backup = release_archive_serial_numbers([doc.id])
|
||||||
logger.info(
|
logger.info(
|
||||||
"Queueing removal of original document after consumption of the split documents",
|
"Queueing removal of original document after consumption of the split documents",
|
||||||
@@ -864,6 +924,7 @@ def split(
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Error splitting document {doc.id}: {e}")
|
logger.exception(f"Error splitting document {doc.id}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
@@ -875,6 +936,7 @@ def delete_pages(
|
|||||||
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
|
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
|
||||||
user: User | None = None,
|
user: User | None = None,
|
||||||
trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI,
|
trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI,
|
||||||
|
parent_task_id: str | None = None,
|
||||||
) -> Literal["OK"]:
|
) -> Literal["OK"]:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Attempting to delete pages {pages} from {len(doc_ids)} documents",
|
f"Attempting to delete pages {pages} from {len(doc_ids)} documents",
|
||||||
@@ -893,22 +955,22 @@ def delete_pages(
|
|||||||
overrides = DocumentMetadataOverrides().from_document(pair.root_doc)
|
overrides = DocumentMetadataOverrides().from_document(pair.root_doc)
|
||||||
if user is not None:
|
if user is not None:
|
||||||
overrides.actor_id = user.id
|
overrides.actor_id = user.id
|
||||||
consume_file.apply_async(
|
queue_consumption(
|
||||||
kwargs={
|
input_doc=ConsumableDocument(
|
||||||
"input_doc": ConsumableDocument(
|
source=DocumentSource.ConsumeFolder,
|
||||||
source=DocumentSource.ConsumeFolder,
|
original_file=filepath,
|
||||||
original_file=filepath,
|
root_document_id=pair.root_doc.id,
|
||||||
root_document_id=pair.root_doc.id,
|
),
|
||||||
),
|
overrides=overrides,
|
||||||
"overrides": overrides,
|
trigger_source=trigger_source,
|
||||||
},
|
parent_task_id=parent_task_id,
|
||||||
headers={"trigger_source": trigger_source},
|
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Queued new version for document {pair.root_doc.id} after deleting pages {pages}",
|
f"Queued new version for document {pair.root_doc.id} after deleting pages {pages}",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Error deleting pages from document {pair.root_doc.id}: {e}")
|
logger.exception(f"Error deleting pages from document {pair.root_doc.id}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
@@ -923,6 +985,7 @@ def edit_pdf(
|
|||||||
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
|
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
|
||||||
user: User | None = None,
|
user: User | None = None,
|
||||||
trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI,
|
trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI,
|
||||||
|
parent_task_id: str | None = None,
|
||||||
) -> Literal["OK"]:
|
) -> Literal["OK"]:
|
||||||
"""
|
"""
|
||||||
Operations is a list of dictionaries describing the final PDF pages.
|
Operations is a list of dictionaries describing the final PDF pages.
|
||||||
@@ -967,16 +1030,15 @@ def edit_pdf(
|
|||||||
if user is not None:
|
if user is not None:
|
||||||
overrides.owner_id = user.id
|
overrides.owner_id = user.id
|
||||||
overrides.actor_id = user.id
|
overrides.actor_id = user.id
|
||||||
consume_file.apply_async(
|
queue_consumption(
|
||||||
kwargs={
|
input_doc=ConsumableDocument(
|
||||||
"input_doc": ConsumableDocument(
|
source=DocumentSource.ConsumeFolder,
|
||||||
source=DocumentSource.ConsumeFolder,
|
original_file=filepath,
|
||||||
original_file=filepath,
|
root_document_id=pair.root_doc.id,
|
||||||
root_document_id=pair.root_doc.id,
|
),
|
||||||
),
|
overrides=overrides,
|
||||||
"overrides": overrides,
|
trigger_source=trigger_source,
|
||||||
},
|
parent_task_id=parent_task_id,
|
||||||
headers={"trigger_source": trigger_source},
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
version_filepaths = pdf_ops.build_pdfs(
|
version_filepaths = pdf_ops.build_pdfs(
|
||||||
@@ -1016,7 +1078,17 @@ def edit_pdf(
|
|||||||
).set(headers={"trigger_source": trigger_source}),
|
).set(headers={"trigger_source": trigger_source}),
|
||||||
)
|
)
|
||||||
|
|
||||||
if delete_original:
|
if parent_task_id is not None:
|
||||||
|
backup = (
|
||||||
|
release_archive_serial_numbers([doc.id]) if delete_original else {}
|
||||||
|
)
|
||||||
|
queue_processing_tasks(
|
||||||
|
parent_task_id,
|
||||||
|
consume_tasks,
|
||||||
|
delete_ids=[doc.id] if delete_original else None,
|
||||||
|
asn_backup=backup,
|
||||||
|
)
|
||||||
|
elif delete_original:
|
||||||
backup = release_archive_serial_numbers([doc.id])
|
backup = release_archive_serial_numbers([doc.id])
|
||||||
try:
|
try:
|
||||||
chord(
|
chord(
|
||||||
@@ -1050,6 +1122,7 @@ def remove_password(
|
|||||||
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
|
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
|
||||||
user: User | None = None,
|
user: User | None = None,
|
||||||
trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI,
|
trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI,
|
||||||
|
parent_task_id: str | None = None,
|
||||||
source_paths_by_id: Mapping[int, Path] | None = None,
|
source_paths_by_id: Mapping[int, Path] | None = None,
|
||||||
) -> Literal["OK"]:
|
) -> Literal["OK"]:
|
||||||
"""
|
"""
|
||||||
@@ -1092,16 +1165,15 @@ def remove_password(
|
|||||||
if user is not None:
|
if user is not None:
|
||||||
overrides.owner_id = user.id
|
overrides.owner_id = user.id
|
||||||
overrides.actor_id = user.id
|
overrides.actor_id = user.id
|
||||||
consume_file.apply_async(
|
queue_consumption(
|
||||||
kwargs={
|
input_doc=ConsumableDocument(
|
||||||
"input_doc": ConsumableDocument(
|
source=DocumentSource.ConsumeFolder,
|
||||||
source=DocumentSource.ConsumeFolder,
|
original_file=filepath,
|
||||||
original_file=filepath,
|
root_document_id=pair.root_doc.id,
|
||||||
root_document_id=pair.root_doc.id,
|
),
|
||||||
),
|
overrides=overrides,
|
||||||
"overrides": overrides,
|
trigger_source=trigger_source,
|
||||||
},
|
parent_task_id=parent_task_id,
|
||||||
headers={"trigger_source": trigger_source},
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
consume_tasks = []
|
consume_tasks = []
|
||||||
@@ -1124,7 +1196,19 @@ def remove_password(
|
|||||||
).set(headers={"trigger_source": trigger_source}),
|
).set(headers={"trigger_source": trigger_source}),
|
||||||
)
|
)
|
||||||
|
|
||||||
if delete_original:
|
if parent_task_id is not None:
|
||||||
|
backup = (
|
||||||
|
release_archive_serial_numbers([doc.id])
|
||||||
|
if delete_original
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
queue_processing_tasks(
|
||||||
|
parent_task_id,
|
||||||
|
consume_tasks,
|
||||||
|
delete_ids=[doc.id] if delete_original else None,
|
||||||
|
asn_backup=backup,
|
||||||
|
)
|
||||||
|
elif delete_original:
|
||||||
chord(
|
chord(
|
||||||
header=consume_tasks,
|
header=consume_tasks,
|
||||||
body=delete.si([doc.id]),
|
body=delete.si([doc.id]),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Queued document metadata edits."""
|
"""Queued document bulk operations."""
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ from rest_framework.exceptions import PermissionDenied
|
|||||||
from documents import bulk_edit
|
from documents import bulk_edit
|
||||||
from documents.models import CustomFieldInstance
|
from documents.models import CustomFieldInstance
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
|
from documents.models import PaperlessTask
|
||||||
from documents.permissions import has_document_operation_permissions
|
from documents.permissions import has_document_operation_permissions
|
||||||
from documents.tasks import bulk_update_documents
|
from documents.tasks import bulk_update_documents
|
||||||
|
|
||||||
@@ -25,7 +26,35 @@ MODIFIED_FIELDS = {
|
|||||||
"modify_custom_fields": "custom_fields",
|
"modify_custom_fields": "custom_fields",
|
||||||
"set_permissions": None,
|
"set_permissions": None,
|
||||||
}
|
}
|
||||||
QUEUED_OPERATIONS = frozenset(MODIFIED_FIELDS)
|
LEGACY_MODIFIED_FIELDS = {"delete": "deleted_at", "reprocess": "checksum"}
|
||||||
|
QUEUED_OPERATIONS = frozenset(
|
||||||
|
(
|
||||||
|
*MODIFIED_FIELDS,
|
||||||
|
"delete",
|
||||||
|
"reprocess",
|
||||||
|
"run_workflow",
|
||||||
|
"rotate",
|
||||||
|
"merge",
|
||||||
|
"merge_as_versions",
|
||||||
|
"split",
|
||||||
|
"delete_pages",
|
||||||
|
"edit_pdf",
|
||||||
|
"remove_password",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
USER_OPERATIONS = frozenset(
|
||||||
|
{
|
||||||
|
"split",
|
||||||
|
"merge",
|
||||||
|
"rotate",
|
||||||
|
"delete_pages",
|
||||||
|
"edit_pdf",
|
||||||
|
"remove_password",
|
||||||
|
"merge_as_versions",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# merge_as_versions doesn't queue any consume tasks
|
||||||
|
TRIGGER_SOURCE_OPERATIONS = USER_OPERATIONS - {"merge_as_versions"}
|
||||||
|
|
||||||
|
|
||||||
def snapshot_field(doc_ids: list[int], field: str) -> dict[int, Any]:
|
def snapshot_field(doc_ids: list[int], field: str) -> dict[int, Any]:
|
||||||
@@ -60,12 +89,14 @@ def snapshot_field(doc_ids: list[int], field: str) -> dict[int, Any]:
|
|||||||
return values
|
return values
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task(bind=True)
|
||||||
def execute_bulk_operation(
|
def execute_bulk_operation(
|
||||||
|
self,
|
||||||
operation: str,
|
operation: str,
|
||||||
document_ids: list[int],
|
document_ids: list[int],
|
||||||
parameters: dict,
|
parameters: dict,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
|
trigger_source: str = PaperlessTask.TriggerSource.API_UPLOAD,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
if operation not in QUEUED_OPERATIONS:
|
if operation not in QUEUED_OPERATIONS:
|
||||||
raise ValueError(f"Unsupported bulk operation: {operation}")
|
raise ValueError(f"Unsupported bulk operation: {operation}")
|
||||||
@@ -79,6 +110,48 @@ def execute_bulk_operation(
|
|||||||
):
|
):
|
||||||
raise PermissionDenied("Insufficient permissions to execute bulk operation")
|
raise PermissionDenied("Insufficient permissions to execute bulk operation")
|
||||||
|
|
||||||
|
if operation == "run_workflow" and not user.has_perm("documents.view_workflow"):
|
||||||
|
raise PermissionDenied("Insufficient permissions to run this workflow")
|
||||||
|
|
||||||
|
if operation not in MODIFIED_FIELDS:
|
||||||
|
execution_parameters = parameters.copy()
|
||||||
|
if (
|
||||||
|
operation in TRIGGER_SOURCE_OPERATIONS | {"reprocess"}
|
||||||
|
and self.request.id is not None
|
||||||
|
):
|
||||||
|
execution_parameters["parent_task_id"] = self.request.id
|
||||||
|
if operation in USER_OPERATIONS:
|
||||||
|
execution_parameters["user"] = user
|
||||||
|
if operation in TRIGGER_SOURCE_OPERATIONS:
|
||||||
|
execution_parameters["trigger_source"] = trigger_source
|
||||||
|
modified_field = LEGACY_MODIFIED_FIELDS.get(operation)
|
||||||
|
with set_actor(user):
|
||||||
|
old_values = (
|
||||||
|
snapshot_field(document_ids, modified_field)
|
||||||
|
if settings.AUDIT_LOG_ENABLED and modified_field
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
result = method(document_ids, **execution_parameters)
|
||||||
|
if settings.AUDIT_LOG_ENABLED and modified_field:
|
||||||
|
from auditlog.models import LogEntry
|
||||||
|
|
||||||
|
new_values = snapshot_field(document_ids, modified_field)
|
||||||
|
for doc in Document.objects.filter(pk__in=document_ids):
|
||||||
|
LogEntry.objects.log_create(
|
||||||
|
instance=doc,
|
||||||
|
changes={
|
||||||
|
modified_field: [old_values[doc.pk], new_values[doc.pk]],
|
||||||
|
},
|
||||||
|
action=LogEntry.Action.UPDATE,
|
||||||
|
actor=user,
|
||||||
|
additional_data={"reason": f"Bulk edit: {operation}"},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"operation": operation,
|
||||||
|
"document_count": len(document_ids),
|
||||||
|
**(result if isinstance(result, dict) else {}),
|
||||||
|
}
|
||||||
|
|
||||||
affected_ids: list[int] = []
|
affected_ids: list[int] = []
|
||||||
modified_field = MODIFIED_FIELDS[operation]
|
modified_field = MODIFIED_FIELDS[operation]
|
||||||
# Keep metadata writes and their audit entries together. Follow-up processing
|
# Keep metadata writes and their audit entries together. Follow-up processing
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 5.2.18 on 2026-11-14 19:59
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("documents", "0030_note_format"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="paperlesstask",
|
||||||
|
name="dispatched",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="paperlesstask",
|
||||||
|
name="parent",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="children",
|
||||||
|
to="documents.paperlesstask",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -970,6 +970,16 @@ class PaperlessTask(ModelWithOwner):
|
|||||||
help_text=_("Structured result data from task execution"),
|
help_text=_("Structured result data from task execution"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
parent = models.ForeignKey(
|
||||||
|
"self",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="children",
|
||||||
|
)
|
||||||
|
|
||||||
|
dispatched = models.BooleanField(default=False)
|
||||||
|
|
||||||
# Acknowledgment
|
# Acknowledgment
|
||||||
acknowledged = models.BooleanField(
|
acknowledged = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
|
|||||||
@@ -789,6 +789,7 @@ def has_document_operation_permissions(
|
|||||||
bulk_edit.edit_pdf,
|
bulk_edit.edit_pdf,
|
||||||
bulk_edit.merge_as_versions,
|
bulk_edit.merge_as_versions,
|
||||||
bulk_edit.remove_password,
|
bulk_edit.remove_password,
|
||||||
|
bulk_edit.run_workflow,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
or (
|
or (
|
||||||
|
|||||||
@@ -1688,6 +1688,7 @@ class MergeDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin
|
|||||||
|
|
||||||
class MergeDocumentsAsVersionsSerializer(DocumentListSerializer):
|
class MergeDocumentsAsVersionsSerializer(DocumentListSerializer):
|
||||||
root_document_id = serializers.IntegerField(required=True)
|
root_document_id = serializers.IntegerField(required=True)
|
||||||
|
from_webui = serializers.BooleanField(required=False, default=False)
|
||||||
version_label = serializers.CharField(
|
version_label = serializers.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
allow_blank=True,
|
allow_blank=True,
|
||||||
@@ -1716,19 +1717,8 @@ class MergeDocumentsAsVersionsSerializer(DocumentListSerializer):
|
|||||||
"root_document_id must be one of the selected documents.",
|
"root_document_id must be one of the selected documents.",
|
||||||
)
|
)
|
||||||
|
|
||||||
selected_documents = Document.objects.filter(id__in=documents)
|
# Version state checks (e.g., whether a document is already part of a version chain)
|
||||||
if selected_documents.filter(root_document__isnull=False).exists():
|
# moved to merge_as_versions() under row locks, since it could change between queueing and execution
|
||||||
raise serializers.ValidationError(
|
|
||||||
"Only top-level documents can be merged as versions.",
|
|
||||||
)
|
|
||||||
|
|
||||||
source_document_ids = set(documents) - {attrs["root_document_id"]}
|
|
||||||
if Document.global_objects.filter(
|
|
||||||
root_document_id__in=source_document_ids,
|
|
||||||
).exists():
|
|
||||||
raise serializers.ValidationError(
|
|
||||||
"Documents with existing versions cannot be merged into another document.",
|
|
||||||
)
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
@@ -1801,11 +1791,12 @@ class RemovePasswordDocumentsSerializer(
|
|||||||
|
|
||||||
|
|
||||||
class DeleteDocumentsSerializer(DocumentSelectionSerializer):
|
class DeleteDocumentsSerializer(DocumentSelectionSerializer):
|
||||||
pass
|
from_webui = serializers.BooleanField(required=False, default=False)
|
||||||
|
|
||||||
|
|
||||||
class ReprocessDocumentsSerializer(DocumentSelectionSerializer):
|
class ReprocessDocumentsSerializer(DocumentSelectionSerializer):
|
||||||
remote_ocr = serializers.BooleanField(required=False, default=False)
|
remote_ocr = serializers.BooleanField(required=False, default=False)
|
||||||
|
from_webui = serializers.BooleanField(required=False, default=False)
|
||||||
|
|
||||||
|
|
||||||
class BulkEditSerializer(
|
class BulkEditSerializer(
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ from documents.models import WorkflowRun
|
|||||||
from documents.models import WorkflowTrigger
|
from documents.models import WorkflowTrigger
|
||||||
from documents.permissions import restrict_queryset_to_visible
|
from documents.permissions import restrict_queryset_to_visible
|
||||||
from documents.plugins.helpers import DocumentsStatusManager
|
from documents.plugins.helpers import DocumentsStatusManager
|
||||||
|
from documents.task_tracking import finish_dispatch
|
||||||
|
from documents.task_tracking import update_parent
|
||||||
from documents.templating.utils import convert_format_str_to_template_format
|
from documents.templating.utils import convert_format_str_to_template_format
|
||||||
from documents.utils import compute_checksum
|
from documents.utils import compute_checksum
|
||||||
from documents.utils import copy_file_with_basic_stats
|
from documents.utils import copy_file_with_basic_stats
|
||||||
@@ -1077,6 +1079,7 @@ TRACKED_TASKS: dict[str, PaperlessTask.TaskType] = {
|
|||||||
"documents.tasks.update_document_content_maybe_archive_file": PaperlessTask.TaskType.REPROCESS_DOCUMENT,
|
"documents.tasks.update_document_content_maybe_archive_file": PaperlessTask.TaskType.REPROCESS_DOCUMENT,
|
||||||
"documents.tasks.build_share_link_bundle": PaperlessTask.TaskType.BUILD_SHARE_LINK,
|
"documents.tasks.build_share_link_bundle": PaperlessTask.TaskType.BUILD_SHARE_LINK,
|
||||||
"documents.bulk_edit.delete": PaperlessTask.TaskType.BULK_DELETE,
|
"documents.bulk_edit.delete": PaperlessTask.TaskType.BULK_DELETE,
|
||||||
|
"documents.task_tracking.finish_file_operation": PaperlessTask.TaskType.BULK_DELETE,
|
||||||
"documents.tasks.apply_ai_suggestions": PaperlessTask.TaskType.APPLY_AI_SUGGESTIONS,
|
"documents.tasks.apply_ai_suggestions": PaperlessTask.TaskType.APPLY_AI_SUGGESTIONS,
|
||||||
BULK_OPERATION_TASK_NAME: PaperlessTask.TaskType.BULK_UPDATE,
|
BULK_OPERATION_TASK_NAME: PaperlessTask.TaskType.BULK_UPDATE,
|
||||||
}
|
}
|
||||||
@@ -1125,6 +1128,8 @@ def _extract_input_data(
|
|||||||
data["overrides"] = override_dict
|
data["overrides"] = override_dict
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
if task_type == PaperlessTask.TaskType.REPROCESS_DOCUMENT:
|
||||||
|
return {"document_id": task_kwargs["document_id"]}
|
||||||
if task_type == PaperlessTask.TaskType.MAIL_FETCH:
|
if task_type == PaperlessTask.TaskType.MAIL_FETCH:
|
||||||
account_ids = task_kwargs.get("account_ids")
|
account_ids = task_kwargs.get("account_ids")
|
||||||
if account_ids is not None:
|
if account_ids is not None:
|
||||||
@@ -1206,10 +1211,25 @@ def before_task_publish_handler(
|
|||||||
"document_count": len(task_kwargs["document_ids"]),
|
"document_count": len(task_kwargs["document_ids"]),
|
||||||
}
|
}
|
||||||
owner_id = task_kwargs["user_id"]
|
owner_id = task_kwargs["user_id"]
|
||||||
|
if task_kwargs["operation"] == "delete":
|
||||||
|
task_type = PaperlessTask.TaskType.BULK_DELETE
|
||||||
else:
|
else:
|
||||||
input_data = _extract_input_data(task_type, task_kwargs)
|
input_data = _extract_input_data(task_type, task_kwargs)
|
||||||
owner_id = _extract_owner_id(task_type, task_kwargs)
|
owner_id = _extract_owner_id(task_type, task_kwargs)
|
||||||
trigger_source = _determine_trigger_source(headers)
|
trigger_source = _determine_trigger_source(headers)
|
||||||
|
# Consumption can fan out again (e.g. barcode splitting). Keep those
|
||||||
|
# descendants attached so their caller cannot finish before them.
|
||||||
|
parent = None
|
||||||
|
if task_type == PaperlessTask.TaskType.CONSUME_FILE and headers.get(
|
||||||
|
"parent_id",
|
||||||
|
):
|
||||||
|
parent = PaperlessTask.objects.filter(
|
||||||
|
task_id=headers["parent_id"],
|
||||||
|
parent__isnull=False,
|
||||||
|
).first()
|
||||||
|
if parent is not None:
|
||||||
|
owner_id = parent.owner_id
|
||||||
|
trigger_source = parent.trigger_source
|
||||||
|
|
||||||
# A retried task is republished with the same task_id, so this fires
|
# A retried task is republished with the same task_id, so this fires
|
||||||
# again for it; get_or_create keeps the original PENDING record
|
# again for it; get_or_create keeps the original PENDING record
|
||||||
@@ -1222,6 +1242,7 @@ def before_task_publish_handler(
|
|||||||
"status": PaperlessTask.Status.PENDING,
|
"status": PaperlessTask.Status.PENDING,
|
||||||
"input_data": input_data,
|
"input_data": input_data,
|
||||||
"owner_id": owner_id,
|
"owner_id": owner_id,
|
||||||
|
"parent": parent,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except Exception: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
@@ -1307,6 +1328,10 @@ def task_postrun_handler(
|
|||||||
except PaperlessTask.DoesNotExist:
|
except PaperlessTask.DoesNotExist:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if task_instance.children.exists():
|
||||||
|
finish_dispatch(task_instance, retval if isinstance(retval, dict) else {})
|
||||||
|
return
|
||||||
|
|
||||||
task_instance.status = new_status
|
task_instance.status = new_status
|
||||||
task_instance.date_done = now
|
task_instance.date_done = now
|
||||||
changed_fields = ["status", "date_done"]
|
changed_fields = ["status", "date_done"]
|
||||||
@@ -1329,7 +1354,21 @@ def task_postrun_handler(
|
|||||||
task_instance.status = PaperlessTask.Status.FAILURE
|
task_instance.status = PaperlessTask.Status.FAILURE
|
||||||
changed_fields.append("status")
|
changed_fields.append("status")
|
||||||
|
|
||||||
|
if (
|
||||||
|
task_instance.parent_id is not None
|
||||||
|
and task_instance.task_type == PaperlessTask.TaskType.CONSUME_FILE
|
||||||
|
and (not isinstance(retval, dict) or "document_id" not in retval)
|
||||||
|
and not (isinstance(retval, dict) and "duplicate_of" in retval)
|
||||||
|
):
|
||||||
|
task_instance.status = PaperlessTask.Status.FAILURE
|
||||||
|
task_instance.result_data = {
|
||||||
|
**(retval if isinstance(retval, dict) else {}),
|
||||||
|
"error_message": "Consumption stopped without creating a document.",
|
||||||
|
}
|
||||||
|
changed_fields.extend(["status", "result_data"])
|
||||||
|
|
||||||
task_instance.save(update_fields=changed_fields)
|
task_instance.save(update_fields=changed_fields)
|
||||||
|
update_parent(task_instance)
|
||||||
if task and task.name == BULK_OPERATION_TASK_NAME:
|
if task and task.name == BULK_OPERATION_TASK_NAME:
|
||||||
_send_bulk_operation_status(task_instance)
|
_send_bulk_operation_status(task_instance)
|
||||||
except Exception: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
@@ -1386,6 +1425,10 @@ def task_failure_handler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
task_qs = PaperlessTask.objects.filter(task_id=task_id)
|
task_qs = PaperlessTask.objects.filter(task_id=task_id)
|
||||||
|
parent = task_qs.first()
|
||||||
|
if parent is not None and parent.children.exists():
|
||||||
|
finish_dispatch(parent, result_data)
|
||||||
|
return
|
||||||
task_instance = task_qs.values("date_started", "date_created").first()
|
task_instance = task_qs.values("date_started", "date_created").first()
|
||||||
if task_instance:
|
if task_instance:
|
||||||
date_started = task_instance["date_started"]
|
date_started = task_instance["date_started"]
|
||||||
@@ -1397,6 +1440,7 @@ def task_failure_handler(
|
|||||||
date_started - date_created
|
date_started - date_created
|
||||||
).total_seconds()
|
).total_seconds()
|
||||||
task_qs.update(**update_fields)
|
task_qs.update(**update_fields)
|
||||||
|
update_parent(task_qs.get())
|
||||||
if sender and sender.name == BULK_OPERATION_TASK_NAME:
|
if sender and sender.name == BULK_OPERATION_TASK_NAME:
|
||||||
_send_bulk_operation_status(task_qs.first())
|
_send_bulk_operation_status(task_qs.first())
|
||||||
except Exception: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
@@ -1443,6 +1487,8 @@ def task_revoked_handler(
|
|||||||
"reason": (reason + " Check worker logs for details."),
|
"reason": (reason + " Check worker logs for details."),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
if task_instance := task_qs.first():
|
||||||
|
update_parent(task_instance)
|
||||||
if sender and sender.name == BULK_OPERATION_TASK_NAME:
|
if sender and sender.name == BULK_OPERATION_TASK_NAME:
|
||||||
_send_bulk_operation_status(task_qs.first())
|
_send_bulk_operation_status(task_qs.first())
|
||||||
except Exception: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
"""Completion tracking for operations which dispatch document processing tasks."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from celery import shared_task
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from documents.models import PaperlessTask
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from celery.canvas import Signature
|
||||||
|
|
||||||
|
from documents.data_models import ConsumableDocument
|
||||||
|
from documents.data_models import DocumentMetadataOverrides
|
||||||
|
|
||||||
|
|
||||||
|
def queue_consumption(
|
||||||
|
*,
|
||||||
|
input_doc: ConsumableDocument,
|
||||||
|
overrides: DocumentMetadataOverrides,
|
||||||
|
trigger_source: str,
|
||||||
|
parent_task_id: str | None,
|
||||||
|
) -> None:
|
||||||
|
from documents.tasks import consume_file
|
||||||
|
|
||||||
|
kwargs = {"input_doc": input_doc, "overrides": overrides}
|
||||||
|
if parent_task_id is None:
|
||||||
|
consume_file.apply_async(
|
||||||
|
kwargs=kwargs,
|
||||||
|
headers={"trigger_source": trigger_source},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
queue_processing_tasks(parent_task_id, [consume_file.s(**kwargs)])
|
||||||
|
|
||||||
|
|
||||||
|
def queue_reprocessing_tasks(
|
||||||
|
parent_task_id: str,
|
||||||
|
document_ids: list[int],
|
||||||
|
*,
|
||||||
|
remote_ocr: bool,
|
||||||
|
) -> None:
|
||||||
|
from documents.tasks import update_document_content_maybe_archive_file
|
||||||
|
|
||||||
|
queue_processing_tasks(
|
||||||
|
parent_task_id,
|
||||||
|
[
|
||||||
|
update_document_content_maybe_archive_file.s(
|
||||||
|
document_id=document_id,
|
||||||
|
remote_ocr=remote_ocr,
|
||||||
|
)
|
||||||
|
for document_id in document_ids
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def queue_processing_tasks(
|
||||||
|
parent_task_id: str,
|
||||||
|
signatures: list[Signature],
|
||||||
|
*,
|
||||||
|
delete_ids: list[int] | None = None,
|
||||||
|
asn_backup: dict | None = None,
|
||||||
|
) -> None:
|
||||||
|
from documents.signals.handlers import TRACKED_TASKS
|
||||||
|
from documents.signals.handlers import _extract_input_data
|
||||||
|
|
||||||
|
parent = PaperlessTask.objects.get(task_id=parent_task_id)
|
||||||
|
children = [
|
||||||
|
PaperlessTask(
|
||||||
|
task_id=str(uuid.uuid4()),
|
||||||
|
task_type=TRACKED_TASKS[signature.task],
|
||||||
|
trigger_source=parent.trigger_source,
|
||||||
|
owner_id=parent.owner_id,
|
||||||
|
parent=parent,
|
||||||
|
input_data=_extract_input_data(
|
||||||
|
TRACKED_TASKS[signature.task],
|
||||||
|
signature.kwargs,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for signature in signatures
|
||||||
|
]
|
||||||
|
# Dependencies and their final step must be registered together.
|
||||||
|
with transaction.atomic():
|
||||||
|
PaperlessTask.objects.bulk_create(children)
|
||||||
|
if delete_ids:
|
||||||
|
PaperlessTask.objects.create(
|
||||||
|
task_id=str(uuid.uuid4()),
|
||||||
|
task_type=PaperlessTask.TaskType.BULK_DELETE,
|
||||||
|
owner_id=parent.owner_id,
|
||||||
|
trigger_source=parent.trigger_source,
|
||||||
|
parent=parent,
|
||||||
|
input_data={
|
||||||
|
"dependencies": [child.pk for child in children],
|
||||||
|
"delete_ids": delete_ids,
|
||||||
|
"asn_backup": asn_backup or {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for index, (signature, child) in enumerate(zip(signatures, children, strict=True)):
|
||||||
|
try:
|
||||||
|
signature.apply_async(
|
||||||
|
task_id=child.task_id,
|
||||||
|
headers={"trigger_source": parent.trigger_source},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
PaperlessTask.objects.filter(
|
||||||
|
pk__in=[task.pk for task in children[index:]],
|
||||||
|
).update(
|
||||||
|
status=PaperlessTask.Status.FAILURE,
|
||||||
|
date_done=timezone.now(),
|
||||||
|
result_data={"error_message": "Could not publish processing task."},
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def finish_file_operation(
|
||||||
|
dependencies: list[int],
|
||||||
|
delete_ids: list[int],
|
||||||
|
asn_backup: dict,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
from documents.bulk_edit import delete
|
||||||
|
from documents.bulk_edit import restore_archive_serial_numbers
|
||||||
|
|
||||||
|
statuses = list(
|
||||||
|
PaperlessTask.objects.filter(pk__in=dependencies).values_list(
|
||||||
|
"status",
|
||||||
|
flat=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if len(statuses) != len(dependencies):
|
||||||
|
raise ValueError(
|
||||||
|
"Processing task records are missing; originals were preserved.",
|
||||||
|
)
|
||||||
|
if any(status != PaperlessTask.Status.SUCCESS for status in statuses):
|
||||||
|
restore_archive_serial_numbers(asn_backup)
|
||||||
|
return {"reason": "Originals preserved; archive serial numbers restored."}
|
||||||
|
delete(delete_ids)
|
||||||
|
return {"reason": "Originals moved to trash."}
|
||||||
|
|
||||||
|
|
||||||
|
def _publish_finalizer(task: PaperlessTask) -> None:
|
||||||
|
try:
|
||||||
|
finish_file_operation.apply_async(
|
||||||
|
task_id=task.task_id,
|
||||||
|
kwargs=task.input_data,
|
||||||
|
headers={"trigger_source": task.trigger_source},
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
from documents.signals.handlers import task_failure_handler
|
||||||
|
|
||||||
|
task_failure_handler(
|
||||||
|
sender=finish_file_operation,
|
||||||
|
task_id=task.task_id,
|
||||||
|
exception=error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_finalizers(parent: PaperlessTask) -> None:
|
||||||
|
for task in parent.children.filter(
|
||||||
|
task_type=PaperlessTask.TaskType.BULK_DELETE,
|
||||||
|
status=PaperlessTask.Status.PENDING,
|
||||||
|
):
|
||||||
|
dependencies = task.input_data.get("dependencies")
|
||||||
|
if dependencies is None or task.result_data:
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
PaperlessTask.objects.filter(pk__in=dependencies)
|
||||||
|
.exclude(
|
||||||
|
status__in=PaperlessTask.COMPLETE_STATUSES,
|
||||||
|
)
|
||||||
|
.exists()
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
task.result_data = {"scheduled": True}
|
||||||
|
task.save(update_fields=["result_data"])
|
||||||
|
transaction.on_commit(lambda task=task: _publish_finalizer(task))
|
||||||
|
|
||||||
|
|
||||||
|
def finish_dispatch(task: PaperlessTask, result_data: dict) -> None:
|
||||||
|
"""Record the scheduling outcome without completing an active parent."""
|
||||||
|
with transaction.atomic():
|
||||||
|
parent = PaperlessTask.objects.select_for_update().get(pk=task.pk)
|
||||||
|
if parent.is_complete:
|
||||||
|
return
|
||||||
|
parent.dispatched = True
|
||||||
|
parent.result_data = result_data
|
||||||
|
parent.save(update_fields=["dispatched", "result_data"])
|
||||||
|
_finalize_parent(parent)
|
||||||
|
|
||||||
|
|
||||||
|
def update_parent(task: PaperlessTask) -> None:
|
||||||
|
if task.parent_id is None:
|
||||||
|
return
|
||||||
|
with transaction.atomic():
|
||||||
|
parent = PaperlessTask.objects.select_for_update().get(pk=task.parent_id)
|
||||||
|
_finalize_parent(parent)
|
||||||
|
|
||||||
|
|
||||||
|
def _finalize_parent(parent: PaperlessTask) -> None:
|
||||||
|
if parent.is_complete or not parent.dispatched:
|
||||||
|
return
|
||||||
|
_schedule_finalizers(parent)
|
||||||
|
statuses = list(parent.children.values_list("status", flat=True))
|
||||||
|
if any(status not in PaperlessTask.COMPLETE_STATUSES for status in statuses):
|
||||||
|
return
|
||||||
|
|
||||||
|
succeeded = statuses.count(PaperlessTask.Status.SUCCESS)
|
||||||
|
failed = len(statuses) - succeeded
|
||||||
|
result_data = parent.result_data or {}
|
||||||
|
if failed and not result_data.get("error_message"):
|
||||||
|
result_data["error_message"] = (
|
||||||
|
f"{succeeded} child task(s) succeeded; {failed} failed or were revoked. "
|
||||||
|
"See child tasks for details."
|
||||||
|
)
|
||||||
|
parent.status = (
|
||||||
|
PaperlessTask.Status.FAILURE
|
||||||
|
if result_data.get("error_message")
|
||||||
|
else PaperlessTask.Status.SUCCESS
|
||||||
|
)
|
||||||
|
parent.result_data = {
|
||||||
|
**parent.input_data,
|
||||||
|
**result_data,
|
||||||
|
"succeeded_count": succeeded,
|
||||||
|
"failed_count": failed,
|
||||||
|
}
|
||||||
|
parent.date_done = timezone.now()
|
||||||
|
if parent.date_started:
|
||||||
|
parent.duration_seconds = (
|
||||||
|
parent.date_done - parent.date_started
|
||||||
|
).total_seconds()
|
||||||
|
parent.wait_time_seconds = (
|
||||||
|
parent.date_started - parent.date_created
|
||||||
|
).total_seconds()
|
||||||
|
parent.save()
|
||||||
|
from documents.signals.handlers import _send_bulk_operation_status
|
||||||
|
|
||||||
|
if "operation" in parent.input_data:
|
||||||
|
transaction.on_commit(lambda: _send_bulk_operation_status(parent))
|
||||||
|
update_parent(parent)
|
||||||
+11
-4
@@ -394,11 +394,10 @@ def update_document_content_maybe_archive_file(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not parser_class:
|
if not parser_class:
|
||||||
logger.error(
|
raise ValueError(
|
||||||
f"No parser found for mime type {mime_type}, cannot "
|
f"No parser found for mime type {mime_type}, cannot "
|
||||||
f"archive document {document} (ID: {document_id})",
|
f"reprocess document {document} (ID: {document_id})",
|
||||||
)
|
)
|
||||||
return
|
|
||||||
|
|
||||||
with parser_class() as parser:
|
with parser_class() as parser:
|
||||||
parser.configure(ParserContext())
|
parser.configure(ParserContext())
|
||||||
@@ -512,6 +511,7 @@ def update_document_content_maybe_archive_file(
|
|||||||
logger.exception(
|
logger.exception(
|
||||||
f"Error while parsing document {document} (ID: {document_id})",
|
f"Error while parsing document {document} (ID: {document_id})",
|
||||||
)
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
@@ -713,8 +713,9 @@ def run_workflows_for_documents(
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
f"Manual workflow run requested for missing workflow {workflow_id}",
|
f"Manual workflow run requested for missing workflow {workflow_id}",
|
||||||
)
|
)
|
||||||
return
|
raise
|
||||||
|
|
||||||
|
failed_ids = []
|
||||||
for document in Document.objects.filter(
|
for document in Document.objects.filter(
|
||||||
id__in=document_ids,
|
id__in=document_ids,
|
||||||
root_document__isnull=True,
|
root_document__isnull=True,
|
||||||
@@ -732,10 +733,16 @@ def run_workflows_for_documents(
|
|||||||
document=document,
|
document=document,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
failed_ids.append(document.pk)
|
||||||
logger.exception(
|
logger.exception(
|
||||||
f"Error running workflow {workflow} on document {document.pk}",
|
f"Error running workflow {workflow} on document {document.pk}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if failed_ids:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Workflow {workflow_id} failed for document(s): {failed_ids}. Check logs for details.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def update_document_parent_tags(tag: Tag, new_parent: Tag) -> None:
|
def update_document_parent_tags(tag: Tag, new_parent: Tag) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -703,11 +703,12 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.run_workflow")
|
@mock.patch("documents.views.bulk_edit.run_workflow")
|
||||||
def test_run_workflow_requires_ownership(self, m) -> None:
|
def test_run_workflow_requires_ownership(self, m) -> None:
|
||||||
"""A non-owner without document ownership is forbidden (actions mutate docs)."""
|
"""A non-owner with change permission cannot run mutating workflow actions."""
|
||||||
self.setup_mock(m, "run_workflow")
|
self.setup_mock(m, "run_workflow")
|
||||||
user = UserFactory(username="no_perms")
|
user = UserFactory(username="non_owner")
|
||||||
grant_all_global(user)
|
grant_all_global(user)
|
||||||
doc = DocumentFactory(owner=UserFactory(username="other"))
|
doc = DocumentFactory(owner=UserFactory(username="other"))
|
||||||
|
grant_object(user, doc, "change_document")
|
||||||
workflow = self._make_manual_workflow()
|
workflow = self._make_manual_workflow()
|
||||||
self.client.force_authenticate(user=user)
|
self.client.force_authenticate(user=user)
|
||||||
|
|
||||||
|
|||||||
@@ -698,10 +698,10 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
|||||||
def test_delete_documents_old_uuid_field(self, m) -> None:
|
def test_delete_documents_old_uuid_field(self, m) -> None:
|
||||||
m.side_effect = Exception("Data too long for column 'transaction_id' at row 1")
|
m.side_effect = Exception("Data too long for column 'transaction_id' at row 1")
|
||||||
doc_ids = [self.doc1.id, self.doc2.id, self.doc3.id]
|
doc_ids = [self.doc1.id, self.doc2.id, self.doc3.id]
|
||||||
bulk_edit.delete(doc_ids)
|
|
||||||
with self.assertLogs(level="WARNING") as cm:
|
with self.assertLogs(level="WARNING") as cm:
|
||||||
bulk_edit.delete(doc_ids)
|
with self.assertRaisesRegex(Exception, "Data too long for column"):
|
||||||
self.assertIn("possible incompatible database column", cm.output[0])
|
bulk_edit.delete(doc_ids)
|
||||||
|
self.assertIn("possible incompatible database column", cm.output[0])
|
||||||
|
|
||||||
|
|
||||||
class TestPDFActions(DirectoriesMixin, TestCase):
|
class TestPDFActions(DirectoriesMixin, TestCase):
|
||||||
@@ -1038,8 +1038,9 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
|
|
||||||
mock_consume_file.assert_not_called()
|
mock_consume_file.assert_not_called()
|
||||||
|
|
||||||
|
@mock.patch("documents.bulk_edit.group")
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
def test_split(self, mock_consume_file) -> None:
|
def test_split(self, mock_consume_file, mock_group) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- Existing documents
|
- Existing documents
|
||||||
@@ -1060,6 +1061,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
self.assertIsNone(mock_consume_file.call_args.kwargs["overrides"].asn)
|
self.assertIsNone(mock_consume_file.call_args.kwargs["overrides"].asn)
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
|
mock_group.return_value.delay.assert_called_once()
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.group")
|
@mock.patch("documents.bulk_edit.group")
|
||||||
@mock.patch("documents.pdf_ops.build_pdfs")
|
@mock.patch("documents.pdf_ops.build_pdfs")
|
||||||
@@ -1168,8 +1170,8 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
sig.apply_async.side_effect = Exception("boom")
|
sig.apply_async.side_effect = Exception("boom")
|
||||||
mock_chord.return_value = sig
|
mock_chord.return_value = sig
|
||||||
|
|
||||||
result = bulk_edit.split(doc_ids, pages, delete_originals=True)
|
with self.assertRaisesMessage(Exception, "boom"):
|
||||||
self.assertEqual(result, "OK")
|
bulk_edit.split(doc_ids, pages, delete_originals=True)
|
||||||
|
|
||||||
self.doc2.refresh_from_db()
|
self.doc2.refresh_from_db()
|
||||||
self.assertEqual(self.doc2.archive_serial_number, 222)
|
self.assertEqual(self.doc2.archive_serial_number, 222)
|
||||||
@@ -1191,7 +1193,8 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
pages = [[1, 2], [3]]
|
pages = [[1, 2], [3]]
|
||||||
|
|
||||||
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
|
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
|
||||||
bulk_edit.split(doc_ids, pages)
|
with self.assertRaisesMessage(Exception, "Error building PDFs"):
|
||||||
|
bulk_edit.split(doc_ids, pages)
|
||||||
error_str = cm.output[0]
|
error_str = cm.output[0]
|
||||||
expected_str = "Error splitting document 2"
|
expected_str = "Error splitting document 2"
|
||||||
self.assertIn(expected_str, error_str)
|
self.assertIn(expected_str, error_str)
|
||||||
@@ -1240,7 +1243,8 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
doc_ids = [self.doc2.id, self.doc3.id]
|
doc_ids = [self.doc2.id, self.doc3.id]
|
||||||
|
|
||||||
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
|
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
|
||||||
bulk_edit.rotate(doc_ids, 90)
|
with self.assertRaisesMessage(Exception, "Error rotating PDF"):
|
||||||
|
bulk_edit.rotate(doc_ids, 90)
|
||||||
error_str = cm.output[0]
|
error_str = cm.output[0]
|
||||||
expected_str = "Error rotating document"
|
expected_str = "Error rotating document"
|
||||||
self.assertIn(expected_str, error_str)
|
self.assertIn(expected_str, error_str)
|
||||||
@@ -1375,7 +1379,8 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
pages = [1, 3]
|
pages = [1, 3]
|
||||||
|
|
||||||
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
|
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
|
||||||
bulk_edit.delete_pages(doc_ids, pages)
|
with self.assertRaisesMessage(Exception, "Error removing pages"):
|
||||||
|
bulk_edit.delete_pages(doc_ids, pages)
|
||||||
error_str = cm.output[0]
|
error_str = cm.output[0]
|
||||||
expected_str = "Error deleting pages from document"
|
expected_str = "Error deleting pages from document"
|
||||||
self.assertIn(expected_str, error_str)
|
self.assertIn(expected_str, error_str)
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ def test_request_only_queues(bulk_setup, endpoint, operation, parameters):
|
|||||||
"document_ids": [doc.pk],
|
"document_ids": [doc.pk],
|
||||||
"parameters": parameters,
|
"parameters": parameters,
|
||||||
"user_id": user.pk,
|
"user_id": user.pk,
|
||||||
|
"trigger_source": "web_ui",
|
||||||
}
|
}
|
||||||
assert enqueue.call_args.kwargs["headers"]["trigger_source"] == "web_ui"
|
assert enqueue.call_args.kwargs["headers"]["trigger_source"] == "web_ui"
|
||||||
|
|
||||||
@@ -102,6 +103,119 @@ def test_permissions_rechecked_on_worker(bulk_setup):
|
|||||||
assert doc.correspondent_id is None
|
assert doc.correspondent_id is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_document_action_runs_with_user_in_worker(bulk_setup):
|
||||||
|
user, _, doc, _ = bulk_setup
|
||||||
|
with mock.patch("documents.bulk_edit.rotate") as rotate:
|
||||||
|
result = execute_bulk_operation(
|
||||||
|
"rotate",
|
||||||
|
[doc.pk],
|
||||||
|
{"degrees": 90},
|
||||||
|
user.pk,
|
||||||
|
trigger_source="web_ui",
|
||||||
|
)
|
||||||
|
rotate.assert_called_once_with(
|
||||||
|
[doc.pk],
|
||||||
|
degrees=90,
|
||||||
|
user=user,
|
||||||
|
trigger_source="web_ui",
|
||||||
|
)
|
||||||
|
assert result == {"operation": "rotate", "document_count": 1}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_document_action_queue_contains_only_serializable_parameters(bulk_setup):
|
||||||
|
user, client, doc, _ = bulk_setup
|
||||||
|
with mock.patch("documents.views.execute_bulk_operation.apply_async") as enqueue:
|
||||||
|
enqueue.return_value.id = "rotate-task"
|
||||||
|
response = client.post(
|
||||||
|
"/api/documents/rotate/",
|
||||||
|
{"documents": [doc.pk], "degrees": 90, "from_webui": True},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.data == {"result": "OK", "task_id": "rotate-task"}
|
||||||
|
assert enqueue.call_args.kwargs["kwargs"] == {
|
||||||
|
"operation": "rotate",
|
||||||
|
"document_ids": [doc.pk],
|
||||||
|
"parameters": {"degrees": 90, "source_mode": "latest_version"},
|
||||||
|
"user_id": user.pk,
|
||||||
|
"trigger_source": "web_ui",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_reprocess_keeps_bulk_audit_entry(bulk_setup, settings):
|
||||||
|
user, _, doc, _ = bulk_setup
|
||||||
|
settings.AUDIT_LOG_ENABLED = True
|
||||||
|
LogEntry.objects.all().delete()
|
||||||
|
with mock.patch("documents.bulk_edit.reprocess"):
|
||||||
|
execute_bulk_operation("reprocess", [doc.pk], {}, user.pk)
|
||||||
|
entry = LogEntry.objects.get_for_object(doc).get()
|
||||||
|
assert entry.changes_dict["checksum"] == [doc.checksum, doc.checksum]
|
||||||
|
assert entry.actor_id == user.pk
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_workflow_runs_in_tracked_task_instead_of_dispatching_another(bulk_setup):
|
||||||
|
user, _, doc, _ = bulk_setup
|
||||||
|
with mock.patch("documents.bulk_edit.run_workflows_for_documents") as run:
|
||||||
|
execute_bulk_operation(
|
||||||
|
"run_workflow",
|
||||||
|
[doc.pk],
|
||||||
|
{"workflow": 7, "filter_documents": False},
|
||||||
|
user.pk,
|
||||||
|
)
|
||||||
|
run.assert_called_once_with(
|
||||||
|
document_ids=[doc.pk],
|
||||||
|
workflow_id=7,
|
||||||
|
filter_documents=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_delete_failure_propagates_from_worker(bulk_setup):
|
||||||
|
user, _, doc, _ = bulk_setup
|
||||||
|
with mock.patch(
|
||||||
|
"documents.models.Document.delete",
|
||||||
|
side_effect=RuntimeError("delete failed"),
|
||||||
|
):
|
||||||
|
with pytest.raises(RuntimeError, match="delete failed"):
|
||||||
|
execute_bulk_operation("delete", [doc.pk], {}, user.pk)
|
||||||
|
assert Document.objects.filter(pk=doc.pk).exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"operation,parameters,preparation",
|
||||||
|
[
|
||||||
|
("rotate", {"degrees": 90}, "rotate_pdf"),
|
||||||
|
("split", {"pages": [[1]]}, "build_pdfs"),
|
||||||
|
("delete_pages", {"pages": [1]}, "remove_pages"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_preparation_failure_propagates_from_worker(
|
||||||
|
bulk_setup,
|
||||||
|
operation,
|
||||||
|
parameters,
|
||||||
|
preparation,
|
||||||
|
):
|
||||||
|
user, _, doc, _ = bulk_setup
|
||||||
|
doc.mime_type = "application/pdf"
|
||||||
|
doc.save()
|
||||||
|
with (
|
||||||
|
mock.patch(
|
||||||
|
f"documents.bulk_edit.pdf_ops.{preparation}",
|
||||||
|
side_effect=RuntimeError("preparation failed"),
|
||||||
|
),
|
||||||
|
mock.patch("documents.bulk_edit.consume_file.apply_async") as consume,
|
||||||
|
mock.patch("documents.bulk_edit.consume_file.s") as consume_signature,
|
||||||
|
pytest.raises(RuntimeError, match="preparation failed"),
|
||||||
|
):
|
||||||
|
execute_bulk_operation(operation, [doc.pk], parameters, user.pk)
|
||||||
|
consume.assert_not_called()
|
||||||
|
consume_signature.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_queue_failure_does_not_change_documents(bulk_setup):
|
def test_queue_failure_does_not_change_documents(bulk_setup):
|
||||||
_, client, doc, correspondent = bulk_setup
|
_, client, doc, correspondent = bulk_setup
|
||||||
@@ -162,6 +276,22 @@ def test_tracked_outcome_records_initiator_and_result(bulk_setup, failed):
|
|||||||
assert task.result_data == {"document_count": 1}
|
assert task.result_data == {"document_count": 1}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_queued_delete_is_tracked_as_bulk_delete(bulk_setup):
|
||||||
|
user, _, doc, _ = bulk_setup
|
||||||
|
before_task_publish_handler(
|
||||||
|
headers={"task": execute_bulk_operation.name, "id": "delete-task"},
|
||||||
|
body=(
|
||||||
|
(),
|
||||||
|
{"operation": "delete", "document_ids": [doc.pk], "user_id": user.pk},
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
task = PaperlessTask.objects.get(task_id="delete-task")
|
||||||
|
assert task.task_type == PaperlessTask.TaskType.BULK_DELETE
|
||||||
|
assert task.owner_id == user.pk
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_large_filtered_selection_is_one_task(bulk_setup):
|
def test_large_filtered_selection_is_one_task(bulk_setup):
|
||||||
_, client, _, _ = bulk_setup
|
_, client, _, _ = bulk_setup
|
||||||
@@ -284,24 +414,46 @@ async def test_completion_socket_is_private(anyio_backend):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@pytest.mark.parametrize("endpoint", ["delete", "bulk_edit"])
|
def test_legacy_trash_queues_without_deleting_in_request(bulk_setup):
|
||||||
def test_trash_remains_synchronous(bulk_setup, endpoint):
|
user, client, doc, _ = bulk_setup
|
||||||
_, client, doc, _ = bulk_setup
|
data = {"documents": [doc.pk], "method": "delete", "parameters": {}}
|
||||||
data = {"documents": [doc.pk]}
|
with mock.patch("documents.views.execute_bulk_operation.apply_async") as enqueue:
|
||||||
if endpoint == "bulk_edit":
|
enqueue.return_value.id = "delete-task"
|
||||||
data.update(method="delete", parameters={})
|
|
||||||
with (
|
|
||||||
mock.patch("documents.views.execute_bulk_operation.apply_async") as enqueue,
|
|
||||||
mock.patch("documents.search.get_backend"),
|
|
||||||
):
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/documents/{endpoint}/",
|
"/api/documents/bulk_edit/",
|
||||||
data,
|
data,
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_ACCEPT="application/json; version=9",
|
HTTP_ACCEPT="application/json; version=9",
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.data == {"result": "OK"}
|
assert response.data == {"result": "OK", "task_id": "delete-task"}
|
||||||
assert not Document.objects.filter(pk=doc.pk).exists()
|
assert Document.objects.filter(pk=doc.pk).exists()
|
||||||
assert Document.deleted_objects.filter(pk=doc.pk).exists()
|
assert enqueue.call_args.kwargs["kwargs"] == {
|
||||||
enqueue.assert_not_called()
|
"operation": "delete",
|
||||||
|
"document_ids": [doc.pk],
|
||||||
|
"parameters": {},
|
||||||
|
"user_id": user.pk,
|
||||||
|
"trigger_source": "api_upload",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_dedicated_trash_queues_without_deleting_in_request(bulk_setup):
|
||||||
|
user, client, doc, _ = bulk_setup
|
||||||
|
with mock.patch("documents.views.execute_bulk_operation.apply_async") as enqueue:
|
||||||
|
enqueue.return_value.id = "delete-task"
|
||||||
|
response = client.post(
|
||||||
|
"/api/documents/delete/",
|
||||||
|
{"documents": [doc.pk]},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data == {"result": "OK", "task_id": "delete-task"}
|
||||||
|
assert Document.objects.filter(pk=doc.pk).exists()
|
||||||
|
assert enqueue.call_args.kwargs["kwargs"] == {
|
||||||
|
"operation": "delete",
|
||||||
|
"document_ids": [doc.pk],
|
||||||
|
"parameters": {},
|
||||||
|
"user_id": user.pk,
|
||||||
|
"trigger_source": "api_upload",
|
||||||
|
}
|
||||||
@@ -69,7 +69,8 @@ class TestArchiver(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
|||||||
doc.save()
|
doc.save()
|
||||||
shutil.copy(sample_file, doc.source_path)
|
shutil.copy(sample_file, doc.source_path)
|
||||||
|
|
||||||
update_document_content_maybe_archive_file(doc.pk)
|
with self.assertRaisesMessage(ValueError, "No parser found"):
|
||||||
|
update_document_content_maybe_archive_file(doc.pk)
|
||||||
|
|
||||||
doc = Document.objects.get(id=doc.id)
|
doc = Document.objects.get(id=doc.id)
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from rest_framework import status
|
|||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from documents.bulk_edit import merge_as_versions
|
from documents.bulk_edit import merge_as_versions
|
||||||
|
from documents.bulk_operations import execute_bulk_operation
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.serialisers import MergeDocumentsAsVersionsSerializer
|
from documents.serialisers import MergeDocumentsAsVersionsSerializer
|
||||||
from paperless_testing.factories import UserFactory
|
from paperless_testing.factories import UserFactory
|
||||||
@@ -34,6 +35,7 @@ class TestMergeDocumentsAsVersionsSerializer(TestCase):
|
|||||||
{
|
{
|
||||||
"documents": [self.doc1.id, self.doc2.id, self.doc3.id],
|
"documents": [self.doc1.id, self.doc2.id, self.doc3.id],
|
||||||
"root_document_id": self.doc2.id,
|
"root_document_id": self.doc2.id,
|
||||||
|
"from_webui": False,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -103,67 +105,6 @@ class TestMergeDocumentsAsVersionsSerializer(TestCase):
|
|||||||
self.assertFalse(serializer.is_valid())
|
self.assertFalse(serializer.is_valid())
|
||||||
self.assertIn("documents", serializer.errors)
|
self.assertIn("documents", serializer.errors)
|
||||||
|
|
||||||
def test_rejects_selected_version(self) -> None:
|
|
||||||
version = Document.objects.create(
|
|
||||||
checksum="D",
|
|
||||||
title="D",
|
|
||||||
root_document=self.doc1,
|
|
||||||
version_index=1,
|
|
||||||
)
|
|
||||||
serializer = MergeDocumentsAsVersionsSerializer(
|
|
||||||
data={
|
|
||||||
"documents": [version.id, self.doc2.id],
|
|
||||||
"root_document_id": self.doc2.id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertFalse(serializer.is_valid())
|
|
||||||
self.assertEqual(
|
|
||||||
serializer.errors["non_field_errors"][0],
|
|
||||||
"Only top-level documents can be merged as versions.",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_rejects_source_document_with_versions(self) -> None:
|
|
||||||
Document.objects.create(
|
|
||||||
checksum="D",
|
|
||||||
title="D",
|
|
||||||
root_document=self.doc1,
|
|
||||||
version_index=1,
|
|
||||||
)
|
|
||||||
serializer = MergeDocumentsAsVersionsSerializer(
|
|
||||||
data={
|
|
||||||
"documents": [self.doc1.id, self.doc2.id],
|
|
||||||
"root_document_id": self.doc2.id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertFalse(serializer.is_valid())
|
|
||||||
self.assertEqual(
|
|
||||||
serializer.errors["non_field_errors"][0],
|
|
||||||
"Documents with existing versions cannot be merged into another document.",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_rejects_source_document_with_trashed_versions(self) -> None:
|
|
||||||
version = Document.objects.create(
|
|
||||||
checksum="D",
|
|
||||||
title="D",
|
|
||||||
root_document=self.doc1,
|
|
||||||
version_index=1,
|
|
||||||
)
|
|
||||||
version.delete() # trashed, but still points at doc1
|
|
||||||
serializer = MergeDocumentsAsVersionsSerializer(
|
|
||||||
data={
|
|
||||||
"documents": [self.doc1.id, self.doc2.id],
|
|
||||||
"root_document_id": self.doc2.id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertFalse(serializer.is_valid())
|
|
||||||
self.assertEqual(
|
|
||||||
serializer.errors["non_field_errors"][0],
|
|
||||||
"Documents with existing versions cannot be merged into another document.",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_allows_root_document_with_versions(self) -> None:
|
def test_allows_root_document_with_versions(self) -> None:
|
||||||
Document.objects.create(
|
Document.objects.create(
|
||||||
checksum="D",
|
checksum="D",
|
||||||
@@ -182,6 +123,95 @@ class TestMergeDocumentsAsVersionsSerializer(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestMergeDocumentsAsVersions(TestCase):
|
class TestMergeDocumentsAsVersions(TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.doc1 = Document.objects.create(checksum="test-A", title="A")
|
||||||
|
self.doc2 = Document.objects.create(checksum="test-B", title="B")
|
||||||
|
|
||||||
|
def test_rejects_selected_version(self) -> None:
|
||||||
|
version = Document.objects.create(
|
||||||
|
checksum="D",
|
||||||
|
title="D",
|
||||||
|
root_document=self.doc1,
|
||||||
|
version_index=1,
|
||||||
|
)
|
||||||
|
with self.assertRaisesMessage(
|
||||||
|
ValueError,
|
||||||
|
"Only top-level documents can be merged as versions.",
|
||||||
|
):
|
||||||
|
merge_as_versions(
|
||||||
|
[version.id, self.doc2.id],
|
||||||
|
root_document_id=self.doc2.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rejects_source_document_with_versions(self) -> None:
|
||||||
|
Document.objects.create(
|
||||||
|
checksum="D",
|
||||||
|
title="D",
|
||||||
|
root_document=self.doc1,
|
||||||
|
version_index=1,
|
||||||
|
)
|
||||||
|
with self.assertRaisesMessage(
|
||||||
|
ValueError,
|
||||||
|
"Documents with existing versions cannot be merged into another document.",
|
||||||
|
):
|
||||||
|
merge_as_versions(
|
||||||
|
[self.doc1.id, self.doc2.id],
|
||||||
|
root_document_id=self.doc2.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rejects_source_document_with_trashed_versions(self) -> None:
|
||||||
|
version = Document.objects.create(
|
||||||
|
checksum="D",
|
||||||
|
title="D",
|
||||||
|
root_document=self.doc1,
|
||||||
|
version_index=1,
|
||||||
|
)
|
||||||
|
version.delete() # trashed, but still points at doc1
|
||||||
|
with self.assertRaisesMessage(
|
||||||
|
ValueError,
|
||||||
|
"Documents with existing versions cannot be merged into another document.",
|
||||||
|
):
|
||||||
|
merge_as_versions(
|
||||||
|
[self.doc1.id, self.doc2.id],
|
||||||
|
root_document_id=self.doc2.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
@mock.patch("documents.bulk_edit.DocumentsStatusManager")
|
||||||
|
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
|
||||||
|
@mock.patch("documents.search.get_backend")
|
||||||
|
def test_rejects_source_that_gained_versions_after_validation(
|
||||||
|
self,
|
||||||
|
_get_backend_mock,
|
||||||
|
_bulk_update_mock,
|
||||||
|
_status_manager_mock,
|
||||||
|
) -> None:
|
||||||
|
destination = Document.objects.create(checksum="destination")
|
||||||
|
serializer = MergeDocumentsAsVersionsSerializer(
|
||||||
|
data={
|
||||||
|
"documents": [self.doc1.id, destination.id],
|
||||||
|
"root_document_id": destination.id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||||
|
|
||||||
|
merge_as_versions(
|
||||||
|
[self.doc1.id, self.doc2.id],
|
||||||
|
root_document_id=self.doc1.id,
|
||||||
|
)
|
||||||
|
with self.assertRaisesMessage(
|
||||||
|
ValueError,
|
||||||
|
"Documents with existing versions cannot be merged into another document.",
|
||||||
|
):
|
||||||
|
merge_as_versions(
|
||||||
|
serializer.validated_data["documents"],
|
||||||
|
root_document_id=serializer.validated_data["root_document_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.doc1.refresh_from_db()
|
||||||
|
self.doc2.refresh_from_db()
|
||||||
|
self.assertIsNone(self.doc1.root_document_id)
|
||||||
|
self.assertEqual(self.doc2.root_document_id, self.doc1.id)
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.DocumentsStatusManager")
|
@mock.patch("documents.bulk_edit.DocumentsStatusManager")
|
||||||
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
|
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
|
||||||
@mock.patch("documents.search.get_backend")
|
@mock.patch("documents.search.get_backend")
|
||||||
@@ -348,6 +378,17 @@ class TestMergeDocumentsAsVersionsAPI(APITestCase):
|
|||||||
owner=self.user,
|
owner=self.user,
|
||||||
)
|
)
|
||||||
self.client.force_authenticate(user=self.user)
|
self.client.force_authenticate(user=self.user)
|
||||||
|
enqueue = mock.patch(
|
||||||
|
"documents.views.execute_bulk_operation.apply_async",
|
||||||
|
side_effect=lambda *, kwargs, headers: self.run_operation(kwargs),
|
||||||
|
)
|
||||||
|
enqueue.start()
|
||||||
|
self.addCleanup(enqueue.stop)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def run_operation(kwargs):
|
||||||
|
execute_bulk_operation(**kwargs)
|
||||||
|
return mock.Mock(id="test-bulk-operation")
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.merge_as_versions")
|
@mock.patch("documents.views.bulk_edit.merge_as_versions")
|
||||||
def test_merges_documents_as_versions(self, merge_mock) -> None:
|
def test_merges_documents_as_versions(self, merge_mock) -> None:
|
||||||
@@ -367,7 +408,10 @@ class TestMergeDocumentsAsVersionsAPI(APITestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(response.data, {"result": "OK"})
|
self.assertEqual(
|
||||||
|
response.data,
|
||||||
|
{"result": "OK", "task_id": "test-bulk-operation"},
|
||||||
|
)
|
||||||
merge_mock.assert_called_once_with(
|
merge_mock.assert_called_once_with(
|
||||||
[self.doc1.id, self.doc2.id],
|
[self.doc1.id, self.doc2.id],
|
||||||
root_document_id=self.doc2.id,
|
root_document_id=self.doc2.id,
|
||||||
@@ -440,8 +484,9 @@ class TestMergeDocumentsAsVersionsAPI(APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
merge_mock.assert_not_called()
|
merge_mock.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.merge_as_versions")
|
@mock.patch("documents.views.execute_bulk_operation.apply_async")
|
||||||
def test_rejects_source_document_with_versions(self, merge_mock) -> None:
|
def test_queues_source_document_with_versions(self, enqueue_mock) -> None:
|
||||||
|
enqueue_mock.return_value.id = "queued-merge"
|
||||||
Document.objects.create(
|
Document.objects.create(
|
||||||
checksum="C",
|
checksum="C",
|
||||||
title="C",
|
title="C",
|
||||||
@@ -459,8 +504,9 @@ class TestMergeDocumentsAsVersionsAPI(APITestCase):
|
|||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
merge_mock.assert_not_called()
|
enqueue_mock.assert_called_once()
|
||||||
|
self.assertEqual(response.data["task_id"], "queued-merge")
|
||||||
self.doc1.refresh_from_db()
|
self.doc1.refresh_from_db()
|
||||||
self.assertIsNone(self.doc1.root_document_id)
|
self.assertIsNone(self.doc1.root_document_id)
|
||||||
|
|
||||||
|
|||||||
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user