mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-11-15 02:22:46 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79e8970b60 | ||
|
|
e21a334881 |
No files matched your search
+2
-8
@@ -285,14 +285,8 @@ The API supports various bulk-editing operations which are executed asynchronous
|
||||
|
||||
### Documents
|
||||
|
||||
Document bulk operations return `{"result":"OK","task_id":"…"}` when queued.
|
||||
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:
|
||||
For bulk operations on documents, use the endpoint `/api/documents/bulk_edit/` which accepts
|
||||
a json payload of the format:
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
+5
-31
@@ -493,7 +493,7 @@ fields and permissions, which will be merged.
|
||||
|
||||
#### Types {#workflow-trigger-types}
|
||||
|
||||
Currently, there are five events that correspond to workflow trigger 'types':
|
||||
Currently, there are four events that correspond to workflow trigger 'types':
|
||||
|
||||
1. **Consumption Started**: _before_ a document is consumed, so events can include filters by source (mail, consumption
|
||||
folder or API), file path, file name, mail rule
|
||||
@@ -505,10 +505,8 @@ Currently, there are five events that correspond to workflow trigger 'types':
|
||||
4. **Scheduled**: a scheduled trigger that can be used to run workflows at a specific time. The date used can be either the document
|
||||
added, created, updated date or you can specify a (date) custom field. You can also specify a day offset from the date (positive
|
||||
offsets will trigger after the date, negative offsets will trigger before).
|
||||
5. **Manual**: a trigger that only runs when you explicitly choose to run the workflow against one or more existing documents. See
|
||||
[running a workflow manually](#running-a-workflow-manually) below.
|
||||
|
||||
The following flow diagram illustrates the document trigger types:
|
||||
The following flow diagram illustrates the four document trigger types:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
@@ -528,10 +526,6 @@ flowchart TD
|
||||
matching
|
||||
trigger(s)"}
|
||||
|
||||
manual{"Matching
|
||||
'Manual'
|
||||
trigger(s)"}
|
||||
|
||||
A[New Document] --> consumption
|
||||
consumption --> |Yes| C[Workflow Actions Run]
|
||||
consumption --> |No| D
|
||||
@@ -549,10 +543,6 @@ flowchart TD
|
||||
scheduled --> |Yes| N[Workflow Actions Run]
|
||||
scheduled --> |No| O[Document Saved]
|
||||
N --> O
|
||||
P[User Runs Workflow] --> manual
|
||||
manual --> |Yes| Q[Workflow Actions Run]
|
||||
manual --> |No| R[Document Saved]
|
||||
Q --> R
|
||||
```
|
||||
|
||||
#### Filters {#workflow-trigger-filters}
|
||||
@@ -564,9 +554,9 @@ Workflows allow you to filter by:
|
||||
- File path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for
|
||||
example, automatically assigning documents to different owners based on the upload directory.
|
||||
- Mail rule. Choosing this option will force 'mail fetch' to be the workflow source.
|
||||
- Content matching (`Added`, `Updated`, `Scheduled` and `Manual` triggers only). Filter document content using the matching settings.
|
||||
- Content matching (`Added`, `Updated` and `Scheduled` triggers only). Filter document content using the matching settings.
|
||||
|
||||
There are also 'advanced' filters available for `Added`, `Updated`, `Scheduled` and `Manual` triggers:
|
||||
There are also 'advanced' filters available for `Added`, `Updated` and `Scheduled` triggers:
|
||||
|
||||
- Any Tags: Filter for documents with any of the specified tags.
|
||||
- All Tags: Filter for documents with all of the specified tags.
|
||||
@@ -579,22 +569,6 @@ There are also 'advanced' filters available for `Added`, `Updated`, `Scheduled`
|
||||
- Not Storage paths: Filter documents without any of these storage paths.
|
||||
- Custom field query: Filter documents with a custom field query (the same as used for the document list filters).
|
||||
|
||||
#### Running a workflow manually {#running-a-workflow-manually}
|
||||
|
||||
A workflow with a **Manual** trigger does not run automatically. Instead, it can be run on demand against existing
|
||||
documents from the web UI:
|
||||
|
||||
- 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.
|
||||
|
||||
You will be asked which workflow to run. From the document list, the workflow's trigger filters apply by default, so
|
||||
documents that do not match are skipped. You can choose to **run on all selected documents** to ignore the filters.
|
||||
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
|
||||
they finish, so you may need to reload the list to see the results.
|
||||
|
||||
### Workflow Actions
|
||||
|
||||
#### Types {#workflow-action-types}
|
||||
@@ -675,7 +649,7 @@ new version of the same document, this time with normal OCR/text extraction appl
|
||||
a password-protected file added with this trigger will briefly exist as an un-OCR'd version before
|
||||
the properly processed version is created.
|
||||
|
||||
**Document Added**, **Document Updated**, **Scheduled**, **Manual**: these triggers run against a document that
|
||||
**Document Added**, **Document Updated**, **Scheduled**: these triggers run against a document that
|
||||
already exists, so password removal happens immediately: the decrypted content is queued for
|
||||
consumption as a new version right away. Note that if the document's initial consumption also
|
||||
happened while it was still encrypted, that original version will likewise be missing OCR text.
|
||||
|
||||
+10
-3
@@ -71,7 +71,7 @@ dependencies = [
|
||||
"scikit-learn~=1.9.0",
|
||||
"sentence-transformers>=5.6.1",
|
||||
"setproctitle~=1.3.4",
|
||||
"sqlite-vec==0.1.9",
|
||||
"sqlite-vec==0.1.10+paperless.3",
|
||||
"tantivy~=0.26.0",
|
||||
"tika-client[httpx]~=1.0",
|
||||
"torch>=2.13,<2.15",
|
||||
@@ -151,8 +151,9 @@ typing = [
|
||||
required-version = ">=0.9.0"
|
||||
prerelease = "disallow"
|
||||
environments = [
|
||||
"sys_platform == 'darwin'",
|
||||
"sys_platform == 'linux'",
|
||||
"sys_platform == 'darwin' and platform_machine == 'arm64'",
|
||||
"sys_platform == 'linux' and platform_machine == 'aarch64'",
|
||||
"sys_platform == 'linux' and platform_machine == 'x86_64'",
|
||||
]
|
||||
package = false
|
||||
[tool.uv.sources]
|
||||
@@ -163,6 +164,12 @@ psycopg-c = [
|
||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.4/psycopg_c-3.3.4-cp314-cp314-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.14'" },
|
||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.4/psycopg_c-3.3.4-cp314-cp314-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.14'" },
|
||||
]
|
||||
# Patched sqlite-vec builds
|
||||
sqlite-vec = [
|
||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/sqlite-vec-0.1.10%2Bpaperless.3/sqlite_vec-0.1.10%2Bpaperless.3-py3-none-manylinux_2_29_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" },
|
||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/sqlite-vec-0.1.10%2Bpaperless.3/sqlite_vec-0.1.10%2Bpaperless.3-py3-none-manylinux_2_29_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64'" },
|
||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/sqlite-vec-0.1.10%2Bpaperless.3/sqlite_vec-0.1.10%2Bpaperless.3-py3-none-macosx_11_0_arm64.whl", marker = "sys_platform == 'darwin' and platform_machine == 'arm64'" },
|
||||
]
|
||||
torch = [
|
||||
{ index = "pytorch-cpu" },
|
||||
]
|
||||
|
||||
+219
-367
File diff suppressed because it is too large.
Load diff
@@ -175,13 +175,7 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="overflow-auto name-col">
|
||||
@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>{{ taskDisplayName(task) }}</div>
|
||||
<div class="small text-muted">
|
||||
@if (taskShowsSeparateTypeLabel(task)) {
|
||||
<span class="me-2">{{ task.task_type_display }}</span>
|
||||
|
||||
@@ -895,117 +895,6 @@ describe('TasksComponent', () => {
|
||||
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', () => {
|
||||
component.setSection(TaskSection.NeedsAttention)
|
||||
component.filterTargetID = 1
|
||||
|
||||
@@ -33,10 +33,6 @@ import { IfPermissionsDirective } from 'src/app/directives/if-permissions.direct
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { TasksService } from 'src/app/services/tasks.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 { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||
@@ -418,30 +414,11 @@ export class TasksComponent
|
||||
}
|
||||
|
||||
taskDisplayName(task: PaperlessTask): string {
|
||||
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
|
||||
return task.input_data?.filename?.toString() || task.task_type_display
|
||||
}
|
||||
|
||||
taskShowsSeparateTypeLabel(task: PaperlessTask): boolean {
|
||||
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
|
||||
return this.taskDisplayName(task) !== task.task_type_display
|
||||
}
|
||||
|
||||
taskResultMessage(task: PaperlessTask): string | null {
|
||||
@@ -469,15 +446,6 @@ export class TasksComponent
|
||||
return errorMessage
|
||||
}
|
||||
|
||||
if (
|
||||
task.status === PaperlessTaskStatus.Success &&
|
||||
this.taskOperationLabel(task)
|
||||
) {
|
||||
return documentOperationHasFollowUpTasks(task.input_data?.operation)
|
||||
? $localize`Submitted for processing`
|
||||
: $localize`Completed`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ import { RemoteVersionService } from 'src/app/services/rest/remote-version.servi
|
||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||
import { SearchService } from 'src/app/services/rest/search.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 { ToastService } from 'src/app/services/toast.service'
|
||||
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
|
||||
@@ -199,14 +198,14 @@ describe('AppFrameComponent', () => {
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('refreshes and updates the task notification on completion', () => {
|
||||
it('refreshes and notifies on completion without client-side task tracking', () => {
|
||||
const list = TestBed.inject(DocumentListViewService)
|
||||
const reload = jest.spyOn(list, 'reload').mockImplementation(() => {})
|
||||
jest.spyOn(list, 'reduceSelectionToFilter').mockImplementation(() => {})
|
||||
const tasksReload = jest
|
||||
.spyOn(tasksService, 'reload')
|
||||
.mockImplementation(() => {})
|
||||
const success = jest.spyOn(toastService, 'show')
|
||||
const success = jest.spyOn(toastService, 'showSuccess')
|
||||
const refresh = jest
|
||||
.spyOn(openDocumentsService, 'refreshDocument')
|
||||
.mockImplementation(() => {})
|
||||
@@ -231,12 +230,7 @@ describe('AppFrameComponent', () => {
|
||||
expect(refresh).toHaveBeenCalledWith(document.id)
|
||||
expect(maybeRefreshSpy).toHaveBeenCalledTimes(1)
|
||||
expect(tasksReload).toHaveBeenCalledTimes(1)
|
||||
expect(success).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'task-bulk-task',
|
||||
content: "Operation 'Set permissions' completed.",
|
||||
})
|
||||
)
|
||||
expect(success).toHaveBeenCalledWith('Bulk operation completed.')
|
||||
fixture.destroy()
|
||||
bulkEvents.next({
|
||||
task_id: 'bulk-task',
|
||||
@@ -251,14 +245,14 @@ describe('AppFrameComponent', () => {
|
||||
const reload = jest.spyOn(list, 'reload').mockImplementation(() => {})
|
||||
jest.spyOn(list, 'reduceSelectionToFilter').mockImplementation(() => {})
|
||||
jest.spyOn(tasksService, 'reload').mockImplementation(() => {})
|
||||
const error = jest.spyOn(toastService, 'show')
|
||||
const error = jest.spyOn(toastService, 'showError')
|
||||
const success = jest.spyOn(toastService, 'showSuccess')
|
||||
for (const status of [
|
||||
PaperlessTaskStatus.Failure,
|
||||
PaperlessTaskStatus.Revoked,
|
||||
]) {
|
||||
bulkEvents.next({
|
||||
task_id: `${status}-task`,
|
||||
task_id: 'bulk-task',
|
||||
operation: 'set_permissions',
|
||||
status,
|
||||
error:
|
||||
@@ -268,36 +262,11 @@ describe('AppFrameComponent', () => {
|
||||
})
|
||||
}
|
||||
expect(reload).toHaveBeenCalledTimes(2)
|
||||
expect(error).toHaveBeenCalledWith(expect.any(String), 'Could not update')
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
content:
|
||||
"Operation 'Set permissions' failed. Some changes may already have been applied.",
|
||||
error: 'Could not update',
|
||||
})
|
||||
'Bulk operation revoked.',
|
||||
'Task expired before execution.'
|
||||
)
|
||||
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()
|
||||
})
|
||||
|
||||
|
||||
@@ -57,7 +57,6 @@ import {
|
||||
} from 'src/app/services/rest/remote-version.service'
|
||||
import { SavedViewService } from 'src/app/services/rest/saved-view.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 { ToastService } from 'src/app/services/toast.service'
|
||||
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
|
||||
@@ -116,7 +115,6 @@ export class AppFrameComponent
|
||||
settingsService = inject(SettingsService)
|
||||
tasksService = inject(TasksService)
|
||||
private readonly toastService = inject(ToastService)
|
||||
private readonly taskNotifications = inject(TaskNotificationService)
|
||||
private modalService = inject(NgbModal)
|
||||
permissionsService = inject(PermissionsService)
|
||||
private djangoMessagesService = inject(DjangoMessagesService)
|
||||
@@ -184,7 +182,7 @@ export class AppFrameComponent
|
||||
),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe((result) => {
|
||||
.subscribe(({ status, error }) => {
|
||||
this.documentList.reload(null, false)
|
||||
this.documentList.reduceSelectionToFilter()
|
||||
// maybe someday we can optimize to only refresh affected documents
|
||||
@@ -200,7 +198,16 @@ export class AppFrameComponent
|
||||
) {
|
||||
this.tasksService.reload()
|
||||
}
|
||||
this.taskNotifications.complete(result)
|
||||
if (status === PaperlessTaskStatus.Success) {
|
||||
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
|
||||
|
||||
@@ -118,7 +118,6 @@ import personFill from 'bootstrap-icons/icons/person-fill.svg'
|
||||
import personLock from 'bootstrap-icons/icons/person-lock.svg'
|
||||
import personSquare from 'bootstrap-icons/icons/person-square.svg'
|
||||
import person from 'bootstrap-icons/icons/person.svg'
|
||||
import playCircle from 'bootstrap-icons/icons/play-circle.svg'
|
||||
import playFill from 'bootstrap-icons/icons/play-fill.svg'
|
||||
import plusCircle from 'bootstrap-icons/icons/plus-circle.svg'
|
||||
import plus from 'bootstrap-icons/icons/plus.svg'
|
||||
@@ -280,7 +279,6 @@ export const icons: Record<string, string> = {
|
||||
personFillLock,
|
||||
personLock,
|
||||
personSquare,
|
||||
playCircle,
|
||||
playFill,
|
||||
plus,
|
||||
plusCircle,
|
||||
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (message) {
|
||||
<p class="text-break" [innerHTML]="message"></p>
|
||||
}
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="runWorkflowSelect" i18n>Workflow</label>
|
||||
<select class="form-select" id="runWorkflowSelect" [(ngModel)]="workflowId" (ngModelChange)="workflowSelected()">
|
||||
<option [ngValue]="null" disabled i18n>Select a workflow</option>
|
||||
@for (workflow of workflows; track workflow.id) {
|
||||
<option [ngValue]="workflow.id">{{workflow.name}}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="runWorkflowForce" [(ngModel)]="force" />
|
||||
@if (singleDocument) {
|
||||
<label class="form-check-label" for="runWorkflowForce" i18n>Run on this document regardless of trigger filters</label>
|
||||
<div class="form-text" i18n>Ignore the workflow's trigger filters for this document.</div>
|
||||
} @else {
|
||||
<label class="form-check-label" for="runWorkflowForce" i18n>Run on all selected documents</label>
|
||||
<div class="form-text" i18n>Ignore the workflow's trigger filters and run on every selected document.</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
|
||||
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
||||
</button>
|
||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
|
||||
{{btnCaption}}
|
||||
</button>
|
||||
</div>
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { RunWorkflowConfirmDialogComponent } from './run-workflow-confirm-dialog.component'
|
||||
|
||||
describe('RunWorkflowConfirmDialogComponent', () => {
|
||||
let component: RunWorkflowConfirmDialogComponent
|
||||
let fixture: ComponentFixture<RunWorkflowConfirmDialogComponent>
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [NgbActiveModal],
|
||||
imports: [RunWorkflowConfirmDialogComponent],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(RunWorkflowConfirmDialogComponent)
|
||||
component = fixture.componentInstance
|
||||
component.workflows = [{ id: 5, name: 'Manual workflow' } as any]
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should disable confirm until a workflow is selected and not force by default', () => {
|
||||
expect(component.confirmButtonEnabled).toBeFalsy()
|
||||
expect(component.force).toBeFalsy()
|
||||
|
||||
component.workflowId = 5
|
||||
component.workflowSelected()
|
||||
expect(component.confirmButtonEnabled).toBeTruthy()
|
||||
|
||||
component.workflowId = null
|
||||
component.workflowSelected()
|
||||
expect(component.confirmButtonEnabled).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should render an option per workflow', () => {
|
||||
const options = fixture.nativeElement.querySelectorAll(
|
||||
'#runWorkflowSelect option'
|
||||
)
|
||||
// placeholder option + one per workflow
|
||||
expect(options).toHaveLength(2)
|
||||
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'
|
||||
)
|
||||
})
|
||||
})
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { Workflow } from 'src/app/data/workflow'
|
||||
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-run-workflow-confirm-dialog',
|
||||
templateUrl: './run-workflow-confirm-dialog.component.html',
|
||||
imports: [FormsModule],
|
||||
})
|
||||
export class RunWorkflowConfirmDialogComponent extends ConfirmDialogComponent {
|
||||
public workflows: Workflow[] = []
|
||||
|
||||
public workflowId: number = null
|
||||
|
||||
public singleDocument: boolean = false
|
||||
|
||||
// i.e. ignore the workflow's trigger filters
|
||||
public force: boolean = false
|
||||
|
||||
override confirmButtonEnabled = false
|
||||
|
||||
public workflowSelected(): void {
|
||||
this.confirmButtonEnabled = !!this.workflowId
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -164,7 +164,7 @@
|
||||
<pngx-input-text i18n-title title="Filter path" formControlName="filter_path" horizontal="true" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case-normalized." [error]="error?.filter_path"></pngx-input-text>
|
||||
<pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules()" horizontal="true" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select>
|
||||
}
|
||||
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled || formGroup.get('type').value === WorkflowTriggerType.Manual) {
|
||||
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
|
||||
<pngx-input-select i18n-title title="Content matching algorithm" horizontal="true" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
||||
@if (matchingPatternRequired(formGroup)) {
|
||||
<pngx-input-text i18n-title title="Content matching pattern" horizontal="true" formControlName="match" [error]="error?.match"></pngx-input-text>
|
||||
@@ -175,7 +175,7 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled || formGroup.get('type').value === WorkflowTriggerType.Manual) {
|
||||
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
|
||||
<div class="row mt-3">
|
||||
<div class="col">
|
||||
<div class="trigger-filters mb-3">
|
||||
|
||||
-4
@@ -123,10 +123,6 @@ export const WORKFLOW_TYPE_OPTIONS = [
|
||||
id: WorkflowTriggerType.Scheduled,
|
||||
name: $localize`Scheduled`,
|
||||
},
|
||||
{
|
||||
id: WorkflowTriggerType.Manual,
|
||||
name: $localize`Manual`,
|
||||
},
|
||||
]
|
||||
|
||||
export const WORKFLOW_ACTION_OPTIONS = [
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
background-color: var(--pngx-toast-bg);
|
||||
color: var(--bs-body-color);
|
||||
overflow: hidden;
|
||||
transition: background-color .18s ease, border-color .18s ease;
|
||||
|
||||
&.toast-success {
|
||||
--pngx-toast-accent: var(--pngx-toast-success);
|
||||
@@ -24,13 +23,6 @@
|
||||
|
||||
.toast-icon {
|
||||
color: var(--pngx-toast-accent);
|
||||
transition: color .18s ease;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast, .toast-icon {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.toast-content {
|
||||
|
||||
@@ -65,36 +65,6 @@ describe('ToastComponent', () => {
|
||||
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', () => {
|
||||
jest.useFakeTimers()
|
||||
const toast = { content: 'Info', delay: 3000 }
|
||||
|
||||
@@ -5,10 +5,8 @@ import {
|
||||
DestroyRef,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnInit,
|
||||
Output,
|
||||
SimpleChanges,
|
||||
ViewChild,
|
||||
inject,
|
||||
signal,
|
||||
@@ -36,10 +34,10 @@ import { Toast, ToastLevel } from 'src/app/services/toast.service'
|
||||
styleUrl: './toast.component.scss',
|
||||
providers: [DatePipe, CustomDatePipe],
|
||||
})
|
||||
export class ToastComponent implements OnInit, OnChanges {
|
||||
private readonly clipboard = inject(Clipboard)
|
||||
private readonly customDatePipe = inject(CustomDatePipe)
|
||||
private readonly destroyRef = inject(DestroyRef)
|
||||
export class ToastComponent implements OnInit {
|
||||
private clipboard = inject(Clipboard)
|
||||
private customDatePipe = inject(CustomDatePipe)
|
||||
private destroyRef = inject(DestroyRef)
|
||||
readonly copied = signal(false)
|
||||
readonly ToastLevel = ToastLevel
|
||||
readonly delayRemaining = signal<number>(null)
|
||||
@@ -71,12 +69,6 @@ export class ToastComponent implements OnInit, OnChanges {
|
||||
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 {
|
||||
return this.customDatePipe.transform(this.toast.timestamp, 'relative')
|
||||
}
|
||||
|
||||
@@ -68,9 +68,9 @@ describe('ToastsComponent', () => {
|
||||
toasts.forEach((t) => toastSubject.next(t))
|
||||
expect(component.toasts().map((t) => t.id)).toEqual(['3', '2', '1'])
|
||||
|
||||
// Updating a visible toast preserves its position without duplicating it.
|
||||
// showing a visible toast again moves it to the top without duplicating
|
||||
toastSubject.next(toasts[1])
|
||||
expect(component.toasts().map((t) => t.id)).toEqual(['3', '2', '1'])
|
||||
expect(component.toasts().map((t) => t.id)).toEqual(['1', '3', '2'])
|
||||
})
|
||||
|
||||
it('should remove a popup when it hides without closing it', () => {
|
||||
|
||||
@@ -32,11 +32,10 @@ export class ToastsComponent {
|
||||
return
|
||||
}
|
||||
this.toasts.update((toasts) =>
|
||||
toasts.some((existing) => existing.id === toast.id)
|
||||
? toasts.map((existing) =>
|
||||
existing.id === toast.id ? toast : existing
|
||||
)
|
||||
: [toast, ...toasts].slice(0, MAX_VISIBLE_TOASTS)
|
||||
[toast, ...toasts.filter((t) => t.id !== toast.id)].slice(
|
||||
0,
|
||||
MAX_VISIBLE_TOASTS
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -49,12 +49,6 @@
|
||||
<pngx-bs-icon width="1em" height="1em" name="arrow-counterclockwise" class="me-2"></pngx-bs-icon><span i18n>Reprocess</span>
|
||||
</button>
|
||||
|
||||
@if (manualWorkflows().length > 0) {
|
||||
<button ngbDropdownItem (click)="runWorkflow()" [disabled]="!userCanEdit || !userIsOwner">
|
||||
<pngx-bs-icon width="1em" height="1em" name="play-circle" class="me-2"></pngx-bs-icon><span i18n>Run workflow</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
<button ngbDropdownItem (click)="printDocument()" [hidden]="useNativePdfViewer || isMobile">
|
||||
<pngx-bs-icon width="1em" height="1em" name="printer" class="me-2"></pngx-bs-icon><span i18n>Print</span>
|
||||
</button>
|
||||
|
||||
@@ -1013,85 +1013,27 @@ describe('DocumentDetailComponent', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('should queue reprocess, show the task link and close the modal', () => {
|
||||
jest.useFakeTimers()
|
||||
it('should support reprocess, confirm and close modal after started', () => {
|
||||
initNormally()
|
||||
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
|
||||
reprocessSpy.mockReturnValue(of({ result: 'OK', task_id: 'reprocess-1' }))
|
||||
reprocessSpy.mockReturnValue(of(true))
|
||||
let openModal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
||||
const modalSpy = jest.spyOn(modalService, 'open')
|
||||
const toastSpy = jest.spyOn(toastService, 'show')
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
component.reprocess()
|
||||
const modalCloseSpy = jest.spyOn(openModal, 'close')
|
||||
openModal.componentInstance.confirmClicked.next()
|
||||
expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] }, false)
|
||||
expect(modalSpy).toHaveBeenCalled()
|
||||
expect(toastSpy).not.toHaveBeenCalled()
|
||||
expect(toastSpy).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', () => {
|
||||
jest.useFakeTimers()
|
||||
initNormally()
|
||||
const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit')
|
||||
bulkEditSpy.mockReturnValue(of({ result: 'OK', task_id: 'workflow-1' }))
|
||||
let openModal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
||||
const toastSpy = jest.spyOn(toastService, 'show')
|
||||
component.runWorkflow()
|
||||
expect(openModal.componentInstance.singleDocument).toBe(true)
|
||||
expect(openModal.componentInstance.force).toBe(true)
|
||||
const modalCloseSpy = jest.spyOn(openModal, 'close')
|
||||
openModal.componentInstance.workflowId = 5
|
||||
openModal.componentInstance.confirmClicked.next()
|
||||
expect(bulkEditSpy).toHaveBeenCalledWith(
|
||||
{ documents: [doc.id] },
|
||||
'run_workflow',
|
||||
{ workflow: 5, filter_documents: false }
|
||||
)
|
||||
jest.advanceTimersByTime(400)
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ content: 'Workflow queued.' })
|
||||
)
|
||||
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', () => {
|
||||
initNormally()
|
||||
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
|
||||
reprocessSpy.mockReturnValue(of({ result: 'OK', task_id: 'reprocess-2' }))
|
||||
reprocessSpy.mockReturnValue(of(true))
|
||||
let openModal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
||||
component.reprocess()
|
||||
@@ -2020,12 +1962,10 @@ describe('DocumentDetailComponent', () => {
|
||||
})
|
||||
|
||||
it('should support pdf editor, handle error', () => {
|
||||
jest.useFakeTimers()
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||
const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument')
|
||||
const errorSpy = jest.spyOn(toastService, 'showError')
|
||||
const queuedToastSpy = jest.spyOn(toastService, 'show')
|
||||
initNormally()
|
||||
component.selectedVersionId.set(10)
|
||||
component.editPdf()
|
||||
@@ -2044,7 +1984,6 @@ describe('DocumentDetailComponent', () => {
|
||||
update_document: false,
|
||||
include_metadata: true,
|
||||
source_mode: 'explicit_selection',
|
||||
from_webui: true,
|
||||
})
|
||||
req.error(new ErrorEvent('failed'))
|
||||
expect(errorSpy).toHaveBeenCalled()
|
||||
@@ -2057,21 +1996,13 @@ describe('DocumentDetailComponent', () => {
|
||||
req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/edit_pdf/`
|
||||
)
|
||||
req.flush({ result: 'OK', task_id: 'edit-pdf-1' })
|
||||
jest.advanceTimersByTime(400)
|
||||
expect(closeSpy).not.toHaveBeenCalled()
|
||||
expect(queuedToastSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ content: 'PDF edit queued.' })
|
||||
)
|
||||
jest.useRealTimers()
|
||||
req.flush(true)
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support removing password protection from pdfs', () => {
|
||||
jest.useFakeTimers()
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||
const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument')
|
||||
const queuedToastSpy = jest.spyOn(toastService, 'show')
|
||||
initNormally()
|
||||
component.selectedVersionId.set(10)
|
||||
component.password = 'secret'
|
||||
@@ -2092,15 +2023,8 @@ describe('DocumentDetailComponent', () => {
|
||||
include_metadata: false,
|
||||
delete_original: true,
|
||||
source_mode: 'explicit_selection',
|
||||
from_webui: 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()
|
||||
req.flush(true)
|
||||
})
|
||||
|
||||
it('should require the current password before removing it', () => {
|
||||
@@ -2152,9 +2076,9 @@ describe('DocumentDetailComponent', () => {
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/remove_password/`
|
||||
)
|
||||
req.flush({ result: 'OK', task_id: 'remove-password-2' })
|
||||
req.flush(true)
|
||||
|
||||
expect(refreshSpy).not.toHaveBeenCalled()
|
||||
expect(refreshSpy).toHaveBeenCalledWith(doc.id)
|
||||
})
|
||||
|
||||
it('should support keyboard shortcuts', () => {
|
||||
|
||||
@@ -68,8 +68,6 @@ import { StoragePath } from 'src/app/data/storage-path'
|
||||
import { Tag } from 'src/app/data/tag'
|
||||
import { SETTINGS_KEYS, SuggestionSource } from 'src/app/data/ui-settings'
|
||||
import { User } from 'src/app/data/user'
|
||||
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 { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
||||
@@ -95,9 +93,7 @@ import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { WorkflowService } from 'src/app/services/rest/workflow.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 { TrashService } from 'src/app/services/trash.service'
|
||||
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
|
||||
@@ -108,7 +104,6 @@ import { DocumentDetailFieldID } from '../admin/settings/settings.component'
|
||||
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
||||
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
|
||||
import { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
|
||||
import { RunWorkflowConfirmDialogComponent } from '../common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component'
|
||||
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
|
||||
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||
@@ -227,7 +222,6 @@ export class DocumentDetailComponent
|
||||
private documentListViewService = inject(DocumentListViewService)
|
||||
private documentTitlePipe = inject(DocumentTitlePipe)
|
||||
private toastService = inject(ToastService)
|
||||
private taskNotifications = inject(TaskNotificationService)
|
||||
private trashService = inject(TrashService)
|
||||
private settings = inject(SettingsService)
|
||||
private storagePathService = inject(StoragePathService)
|
||||
@@ -239,7 +233,6 @@ export class DocumentDetailComponent
|
||||
private componentRouterService = inject(ComponentRouterService)
|
||||
private deviceDetectorService = inject(DeviceDetectorService)
|
||||
private savedViewService = inject(SavedViewService)
|
||||
private readonly workflowService = inject(WorkflowService)
|
||||
private readonly websocketStatusService = inject(WebsocketStatusService)
|
||||
private readonly useNativePdfViewerSetting = this.settings.getSignal<boolean>(
|
||||
SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER
|
||||
@@ -302,7 +295,6 @@ export class DocumentDetailComponent
|
||||
readonly correspondents = signal<Correspondent[]>(undefined)
|
||||
readonly documentTypes = signal<DocumentType[]>(undefined)
|
||||
readonly storagePaths = signal<StoragePath[]>(undefined)
|
||||
readonly manualWorkflows = signal<Workflow[]>([])
|
||||
|
||||
documentForm: FormGroup = new FormGroup({
|
||||
title: new FormControl(''),
|
||||
@@ -844,20 +836,6 @@ export class DocumentDetailComponent
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((result) => this.users.set(result.results))
|
||||
}
|
||||
if (
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.Workflow
|
||||
)
|
||||
) {
|
||||
this.workflowService
|
||||
.list(1, 100000, null, null, {
|
||||
trigger_type: WorkflowTriggerType.Manual,
|
||||
enabled: true,
|
||||
})
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((result) => this.manualWorkflows.set(result.results))
|
||||
}
|
||||
|
||||
this.getCustomFields()
|
||||
|
||||
@@ -1560,11 +1538,9 @@ export class DocumentDetailComponent
|
||||
modal.componentInstance.remoteOcr
|
||||
)
|
||||
.subscribe({
|
||||
next: ({ task_id }) => {
|
||||
this.taskNotifications.showQueued(
|
||||
task_id,
|
||||
$localize`Document reprocessing queued.`,
|
||||
{ content: $localize`Document submitted for reprocessing.` }
|
||||
next: () => {
|
||||
this.toastService.showInfo(
|
||||
$localize`Reprocess operation for "${this.document().title}" will begin in the background.`
|
||||
)
|
||||
if (modal) {
|
||||
modal.close()
|
||||
@@ -1583,45 +1559,6 @@ export class DocumentDetailComponent
|
||||
})
|
||||
}
|
||||
|
||||
runWorkflow() {
|
||||
const modal = this.modalService.open(RunWorkflowConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.title = $localize`Run workflow`
|
||||
modal.componentInstance.message = $localize`Run a workflow on "${this.document().title}".`
|
||||
modal.componentInstance.btnCaption = $localize`Run`
|
||||
modal.componentInstance.workflows = this.manualWorkflows()
|
||||
modal.componentInstance.singleDocument = true
|
||||
modal.componentInstance.force = true
|
||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
this.documentsService
|
||||
.bulkEdit({ documents: [this.document().id] }, 'run_workflow', {
|
||||
workflow: modal.componentInstance.workflowId,
|
||||
filter_documents: !modal.componentInstance.force,
|
||||
})
|
||||
.subscribe({
|
||||
next: ({ task_id }) => {
|
||||
this.taskNotifications.showQueued(
|
||||
task_id,
|
||||
$localize`Workflow queued.`,
|
||||
{
|
||||
content: $localize`Workflow completed.`,
|
||||
}
|
||||
)
|
||||
modal.close()
|
||||
},
|
||||
error: (error) => {
|
||||
modal.componentInstance.buttonsEnabled.set(true)
|
||||
this.toastService.showError(
|
||||
$localize`Error executing operation`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private getSelectedNonLatestVersionId(): number | null {
|
||||
const versions = this.document()?.versions ?? []
|
||||
if (!versions.length || !this.selectedVersionId()) {
|
||||
@@ -1997,15 +1934,14 @@ export class DocumentDetailComponent
|
||||
})
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: ({ task_id }) => {
|
||||
this.taskNotifications.showQueued(
|
||||
task_id,
|
||||
$localize`PDF edit queued.`,
|
||||
{
|
||||
content: $localize`PDF edit submitted for processing.`,
|
||||
}
|
||||
next: () => {
|
||||
this.toastService.showInfo(
|
||||
$localize`PDF edit operation for "${this.document().title}" will begin in the background.`
|
||||
)
|
||||
modal.close()
|
||||
if (modal.componentInstance.deleteOriginal) {
|
||||
this.openDocumentService.closeDocument(this.document())
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
if (modal) {
|
||||
@@ -2055,16 +1991,17 @@ export class DocumentDetailComponent
|
||||
})
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: ({ task_id }) => {
|
||||
this.taskNotifications.showQueued(
|
||||
task_id,
|
||||
$localize`Password removal queued.`,
|
||||
{
|
||||
content: $localize`Password removal submitted for processing.`,
|
||||
}
|
||||
next: () => {
|
||||
this.toastService.showInfo(
|
||||
$localize`Password removal operation for "${this.document().title}" will begin in the background.`
|
||||
)
|
||||
this.networkActive.set(false)
|
||||
modal.close()
|
||||
if (!dialog.updateDocument && dialog.deleteOriginal) {
|
||||
this.openDocumentService.closeDocument(this.document())
|
||||
} else if (dialog.updateDocument) {
|
||||
this.openDocumentService.refreshDocument(this.documentId())
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
dialog.buttonsEnabled.set(true)
|
||||
|
||||
+14
-23
@@ -2,7 +2,6 @@ import { DatePipe } from '@angular/common'
|
||||
import { SimpleChange, signal } from '@angular/core'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { Router } from '@angular/router'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { Subject, of, throwError } from 'rxjs'
|
||||
import { DocumentVersionInfo } from 'src/app/data/document'
|
||||
@@ -34,7 +33,7 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
>
|
||||
>
|
||||
let toastService: jest.Mocked<
|
||||
Pick<ToastService, 'show' | 'showError' | 'showInfo' | 'showSuccess'>
|
||||
Pick<ToastService, 'showError' | 'showInfo' | 'showSuccess'>
|
||||
>
|
||||
let finished$: Subject<{ taskId: string }>
|
||||
let failed$: Subject<{ taskId: string; message?: string }>
|
||||
@@ -55,7 +54,6 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
}
|
||||
modalService = { open: jest.fn() }
|
||||
toastService = {
|
||||
show: jest.fn(),
|
||||
showError: jest.fn(),
|
||||
showInfo: jest.fn(),
|
||||
showSuccess: jest.fn(),
|
||||
@@ -90,10 +88,6 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
provide: PermissionsService,
|
||||
useValue: permissionsService,
|
||||
},
|
||||
{
|
||||
provide: Router,
|
||||
useValue: { navigate: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: WebsocketStatusService,
|
||||
useValue: {
|
||||
@@ -383,8 +377,7 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
expect(component.versionLabelDraft).toEqual('')
|
||||
})
|
||||
|
||||
it('addExistingDocumentAsVersion should queue with a label and wait to refresh versions', () => {
|
||||
jest.useFakeTimers()
|
||||
it('addExistingDocumentAsVersion should merge with a label and refresh versions', () => {
|
||||
const confirmClicked = new Subject<number>()
|
||||
const modal = {
|
||||
componentInstance: {
|
||||
@@ -395,9 +388,14 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
close: jest.fn(),
|
||||
}
|
||||
modalService.open.mockReturnValue(modal as any)
|
||||
documentService.mergeDocumentsAsVersions.mockReturnValue(
|
||||
of({ result: 'OK', task_id: 'versions-1' })
|
||||
)
|
||||
documentService.mergeDocumentsAsVersions.mockReturnValue(of({} as any))
|
||||
// Newest first, as the API returns them. The merged document has a lower id
|
||||
// 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 '
|
||||
const versionsEmitSpy = jest.spyOn(component.versionsUpdated, 'emit')
|
||||
const selectedEmitSpy = jest.spyOn(component.versionSelected, 'emit')
|
||||
@@ -412,18 +410,11 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
'Imported'
|
||||
)
|
||||
expect(documentService.updateVersionLabel).not.toHaveBeenCalled()
|
||||
expect(documentService.getVersions).not.toHaveBeenCalled()
|
||||
expect(versionsEmitSpy).not.toHaveBeenCalled()
|
||||
expect(selectedEmitSpy).not.toHaveBeenCalled()
|
||||
expect(documentService.getVersions).toHaveBeenCalledWith(3)
|
||||
expect(versionsEmitSpy).toHaveBeenCalledWith(versions)
|
||||
expect(selectedEmitSpy).toHaveBeenCalledWith(2)
|
||||
expect(component.newVersionLabel).toEqual('')
|
||||
expect(modal.close).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()
|
||||
expect(toastService.showSuccess).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
+9
-7
@@ -28,7 +28,6 @@ import { DocumentVersionInfo } from 'src/app/data/document'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
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 {
|
||||
UploadState,
|
||||
@@ -78,7 +77,6 @@ export class DocumentVersionDropdownComponent
|
||||
private readonly toastService = inject(ToastService)
|
||||
private readonly websocketStatusService = inject(WebsocketStatusService)
|
||||
private readonly modalService = inject(NgbModal)
|
||||
private readonly taskNotifications = inject(TaskNotificationService)
|
||||
private readonly destroy$ = new Subject<void>()
|
||||
private readonly documentChange$ = new Subject<void>()
|
||||
|
||||
@@ -307,19 +305,23 @@ export class DocumentVersionDropdownComponent
|
||||
versionLabel
|
||||
)
|
||||
.pipe(
|
||||
switchMap(() => this.documentsService.getVersions(this.documentId)),
|
||||
first(),
|
||||
finalize(() => dialog.buttonsEnabled.set(true)),
|
||||
takeUntil(this.destroy$),
|
||||
takeUntil(this.documentChange$)
|
||||
)
|
||||
.subscribe({
|
||||
next: ({ task_id }) => {
|
||||
next: (document) => {
|
||||
if (document?.versions?.length) {
|
||||
this.versionsUpdated.emit(document.versions)
|
||||
// The API returns versions newest first
|
||||
this.versionSelected.emit(document.versions[0].id)
|
||||
}
|
||||
this.newVersionLabel = ''
|
||||
modal.close()
|
||||
this.taskNotifications.showQueued(
|
||||
task_id,
|
||||
$localize`Existing document queued to be added as a version.`,
|
||||
{ content: $localize`Existing document added as a version.` }
|
||||
this.toastService.showSuccess(
|
||||
$localize`Existing document added as a version.`
|
||||
)
|
||||
},
|
||||
error: (error) => {
|
||||
|
||||
@@ -98,11 +98,6 @@
|
||||
<button ngbDropdownItem (click)="mergeSelectedAsVersions()" [disabled]="!userOwnsAll || !userCanEditAll || !userCanDelete || list.allSelected || list.selectedCount < 2">
|
||||
<pngx-bs-icon name="journal-bookmark-fill" class="me-2"></pngx-bs-icon><ng-container i18n>Merge as versions</ng-container>
|
||||
</button>
|
||||
@if (manualWorkflows.length > 0) {
|
||||
<button ngbDropdownItem (click)="runWorkflowSelected()" [disabled]="!canRunWorkflow">
|
||||
<pngx-bs-icon name="play-circle" class="me-2"></pngx-bs-icon><ng-container i18n>Run workflow</ng-container>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+29
-168
@@ -14,11 +14,9 @@ import { Correspondent } from 'src/app/data/correspondent'
|
||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
import { DocumentType } from 'src/app/data/document-type'
|
||||
import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
|
||||
import { PaperlessTaskStatus } from 'src/app/data/paperless-task'
|
||||
import { Results, SelectionData } from 'src/app/data/results'
|
||||
import { Results } from 'src/app/data/results'
|
||||
import { StoragePath } from 'src/app/data/storage-path'
|
||||
import { Tag } from 'src/app/data/tag'
|
||||
import { WorkflowTriggerType } from 'src/app/data/workflow-trigger'
|
||||
import { FilterPipe } from 'src/app/pipes/filter.pipe'
|
||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||
import {
|
||||
@@ -29,15 +27,16 @@ import {
|
||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import {
|
||||
DocumentService,
|
||||
SelectionData,
|
||||
} from 'src/app/services/rest/document.service'
|
||||
import { GroupService } from 'src/app/services/rest/group.service'
|
||||
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { WorkflowService } from 'src/app/services/rest/workflow.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 { TrashService } from 'src/app/services/trash.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
@@ -84,7 +83,6 @@ describe('BulkEditorComponent', () => {
|
||||
let customFieldsService: CustomFieldsService
|
||||
let httpTestingController: HttpTestingController
|
||||
let shareLinkBundleService: ShareLinkBundleService
|
||||
let workflowService: WorkflowService
|
||||
let router: Router
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -175,22 +173,6 @@ describe('BulkEditorComponent', () => {
|
||||
delete: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: WorkflowService,
|
||||
useValue: {
|
||||
list: jest.fn(() =>
|
||||
of({
|
||||
results: [
|
||||
{
|
||||
id: 5,
|
||||
name: 'Manual workflow',
|
||||
triggers: [{ type: WorkflowTriggerType.Manual }],
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: Router,
|
||||
useValue: { navigate: jest.fn().mockResolvedValue(true) },
|
||||
@@ -213,7 +195,6 @@ describe('BulkEditorComponent', () => {
|
||||
customFieldsService = TestBed.inject(CustomFieldsService)
|
||||
httpTestingController = TestBed.inject(HttpTestingController)
|
||||
shareLinkBundleService = TestBed.inject(ShareLinkBundleService)
|
||||
workflowService = TestBed.inject(WorkflowService)
|
||||
router = TestBed.inject(Router)
|
||||
|
||||
fixture = TestBed.createComponent(BulkEditorComponent)
|
||||
@@ -673,81 +654,6 @@ describe('BulkEditorComponent', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should load manual workflows on init', () => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
fixture.detectChanges()
|
||||
expect(workflowService.list).toHaveBeenCalledWith(1, 100000, null, null, {
|
||||
trigger_type: WorkflowTriggerType.Manual,
|
||||
enabled: true,
|
||||
})
|
||||
expect(component.manualWorkflows).toHaveLength(1)
|
||||
expect(component.manualWorkflows[0].id).toEqual(5)
|
||||
})
|
||||
|
||||
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
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
jest
|
||||
.spyOn(documentListViewService, 'documents', 'get')
|
||||
.mockReturnValue([{ id: 3 }, { id: 4 }])
|
||||
jest
|
||||
.spyOn(documentListViewService, 'selected', 'get')
|
||||
.mockReturnValue(new Set([3, 4]))
|
||||
fixture.detectChanges()
|
||||
component.runWorkflowSelected()
|
||||
expect(modal).not.toBeUndefined()
|
||||
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.force = true
|
||||
modal.componentInstance.confirm()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
)
|
||||
expect(req.request.body.method).toEqual('run_workflow')
|
||||
expect(req.request.body.parameters).toEqual({
|
||||
workflow: 5,
|
||||
filter_documents: false,
|
||||
})
|
||||
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(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
) // list reload
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||
) // listAllFilteredIds
|
||||
})
|
||||
|
||||
it('should 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', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
@@ -1266,15 +1172,14 @@ describe('BulkEditorComponent', () => {
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/delete/`
|
||||
)
|
||||
const trackSpy = jest
|
||||
.spyOn(trashService, 'trackMovedToTrash')
|
||||
const notifySpy = jest
|
||||
.spyOn(trashService, 'notifyMovedToTrash')
|
||||
.mockImplementation(() => {})
|
||||
req.flush({ result: 'OK', task_id: 'delete-1' })
|
||||
req.flush(true)
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [3, 4],
|
||||
from_webui: true,
|
||||
})
|
||||
expect(trackSpy).toHaveBeenCalledWith('delete-1', 2, [3, 4])
|
||||
expect(notifySpy).toHaveBeenCalledWith(expect.any(String), [3, 4])
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
) // list reload
|
||||
@@ -1291,9 +1196,9 @@ describe('BulkEditorComponent', () => {
|
||||
req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/delete/`
|
||||
)
|
||||
req.flush({ result: 'OK', task_id: 'delete-2' })
|
||||
req.flush(true)
|
||||
// ids aren't known when all matching documents are selected, so no undo
|
||||
expect(trackSpy).toHaveBeenLastCalledWith('delete-2', 0, null)
|
||||
expect(notifySpy).toHaveBeenLastCalledWith(expect.any(String), null)
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
) // list reload
|
||||
@@ -1373,11 +1278,10 @@ describe('BulkEditorComponent', () => {
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/reprocess/`
|
||||
)
|
||||
req.flush({ result: 'OK', task_id: 'reprocess-1' })
|
||||
req.flush(true)
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [3, 4],
|
||||
remote_ocr: false,
|
||||
from_webui: true,
|
||||
})
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
@@ -1388,7 +1292,6 @@ describe('BulkEditorComponent', () => {
|
||||
})
|
||||
|
||||
it('should support rotate', () => {
|
||||
const toast = jest.spyOn(toastService, 'show')
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
@@ -1409,20 +1312,11 @@ describe('BulkEditorComponent', () => {
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/rotate/`
|
||||
)
|
||||
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."
|
||||
)
|
||||
req.flush(true)
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [3, 4],
|
||||
degrees: 90,
|
||||
source_mode: 'latest_version',
|
||||
from_webui: true,
|
||||
})
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
@@ -1460,11 +1354,10 @@ describe('BulkEditorComponent', () => {
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/merge/`
|
||||
)
|
||||
req.flush({ result: 'OK', task_id: 'merge-1' })
|
||||
req.flush(true)
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [3, 4],
|
||||
metadata_document_id: 3,
|
||||
from_webui: true,
|
||||
})
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
@@ -1479,12 +1372,11 @@ describe('BulkEditorComponent', () => {
|
||||
req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/merge/`
|
||||
)
|
||||
req.flush({ result: 'OK', task_id: 'merge-2' })
|
||||
req.flush(true)
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [3, 4],
|
||||
metadata_document_id: 3,
|
||||
delete_originals: true,
|
||||
from_webui: true,
|
||||
})
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
@@ -1501,12 +1393,11 @@ describe('BulkEditorComponent', () => {
|
||||
req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/merge/`
|
||||
)
|
||||
req.flush({ result: 'OK', task_id: 'merge-3' })
|
||||
req.flush(true)
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [3, 4],
|
||||
metadata_document_id: 3,
|
||||
archive_fallback: true,
|
||||
from_webui: true,
|
||||
})
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
@@ -1518,7 +1409,6 @@ describe('BulkEditorComponent', () => {
|
||||
})
|
||||
|
||||
it('should support merging documents as versions', () => {
|
||||
jest.useFakeTimers()
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
@@ -1546,8 +1436,8 @@ describe('BulkEditorComponent', () => {
|
||||
.mockReturnValue(true)
|
||||
const mergeAsVersionsSpy = jest
|
||||
.spyOn(documentService, 'mergeDocumentsAsVersions')
|
||||
.mockReturnValue(of({ result: 'OK', task_id: 'versions-1' }))
|
||||
const toastSpy = jest.spyOn(toastService, 'show')
|
||||
.mockReturnValue(of(true))
|
||||
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
|
||||
fixture.detectChanges()
|
||||
|
||||
component.mergeSelectedAsVersions()
|
||||
@@ -1563,21 +1453,9 @@ describe('BulkEditorComponent', () => {
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||
)
|
||||
expect(documentListViewService.selected.size).toEqual(0)
|
||||
jest.advanceTimersByTime(400)
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
content: "Bulk operation 'Merge as versions' queued.",
|
||||
})
|
||||
expect(toastSuccessSpy).toHaveBeenCalledWith(
|
||||
'Documents merged as versions.'
|
||||
)
|
||||
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', () => {
|
||||
@@ -1668,22 +1546,12 @@ describe('BulkEditorComponent', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.each<[boolean, number]>([
|
||||
[true, 2],
|
||||
[false, 2],
|
||||
[true, 1],
|
||||
])(
|
||||
'names queued work and gates the task link (permission %s, documents %s)',
|
||||
(canViewTasks, count) => {
|
||||
jest.useFakeTimers()
|
||||
it.each([true, false])(
|
||||
'shows queued feedback and gates the task link on permission (%s)',
|
||||
(canViewTasks) => {
|
||||
jest
|
||||
.spyOn(permissionsService, 'currentUserCan')
|
||||
.mockReturnValue(canViewTasks)
|
||||
jest
|
||||
.spyOn(documentListViewService, 'selected', 'get')
|
||||
.mockReturnValue(
|
||||
new Set(Array.from({ length: count }, (_, index) => index + 3))
|
||||
)
|
||||
const queued = jest.spyOn(toastService, 'show')
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((instances) => {
|
||||
@@ -1701,11 +1569,8 @@ describe('BulkEditorComponent', () => {
|
||||
)
|
||||
req.flush({ result: 'OK', task_id: 'queued-operation' })
|
||||
expect(close).toHaveBeenCalledTimes(1)
|
||||
expect(queued).not.toHaveBeenCalled()
|
||||
jest.advanceTimersByTime(400)
|
||||
const notification = queued.mock.calls.at(-1)[0]
|
||||
const prefix = count > 1 ? 'Bulk operation' : 'Operation'
|
||||
expect(notification.content).toBe(`${prefix} \'Set permissions\' queued.`)
|
||||
expect(notification.content).toBe('Bulk operation queued.')
|
||||
if (canViewTasks) {
|
||||
expect(notification.actionName).toBe('View task')
|
||||
notification.action()
|
||||
@@ -1719,15 +1584,6 @@ describe('BulkEditorComponent', () => {
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
expect(documentListViewService.selected.size).toBe(0)
|
||||
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()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1808,6 +1664,7 @@ describe('BulkEditorComponent', () => {
|
||||
{ id: 2, name: 'Tag 2' },
|
||||
],
|
||||
count: 2,
|
||||
all: [1, 2],
|
||||
}
|
||||
|
||||
const modalInstance = {
|
||||
@@ -1850,6 +1707,7 @@ describe('BulkEditorComponent', () => {
|
||||
{ id: 2, name: 'Correspondent 2' },
|
||||
],
|
||||
count: 2,
|
||||
all: [1, 2],
|
||||
}
|
||||
|
||||
const modalInstance = {
|
||||
@@ -1898,6 +1756,7 @@ describe('BulkEditorComponent', () => {
|
||||
{ id: 2, name: 'Document Type 2' },
|
||||
],
|
||||
count: 2,
|
||||
all: [1, 2],
|
||||
}
|
||||
|
||||
const modalInstance = {
|
||||
@@ -1943,6 +1802,7 @@ describe('BulkEditorComponent', () => {
|
||||
{ id: 2, name: 'Storage Path 2' },
|
||||
],
|
||||
count: 2,
|
||||
all: [1, 2],
|
||||
}
|
||||
|
||||
const modalInstance = {
|
||||
@@ -1996,6 +1856,7 @@ describe('BulkEditorComponent', () => {
|
||||
},
|
||||
],
|
||||
count: 2,
|
||||
all: [1, 2],
|
||||
}
|
||||
|
||||
const modalInstance = {
|
||||
|
||||
@@ -19,18 +19,24 @@ import {
|
||||
NgbModalRef,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { saveAs } from 'file-saver'
|
||||
import { first, map, Observable, Subject, switchMap, takeUntil } from 'rxjs'
|
||||
import {
|
||||
first,
|
||||
map,
|
||||
Observable,
|
||||
Subject,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
tap,
|
||||
} from 'rxjs'
|
||||
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 { BulkOperationResponse } from 'src/app/data/bulk-operation'
|
||||
import { CustomField } from 'src/app/data/custom-field'
|
||||
import { MatchingModel } from 'src/app/data/matching-model'
|
||||
import { SelectionDataItem } from 'src/app/data/results'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
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 { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
||||
import {
|
||||
PermissionAction,
|
||||
PermissionsService,
|
||||
@@ -45,25 +51,19 @@ import {
|
||||
DocumentService,
|
||||
MergeDocumentsRequest,
|
||||
} 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 { StoragePathService } from 'src/app/services/rest/storage-path.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 { TaskNotificationService } from 'src/app/services/task-notification.service'
|
||||
import { ToastService } from 'src/app/services/toast.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 { 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 { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
||||
import { ReprocessConfirmDialogComponent } from '../../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
|
||||
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
||||
import { RunWorkflowConfirmDialogComponent } from '../../common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component'
|
||||
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||
@@ -106,18 +106,16 @@ export class BulkEditorComponent
|
||||
private documentService = inject(DocumentService)
|
||||
private trashService = inject(TrashService)
|
||||
private modalService = inject(NgbModal)
|
||||
private openDocumentService = inject(OpenDocumentsService)
|
||||
private settings = inject(SettingsService)
|
||||
private toastService = inject(ToastService)
|
||||
private taskNotifications = inject(TaskNotificationService)
|
||||
private storagePathService = inject(StoragePathService)
|
||||
private customFieldService = inject(CustomFieldsService)
|
||||
public readonly permissionService = inject(PermissionsService)
|
||||
private savedViewService = inject(SavedViewService)
|
||||
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
|
||||
private readonly workflowService = inject(WorkflowService)
|
||||
private readonly router = inject(Router)
|
||||
|
||||
public manualWorkflows: Workflow[] = []
|
||||
|
||||
tagSelectionModel = new FilterableDropdownSelectionModel(true)
|
||||
correspondentSelectionModel = new FilterableDropdownSelectionModel()
|
||||
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
|
||||
@@ -260,20 +258,6 @@ export class BulkEditorComponent
|
||||
(result) => (this.customFieldsSelectionModel.items = result.results)
|
||||
)
|
||||
}
|
||||
if (
|
||||
this.permissionService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.Workflow
|
||||
)
|
||||
) {
|
||||
this.workflowService
|
||||
.list(1, 100000, null, null, {
|
||||
trigger_type: WorkflowTriggerType.Manual,
|
||||
enabled: true,
|
||||
})
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.manualWorkflows = result.results))
|
||||
}
|
||||
|
||||
this.downloadForm
|
||||
.get('downloadFileTypeArchive')
|
||||
@@ -302,43 +286,6 @@ export class BulkEditorComponent
|
||||
this.unsubscribeNotifier.complete()
|
||||
}
|
||||
|
||||
get canRunWorkflow(): boolean {
|
||||
return (
|
||||
this.userCanEditAll && this.userOwnsAll && this.manualWorkflows.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
runWorkflowSelected() {
|
||||
const modal = this.modalService.open(RunWorkflowConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.title = $localize`Run workflow`
|
||||
modal.componentInstance.message = $localize`Run a workflow on ${this.getSelectionSize()} selected document(s).`
|
||||
modal.componentInstance.btnCaption = $localize`Run`
|
||||
modal.componentInstance.workflows = this.manualWorkflows
|
||||
modal.componentInstance.confirmClicked
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
this.documentService
|
||||
.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),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private executeBulkEditMethod(
|
||||
modal: NgbModalRef,
|
||||
method: DocumentBulkEditMethod,
|
||||
@@ -352,62 +299,70 @@ export class BulkEditorComponent
|
||||
.bulkEdit(overrideSelection ?? this.getSelectionQuery(), method, args)
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: ({ task_id }) =>
|
||||
this.handleOperationQueued(modal, task_id, method),
|
||||
next: ({ task_id }) => this.handleOperationQueued(modal, task_id),
|
||||
error: (error) => this.handleOperationError(modal, error),
|
||||
})
|
||||
}
|
||||
|
||||
private handleOperationQueued(
|
||||
modal: NgbModalRef,
|
||||
taskId: string,
|
||||
operation: string,
|
||||
onQueued?: (taskId: string) => void
|
||||
) {
|
||||
const bulk = this.getSelectionSize() > 1
|
||||
private handleOperationQueued(modal: NgbModalRef, taskId: string) {
|
||||
modal?.close()
|
||||
this.list.selectNone()
|
||||
if (onQueued) {
|
||||
onQueued(taskId)
|
||||
} else {
|
||||
const label = getDocumentOperationLabel(operation)
|
||||
const submittedMessage = bulk
|
||||
? $localize`Bulk operation '${label}' submitted for processing.`
|
||||
: $localize`Operation '${label}' submitted for processing.`
|
||||
const completedMessage = bulk
|
||||
? $localize`Bulk operation '${label}' completed.`
|
||||
: $localize`Operation '${label}' completed.`
|
||||
this.taskNotifications.showQueued(
|
||||
taskId,
|
||||
bulk
|
||||
? $localize`Bulk operation '${label}' queued.`
|
||||
: $localize`Operation '${label}' queued.`,
|
||||
{
|
||||
content: documentOperationHasFollowUpTasks(operation)
|
||||
? submittedMessage
|
||||
: completedMessage,
|
||||
}
|
||||
)
|
||||
}
|
||||
const canViewTasks = this.permissionService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.PaperlessTask
|
||||
)
|
||||
this.toastService.show({
|
||||
content: $localize`Bulk operation queued.`,
|
||||
delay: 5000,
|
||||
...(canViewTasks
|
||||
? {
|
||||
actionName: $localize`View task`,
|
||||
action: () =>
|
||||
this.router.navigate(['/tasks'], {
|
||||
queryParams: { task_id: taskId },
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
|
||||
private executeDocumentAction(
|
||||
modal: NgbModalRef,
|
||||
operation: string,
|
||||
request: Observable<BulkOperationResponse>,
|
||||
onQueued?: (taskId: string) => void
|
||||
request: Observable<any>,
|
||||
options: { clearSelection?: boolean; successMessage?: string } = {}
|
||||
) {
|
||||
if (modal) {
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
}
|
||||
request.pipe(first()).subscribe({
|
||||
next: ({ task_id }) => {
|
||||
this.handleOperationQueued(modal, task_id, operation, onQueued)
|
||||
next: () => {
|
||||
this.handleOperationSuccess(modal, options.clearSelection ?? false)
|
||||
if (options.successMessage) {
|
||||
this.toastService.showSuccess(options.successMessage)
|
||||
}
|
||||
},
|
||||
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) {
|
||||
if (modal) {
|
||||
modal.componentInstance.buttonsEnabled.set(true)
|
||||
@@ -975,9 +930,16 @@ export class BulkEditorComponent
|
||||
const count = this.getSelectionSize()
|
||||
this.executeDocumentAction(
|
||||
modal,
|
||||
'delete',
|
||||
this.documentService.deleteDocuments(this.getSelectionQuery()),
|
||||
(taskId) => this.trashService.trackMovedToTrash(taskId, count, documents)
|
||||
this.documentService
|
||||
.deleteDocuments(this.getSelectionQuery())
|
||||
.pipe(
|
||||
tap(() =>
|
||||
this.trashService.notifyMovedToTrash(
|
||||
$localize`Moved ${count} document(s) to the trash`,
|
||||
documents
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1018,7 +980,6 @@ export class BulkEditorComponent
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
this.executeDocumentAction(
|
||||
modal,
|
||||
'reprocess',
|
||||
this.documentService.reprocessDocuments(
|
||||
this.getSelectionQuery(),
|
||||
modal.componentInstance.remoteOcr
|
||||
@@ -1059,7 +1020,6 @@ export class BulkEditorComponent
|
||||
rotateDialog.buttonsEnabled.set(false)
|
||||
this.executeDocumentAction(
|
||||
modal,
|
||||
'rotate',
|
||||
this.documentService.rotateDocuments(
|
||||
this.getSelectionQuery(),
|
||||
rotateDialog.degrees
|
||||
@@ -1093,8 +1053,11 @@ export class BulkEditorComponent
|
||||
mergeDialog.buttonsEnabled.set(false)
|
||||
this.executeDocumentAction(
|
||||
modal,
|
||||
'merge',
|
||||
this.documentService.mergeDocuments(mergeDialog.documentIDs(), args)
|
||||
this.documentService.mergeDocuments(mergeDialog.documentIDs(), args),
|
||||
{ clearSelection: !!args.delete_originals }
|
||||
)
|
||||
this.toastService.showInfo(
|
||||
$localize`Merged document will be queued for consumption.`
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1116,11 +1079,14 @@ export class BulkEditorComponent
|
||||
.subscribe(() => {
|
||||
this.executeDocumentAction(
|
||||
modal,
|
||||
'merge_as_versions',
|
||||
this.documentService.mergeDocumentsAsVersions(
|
||||
mergeDialog.documentIDs(),
|
||||
mergeDialog.rootDocumentID()
|
||||
)
|
||||
),
|
||||
{
|
||||
clearSelection: true,
|
||||
successMessage: $localize`Documents merged as versions.`,
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1145,7 +1111,7 @@ export class BulkEditorComponent
|
||||
dialog.selection = this.getSelectionQuery()
|
||||
dialog.selectionCount = this.getSelectionSize()
|
||||
dialog.succeeded.subscribe(({ task_id }) =>
|
||||
this.handleOperationQueued(null, task_id, 'modify_custom_fields')
|
||||
this.handleOperationQueued(null, task_id)
|
||||
)
|
||||
dialog.failed.subscribe((error) => {
|
||||
this.toastService.showError(
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@
|
||||
</pngx-input-select>
|
||||
<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-permissions-form [users]="users()" accordion="true" formControlName="permissions_form"></pngx-permissions-form>
|
||||
<pngx-permissions-form accordion="true" formControlName="permissions_form"></pngx-permissions-form>
|
||||
@if (error()?.filter_rules) {
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<h6 class="alert-heading" i18n>Filter rules error occurred while saving this view</h6>
|
||||
|
||||
+1
-16
@@ -3,7 +3,6 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { NgbActiveModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { of } from 'rxjs'
|
||||
import { User } from 'src/app/data/user'
|
||||
import { GroupService } from 'src/app/services/rest/group.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { CheckComponent } from '../../common/input/check/check.component'
|
||||
@@ -15,7 +14,6 @@ import { TextComponent } from '../../common/input/text/text.component'
|
||||
import { SaveViewConfigDialogComponent } from './save-view-config-dialog.component'
|
||||
|
||||
describe('SaveViewConfigDialogComponent', () => {
|
||||
const users = [{ id: 10, username: 'user10' }] as User[]
|
||||
let component: SaveViewConfigDialogComponent
|
||||
let fixture: ComponentFixture<SaveViewConfigDialogComponent>
|
||||
let modal: NgbActiveModal
|
||||
@@ -27,7 +25,7 @@ describe('SaveViewConfigDialogComponent', () => {
|
||||
{
|
||||
provide: UserService,
|
||||
useValue: {
|
||||
listAll: () => of({ results: users }),
|
||||
listAll: () => of({ results: [] }),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -126,19 +124,6 @@ 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', () => {
|
||||
const saveClickedSpy = jest.spyOn(component.saveClicked, 'emit')
|
||||
const modalCloseSpy = jest.spyOn(modal, 'close')
|
||||
|
||||
+1
-16
@@ -6,7 +6,6 @@ import {
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core'
|
||||
import { toSignal } from '@angular/core/rxjs-interop'
|
||||
import {
|
||||
FormControl,
|
||||
FormGroup,
|
||||
@@ -14,14 +13,11 @@ import {
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { catchError, map, of } from 'rxjs'
|
||||
import {
|
||||
DEFAULT_SAVED_VIEW_ICON,
|
||||
SAVED_VIEW_ICONS,
|
||||
} from 'src/app/data/saved-view-icons'
|
||||
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 { PermissionsFormComponent } from '../../common/input/permissions/permissions-form/permissions-form.component'
|
||||
import { SelectComponent } from '../../common/input/select/select.component'
|
||||
@@ -42,8 +38,6 @@ import { TextComponent } from '../../common/input/text/text.component'
|
||||
})
|
||||
export class SaveViewConfigDialogComponent implements OnInit {
|
||||
private modal = inject(NgbActiveModal)
|
||||
private userService = inject(UserService)
|
||||
private toastService = inject(ToastService)
|
||||
readonly error = signal(undefined)
|
||||
readonly buttonsEnabled = signal(true)
|
||||
readonly defaultName = signal('')
|
||||
@@ -52,16 +46,7 @@ export class SaveViewConfigDialogComponent implements OnInit {
|
||||
@Output()
|
||||
public saveClicked = new EventEmitter()
|
||||
|
||||
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[] }
|
||||
)
|
||||
users: User[]
|
||||
readonly savedViewIcons = SAVED_VIEW_ICONS
|
||||
|
||||
setDefaultName(value: string) {
|
||||
|
||||
@@ -2,47 +2,26 @@
|
||||
<div>
|
||||
<form [formGroup]="noteForm" class="needs-validation mt-3" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Note }" novalidate>
|
||||
<div class="form-group">
|
||||
<textarea class="form-control form-control-sm" [class.is-invalid]="newNoteError" [class.d-none]="noteView() !== NoteViewMode.Edit" [attr.aria-invalid]="newNoteError" aria-label="Note" i18n-aria-label aria-describedby="noteFeedback" rows="3" formControlName="newNote" placeholder="Enter note (Markdown is supported)" i18n-placeholder (keydown)="noteFormKeydown($event)" required></textarea>
|
||||
@if (noteView() === NoteViewMode.Preview) {
|
||||
<section class="form-control form-control-sm note-preview" aria-label="Note preview" i18n-aria-label>
|
||||
@if (noteForm.get('newNote').value) {
|
||||
<div class="note-markdown text-break text-wrap" [innerHTML]="noteForm.get('newNote').value | markdown"></div>
|
||||
} @else {
|
||||
<span class="text-muted" i18n>Nothing to preview</span>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
<div id="noteFeedback" class="invalid-feedback" i18n>
|
||||
Please enter a note.
|
||||
</div>
|
||||
<textarea class="form-control form-control-sm" [class.is-invalid]="newNoteError" rows="3" formControlName="newNote" placeholder="Enter note" i18n-placeholder (keydown)="noteFormKeydown($event)" required></textarea>
|
||||
<div class="invalid-feedback" i18n>
|
||||
Please enter a note.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group mt-2 d-flex justify-content-between align-items-center">
|
||||
<div class="form-group mt-2 d-flex justify-content-end align-items-center">
|
||||
@if (networkActive()) {
|
||||
<div class="spinner-border spinner-border-sm fw-normal me-auto" role="status"></div>
|
||||
}
|
||||
<fieldset class="btn-group btn-group-sm" aria-label="Note input mode" i18n-aria-label>
|
||||
<input type="radio" class="btn-check" id="noteViewEdit" name="noteViewMode" [checked]="noteView() === NoteViewMode.Edit" (change)="noteView.set(NoteViewMode.Edit)">
|
||||
<label class="btn btn-outline-secondary" for="noteViewEdit" i18n>Edit</label>
|
||||
<input type="radio" class="btn-check" id="noteViewPreview" name="noteViewMode" [checked]="noteView() === NoteViewMode.Preview" (change)="noteView.set(NoteViewMode.Preview)">
|
||||
<label class="btn btn-outline-secondary" for="noteViewPreview" i18n>Preview</label>
|
||||
</fieldset>
|
||||
<button type="button" class="btn btn-primary btn-sm" [disabled]="networkActive() || addDisabled()" (click)="addNote()" i18n>Add note</button>
|
||||
</div>
|
||||
</form>
|
||||
<hr>
|
||||
@for (note of notes(); track note.id) {
|
||||
@for (note of notes(); track note) {
|
||||
<div class="card border mb-3">
|
||||
<div class="card-body text-dark">
|
||||
@if (note.format === NoteFormat.Markdown) {
|
||||
<div class="card-text note-markdown text-break text-wrap" [innerHTML]="note.note | markdown"></div>
|
||||
} @else {
|
||||
<p class="card-text">{{note.note}}</p>
|
||||
}
|
||||
<p class="card-text">{{note.note}}</p>
|
||||
</div>
|
||||
<div class="d-flex card-footer small bg-light text-primary justify-content-between align-items-center">
|
||||
<span>
|
||||
{{displayName(note)}} - {{ note.created | customDate}}
|
||||
</span>
|
||||
<span>{{displayName(note)}} - {{ note.created | customDate}}</span>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 fade" title="Delete note" i18n-title (click)="deleteNote(note.id)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Note }">
|
||||
<pngx-bs-icon name="trash"></pngx-bs-icon>
|
||||
<span class="visually-hidden" i18n>Delete note</span>
|
||||
|
||||
@@ -5,45 +5,3 @@
|
||||
.card:hover .fade {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.note-preview {
|
||||
min-height: calc(1.5em * 3 + 0.5rem + var(--bs-border-width) * 2); // textfield height with 3 rows
|
||||
}
|
||||
|
||||
.note-markdown ::ng-deep {
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
h1 { font-size: 1.25em; }
|
||||
h2 { font-size: 1.15em; }
|
||||
h3 { font-size: 1.05em; }
|
||||
|
||||
table {
|
||||
margin-bottom: 1rem;
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.15rem 0.4rem;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: 0.5rem;
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
border-radius: var(--bs-border-radius);
|
||||
}
|
||||
|
||||
blockquote {
|
||||
padding-left: 0.75rem;
|
||||
border-left: 0.25rem solid var(--bs-border-color);
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
> :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { DocumentNote, NoteFormat } from 'src/app/data/document-note'
|
||||
import { DocumentNote } from 'src/app/data/document-note'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
@@ -129,66 +129,6 @@ describe('DocumentNotesComponent', () => {
|
||||
expect(fixture.debugElement.nativeElement.textContent).toContain('user3')
|
||||
})
|
||||
|
||||
it('should render notes according to their format', () => {
|
||||
component.notes.set([
|
||||
{ id: 31, note: '**x**', format: NoteFormat.Markdown },
|
||||
{
|
||||
id: 32,
|
||||
note: '<script>alert(1)</script>',
|
||||
format: NoteFormat.Markdown,
|
||||
},
|
||||
{ id: 33, note: '**x**', format: NoteFormat.Plain },
|
||||
])
|
||||
fixture.detectChanges()
|
||||
|
||||
const noteCards = fixture.debugElement.queryAll(By.css('.card-text'))
|
||||
expect(noteCards[0].query(By.css('strong')).nativeElement.textContent).toBe(
|
||||
'x'
|
||||
)
|
||||
expect(noteCards[1].query(By.css('script'))).toBeNull()
|
||||
expect(noteCards[2].query(By.css('strong'))).toBeNull()
|
||||
expect(noteCards[2].nativeElement.textContent).toBe('**x**')
|
||||
})
|
||||
|
||||
it('should toggle between edit and preview', () => {
|
||||
const radios = fixture.debugElement.queryAll(
|
||||
By.css('.btn-group input[type="radio"]')
|
||||
)
|
||||
radios[1].triggerEventHandler('change')
|
||||
fixture.detectChanges()
|
||||
expect(
|
||||
fixture.debugElement.query(By.css('.note-preview')).nativeElement
|
||||
.textContent
|
||||
).toContain('Nothing to preview')
|
||||
|
||||
component.noteForm.get('newNote').setValue('**bold**')
|
||||
fixture.detectChanges()
|
||||
expect(
|
||||
fixture.debugElement.query(By.css('.note-preview strong'))
|
||||
).not.toBeNull()
|
||||
expect(
|
||||
fixture.debugElement.query(By.css('textarea')).nativeElement.classList
|
||||
).toContain('d-none')
|
||||
|
||||
radios[0].triggerEventHandler('change')
|
||||
fixture.detectChanges()
|
||||
expect(fixture.debugElement.query(By.css('.note-preview'))).toBeNull()
|
||||
expect(
|
||||
fixture.debugElement.query(By.css('textarea')).nativeElement.classList
|
||||
).not.toContain('d-none')
|
||||
})
|
||||
|
||||
it('should return to edit mode after saving', () => {
|
||||
component.documentId.set(12)
|
||||
component.noteForm.get('newNote').setValue('Preview note')
|
||||
fixture.debugElement
|
||||
.queryAll(By.css('.btn-group input[type="radio"]'))[1]
|
||||
.triggerEventHandler('change')
|
||||
jest.spyOn(notesService, 'addNote').mockReturnValueOnce(of(notes))
|
||||
component.addNote()
|
||||
expect(component.noteView()).toBe('edit')
|
||||
})
|
||||
|
||||
it('should handle note user display in all situations', () => {
|
||||
expect(
|
||||
component.displayName({
|
||||
@@ -221,7 +161,7 @@ describe('DocumentNotesComponent', () => {
|
||||
const addSpy = jest.spyOn(notesService, 'addNote')
|
||||
addSpy.mockReturnValueOnce(throwError(() => new Error('error saving note')))
|
||||
const toastsSpy = jest.spyOn(toastService, 'showError')
|
||||
const addButton = fixture.debugElement.query(By.css('.btn-primary'))
|
||||
const addButton = fixture.debugElement.query(By.css('button'))
|
||||
addButton.triggerEventHandler('click')
|
||||
expect(addSpy).toHaveBeenCalledWith(12, note)
|
||||
expect(toastsSpy).toHaveBeenCalled()
|
||||
@@ -250,9 +190,7 @@ describe('DocumentNotesComponent', () => {
|
||||
component.documentId.set(12)
|
||||
component.notes.set(notes)
|
||||
fixture.detectChanges()
|
||||
const deleteButton = fixture.debugElement.query(
|
||||
By.css('.card-footer button')
|
||||
)
|
||||
const deleteButton = fixture.debugElement.queryAll(By.css('button'))[1] // 0 is add button
|
||||
const deleteSpy = jest.spyOn(notesService, 'deleteNote')
|
||||
const toastsSpy = jest.spyOn(toastService, 'showError')
|
||||
deleteSpy.mockReturnValueOnce(
|
||||
|
||||
@@ -14,21 +14,15 @@ import {
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
||||
import { DocumentNote, NoteFormat } from 'src/app/data/document-note'
|
||||
import { DocumentNote } from 'src/app/data/document-note'
|
||||
import { User } from 'src/app/data/user'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'
|
||||
import { DocumentNotesService } from 'src/app/services/rest/document-notes.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
||||
|
||||
enum NoteViewMode {
|
||||
Edit = 'edit',
|
||||
Preview = 'preview',
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-document-notes',
|
||||
templateUrl: './document-notes.component.html',
|
||||
@@ -36,7 +30,6 @@ enum NoteViewMode {
|
||||
imports: [
|
||||
IfPermissionsDirective,
|
||||
CustomDatePipe,
|
||||
MarkdownPipe,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
BootstrapIconComponent,
|
||||
@@ -50,9 +43,6 @@ export class DocumentNotesComponent extends ComponentWithPermissions {
|
||||
readonly notes = model<DocumentNote[]>([])
|
||||
readonly addDisabled = input(false)
|
||||
readonly networkActive = signal(false)
|
||||
protected readonly NoteViewMode = NoteViewMode
|
||||
protected readonly NoteFormat = NoteFormat
|
||||
protected readonly noteView = signal<NoteViewMode>(NoteViewMode.Edit)
|
||||
|
||||
noteForm: FormGroup = new FormGroup({
|
||||
newNote: new FormControl(''),
|
||||
@@ -85,7 +75,6 @@ export class DocumentNotesComponent extends ComponentWithPermissions {
|
||||
next: (result) => {
|
||||
this.notes.set(result)
|
||||
this.noteForm.get('newNote').reset()
|
||||
this.noteView.set(NoteViewMode.Edit)
|
||||
this.networkActive.set(false)
|
||||
this.updated.emit(this.notes())
|
||||
},
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { ObjectWithId } from './object-with-id'
|
||||
import { User } from './user'
|
||||
|
||||
export enum NoteFormat {
|
||||
Plain = 'plain',
|
||||
Markdown = 'markdown',
|
||||
}
|
||||
|
||||
export interface DocumentNote extends ObjectWithId {
|
||||
created?: Date
|
||||
note?: string
|
||||
user?: User
|
||||
format?: NoteFormat
|
||||
}
|
||||
@@ -12,7 +12,6 @@ export enum WorkflowTriggerType {
|
||||
DocumentAdded = 2,
|
||||
DocumentUpdated = 3,
|
||||
Scheduled = 4,
|
||||
Manual = 5,
|
||||
}
|
||||
|
||||
export enum ScheduleDateField {
|
||||
|
||||
@@ -240,7 +240,7 @@ describe('OpenDocumentsService', () => {
|
||||
expect(refreshed.tags).toEqual([4])
|
||||
})
|
||||
|
||||
it('keeps documents open after a network error during refresh', () => {
|
||||
it('should handle error on refresh documents', () => {
|
||||
subscriptions.push(
|
||||
openDocumentsService.openDocument(documents[1]).subscribe()
|
||||
)
|
||||
@@ -250,63 +250,7 @@ describe('OpenDocumentsService', () => {
|
||||
)
|
||||
expect(req.request.method).toEqual('GET')
|
||||
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)
|
||||
openDocumentsService.openDocument(first).subscribe()
|
||||
secondRequest.flush({}, { status: 404, statusText: 'Not Found' })
|
||||
expect(openDocumentsService.getOpenDocuments()).toEqual([first])
|
||||
})
|
||||
|
||||
it('should log error on sessionStorage save', () => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http'
|
||||
import { Injectable, inject, signal } from '@angular/core'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { Observable, Subject, of } from 'rxjs'
|
||||
@@ -62,12 +61,7 @@ export class OpenDocumentsService {
|
||||
Object.assign(openDoc, doc, unsavedEdits)
|
||||
this.save()
|
||||
},
|
||||
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
|
||||
|
||||
error: () => {
|
||||
this.openDocuments.splice(index, 1)
|
||||
this.save()
|
||||
},
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { HttpTestingController } from '@angular/common/http/testing'
|
||||
import { TestBed } from '@angular/core/testing'
|
||||
import { Subscription } from 'rxjs'
|
||||
import { NoteFormat } from 'src/app/data/document-note'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
|
||||
import { DocumentNotesService } from './document-notes.service'
|
||||
@@ -51,7 +50,6 @@ describe(`Additional service tests for DocumentNotesService`, () => {
|
||||
expect(req.request.method).toEqual('POST')
|
||||
expect(req.request.body).toEqual({
|
||||
note: content,
|
||||
format: NoteFormat.Markdown,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { HttpParams } from '@angular/common/http'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { Observable } from 'rxjs'
|
||||
import { DocumentNote, NoteFormat } from 'src/app/data/document-note'
|
||||
import { DocumentNote } from 'src/app/data/document-note'
|
||||
import { AbstractPaperlessService } from './abstract-paperless-service'
|
||||
|
||||
@Injectable({
|
||||
@@ -22,7 +22,6 @@ export class DocumentNotesService extends AbstractPaperlessService<DocumentNote>
|
||||
addNote(id: number, note: string): Observable<DocumentNote[]> {
|
||||
return this.http.post<DocumentNote[]>(this.getResourceUrl(id, 'notes'), {
|
||||
note: note,
|
||||
format: NoteFormat.Markdown,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -290,7 +290,6 @@ describe(`DocumentService`, () => {
|
||||
expect(req.request.method).toEqual('POST')
|
||||
expect(req.request.body).toEqual({
|
||||
documents: ids,
|
||||
from_webui: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -304,7 +303,6 @@ describe(`DocumentService`, () => {
|
||||
expect(req.request.body).toEqual({
|
||||
documents: ids,
|
||||
remote_ocr: false,
|
||||
from_webui: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -319,7 +317,6 @@ describe(`DocumentService`, () => {
|
||||
expect(req.request.body).toEqual({
|
||||
documents: ids,
|
||||
remote_ocr: true,
|
||||
from_webui: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -334,7 +331,6 @@ describe(`DocumentService`, () => {
|
||||
documents: ids,
|
||||
degrees: 90,
|
||||
source_mode: 'latest_version',
|
||||
from_webui: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -350,7 +346,6 @@ describe(`DocumentService`, () => {
|
||||
documents: ids,
|
||||
metadata_document_id: 1,
|
||||
delete_originals: true,
|
||||
from_webui: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -364,7 +359,6 @@ describe(`DocumentService`, () => {
|
||||
expect(req.request.body).toEqual({
|
||||
documents: ids,
|
||||
root_document_id: 2,
|
||||
from_webui: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -380,7 +374,6 @@ describe(`DocumentService`, () => {
|
||||
documents: ids,
|
||||
root_document_id: 2,
|
||||
version_label: 'Imported',
|
||||
from_webui: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -395,7 +388,6 @@ describe(`DocumentService`, () => {
|
||||
expect(req.request.body).toEqual({
|
||||
documents: ids,
|
||||
operations: [{ page: 1, rotate: 90, doc: 0 }],
|
||||
from_webui: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -411,7 +403,6 @@ describe(`DocumentService`, () => {
|
||||
documents: ids,
|
||||
password: 'secret',
|
||||
update_document: true,
|
||||
from_webui: true,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ export type DocumentBulkEditMethod =
|
||||
| 'modify_tags'
|
||||
| 'modify_custom_fields'
|
||||
| 'set_permissions'
|
||||
| 'run_workflow'
|
||||
|
||||
export interface MergeDocumentsRequest {
|
||||
metadata_document_id?: number
|
||||
@@ -351,24 +350,19 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
||||
}
|
||||
|
||||
deleteDocuments(selection: DocumentSelectionQuery) {
|
||||
return this.http.post<BulkOperationResponse>(
|
||||
this.getResourceUrl(null, 'delete'),
|
||||
{ ...selection, from_webui: true }
|
||||
)
|
||||
return this.http.post(this.getResourceUrl(null, 'delete'), {
|
||||
...selection,
|
||||
})
|
||||
}
|
||||
|
||||
reprocessDocuments(
|
||||
selection: DocumentSelectionQuery,
|
||||
remoteOcr: boolean = false
|
||||
) {
|
||||
return this.http.post<BulkOperationResponse>(
|
||||
this.getResourceUrl(null, 'reprocess'),
|
||||
{
|
||||
...selection,
|
||||
remote_ocr: remoteOcr,
|
||||
from_webui: true,
|
||||
}
|
||||
)
|
||||
return this.http.post(this.getResourceUrl(null, 'reprocess'), {
|
||||
...selection,
|
||||
remote_ocr: remoteOcr,
|
||||
})
|
||||
}
|
||||
|
||||
rotateDocuments(
|
||||
@@ -376,26 +370,18 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
||||
degrees: number,
|
||||
sourceMode: BulkEditSourceMode = BulkEditSourceMode.LATEST_VERSION
|
||||
) {
|
||||
return this.http.post<BulkOperationResponse>(
|
||||
this.getResourceUrl(null, 'rotate'),
|
||||
{
|
||||
...selection,
|
||||
degrees,
|
||||
source_mode: sourceMode,
|
||||
from_webui: true,
|
||||
}
|
||||
)
|
||||
return this.http.post(this.getResourceUrl(null, 'rotate'), {
|
||||
...selection,
|
||||
degrees,
|
||||
source_mode: sourceMode,
|
||||
})
|
||||
}
|
||||
|
||||
mergeDocuments(ids: number[], request: MergeDocumentsRequest = {}) {
|
||||
return this.http.post<BulkOperationResponse>(
|
||||
this.getResourceUrl(null, 'merge'),
|
||||
{
|
||||
documents: ids,
|
||||
...request,
|
||||
from_webui: true,
|
||||
}
|
||||
)
|
||||
return this.http.post(this.getResourceUrl(null, 'merge'), {
|
||||
documents: ids,
|
||||
...request,
|
||||
})
|
||||
}
|
||||
|
||||
mergeDocumentsAsVersions(
|
||||
@@ -403,40 +389,28 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
||||
rootDocumentId: number,
|
||||
versionLabel?: string
|
||||
) {
|
||||
return this.http.post<BulkOperationResponse>(
|
||||
this.getResourceUrl(null, 'merge_as_versions'),
|
||||
{
|
||||
documents: ids,
|
||||
root_document_id: rootDocumentId,
|
||||
...(versionLabel ? { version_label: versionLabel } : {}),
|
||||
from_webui: true,
|
||||
}
|
||||
)
|
||||
return this.http.post(this.getResourceUrl(null, 'merge_as_versions'), {
|
||||
documents: ids,
|
||||
root_document_id: rootDocumentId,
|
||||
...(versionLabel ? { version_label: versionLabel } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
editPdfDocuments(ids: number[], request: EditPdfDocumentsRequest) {
|
||||
return this.http.post<BulkOperationResponse>(
|
||||
this.getResourceUrl(null, 'edit_pdf'),
|
||||
{
|
||||
documents: ids,
|
||||
...request,
|
||||
from_webui: true,
|
||||
}
|
||||
)
|
||||
return this.http.post(this.getResourceUrl(null, 'edit_pdf'), {
|
||||
documents: ids,
|
||||
...request,
|
||||
})
|
||||
}
|
||||
|
||||
removePasswordDocuments(
|
||||
ids: number[],
|
||||
request: RemovePasswordDocumentsRequest
|
||||
) {
|
||||
return this.http.post<BulkOperationResponse>(
|
||||
this.getResourceUrl(null, 'remove_password'),
|
||||
{
|
||||
documents: ids,
|
||||
...request,
|
||||
from_webui: true,
|
||||
}
|
||||
)
|
||||
return this.http.post(this.getResourceUrl(null, 'remove_password'), {
|
||||
documents: ids,
|
||||
...request,
|
||||
})
|
||||
}
|
||||
|
||||
getSelectionData(
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
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.')
|
||||
})
|
||||
})
|
||||
@@ -1,128 +0,0 @@
|
||||
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,29 +38,6 @@ 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', () => {
|
||||
const toast = {
|
||||
title: 'Title',
|
||||
|
||||
@@ -75,14 +75,7 @@ export class ToastService {
|
||||
if (persistent) {
|
||||
// the dropdown is open, so it's seen as it arrives
|
||||
toast.read = !!this._suppressPopupToasts
|
||||
const index = this.toasts.findIndex(
|
||||
(existing) => existing.id === toast.id
|
||||
)
|
||||
if (index === -1) {
|
||||
this.toasts.unshift(toast)
|
||||
} else {
|
||||
this.toasts[index] = toast
|
||||
}
|
||||
this.toasts.unshift(toast)
|
||||
this.toastsSubject.next(this.toasts)
|
||||
}
|
||||
// Popups are suppressed while the dropdown is open
|
||||
|
||||
@@ -5,10 +5,8 @@ import {
|
||||
HttpTestingController,
|
||||
} from '@angular/common/http/testing'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { PaperlessTaskStatus } from '../data/paperless-task'
|
||||
import { DocumentListViewService } from './document-list-view.service'
|
||||
import { TaskNotificationService } from './task-notification.service'
|
||||
import { Toast, ToastLevel, ToastService } from './toast.service'
|
||||
import { Toast, ToastService } from './toast.service'
|
||||
import { TrashService } from './trash.service'
|
||||
|
||||
describe('TrashService', () => {
|
||||
@@ -107,52 +105,4 @@ describe('TrashService', () => {
|
||||
expect(toast.content).toEqual('Moved 500 document(s) to the trash')
|
||||
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,8 +5,7 @@ import { environment } from 'src/environments/environment'
|
||||
import { Document } from '../data/document'
|
||||
import { Results } from '../data/results'
|
||||
import { DocumentListViewService } from './document-list-view.service'
|
||||
import { TaskNotificationService } from './task-notification.service'
|
||||
import { Toast, ToastLevel, ToastService } from './toast.service'
|
||||
import { ToastLevel, ToastService } from './toast.service'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
@@ -15,24 +14,6 @@ export class TrashService {
|
||||
private http = inject(HttpClient)
|
||||
private toastService = inject(ToastService)
|
||||
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>> {
|
||||
const httpParams = new HttpParams().set('page', page.toString())
|
||||
@@ -62,22 +43,14 @@ export class TrashService {
|
||||
* Shows a toast with an Undo action when possible
|
||||
*/
|
||||
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
|
||||
return {
|
||||
this.toastService.show({
|
||||
content,
|
||||
delay: 10000,
|
||||
level: ToastLevel.Success,
|
||||
...(canUndo
|
||||
? {
|
||||
actionName: $localize`Undo`,
|
||||
action: () => this.undoMoveToTrash(documents),
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
actionName: canUndo ? $localize`Undo` : undefined,
|
||||
action: canUndo ? () => this.undoMoveToTrash(documents) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
private undoMoveToTrash(documents: number[]) {
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,38 +0,0 @@
|
||||
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,8 +18,7 @@
|
||||
"es2022",
|
||||
"dom"
|
||||
],
|
||||
"useDefineForClassFields": false,
|
||||
"types": ["jest", "node"]
|
||||
"useDefineForClassFields": false
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
|
||||
+45
-149
@@ -39,12 +39,9 @@ from documents.models import Tag
|
||||
from documents.permissions import PermissionsSpec
|
||||
from documents.permissions import set_permissions_for_objects
|
||||
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 consume_file
|
||||
from documents.tasks import remove_document_from_index
|
||||
from documents.tasks import run_workflows_for_documents
|
||||
from documents.tasks import update_document_content_maybe_archive_file
|
||||
from documents.versioning import get_latest_version_for_root
|
||||
from documents.versioning import get_root_document
|
||||
@@ -429,17 +426,11 @@ def delete(doc_ids: list[int]) -> Literal["OK"]:
|
||||
"Detected a possible incompatible database column. See https://docs.paperless-ngx.com/troubleshooting/#convert-uuid-field",
|
||||
)
|
||||
logger.error(f"Error deleting documents: {e!s}")
|
||||
raise
|
||||
|
||||
return "OK"
|
||||
|
||||
|
||||
def reprocess(
|
||||
doc_ids: list[int],
|
||||
*,
|
||||
remote_ocr: bool = False,
|
||||
parent_task_id: str | None = None,
|
||||
) -> Literal["OK"]:
|
||||
def reprocess(doc_ids: list[int], *, remote_ocr: bool = False) -> Literal["OK"]:
|
||||
"""
|
||||
Re-run parsing for the given documents.
|
||||
|
||||
@@ -464,16 +455,6 @@ def reprocess(
|
||||
.values_list("source_id", flat=True)
|
||||
.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:
|
||||
update_document_content_maybe_archive_file.apply_async(
|
||||
kwargs={"document_id": source_id, "remote_ocr": remote_ocr},
|
||||
@@ -483,25 +464,6 @@ def reprocess(
|
||||
return "OK"
|
||||
|
||||
|
||||
def run_workflow(
|
||||
doc_ids: list[int],
|
||||
workflow: int,
|
||||
*,
|
||||
filter_documents: bool = True,
|
||||
) -> Literal["OK"]:
|
||||
"""
|
||||
Manually run a single workflow against the given documents.
|
||||
|
||||
Called from the tracked bulk operation task.
|
||||
"""
|
||||
run_workflows_for_documents(
|
||||
document_ids=doc_ids,
|
||||
workflow_id=workflow,
|
||||
filter_documents=filter_documents,
|
||||
)
|
||||
return "OK"
|
||||
|
||||
|
||||
def set_permissions(
|
||||
doc_ids: list[int],
|
||||
set_permissions: PermissionsSpec,
|
||||
@@ -538,7 +500,6 @@ def rotate(
|
||||
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
|
||||
user: User | None = None,
|
||||
trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI,
|
||||
parent_task_id: str | None = None,
|
||||
) -> Literal["OK"]:
|
||||
logger.info(
|
||||
f"Attempting to rotate {len(doc_ids)} documents by {degrees} degrees.",
|
||||
@@ -576,22 +537,22 @@ def rotate(
|
||||
if user is not None:
|
||||
overrides.actor_id = user.id
|
||||
|
||||
queue_consumption(
|
||||
input_doc=ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=filepath,
|
||||
root_document_id=pair.root_doc.id,
|
||||
),
|
||||
overrides=overrides,
|
||||
trigger_source=trigger_source,
|
||||
parent_task_id=parent_task_id,
|
||||
consume_file.apply_async(
|
||||
kwargs={
|
||||
"input_doc": ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=filepath,
|
||||
root_document_id=pair.root_doc.id,
|
||||
),
|
||||
"overrides": overrides,
|
||||
},
|
||||
headers={"trigger_source": trigger_source},
|
||||
)
|
||||
logger.info(
|
||||
f"Queued new rotated version for document {pair.root_doc.id} by {degrees} degrees",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error rotating document {pair.root_doc.id}: {e}")
|
||||
raise
|
||||
|
||||
return "OK"
|
||||
|
||||
@@ -605,15 +566,13 @@ def merge(
|
||||
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
|
||||
user: User | None = None,
|
||||
trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI,
|
||||
parent_task_id: str | None = None,
|
||||
) -> Literal["OK"] | dict:
|
||||
) -> Literal["OK"]:
|
||||
logger.info(
|
||||
f"Attempting to merge {len(doc_ids)} documents into a single document.",
|
||||
)
|
||||
qs = Document.objects.select_related("root_document").filter(id__in=doc_ids)
|
||||
docs_by_id = {doc.id: doc for doc in qs}
|
||||
affected_docs: list[int] = []
|
||||
failed_ids: list[int] = []
|
||||
handoff_asn: int | None = None
|
||||
with pdf_ops.PdfMerger() as merger:
|
||||
# use doc_ids to preserve order
|
||||
@@ -639,14 +598,11 @@ def merge(
|
||||
if handoff_asn is None and doc.archive_serial_number is not None:
|
||||
handoff_asn = doc.archive_serial_number
|
||||
except Exception as e:
|
||||
failed_ids.append(doc.id)
|
||||
logger.exception(
|
||||
f"Error merging document {doc.id}, it will not be included in the merge: {e}",
|
||||
)
|
||||
if len(affected_docs) == 0:
|
||||
logger.warning("No documents were merged")
|
||||
if parent_task_id is not None:
|
||||
raise ValueError("None of the selected documents could be merged.")
|
||||
return "OK"
|
||||
|
||||
filepath = (
|
||||
@@ -689,23 +645,6 @@ def merge(
|
||||
overrides=overrides,
|
||||
).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:
|
||||
backup = release_archive_serial_numbers(affected_docs)
|
||||
logger.info(
|
||||
@@ -742,15 +681,7 @@ def merge_as_versions(
|
||||
)
|
||||
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]
|
||||
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]
|
||||
next_version_index = (
|
||||
Document.global_objects.filter(
|
||||
@@ -850,7 +781,6 @@ def split(
|
||||
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
|
||||
user: User | None = None,
|
||||
trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI,
|
||||
parent_task_id: str | None = None,
|
||||
) -> Literal["OK"]:
|
||||
logger.info(
|
||||
f"Attempting to split document {doc_ids[0]} into {len(pages)} documents",
|
||||
@@ -894,17 +824,7 @@ def split(
|
||||
).set(headers={"trigger_source": trigger_source}),
|
||||
)
|
||||
|
||||
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:
|
||||
if delete_originals:
|
||||
backup = release_archive_serial_numbers([doc.id])
|
||||
logger.info(
|
||||
"Queueing removal of original document after consumption of the split documents",
|
||||
@@ -924,7 +844,6 @@ def split(
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error splitting document {doc.id}: {e}")
|
||||
raise
|
||||
|
||||
return "OK"
|
||||
|
||||
@@ -936,7 +855,6 @@ def delete_pages(
|
||||
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
|
||||
user: User | None = None,
|
||||
trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI,
|
||||
parent_task_id: str | None = None,
|
||||
) -> Literal["OK"]:
|
||||
logger.info(
|
||||
f"Attempting to delete pages {pages} from {len(doc_ids)} documents",
|
||||
@@ -955,22 +873,22 @@ def delete_pages(
|
||||
overrides = DocumentMetadataOverrides().from_document(pair.root_doc)
|
||||
if user is not None:
|
||||
overrides.actor_id = user.id
|
||||
queue_consumption(
|
||||
input_doc=ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=filepath,
|
||||
root_document_id=pair.root_doc.id,
|
||||
),
|
||||
overrides=overrides,
|
||||
trigger_source=trigger_source,
|
||||
parent_task_id=parent_task_id,
|
||||
consume_file.apply_async(
|
||||
kwargs={
|
||||
"input_doc": ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=filepath,
|
||||
root_document_id=pair.root_doc.id,
|
||||
),
|
||||
"overrides": overrides,
|
||||
},
|
||||
headers={"trigger_source": trigger_source},
|
||||
)
|
||||
logger.info(
|
||||
f"Queued new version for document {pair.root_doc.id} after deleting pages {pages}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error deleting pages from document {pair.root_doc.id}: {e}")
|
||||
raise
|
||||
|
||||
return "OK"
|
||||
|
||||
@@ -985,7 +903,6 @@ def edit_pdf(
|
||||
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
|
||||
user: User | None = None,
|
||||
trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI,
|
||||
parent_task_id: str | None = None,
|
||||
) -> Literal["OK"]:
|
||||
"""
|
||||
Operations is a list of dictionaries describing the final PDF pages.
|
||||
@@ -1030,15 +947,16 @@ def edit_pdf(
|
||||
if user is not None:
|
||||
overrides.owner_id = user.id
|
||||
overrides.actor_id = user.id
|
||||
queue_consumption(
|
||||
input_doc=ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=filepath,
|
||||
root_document_id=pair.root_doc.id,
|
||||
),
|
||||
overrides=overrides,
|
||||
trigger_source=trigger_source,
|
||||
parent_task_id=parent_task_id,
|
||||
consume_file.apply_async(
|
||||
kwargs={
|
||||
"input_doc": ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=filepath,
|
||||
root_document_id=pair.root_doc.id,
|
||||
),
|
||||
"overrides": overrides,
|
||||
},
|
||||
headers={"trigger_source": trigger_source},
|
||||
)
|
||||
else:
|
||||
version_filepaths = pdf_ops.build_pdfs(
|
||||
@@ -1078,17 +996,7 @@ def edit_pdf(
|
||||
).set(headers={"trigger_source": trigger_source}),
|
||||
)
|
||||
|
||||
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:
|
||||
if delete_original:
|
||||
backup = release_archive_serial_numbers([doc.id])
|
||||
try:
|
||||
chord(
|
||||
@@ -1122,7 +1030,6 @@ def remove_password(
|
||||
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
|
||||
user: User | None = None,
|
||||
trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI,
|
||||
parent_task_id: str | None = None,
|
||||
source_paths_by_id: Mapping[int, Path] | None = None,
|
||||
) -> Literal["OK"]:
|
||||
"""
|
||||
@@ -1165,15 +1072,16 @@ def remove_password(
|
||||
if user is not None:
|
||||
overrides.owner_id = user.id
|
||||
overrides.actor_id = user.id
|
||||
queue_consumption(
|
||||
input_doc=ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=filepath,
|
||||
root_document_id=pair.root_doc.id,
|
||||
),
|
||||
overrides=overrides,
|
||||
trigger_source=trigger_source,
|
||||
parent_task_id=parent_task_id,
|
||||
consume_file.apply_async(
|
||||
kwargs={
|
||||
"input_doc": ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=filepath,
|
||||
root_document_id=pair.root_doc.id,
|
||||
),
|
||||
"overrides": overrides,
|
||||
},
|
||||
headers={"trigger_source": trigger_source},
|
||||
)
|
||||
else:
|
||||
consume_tasks = []
|
||||
@@ -1196,19 +1104,7 @@ def remove_password(
|
||||
).set(headers={"trigger_source": trigger_source}),
|
||||
)
|
||||
|
||||
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:
|
||||
if delete_original:
|
||||
chord(
|
||||
header=consume_tasks,
|
||||
body=delete.si([doc.id]),
|
||||
|
||||
Loaded 100 of 178 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user