From 56c744fd5620b072e1724990eda892673bbda9e4 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Fri, 23 Jan 2026 15:49:00 -0800 Subject: [PATCH 01/33] Fixes the spelling of the commitish argument to the action --- .github/workflows/ci.yml | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6abd71dfd..c1169f664 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -617,7 +617,7 @@ jobs: version: ${{ steps.get_version.outputs.version }} prerelease: ${{ steps.get_version.outputs.prerelease }} publish: true # ensures release is not marked as draft - committish: ${{ github.sha }} + commitish: ${{ github.sha }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload release archive diff --git a/pyproject.toml b/pyproject.toml index d4d24a7e8..ee1748cc3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -238,7 +238,7 @@ lint.isort.force-single-line = true [tool.codespell] write-changes = true -ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober" +ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish" skip = "src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/documents/tests/samples/*,*.po,*.json" [tool.pytest.ini_options] From 32d04e1fd3be7b3256b76ac86dbb3e50f362e4b1 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Fri, 23 Jan 2026 15:49:22 -0800 Subject: [PATCH 02/33] Fix: use correct field id for overrides (#11869) --- src/documents/data_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/documents/data_models.py b/src/documents/data_models.py index 2623a6138..8e2ff2e92 100644 --- a/src/documents/data_models.py +++ b/src/documents/data_models.py @@ -115,7 +115,7 @@ class DocumentMetadataOverrides: ).values_list("id", flat=True), ) overrides.custom_fields = { - custom_field.id: custom_field.value + custom_field.field.id: custom_field.value for custom_field in doc.custom_fields.all() } From 94f6b8d36d1cee8f121946fb591488b55141d93e Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Fri, 23 Jan 2026 16:08:28 -0800 Subject: [PATCH 03/33] Fixes the management scripts under a non-root install where the user ID is something besides 1000 (#11870) --- docker/management_script.sh | 8 +++----- docker/rootfs/usr/local/bin/convert_mariadb_uuid | 8 +++----- docker/rootfs/usr/local/bin/createsuperuser | 8 +++----- docker/rootfs/usr/local/bin/decrypt_documents | 8 +++----- docker/rootfs/usr/local/bin/document_archiver | 8 +++----- docker/rootfs/usr/local/bin/document_create_classifier | 8 +++----- docker/rootfs/usr/local/bin/document_exporter | 8 +++----- docker/rootfs/usr/local/bin/document_fuzzy_match | 8 +++----- docker/rootfs/usr/local/bin/document_importer | 8 +++----- docker/rootfs/usr/local/bin/document_index | 8 +++----- docker/rootfs/usr/local/bin/document_renamer | 8 +++----- docker/rootfs/usr/local/bin/document_retagger | 8 +++----- docker/rootfs/usr/local/bin/document_sanity_checker | 8 +++----- docker/rootfs/usr/local/bin/document_thumbnails | 8 +++----- docker/rootfs/usr/local/bin/mail_fetcher | 8 +++----- docker/rootfs/usr/local/bin/manage_superuser | 8 +++----- docker/rootfs/usr/local/bin/prune_audit_logs | 8 +++----- 17 files changed, 51 insertions(+), 85 deletions(-) diff --git a/docker/management_script.sh b/docker/management_script.sh index 1fa31c372..91a6336d0 100755 --- a/docker/management_script.sh +++ b/docker/management_script.sh @@ -5,10 +5,8 @@ set -e cd "${PAPERLESS_SRC_DIR}" -if [[ $(id -u) == 0 ]]; then - s6-setuidgid paperless python3 manage.py management_command "$@" -elif [[ $(id -un) == "paperless" ]]; then +if [[ -n "${USER_IS_NON_ROOT}" ]]; then python3 manage.py management_command "$@" -else - echo "Unknown user." +elif [[ $(id -un) == "paperless" ]]; then + s6-setuidgid paperless python3 manage.py management_command "$@" fi diff --git a/docker/rootfs/usr/local/bin/convert_mariadb_uuid b/docker/rootfs/usr/local/bin/convert_mariadb_uuid index 806a98f3b..019c558f1 100755 --- a/docker/rootfs/usr/local/bin/convert_mariadb_uuid +++ b/docker/rootfs/usr/local/bin/convert_mariadb_uuid @@ -5,10 +5,8 @@ set -e cd "${PAPERLESS_SRC_DIR}" -if [[ $(id -u) == 0 ]]; then - s6-setuidgid paperless python3 manage.py convert_mariadb_uuid "$@" +if [[ -n "${USER_IS_NON_ROOT}" ]]; then + python3 manage.py convert_mariadb_uuid "$@" elif [[ $(id -un) == "paperless" ]]; then - python3 manage.py convert_mariadb_uuid "$@" -else - echo "Unknown user." + s6-setuidgid paperless python3 manage.py convert_mariadb_uuid "$@" fi diff --git a/docker/rootfs/usr/local/bin/createsuperuser b/docker/rootfs/usr/local/bin/createsuperuser index f931952ba..2b56869f6 100755 --- a/docker/rootfs/usr/local/bin/createsuperuser +++ b/docker/rootfs/usr/local/bin/createsuperuser @@ -5,10 +5,8 @@ set -e cd "${PAPERLESS_SRC_DIR}" -if [[ $(id -u) == 0 ]]; then - s6-setuidgid paperless python3 manage.py createsuperuser "$@" +if [[ -n "${USER_IS_NON_ROOT}" ]]; then + python3 manage.py createsuperuser "$@" elif [[ $(id -un) == "paperless" ]]; then - python3 manage.py createsuperuser "$@" -else - echo "Unknown user." + s6-setuidgid paperless python3 manage.py createsuperuser "$@" fi diff --git a/docker/rootfs/usr/local/bin/decrypt_documents b/docker/rootfs/usr/local/bin/decrypt_documents index 4da1549ee..27f0a21fe 100755 --- a/docker/rootfs/usr/local/bin/decrypt_documents +++ b/docker/rootfs/usr/local/bin/decrypt_documents @@ -5,10 +5,8 @@ set -e cd "${PAPERLESS_SRC_DIR}" -if [[ $(id -u) == 0 ]]; then - s6-setuidgid paperless python3 manage.py decrypt_documents "$@" +if [[ -n "${USER_IS_NON_ROOT}" ]]; then + python3 manage.py decrypt_documents "$@" elif [[ $(id -un) == "paperless" ]]; then - python3 manage.py decrypt_documents "$@" -else - echo "Unknown user." + s6-setuidgid paperless python3 manage.py decrypt_documents "$@" fi diff --git a/docker/rootfs/usr/local/bin/document_archiver b/docker/rootfs/usr/local/bin/document_archiver index 383acfcc6..8d7771d26 100755 --- a/docker/rootfs/usr/local/bin/document_archiver +++ b/docker/rootfs/usr/local/bin/document_archiver @@ -5,10 +5,8 @@ set -e cd "${PAPERLESS_SRC_DIR}" -if [[ $(id -u) == 0 ]]; then - s6-setuidgid paperless python3 manage.py document_archiver "$@" +if [[ -n "${USER_IS_NON_ROOT}" ]]; then + python3 manage.py document_archiver "$@" elif [[ $(id -un) == "paperless" ]]; then - python3 manage.py document_archiver "$@" -else - echo "Unknown user." + s6-setuidgid paperless python3 manage.py document_archiver "$@" fi diff --git a/docker/rootfs/usr/local/bin/document_create_classifier b/docker/rootfs/usr/local/bin/document_create_classifier index 72dc33d6f..23acc6741 100755 --- a/docker/rootfs/usr/local/bin/document_create_classifier +++ b/docker/rootfs/usr/local/bin/document_create_classifier @@ -5,10 +5,8 @@ set -e cd "${PAPERLESS_SRC_DIR}" -if [[ $(id -u) == 0 ]]; then - s6-setuidgid paperless python3 manage.py document_create_classifier "$@" +if [[ -n "${USER_IS_NON_ROOT}" ]]; then + python3 manage.py document_create_classifier "$@" elif [[ $(id -un) == "paperless" ]]; then - python3 manage.py document_create_classifier "$@" -else - echo "Unknown user." + s6-setuidgid paperless python3 manage.py document_create_classifier "$@" fi diff --git a/docker/rootfs/usr/local/bin/document_exporter b/docker/rootfs/usr/local/bin/document_exporter index 7f48215d7..d55f01d48 100755 --- a/docker/rootfs/usr/local/bin/document_exporter +++ b/docker/rootfs/usr/local/bin/document_exporter @@ -5,10 +5,8 @@ set -e cd "${PAPERLESS_SRC_DIR}" -if [[ $(id -u) == 0 ]]; then - s6-setuidgid paperless python3 manage.py document_exporter "$@" +if [[ -n "${USER_IS_NON_ROOT}" ]]; then + python3 manage.py document_exporter "$@" elif [[ $(id -un) == "paperless" ]]; then - python3 manage.py document_exporter "$@" -else - echo "Unknown user." + s6-setuidgid paperless python3 manage.py document_exporter "$@" fi diff --git a/docker/rootfs/usr/local/bin/document_fuzzy_match b/docker/rootfs/usr/local/bin/document_fuzzy_match index 5b9548557..c6e4edadc 100755 --- a/docker/rootfs/usr/local/bin/document_fuzzy_match +++ b/docker/rootfs/usr/local/bin/document_fuzzy_match @@ -5,10 +5,8 @@ set -e cd "${PAPERLESS_SRC_DIR}" -if [[ $(id -u) == 0 ]]; then - s6-setuidgid paperless python3 manage.py document_fuzzy_match "$@" +if [[ -n "${USER_IS_NON_ROOT}" ]]; then + python3 manage.py document_fuzzy_match "$@" elif [[ $(id -un) == "paperless" ]]; then - python3 manage.py document_fuzzy_match "$@" -else - echo "Unknown user." + s6-setuidgid paperless python3 manage.py document_fuzzy_match "$@" fi diff --git a/docker/rootfs/usr/local/bin/document_importer b/docker/rootfs/usr/local/bin/document_importer index 2286e89f7..07c92bb04 100755 --- a/docker/rootfs/usr/local/bin/document_importer +++ b/docker/rootfs/usr/local/bin/document_importer @@ -5,10 +5,8 @@ set -e cd "${PAPERLESS_SRC_DIR}" -if [[ $(id -u) == 0 ]]; then - s6-setuidgid paperless python3 manage.py document_importer "$@" +if [[ -n "${USER_IS_NON_ROOT}" ]]; then + python3 manage.py document_importer "$@" elif [[ $(id -un) == "paperless" ]]; then - python3 manage.py document_importer "$@" -else - echo "Unknown user." + s6-setuidgid paperless python3 manage.py document_importer "$@" fi diff --git a/docker/rootfs/usr/local/bin/document_index b/docker/rootfs/usr/local/bin/document_index index 2d518b5c5..47c893c10 100755 --- a/docker/rootfs/usr/local/bin/document_index +++ b/docker/rootfs/usr/local/bin/document_index @@ -5,10 +5,8 @@ set -e cd "${PAPERLESS_SRC_DIR}" -if [[ $(id -u) == 0 ]]; then - s6-setuidgid paperless python3 manage.py document_index "$@" +if [[ -n "${USER_IS_NON_ROOT}" ]]; then + python3 manage.py document_index "$@" elif [[ $(id -un) == "paperless" ]]; then - python3 manage.py document_index "$@" -else - echo "Unknown user." + s6-setuidgid paperless python3 manage.py document_index "$@" fi diff --git a/docker/rootfs/usr/local/bin/document_renamer b/docker/rootfs/usr/local/bin/document_renamer index 326317a73..3406182ee 100755 --- a/docker/rootfs/usr/local/bin/document_renamer +++ b/docker/rootfs/usr/local/bin/document_renamer @@ -5,10 +5,8 @@ set -e cd "${PAPERLESS_SRC_DIR}" -if [[ $(id -u) == 0 ]]; then - s6-setuidgid paperless python3 manage.py document_renamer "$@" +if [[ -n "${USER_IS_NON_ROOT}" ]]; then + python3 manage.py document_renamer "$@" elif [[ $(id -un) == "paperless" ]]; then - python3 manage.py document_renamer "$@" -else - echo "Unknown user." + s6-setuidgid paperless python3 manage.py document_renamer "$@" fi diff --git a/docker/rootfs/usr/local/bin/document_retagger b/docker/rootfs/usr/local/bin/document_retagger index 3bab3e790..b0d1047ff 100755 --- a/docker/rootfs/usr/local/bin/document_retagger +++ b/docker/rootfs/usr/local/bin/document_retagger @@ -5,10 +5,8 @@ set -e cd "${PAPERLESS_SRC_DIR}" -if [[ $(id -u) == 0 ]]; then - s6-setuidgid paperless python3 manage.py document_retagger "$@" +if [[ -n "${USER_IS_NON_ROOT}" ]]; then + python3 manage.py document_retagger "$@" elif [[ $(id -un) == "paperless" ]]; then - python3 manage.py document_retagger "$@" -else - echo "Unknown user." + s6-setuidgid paperless python3 manage.py document_retagger "$@" fi diff --git a/docker/rootfs/usr/local/bin/document_sanity_checker b/docker/rootfs/usr/local/bin/document_sanity_checker index 5c0c29ef2..d792124fc 100755 --- a/docker/rootfs/usr/local/bin/document_sanity_checker +++ b/docker/rootfs/usr/local/bin/document_sanity_checker @@ -5,10 +5,8 @@ set -e cd "${PAPERLESS_SRC_DIR}" -if [[ $(id -u) == 0 ]]; then - s6-setuidgid paperless python3 manage.py document_sanity_checker "$@" +if [[ -n "${USER_IS_NON_ROOT}" ]]; then + python3 manage.py document_sanity_checker "$@" elif [[ $(id -un) == "paperless" ]]; then - python3 manage.py document_sanity_checker "$@" -else - echo "Unknown user." + s6-setuidgid paperless python3 manage.py document_sanity_checker "$@" fi diff --git a/docker/rootfs/usr/local/bin/document_thumbnails b/docker/rootfs/usr/local/bin/document_thumbnails index c1000c31a..71d80e00d 100755 --- a/docker/rootfs/usr/local/bin/document_thumbnails +++ b/docker/rootfs/usr/local/bin/document_thumbnails @@ -5,10 +5,8 @@ set -e cd "${PAPERLESS_SRC_DIR}" -if [[ $(id -u) == 0 ]]; then - s6-setuidgid paperless python3 manage.py document_thumbnails "$@" +if [[ -n "${USER_IS_NON_ROOT}" ]]; then + python3 manage.py document_thumbnails "$@" elif [[ $(id -un) == "paperless" ]]; then - python3 manage.py document_thumbnails "$@" -else - echo "Unknown user." + s6-setuidgid paperless python3 manage.py document_thumbnails "$@" fi diff --git a/docker/rootfs/usr/local/bin/mail_fetcher b/docker/rootfs/usr/local/bin/mail_fetcher index 2ae1d1dfb..654c07389 100755 --- a/docker/rootfs/usr/local/bin/mail_fetcher +++ b/docker/rootfs/usr/local/bin/mail_fetcher @@ -5,10 +5,8 @@ set -e cd "${PAPERLESS_SRC_DIR}" -if [[ $(id -u) == 0 ]]; then - s6-setuidgid paperless python3 manage.py mail_fetcher "$@" +if [[ -n "${USER_IS_NON_ROOT}" ]]; then + python3 manage.py mail_fetcher "$@" elif [[ $(id -un) == "paperless" ]]; then - python3 manage.py mail_fetcher "$@" -else - echo "Unknown user." + s6-setuidgid paperless python3 manage.py mail_fetcher "$@" fi diff --git a/docker/rootfs/usr/local/bin/manage_superuser b/docker/rootfs/usr/local/bin/manage_superuser index 9f7f37ecf..a6e41168c 100755 --- a/docker/rootfs/usr/local/bin/manage_superuser +++ b/docker/rootfs/usr/local/bin/manage_superuser @@ -5,10 +5,8 @@ set -e cd "${PAPERLESS_SRC_DIR}" -if [[ $(id -u) == 0 ]]; then - s6-setuidgid paperless python3 manage.py manage_superuser "$@" +if [[ -n "${USER_IS_NON_ROOT}" ]]; then + python3 manage.py manage_superuser "$@" elif [[ $(id -un) == "paperless" ]]; then - python3 manage.py manage_superuser "$@" -else - echo "Unknown user." + s6-setuidgid paperless python3 manage.py manage_superuser "$@" fi diff --git a/docker/rootfs/usr/local/bin/prune_audit_logs b/docker/rootfs/usr/local/bin/prune_audit_logs index b9142e98e..04446df17 100755 --- a/docker/rootfs/usr/local/bin/prune_audit_logs +++ b/docker/rootfs/usr/local/bin/prune_audit_logs @@ -5,10 +5,8 @@ set -e cd "${PAPERLESS_SRC_DIR}" -if [[ $(id -u) == 0 ]]; then - s6-setuidgid paperless python3 manage.py prune_audit_logs "$@" +if [[ -n "${USER_IS_NON_ROOT}" ]]; then + python3 manage.py prune_audit_logs "$@" elif [[ $(id -un) == "paperless" ]]; then - python3 manage.py prune_audit_logs "$@" -else - echo "Unknown user." + s6-setuidgid paperless python3 manage.py prune_audit_logs "$@" fi From 45f5025f78c6e2d5724edf7a66fe618d9a42ec2a Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sun, 25 Jan 2026 13:45:50 -0800 Subject: [PATCH 04/33] Enhancement: Add 'any of' workflow trigger filters (#11683) --- .../workflow-edit-dialog.component.spec.ts | 49 ++++++++- .../workflow-edit-dialog.component.ts | 72 ++++++++++++ src-ui/src/app/data/workflow-trigger.ts | 6 + src/documents/matching.py | 49 +++++++++ ..._filter_has_any_correspondents_and_more.py | 43 ++++++++ src/documents/models.py | 21 ++++ src/documents/serialisers.py | 27 +++++ src/documents/tests/test_api_workflows.py | 30 +++++ src/documents/tests/test_workflows.py | 103 ++++++++++++++++++ 9 files changed, 398 insertions(+), 2 deletions(-) create mode 100644 src/documents/migrations/0005_workflowtrigger_filter_has_any_correspondents_and_more.py diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts index fafc9e876..ac8a5d2c7 100644 --- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts +++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts @@ -412,6 +412,9 @@ describe('WorkflowEditDialogComponent', () => { return newFilter } + const correspondentAny = addFilterOfType(TriggerFilterType.CorrespondentAny) + correspondentAny.get('values').setValue([11]) + const correspondentIs = addFilterOfType(TriggerFilterType.CorrespondentIs) correspondentIs.get('values').setValue(1) @@ -421,12 +424,18 @@ describe('WorkflowEditDialogComponent', () => { const documentTypeIs = addFilterOfType(TriggerFilterType.DocumentTypeIs) documentTypeIs.get('values').setValue(1) + const documentTypeAny = addFilterOfType(TriggerFilterType.DocumentTypeAny) + documentTypeAny.get('values').setValue([12]) + const documentTypeNot = addFilterOfType(TriggerFilterType.DocumentTypeNot) documentTypeNot.get('values').setValue([1]) const storagePathIs = addFilterOfType(TriggerFilterType.StoragePathIs) storagePathIs.get('values').setValue(1) + const storagePathAny = addFilterOfType(TriggerFilterType.StoragePathAny) + storagePathAny.get('values').setValue([13]) + const storagePathNot = addFilterOfType(TriggerFilterType.StoragePathNot) storagePathNot.get('values').setValue([1]) @@ -441,10 +450,13 @@ describe('WorkflowEditDialogComponent', () => { expect(formValues.triggers[0].filter_has_tags).toEqual([1]) expect(formValues.triggers[0].filter_has_all_tags).toEqual([2, 3]) expect(formValues.triggers[0].filter_has_not_tags).toEqual([4]) + expect(formValues.triggers[0].filter_has_any_correspondents).toEqual([11]) expect(formValues.triggers[0].filter_has_correspondent).toEqual(1) expect(formValues.triggers[0].filter_has_not_correspondents).toEqual([1]) + expect(formValues.triggers[0].filter_has_any_document_types).toEqual([12]) expect(formValues.triggers[0].filter_has_document_type).toEqual(1) expect(formValues.triggers[0].filter_has_not_document_types).toEqual([1]) + expect(formValues.triggers[0].filter_has_any_storage_paths).toEqual([13]) expect(formValues.triggers[0].filter_has_storage_path).toEqual(1) expect(formValues.triggers[0].filter_has_not_storage_paths).toEqual([1]) expect(formValues.triggers[0].filter_custom_field_query).toEqual( @@ -507,16 +519,22 @@ describe('WorkflowEditDialogComponent', () => { setFilter(TriggerFilterType.TagsAll, 11) setFilter(TriggerFilterType.TagsNone, 12) + setFilter(TriggerFilterType.CorrespondentAny, 16) setFilter(TriggerFilterType.CorrespondentNot, 13) + setFilter(TriggerFilterType.DocumentTypeAny, 17) setFilter(TriggerFilterType.DocumentTypeNot, 14) + setFilter(TriggerFilterType.StoragePathAny, 18) setFilter(TriggerFilterType.StoragePathNot, 15) const formValues = component['getFormValues']() expect(formValues.triggers[0].filter_has_all_tags).toEqual([11]) expect(formValues.triggers[0].filter_has_not_tags).toEqual([12]) + expect(formValues.triggers[0].filter_has_any_correspondents).toEqual([16]) expect(formValues.triggers[0].filter_has_not_correspondents).toEqual([13]) + expect(formValues.triggers[0].filter_has_any_document_types).toEqual([17]) expect(formValues.triggers[0].filter_has_not_document_types).toEqual([14]) + expect(formValues.triggers[0].filter_has_any_storage_paths).toEqual([18]) expect(formValues.triggers[0].filter_has_not_storage_paths).toEqual([15]) }) @@ -640,8 +658,11 @@ describe('WorkflowEditDialogComponent', () => { filter_has_tags: [], filter_has_all_tags: [], filter_has_not_tags: [], + filter_has_any_correspondents: [], filter_has_not_correspondents: [], + filter_has_any_document_types: [], filter_has_not_document_types: [], + filter_has_any_storage_paths: [], filter_has_not_storage_paths: [], filter_has_correspondent: null, filter_has_document_type: null, @@ -699,11 +720,14 @@ describe('WorkflowEditDialogComponent', () => { trigger.filter_has_tags = [1] trigger.filter_has_all_tags = [2, 3] trigger.filter_has_not_tags = [4] + trigger.filter_has_any_correspondents = [10] as any trigger.filter_has_correspondent = 5 as any trigger.filter_has_not_correspondents = [6] as any trigger.filter_has_document_type = 7 as any + trigger.filter_has_any_document_types = [11] as any trigger.filter_has_not_document_types = [8] as any trigger.filter_has_storage_path = 9 as any + trigger.filter_has_any_storage_paths = [12] as any trigger.filter_has_not_storage_paths = [10] as any trigger.filter_custom_field_query = JSON.stringify([ 'AND', @@ -714,8 +738,8 @@ describe('WorkflowEditDialogComponent', () => { component.ngOnInit() const triggerGroup = component.triggerFields.at(0) as FormGroup const filters = component.getFiltersFormArray(triggerGroup) - expect(filters.length).toBe(10) - const customFieldFilter = filters.at(9) as FormGroup + expect(filters.length).toBe(13) + const customFieldFilter = filters.at(12) as FormGroup expect(customFieldFilter.get('type').value).toBe( TriggerFilterType.CustomFieldQuery ) @@ -724,12 +748,27 @@ describe('WorkflowEditDialogComponent', () => { }) it('should expose select metadata helpers', () => { + expect(component.isSelectMultiple(TriggerFilterType.CorrespondentAny)).toBe( + true + ) expect(component.isSelectMultiple(TriggerFilterType.CorrespondentNot)).toBe( true ) expect(component.isSelectMultiple(TriggerFilterType.CorrespondentIs)).toBe( false ) + expect(component.isSelectMultiple(TriggerFilterType.DocumentTypeAny)).toBe( + true + ) + expect(component.isSelectMultiple(TriggerFilterType.DocumentTypeIs)).toBe( + false + ) + expect(component.isSelectMultiple(TriggerFilterType.StoragePathAny)).toBe( + true + ) + expect(component.isSelectMultiple(TriggerFilterType.StoragePathIs)).toBe( + false + ) component.correspondents = [{ id: 1, name: 'C1' } as any] component.documentTypes = [{ id: 2, name: 'DT' } as any] @@ -741,9 +780,15 @@ describe('WorkflowEditDialogComponent', () => { expect( component.getFilterSelectItems(TriggerFilterType.DocumentTypeIs) ).toEqual(component.documentTypes) + expect( + component.getFilterSelectItems(TriggerFilterType.DocumentTypeAny) + ).toEqual(component.documentTypes) expect( component.getFilterSelectItems(TriggerFilterType.StoragePathIs) ).toEqual(component.storagePaths) + expect( + component.getFilterSelectItems(TriggerFilterType.StoragePathAny) + ).toEqual(component.storagePaths) expect(component.getFilterSelectItems(TriggerFilterType.TagsAll)).toEqual( [] ) diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts index 74221e3f0..94d8318e0 100644 --- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts +++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts @@ -145,10 +145,13 @@ export enum TriggerFilterType { TagsAny = 'tags_any', TagsAll = 'tags_all', TagsNone = 'tags_none', + CorrespondentAny = 'correspondent_any', CorrespondentIs = 'correspondent_is', CorrespondentNot = 'correspondent_not', + DocumentTypeAny = 'document_type_any', DocumentTypeIs = 'document_type_is', DocumentTypeNot = 'document_type_not', + StoragePathAny = 'storage_path_any', StoragePathIs = 'storage_path_is', StoragePathNot = 'storage_path_not', CustomFieldQuery = 'custom_field_query', @@ -172,8 +175,11 @@ type TriggerFilterAggregate = { filter_has_tags: number[] filter_has_all_tags: number[] filter_has_not_tags: number[] + filter_has_any_correspondents: number[] filter_has_not_correspondents: number[] + filter_has_any_document_types: number[] filter_has_not_document_types: number[] + filter_has_any_storage_paths: number[] filter_has_not_storage_paths: number[] filter_has_correspondent: number | null filter_has_document_type: number | null @@ -219,6 +225,14 @@ const TRIGGER_FILTER_DEFINITIONS: TriggerFilterDefinition[] = [ allowMultipleEntries: false, allowMultipleValues: true, }, + { + id: TriggerFilterType.CorrespondentAny, + name: $localize`Has any of these correspondents`, + inputType: 'select', + allowMultipleEntries: false, + allowMultipleValues: true, + selectItems: 'correspondents', + }, { id: TriggerFilterType.CorrespondentIs, name: $localize`Has correspondent`, @@ -243,6 +257,14 @@ const TRIGGER_FILTER_DEFINITIONS: TriggerFilterDefinition[] = [ allowMultipleValues: false, selectItems: 'documentTypes', }, + { + id: TriggerFilterType.DocumentTypeAny, + name: $localize`Has any of these document types`, + inputType: 'select', + allowMultipleEntries: false, + allowMultipleValues: true, + selectItems: 'documentTypes', + }, { id: TriggerFilterType.DocumentTypeNot, name: $localize`Does not have document types`, @@ -259,6 +281,14 @@ const TRIGGER_FILTER_DEFINITIONS: TriggerFilterDefinition[] = [ allowMultipleValues: false, selectItems: 'storagePaths', }, + { + id: TriggerFilterType.StoragePathAny, + name: $localize`Has any of these storage paths`, + inputType: 'select', + allowMultipleEntries: false, + allowMultipleValues: true, + selectItems: 'storagePaths', + }, { id: TriggerFilterType.StoragePathNot, name: $localize`Does not have storage paths`, @@ -306,6 +336,15 @@ const FILTER_HANDLERS: Record = { extract: (trigger) => trigger.filter_has_not_tags, hasValue: (value) => Array.isArray(value) && value.length > 0, }, + [TriggerFilterType.CorrespondentAny]: { + apply: (aggregate, values) => { + aggregate.filter_has_any_correspondents = Array.isArray(values) + ? [...values] + : [values] + }, + extract: (trigger) => trigger.filter_has_any_correspondents, + hasValue: (value) => Array.isArray(value) && value.length > 0, + }, [TriggerFilterType.CorrespondentIs]: { apply: (aggregate, values) => { aggregate.filter_has_correspondent = Array.isArray(values) @@ -333,6 +372,15 @@ const FILTER_HANDLERS: Record = { extract: (trigger) => trigger.filter_has_document_type, hasValue: (value) => value !== null && value !== undefined, }, + [TriggerFilterType.DocumentTypeAny]: { + apply: (aggregate, values) => { + aggregate.filter_has_any_document_types = Array.isArray(values) + ? [...values] + : [values] + }, + extract: (trigger) => trigger.filter_has_any_document_types, + hasValue: (value) => Array.isArray(value) && value.length > 0, + }, [TriggerFilterType.DocumentTypeNot]: { apply: (aggregate, values) => { aggregate.filter_has_not_document_types = Array.isArray(values) @@ -351,6 +399,15 @@ const FILTER_HANDLERS: Record = { extract: (trigger) => trigger.filter_has_storage_path, hasValue: (value) => value !== null && value !== undefined, }, + [TriggerFilterType.StoragePathAny]: { + apply: (aggregate, values) => { + aggregate.filter_has_any_storage_paths = Array.isArray(values) + ? [...values] + : [values] + }, + extract: (trigger) => trigger.filter_has_any_storage_paths, + hasValue: (value) => Array.isArray(value) && value.length > 0, + }, [TriggerFilterType.StoragePathNot]: { apply: (aggregate, values) => { aggregate.filter_has_not_storage_paths = Array.isArray(values) @@ -642,8 +699,11 @@ export class WorkflowEditDialogComponent filter_has_tags: [], filter_has_all_tags: [], filter_has_not_tags: [], + filter_has_any_correspondents: [], filter_has_not_correspondents: [], + filter_has_any_document_types: [], filter_has_not_document_types: [], + filter_has_any_storage_paths: [], filter_has_not_storage_paths: [], filter_has_correspondent: null, filter_has_document_type: null, @@ -670,10 +730,16 @@ export class WorkflowEditDialogComponent trigger.filter_has_tags = aggregate.filter_has_tags trigger.filter_has_all_tags = aggregate.filter_has_all_tags trigger.filter_has_not_tags = aggregate.filter_has_not_tags + trigger.filter_has_any_correspondents = + aggregate.filter_has_any_correspondents trigger.filter_has_not_correspondents = aggregate.filter_has_not_correspondents + trigger.filter_has_any_document_types = + aggregate.filter_has_any_document_types trigger.filter_has_not_document_types = aggregate.filter_has_not_document_types + trigger.filter_has_any_storage_paths = + aggregate.filter_has_any_storage_paths trigger.filter_has_not_storage_paths = aggregate.filter_has_not_storage_paths trigger.filter_has_correspondent = @@ -856,8 +922,11 @@ export class WorkflowEditDialogComponent case TriggerFilterType.TagsAny: case TriggerFilterType.TagsAll: case TriggerFilterType.TagsNone: + case TriggerFilterType.CorrespondentAny: case TriggerFilterType.CorrespondentNot: + case TriggerFilterType.DocumentTypeAny: case TriggerFilterType.DocumentTypeNot: + case TriggerFilterType.StoragePathAny: case TriggerFilterType.StoragePathNot: return true default: @@ -1179,8 +1248,11 @@ export class WorkflowEditDialogComponent filter_has_tags: [], filter_has_all_tags: [], filter_has_not_tags: [], + filter_has_any_correspondents: [], filter_has_not_correspondents: [], + filter_has_any_document_types: [], filter_has_not_document_types: [], + filter_has_any_storage_paths: [], filter_has_not_storage_paths: [], filter_custom_field_query: null, filter_has_correspondent: null, diff --git a/src-ui/src/app/data/workflow-trigger.ts b/src-ui/src/app/data/workflow-trigger.ts index 888b18cc3..2bc89f188 100644 --- a/src-ui/src/app/data/workflow-trigger.ts +++ b/src-ui/src/app/data/workflow-trigger.ts @@ -44,10 +44,16 @@ export interface WorkflowTrigger extends ObjectWithId { filter_has_not_tags?: number[] // Tag.id[] + filter_has_any_correspondents?: number[] // Correspondent.id[] + filter_has_not_correspondents?: number[] // Correspondent.id[] + filter_has_any_document_types?: number[] // DocumentType.id[] + filter_has_not_document_types?: number[] // DocumentType.id[] + filter_has_any_storage_paths?: number[] // StoragePath.id[] + filter_has_not_storage_paths?: number[] // StoragePath.id[] filter_custom_field_query?: string diff --git a/src/documents/matching.py b/src/documents/matching.py index 198ead64c..9276ad583 100644 --- a/src/documents/matching.py +++ b/src/documents/matching.py @@ -403,6 +403,18 @@ def existing_document_matches_workflow( f"Document tags {list(document.tags.all())} include excluded tags {list(trigger_has_not_tags_qs)}", ) + allowed_correspondent_ids = set( + trigger.filter_has_any_correspondents.values_list("id", flat=True), + ) + if ( + allowed_correspondent_ids + and document.correspondent_id not in allowed_correspondent_ids + ): + return ( + False, + f"Document correspondent {document.correspondent} is not one of {list(trigger.filter_has_any_correspondents.all())}", + ) + # Document correspondent vs trigger has_correspondent if ( trigger.filter_has_correspondent_id is not None @@ -424,6 +436,17 @@ def existing_document_matches_workflow( f"Document correspondent {document.correspondent} is excluded by {list(trigger.filter_has_not_correspondents.all())}", ) + allowed_document_type_ids = set( + trigger.filter_has_any_document_types.values_list("id", flat=True), + ) + if allowed_document_type_ids and ( + document.document_type_id not in allowed_document_type_ids + ): + return ( + False, + f"Document doc type {document.document_type} is not one of {list(trigger.filter_has_any_document_types.all())}", + ) + # Document document_type vs trigger has_document_type if ( trigger.filter_has_document_type_id is not None @@ -445,6 +468,17 @@ def existing_document_matches_workflow( f"Document doc type {document.document_type} is excluded by {list(trigger.filter_has_not_document_types.all())}", ) + allowed_storage_path_ids = set( + trigger.filter_has_any_storage_paths.values_list("id", flat=True), + ) + if allowed_storage_path_ids and ( + document.storage_path_id not in allowed_storage_path_ids + ): + return ( + False, + f"Document storage path {document.storage_path} is not one of {list(trigger.filter_has_any_storage_paths.all())}", + ) + # Document storage_path vs trigger has_storage_path if ( trigger.filter_has_storage_path_id is not None @@ -532,6 +566,10 @@ def prefilter_documents_by_workflowtrigger( # Correspondent, DocumentType, etc. filtering + if trigger.filter_has_any_correspondents.exists(): + documents = documents.filter( + correspondent__in=trigger.filter_has_any_correspondents.all(), + ) if trigger.filter_has_correspondent is not None: documents = documents.filter( correspondent=trigger.filter_has_correspondent, @@ -541,6 +579,10 @@ def prefilter_documents_by_workflowtrigger( correspondent__in=trigger.filter_has_not_correspondents.all(), ) + if trigger.filter_has_any_document_types.exists(): + documents = documents.filter( + document_type__in=trigger.filter_has_any_document_types.all(), + ) if trigger.filter_has_document_type is not None: documents = documents.filter( document_type=trigger.filter_has_document_type, @@ -550,6 +592,10 @@ def prefilter_documents_by_workflowtrigger( document_type__in=trigger.filter_has_not_document_types.all(), ) + if trigger.filter_has_any_storage_paths.exists(): + documents = documents.filter( + storage_path__in=trigger.filter_has_any_storage_paths.all(), + ) if trigger.filter_has_storage_path is not None: documents = documents.filter( storage_path=trigger.filter_has_storage_path, @@ -604,8 +650,11 @@ def document_matches_workflow( "filter_has_tags", "filter_has_all_tags", "filter_has_not_tags", + "filter_has_any_document_types", "filter_has_not_document_types", + "filter_has_any_correspondents", "filter_has_not_correspondents", + "filter_has_any_storage_paths", "filter_has_not_storage_paths", ) ) diff --git a/src/documents/migrations/0005_workflowtrigger_filter_has_any_correspondents_and_more.py b/src/documents/migrations/0005_workflowtrigger_filter_has_any_correspondents_and_more.py new file mode 100644 index 000000000..db5ef5754 --- /dev/null +++ b/src/documents/migrations/0005_workflowtrigger_filter_has_any_correspondents_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.7 on 2025-12-17 22:25 + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + dependencies = [ + ("documents", "0004_remove_document_storage_type"), + ] + + operations = [ + migrations.AddField( + model_name="workflowtrigger", + name="filter_has_any_correspondents", + field=models.ManyToManyField( + blank=True, + related_name="workflowtriggers_has_any_correspondent", + to="documents.correspondent", + verbose_name="has one of these correspondents", + ), + ), + migrations.AddField( + model_name="workflowtrigger", + name="filter_has_any_document_types", + field=models.ManyToManyField( + blank=True, + related_name="workflowtriggers_has_any_document_type", + to="documents.documenttype", + verbose_name="has one of these document types", + ), + ), + migrations.AddField( + model_name="workflowtrigger", + name="filter_has_any_storage_paths", + field=models.ManyToManyField( + blank=True, + related_name="workflowtriggers_has_any_storage_path", + to="documents.storagepath", + verbose_name="has one of these storage paths", + ), + ), + ] diff --git a/src/documents/models.py b/src/documents/models.py index 88d33f1fe..0ea525d49 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -1066,6 +1066,13 @@ class WorkflowTrigger(models.Model): verbose_name=_("has this document type"), ) + filter_has_any_document_types = models.ManyToManyField( + DocumentType, + blank=True, + related_name="workflowtriggers_has_any_document_type", + verbose_name=_("has one of these document types"), + ) + filter_has_not_document_types = models.ManyToManyField( DocumentType, blank=True, @@ -1088,6 +1095,13 @@ class WorkflowTrigger(models.Model): verbose_name=_("does not have these correspondent(s)"), ) + filter_has_any_correspondents = models.ManyToManyField( + Correspondent, + blank=True, + related_name="workflowtriggers_has_any_correspondent", + verbose_name=_("has one of these correspondents"), + ) + filter_has_storage_path = models.ForeignKey( StoragePath, null=True, @@ -1096,6 +1110,13 @@ class WorkflowTrigger(models.Model): verbose_name=_("has this storage path"), ) + filter_has_any_storage_paths = models.ManyToManyField( + StoragePath, + blank=True, + related_name="workflowtriggers_has_any_storage_path", + verbose_name=_("has one of these storage paths"), + ) + filter_has_not_storage_paths = models.ManyToManyField( StoragePath, blank=True, diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index a265b036b..d9e5c22e0 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -2299,8 +2299,11 @@ class WorkflowTriggerSerializer(serializers.ModelSerializer): "filter_has_all_tags", "filter_has_not_tags", "filter_custom_field_query", + "filter_has_any_correspondents", "filter_has_not_correspondents", + "filter_has_any_document_types", "filter_has_not_document_types", + "filter_has_any_storage_paths", "filter_has_not_storage_paths", "filter_has_correspondent", "filter_has_document_type", @@ -2538,14 +2541,26 @@ class WorkflowSerializer(serializers.ModelSerializer): filter_has_tags = trigger.pop("filter_has_tags", None) filter_has_all_tags = trigger.pop("filter_has_all_tags", None) filter_has_not_tags = trigger.pop("filter_has_not_tags", None) + filter_has_any_correspondents = trigger.pop( + "filter_has_any_correspondents", + None, + ) filter_has_not_correspondents = trigger.pop( "filter_has_not_correspondents", None, ) + filter_has_any_document_types = trigger.pop( + "filter_has_any_document_types", + None, + ) filter_has_not_document_types = trigger.pop( "filter_has_not_document_types", None, ) + filter_has_any_storage_paths = trigger.pop( + "filter_has_any_storage_paths", + None, + ) filter_has_not_storage_paths = trigger.pop( "filter_has_not_storage_paths", None, @@ -2562,14 +2577,26 @@ class WorkflowSerializer(serializers.ModelSerializer): trigger_instance.filter_has_all_tags.set(filter_has_all_tags) if filter_has_not_tags is not None: trigger_instance.filter_has_not_tags.set(filter_has_not_tags) + if filter_has_any_correspondents is not None: + trigger_instance.filter_has_any_correspondents.set( + filter_has_any_correspondents, + ) if filter_has_not_correspondents is not None: trigger_instance.filter_has_not_correspondents.set( filter_has_not_correspondents, ) + if filter_has_any_document_types is not None: + trigger_instance.filter_has_any_document_types.set( + filter_has_any_document_types, + ) if filter_has_not_document_types is not None: trigger_instance.filter_has_not_document_types.set( filter_has_not_document_types, ) + if filter_has_any_storage_paths is not None: + trigger_instance.filter_has_any_storage_paths.set( + filter_has_any_storage_paths, + ) if filter_has_not_storage_paths is not None: trigger_instance.filter_has_not_storage_paths.set( filter_has_not_storage_paths, diff --git a/src/documents/tests/test_api_workflows.py b/src/documents/tests/test_api_workflows.py index 9efdb8451..1d3efd457 100644 --- a/src/documents/tests/test_api_workflows.py +++ b/src/documents/tests/test_api_workflows.py @@ -186,8 +186,11 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase): "filter_has_tags": [self.t1.id], "filter_has_all_tags": [self.t2.id], "filter_has_not_tags": [self.t3.id], + "filter_has_any_correspondents": [self.c.id], "filter_has_not_correspondents": [self.c2.id], + "filter_has_any_document_types": [self.dt.id], "filter_has_not_document_types": [self.dt2.id], + "filter_has_any_storage_paths": [self.sp.id], "filter_has_not_storage_paths": [self.sp2.id], "filter_custom_field_query": json.dumps( [ @@ -248,14 +251,26 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase): set(trigger.filter_has_not_tags.values_list("id", flat=True)), {self.t3.id}, ) + self.assertSetEqual( + set(trigger.filter_has_any_correspondents.values_list("id", flat=True)), + {self.c.id}, + ) self.assertSetEqual( set(trigger.filter_has_not_correspondents.values_list("id", flat=True)), {self.c2.id}, ) + self.assertSetEqual( + set(trigger.filter_has_any_document_types.values_list("id", flat=True)), + {self.dt.id}, + ) self.assertSetEqual( set(trigger.filter_has_not_document_types.values_list("id", flat=True)), {self.dt2.id}, ) + self.assertSetEqual( + set(trigger.filter_has_any_storage_paths.values_list("id", flat=True)), + {self.sp.id}, + ) self.assertSetEqual( set(trigger.filter_has_not_storage_paths.values_list("id", flat=True)), {self.sp2.id}, @@ -419,8 +434,11 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase): "filter_has_tags": [self.t1.id], "filter_has_all_tags": [self.t2.id], "filter_has_not_tags": [self.t3.id], + "filter_has_any_correspondents": [self.c.id], "filter_has_not_correspondents": [self.c2.id], + "filter_has_any_document_types": [self.dt.id], "filter_has_not_document_types": [self.dt2.id], + "filter_has_any_storage_paths": [self.sp.id], "filter_has_not_storage_paths": [self.sp2.id], "filter_custom_field_query": json.dumps( ["AND", [[self.cf1.id, "exact", "value"]]], @@ -450,14 +468,26 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase): workflow.triggers.first().filter_has_not_tags.first(), self.t3, ) + self.assertEqual( + workflow.triggers.first().filter_has_any_correspondents.first(), + self.c, + ) self.assertEqual( workflow.triggers.first().filter_has_not_correspondents.first(), self.c2, ) + self.assertEqual( + workflow.triggers.first().filter_has_any_document_types.first(), + self.dt, + ) self.assertEqual( workflow.triggers.first().filter_has_not_document_types.first(), self.dt2, ) + self.assertEqual( + workflow.triggers.first().filter_has_any_storage_paths.first(), + self.sp, + ) self.assertEqual( workflow.triggers.first().filter_has_not_storage_paths.first(), self.sp2, diff --git a/src/documents/tests/test_workflows.py b/src/documents/tests/test_workflows.py index d2f843a68..75f9d5fe6 100644 --- a/src/documents/tests/test_workflows.py +++ b/src/documents/tests/test_workflows.py @@ -1276,6 +1276,76 @@ class TestWorkflows( ) self.assertIn(expected_str, cm.output[1]) + def test_document_added_any_filters(self): + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED, + ) + trigger.filter_has_any_correspondents.set([self.c]) + trigger.filter_has_any_document_types.set([self.dt]) + trigger.filter_has_any_storage_paths.set([self.sp]) + + matching_doc = Document.objects.create( + title="sample test", + correspondent=self.c, + document_type=self.dt, + storage_path=self.sp, + original_filename="sample.pdf", + checksum="checksum-any-match", + ) + + matched, reason = existing_document_matches_workflow(matching_doc, trigger) + self.assertTrue(matched) + self.assertIsNone(reason) + + wrong_correspondent = Document.objects.create( + title="wrong correspondent", + correspondent=self.c2, + document_type=self.dt, + storage_path=self.sp, + original_filename="sample2.pdf", + ) + matched, reason = existing_document_matches_workflow( + wrong_correspondent, + trigger, + ) + self.assertFalse(matched) + self.assertIn("correspondent", reason) + + other_document_type = DocumentType.objects.create(name="Other") + wrong_document_type = Document.objects.create( + title="wrong doc type", + correspondent=self.c, + document_type=other_document_type, + storage_path=self.sp, + original_filename="sample3.pdf", + checksum="checksum-wrong-doc-type", + ) + matched, reason = existing_document_matches_workflow( + wrong_document_type, + trigger, + ) + self.assertFalse(matched) + self.assertIn("doc type", reason) + + other_storage_path = StoragePath.objects.create( + name="Other path", + path="/other/", + ) + wrong_storage_path = Document.objects.create( + title="wrong storage", + correspondent=self.c, + document_type=self.dt, + storage_path=other_storage_path, + original_filename="sample4.pdf", + checksum="checksum-wrong-storage-path", + ) + matched, reason = existing_document_matches_workflow( + wrong_storage_path, + trigger, + ) + self.assertFalse(matched) + self.assertIn("storage path", reason) + def test_document_added_custom_field_query_no_match(self): trigger = WorkflowTrigger.objects.create( type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED, @@ -1384,6 +1454,39 @@ class TestWorkflows( self.assertIn(doc1, filtered) self.assertNotIn(doc2, filtered) + def test_prefilter_documents_any_filters(self): + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED, + ) + trigger.filter_has_any_correspondents.set([self.c]) + trigger.filter_has_any_document_types.set([self.dt]) + trigger.filter_has_any_storage_paths.set([self.sp]) + + allowed_document = Document.objects.create( + title="allowed", + correspondent=self.c, + document_type=self.dt, + storage_path=self.sp, + original_filename="doc-allowed.pdf", + checksum="checksum-any-allowed", + ) + blocked_document = Document.objects.create( + title="blocked", + correspondent=self.c2, + document_type=self.dt, + storage_path=self.sp, + original_filename="doc-blocked.pdf", + checksum="checksum-any-blocked", + ) + + filtered = prefilter_documents_by_workflowtrigger( + Document.objects.all(), + trigger, + ) + + self.assertIn(allowed_document, filtered) + self.assertNotIn(blocked_document, filtered) + def test_consumption_trigger_requires_filter_configuration(self): serializer = WorkflowTriggerSerializer( data={ From aecf42d1ab29d3188631a6370834d1c038545b7e Mon Sep 17 00:00:00 2001 From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 25 Jan 2026 21:47:42 +0000 Subject: [PATCH 05/33] Auto translate strings --- src-ui/messages.xlf | 45 ++++-- src/locale/en_US/LC_MESSAGES/django.po | 204 +++++++++++++------------ 2 files changed, 141 insertions(+), 108 deletions(-) diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index 5cab6203c..2af2fcbf3 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -5257,84 +5257,105 @@ Has any of these tags src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 203 + 209 Has all of these tags src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 210 + 216 Does not have these tags src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 217 + 223 + + + + Has any of these correspondents + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts + 230 Has correspondent src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 224 + 238 Does not have correspondents src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 232 + 246 Has document type src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 240 + 254 + + + + Has any of these document types + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts + 262 Does not have document types src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 248 + 270 Has storage path src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 256 + 278 + + + + Has any of these storage paths + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts + 286 Does not have storage paths src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 264 + 294 Matches custom field query src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 272 + 302 Create new workflow src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 474 + 531 Edit workflow src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 478 + 535 diff --git a/src/locale/en_US/LC_MESSAGES/django.po b/src/locale/en_US/LC_MESSAGES/django.po index 7e4bf0abf..7bc9a9801 100644 --- a/src/locale/en_US/LC_MESSAGES/django.po +++ b/src/locale/en_US/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: paperless-ngx\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-01-25 03:30+0000\n" +"POT-Creation-Date: 2026-01-25 21:46+0000\n" "PO-Revision-Date: 2022-02-17 04:17\n" "Last-Translator: \n" "Language-Team: English\n" @@ -89,7 +89,7 @@ msgstr "" msgid "Automatic" msgstr "" -#: documents/models.py:64 documents/models.py:434 documents/models.py:1507 +#: documents/models.py:64 documents/models.py:434 documents/models.py:1528 #: paperless_mail/models.py:23 paperless_mail/models.py:143 msgid "name" msgstr "" @@ -252,7 +252,7 @@ msgid "The position of this document in your physical document archive." msgstr "" #: documents/models.py:303 documents/models.py:678 documents/models.py:732 -#: documents/models.py:1550 +#: documents/models.py:1571 msgid "document" msgstr "" @@ -869,346 +869,358 @@ msgid "has this document type" msgstr "" #: documents/models.py:1073 +msgid "has one of these document types" +msgstr "" + +#: documents/models.py:1080 msgid "does not have these document type(s)" msgstr "" -#: documents/models.py:1081 +#: documents/models.py:1088 msgid "has this correspondent" msgstr "" -#: documents/models.py:1088 +#: documents/models.py:1095 msgid "does not have these correspondent(s)" msgstr "" -#: documents/models.py:1096 -msgid "has this storage path" -msgstr "" - -#: documents/models.py:1103 -msgid "does not have these storage path(s)" -msgstr "" - -#: documents/models.py:1107 -msgid "filter custom field query" +#: documents/models.py:1102 +msgid "has one of these correspondents" msgstr "" #: documents/models.py:1110 -msgid "JSON-encoded custom field query expression." -msgstr "" - -#: documents/models.py:1114 -msgid "schedule offset days" +msgid "has this storage path" msgstr "" #: documents/models.py:1117 +msgid "has one of these storage paths" +msgstr "" + +#: documents/models.py:1124 +msgid "does not have these storage path(s)" +msgstr "" + +#: documents/models.py:1128 +msgid "filter custom field query" +msgstr "" + +#: documents/models.py:1131 +msgid "JSON-encoded custom field query expression." +msgstr "" + +#: documents/models.py:1135 +msgid "schedule offset days" +msgstr "" + +#: documents/models.py:1138 msgid "The number of days to offset the schedule trigger by." msgstr "" -#: documents/models.py:1122 +#: documents/models.py:1143 msgid "schedule is recurring" msgstr "" -#: documents/models.py:1125 +#: documents/models.py:1146 msgid "If the schedule should be recurring." msgstr "" -#: documents/models.py:1130 +#: documents/models.py:1151 msgid "schedule recurring delay in days" msgstr "" -#: documents/models.py:1134 +#: documents/models.py:1155 msgid "The number of days between recurring schedule triggers." msgstr "" -#: documents/models.py:1139 +#: documents/models.py:1160 msgid "schedule date field" msgstr "" -#: documents/models.py:1144 +#: documents/models.py:1165 msgid "The field to check for a schedule trigger." msgstr "" -#: documents/models.py:1153 +#: documents/models.py:1174 msgid "schedule date custom field" msgstr "" -#: documents/models.py:1157 +#: documents/models.py:1178 msgid "workflow trigger" msgstr "" -#: documents/models.py:1158 +#: documents/models.py:1179 msgid "workflow triggers" msgstr "" -#: documents/models.py:1166 +#: documents/models.py:1187 msgid "email subject" msgstr "" -#: documents/models.py:1170 +#: documents/models.py:1191 msgid "" "The subject of the email, can include some placeholders, see documentation." msgstr "" -#: documents/models.py:1176 +#: documents/models.py:1197 msgid "email body" msgstr "" -#: documents/models.py:1179 +#: documents/models.py:1200 msgid "" "The body (message) of the email, can include some placeholders, see " "documentation." msgstr "" -#: documents/models.py:1185 +#: documents/models.py:1206 msgid "emails to" msgstr "" -#: documents/models.py:1188 +#: documents/models.py:1209 msgid "The destination email addresses, comma separated." msgstr "" -#: documents/models.py:1194 +#: documents/models.py:1215 msgid "include document in email" msgstr "" -#: documents/models.py:1205 +#: documents/models.py:1226 msgid "webhook url" msgstr "" -#: documents/models.py:1208 +#: documents/models.py:1229 msgid "The destination URL for the notification." msgstr "" -#: documents/models.py:1213 +#: documents/models.py:1234 msgid "use parameters" msgstr "" -#: documents/models.py:1218 +#: documents/models.py:1239 msgid "send as JSON" msgstr "" -#: documents/models.py:1222 +#: documents/models.py:1243 msgid "webhook parameters" msgstr "" -#: documents/models.py:1225 +#: documents/models.py:1246 msgid "The parameters to send with the webhook URL if body not used." msgstr "" -#: documents/models.py:1229 +#: documents/models.py:1250 msgid "webhook body" msgstr "" -#: documents/models.py:1232 +#: documents/models.py:1253 msgid "The body to send with the webhook URL if parameters not used." msgstr "" -#: documents/models.py:1236 +#: documents/models.py:1257 msgid "webhook headers" msgstr "" -#: documents/models.py:1239 +#: documents/models.py:1260 msgid "The headers to send with the webhook URL." msgstr "" -#: documents/models.py:1244 +#: documents/models.py:1265 msgid "include document in webhook" msgstr "" -#: documents/models.py:1255 +#: documents/models.py:1276 msgid "Assignment" msgstr "" -#: documents/models.py:1259 +#: documents/models.py:1280 msgid "Removal" msgstr "" -#: documents/models.py:1263 documents/templates/account/password_reset.html:15 +#: documents/models.py:1284 documents/templates/account/password_reset.html:15 msgid "Email" msgstr "" -#: documents/models.py:1267 +#: documents/models.py:1288 msgid "Webhook" msgstr "" -#: documents/models.py:1271 +#: documents/models.py:1292 msgid "Workflow Action Type" msgstr "" -#: documents/models.py:1276 documents/models.py:1509 +#: documents/models.py:1297 documents/models.py:1530 #: paperless_mail/models.py:145 msgid "order" msgstr "" -#: documents/models.py:1279 +#: documents/models.py:1300 msgid "assign title" msgstr "" -#: documents/models.py:1283 +#: documents/models.py:1304 msgid "Assign a document title, must be a Jinja2 template, see documentation." msgstr "" -#: documents/models.py:1291 paperless_mail/models.py:274 +#: documents/models.py:1312 paperless_mail/models.py:274 msgid "assign this tag" msgstr "" -#: documents/models.py:1300 paperless_mail/models.py:282 +#: documents/models.py:1321 paperless_mail/models.py:282 msgid "assign this document type" msgstr "" -#: documents/models.py:1309 paperless_mail/models.py:296 +#: documents/models.py:1330 paperless_mail/models.py:296 msgid "assign this correspondent" msgstr "" -#: documents/models.py:1318 +#: documents/models.py:1339 msgid "assign this storage path" msgstr "" -#: documents/models.py:1327 +#: documents/models.py:1348 msgid "assign this owner" msgstr "" -#: documents/models.py:1334 +#: documents/models.py:1355 msgid "grant view permissions to these users" msgstr "" -#: documents/models.py:1341 +#: documents/models.py:1362 msgid "grant view permissions to these groups" msgstr "" -#: documents/models.py:1348 +#: documents/models.py:1369 msgid "grant change permissions to these users" msgstr "" -#: documents/models.py:1355 +#: documents/models.py:1376 msgid "grant change permissions to these groups" msgstr "" -#: documents/models.py:1362 +#: documents/models.py:1383 msgid "assign these custom fields" msgstr "" -#: documents/models.py:1366 +#: documents/models.py:1387 msgid "custom field values" msgstr "" -#: documents/models.py:1370 +#: documents/models.py:1391 msgid "Optional values to assign to the custom fields." msgstr "" -#: documents/models.py:1379 +#: documents/models.py:1400 msgid "remove these tag(s)" msgstr "" -#: documents/models.py:1384 +#: documents/models.py:1405 msgid "remove all tags" msgstr "" -#: documents/models.py:1391 +#: documents/models.py:1412 msgid "remove these document type(s)" msgstr "" -#: documents/models.py:1396 +#: documents/models.py:1417 msgid "remove all document types" msgstr "" -#: documents/models.py:1403 +#: documents/models.py:1424 msgid "remove these correspondent(s)" msgstr "" -#: documents/models.py:1408 +#: documents/models.py:1429 msgid "remove all correspondents" msgstr "" -#: documents/models.py:1415 +#: documents/models.py:1436 msgid "remove these storage path(s)" msgstr "" -#: documents/models.py:1420 +#: documents/models.py:1441 msgid "remove all storage paths" msgstr "" -#: documents/models.py:1427 +#: documents/models.py:1448 msgid "remove these owner(s)" msgstr "" -#: documents/models.py:1432 +#: documents/models.py:1453 msgid "remove all owners" msgstr "" -#: documents/models.py:1439 +#: documents/models.py:1460 msgid "remove view permissions for these users" msgstr "" -#: documents/models.py:1446 +#: documents/models.py:1467 msgid "remove view permissions for these groups" msgstr "" -#: documents/models.py:1453 +#: documents/models.py:1474 msgid "remove change permissions for these users" msgstr "" -#: documents/models.py:1460 +#: documents/models.py:1481 msgid "remove change permissions for these groups" msgstr "" -#: documents/models.py:1465 +#: documents/models.py:1486 msgid "remove all permissions" msgstr "" -#: documents/models.py:1472 +#: documents/models.py:1493 msgid "remove these custom fields" msgstr "" -#: documents/models.py:1477 +#: documents/models.py:1498 msgid "remove all custom fields" msgstr "" -#: documents/models.py:1486 +#: documents/models.py:1507 msgid "email" msgstr "" -#: documents/models.py:1495 +#: documents/models.py:1516 msgid "webhook" msgstr "" -#: documents/models.py:1499 +#: documents/models.py:1520 msgid "workflow action" msgstr "" -#: documents/models.py:1500 +#: documents/models.py:1521 msgid "workflow actions" msgstr "" -#: documents/models.py:1515 +#: documents/models.py:1536 msgid "triggers" msgstr "" -#: documents/models.py:1522 +#: documents/models.py:1543 msgid "actions" msgstr "" -#: documents/models.py:1525 paperless_mail/models.py:154 +#: documents/models.py:1546 paperless_mail/models.py:154 msgid "enabled" msgstr "" -#: documents/models.py:1536 +#: documents/models.py:1557 msgid "workflow" msgstr "" -#: documents/models.py:1540 +#: documents/models.py:1561 msgid "workflow trigger type" msgstr "" -#: documents/models.py:1554 +#: documents/models.py:1575 msgid "date run" msgstr "" -#: documents/models.py:1560 +#: documents/models.py:1581 msgid "workflow run" msgstr "" -#: documents/models.py:1561 +#: documents/models.py:1582 msgid "workflow runs" msgstr "" From 5c9ff367e35e9ef3cdb670631f7411962dae1c69 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sun, 25 Jan 2026 16:56:51 -0800 Subject: [PATCH 06/33] Fixhancement: change date calculation for 'this year' to include future documents (#11884) --- src/documents/index.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/documents/index.py b/src/documents/index.py index ea26ea926..8afc31fe9 100644 --- a/src/documents/index.py +++ b/src/documents/index.py @@ -602,7 +602,7 @@ def rewrite_natural_date_keywords(query_string: str) -> str: case "this year": start = datetime(local_now.year, 1, 1, 0, 0, 0, tzinfo=tz) - end = datetime.combine(today, time.max, tzinfo=tz) + end = datetime(local_now.year, 12, 31, 23, 59, 59, tzinfo=tz) case "previous week": days_since_monday = local_now.weekday() From 72e8b73108dcdd5a586e6f03014405d4b4629d17 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sun, 25 Jan 2026 17:08:15 -0800 Subject: [PATCH 07/33] Fix test --- src/documents/tests/test_index.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/documents/tests/test_index.py b/src/documents/tests/test_index.py index 3167bb762..ef6b535f7 100644 --- a/src/documents/tests/test_index.py +++ b/src/documents/tests/test_index.py @@ -180,7 +180,7 @@ class TestRewriteNaturalDateKeywords(SimpleTestCase): ( "added:this year", datetime(2025, 7, 15, 12, 0, 0, tzinfo=timezone.utc), - ("added:[20250101", "TO 20250715"), + ("added:[20250101", "TO 20251231"), ), ( "added:previous year", From 2312314aa739a3c12dd83ccf42dec22f06db2dd0 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 12 Jan 2026 13:04:03 -0800 Subject: [PATCH 08/33] Performance: improve treenode inefficiencies (#11606) --- src/documents/serialisers.py | 44 ++++++++++++++++++++---------------- src/documents/views.py | 35 ++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 20 deletions(-) diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 0648aa0b3..e96400eff 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -580,30 +580,34 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer): ), ) def get_children(self, obj): - filter_q = self.context.get("document_count_filter") - request = self.context.get("request") - if filter_q is None: - user = getattr(request, "user", None) if request else None - filter_q = get_document_count_filter_for_user(user) - self.context["document_count_filter"] = filter_q + children_map = self.context.get("children_map") + if children_map is not None: + children = children_map.get(obj.pk, []) + else: + filter_q = self.context.get("document_count_filter") + request = self.context.get("request") + if filter_q is None: + user = getattr(request, "user", None) if request else None + filter_q = get_document_count_filter_for_user(user) + self.context["document_count_filter"] = filter_q - children_queryset = ( - obj.get_children_queryset() - .select_related("owner") - .annotate(document_count=Count("documents", filter=filter_q)) - ) + children = ( + obj.get_children_queryset() + .select_related("owner") + .annotate(document_count=Count("documents", filter=filter_q)) + ) - view = self.context.get("view") - ordering = ( - OrderingFilter().get_ordering(request, children_queryset, view) - if request and view - else None - ) - ordering = ordering or (Lower("name"),) - children_queryset = children_queryset.order_by(*ordering) + view = self.context.get("view") + ordering = ( + OrderingFilter().get_ordering(request, children, view) + if request and view + else None + ) + ordering = ordering or (Lower("name"),) + children = children.order_by(*ordering) serializer = TagSerializer( - children_queryset, + children, many=True, user=self.user, full_perms=self.full_perms, diff --git a/src/documents/views.py b/src/documents/views.py index d5910497f..4e0460dc9 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -448,8 +448,43 @@ class TagViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin): def get_serializer_context(self): context = super().get_serializer_context() context["document_count_filter"] = self.get_document_count_filter() + if hasattr(self, "_children_map"): + context["children_map"] = self._children_map return context + def list(self, request, *args, **kwargs): + """ + Build a children map once to avoid per-parent queries in the serializer. + """ + queryset = self.filter_queryset(self.get_queryset()) + ordering = OrderingFilter().get_ordering(request, queryset, self) or ( + Lower("name"), + ) + queryset = queryset.order_by(*ordering) + + all_tags = list(queryset) + descendant_pks = {pk for tag in all_tags for pk in tag.get_descendants_pks()} + + if descendant_pks: + filter_q = self.get_document_count_filter() + children_source = ( + Tag.objects.filter(pk__in=descendant_pks | {t.pk for t in all_tags}) + .select_related("owner") + .annotate(document_count=Count("documents", filter=filter_q)) + .order_by(*ordering) + ) + else: + children_source = all_tags + + children_map = {} + for tag in children_source: + children_map.setdefault(tag.tn_parent_id, []).append(tag) + self._children_map = children_map + + page = self.paginate_queryset(queryset) + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + def perform_update(self, serializer): old_parent = self.get_object().get_parent() tag = serializer.save() From f2bb6c9725afa1426e9469751096f7afb02b69e3 Mon Sep 17 00:00:00 2001 From: Paul Gessinger Date: Mon, 26 Jan 2026 09:29:36 +0100 Subject: [PATCH 09/33] Enhancement: Add support for app oidc (#11756) --------- Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com> --- docs/api.md | 10 +++- src/paperless/adapter.py | 11 +++++ src/paperless/settings.py | 2 + src/paperless/tests/test_adapter.py | 75 +++++++++++++++++++++++++++++ src/paperless/urls.py | 1 + 5 files changed, 98 insertions(+), 1 deletion(-) diff --git a/docs/api.md b/docs/api.md index 1ac634162..ced8eb5b3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -8,7 +8,7 @@ Further documentation is provided here for some endpoints and features. ## Authorization -The REST api provides four different forms of authentication. +The REST api provides five different forms of authentication. 1. Basic authentication @@ -52,6 +52,14 @@ The REST api provides four different forms of authentication. [configuration](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER_API)), you can authenticate against the API using Remote User auth. +5. Headless OIDC via [`django-allauth`](https://codeberg.org/allauth/django-allauth) + + `django-allauth` exposes API endpoints under `api/auth/` which enable tools + like third-party apps to authenticate with social accounts that are + configured. See + [here](advanced_usage.md#openid-connect-and-social-authentication) for more + information on social accounts. + ## Searching for documents Full text searching is available on the `/api/documents/` endpoint. Two diff --git a/src/paperless/adapter.py b/src/paperless/adapter.py index a4506275e..f1f478141 100644 --- a/src/paperless/adapter.py +++ b/src/paperless/adapter.py @@ -3,12 +3,15 @@ from urllib.parse import quote from allauth.account.adapter import DefaultAccountAdapter from allauth.core import context +from allauth.headless.tokens.sessions import SessionTokenStrategy from allauth.socialaccount.adapter import DefaultSocialAccountAdapter from django.conf import settings from django.contrib.auth.models import Group from django.contrib.auth.models import User from django.forms import ValidationError +from django.http import HttpRequest from django.urls import reverse +from rest_framework.authtoken.models import Token from documents.models import Document from paperless.signals import handle_social_account_updated @@ -159,3 +162,11 @@ class CustomSocialAccountAdapter(DefaultSocialAccountAdapter): exception, extra_context, ) + + +class DrfTokenStrategy(SessionTokenStrategy): + def create_access_token(self, request: HttpRequest) -> str | None: + if not request.user.is_authenticated: + return None + token, _ = Token.objects.get_or_create(user=request.user) + return token.key diff --git a/src/paperless/settings.py b/src/paperless/settings.py index 30ee213d1..532a2bc36 100644 --- a/src/paperless/settings.py +++ b/src/paperless/settings.py @@ -345,6 +345,7 @@ INSTALLED_APPS = [ "allauth.account", "allauth.socialaccount", "allauth.mfa", + "allauth.headless", "drf_spectacular", "drf_spectacular_sidecar", "treenode", @@ -539,6 +540,7 @@ SOCIALACCOUNT_PROVIDERS = json.loads( ) SOCIAL_ACCOUNT_DEFAULT_GROUPS = __get_list("PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS") SOCIAL_ACCOUNT_SYNC_GROUPS = __get_boolean("PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS") +HEADLESS_TOKEN_STRATEGY = "paperless.adapter.DrfTokenStrategy" MFA_TOTP_ISSUER = "Paperless-ngx" diff --git a/src/paperless/tests/test_adapter.py b/src/paperless/tests/test_adapter.py index 37b8aaa3b..dbef3fde7 100644 --- a/src/paperless/tests/test_adapter.py +++ b/src/paperless/tests/test_adapter.py @@ -4,6 +4,7 @@ from allauth.account.adapter import get_adapter from allauth.core import context from allauth.socialaccount.adapter import get_adapter as get_social_adapter from django.conf import settings +from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import Group from django.contrib.auth.models import User from django.forms import ValidationError @@ -11,6 +12,9 @@ from django.http import HttpRequest from django.test import TestCase from django.test import override_settings from django.urls import reverse +from rest_framework.authtoken.models import Token + +from paperless.adapter import DrfTokenStrategy class TestCustomAccountAdapter(TestCase): @@ -181,3 +185,74 @@ class TestCustomSocialAccountAdapter(TestCase): self.assertTrue( any("Test authentication error" in message for message in log_cm.output), ) + + +class TestDrfTokenStrategy(TestCase): + def test_create_access_token_creates_new_token(self): + """ + GIVEN: + - A user with no existing DRF token + WHEN: + - create_access_token is called + THEN: + - A new token is created and its key is returned + """ + + user = User.objects.create_user("testuser") + request = HttpRequest() + request.user = user + + strategy = DrfTokenStrategy() + token_key = strategy.create_access_token(request) + + # Verify a token was created + self.assertIsNotNone(token_key) + self.assertTrue(Token.objects.filter(user=user).exists()) + + # Verify the returned key matches the created token + token = Token.objects.get(user=user) + self.assertEqual(token_key, token.key) + + def test_create_access_token_returns_existing_token(self): + """ + GIVEN: + - A user with an existing DRF token + WHEN: + - create_access_token is called again + THEN: + - The same token key is returned (no new token created) + """ + + user = User.objects.create_user("testuser") + existing_token = Token.objects.create(user=user) + + request = HttpRequest() + request.user = user + + strategy = DrfTokenStrategy() + token_key = strategy.create_access_token(request) + + # Verify the existing token key is returned + self.assertEqual(token_key, existing_token.key) + + # Verify only one token exists (no duplicate created) + self.assertEqual(Token.objects.filter(user=user).count(), 1) + + def test_create_access_token_returns_none_for_unauthenticated_user(self): + """ + GIVEN: + - An unauthenticated request + WHEN: + - create_access_token is called + THEN: + - None is returned and no token is created + """ + + request = HttpRequest() + request.user = AnonymousUser() + + strategy = DrfTokenStrategy() + token_key = strategy.create_access_token(request) + + self.assertIsNone(token_key) + self.assertEqual(Token.objects.count(), 0) diff --git a/src/paperless/urls.py b/src/paperless/urls.py index 179af14e0..ce5c68494 100644 --- a/src/paperless/urls.py +++ b/src/paperless/urls.py @@ -228,6 +228,7 @@ urlpatterns = [ ], ), ), + re_path("^auth/headless/", include("allauth.headless.urls")), re_path( "^$", # Redirect to the API swagger view RedirectView.as_view(url="schema/view/"), From 991d3cef88b919a71766fe28a59cced9a2ceb8c6 Mon Sep 17 00:00:00 2001 From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 08:31:35 +0000 Subject: [PATCH 10/33] Auto translate strings --- src/locale/en_US/LC_MESSAGES/django.po | 78 +++++++++++++------------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/src/locale/en_US/LC_MESSAGES/django.po b/src/locale/en_US/LC_MESSAGES/django.po index 7bc9a9801..e652d13a9 100644 --- a/src/locale/en_US/LC_MESSAGES/django.po +++ b/src/locale/en_US/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: paperless-ngx\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-01-25 21:46+0000\n" +"POT-Creation-Date: 2026-01-26 08:30+0000\n" "PO-Revision-Date: 2022-02-17 04:17\n" "Last-Translator: \n" "Language-Team: English\n" @@ -1747,155 +1747,155 @@ msgstr "" msgid "paperless application settings" msgstr "" -#: paperless/settings.py:800 +#: paperless/settings.py:802 msgid "English (US)" msgstr "" -#: paperless/settings.py:801 +#: paperless/settings.py:803 msgid "Arabic" msgstr "" -#: paperless/settings.py:802 +#: paperless/settings.py:804 msgid "Afrikaans" msgstr "" -#: paperless/settings.py:803 +#: paperless/settings.py:805 msgid "Belarusian" msgstr "" -#: paperless/settings.py:804 +#: paperless/settings.py:806 msgid "Bulgarian" msgstr "" -#: paperless/settings.py:805 +#: paperless/settings.py:807 msgid "Catalan" msgstr "" -#: paperless/settings.py:806 +#: paperless/settings.py:808 msgid "Czech" msgstr "" -#: paperless/settings.py:807 +#: paperless/settings.py:809 msgid "Danish" msgstr "" -#: paperless/settings.py:808 +#: paperless/settings.py:810 msgid "German" msgstr "" -#: paperless/settings.py:809 +#: paperless/settings.py:811 msgid "Greek" msgstr "" -#: paperless/settings.py:810 +#: paperless/settings.py:812 msgid "English (GB)" msgstr "" -#: paperless/settings.py:811 +#: paperless/settings.py:813 msgid "Spanish" msgstr "" -#: paperless/settings.py:812 +#: paperless/settings.py:814 msgid "Persian" msgstr "" -#: paperless/settings.py:813 +#: paperless/settings.py:815 msgid "Finnish" msgstr "" -#: paperless/settings.py:814 +#: paperless/settings.py:816 msgid "French" msgstr "" -#: paperless/settings.py:815 +#: paperless/settings.py:817 msgid "Hungarian" msgstr "" -#: paperless/settings.py:816 +#: paperless/settings.py:818 msgid "Indonesian" msgstr "" -#: paperless/settings.py:817 +#: paperless/settings.py:819 msgid "Italian" msgstr "" -#: paperless/settings.py:818 +#: paperless/settings.py:820 msgid "Japanese" msgstr "" -#: paperless/settings.py:819 +#: paperless/settings.py:821 msgid "Korean" msgstr "" -#: paperless/settings.py:820 +#: paperless/settings.py:822 msgid "Luxembourgish" msgstr "" -#: paperless/settings.py:821 +#: paperless/settings.py:823 msgid "Norwegian" msgstr "" -#: paperless/settings.py:822 +#: paperless/settings.py:824 msgid "Dutch" msgstr "" -#: paperless/settings.py:823 +#: paperless/settings.py:825 msgid "Polish" msgstr "" -#: paperless/settings.py:824 +#: paperless/settings.py:826 msgid "Portuguese (Brazil)" msgstr "" -#: paperless/settings.py:825 +#: paperless/settings.py:827 msgid "Portuguese" msgstr "" -#: paperless/settings.py:826 +#: paperless/settings.py:828 msgid "Romanian" msgstr "" -#: paperless/settings.py:827 +#: paperless/settings.py:829 msgid "Russian" msgstr "" -#: paperless/settings.py:828 +#: paperless/settings.py:830 msgid "Slovak" msgstr "" -#: paperless/settings.py:829 +#: paperless/settings.py:831 msgid "Slovenian" msgstr "" -#: paperless/settings.py:830 +#: paperless/settings.py:832 msgid "Serbian" msgstr "" -#: paperless/settings.py:831 +#: paperless/settings.py:833 msgid "Swedish" msgstr "" -#: paperless/settings.py:832 +#: paperless/settings.py:834 msgid "Turkish" msgstr "" -#: paperless/settings.py:833 +#: paperless/settings.py:835 msgid "Ukrainian" msgstr "" -#: paperless/settings.py:834 +#: paperless/settings.py:836 msgid "Vietnamese" msgstr "" -#: paperless/settings.py:835 +#: paperless/settings.py:837 msgid "Chinese Simplified" msgstr "" -#: paperless/settings.py:836 +#: paperless/settings.py:838 msgid "Chinese Traditional" msgstr "" -#: paperless/urls.py:376 +#: paperless/urls.py:377 msgid "Paperless-ngx administration" msgstr "" From b6531aed2fffa4c513adf4c95cc758a446a6c3a3 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 26 Jan 2026 08:30:43 -0800 Subject: [PATCH 11/33] Tweakhancement: display document id, with copy (#11896) --- .../page-header/page-header.component.html | 15 ++++++++++++--- .../page-header/page-header.component.scss | 5 +++++ .../page-header/page-header.component.spec.ts | 18 +++++++++++++++++- .../page-header/page-header.component.ts | 17 ++++++++++++++++- .../document-detail.component.html | 2 +- 5 files changed, 51 insertions(+), 6 deletions(-) diff --git a/src-ui/src/app/components/common/page-header/page-header.component.html b/src-ui/src/app/components/common/page-header/page-header.component.html index 283218219..488fff59d 100644 --- a/src-ui/src/app/components/common/page-header/page-header.component.html +++ b/src-ui/src/app/components/common/page-header/page-header.component.html @@ -1,9 +1,18 @@
-

- {{title}} +

+ {{title}} + @if (id) { + + @if (copied) { +  Copied! + } @else { + ID: {{id}} + } + + } @if (subTitle) { - {{subTitle}} + {{subTitle}} } @if (info) { - - - +
+ + + +
+

+ +
+
+ Select: +
+
+ @if (selectedObjects.size > 0) { + + } + + +
+
+ + + +
@@ -31,7 +62,7 @@
- +
diff --git a/src-ui/src/app/components/manage/management-list/management-list.component.spec.ts b/src-ui/src/app/components/manage/management-list/management-list.component.spec.ts index c5a742f4d..dca1bb2c9 100644 --- a/src-ui/src/app/components/manage/management-list/management-list.component.spec.ts +++ b/src-ui/src/app/components/manage/management-list/management-list.component.spec.ts @@ -163,8 +163,7 @@ describe('ManagementListComponent', () => { const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const reloadSpy = jest.spyOn(component, 'reloadData') - const createButton = fixture.debugElement.queryAll(By.css('button'))[4] - createButton.triggerEventHandler('click') + component.openCreateDialog() expect(modal).not.toBeUndefined() const editDialog = modal.componentInstance as EditDialogComponent @@ -187,8 +186,7 @@ describe('ManagementListComponent', () => { const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const reloadSpy = jest.spyOn(component, 'reloadData') - const editButton = fixture.debugElement.queryAll(By.css('button'))[7] - editButton.triggerEventHandler('click') + component.openEditDialog(tags[0]) expect(modal).not.toBeUndefined() const editDialog = modal.componentInstance as EditDialogComponent @@ -212,8 +210,7 @@ describe('ManagementListComponent', () => { const deleteSpy = jest.spyOn(tagService, 'delete') const reloadSpy = jest.spyOn(component, 'reloadData') - const deleteButton = fixture.debugElement.queryAll(By.css('button'))[8] - deleteButton.triggerEventHandler('click') + component.openDeleteDialog(tags[0]) expect(modal).not.toBeUndefined() const editDialog = modal.componentInstance as ConfirmDialogComponent @@ -279,19 +276,84 @@ describe('ManagementListComponent', () => { expect(component.page).toEqual(1) }) - it('should support toggle all items in view', () => { + it('should support toggle select page in vew', () => { expect(component.selectedObjects.size).toEqual(0) - const toggleAllSpy = jest.spyOn(component, 'toggleAll') + const selectPageSpy = jest.spyOn(component, 'selectPage') const checkButton = fixture.debugElement.queryAll( By.css('input.form-check-input') )[0] - checkButton.nativeElement.dispatchEvent(new Event('click')) + checkButton.nativeElement.dispatchEvent(new Event('change')) checkButton.nativeElement.checked = true - checkButton.nativeElement.dispatchEvent(new Event('click')) - expect(toggleAllSpy).toHaveBeenCalled() + checkButton.nativeElement.dispatchEvent(new Event('change')) + expect(selectPageSpy).toHaveBeenCalled() expect(component.selectedObjects.size).toEqual(tags.length) }) + it('selectNone should clear selection and reset toggle flag', () => { + component.selectedObjects = new Set([tags[0].id, tags[1].id]) + component.togggleAll = true + + component.selectNone() + + expect(component.selectedObjects.size).toBe(0) + expect(component.togggleAll).toBe(false) + }) + + it('selectPage should select current page items or clear selection', () => { + component.selectPage(true) + expect(component.selectedObjects).toEqual(new Set(tags.map((t) => t.id))) + expect(component.togggleAll).toBe(true) + + component.togggleAll = true + component.selectPage(false) + expect(component.selectedObjects.size).toBe(0) + expect(component.togggleAll).toBe(false) + }) + + it('selectAll should use all IDs when collection size exists', () => { + ;(component as any).allIDs = [1, 2, 3, 4] + component.collectionSize = 4 + + component.selectAll() + + expect(component.selectedObjects).toEqual(new Set([1, 2, 3, 4])) + expect(component.togggleAll).toBe(true) + }) + + it('selectAll should clear selection when collection size is zero', () => { + component.selectedObjects = new Set([1]) + component.collectionSize = 0 + component.togggleAll = true + + component.selectAll() + + expect(component.selectedObjects.size).toBe(0) + expect(component.togggleAll).toBe(false) + }) + + it('toggleSelected should toggle object selection and update toggle state', () => { + component.toggleSelected(tags[0]) + expect(component.selectedObjects.has(tags[0].id)).toBe(true) + expect(component.togggleAll).toBe(false) + + component.toggleSelected(tags[1]) + component.toggleSelected(tags[2]) + expect(component.togggleAll).toBe(true) + + component.toggleSelected(tags[1]) + expect(component.selectedObjects.has(tags[1].id)).toBe(false) + expect(component.togggleAll).toBe(false) + }) + + it('areAllPageItemsSelected should return false when page has no selectable items', () => { + component.data = [] + component.selectedObjects.clear() + + expect((component as any).areAllPageItemsSelected()).toBe(false) + + component.data = tags + }) + it('should support bulk edit permissions', () => { const bulkEditPermsSpy = jest.spyOn(tagService, 'bulk_edit_objects') component.toggleSelected(tags[0]) diff --git a/src-ui/src/app/components/manage/management-list/management-list.component.ts b/src-ui/src/app/components/manage/management-list/management-list.component.ts index 29d6f3b38..daa6a0ea0 100644 --- a/src-ui/src/app/components/manage/management-list/management-list.component.ts +++ b/src-ui/src/app/components/manage/management-list/management-list.component.ts @@ -84,6 +84,7 @@ export abstract class ManagementListComponent public data: T[] = [] private unfilteredData: T[] = [] + private allIDs: number[] = [] public page = 1 @@ -172,6 +173,7 @@ export abstract class ManagementListComponent this.unfilteredData = c.results this.data = this.filterData(c.results) this.collectionSize = c.all?.length ?? c.count + this.allIDs = c.all }), delay(100) ) @@ -300,16 +302,6 @@ export abstract class ManagementListComponent return ownsAll } - toggleAll(event: PointerEvent) { - const checked = (event.target as HTMLInputElement).checked - this.togggleAll = checked - if (checked) { - this.selectedObjects = new Set(this.getSelectableIDs(this.data)) - } else { - this.clearSelection() - } - } - protected getSelectableIDs(objects: T[]): number[] { return objects.map((o) => o.id) } @@ -319,10 +311,38 @@ export abstract class ManagementListComponent this.selectedObjects.clear() } + selectNone() { + this.clearSelection() + } + + selectPage(select: boolean) { + if (select) { + this.selectedObjects = new Set(this.getSelectableIDs(this.data)) + this.togggleAll = this.areAllPageItemsSelected() + } else { + this.clearSelection() + } + } + + selectAll() { + if (!this.collectionSize) { + this.clearSelection() + return + } + this.selectedObjects = new Set(this.allIDs) + this.togggleAll = this.areAllPageItemsSelected() + } + toggleSelected(object) { this.selectedObjects.has(object.id) ? this.selectedObjects.delete(object.id) : this.selectedObjects.add(object.id) + this.togggleAll = this.areAllPageItemsSelected() + } + + protected areAllPageItemsSelected(): boolean { + const ids = this.getSelectableIDs(this.data) + return ids.length > 0 && ids.every((id) => this.selectedObjects.has(id)) } setPermissions() { diff --git a/src-ui/src/app/components/manage/storage-path-list/storage-path-list.component.ts b/src-ui/src/app/components/manage/storage-path-list/storage-path-list.component.ts index cac8637d7..3ab940521 100644 --- a/src-ui/src/app/components/manage/storage-path-list/storage-path-list.component.ts +++ b/src-ui/src/app/components/manage/storage-path-list/storage-path-list.component.ts @@ -13,6 +13,7 @@ import { IfPermissionsDirective } from 'src/app/directives/if-permissions.direct import { SortableDirective } from 'src/app/directives/sortable.directive' import { PermissionType } from 'src/app/services/permissions.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service' +import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component' import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component' import { PageHeaderComponent } from '../../common/page-header/page-header.component' import { ManagementListComponent } from '../management-list/management-list.component' @@ -34,6 +35,7 @@ import { ManagementListComponent } from '../management-list/management-list.comp NgbDropdownModule, NgbPaginationModule, NgxBootstrapIconsModule, + ClearableBadgeComponent, ], }) export class StoragePathListComponent extends ManagementListComponent { diff --git a/src-ui/src/app/components/manage/tag-list/tag-list.component.spec.ts b/src-ui/src/app/components/manage/tag-list/tag-list.component.spec.ts index 9b1923e43..51403379d 100644 --- a/src-ui/src/app/components/manage/tag-list/tag-list.component.spec.ts +++ b/src-ui/src/app/components/manage/tag-list/tag-list.component.spec.ts @@ -138,16 +138,12 @@ describe('TagListComponent', () => { } component.data = [parent as any] - const selectEvent = { target: { checked: true } } as unknown as PointerEvent - component.toggleAll(selectEvent) + component.selectPage(true) expect(component.selectedObjects.has(10)).toBe(true) expect(component.selectedObjects.has(11)).toBe(true) - const deselectEvent = { - target: { checked: false }, - } as unknown as PointerEvent - component.toggleAll(deselectEvent) + component.selectPage(false) expect(component.selectedObjects.size).toBe(0) }) }) diff --git a/src-ui/src/app/components/manage/tag-list/tag-list.component.ts b/src-ui/src/app/components/manage/tag-list/tag-list.component.ts index 544e99b58..87045a50a 100644 --- a/src-ui/src/app/components/manage/tag-list/tag-list.component.ts +++ b/src-ui/src/app/components/manage/tag-list/tag-list.component.ts @@ -13,6 +13,7 @@ import { IfPermissionsDirective } from 'src/app/directives/if-permissions.direct import { SortableDirective } from 'src/app/directives/sortable.directive' import { PermissionType } from 'src/app/services/permissions.service' import { TagService } from 'src/app/services/rest/tag.service' +import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component' import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component' import { PageHeaderComponent } from '../../common/page-header/page-header.component' import { ManagementListComponent } from '../management-list/management-list.component' @@ -34,6 +35,7 @@ import { ManagementListComponent } from '../management-list/management-list.comp NgbDropdownModule, NgbPaginationModule, NgxBootstrapIconsModule, + ClearableBadgeComponent, ], }) export class TagListComponent extends ManagementListComponent { From cafb0f202228a8a393952152926d4f1c1d689942 Mon Sep 17 00:00:00 2001 From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 17:51:20 +0000 Subject: [PATCH 15/33] Auto translate strings --- src-ui/messages.xlf | 346 +++++++++++++++++++++++++++++--------------- 1 file changed, 229 insertions(+), 117 deletions(-) diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index aa3c9746b..7bcc97bae 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -332,19 +332,19 @@ src/app/components/manage/management-list/management-list.component.html - 151 + 182 src/app/components/manage/management-list/management-list.component.html - 151 + 182 src/app/components/manage/management-list/management-list.component.html - 151 + 182 src/app/components/manage/management-list/management-list.component.html - 151 + 182 @@ -789,19 +789,19 @@ src/app/components/manage/management-list/management-list.component.html - 52 + 83 src/app/components/manage/management-list/management-list.component.html - 52 + 83 src/app/components/manage/management-list/management-list.component.html - 52 + 83 src/app/components/manage/management-list/management-list.component.html - 52 + 83 src/app/components/manage/saved-views/saved-views.component.html @@ -1262,19 +1262,19 @@ src/app/components/manage/management-list/management-list.component.html - 7 + 38 src/app/components/manage/management-list/management-list.component.html - 7 + 38 src/app/components/manage/management-list/management-list.component.html - 7 + 38 src/app/components/manage/management-list/management-list.component.html - 7 + 38 @@ -1633,22 +1633,6 @@ src/app/components/document-list/document-list.component.html 153 - - src/app/components/manage/management-list/management-list.component.html - 4 - - - src/app/components/manage/management-list/management-list.component.html - 4 - - - src/app/components/manage/management-list/management-list.component.html - 4 - - - src/app/components/manage/management-list/management-list.component.html - 4 - Filter by @@ -1733,35 +1717,35 @@ src/app/components/manage/management-list/management-list.component.html - 21 + 52 src/app/components/manage/management-list/management-list.component.html - 21 + 52 src/app/components/manage/management-list/management-list.component.html - 21 + 52 src/app/components/manage/management-list/management-list.component.html - 21 + 52 src/app/components/manage/management-list/management-list.component.html - 38 + 69 src/app/components/manage/management-list/management-list.component.html - 38 + 69 src/app/components/manage/management-list/management-list.component.html - 38 + 69 src/app/components/manage/management-list/management-list.component.html - 38 + 69 src/app/components/manage/workflows/workflows.component.html @@ -1853,19 +1837,19 @@ src/app/components/manage/management-list/management-list.component.html - 44 + 75 src/app/components/manage/management-list/management-list.component.html - 44 + 75 src/app/components/manage/management-list/management-list.component.html - 44 + 75 src/app/components/manage/management-list/management-list.component.html - 44 + 75 src/app/components/manage/saved-views/saved-views.component.html @@ -2184,55 +2168,55 @@ src/app/components/manage/management-list/management-list.component.html - 10 + 41 src/app/components/manage/management-list/management-list.component.html - 10 + 41 src/app/components/manage/management-list/management-list.component.html - 10 + 41 src/app/components/manage/management-list/management-list.component.html - 10 + 41 src/app/components/manage/management-list/management-list.component.html - 121 + 152 src/app/components/manage/management-list/management-list.component.html - 121 + 152 src/app/components/manage/management-list/management-list.component.html - 121 + 152 src/app/components/manage/management-list/management-list.component.html - 121 + 152 src/app/components/manage/management-list/management-list.component.html - 140 + 171 src/app/components/manage/management-list/management-list.component.html - 140 + 171 src/app/components/manage/management-list/management-list.component.html - 140 + 171 src/app/components/manage/management-list/management-list.component.html - 140 + 171 src/app/components/manage/management-list/management-list.component.ts - 247 + 249 src/app/components/manage/saved-views/saved-views.component.html @@ -2266,11 +2250,11 @@ src/app/components/manage/management-list/management-list.component.ts - 243 + 245 src/app/components/manage/management-list/management-list.component.ts - 366 + 386 @@ -2312,7 +2296,7 @@ src/app/components/manage/management-list/management-list.component.ts - 368 + 388 src/app/components/manage/workflows/workflows.component.ts @@ -2503,35 +2487,35 @@ src/app/components/manage/management-list/management-list.component.html - 120 + 151 src/app/components/manage/management-list/management-list.component.html - 120 + 151 src/app/components/manage/management-list/management-list.component.html - 120 + 151 src/app/components/manage/management-list/management-list.component.html - 120 + 151 src/app/components/manage/management-list/management-list.component.html - 137 + 168 src/app/components/manage/management-list/management-list.component.html - 137 + 168 src/app/components/manage/management-list/management-list.component.html - 137 + 168 src/app/components/manage/management-list/management-list.component.html - 137 + 168 src/app/components/manage/workflows/workflows.component.html @@ -2639,7 +2623,7 @@ src/app/components/manage/management-list/management-list.component.ts - 370 + 390 src/app/components/manage/workflows/workflows.component.ts @@ -3573,6 +3557,22 @@ src/app/components/document-list/document-list.component.html 30 + + src/app/components/manage/management-list/management-list.component.html + 32 + + + src/app/components/manage/management-list/management-list.component.html + 32 + + + src/app/components/manage/management-list/management-list.component.html + 32 + + + src/app/components/manage/management-list/management-list.component.html + 32 + Not @@ -4440,7 +4440,7 @@ src/app/components/manage/storage-path-list/storage-path-list.component.ts - 51 + 53 @@ -4515,7 +4515,7 @@ src/app/components/manage/tag-list/tag-list.component.ts - 51 + 53 @@ -5458,19 +5458,19 @@ src/app/components/manage/management-list/management-list.component.html - 13 + 44 src/app/components/manage/management-list/management-list.component.html - 13 + 44 src/app/components/manage/management-list/management-list.component.html - 13 + 44 src/app/components/manage/management-list/management-list.component.html - 13 + 44 @@ -6902,6 +6902,22 @@ src/app/components/document-list/document-list.component.html 27 + + src/app/components/manage/management-list/management-list.component.html + 29 + + + src/app/components/manage/management-list/management-list.component.html + 29 + + + src/app/components/manage/management-list/management-list.component.html + 29 + + + src/app/components/manage/management-list/management-list.component.html + 29 + of @@ -8075,6 +8091,22 @@ src/app/components/document-list/document-list.component.html 5 + + src/app/components/manage/management-list/management-list.component.html + 6 + + + src/app/components/manage/management-list/management-list.component.html + 6 + + + src/app/components/manage/management-list/management-list.component.html + 6 + + + src/app/components/manage/management-list/management-list.component.html + 6 + src/app/data/custom-field.ts 51 @@ -8086,6 +8118,22 @@ src/app/components/document-list/document-list.component.html 11 + + src/app/components/manage/management-list/management-list.component.html + 12 + + + src/app/components/manage/management-list/management-list.component.html + 12 + + + src/app/components/manage/management-list/management-list.component.html + 12 + + + src/app/components/manage/management-list/management-list.component.html + 12 + Select page @@ -8097,6 +8145,22 @@ src/app/components/document-list/document-list.component.ts 315 + + src/app/components/manage/management-list/management-list.component.html + 13 + + + src/app/components/manage/management-list/management-list.component.html + 13 + + + src/app/components/manage/management-list/management-list.component.html + 13 + + + src/app/components/manage/management-list/management-list.component.html + 13 + Select all @@ -8108,6 +8172,22 @@ src/app/components/document-list/document-list.component.ts 308 + + src/app/components/manage/management-list/management-list.component.html + 14 + + + src/app/components/manage/management-list/management-list.component.html + 14 + + + src/app/components/manage/management-list/management-list.component.html + 14 + + + src/app/components/manage/management-list/management-list.component.html + 14 + Select: @@ -8115,6 +8195,22 @@ src/app/components/document-list/document-list.component.html 18 + + src/app/components/manage/management-list/management-list.component.html + 20 + + + src/app/components/manage/management-list/management-list.component.html + 20 + + + src/app/components/manage/management-list/management-list.component.html + 20 + + + src/app/components/manage/management-list/management-list.component.html + 20 + None @@ -8122,9 +8218,25 @@ src/app/components/document-list/document-list.component.html 23 + + src/app/components/manage/management-list/management-list.component.html + 25 + + + src/app/components/manage/management-list/management-list.component.html + 25 + + + src/app/components/manage/management-list/management-list.component.html + 25 + + + src/app/components/manage/management-list/management-list.component.html + 25 + src/app/components/manage/management-list/management-list.component.ts - 124 + 125 src/app/data/matching-model.ts @@ -8690,28 +8802,28 @@ correspondent src/app/components/manage/correspondent-list/correspondent-list.component.ts - 49 + 51 correspondents src/app/components/manage/correspondent-list/correspondent-list.component.ts - 50 + 52 Last used src/app/components/manage/correspondent-list/correspondent-list.component.ts - 55 + 57 Do you really want to delete the correspondent ""? src/app/components/manage/correspondent-list/correspondent-list.component.ts - 80 + 82 @@ -8743,19 +8855,19 @@ src/app/components/manage/management-list/management-list.component.html - 129 + 160 src/app/components/manage/management-list/management-list.component.html - 129 + 160 src/app/components/manage/management-list/management-list.component.html - 129 + 160 src/app/components/manage/management-list/management-list.component.html - 129 + 160 @@ -8797,21 +8909,21 @@ document type src/app/components/manage/document-type-list/document-type-list.component.ts - 45 + 47 document types src/app/components/manage/document-type-list/document-type-list.component.ts - 46 + 48 Do you really want to delete the document type ""? src/app/components/manage/document-type-list/document-type-list.component.ts - 51 + 53 @@ -9082,7 +9194,7 @@ src/app/components/manage/management-list/management-list.component.ts - 353 + 373 @@ -9124,83 +9236,83 @@ Filter by: src/app/components/manage/management-list/management-list.component.html - 20 + 51 src/app/components/manage/management-list/management-list.component.html - 20 + 51 src/app/components/manage/management-list/management-list.component.html - 20 + 51 src/app/components/manage/management-list/management-list.component.html - 20 + 51 Matching src/app/components/manage/management-list/management-list.component.html - 39 + 70 src/app/components/manage/management-list/management-list.component.html - 39 + 70 src/app/components/manage/management-list/management-list.component.html - 39 + 70 src/app/components/manage/management-list/management-list.component.html - 39 + 70 Document count src/app/components/manage/management-list/management-list.component.html - 40 + 71 src/app/components/manage/management-list/management-list.component.html - 40 + 71 src/app/components/manage/management-list/management-list.component.html - 40 + 71 src/app/components/manage/management-list/management-list.component.html - 40 + 71 {VAR_PLURAL, plural, =1 {One } other { total }} src/app/components/manage/management-list/management-list.component.html - 67 + 98 src/app/components/manage/management-list/management-list.component.html - 67 + 98 src/app/components/manage/management-list/management-list.component.html - 67 + 98 src/app/components/manage/management-list/management-list.component.html - 67 + 98 Automatic src/app/components/manage/management-list/management-list.component.ts - 122 + 123 src/app/data/matching-model.ts @@ -9211,70 +9323,70 @@ Successfully created . src/app/components/manage/management-list/management-list.component.ts - 200 + 202 Error occurred while creating . src/app/components/manage/management-list/management-list.component.ts - 205 + 207 Successfully updated "". src/app/components/manage/management-list/management-list.component.ts - 220 + 222 Error occurred while saving . src/app/components/manage/management-list/management-list.component.ts - 225 + 227 Associated documents will not be deleted. src/app/components/manage/management-list/management-list.component.ts - 245 + 247 Error while deleting element src/app/components/manage/management-list/management-list.component.ts - 261 + 263 Permissions updated successfully src/app/components/manage/management-list/management-list.component.ts - 346 + 366 This operation will permanently delete all objects. src/app/components/manage/management-list/management-list.component.ts - 367 + 387 Objects deleted successfully src/app/components/manage/management-list/management-list.component.ts - 381 + 401 Error deleting objects src/app/components/manage/management-list/management-list.component.ts - 387 + 407 @@ -9351,42 +9463,42 @@ storage path src/app/components/manage/storage-path-list/storage-path-list.component.ts - 45 + 47 storage paths src/app/components/manage/storage-path-list/storage-path-list.component.ts - 46 + 48 Do you really want to delete the storage path ""? src/app/components/manage/storage-path-list/storage-path-list.component.ts - 62 + 64 tag src/app/components/manage/tag-list/tag-list.component.ts - 45 + 47 tags src/app/components/manage/tag-list/tag-list.component.ts - 46 + 48 Do you really want to delete the tag ""? src/app/components/manage/tag-list/tag-list.component.ts - 62 + 64 From e9e138e62cae6cb4c9dac0c5992040c5d02b8985 Mon Sep 17 00:00:00 2001 From: Gabgobie <105999094+Gabgobie@users.noreply.github.com> Date: Mon, 26 Jan 2026 19:31:01 +0100 Subject: [PATCH 16/33] Enhancement: configurable SSO groups claim (#11841) --------- Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com> --- docs/configuration.md | 8 +++++++- src/paperless/settings.py | 5 +++++ src/paperless/signals.py | 10 +++++++--- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index cc829342d..872c93e44 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -659,7 +659,7 @@ system. See the corresponding : Sync groups from the third party authentication system (e.g. OIDC) to Paperless-ngx. When enabled, users will be added or removed from groups based on their group membership in the third party authentication system. Groups must already exist in Paperless-ngx and have the same name as in the third party authentication system. Groups are updated upon logging in via the third party authentication system, see the corresponding [django-allauth documentation](https://docs.allauth.org/en/dev/socialaccount/signals.html). -: In order to pass groups from the authentication system you will need to update your [PAPERLESS_SOCIALACCOUNT_PROVIDERS](#PAPERLESS_SOCIALACCOUNT_PROVIDERS) setting by adding a top-level "SCOPES" setting which includes "groups", e.g.: +: In order to pass groups from the authentication system you will need to update your [PAPERLESS_SOCIALACCOUNT_PROVIDERS](#PAPERLESS_SOCIALACCOUNT_PROVIDERS) setting by adding a top-level "SCOPES" setting which includes "groups", or the custom groups claim configured in [`PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM`](#PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM) e.g.: ```json {"openid_connect":{"SCOPE": ["openid","profile","email","groups"]... @@ -667,6 +667,12 @@ system. See the corresponding Defaults to False +#### [`PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM=`](#PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM) {#PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM} + +: Allows you to define a custom groups claim. See [PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS](#PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS) which is required for this setting to take effect. + + Defaults to "groups" + #### [`PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS=`](#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS) {#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS} : A list of group names that users who signup via social accounts will be added to upon signup. Groups listed here must already exist. diff --git a/src/paperless/settings.py b/src/paperless/settings.py index 532a2bc36..9ad0fea4d 100644 --- a/src/paperless/settings.py +++ b/src/paperless/settings.py @@ -540,6 +540,11 @@ SOCIALACCOUNT_PROVIDERS = json.loads( ) SOCIAL_ACCOUNT_DEFAULT_GROUPS = __get_list("PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS") SOCIAL_ACCOUNT_SYNC_GROUPS = __get_boolean("PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS") +SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM: Final[str] = os.getenv( + "PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM", + "groups", +) + HEADLESS_TOKEN_STRATEGY = "paperless.adapter.DrfTokenStrategy" MFA_TOTP_ISSUER = "Paperless-ngx" diff --git a/src/paperless/signals.py b/src/paperless/signals.py index cfad29dbd..1ed88c051 100644 --- a/src/paperless/signals.py +++ b/src/paperless/signals.py @@ -40,15 +40,19 @@ def handle_social_account_updated(sender, request, sociallogin, **kwargs): extra_data = sociallogin.account.extra_data or {} social_account_groups = extra_data.get( - "groups", + settings.SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM, [], ) # pre-allauth 65.11.0 structure if not social_account_groups: # allauth 65.11.0+ nests claims under `userinfo`/`id_token` social_account_groups = ( - extra_data.get("userinfo", {}).get("groups") - or extra_data.get("id_token", {}).get("groups") + extra_data.get("userinfo", {}).get( + settings.SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM, + ) + or extra_data.get("id_token", {}).get( + settings.SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM, + ) or [] ) if settings.SOCIAL_ACCOUNT_SYNC_GROUPS and social_account_groups is not None: From df1aa13551bda01d5ced7dac7ac56481f9794c4d Mon Sep 17 00:00:00 2001 From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 18:32:50 +0000 Subject: [PATCH 17/33] Auto translate strings --- src/locale/en_US/LC_MESSAGES/django.po | 76 +++++++++++++------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/src/locale/en_US/LC_MESSAGES/django.po b/src/locale/en_US/LC_MESSAGES/django.po index e652d13a9..e88f74515 100644 --- a/src/locale/en_US/LC_MESSAGES/django.po +++ b/src/locale/en_US/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: paperless-ngx\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-01-26 08:30+0000\n" +"POT-Creation-Date: 2026-01-26 18:31+0000\n" "PO-Revision-Date: 2022-02-17 04:17\n" "Last-Translator: \n" "Language-Team: English\n" @@ -1747,151 +1747,151 @@ msgstr "" msgid "paperless application settings" msgstr "" -#: paperless/settings.py:802 +#: paperless/settings.py:807 msgid "English (US)" msgstr "" -#: paperless/settings.py:803 +#: paperless/settings.py:808 msgid "Arabic" msgstr "" -#: paperless/settings.py:804 +#: paperless/settings.py:809 msgid "Afrikaans" msgstr "" -#: paperless/settings.py:805 +#: paperless/settings.py:810 msgid "Belarusian" msgstr "" -#: paperless/settings.py:806 +#: paperless/settings.py:811 msgid "Bulgarian" msgstr "" -#: paperless/settings.py:807 +#: paperless/settings.py:812 msgid "Catalan" msgstr "" -#: paperless/settings.py:808 +#: paperless/settings.py:813 msgid "Czech" msgstr "" -#: paperless/settings.py:809 +#: paperless/settings.py:814 msgid "Danish" msgstr "" -#: paperless/settings.py:810 +#: paperless/settings.py:815 msgid "German" msgstr "" -#: paperless/settings.py:811 +#: paperless/settings.py:816 msgid "Greek" msgstr "" -#: paperless/settings.py:812 +#: paperless/settings.py:817 msgid "English (GB)" msgstr "" -#: paperless/settings.py:813 +#: paperless/settings.py:818 msgid "Spanish" msgstr "" -#: paperless/settings.py:814 +#: paperless/settings.py:819 msgid "Persian" msgstr "" -#: paperless/settings.py:815 +#: paperless/settings.py:820 msgid "Finnish" msgstr "" -#: paperless/settings.py:816 +#: paperless/settings.py:821 msgid "French" msgstr "" -#: paperless/settings.py:817 +#: paperless/settings.py:822 msgid "Hungarian" msgstr "" -#: paperless/settings.py:818 +#: paperless/settings.py:823 msgid "Indonesian" msgstr "" -#: paperless/settings.py:819 +#: paperless/settings.py:824 msgid "Italian" msgstr "" -#: paperless/settings.py:820 +#: paperless/settings.py:825 msgid "Japanese" msgstr "" -#: paperless/settings.py:821 +#: paperless/settings.py:826 msgid "Korean" msgstr "" -#: paperless/settings.py:822 +#: paperless/settings.py:827 msgid "Luxembourgish" msgstr "" -#: paperless/settings.py:823 +#: paperless/settings.py:828 msgid "Norwegian" msgstr "" -#: paperless/settings.py:824 +#: paperless/settings.py:829 msgid "Dutch" msgstr "" -#: paperless/settings.py:825 +#: paperless/settings.py:830 msgid "Polish" msgstr "" -#: paperless/settings.py:826 +#: paperless/settings.py:831 msgid "Portuguese (Brazil)" msgstr "" -#: paperless/settings.py:827 +#: paperless/settings.py:832 msgid "Portuguese" msgstr "" -#: paperless/settings.py:828 +#: paperless/settings.py:833 msgid "Romanian" msgstr "" -#: paperless/settings.py:829 +#: paperless/settings.py:834 msgid "Russian" msgstr "" -#: paperless/settings.py:830 +#: paperless/settings.py:835 msgid "Slovak" msgstr "" -#: paperless/settings.py:831 +#: paperless/settings.py:836 msgid "Slovenian" msgstr "" -#: paperless/settings.py:832 +#: paperless/settings.py:837 msgid "Serbian" msgstr "" -#: paperless/settings.py:833 +#: paperless/settings.py:838 msgid "Swedish" msgstr "" -#: paperless/settings.py:834 +#: paperless/settings.py:839 msgid "Turkish" msgstr "" -#: paperless/settings.py:835 +#: paperless/settings.py:840 msgid "Ukrainian" msgstr "" -#: paperless/settings.py:836 +#: paperless/settings.py:841 msgid "Vietnamese" msgstr "" -#: paperless/settings.py:837 +#: paperless/settings.py:842 msgid "Chinese Simplified" msgstr "" -#: paperless/settings.py:838 +#: paperless/settings.py:843 msgid "Chinese Traditional" msgstr "" From 4428354150dfaeacd65287529a59c7f0e5594068 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 26 Jan 2026 10:55:08 -0800 Subject: [PATCH 18/33] Feature: allow duplicates with warnings, UI for discovery (#11815) --- docs/configuration.md | 5 +- .../admin/tasks/tasks.component.html | 6 ++ .../document-detail.component.html | 31 ++++++++ .../document-detail.component.spec.ts | 50 ++++++++++++- .../document-detail.component.ts | 16 +++- src-ui/src/app/data/document.ts | 2 + src-ui/src/app/data/paperless-task.ts | 3 + src/documents/consumer.py | 44 ++++++++--- .../0006_alter_document_checksum_unique.py | 23 ++++++ src/documents/models.py | 1 - src/documents/permissions.py | 24 +++++- src/documents/serialisers.py | 56 ++++++++++++++ src/documents/tests/test_api_tasks.py | 31 ++++++-- src/documents/tests/test_consumer.py | 73 +++++++++++++------ 14 files changed, 316 insertions(+), 49 deletions(-) create mode 100644 src/documents/migrations/0006_alter_document_checksum_unique.py diff --git a/docs/configuration.md b/docs/configuration.md index 872c93e44..41d43d424 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1152,8 +1152,9 @@ via the consumption directory, you can disable the consumer to save resources. #### [`PAPERLESS_CONSUMER_DELETE_DUPLICATES=`](#PAPERLESS_CONSUMER_DELETE_DUPLICATES) {#PAPERLESS_CONSUMER_DELETE_DUPLICATES} -: When the consumer detects a duplicate document, it will not touch -the original document. This default behavior can be changed here. +: As of version 3.0 Paperless-ngx allows duplicate documents to be consumed by default, _except_ when +this setting is enabled. When enabled, Paperless will check if a document with the same hash already +exists in the system and delete the duplicate file from the consumption directory without consuming it. Defaults to false. diff --git a/src-ui/src/app/components/admin/tasks/tasks.component.html b/src-ui/src/app/components/admin/tasks/tasks.component.html index 084195221..ad625789c 100644 --- a/src-ui/src/app/components/admin/tasks/tasks.component.html +++ b/src-ui/src/app/components/admin/tasks/tasks.component.html @@ -97,6 +97,12 @@
(click for full output) } + @if (task.duplicate_documents?.length > 0) { +
+ + Duplicate(s) detected +
+ } } diff --git a/src-ui/src/app/components/document-detail/document-detail.component.html b/src-ui/src/app/components/document-detail/document-detail.component.html index 34e1f7980..5ca002479 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.html +++ b/src-ui/src/app/components/document-detail/document-detail.component.html @@ -370,6 +370,37 @@ } + + @if (document?.duplicate_documents?.length) { +
  • + + Duplicates + {{ document.duplicate_documents.length }} + + +
    +
    Duplicate documents detected:
    + +
    +
    +
  • + }
    diff --git a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts index 198e7a7a4..d1d10c985 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts @@ -301,16 +301,16 @@ describe('DocumentDetailComponent', () => { .spyOn(openDocumentsService, 'openDocument') .mockReturnValueOnce(of(true)) fixture.detectChanges() - expect(component.activeNavID).toEqual(5) // DocumentDetailNavIDs.Notes + expect(component.activeNavID).toEqual(component.DocumentDetailNavIDs.Notes) }) it('should change url on tab switch', () => { initNormally() const navigateSpy = jest.spyOn(router, 'navigate') - component.nav.select(5) + component.nav.select(component.DocumentDetailNavIDs.Notes) component.nav.navChange.next({ activeId: 1, - nextId: 5, + nextId: component.DocumentDetailNavIDs.Notes, preventDefault: () => {}, }) fixture.detectChanges() @@ -352,6 +352,18 @@ describe('DocumentDetailComponent', () => { expect(component.document).toEqual(doc) }) + it('should fall back to details tab when duplicates tab is active but no duplicates', () => { + initNormally() + component.activeNavID = component.DocumentDetailNavIDs.Duplicates + const noDupDoc = { ...doc, duplicate_documents: [] } + + component.updateComponent(noDupDoc) + + expect(component.activeNavID).toEqual( + component.DocumentDetailNavIDs.Details + ) + }) + it('should load already-opened document via param', () => { initNormally() jest.spyOn(documentService, 'get').mockReturnValueOnce(of(doc)) @@ -367,6 +379,38 @@ describe('DocumentDetailComponent', () => { expect(component.document).toEqual(doc) }) + it('should update cached open document duplicates when reloading an open doc', () => { + const openDoc = { ...doc, duplicate_documents: [{ id: 1, title: 'Old' }] } + const updatedDuplicates = [ + { id: 2, title: 'Newer duplicate', deleted_at: null }, + ] + jest + .spyOn(activatedRoute, 'paramMap', 'get') + .mockReturnValue(of(convertToParamMap({ id: 3, section: 'details' }))) + jest.spyOn(documentService, 'get').mockReturnValue( + of({ + ...doc, + modified: new Date('2024-01-02T00:00:00Z'), + duplicate_documents: updatedDuplicates, + }) + ) + jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc) + const saveSpy = jest.spyOn(openDocumentsService, 'save') + jest.spyOn(openDocumentsService, 'openDocument').mockReturnValue(of(true)) + jest.spyOn(customFieldsService, 'listAll').mockReturnValue( + of({ + count: customFields.length, + all: customFields.map((f) => f.id), + results: customFields, + }) + ) + + fixture.detectChanges() + + expect(openDoc.duplicate_documents).toEqual(updatedDuplicates) + expect(saveSpy).toHaveBeenCalled() + }) + it('should disable form if user cannot edit', () => { currentUserHasObjectPermissions = false initNormally() diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts index 5bac6fe72..917597ef6 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.ts @@ -8,7 +8,7 @@ import { FormsModule, ReactiveFormsModule, } from '@angular/forms' -import { ActivatedRoute, Router } from '@angular/router' +import { ActivatedRoute, Router, RouterModule } from '@angular/router' import { NgbDateStruct, NgbDropdownModule, @@ -124,6 +124,7 @@ enum DocumentDetailNavIDs { Notes = 5, Permissions = 6, History = 7, + Duplicates = 8, } enum ContentRenderType { @@ -181,6 +182,7 @@ export enum ZoomSetting { NgxBootstrapIconsModule, PdfViewerModule, TextAreaComponent, + RouterModule, ], }) export class DocumentDetailComponent @@ -454,6 +456,11 @@ export class DocumentDetailComponent const openDocument = this.openDocumentService.getOpenDocument( this.documentId ) + // update duplicate documents if present + if (openDocument && doc?.duplicate_documents) { + openDocument.duplicate_documents = doc.duplicate_documents + this.openDocumentService.save() + } const useDoc = openDocument || doc if (openDocument) { if ( @@ -704,6 +711,13 @@ export class DocumentDetailComponent } this.title = this.documentTitlePipe.transform(doc.title) this.prepareForm(doc) + + if ( + this.activeNavID === DocumentDetailNavIDs.Duplicates && + !doc?.duplicate_documents?.length + ) { + this.activeNavID = DocumentDetailNavIDs.Details + } } get customFieldFormFields(): FormArray { diff --git a/src-ui/src/app/data/document.ts b/src-ui/src/app/data/document.ts index 8aae31945..03d3bf09b 100644 --- a/src-ui/src/app/data/document.ts +++ b/src-ui/src/app/data/document.ts @@ -159,6 +159,8 @@ export interface Document extends ObjectWithPermissions { page_count?: number + duplicate_documents?: Document[] + // Frontend only __changedFields?: string[] } diff --git a/src-ui/src/app/data/paperless-task.ts b/src-ui/src/app/data/paperless-task.ts index b30af7cdd..19dd3921e 100644 --- a/src-ui/src/app/data/paperless-task.ts +++ b/src-ui/src/app/data/paperless-task.ts @@ -1,3 +1,4 @@ +import { Document } from './document' import { ObjectWithId } from './object-with-id' export enum PaperlessTaskType { @@ -42,5 +43,7 @@ export interface PaperlessTask extends ObjectWithId { related_document?: number + duplicate_documents?: Document[] + owner?: number } diff --git a/src/documents/consumer.py b/src/documents/consumer.py index 4c8c4dd28..1ff60220b 100644 --- a/src/documents/consumer.py +++ b/src/documents/consumer.py @@ -779,19 +779,45 @@ class ConsumerPreflightPlugin( Q(checksum=checksum) | Q(archive_checksum=checksum), ) if existing_doc.exists(): - msg = ConsumerStatusShortMessage.DOCUMENT_ALREADY_EXISTS - log_msg = f"Not consuming {self.filename}: It is a duplicate of {existing_doc.get().title} (#{existing_doc.get().pk})." + existing_doc = existing_doc.order_by("-created") + duplicates_in_trash = existing_doc.filter(deleted_at__isnull=False) + log_msg = ( + f"Consuming duplicate {self.filename}: " + f"{existing_doc.count()} existing document(s) share the same content." + ) - if existing_doc.first().deleted_at is not None: - msg = ConsumerStatusShortMessage.DOCUMENT_ALREADY_EXISTS_IN_TRASH - log_msg += " Note: existing document is in the trash." + if duplicates_in_trash.exists(): + log_msg += " Note: at least one existing document is in the trash." + + self.log.warning(log_msg) if settings.CONSUMER_DELETE_DUPLICATES: + duplicate = existing_doc.first() + duplicate_label = ( + duplicate.title + or duplicate.original_filename + or (Path(duplicate.filename).name if duplicate.filename else None) + or str(duplicate.pk) + ) + Path(self.input_doc.original_file).unlink() - self._fail( - msg, - log_msg, - ) + + failure_msg = ( + f"Not consuming {self.filename}: " + f"It is a duplicate of {duplicate_label} (#{duplicate.pk})" + ) + status_msg = ConsumerStatusShortMessage.DOCUMENT_ALREADY_EXISTS + + if duplicates_in_trash.exists(): + status_msg = ( + ConsumerStatusShortMessage.DOCUMENT_ALREADY_EXISTS_IN_TRASH + ) + failure_msg += " Note: existing document is in the trash." + + self._fail( + status_msg, + failure_msg, + ) def pre_check_directories(self): """ diff --git a/src/documents/migrations/0006_alter_document_checksum_unique.py b/src/documents/migrations/0006_alter_document_checksum_unique.py new file mode 100644 index 000000000..f86799494 --- /dev/null +++ b/src/documents/migrations/0006_alter_document_checksum_unique.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.7 on 2026-01-14 17:45 + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + dependencies = [ + ("documents", "0005_workflowtrigger_filter_has_any_correspondents_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="document", + name="checksum", + field=models.CharField( + editable=False, + max_length=32, + verbose_name="checksum", + help_text="The checksum of the original document.", + ), + ), + ] diff --git a/src/documents/models.py b/src/documents/models.py index 0ea525d49..fe41796bd 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -205,7 +205,6 @@ class Document(SoftDeleteModel, ModelWithOwner): _("checksum"), max_length=32, editable=False, - unique=True, help_text=_("The checksum of the original document."), ) diff --git a/src/documents/permissions.py b/src/documents/permissions.py index ac6d3f9ca..9d5c9eb68 100644 --- a/src/documents/permissions.py +++ b/src/documents/permissions.py @@ -148,13 +148,29 @@ def get_document_count_filter_for_user(user): ) -def get_objects_for_user_owner_aware(user, perms, Model) -> QuerySet: - objects_owned = Model.objects.filter(owner=user) - objects_unowned = Model.objects.filter(owner__isnull=True) +def get_objects_for_user_owner_aware( + user, + perms, + Model, + *, + include_deleted=False, +) -> QuerySet: + """ + Returns objects the user owns, are unowned, or has explicit perms. + When include_deleted is True, soft-deleted items are also included. + """ + manager = ( + Model.global_objects + if include_deleted and hasattr(Model, "global_objects") + else Model.objects + ) + + objects_owned = manager.filter(owner=user) + objects_unowned = manager.filter(owner__isnull=True) objects_with_perms = get_objects_for_user( user=user, perms=perms, - klass=Model, + klass=manager.all(), accept_global_perms=False, ) return objects_owned | objects_unowned | objects_with_perms diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index d9e5c22e0..4dc23d740 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -23,6 +23,7 @@ from django.core.validators import MinValueValidator from django.core.validators import RegexValidator from django.core.validators import integer_validator from django.db.models import Count +from django.db.models import Q from django.db.models.functions import Lower from django.utils.crypto import get_random_string from django.utils.dateparse import parse_datetime @@ -72,6 +73,7 @@ from documents.models import WorkflowTrigger from documents.parsers import is_mime_type_supported from documents.permissions import get_document_count_filter_for_user from documents.permissions import get_groups_with_only_permission +from documents.permissions import get_objects_for_user_owner_aware from documents.permissions import set_permissions_for_object from documents.regex import validate_regex_pattern from documents.templating.filepath import validate_filepath_template_and_render @@ -82,6 +84,9 @@ from documents.validators import url_validator if TYPE_CHECKING: from collections.abc import Iterable + from django.db.models.query import QuerySet + + logger = logging.getLogger("paperless.serializers") @@ -1014,6 +1019,32 @@ class NotesSerializer(serializers.ModelSerializer): return ret +def _get_viewable_duplicates( + document: Document, + user: User | None, +) -> QuerySet[Document]: + checksums = {document.checksum} + if document.archive_checksum: + checksums.add(document.archive_checksum) + duplicates = Document.global_objects.filter( + Q(checksum__in=checksums) | Q(archive_checksum__in=checksums), + ).exclude(pk=document.pk) + duplicates = duplicates.order_by("-created") + allowed = get_objects_for_user_owner_aware( + user, + "documents.view_document", + Document, + include_deleted=True, + ) + return duplicates.filter(id__in=allowed) + + +class DuplicateDocumentSummarySerializer(serializers.Serializer): + id = serializers.IntegerField() + title = serializers.CharField() + deleted_at = serializers.DateTimeField(allow_null=True) + + @extend_schema_serializer( deprecate_fields=["created_date"], ) @@ -1031,6 +1062,7 @@ class DocumentSerializer( archived_file_name = SerializerMethodField() created_date = serializers.DateField(required=False) page_count = SerializerMethodField() + duplicate_documents = SerializerMethodField() notes = NotesSerializer(many=True, required=False, read_only=True) @@ -1056,6 +1088,16 @@ class DocumentSerializer( def get_page_count(self, obj) -> int | None: return obj.page_count + @extend_schema_field(DuplicateDocumentSummarySerializer(many=True)) + def get_duplicate_documents(self, obj): + view = self.context.get("view") + if view and getattr(view, "action", None) != "retrieve": + return [] + request = self.context.get("request") + user = request.user if request else None + duplicates = _get_viewable_duplicates(obj, user) + return list(duplicates.values("id", "title", "deleted_at")) + def get_original_file_name(self, obj) -> str | None: return obj.original_filename @@ -1233,6 +1275,7 @@ class DocumentSerializer( "archive_serial_number", "original_file_name", "archived_file_name", + "duplicate_documents", "owner", "permissions", "user_can_change", @@ -2094,10 +2137,12 @@ class TasksViewSerializer(OwnedObjectSerializer): "result", "acknowledged", "related_document", + "duplicate_documents", "owner", ) related_document = serializers.SerializerMethodField() + duplicate_documents = serializers.SerializerMethodField() created_doc_re = re.compile(r"New document id (\d+) created") duplicate_doc_re = re.compile(r"It is a duplicate of .* \(#(\d+)\)") @@ -2122,6 +2167,17 @@ class TasksViewSerializer(OwnedObjectSerializer): return result + @extend_schema_field(DuplicateDocumentSummarySerializer(many=True)) + def get_duplicate_documents(self, obj): + related_document = self.get_related_document(obj) + request = self.context.get("request") + user = request.user if request else None + document = Document.global_objects.filter(pk=related_document).first() + if not related_document or not user or not document: + return [] + duplicates = _get_viewable_duplicates(document, user) + return list(duplicates.values("id", "title", "deleted_at")) + class RunTaskViewSerializer(serializers.Serializer): task_name = serializers.ChoiceField( diff --git a/src/documents/tests/test_api_tasks.py b/src/documents/tests/test_api_tasks.py index aa42577c4..6429ef44f 100644 --- a/src/documents/tests/test_api_tasks.py +++ b/src/documents/tests/test_api_tasks.py @@ -7,6 +7,7 @@ from django.contrib.auth.models import User from rest_framework import status from rest_framework.test import APITestCase +from documents.models import Document from documents.models import PaperlessTask from documents.tests.utils import DirectoriesMixin from documents.views import TasksViewSet @@ -258,7 +259,7 @@ class TestTasks(DirectoriesMixin, APITestCase): task_id=str(uuid.uuid4()), task_file_name="task_one.pdf", status=celery.states.FAILURE, - result="test.pdf: Not consuming test.pdf: It is a duplicate.", + result="test.pdf: Unexpected error during ingestion.", ) response = self.client.get(self.ENDPOINT) @@ -270,7 +271,7 @@ class TestTasks(DirectoriesMixin, APITestCase): self.assertEqual( returned_data["result"], - "test.pdf: Not consuming test.pdf: It is a duplicate.", + "test.pdf: Unexpected error during ingestion.", ) def test_task_name_webui(self): @@ -325,20 +326,34 @@ class TestTasks(DirectoriesMixin, APITestCase): self.assertEqual(returned_data["task_file_name"], "anothertest.pdf") - def test_task_result_failed_duplicate_includes_related_doc(self): + def test_task_result_duplicate_warning_includes_count(self): """ GIVEN: - - A celery task failed with a duplicate error + - A celery task succeeds, but a duplicate exists WHEN: - API call is made to get tasks THEN: - - The returned data includes a related document link + - The returned data includes duplicate warning metadata """ + checksum = "duplicate-checksum" + Document.objects.create( + title="Existing", + content="", + mime_type="application/pdf", + checksum=checksum, + ) + created_doc = Document.objects.create( + title="Created", + content="", + mime_type="application/pdf", + checksum=checksum, + archive_checksum="another-checksum", + ) PaperlessTask.objects.create( task_id=str(uuid.uuid4()), task_file_name="task_one.pdf", - status=celery.states.FAILURE, - result="Not consuming task_one.pdf: It is a duplicate of task_one_existing.pdf (#1234).", + status=celery.states.SUCCESS, + result=f"Success. New document id {created_doc.pk} created", ) response = self.client.get(self.ENDPOINT) @@ -348,7 +363,7 @@ class TestTasks(DirectoriesMixin, APITestCase): returned_data = response.data[0] - self.assertEqual(returned_data["related_document"], "1234") + self.assertEqual(returned_data["related_document"], str(created_doc.pk)) def test_run_train_classifier_task(self): """ diff --git a/src/documents/tests/test_consumer.py b/src/documents/tests/test_consumer.py index 63d6f8f5b..16fa2bf70 100644 --- a/src/documents/tests/test_consumer.py +++ b/src/documents/tests/test_consumer.py @@ -485,21 +485,21 @@ class TestConsumer( with self.get_consumer(self.get_test_file()) as consumer: consumer.run() - with self.assertRaisesMessage(ConsumerError, "It is a duplicate"): - with self.get_consumer(self.get_test_file()) as consumer: - consumer.run() + with self.get_consumer(self.get_test_file()) as consumer: + consumer.run() - self._assert_first_last_send_progress(last_status="FAILED") + self.assertEqual(Document.objects.count(), 2) + self._assert_first_last_send_progress() def testDuplicates2(self): with self.get_consumer(self.get_test_file()) as consumer: consumer.run() - with self.assertRaisesMessage(ConsumerError, "It is a duplicate"): - with self.get_consumer(self.get_test_archive_file()) as consumer: - consumer.run() + with self.get_consumer(self.get_test_archive_file()) as consumer: + consumer.run() - self._assert_first_last_send_progress(last_status="FAILED") + self.assertEqual(Document.objects.count(), 2) + self._assert_first_last_send_progress() def testDuplicates3(self): with self.get_consumer(self.get_test_archive_file()) as consumer: @@ -513,9 +513,10 @@ class TestConsumer( Document.objects.all().delete() - with self.assertRaisesMessage(ConsumerError, "document is in the trash"): - with self.get_consumer(self.get_test_file()) as consumer: - consumer.run() + with self.get_consumer(self.get_test_file()) as consumer: + consumer.run() + + self.assertEqual(Document.objects.count(), 1) def testAsnExists(self): with self.get_consumer( @@ -718,12 +719,45 @@ class TestConsumer( dst = self.get_test_file() self.assertIsFile(dst) - with self.assertRaises(ConsumerError): + expected_message = ( + f"{dst.name}: Not consuming {dst.name}: " + f"It is a duplicate of {document.title} (#{document.pk})" + ) + + with self.assertRaisesMessage(ConsumerError, expected_message): with self.get_consumer(dst) as consumer: consumer.run() self.assertIsNotFile(dst) - self._assert_first_last_send_progress(last_status="FAILED") + self.assertEqual(Document.objects.count(), 1) + self._assert_first_last_send_progress(last_status=ProgressStatusOptions.FAILED) + + @override_settings(CONSUMER_DELETE_DUPLICATES=True) + def test_delete_duplicate_in_trash(self): + dst = self.get_test_file() + with self.get_consumer(dst) as consumer: + consumer.run() + + # Move the existing document to trash + document = Document.objects.first() + document.delete() + + dst = self.get_test_file() + self.assertIsFile(dst) + + expected_message = ( + f"{dst.name}: Not consuming {dst.name}: " + f"It is a duplicate of {document.title} (#{document.pk})" + f" Note: existing document is in the trash." + ) + + with self.assertRaisesMessage(ConsumerError, expected_message): + with self.get_consumer(dst) as consumer: + consumer.run() + + self.assertIsNotFile(dst) + self.assertEqual(Document.global_objects.count(), 1) + self.assertEqual(Document.objects.count(), 0) @override_settings(CONSUMER_DELETE_DUPLICATES=False) def test_no_delete_duplicate(self): @@ -743,15 +777,12 @@ class TestConsumer( dst = self.get_test_file() self.assertIsFile(dst) - with self.assertRaisesRegex( - ConsumerError, - r"sample\.pdf: Not consuming sample\.pdf: It is a duplicate of sample \(#\d+\)", - ): - with self.get_consumer(dst) as consumer: - consumer.run() + with self.get_consumer(dst) as consumer: + consumer.run() - self.assertIsFile(dst) - self._assert_first_last_send_progress(last_status="FAILED") + self.assertIsNotFile(dst) + self.assertEqual(Document.objects.count(), 2) + self._assert_first_last_send_progress() @override_settings(FILENAME_FORMAT="{title}") @mock.patch("documents.parsers.document_consumer_declaration.send") From cac1b721b955f6c0edf7a38f7fcd91e10a02a7fc Mon Sep 17 00:00:00 2001 From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 18:57:50 +0000 Subject: [PATCH 19/33] Auto translate strings --- src-ui/messages.xlf | 144 ++++--- src/locale/en_US/LC_MESSAGES/django.po | 558 ++++++++++++------------- 2 files changed, 365 insertions(+), 337 deletions(-) diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index 7bcc97bae..439b83908 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -534,7 +534,7 @@ src/app/components/document-detail/document-detail.component.html - 396 + 427
    @@ -593,7 +593,7 @@ src/app/components/document-detail/document-detail.component.html - 389 + 420 src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.html @@ -761,7 +761,7 @@ src/app/components/document-detail/document-detail.component.html - 409 + 440 src/app/components/document-list/document-list.component.html @@ -1867,11 +1867,18 @@ 97 + + Duplicate(s) detected + + src/app/components/admin/tasks/tasks.component.html + 103 + + Dismiss src/app/components/admin/tasks/tasks.component.html - 110 + 116 src/app/components/admin/tasks/tasks.component.ts @@ -1882,49 +1889,49 @@ Open Document src/app/components/admin/tasks/tasks.component.html - 115 + 121 {VAR_PLURAL, plural, =1 {One task} other { total tasks}} src/app/components/admin/tasks/tasks.component.html - 134 + 140  ( selected) src/app/components/admin/tasks/tasks.component.html - 136 + 142 Failed src/app/components/admin/tasks/tasks.component.html - 148,150 + 154,156 Complete src/app/components/admin/tasks/tasks.component.html - 156,158 + 162,164 Started src/app/components/admin/tasks/tasks.component.html - 164,166 + 170,172 Queued src/app/components/admin/tasks/tasks.component.html - 172,174 + 178,180 @@ -2591,11 +2598,11 @@ src/app/components/document-detail/document-detail.component.ts - 1098 + 1112 src/app/components/document-detail/document-detail.component.ts - 1463 + 1477 src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -3221,7 +3228,7 @@ src/app/components/document-detail/document-detail.component.ts - 1051 + 1065 src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -3326,7 +3333,7 @@ src/app/components/document-detail/document-detail.component.ts - 1514 + 1528 @@ -3337,7 +3344,7 @@ src/app/components/document-detail/document-detail.component.ts - 1515 + 1529 @@ -3348,7 +3355,7 @@ src/app/components/document-detail/document-detail.component.ts - 1516 + 1530 @@ -6984,7 +6991,7 @@ src/app/components/document-detail/document-detail.component.ts - 1462 + 1476 @@ -7249,88 +7256,109 @@ 354 + + Duplicates + + src/app/components/document-detail/document-detail.component.html + 376,380 + + + + Duplicate documents detected: + + src/app/components/document-detail/document-detail.component.html + 382 + + + + In trash + + src/app/components/document-detail/document-detail.component.html + 393 + + Save & next src/app/components/document-detail/document-detail.component.html - 391 + 422 Save & close src/app/components/document-detail/document-detail.component.html - 394 + 425 Document loading... src/app/components/document-detail/document-detail.component.html - 404 + 435 Enter Password src/app/components/document-detail/document-detail.component.html - 458 + 489 An error occurred loading content: src/app/components/document-detail/document-detail.component.ts - 430,432 + 432,434 Document changes detected src/app/components/document-detail/document-detail.component.ts - 464 + 471 The version of this document in your browser session appears older than the existing version. src/app/components/document-detail/document-detail.component.ts - 465 + 472 Saving the document here may overwrite other changes that were made. To restore the existing version, discard your changes or close the document. src/app/components/document-detail/document-detail.component.ts - 466 + 473 Ok src/app/components/document-detail/document-detail.component.ts - 468 + 475 Next document src/app/components/document-detail/document-detail.component.ts - 594 + 601 Previous document src/app/components/document-detail/document-detail.component.ts - 604 + 611 Close document src/app/components/document-detail/document-detail.component.ts - 612 + 619 src/app/services/open-documents.service.ts @@ -7341,67 +7369,67 @@ Save document src/app/components/document-detail/document-detail.component.ts - 619 + 626 Save and close / next src/app/components/document-detail/document-detail.component.ts - 628 + 635 Error retrieving metadata src/app/components/document-detail/document-detail.component.ts - 683 + 690 Error retrieving suggestions. src/app/components/document-detail/document-detail.component.ts - 731 + 745 Document "" saved successfully. src/app/components/document-detail/document-detail.component.ts - 940 + 954 src/app/components/document-detail/document-detail.component.ts - 964 + 978 Error saving document "" src/app/components/document-detail/document-detail.component.ts - 970 + 984 Error saving document src/app/components/document-detail/document-detail.component.ts - 1020 + 1034 Do you really want to move the document "" to the trash? src/app/components/document-detail/document-detail.component.ts - 1052 + 1066 Documents can be restored prior to permanent deletion. src/app/components/document-detail/document-detail.component.ts - 1053 + 1067 src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -7412,7 +7440,7 @@ Move to trash src/app/components/document-detail/document-detail.component.ts - 1055 + 1069 src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -7423,14 +7451,14 @@ Error deleting document src/app/components/document-detail/document-detail.component.ts - 1074 + 1088 Reprocess confirm src/app/components/document-detail/document-detail.component.ts - 1094 + 1108 src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -7441,102 +7469,102 @@ This operation will permanently recreate the archive file for this document. src/app/components/document-detail/document-detail.component.ts - 1095 + 1109 The archive file will be re-generated with the current settings. src/app/components/document-detail/document-detail.component.ts - 1096 + 1110 Reprocess operation for "" will begin in the background. Close and re-open or reload this document after the operation has completed to see new content. src/app/components/document-detail/document-detail.component.ts - 1106 + 1120 Error executing operation src/app/components/document-detail/document-detail.component.ts - 1117 + 1131 Error downloading document src/app/components/document-detail/document-detail.component.ts - 1166 + 1180 Page Fit src/app/components/document-detail/document-detail.component.ts - 1243 + 1257 PDF edit operation for "" will begin in the background. src/app/components/document-detail/document-detail.component.ts - 1481 + 1495 Error executing PDF edit operation src/app/components/document-detail/document-detail.component.ts - 1493 + 1507 Please enter the current password before attempting to remove it. src/app/components/document-detail/document-detail.component.ts - 1504 + 1518 Password removal operation for "" will begin in the background. src/app/components/document-detail/document-detail.component.ts - 1536 + 1550 Error executing password removal operation src/app/components/document-detail/document-detail.component.ts - 1550 + 1564 Print failed. src/app/components/document-detail/document-detail.component.ts - 1587 + 1601 Error loading document for printing. src/app/components/document-detail/document-detail.component.ts - 1599 + 1613 An error occurred loading tiff: src/app/components/document-detail/document-detail.component.ts - 1664 + 1678 src/app/components/document-detail/document-detail.component.ts - 1668 + 1682 diff --git a/src/locale/en_US/LC_MESSAGES/django.po b/src/locale/en_US/LC_MESSAGES/django.po index e88f74515..41a007ebd 100644 --- a/src/locale/en_US/LC_MESSAGES/django.po +++ b/src/locale/en_US/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: paperless-ngx\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-01-26 18:31+0000\n" +"POT-Creation-Date: 2026-01-26 18:56+0000\n" "PO-Revision-Date: 2022-02-17 04:17\n" "Last-Translator: \n" "Language-Team: English\n" @@ -57,31 +57,31 @@ msgstr "" msgid "Custom field not found" msgstr "" -#: documents/models.py:38 documents/models.py:747 +#: documents/models.py:38 documents/models.py:746 msgid "owner" msgstr "" -#: documents/models.py:55 documents/models.py:962 +#: documents/models.py:55 documents/models.py:961 msgid "None" msgstr "" -#: documents/models.py:56 documents/models.py:963 +#: documents/models.py:56 documents/models.py:962 msgid "Any word" msgstr "" -#: documents/models.py:57 documents/models.py:964 +#: documents/models.py:57 documents/models.py:963 msgid "All words" msgstr "" -#: documents/models.py:58 documents/models.py:965 +#: documents/models.py:58 documents/models.py:964 msgid "Exact match" msgstr "" -#: documents/models.py:59 documents/models.py:966 +#: documents/models.py:59 documents/models.py:965 msgid "Regular expression" msgstr "" -#: documents/models.py:60 documents/models.py:967 +#: documents/models.py:60 documents/models.py:966 msgid "Fuzzy word" msgstr "" @@ -89,20 +89,20 @@ msgstr "" msgid "Automatic" msgstr "" -#: documents/models.py:64 documents/models.py:434 documents/models.py:1528 +#: documents/models.py:64 documents/models.py:433 documents/models.py:1527 #: paperless_mail/models.py:23 paperless_mail/models.py:143 msgid "name" msgstr "" -#: documents/models.py:66 documents/models.py:1031 +#: documents/models.py:66 documents/models.py:1030 msgid "match" msgstr "" -#: documents/models.py:69 documents/models.py:1034 +#: documents/models.py:69 documents/models.py:1033 msgid "matching algorithm" msgstr "" -#: documents/models.py:74 documents/models.py:1039 +#: documents/models.py:74 documents/models.py:1038 msgid "is insensitive" msgstr "" @@ -168,7 +168,7 @@ msgstr "" msgid "title" msgstr "" -#: documents/models.py:187 documents/models.py:661 +#: documents/models.py:187 documents/models.py:660 msgid "content" msgstr "" @@ -186,1073 +186,1073 @@ msgstr "" msgid "checksum" msgstr "" -#: documents/models.py:209 +#: documents/models.py:208 msgid "The checksum of the original document." msgstr "" -#: documents/models.py:213 +#: documents/models.py:212 msgid "archive checksum" msgstr "" -#: documents/models.py:218 +#: documents/models.py:217 msgid "The checksum of the archived document." msgstr "" -#: documents/models.py:222 +#: documents/models.py:221 msgid "page count" msgstr "" -#: documents/models.py:229 +#: documents/models.py:228 msgid "The number of pages of the document." msgstr "" -#: documents/models.py:234 documents/models.py:667 documents/models.py:705 -#: documents/models.py:777 documents/models.py:836 +#: documents/models.py:233 documents/models.py:666 documents/models.py:704 +#: documents/models.py:776 documents/models.py:835 msgid "created" msgstr "" -#: documents/models.py:240 +#: documents/models.py:239 msgid "modified" msgstr "" -#: documents/models.py:247 +#: documents/models.py:246 msgid "added" msgstr "" -#: documents/models.py:254 +#: documents/models.py:253 msgid "filename" msgstr "" -#: documents/models.py:260 +#: documents/models.py:259 msgid "Current filename in storage" msgstr "" -#: documents/models.py:264 +#: documents/models.py:263 msgid "archive filename" msgstr "" -#: documents/models.py:270 +#: documents/models.py:269 msgid "Current archive filename in storage" msgstr "" -#: documents/models.py:274 +#: documents/models.py:273 msgid "original filename" msgstr "" -#: documents/models.py:280 +#: documents/models.py:279 msgid "The original name of the file when it was uploaded" msgstr "" -#: documents/models.py:287 +#: documents/models.py:286 msgid "archive serial number" msgstr "" -#: documents/models.py:297 +#: documents/models.py:296 msgid "The position of this document in your physical document archive." msgstr "" -#: documents/models.py:303 documents/models.py:678 documents/models.py:732 -#: documents/models.py:1571 +#: documents/models.py:302 documents/models.py:677 documents/models.py:731 +#: documents/models.py:1570 msgid "document" msgstr "" -#: documents/models.py:304 +#: documents/models.py:303 msgid "documents" msgstr "" -#: documents/models.py:415 +#: documents/models.py:414 msgid "Table" msgstr "" -#: documents/models.py:416 +#: documents/models.py:415 msgid "Small Cards" msgstr "" -#: documents/models.py:417 +#: documents/models.py:416 msgid "Large Cards" msgstr "" -#: documents/models.py:420 +#: documents/models.py:419 msgid "Title" msgstr "" -#: documents/models.py:421 documents/models.py:983 +#: documents/models.py:420 documents/models.py:982 msgid "Created" msgstr "" -#: documents/models.py:422 documents/models.py:982 +#: documents/models.py:421 documents/models.py:981 msgid "Added" msgstr "" -#: documents/models.py:423 +#: documents/models.py:422 msgid "Tags" msgstr "" -#: documents/models.py:424 +#: documents/models.py:423 msgid "Correspondent" msgstr "" -#: documents/models.py:425 +#: documents/models.py:424 msgid "Document Type" msgstr "" -#: documents/models.py:426 +#: documents/models.py:425 msgid "Storage Path" msgstr "" -#: documents/models.py:427 +#: documents/models.py:426 msgid "Note" msgstr "" -#: documents/models.py:428 +#: documents/models.py:427 msgid "Owner" msgstr "" -#: documents/models.py:429 +#: documents/models.py:428 msgid "Shared" msgstr "" -#: documents/models.py:430 +#: documents/models.py:429 msgid "ASN" msgstr "" -#: documents/models.py:431 +#: documents/models.py:430 msgid "Pages" msgstr "" -#: documents/models.py:437 +#: documents/models.py:436 msgid "show on dashboard" msgstr "" -#: documents/models.py:440 +#: documents/models.py:439 msgid "show in sidebar" msgstr "" -#: documents/models.py:444 +#: documents/models.py:443 msgid "sort field" msgstr "" -#: documents/models.py:449 +#: documents/models.py:448 msgid "sort reverse" msgstr "" -#: documents/models.py:452 +#: documents/models.py:451 msgid "View page size" msgstr "" -#: documents/models.py:460 +#: documents/models.py:459 msgid "View display mode" msgstr "" -#: documents/models.py:467 +#: documents/models.py:466 msgid "Document display fields" msgstr "" -#: documents/models.py:474 documents/models.py:537 +#: documents/models.py:473 documents/models.py:536 msgid "saved view" msgstr "" -#: documents/models.py:475 +#: documents/models.py:474 msgid "saved views" msgstr "" -#: documents/models.py:483 +#: documents/models.py:482 msgid "title contains" msgstr "" -#: documents/models.py:484 +#: documents/models.py:483 msgid "content contains" msgstr "" -#: documents/models.py:485 +#: documents/models.py:484 msgid "ASN is" msgstr "" -#: documents/models.py:486 +#: documents/models.py:485 msgid "correspondent is" msgstr "" -#: documents/models.py:487 +#: documents/models.py:486 msgid "document type is" msgstr "" -#: documents/models.py:488 +#: documents/models.py:487 msgid "is in inbox" msgstr "" -#: documents/models.py:489 +#: documents/models.py:488 msgid "has tag" msgstr "" -#: documents/models.py:490 +#: documents/models.py:489 msgid "has any tag" msgstr "" -#: documents/models.py:491 +#: documents/models.py:490 msgid "created before" msgstr "" -#: documents/models.py:492 +#: documents/models.py:491 msgid "created after" msgstr "" -#: documents/models.py:493 +#: documents/models.py:492 msgid "created year is" msgstr "" -#: documents/models.py:494 +#: documents/models.py:493 msgid "created month is" msgstr "" -#: documents/models.py:495 +#: documents/models.py:494 msgid "created day is" msgstr "" -#: documents/models.py:496 +#: documents/models.py:495 msgid "added before" msgstr "" -#: documents/models.py:497 +#: documents/models.py:496 msgid "added after" msgstr "" -#: documents/models.py:498 +#: documents/models.py:497 msgid "modified before" msgstr "" -#: documents/models.py:499 +#: documents/models.py:498 msgid "modified after" msgstr "" -#: documents/models.py:500 +#: documents/models.py:499 msgid "does not have tag" msgstr "" -#: documents/models.py:501 +#: documents/models.py:500 msgid "does not have ASN" msgstr "" -#: documents/models.py:502 +#: documents/models.py:501 msgid "title or content contains" msgstr "" -#: documents/models.py:503 +#: documents/models.py:502 msgid "fulltext query" msgstr "" -#: documents/models.py:504 +#: documents/models.py:503 msgid "more like this" msgstr "" -#: documents/models.py:505 +#: documents/models.py:504 msgid "has tags in" msgstr "" -#: documents/models.py:506 +#: documents/models.py:505 msgid "ASN greater than" msgstr "" -#: documents/models.py:507 +#: documents/models.py:506 msgid "ASN less than" msgstr "" -#: documents/models.py:508 +#: documents/models.py:507 msgid "storage path is" msgstr "" -#: documents/models.py:509 +#: documents/models.py:508 msgid "has correspondent in" msgstr "" -#: documents/models.py:510 +#: documents/models.py:509 msgid "does not have correspondent in" msgstr "" -#: documents/models.py:511 +#: documents/models.py:510 msgid "has document type in" msgstr "" -#: documents/models.py:512 +#: documents/models.py:511 msgid "does not have document type in" msgstr "" -#: documents/models.py:513 +#: documents/models.py:512 msgid "has storage path in" msgstr "" -#: documents/models.py:514 +#: documents/models.py:513 msgid "does not have storage path in" msgstr "" -#: documents/models.py:515 +#: documents/models.py:514 msgid "owner is" msgstr "" -#: documents/models.py:516 +#: documents/models.py:515 msgid "has owner in" msgstr "" -#: documents/models.py:517 +#: documents/models.py:516 msgid "does not have owner" msgstr "" -#: documents/models.py:518 +#: documents/models.py:517 msgid "does not have owner in" msgstr "" -#: documents/models.py:519 +#: documents/models.py:518 msgid "has custom field value" msgstr "" -#: documents/models.py:520 +#: documents/models.py:519 msgid "is shared by me" msgstr "" -#: documents/models.py:521 +#: documents/models.py:520 msgid "has custom fields" msgstr "" -#: documents/models.py:522 +#: documents/models.py:521 msgid "has custom field in" msgstr "" -#: documents/models.py:523 +#: documents/models.py:522 msgid "does not have custom field in" msgstr "" -#: documents/models.py:524 +#: documents/models.py:523 msgid "does not have custom field" msgstr "" -#: documents/models.py:525 +#: documents/models.py:524 msgid "custom fields query" msgstr "" -#: documents/models.py:526 +#: documents/models.py:525 msgid "created to" msgstr "" -#: documents/models.py:527 +#: documents/models.py:526 msgid "created from" msgstr "" -#: documents/models.py:528 +#: documents/models.py:527 msgid "added to" msgstr "" -#: documents/models.py:529 +#: documents/models.py:528 msgid "added from" msgstr "" -#: documents/models.py:530 +#: documents/models.py:529 msgid "mime type is" msgstr "" -#: documents/models.py:540 +#: documents/models.py:539 msgid "rule type" msgstr "" -#: documents/models.py:542 +#: documents/models.py:541 msgid "value" msgstr "" -#: documents/models.py:545 +#: documents/models.py:544 msgid "filter rule" msgstr "" -#: documents/models.py:546 +#: documents/models.py:545 msgid "filter rules" msgstr "" -#: documents/models.py:570 +#: documents/models.py:569 msgid "Auto Task" msgstr "" -#: documents/models.py:571 +#: documents/models.py:570 msgid "Scheduled Task" msgstr "" -#: documents/models.py:572 +#: documents/models.py:571 msgid "Manual Task" msgstr "" -#: documents/models.py:575 +#: documents/models.py:574 msgid "Consume File" msgstr "" -#: documents/models.py:576 +#: documents/models.py:575 msgid "Train Classifier" msgstr "" -#: documents/models.py:577 +#: documents/models.py:576 msgid "Check Sanity" msgstr "" -#: documents/models.py:578 +#: documents/models.py:577 msgid "Index Optimize" msgstr "" -#: documents/models.py:579 +#: documents/models.py:578 msgid "LLM Index Update" msgstr "" -#: documents/models.py:584 +#: documents/models.py:583 msgid "Task ID" msgstr "" -#: documents/models.py:585 +#: documents/models.py:584 msgid "Celery ID for the Task that was run" msgstr "" -#: documents/models.py:590 +#: documents/models.py:589 msgid "Acknowledged" msgstr "" -#: documents/models.py:591 +#: documents/models.py:590 msgid "If the task is acknowledged via the frontend or API" msgstr "" -#: documents/models.py:597 +#: documents/models.py:596 msgid "Task Filename" msgstr "" -#: documents/models.py:598 +#: documents/models.py:597 msgid "Name of the file which the Task was run for" msgstr "" -#: documents/models.py:605 +#: documents/models.py:604 msgid "Task Name" msgstr "" -#: documents/models.py:606 +#: documents/models.py:605 msgid "Name of the task that was run" msgstr "" -#: documents/models.py:613 +#: documents/models.py:612 msgid "Task State" msgstr "" -#: documents/models.py:614 +#: documents/models.py:613 msgid "Current state of the task being run" msgstr "" -#: documents/models.py:620 +#: documents/models.py:619 msgid "Created DateTime" msgstr "" -#: documents/models.py:621 +#: documents/models.py:620 msgid "Datetime field when the task result was created in UTC" msgstr "" -#: documents/models.py:627 +#: documents/models.py:626 msgid "Started DateTime" msgstr "" -#: documents/models.py:628 +#: documents/models.py:627 msgid "Datetime field when the task was started in UTC" msgstr "" -#: documents/models.py:634 +#: documents/models.py:633 msgid "Completed DateTime" msgstr "" -#: documents/models.py:635 +#: documents/models.py:634 msgid "Datetime field when the task was completed in UTC" msgstr "" -#: documents/models.py:641 +#: documents/models.py:640 msgid "Result Data" msgstr "" -#: documents/models.py:643 +#: documents/models.py:642 msgid "The data returned by the task" msgstr "" -#: documents/models.py:651 +#: documents/models.py:650 msgid "Task Type" msgstr "" -#: documents/models.py:652 +#: documents/models.py:651 msgid "The type of task that was run" msgstr "" -#: documents/models.py:663 +#: documents/models.py:662 msgid "Note for the document" msgstr "" -#: documents/models.py:687 +#: documents/models.py:686 msgid "user" msgstr "" -#: documents/models.py:692 +#: documents/models.py:691 msgid "note" msgstr "" -#: documents/models.py:693 +#: documents/models.py:692 msgid "notes" msgstr "" -#: documents/models.py:701 +#: documents/models.py:700 msgid "Archive" msgstr "" -#: documents/models.py:702 +#: documents/models.py:701 msgid "Original" msgstr "" -#: documents/models.py:713 paperless_mail/models.py:75 +#: documents/models.py:712 paperless_mail/models.py:75 msgid "expiration" msgstr "" -#: documents/models.py:720 +#: documents/models.py:719 msgid "slug" msgstr "" -#: documents/models.py:752 +#: documents/models.py:751 msgid "share link" msgstr "" -#: documents/models.py:753 +#: documents/models.py:752 msgid "share links" msgstr "" -#: documents/models.py:765 +#: documents/models.py:764 msgid "String" msgstr "" -#: documents/models.py:766 +#: documents/models.py:765 msgid "URL" msgstr "" -#: documents/models.py:767 +#: documents/models.py:766 msgid "Date" msgstr "" -#: documents/models.py:768 +#: documents/models.py:767 msgid "Boolean" msgstr "" -#: documents/models.py:769 +#: documents/models.py:768 msgid "Integer" msgstr "" -#: documents/models.py:770 +#: documents/models.py:769 msgid "Float" msgstr "" -#: documents/models.py:771 +#: documents/models.py:770 msgid "Monetary" msgstr "" -#: documents/models.py:772 +#: documents/models.py:771 msgid "Document Link" msgstr "" -#: documents/models.py:773 +#: documents/models.py:772 msgid "Select" msgstr "" -#: documents/models.py:774 +#: documents/models.py:773 msgid "Long Text" msgstr "" -#: documents/models.py:786 +#: documents/models.py:785 msgid "data type" msgstr "" -#: documents/models.py:793 +#: documents/models.py:792 msgid "extra data" msgstr "" -#: documents/models.py:797 +#: documents/models.py:796 msgid "Extra data for the custom field, such as select options" msgstr "" -#: documents/models.py:803 +#: documents/models.py:802 msgid "custom field" msgstr "" -#: documents/models.py:804 +#: documents/models.py:803 msgid "custom fields" msgstr "" -#: documents/models.py:904 +#: documents/models.py:903 msgid "custom field instance" msgstr "" -#: documents/models.py:905 +#: documents/models.py:904 msgid "custom field instances" msgstr "" -#: documents/models.py:970 +#: documents/models.py:969 msgid "Consumption Started" msgstr "" -#: documents/models.py:971 +#: documents/models.py:970 msgid "Document Added" msgstr "" -#: documents/models.py:972 +#: documents/models.py:971 msgid "Document Updated" msgstr "" -#: documents/models.py:973 +#: documents/models.py:972 msgid "Scheduled" msgstr "" -#: documents/models.py:976 +#: documents/models.py:975 msgid "Consume Folder" msgstr "" -#: documents/models.py:977 +#: documents/models.py:976 msgid "Api Upload" msgstr "" -#: documents/models.py:978 +#: documents/models.py:977 msgid "Mail Fetch" msgstr "" -#: documents/models.py:979 +#: documents/models.py:978 msgid "Web UI" msgstr "" -#: documents/models.py:984 +#: documents/models.py:983 msgid "Modified" msgstr "" -#: documents/models.py:985 +#: documents/models.py:984 msgid "Custom Field" msgstr "" -#: documents/models.py:988 +#: documents/models.py:987 msgid "Workflow Trigger Type" msgstr "" -#: documents/models.py:1000 +#: documents/models.py:999 msgid "filter path" msgstr "" -#: documents/models.py:1005 +#: documents/models.py:1004 msgid "" "Only consume documents with a path that matches this if specified. Wildcards " "specified as * are allowed. Case insensitive." msgstr "" -#: documents/models.py:1012 +#: documents/models.py:1011 msgid "filter filename" msgstr "" -#: documents/models.py:1017 paperless_mail/models.py:200 +#: documents/models.py:1016 paperless_mail/models.py:200 msgid "" "Only consume documents which entirely match this filename if specified. " "Wildcards such as *.pdf or *invoice* are allowed. Case insensitive." msgstr "" -#: documents/models.py:1028 +#: documents/models.py:1027 msgid "filter documents from this mail rule" msgstr "" -#: documents/models.py:1044 +#: documents/models.py:1043 msgid "has these tag(s)" msgstr "" -#: documents/models.py:1051 +#: documents/models.py:1050 msgid "has all of these tag(s)" msgstr "" -#: documents/models.py:1058 +#: documents/models.py:1057 msgid "does not have these tag(s)" msgstr "" -#: documents/models.py:1066 +#: documents/models.py:1065 msgid "has this document type" msgstr "" -#: documents/models.py:1073 +#: documents/models.py:1072 msgid "has one of these document types" msgstr "" -#: documents/models.py:1080 +#: documents/models.py:1079 msgid "does not have these document type(s)" msgstr "" -#: documents/models.py:1088 +#: documents/models.py:1087 msgid "has this correspondent" msgstr "" -#: documents/models.py:1095 +#: documents/models.py:1094 msgid "does not have these correspondent(s)" msgstr "" -#: documents/models.py:1102 +#: documents/models.py:1101 msgid "has one of these correspondents" msgstr "" -#: documents/models.py:1110 +#: documents/models.py:1109 msgid "has this storage path" msgstr "" -#: documents/models.py:1117 +#: documents/models.py:1116 msgid "has one of these storage paths" msgstr "" -#: documents/models.py:1124 +#: documents/models.py:1123 msgid "does not have these storage path(s)" msgstr "" -#: documents/models.py:1128 +#: documents/models.py:1127 msgid "filter custom field query" msgstr "" -#: documents/models.py:1131 +#: documents/models.py:1130 msgid "JSON-encoded custom field query expression." msgstr "" -#: documents/models.py:1135 +#: documents/models.py:1134 msgid "schedule offset days" msgstr "" -#: documents/models.py:1138 +#: documents/models.py:1137 msgid "The number of days to offset the schedule trigger by." msgstr "" -#: documents/models.py:1143 +#: documents/models.py:1142 msgid "schedule is recurring" msgstr "" -#: documents/models.py:1146 +#: documents/models.py:1145 msgid "If the schedule should be recurring." msgstr "" -#: documents/models.py:1151 +#: documents/models.py:1150 msgid "schedule recurring delay in days" msgstr "" -#: documents/models.py:1155 +#: documents/models.py:1154 msgid "The number of days between recurring schedule triggers." msgstr "" -#: documents/models.py:1160 +#: documents/models.py:1159 msgid "schedule date field" msgstr "" -#: documents/models.py:1165 +#: documents/models.py:1164 msgid "The field to check for a schedule trigger." msgstr "" -#: documents/models.py:1174 +#: documents/models.py:1173 msgid "schedule date custom field" msgstr "" -#: documents/models.py:1178 +#: documents/models.py:1177 msgid "workflow trigger" msgstr "" -#: documents/models.py:1179 +#: documents/models.py:1178 msgid "workflow triggers" msgstr "" -#: documents/models.py:1187 +#: documents/models.py:1186 msgid "email subject" msgstr "" -#: documents/models.py:1191 +#: documents/models.py:1190 msgid "" "The subject of the email, can include some placeholders, see documentation." msgstr "" -#: documents/models.py:1197 +#: documents/models.py:1196 msgid "email body" msgstr "" -#: documents/models.py:1200 +#: documents/models.py:1199 msgid "" "The body (message) of the email, can include some placeholders, see " "documentation." msgstr "" -#: documents/models.py:1206 +#: documents/models.py:1205 msgid "emails to" msgstr "" -#: documents/models.py:1209 +#: documents/models.py:1208 msgid "The destination email addresses, comma separated." msgstr "" -#: documents/models.py:1215 +#: documents/models.py:1214 msgid "include document in email" msgstr "" -#: documents/models.py:1226 +#: documents/models.py:1225 msgid "webhook url" msgstr "" -#: documents/models.py:1229 +#: documents/models.py:1228 msgid "The destination URL for the notification." msgstr "" -#: documents/models.py:1234 +#: documents/models.py:1233 msgid "use parameters" msgstr "" -#: documents/models.py:1239 +#: documents/models.py:1238 msgid "send as JSON" msgstr "" -#: documents/models.py:1243 +#: documents/models.py:1242 msgid "webhook parameters" msgstr "" -#: documents/models.py:1246 +#: documents/models.py:1245 msgid "The parameters to send with the webhook URL if body not used." msgstr "" -#: documents/models.py:1250 +#: documents/models.py:1249 msgid "webhook body" msgstr "" -#: documents/models.py:1253 +#: documents/models.py:1252 msgid "The body to send with the webhook URL if parameters not used." msgstr "" -#: documents/models.py:1257 +#: documents/models.py:1256 msgid "webhook headers" msgstr "" -#: documents/models.py:1260 +#: documents/models.py:1259 msgid "The headers to send with the webhook URL." msgstr "" -#: documents/models.py:1265 +#: documents/models.py:1264 msgid "include document in webhook" msgstr "" -#: documents/models.py:1276 +#: documents/models.py:1275 msgid "Assignment" msgstr "" -#: documents/models.py:1280 +#: documents/models.py:1279 msgid "Removal" msgstr "" -#: documents/models.py:1284 documents/templates/account/password_reset.html:15 +#: documents/models.py:1283 documents/templates/account/password_reset.html:15 msgid "Email" msgstr "" -#: documents/models.py:1288 +#: documents/models.py:1287 msgid "Webhook" msgstr "" -#: documents/models.py:1292 +#: documents/models.py:1291 msgid "Workflow Action Type" msgstr "" -#: documents/models.py:1297 documents/models.py:1530 +#: documents/models.py:1296 documents/models.py:1529 #: paperless_mail/models.py:145 msgid "order" msgstr "" -#: documents/models.py:1300 +#: documents/models.py:1299 msgid "assign title" msgstr "" -#: documents/models.py:1304 +#: documents/models.py:1303 msgid "Assign a document title, must be a Jinja2 template, see documentation." msgstr "" -#: documents/models.py:1312 paperless_mail/models.py:274 +#: documents/models.py:1311 paperless_mail/models.py:274 msgid "assign this tag" msgstr "" -#: documents/models.py:1321 paperless_mail/models.py:282 +#: documents/models.py:1320 paperless_mail/models.py:282 msgid "assign this document type" msgstr "" -#: documents/models.py:1330 paperless_mail/models.py:296 +#: documents/models.py:1329 paperless_mail/models.py:296 msgid "assign this correspondent" msgstr "" -#: documents/models.py:1339 +#: documents/models.py:1338 msgid "assign this storage path" msgstr "" -#: documents/models.py:1348 +#: documents/models.py:1347 msgid "assign this owner" msgstr "" -#: documents/models.py:1355 +#: documents/models.py:1354 msgid "grant view permissions to these users" msgstr "" -#: documents/models.py:1362 +#: documents/models.py:1361 msgid "grant view permissions to these groups" msgstr "" -#: documents/models.py:1369 +#: documents/models.py:1368 msgid "grant change permissions to these users" msgstr "" -#: documents/models.py:1376 +#: documents/models.py:1375 msgid "grant change permissions to these groups" msgstr "" -#: documents/models.py:1383 +#: documents/models.py:1382 msgid "assign these custom fields" msgstr "" -#: documents/models.py:1387 +#: documents/models.py:1386 msgid "custom field values" msgstr "" -#: documents/models.py:1391 +#: documents/models.py:1390 msgid "Optional values to assign to the custom fields." msgstr "" -#: documents/models.py:1400 +#: documents/models.py:1399 msgid "remove these tag(s)" msgstr "" -#: documents/models.py:1405 +#: documents/models.py:1404 msgid "remove all tags" msgstr "" -#: documents/models.py:1412 +#: documents/models.py:1411 msgid "remove these document type(s)" msgstr "" -#: documents/models.py:1417 +#: documents/models.py:1416 msgid "remove all document types" msgstr "" -#: documents/models.py:1424 +#: documents/models.py:1423 msgid "remove these correspondent(s)" msgstr "" -#: documents/models.py:1429 +#: documents/models.py:1428 msgid "remove all correspondents" msgstr "" -#: documents/models.py:1436 +#: documents/models.py:1435 msgid "remove these storage path(s)" msgstr "" -#: documents/models.py:1441 +#: documents/models.py:1440 msgid "remove all storage paths" msgstr "" -#: documents/models.py:1448 +#: documents/models.py:1447 msgid "remove these owner(s)" msgstr "" -#: documents/models.py:1453 +#: documents/models.py:1452 msgid "remove all owners" msgstr "" -#: documents/models.py:1460 +#: documents/models.py:1459 msgid "remove view permissions for these users" msgstr "" -#: documents/models.py:1467 +#: documents/models.py:1466 msgid "remove view permissions for these groups" msgstr "" -#: documents/models.py:1474 +#: documents/models.py:1473 msgid "remove change permissions for these users" msgstr "" -#: documents/models.py:1481 +#: documents/models.py:1480 msgid "remove change permissions for these groups" msgstr "" -#: documents/models.py:1486 +#: documents/models.py:1485 msgid "remove all permissions" msgstr "" -#: documents/models.py:1493 +#: documents/models.py:1492 msgid "remove these custom fields" msgstr "" -#: documents/models.py:1498 +#: documents/models.py:1497 msgid "remove all custom fields" msgstr "" -#: documents/models.py:1507 +#: documents/models.py:1506 msgid "email" msgstr "" -#: documents/models.py:1516 +#: documents/models.py:1515 msgid "webhook" msgstr "" -#: documents/models.py:1520 +#: documents/models.py:1519 msgid "workflow action" msgstr "" -#: documents/models.py:1521 +#: documents/models.py:1520 msgid "workflow actions" msgstr "" -#: documents/models.py:1536 +#: documents/models.py:1535 msgid "triggers" msgstr "" -#: documents/models.py:1543 +#: documents/models.py:1542 msgid "actions" msgstr "" -#: documents/models.py:1546 paperless_mail/models.py:154 +#: documents/models.py:1545 paperless_mail/models.py:154 msgid "enabled" msgstr "" -#: documents/models.py:1557 +#: documents/models.py:1556 msgid "workflow" msgstr "" -#: documents/models.py:1561 +#: documents/models.py:1560 msgid "workflow trigger type" msgstr "" -#: documents/models.py:1575 +#: documents/models.py:1574 msgid "date run" msgstr "" -#: documents/models.py:1581 +#: documents/models.py:1580 msgid "workflow run" msgstr "" -#: documents/models.py:1582 +#: documents/models.py:1581 msgid "workflow runs" msgstr "" -#: documents/serialisers.py:646 +#: documents/serialisers.py:651 msgid "Invalid color." msgstr "" -#: documents/serialisers.py:1850 +#: documents/serialisers.py:1893 #, python-format msgid "File type %(type)s not supported" msgstr "" -#: documents/serialisers.py:1894 +#: documents/serialisers.py:1937 #, python-format msgid "Custom field id must be an integer: %(id)s" msgstr "" -#: documents/serialisers.py:1901 +#: documents/serialisers.py:1944 #, python-format msgid "Custom field with id %(id)s does not exist" msgstr "" -#: documents/serialisers.py:1918 documents/serialisers.py:1928 +#: documents/serialisers.py:1961 documents/serialisers.py:1971 msgid "" "Custom fields must be a list of integers or an object mapping ids to values." msgstr "" -#: documents/serialisers.py:1923 +#: documents/serialisers.py:1966 msgid "Some custom fields don't exist or were specified twice." msgstr "" -#: documents/serialisers.py:2038 +#: documents/serialisers.py:2081 msgid "Invalid variable detected." msgstr "" From df07b8a03e1d7c992e8b7e8df60337d223437aa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoine=20M=C3=A9rino?= Date: Mon, 26 Jan 2026 21:10:57 +0100 Subject: [PATCH 20/33] Performance: faster statistics panel on dashboard (#11760) --- .../0007_document_content_length.py | 25 ++++++++ src/documents/models.py | 13 +++- src/documents/tests/test_api_documents.py | 4 ++ .../tests/test_management_exporter.py | 4 ++ src/documents/views.py | 61 +++++++++---------- 5 files changed, 73 insertions(+), 34 deletions(-) create mode 100644 src/documents/migrations/0007_document_content_length.py diff --git a/src/documents/migrations/0007_document_content_length.py b/src/documents/migrations/0007_document_content_length.py new file mode 100644 index 000000000..c294afca5 --- /dev/null +++ b/src/documents/migrations/0007_document_content_length.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.6 on 2026-01-24 07:33 + +import django.db.models.functions.text +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + dependencies = [ + ("documents", "0006_alter_document_checksum_unique"), + ] + + operations = [ + migrations.AddField( + model_name="document", + name="content_length", + field=models.GeneratedField( + db_persist=True, + expression=django.db.models.functions.text.Length("content"), + null=False, + help_text="Length of the content field in characters. Automatically maintained by the database for faster statistics computation.", + output_field=models.PositiveIntegerField(default=0), + ), + ), + ] diff --git a/src/documents/models.py b/src/documents/models.py index fe41796bd..ad5e66fe9 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -20,7 +20,9 @@ if settings.AUDIT_LOG_ENABLED: from auditlog.registry import auditlog from django.db.models import Case +from django.db.models import PositiveIntegerField from django.db.models.functions import Cast +from django.db.models.functions import Length from django.db.models.functions import Substr from django_softdelete.models import SoftDeleteModel @@ -192,6 +194,15 @@ class Document(SoftDeleteModel, ModelWithOwner): ), ) + content_length = models.GeneratedField( + expression=Length("content"), + output_field=PositiveIntegerField(default=0), + db_persist=True, + null=False, + serialize=False, + help_text="Length of the content field in characters. Automatically maintained by the database for faster statistics computation.", + ) + mime_type = models.CharField(_("mime type"), max_length=256, editable=False) tags = models.ManyToManyField( @@ -945,7 +956,7 @@ if settings.AUDIT_LOG_ENABLED: auditlog.register( Document, m2m_fields={"tags"}, - exclude_fields=["modified"], + exclude_fields=["content_length", "modified"], ) auditlog.register(Correspondent) auditlog.register(Tag) diff --git a/src/documents/tests/test_api_documents.py b/src/documents/tests/test_api_documents.py index f40ef157f..96d22dc2c 100644 --- a/src/documents/tests/test_api_documents.py +++ b/src/documents/tests/test_api_documents.py @@ -131,6 +131,10 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): self.assertIn("content", results_full[0]) self.assertIn("id", results_full[0]) + # Content length is used internally for performance reasons. + # No need to expose this field. + self.assertNotIn("content_length", results_full[0]) + response = self.client.get("/api/documents/?fields=id", format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) results = response.data["results"] diff --git a/src/documents/tests/test_management_exporter.py b/src/documents/tests/test_management_exporter.py index 81262779a..c2a1360ca 100644 --- a/src/documents/tests/test_management_exporter.py +++ b/src/documents/tests/test_management_exporter.py @@ -241,6 +241,10 @@ class TestExportImport( checksum = hashlib.md5(f.read()).hexdigest() self.assertEqual(checksum, element["fields"]["checksum"]) + # Generated field "content_length" should not be exported, + # it is automatically computed during import. + self.assertNotIn("content_length", element["fields"]) + if document_exporter.EXPORTER_ARCHIVE_NAME in element: fname = ( self.target / element[document_exporter.EXPORTER_ARCHIVE_NAME] diff --git a/src/documents/views.py b/src/documents/views.py index c9ac9c848..88c9c5cf7 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -35,7 +35,6 @@ from django.db.models import Model from django.db.models import Q from django.db.models import Sum from django.db.models import When -from django.db.models.functions import Length from django.db.models.functions import Lower from django.db.models.manager import Manager from django.http import FileResponse @@ -2326,23 +2325,19 @@ class StatisticsView(GenericAPIView): user = request.user if request.user is not None else None documents = ( - ( - Document.objects.all() - if user is None - else get_objects_for_user_owner_aware( - user, - "documents.view_document", - Document, - ) + Document.objects.all() + if user is None + else get_objects_for_user_owner_aware( + user, + "documents.view_document", + Document, ) - .only("mime_type", "content") - .prefetch_related("tags") ) tags = ( Tag.objects.all() if user is None else get_objects_for_user_owner_aware(user, "documents.view_tag", Tag) - ) + ).only("id", "is_inbox_tag") correspondent_count = ( Correspondent.objects.count() if user is None @@ -2371,31 +2366,33 @@ class StatisticsView(GenericAPIView): ).count() ) - documents_total = documents.count() - - inbox_tags = tags.filter(is_inbox_tag=True) + inbox_tag_pks = list( + tags.filter(is_inbox_tag=True).values_list("pk", flat=True), + ) documents_inbox = ( - documents.filter(tags__id__in=inbox_tags).distinct().count() - if inbox_tags.exists() + documents.filter(tags__id__in=inbox_tag_pks).values("id").distinct().count() + if inbox_tag_pks else None ) - document_file_type_counts = ( + # Single SQL request for document stats and mime type counts + mime_type_stats = list( documents.values("mime_type") - .annotate(mime_type_count=Count("mime_type")) - .order_by("-mime_type_count") - if documents_total > 0 - else [] + .annotate( + mime_type_count=Count("id"), + mime_type_chars=Sum("content_length"), + ) + .order_by("-mime_type_count"), ) - character_count = ( - documents.annotate( - characters=Length("content"), - ) - .aggregate(Sum("characters")) - .get("characters__sum") - ) + # Calculate totals from grouped results + documents_total = sum(row["mime_type_count"] for row in mime_type_stats) + character_count = sum(row["mime_type_chars"] or 0 for row in mime_type_stats) + document_file_type_counts = [ + {"mime_type": row["mime_type"], "mime_type_count": row["mime_type_count"]} + for row in mime_type_stats + ] current_asn = Document.objects.aggregate( Max("archive_serial_number", default=0), @@ -2408,11 +2405,9 @@ class StatisticsView(GenericAPIView): "documents_total": documents_total, "documents_inbox": documents_inbox, "inbox_tag": ( - inbox_tags.first().pk if inbox_tags.exists() else None + inbox_tag_pks[0] if inbox_tag_pks else None ), # backwards compatibility - "inbox_tags": ( - [tag.pk for tag in inbox_tags] if inbox_tags.exists() else None - ), + "inbox_tags": (inbox_tag_pks if inbox_tag_pks else None), "document_file_type_counts": document_file_type_counts, "character_count": character_count, "tag_count": len(tags), From ac767102963ec5e8e1ad7d7413bc9c3980a9db73 Mon Sep 17 00:00:00 2001 From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 20:12:45 +0000 Subject: [PATCH 21/33] Auto translate strings --- src/locale/en_US/LC_MESSAGES/django.po | 584 ++++++++++++------------- 1 file changed, 292 insertions(+), 292 deletions(-) diff --git a/src/locale/en_US/LC_MESSAGES/django.po b/src/locale/en_US/LC_MESSAGES/django.po index 41a007ebd..5bdd1ccf9 100644 --- a/src/locale/en_US/LC_MESSAGES/django.po +++ b/src/locale/en_US/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: paperless-ngx\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-01-26 18:56+0000\n" +"POT-Creation-Date: 2026-01-26 20:11+0000\n" "PO-Revision-Date: 2022-02-17 04:17\n" "Last-Translator: \n" "Language-Team: English\n" @@ -49,7 +49,7 @@ msgstr "" msgid "{data_type} does not support query expr {expr!r}." msgstr "" -#: documents/filters.py:669 documents/models.py:135 +#: documents/filters.py:669 documents/models.py:137 msgid "Maximum nesting depth exceeded." msgstr "" @@ -57,1170 +57,1170 @@ msgstr "" msgid "Custom field not found" msgstr "" -#: documents/models.py:38 documents/models.py:746 +#: documents/models.py:40 documents/models.py:757 msgid "owner" msgstr "" -#: documents/models.py:55 documents/models.py:961 +#: documents/models.py:57 documents/models.py:972 msgid "None" msgstr "" -#: documents/models.py:56 documents/models.py:962 +#: documents/models.py:58 documents/models.py:973 msgid "Any word" msgstr "" -#: documents/models.py:57 documents/models.py:963 +#: documents/models.py:59 documents/models.py:974 msgid "All words" msgstr "" -#: documents/models.py:58 documents/models.py:964 +#: documents/models.py:60 documents/models.py:975 msgid "Exact match" msgstr "" -#: documents/models.py:59 documents/models.py:965 +#: documents/models.py:61 documents/models.py:976 msgid "Regular expression" msgstr "" -#: documents/models.py:60 documents/models.py:966 +#: documents/models.py:62 documents/models.py:977 msgid "Fuzzy word" msgstr "" -#: documents/models.py:61 +#: documents/models.py:63 msgid "Automatic" msgstr "" -#: documents/models.py:64 documents/models.py:433 documents/models.py:1527 +#: documents/models.py:66 documents/models.py:444 documents/models.py:1538 #: paperless_mail/models.py:23 paperless_mail/models.py:143 msgid "name" msgstr "" -#: documents/models.py:66 documents/models.py:1030 +#: documents/models.py:68 documents/models.py:1041 msgid "match" msgstr "" -#: documents/models.py:69 documents/models.py:1033 +#: documents/models.py:71 documents/models.py:1044 msgid "matching algorithm" msgstr "" -#: documents/models.py:74 documents/models.py:1038 +#: documents/models.py:76 documents/models.py:1049 msgid "is insensitive" msgstr "" -#: documents/models.py:97 documents/models.py:163 +#: documents/models.py:99 documents/models.py:165 msgid "correspondent" msgstr "" -#: documents/models.py:98 +#: documents/models.py:100 msgid "correspondents" msgstr "" -#: documents/models.py:102 +#: documents/models.py:104 msgid "color" msgstr "" -#: documents/models.py:107 +#: documents/models.py:109 msgid "is inbox tag" msgstr "" -#: documents/models.py:110 +#: documents/models.py:112 msgid "" "Marks this tag as an inbox tag: All newly consumed documents will be tagged " "with inbox tags." msgstr "" -#: documents/models.py:116 +#: documents/models.py:118 msgid "tag" msgstr "" -#: documents/models.py:117 documents/models.py:201 +#: documents/models.py:119 documents/models.py:212 msgid "tags" msgstr "" -#: documents/models.py:123 +#: documents/models.py:125 msgid "Cannot set itself as parent." msgstr "" -#: documents/models.py:125 +#: documents/models.py:127 msgid "Cannot set parent to a descendant." msgstr "" -#: documents/models.py:142 documents/models.py:183 +#: documents/models.py:144 documents/models.py:185 msgid "document type" msgstr "" -#: documents/models.py:143 +#: documents/models.py:145 msgid "document types" msgstr "" -#: documents/models.py:148 +#: documents/models.py:150 msgid "path" msgstr "" -#: documents/models.py:152 documents/models.py:172 +#: documents/models.py:154 documents/models.py:174 msgid "storage path" msgstr "" -#: documents/models.py:153 +#: documents/models.py:155 msgid "storage paths" msgstr "" -#: documents/models.py:175 +#: documents/models.py:177 msgid "title" msgstr "" -#: documents/models.py:187 documents/models.py:660 +#: documents/models.py:189 documents/models.py:671 msgid "content" msgstr "" -#: documents/models.py:190 +#: documents/models.py:192 msgid "" "The raw, text-only data of the document. This field is primarily used for " "searching." msgstr "" -#: documents/models.py:195 +#: documents/models.py:206 msgid "mime type" msgstr "" -#: documents/models.py:205 +#: documents/models.py:216 msgid "checksum" msgstr "" -#: documents/models.py:208 +#: documents/models.py:219 msgid "The checksum of the original document." msgstr "" -#: documents/models.py:212 +#: documents/models.py:223 msgid "archive checksum" msgstr "" -#: documents/models.py:217 +#: documents/models.py:228 msgid "The checksum of the archived document." msgstr "" -#: documents/models.py:221 +#: documents/models.py:232 msgid "page count" msgstr "" -#: documents/models.py:228 +#: documents/models.py:239 msgid "The number of pages of the document." msgstr "" -#: documents/models.py:233 documents/models.py:666 documents/models.py:704 -#: documents/models.py:776 documents/models.py:835 +#: documents/models.py:244 documents/models.py:677 documents/models.py:715 +#: documents/models.py:787 documents/models.py:846 msgid "created" msgstr "" -#: documents/models.py:239 +#: documents/models.py:250 msgid "modified" msgstr "" -#: documents/models.py:246 +#: documents/models.py:257 msgid "added" msgstr "" -#: documents/models.py:253 +#: documents/models.py:264 msgid "filename" msgstr "" -#: documents/models.py:259 +#: documents/models.py:270 msgid "Current filename in storage" msgstr "" -#: documents/models.py:263 +#: documents/models.py:274 msgid "archive filename" msgstr "" -#: documents/models.py:269 +#: documents/models.py:280 msgid "Current archive filename in storage" msgstr "" -#: documents/models.py:273 +#: documents/models.py:284 msgid "original filename" msgstr "" -#: documents/models.py:279 +#: documents/models.py:290 msgid "The original name of the file when it was uploaded" msgstr "" -#: documents/models.py:286 +#: documents/models.py:297 msgid "archive serial number" msgstr "" -#: documents/models.py:296 +#: documents/models.py:307 msgid "The position of this document in your physical document archive." msgstr "" -#: documents/models.py:302 documents/models.py:677 documents/models.py:731 -#: documents/models.py:1570 +#: documents/models.py:313 documents/models.py:688 documents/models.py:742 +#: documents/models.py:1581 msgid "document" msgstr "" -#: documents/models.py:303 +#: documents/models.py:314 msgid "documents" msgstr "" -#: documents/models.py:414 +#: documents/models.py:425 msgid "Table" msgstr "" -#: documents/models.py:415 +#: documents/models.py:426 msgid "Small Cards" msgstr "" -#: documents/models.py:416 +#: documents/models.py:427 msgid "Large Cards" msgstr "" -#: documents/models.py:419 +#: documents/models.py:430 msgid "Title" msgstr "" -#: documents/models.py:420 documents/models.py:982 +#: documents/models.py:431 documents/models.py:993 msgid "Created" msgstr "" -#: documents/models.py:421 documents/models.py:981 +#: documents/models.py:432 documents/models.py:992 msgid "Added" msgstr "" -#: documents/models.py:422 +#: documents/models.py:433 msgid "Tags" msgstr "" -#: documents/models.py:423 +#: documents/models.py:434 msgid "Correspondent" msgstr "" -#: documents/models.py:424 +#: documents/models.py:435 msgid "Document Type" msgstr "" -#: documents/models.py:425 +#: documents/models.py:436 msgid "Storage Path" msgstr "" -#: documents/models.py:426 +#: documents/models.py:437 msgid "Note" msgstr "" -#: documents/models.py:427 +#: documents/models.py:438 msgid "Owner" msgstr "" -#: documents/models.py:428 +#: documents/models.py:439 msgid "Shared" msgstr "" -#: documents/models.py:429 +#: documents/models.py:440 msgid "ASN" msgstr "" -#: documents/models.py:430 +#: documents/models.py:441 msgid "Pages" msgstr "" -#: documents/models.py:436 +#: documents/models.py:447 msgid "show on dashboard" msgstr "" -#: documents/models.py:439 +#: documents/models.py:450 msgid "show in sidebar" msgstr "" -#: documents/models.py:443 +#: documents/models.py:454 msgid "sort field" msgstr "" -#: documents/models.py:448 +#: documents/models.py:459 msgid "sort reverse" msgstr "" -#: documents/models.py:451 +#: documents/models.py:462 msgid "View page size" msgstr "" -#: documents/models.py:459 +#: documents/models.py:470 msgid "View display mode" msgstr "" -#: documents/models.py:466 +#: documents/models.py:477 msgid "Document display fields" msgstr "" -#: documents/models.py:473 documents/models.py:536 +#: documents/models.py:484 documents/models.py:547 msgid "saved view" msgstr "" -#: documents/models.py:474 +#: documents/models.py:485 msgid "saved views" msgstr "" -#: documents/models.py:482 +#: documents/models.py:493 msgid "title contains" msgstr "" -#: documents/models.py:483 +#: documents/models.py:494 msgid "content contains" msgstr "" -#: documents/models.py:484 +#: documents/models.py:495 msgid "ASN is" msgstr "" -#: documents/models.py:485 +#: documents/models.py:496 msgid "correspondent is" msgstr "" -#: documents/models.py:486 +#: documents/models.py:497 msgid "document type is" msgstr "" -#: documents/models.py:487 +#: documents/models.py:498 msgid "is in inbox" msgstr "" -#: documents/models.py:488 +#: documents/models.py:499 msgid "has tag" msgstr "" -#: documents/models.py:489 +#: documents/models.py:500 msgid "has any tag" msgstr "" -#: documents/models.py:490 +#: documents/models.py:501 msgid "created before" msgstr "" -#: documents/models.py:491 +#: documents/models.py:502 msgid "created after" msgstr "" -#: documents/models.py:492 +#: documents/models.py:503 msgid "created year is" msgstr "" -#: documents/models.py:493 +#: documents/models.py:504 msgid "created month is" msgstr "" -#: documents/models.py:494 +#: documents/models.py:505 msgid "created day is" msgstr "" -#: documents/models.py:495 +#: documents/models.py:506 msgid "added before" msgstr "" -#: documents/models.py:496 +#: documents/models.py:507 msgid "added after" msgstr "" -#: documents/models.py:497 +#: documents/models.py:508 msgid "modified before" msgstr "" -#: documents/models.py:498 +#: documents/models.py:509 msgid "modified after" msgstr "" -#: documents/models.py:499 +#: documents/models.py:510 msgid "does not have tag" msgstr "" -#: documents/models.py:500 +#: documents/models.py:511 msgid "does not have ASN" msgstr "" -#: documents/models.py:501 +#: documents/models.py:512 msgid "title or content contains" msgstr "" -#: documents/models.py:502 +#: documents/models.py:513 msgid "fulltext query" msgstr "" -#: documents/models.py:503 +#: documents/models.py:514 msgid "more like this" msgstr "" -#: documents/models.py:504 +#: documents/models.py:515 msgid "has tags in" msgstr "" -#: documents/models.py:505 +#: documents/models.py:516 msgid "ASN greater than" msgstr "" -#: documents/models.py:506 +#: documents/models.py:517 msgid "ASN less than" msgstr "" -#: documents/models.py:507 +#: documents/models.py:518 msgid "storage path is" msgstr "" -#: documents/models.py:508 +#: documents/models.py:519 msgid "has correspondent in" msgstr "" -#: documents/models.py:509 +#: documents/models.py:520 msgid "does not have correspondent in" msgstr "" -#: documents/models.py:510 +#: documents/models.py:521 msgid "has document type in" msgstr "" -#: documents/models.py:511 +#: documents/models.py:522 msgid "does not have document type in" msgstr "" -#: documents/models.py:512 +#: documents/models.py:523 msgid "has storage path in" msgstr "" -#: documents/models.py:513 +#: documents/models.py:524 msgid "does not have storage path in" msgstr "" -#: documents/models.py:514 +#: documents/models.py:525 msgid "owner is" msgstr "" -#: documents/models.py:515 +#: documents/models.py:526 msgid "has owner in" msgstr "" -#: documents/models.py:516 +#: documents/models.py:527 msgid "does not have owner" msgstr "" -#: documents/models.py:517 +#: documents/models.py:528 msgid "does not have owner in" msgstr "" -#: documents/models.py:518 +#: documents/models.py:529 msgid "has custom field value" msgstr "" -#: documents/models.py:519 +#: documents/models.py:530 msgid "is shared by me" msgstr "" -#: documents/models.py:520 +#: documents/models.py:531 msgid "has custom fields" msgstr "" -#: documents/models.py:521 +#: documents/models.py:532 msgid "has custom field in" msgstr "" -#: documents/models.py:522 +#: documents/models.py:533 msgid "does not have custom field in" msgstr "" -#: documents/models.py:523 +#: documents/models.py:534 msgid "does not have custom field" msgstr "" -#: documents/models.py:524 +#: documents/models.py:535 msgid "custom fields query" msgstr "" -#: documents/models.py:525 +#: documents/models.py:536 msgid "created to" msgstr "" -#: documents/models.py:526 +#: documents/models.py:537 msgid "created from" msgstr "" -#: documents/models.py:527 +#: documents/models.py:538 msgid "added to" msgstr "" -#: documents/models.py:528 +#: documents/models.py:539 msgid "added from" msgstr "" -#: documents/models.py:529 +#: documents/models.py:540 msgid "mime type is" msgstr "" -#: documents/models.py:539 +#: documents/models.py:550 msgid "rule type" msgstr "" -#: documents/models.py:541 +#: documents/models.py:552 msgid "value" msgstr "" -#: documents/models.py:544 +#: documents/models.py:555 msgid "filter rule" msgstr "" -#: documents/models.py:545 +#: documents/models.py:556 msgid "filter rules" msgstr "" -#: documents/models.py:569 +#: documents/models.py:580 msgid "Auto Task" msgstr "" -#: documents/models.py:570 +#: documents/models.py:581 msgid "Scheduled Task" msgstr "" -#: documents/models.py:571 +#: documents/models.py:582 msgid "Manual Task" msgstr "" -#: documents/models.py:574 +#: documents/models.py:585 msgid "Consume File" msgstr "" -#: documents/models.py:575 +#: documents/models.py:586 msgid "Train Classifier" msgstr "" -#: documents/models.py:576 +#: documents/models.py:587 msgid "Check Sanity" msgstr "" -#: documents/models.py:577 +#: documents/models.py:588 msgid "Index Optimize" msgstr "" -#: documents/models.py:578 +#: documents/models.py:589 msgid "LLM Index Update" msgstr "" -#: documents/models.py:583 +#: documents/models.py:594 msgid "Task ID" msgstr "" -#: documents/models.py:584 +#: documents/models.py:595 msgid "Celery ID for the Task that was run" msgstr "" -#: documents/models.py:589 +#: documents/models.py:600 msgid "Acknowledged" msgstr "" -#: documents/models.py:590 +#: documents/models.py:601 msgid "If the task is acknowledged via the frontend or API" msgstr "" -#: documents/models.py:596 +#: documents/models.py:607 msgid "Task Filename" msgstr "" -#: documents/models.py:597 +#: documents/models.py:608 msgid "Name of the file which the Task was run for" msgstr "" -#: documents/models.py:604 +#: documents/models.py:615 msgid "Task Name" msgstr "" -#: documents/models.py:605 +#: documents/models.py:616 msgid "Name of the task that was run" msgstr "" -#: documents/models.py:612 +#: documents/models.py:623 msgid "Task State" msgstr "" -#: documents/models.py:613 +#: documents/models.py:624 msgid "Current state of the task being run" msgstr "" -#: documents/models.py:619 +#: documents/models.py:630 msgid "Created DateTime" msgstr "" -#: documents/models.py:620 +#: documents/models.py:631 msgid "Datetime field when the task result was created in UTC" msgstr "" -#: documents/models.py:626 +#: documents/models.py:637 msgid "Started DateTime" msgstr "" -#: documents/models.py:627 +#: documents/models.py:638 msgid "Datetime field when the task was started in UTC" msgstr "" -#: documents/models.py:633 +#: documents/models.py:644 msgid "Completed DateTime" msgstr "" -#: documents/models.py:634 +#: documents/models.py:645 msgid "Datetime field when the task was completed in UTC" msgstr "" -#: documents/models.py:640 +#: documents/models.py:651 msgid "Result Data" msgstr "" -#: documents/models.py:642 +#: documents/models.py:653 msgid "The data returned by the task" msgstr "" -#: documents/models.py:650 +#: documents/models.py:661 msgid "Task Type" msgstr "" -#: documents/models.py:651 +#: documents/models.py:662 msgid "The type of task that was run" msgstr "" -#: documents/models.py:662 +#: documents/models.py:673 msgid "Note for the document" msgstr "" -#: documents/models.py:686 +#: documents/models.py:697 msgid "user" msgstr "" -#: documents/models.py:691 +#: documents/models.py:702 msgid "note" msgstr "" -#: documents/models.py:692 +#: documents/models.py:703 msgid "notes" msgstr "" -#: documents/models.py:700 +#: documents/models.py:711 msgid "Archive" msgstr "" -#: documents/models.py:701 +#: documents/models.py:712 msgid "Original" msgstr "" -#: documents/models.py:712 paperless_mail/models.py:75 +#: documents/models.py:723 paperless_mail/models.py:75 msgid "expiration" msgstr "" -#: documents/models.py:719 +#: documents/models.py:730 msgid "slug" msgstr "" -#: documents/models.py:751 +#: documents/models.py:762 msgid "share link" msgstr "" -#: documents/models.py:752 +#: documents/models.py:763 msgid "share links" msgstr "" -#: documents/models.py:764 +#: documents/models.py:775 msgid "String" msgstr "" -#: documents/models.py:765 +#: documents/models.py:776 msgid "URL" msgstr "" -#: documents/models.py:766 +#: documents/models.py:777 msgid "Date" msgstr "" -#: documents/models.py:767 +#: documents/models.py:778 msgid "Boolean" msgstr "" -#: documents/models.py:768 +#: documents/models.py:779 msgid "Integer" msgstr "" -#: documents/models.py:769 +#: documents/models.py:780 msgid "Float" msgstr "" -#: documents/models.py:770 +#: documents/models.py:781 msgid "Monetary" msgstr "" -#: documents/models.py:771 +#: documents/models.py:782 msgid "Document Link" msgstr "" -#: documents/models.py:772 +#: documents/models.py:783 msgid "Select" msgstr "" -#: documents/models.py:773 +#: documents/models.py:784 msgid "Long Text" msgstr "" -#: documents/models.py:785 +#: documents/models.py:796 msgid "data type" msgstr "" -#: documents/models.py:792 +#: documents/models.py:803 msgid "extra data" msgstr "" -#: documents/models.py:796 +#: documents/models.py:807 msgid "Extra data for the custom field, such as select options" msgstr "" -#: documents/models.py:802 +#: documents/models.py:813 msgid "custom field" msgstr "" -#: documents/models.py:803 +#: documents/models.py:814 msgid "custom fields" msgstr "" -#: documents/models.py:903 +#: documents/models.py:914 msgid "custom field instance" msgstr "" -#: documents/models.py:904 +#: documents/models.py:915 msgid "custom field instances" msgstr "" -#: documents/models.py:969 +#: documents/models.py:980 msgid "Consumption Started" msgstr "" -#: documents/models.py:970 +#: documents/models.py:981 msgid "Document Added" msgstr "" -#: documents/models.py:971 +#: documents/models.py:982 msgid "Document Updated" msgstr "" -#: documents/models.py:972 +#: documents/models.py:983 msgid "Scheduled" msgstr "" -#: documents/models.py:975 +#: documents/models.py:986 msgid "Consume Folder" msgstr "" -#: documents/models.py:976 +#: documents/models.py:987 msgid "Api Upload" msgstr "" -#: documents/models.py:977 +#: documents/models.py:988 msgid "Mail Fetch" msgstr "" -#: documents/models.py:978 +#: documents/models.py:989 msgid "Web UI" msgstr "" -#: documents/models.py:983 +#: documents/models.py:994 msgid "Modified" msgstr "" -#: documents/models.py:984 +#: documents/models.py:995 msgid "Custom Field" msgstr "" -#: documents/models.py:987 +#: documents/models.py:998 msgid "Workflow Trigger Type" msgstr "" -#: documents/models.py:999 +#: documents/models.py:1010 msgid "filter path" msgstr "" -#: documents/models.py:1004 +#: documents/models.py:1015 msgid "" "Only consume documents with a path that matches this if specified. Wildcards " "specified as * are allowed. Case insensitive." msgstr "" -#: documents/models.py:1011 +#: documents/models.py:1022 msgid "filter filename" msgstr "" -#: documents/models.py:1016 paperless_mail/models.py:200 +#: documents/models.py:1027 paperless_mail/models.py:200 msgid "" "Only consume documents which entirely match this filename if specified. " "Wildcards such as *.pdf or *invoice* are allowed. Case insensitive." msgstr "" -#: documents/models.py:1027 +#: documents/models.py:1038 msgid "filter documents from this mail rule" msgstr "" -#: documents/models.py:1043 +#: documents/models.py:1054 msgid "has these tag(s)" msgstr "" -#: documents/models.py:1050 +#: documents/models.py:1061 msgid "has all of these tag(s)" msgstr "" -#: documents/models.py:1057 +#: documents/models.py:1068 msgid "does not have these tag(s)" msgstr "" -#: documents/models.py:1065 +#: documents/models.py:1076 msgid "has this document type" msgstr "" -#: documents/models.py:1072 +#: documents/models.py:1083 msgid "has one of these document types" msgstr "" -#: documents/models.py:1079 +#: documents/models.py:1090 msgid "does not have these document type(s)" msgstr "" -#: documents/models.py:1087 +#: documents/models.py:1098 msgid "has this correspondent" msgstr "" -#: documents/models.py:1094 +#: documents/models.py:1105 msgid "does not have these correspondent(s)" msgstr "" -#: documents/models.py:1101 +#: documents/models.py:1112 msgid "has one of these correspondents" msgstr "" -#: documents/models.py:1109 +#: documents/models.py:1120 msgid "has this storage path" msgstr "" -#: documents/models.py:1116 +#: documents/models.py:1127 msgid "has one of these storage paths" msgstr "" -#: documents/models.py:1123 +#: documents/models.py:1134 msgid "does not have these storage path(s)" msgstr "" -#: documents/models.py:1127 +#: documents/models.py:1138 msgid "filter custom field query" msgstr "" -#: documents/models.py:1130 +#: documents/models.py:1141 msgid "JSON-encoded custom field query expression." msgstr "" -#: documents/models.py:1134 +#: documents/models.py:1145 msgid "schedule offset days" msgstr "" -#: documents/models.py:1137 +#: documents/models.py:1148 msgid "The number of days to offset the schedule trigger by." msgstr "" -#: documents/models.py:1142 +#: documents/models.py:1153 msgid "schedule is recurring" msgstr "" -#: documents/models.py:1145 +#: documents/models.py:1156 msgid "If the schedule should be recurring." msgstr "" -#: documents/models.py:1150 +#: documents/models.py:1161 msgid "schedule recurring delay in days" msgstr "" -#: documents/models.py:1154 +#: documents/models.py:1165 msgid "The number of days between recurring schedule triggers." msgstr "" -#: documents/models.py:1159 +#: documents/models.py:1170 msgid "schedule date field" msgstr "" -#: documents/models.py:1164 +#: documents/models.py:1175 msgid "The field to check for a schedule trigger." msgstr "" -#: documents/models.py:1173 +#: documents/models.py:1184 msgid "schedule date custom field" msgstr "" -#: documents/models.py:1177 +#: documents/models.py:1188 msgid "workflow trigger" msgstr "" -#: documents/models.py:1178 +#: documents/models.py:1189 msgid "workflow triggers" msgstr "" -#: documents/models.py:1186 +#: documents/models.py:1197 msgid "email subject" msgstr "" -#: documents/models.py:1190 +#: documents/models.py:1201 msgid "" "The subject of the email, can include some placeholders, see documentation." msgstr "" -#: documents/models.py:1196 +#: documents/models.py:1207 msgid "email body" msgstr "" -#: documents/models.py:1199 +#: documents/models.py:1210 msgid "" "The body (message) of the email, can include some placeholders, see " "documentation." msgstr "" -#: documents/models.py:1205 +#: documents/models.py:1216 msgid "emails to" msgstr "" -#: documents/models.py:1208 +#: documents/models.py:1219 msgid "The destination email addresses, comma separated." msgstr "" -#: documents/models.py:1214 +#: documents/models.py:1225 msgid "include document in email" msgstr "" -#: documents/models.py:1225 +#: documents/models.py:1236 msgid "webhook url" msgstr "" -#: documents/models.py:1228 +#: documents/models.py:1239 msgid "The destination URL for the notification." msgstr "" -#: documents/models.py:1233 +#: documents/models.py:1244 msgid "use parameters" msgstr "" -#: documents/models.py:1238 +#: documents/models.py:1249 msgid "send as JSON" msgstr "" -#: documents/models.py:1242 +#: documents/models.py:1253 msgid "webhook parameters" msgstr "" -#: documents/models.py:1245 +#: documents/models.py:1256 msgid "The parameters to send with the webhook URL if body not used." msgstr "" -#: documents/models.py:1249 +#: documents/models.py:1260 msgid "webhook body" msgstr "" -#: documents/models.py:1252 +#: documents/models.py:1263 msgid "The body to send with the webhook URL if parameters not used." msgstr "" -#: documents/models.py:1256 +#: documents/models.py:1267 msgid "webhook headers" msgstr "" -#: documents/models.py:1259 +#: documents/models.py:1270 msgid "The headers to send with the webhook URL." msgstr "" -#: documents/models.py:1264 +#: documents/models.py:1275 msgid "include document in webhook" msgstr "" -#: documents/models.py:1275 +#: documents/models.py:1286 msgid "Assignment" msgstr "" -#: documents/models.py:1279 +#: documents/models.py:1290 msgid "Removal" msgstr "" -#: documents/models.py:1283 documents/templates/account/password_reset.html:15 +#: documents/models.py:1294 documents/templates/account/password_reset.html:15 msgid "Email" msgstr "" -#: documents/models.py:1287 +#: documents/models.py:1298 msgid "Webhook" msgstr "" -#: documents/models.py:1291 +#: documents/models.py:1302 msgid "Workflow Action Type" msgstr "" -#: documents/models.py:1296 documents/models.py:1529 +#: documents/models.py:1307 documents/models.py:1540 #: paperless_mail/models.py:145 msgid "order" msgstr "" -#: documents/models.py:1299 +#: documents/models.py:1310 msgid "assign title" msgstr "" -#: documents/models.py:1303 +#: documents/models.py:1314 msgid "Assign a document title, must be a Jinja2 template, see documentation." msgstr "" -#: documents/models.py:1311 paperless_mail/models.py:274 +#: documents/models.py:1322 paperless_mail/models.py:274 msgid "assign this tag" msgstr "" -#: documents/models.py:1320 paperless_mail/models.py:282 +#: documents/models.py:1331 paperless_mail/models.py:282 msgid "assign this document type" msgstr "" -#: documents/models.py:1329 paperless_mail/models.py:296 +#: documents/models.py:1340 paperless_mail/models.py:296 msgid "assign this correspondent" msgstr "" -#: documents/models.py:1338 +#: documents/models.py:1349 msgid "assign this storage path" msgstr "" -#: documents/models.py:1347 +#: documents/models.py:1358 msgid "assign this owner" msgstr "" -#: documents/models.py:1354 +#: documents/models.py:1365 msgid "grant view permissions to these users" msgstr "" -#: documents/models.py:1361 +#: documents/models.py:1372 msgid "grant view permissions to these groups" msgstr "" -#: documents/models.py:1368 +#: documents/models.py:1379 msgid "grant change permissions to these users" msgstr "" -#: documents/models.py:1375 +#: documents/models.py:1386 msgid "grant change permissions to these groups" msgstr "" -#: documents/models.py:1382 +#: documents/models.py:1393 msgid "assign these custom fields" msgstr "" -#: documents/models.py:1386 +#: documents/models.py:1397 msgid "custom field values" msgstr "" -#: documents/models.py:1390 +#: documents/models.py:1401 msgid "Optional values to assign to the custom fields." msgstr "" -#: documents/models.py:1399 +#: documents/models.py:1410 msgid "remove these tag(s)" msgstr "" -#: documents/models.py:1404 +#: documents/models.py:1415 msgid "remove all tags" msgstr "" -#: documents/models.py:1411 +#: documents/models.py:1422 msgid "remove these document type(s)" msgstr "" -#: documents/models.py:1416 +#: documents/models.py:1427 msgid "remove all document types" msgstr "" -#: documents/models.py:1423 +#: documents/models.py:1434 msgid "remove these correspondent(s)" msgstr "" -#: documents/models.py:1428 +#: documents/models.py:1439 msgid "remove all correspondents" msgstr "" -#: documents/models.py:1435 +#: documents/models.py:1446 msgid "remove these storage path(s)" msgstr "" -#: documents/models.py:1440 +#: documents/models.py:1451 msgid "remove all storage paths" msgstr "" -#: documents/models.py:1447 +#: documents/models.py:1458 msgid "remove these owner(s)" msgstr "" -#: documents/models.py:1452 +#: documents/models.py:1463 msgid "remove all owners" msgstr "" -#: documents/models.py:1459 +#: documents/models.py:1470 msgid "remove view permissions for these users" msgstr "" -#: documents/models.py:1466 +#: documents/models.py:1477 msgid "remove view permissions for these groups" msgstr "" -#: documents/models.py:1473 +#: documents/models.py:1484 msgid "remove change permissions for these users" msgstr "" -#: documents/models.py:1480 +#: documents/models.py:1491 msgid "remove change permissions for these groups" msgstr "" -#: documents/models.py:1485 +#: documents/models.py:1496 msgid "remove all permissions" msgstr "" -#: documents/models.py:1492 +#: documents/models.py:1503 msgid "remove these custom fields" msgstr "" -#: documents/models.py:1497 +#: documents/models.py:1508 msgid "remove all custom fields" msgstr "" -#: documents/models.py:1506 +#: documents/models.py:1517 msgid "email" msgstr "" -#: documents/models.py:1515 +#: documents/models.py:1526 msgid "webhook" msgstr "" -#: documents/models.py:1519 +#: documents/models.py:1530 msgid "workflow action" msgstr "" -#: documents/models.py:1520 +#: documents/models.py:1531 msgid "workflow actions" msgstr "" -#: documents/models.py:1535 +#: documents/models.py:1546 msgid "triggers" msgstr "" -#: documents/models.py:1542 +#: documents/models.py:1553 msgid "actions" msgstr "" -#: documents/models.py:1545 paperless_mail/models.py:154 +#: documents/models.py:1556 paperless_mail/models.py:154 msgid "enabled" msgstr "" -#: documents/models.py:1556 +#: documents/models.py:1567 msgid "workflow" msgstr "" -#: documents/models.py:1560 +#: documents/models.py:1571 msgid "workflow trigger type" msgstr "" -#: documents/models.py:1574 +#: documents/models.py:1585 msgid "date run" msgstr "" -#: documents/models.py:1580 +#: documents/models.py:1591 msgid "workflow run" msgstr "" -#: documents/models.py:1581 +#: documents/models.py:1592 msgid "workflow runs" msgstr "" From f82f31f38388c32b0d5763e41bd3be6d5504d616 Mon Sep 17 00:00:00 2001 From: Jan Kleine Date: Mon, 26 Jan 2026 21:56:29 +0100 Subject: [PATCH 22/33] Enhancement: improve relative dates in date filter (#11899) --- .../dates-dropdown/dates-dropdown.component.html | 6 ++++-- .../common/dates-dropdown/dates-dropdown.component.ts | 10 ++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.html b/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.html index 74b49bbdb..2057a79ff 100644 --- a/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.html +++ b/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.html @@ -164,9 +164,11 @@ {{ item.name }} @if (item.dateEnd) { - {{ item.date | customDate:'MMM d' }} – {{ item.dateEnd | customDate:'mediumDate' }} + {{ item.date | customDate:'mediumDate' }} – {{ item.dateEnd | customDate:'mediumDate' }} + } @else if (item.dateTilNow) { + {{ item.dateTilNow | customDate:'mediumDate' }} – now } @else { - {{ item.date | customDate:'mediumDate' }} – now + {{ item.date | customDate:'mediumDate' }} }
    diff --git a/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.ts b/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.ts index e07b08959..42bd3b0e4 100644 --- a/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.ts +++ b/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.ts @@ -79,32 +79,34 @@ export class DatesDropdownComponent implements OnInit, OnDestroy { { id: RelativeDate.WITHIN_1_WEEK, name: $localize`Within 1 week`, - date: new Date().setDate(new Date().getDate() - 7), + dateTilNow: new Date().setDate(new Date().getDate() - 7), }, { id: RelativeDate.WITHIN_1_MONTH, name: $localize`Within 1 month`, - date: new Date().setMonth(new Date().getMonth() - 1), + dateTilNow: new Date().setMonth(new Date().getMonth() - 1), }, { id: RelativeDate.WITHIN_3_MONTHS, name: $localize`Within 3 months`, - date: new Date().setMonth(new Date().getMonth() - 3), + dateTilNow: new Date().setMonth(new Date().getMonth() - 3), }, { id: RelativeDate.WITHIN_1_YEAR, name: $localize`Within 1 year`, - date: new Date().setFullYear(new Date().getFullYear() - 1), + dateTilNow: new Date().setFullYear(new Date().getFullYear() - 1), }, { id: RelativeDate.THIS_YEAR, name: $localize`This year`, date: new Date('1/1/' + new Date().getFullYear()), + dateEnd: new Date('12/31/' + new Date().getFullYear()), }, { id: RelativeDate.THIS_MONTH, name: $localize`This month`, date: new Date().setDate(1), + dateEnd: new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0), }, { id: RelativeDate.TODAY, From 6997a2ab8bc610807b3b139cf7cc7493d6d2158b Mon Sep 17 00:00:00 2001 From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 20:58:22 +0000 Subject: [PATCH 23/33] Auto translate strings --- src-ui/messages.xlf | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index 439b83908..ea44b87bf 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -3444,7 +3444,7 @@ src/app/components/common/dates-dropdown/dates-dropdown.component.ts - 111 + 113 src/app/components/common/input/date/date.component.html @@ -3704,14 +3704,14 @@ This month src/app/components/common/dates-dropdown/dates-dropdown.component.ts - 106 + 107 Yesterday src/app/components/common/dates-dropdown/dates-dropdown.component.ts - 116 + 118 src/app/pipes/custom-date.pipe.ts @@ -3722,28 +3722,28 @@ Previous week src/app/components/common/dates-dropdown/dates-dropdown.component.ts - 121 + 123 Previous month src/app/components/common/dates-dropdown/dates-dropdown.component.ts - 135 + 137 Previous quarter src/app/components/common/dates-dropdown/dates-dropdown.component.ts - 141 + 143 Previous year src/app/components/common/dates-dropdown/dates-dropdown.component.ts - 155 + 157 From 045994042b4b08e2151f1e69dd27929c13ba18b6 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 26 Jan 2026 23:23:53 -0800 Subject: [PATCH 24/33] Enhancement: user control of doc details fields (#11906) --- .../admin/settings/settings.component.html | 102 +++++++++++------- .../admin/settings/settings.component.spec.ts | 30 ++++-- .../admin/settings/settings.component.ts | 55 +++++++++- .../document-detail.component.html | 26 +++-- .../document-detail.component.spec.ts | 5 +- .../document-detail.component.ts | 9 ++ src-ui/src/app/data/ui-settings.ts | 7 ++ 7 files changed, 176 insertions(+), 58 deletions(-) diff --git a/src-ui/src/app/components/admin/settings/settings.component.html b/src-ui/src/app/components/admin/settings/settings.component.html index b228dac32..807368aa6 100644 --- a/src-ui/src/app/components/admin/settings/settings.component.html +++ b/src-ui/src/app/components/admin/settings/settings.component.html @@ -103,22 +103,6 @@
    -
    - Items per page -
    -
    - - - -
    -
    - -
    Sidebar
    @@ -153,8 +137,28 @@
    + +
    +
    Global search
    +
    +
    + +
    +
    -
    Update checking
    +
    +
    + Full search links to +
    +
    + +
    +
    + +
    Update checking
    @@ -179,11 +183,33 @@
    -
    -
    -
    Document editing
    +
    + + + +
  • + Documents + +
    +
    +
    Documents
    +
    +
    + Items per page +
    +
    + +
    +
    + +
    Document editing
    @@ -209,31 +235,31 @@
    -
    +
    -
    Global search
    -
    -
    - -
    -
    -
    -
    - Full search links to -
    -
    - +
    +

    Built-in fields to show:

    + @for (option of documentDetailFieldOptions; track option.id) { +
    + + +
    + } +

    Uncheck fields to hide them on the document details page.

    - +
    +
    Bulk editing
    @@ -248,10 +274,8 @@
    -
    -
  • diff --git a/src-ui/src/app/components/admin/settings/settings.component.spec.ts b/src-ui/src/app/components/admin/settings/settings.component.spec.ts index 650d6d8ea..62a5aa363 100644 --- a/src-ui/src/app/components/admin/settings/settings.component.spec.ts +++ b/src-ui/src/app/components/admin/settings/settings.component.spec.ts @@ -201,9 +201,9 @@ describe('SettingsComponent', () => { const navigateSpy = jest.spyOn(router, 'navigate') const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) tabButtons[1].nativeElement.dispatchEvent(new MouseEvent('click')) - expect(navigateSpy).toHaveBeenCalledWith(['settings', 'permissions']) + expect(navigateSpy).toHaveBeenCalledWith(['settings', 'documents']) tabButtons[2].nativeElement.dispatchEvent(new MouseEvent('click')) - expect(navigateSpy).toHaveBeenCalledWith(['settings', 'notifications']) + expect(navigateSpy).toHaveBeenCalledWith(['settings', 'permissions']) const initSpy = jest.spyOn(component, 'initialize') component.isDirty = true // mock dirty @@ -213,8 +213,8 @@ describe('SettingsComponent', () => { expect(initSpy).not.toHaveBeenCalled() navigateSpy.mockResolvedValueOnce(true) // nav accepted even though dirty - tabButtons[1].nativeElement.dispatchEvent(new MouseEvent('click')) - expect(navigateSpy).toHaveBeenCalledWith(['settings', 'notifications']) + tabButtons[2].nativeElement.dispatchEvent(new MouseEvent('click')) + expect(navigateSpy).toHaveBeenCalledWith(['settings', 'permissions']) expect(initSpy).toHaveBeenCalled() }) @@ -226,7 +226,7 @@ describe('SettingsComponent', () => { activatedRoute.snapshot.fragment = '#notifications' const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor') component.ngOnInit() - expect(component.activeNavID).toEqual(3) // Notifications + expect(component.activeNavID).toEqual(4) // Notifications component.ngAfterViewInit() expect(scrollSpy).toHaveBeenCalledWith('#notifications') }) @@ -251,7 +251,7 @@ describe('SettingsComponent', () => { expect(toastErrorSpy).toHaveBeenCalled() expect(storeSpy).toHaveBeenCalled() expect(appearanceSettingsSpy).not.toHaveBeenCalled() - expect(setSpy).toHaveBeenCalledTimes(30) + expect(setSpy).toHaveBeenCalledTimes(31) // succeed storeSpy.mockReturnValueOnce(of(true)) @@ -366,4 +366,22 @@ describe('SettingsComponent', () => { settingsService.settingsSaved.emit(true) expect(maybeRefreshSpy).toHaveBeenCalled() }) + + it('should support toggling document detail fields', () => { + completeSetup() + const field = 'storage_path' + expect( + component.settingsForm.get('documentDetailsHiddenFields').value.length + ).toEqual(0) + component.toggleDocumentDetailField(field, false) + expect( + component.settingsForm.get('documentDetailsHiddenFields').value.length + ).toEqual(1) + expect(component.isDocumentDetailFieldShown(field)).toBeFalsy() + component.toggleDocumentDetailField(field, true) + expect( + component.settingsForm.get('documentDetailsHiddenFields').value.length + ).toEqual(0) + expect(component.isDocumentDetailFieldShown(field)).toBeTruthy() + }) }) diff --git a/src-ui/src/app/components/admin/settings/settings.component.ts b/src-ui/src/app/components/admin/settings/settings.component.ts index 614d2fcd0..990944ff6 100644 --- a/src-ui/src/app/components/admin/settings/settings.component.ts +++ b/src-ui/src/app/components/admin/settings/settings.component.ts @@ -70,9 +70,9 @@ import { ComponentWithPermissions } from '../../with-permissions/with-permission enum SettingsNavIDs { General = 1, - Permissions = 2, - Notifications = 3, - SavedViews = 4, + Documents = 2, + Permissions = 3, + Notifications = 4, } const systemLanguage = { code: '', name: $localize`Use system language` } @@ -81,6 +81,25 @@ const systemDateFormat = { name: $localize`Use date format of display language`, } +export enum DocumentDetailFieldID { + ArchiveSerialNumber = 'archive_serial_number', + Correspondent = 'correspondent', + DocumentType = 'document_type', + StoragePath = 'storage_path', + Tags = 'tags', +} + +const documentDetailFieldOptions = [ + { + id: DocumentDetailFieldID.ArchiveSerialNumber, + label: $localize`Archive serial number`, + }, + { id: DocumentDetailFieldID.Correspondent, label: $localize`Correspondent` }, + { id: DocumentDetailFieldID.DocumentType, label: $localize`Document type` }, + { id: DocumentDetailFieldID.StoragePath, label: $localize`Storage path` }, + { id: DocumentDetailFieldID.Tags, label: $localize`Tags` }, +] + @Component({ selector: 'pngx-settings', templateUrl: './settings.component.html', @@ -146,6 +165,7 @@ export class SettingsComponent pdfViewerDefaultZoom: new FormControl(null), documentEditingRemoveInboxTags: new FormControl(null), documentEditingOverlayThumbnail: new FormControl(null), + documentDetailsHiddenFields: new FormControl([]), searchDbOnly: new FormControl(null), searchLink: new FormControl(null), @@ -176,6 +196,8 @@ export class SettingsComponent public readonly ZoomSetting = ZoomSetting + public readonly documentDetailFieldOptions = documentDetailFieldOptions + get systemStatusHasErrors(): boolean { return ( this.systemStatus.database.status === SystemStatusItemStatus.ERROR || @@ -336,6 +358,9 @@ export class SettingsComponent documentEditingOverlayThumbnail: this.settings.get( SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL ), + documentDetailsHiddenFields: this.settings.get( + SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS + ), searchDbOnly: this.settings.get(SETTINGS_KEYS.SEARCH_DB_ONLY), searchLink: this.settings.get(SETTINGS_KEYS.SEARCH_FULL_TYPE), } @@ -526,6 +551,10 @@ export class SettingsComponent SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL, this.settingsForm.value.documentEditingOverlayThumbnail ) + this.settings.set( + SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS, + this.settingsForm.value.documentDetailsHiddenFields + ) this.settings.set( SETTINGS_KEYS.SEARCH_DB_ONLY, this.settingsForm.value.searchDbOnly @@ -587,6 +616,26 @@ export class SettingsComponent this.settingsForm.get('themeColor').patchValue('') } + isDocumentDetailFieldShown(fieldId: string): boolean { + const hiddenFields = + this.settingsForm.value.documentDetailsHiddenFields || [] + return !hiddenFields.includes(fieldId) + } + + toggleDocumentDetailField(fieldId: string, checked: boolean) { + const hiddenFields = new Set( + this.settingsForm.value.documentDetailsHiddenFields || [] + ) + if (checked) { + hiddenFields.delete(fieldId) + } else { + hiddenFields.add(fieldId) + } + this.settingsForm + .get('documentDetailsHiddenFields') + .setValue(Array.from(hiddenFields)) + } + showSystemStatus() { const modal: NgbModalRef = this.modalService.open( SystemStatusDialogComponent, diff --git a/src-ui/src/app/components/document-detail/document-detail.component.html b/src-ui/src/app/components/document-detail/document-detail.component.html index 5ca002479..306152cc4 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.html +++ b/src-ui/src/app/components/document-detail/document-detail.component.html @@ -146,16 +146,26 @@
    - + @if (!isFieldHidden(DocumentDetailFieldID.ArchiveSerialNumber)) { + + } - - - - + @if (!isFieldHidden(DocumentDetailFieldID.Correspondent)) { + + } + @if (!isFieldHidden(DocumentDetailFieldID.DocumentType)) { + + } + @if (!isFieldHidden(DocumentDetailFieldID.StoragePath)) { + + } + @if (!isFieldHidden(DocumentDetailFieldID.Tags)) { + + } @for (fieldInstance of document?.custom_fields; track fieldInstance.field; let i = $index) {
    @switch (getCustomFieldFromInstance(fieldInstance)?.data_type) { diff --git a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts index d1d10c985..809478816 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts @@ -48,6 +48,7 @@ import { } from 'src/app/data/filter-rule-type' import { StoragePath } from 'src/app/data/storage-path' import { Tag } from 'src/app/data/tag' +import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { PermissionsGuard } from 'src/app/guards/permissions.guard' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe' @@ -1015,7 +1016,7 @@ describe('DocumentDetailComponent', () => { it('should display built-in pdf viewer if not disabled', () => { initNormally() component.document.archived_file_name = 'file.pdf' - jest.spyOn(settingsService, 'get').mockReturnValue(false) + settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false) expect(component.useNativePdfViewer).toBeFalsy() fixture.detectChanges() expect(fixture.debugElement.query(By.css('pdf-viewer'))).not.toBeNull() @@ -1024,7 +1025,7 @@ describe('DocumentDetailComponent', () => { it('should display native pdf viewer if enabled', () => { initNormally() component.document.archived_file_name = 'file.pdf' - jest.spyOn(settingsService, 'get').mockReturnValue(true) + settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, true) expect(component.useNativePdfViewer).toBeTruthy() fixture.detectChanges() expect(fixture.debugElement.query(By.css('object'))).not.toBeNull() diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts index 917597ef6..8c22f53c2 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.ts @@ -84,6 +84,7 @@ import { ToastService } from 'src/app/services/toast.service' import { getFilenameFromContentDisposition } from 'src/app/utils/http' import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter' import * as UTIF from 'utif' +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 { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component' @@ -281,6 +282,8 @@ export class DocumentDetailComponent public readonly DataType = DataType + public readonly DocumentDetailFieldID = DocumentDetailFieldID + @ViewChild('nav') nav: NgbNav @ViewChild('pdfPreview') set pdfPreview(element) { // this gets called when component added or removed from DOM @@ -327,6 +330,12 @@ export class DocumentDetailComponent return this.settings.get(SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL) } + isFieldHidden(fieldId: DocumentDetailFieldID): boolean { + return this.settings + .get(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS) + .includes(fieldId) + } + private getRenderType(mimeType: string): ContentRenderType { if (!mimeType) return ContentRenderType.Unknown if (mimeType === 'application/pdf') { diff --git a/src-ui/src/app/data/ui-settings.ts b/src-ui/src/app/data/ui-settings.ts index e797fe9b3..827a1b82d 100644 --- a/src-ui/src/app/data/ui-settings.ts +++ b/src-ui/src/app/data/ui-settings.ts @@ -70,6 +70,8 @@ export const SETTINGS_KEYS = { 'general-settings:document-editing:remove-inbox-tags', DOCUMENT_EDITING_OVERLAY_THUMBNAIL: 'general-settings:document-editing:overlay-thumbnail', + DOCUMENT_DETAILS_HIDDEN_FIELDS: + 'general-settings:document-details:hidden-fields', SEARCH_DB_ONLY: 'general-settings:search:db-only', SEARCH_FULL_TYPE: 'general-settings:search:more-link', EMPTY_TRASH_DELAY: 'trash_delay', @@ -255,6 +257,11 @@ export const SETTINGS: UiSetting[] = [ type: 'boolean', default: true, }, + { + key: SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS, + type: 'array', + default: [], + }, { key: SETTINGS_KEYS.SEARCH_DB_ONLY, type: 'boolean', From 94b0f4e1146113970517b1b6df47ed5cba606963 Mon Sep 17 00:00:00 2001 From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 27 Jan 2026 07:25:45 +0000 Subject: [PATCH 25/33] Auto translate strings --- src-ui/messages.xlf | 628 +++++++++++++++++++++++--------------------- 1 file changed, 335 insertions(+), 293 deletions(-) diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index ea44b87bf..f9cf0e906 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -314,6 +314,14 @@ src/app/app.component.ts 152 + + src/app/components/admin/settings/settings.component.html + 193 + + + src/app/components/admin/settings/settings.component.html + 197 + src/app/components/app-frame/app-frame.component.html 94 @@ -534,7 +542,7 @@ src/app/components/document-detail/document-detail.component.html - 427 + 437 @@ -545,7 +553,7 @@ src/app/components/admin/settings/settings.component.html - 362 + 386 src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html @@ -593,7 +601,7 @@ src/app/components/document-detail/document-detail.component.html - 420 + 430 src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.html @@ -761,7 +769,7 @@ src/app/components/document-detail/document-detail.component.html - 440 + 450 src/app/components/document-list/document-list.component.html @@ -914,88 +922,128 @@ 99,100 - - Items per page - - src/app/components/admin/settings/settings.component.html - 107 - - Sidebar src/app/components/admin/settings/settings.component.html - 123 + 107 Use 'slim' sidebar (icons only) src/app/components/admin/settings/settings.component.html - 127 + 111 Dark mode src/app/components/admin/settings/settings.component.html - 134 + 118 Use system settings src/app/components/admin/settings/settings.component.html - 137 + 121 Enable dark mode src/app/components/admin/settings/settings.component.html - 138 + 122 Invert thumbnails in dark mode src/app/components/admin/settings/settings.component.html - 139 + 123 Theme Color src/app/components/admin/settings/settings.component.html - 145 + 129 Reset src/app/components/admin/settings/settings.component.html - 152 + 136 + + + + Global search + + src/app/components/admin/settings/settings.component.html + 142 + + + src/app/components/app-frame/global-search/global-search.component.ts + 122 + + + + Do not include advanced search results + + src/app/components/admin/settings/settings.component.html + 145 + + + + Full search links to + + src/app/components/admin/settings/settings.component.html + 151 + + + + Title and content search + + src/app/components/admin/settings/settings.component.html + 155 + + + + Advanced search + + src/app/components/admin/settings/settings.component.html + 156 + + + src/app/components/app-frame/global-search/global-search.component.html + 24 + + + src/app/components/document-list/filter-editor/filter-editor.component.ts + 208 Update checking src/app/components/admin/settings/settings.component.html - 157 + 161 Enable update checking src/app/components/admin/settings/settings.component.html - 160 + 164 What's this? src/app/components/admin/settings/settings.component.html - 161 + 165 src/app/components/common/page-header/page-header.component.html @@ -1014,21 +1062,21 @@ Update checking works by pinging the public GitHub API for the latest release to determine whether a new version is available. Actual updating of the app must still be performed manually. src/app/components/admin/settings/settings.component.html - 165,167 + 169,171 No tracking data is collected by the app in any way. src/app/components/admin/settings/settings.component.html - 169 + 173 Saved Views src/app/components/admin/settings/settings.component.html - 175 + 179 src/app/components/app-frame/app-frame.component.html @@ -1047,152 +1095,126 @@ Show warning when closing saved views with unsaved changes src/app/components/admin/settings/settings.component.html - 178 + 182 Show document counts in sidebar saved views src/app/components/admin/settings/settings.component.html - 179 + 183 + + + + Items per page + + src/app/components/admin/settings/settings.component.html + 200 Document editing src/app/components/admin/settings/settings.component.html - 185 + 212 Use PDF viewer provided by the browser src/app/components/admin/settings/settings.component.html - 189 + 215 This is usually faster for displaying large PDF documents, but it might not work on some browsers. src/app/components/admin/settings/settings.component.html - 189 + 215 Default zoom src/app/components/admin/settings/settings.component.html - 195 + 221 Fit width src/app/components/admin/settings/settings.component.html - 199 + 225 Fit page src/app/components/admin/settings/settings.component.html - 200 + 226 Only applies to the Paperless-ngx PDF viewer. src/app/components/admin/settings/settings.component.html - 202 + 228 Automatically remove inbox tag(s) on save src/app/components/admin/settings/settings.component.html - 208 + 234 Show document thumbnail during loading src/app/components/admin/settings/settings.component.html - 214 + 240 - - Global search + + Built-in fields to show: src/app/components/admin/settings/settings.component.html - 218 - - - src/app/components/app-frame/global-search/global-search.component.ts - 122 + 246 - - Do not include advanced search results + + Uncheck fields to hide them on the document details page. src/app/components/admin/settings/settings.component.html - 221 - - - - Full search links to - - src/app/components/admin/settings/settings.component.html - 227 - - - - Title and content search - - src/app/components/admin/settings/settings.component.html - 231 - - - - Advanced search - - src/app/components/admin/settings/settings.component.html - 232 - - - src/app/components/app-frame/global-search/global-search.component.html - 24 - - - src/app/components/document-list/filter-editor/filter-editor.component.ts - 208 + 258 Bulk editing src/app/components/admin/settings/settings.component.html - 237 + 263 Show confirmation dialogs src/app/components/admin/settings/settings.component.html - 240 + 266 Apply on close src/app/components/admin/settings/settings.component.html - 241 + 267 Notes src/app/components/admin/settings/settings.component.html - 245 + 271 src/app/components/document-list/document-list.component.html @@ -1211,14 +1233,14 @@ Enable notes src/app/components/admin/settings/settings.component.html - 248 + 274 Permissions src/app/components/admin/settings/settings.component.html - 259 + 283 src/app/components/common/edit-dialog/group-edit-dialog/group-edit-dialog.component.html @@ -1234,7 +1256,7 @@ src/app/components/document-detail/document-detail.component.html - 365 + 375 src/app/components/document-list/bulk-editor/bulk-editor.component.html @@ -1281,28 +1303,28 @@ Default Permissions src/app/components/admin/settings/settings.component.html - 262 + 286 Settings apply to this user account for objects (Tags, Mail Rules, etc. but not documents) created via the web UI. src/app/components/admin/settings/settings.component.html - 266,268 + 290,292 Default Owner src/app/components/admin/settings/settings.component.html - 273 + 297 Objects without an owner can be viewed and edited by all users src/app/components/admin/settings/settings.component.html - 277 + 301 src/app/components/common/input/permissions/permissions-form/permissions-form.component.html @@ -1313,18 +1335,18 @@ Default View Permissions src/app/components/admin/settings/settings.component.html - 282 + 306 Users: src/app/components/admin/settings/settings.component.html - 287 + 311 src/app/components/admin/settings/settings.component.html - 314 + 338 src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html @@ -1355,11 +1377,11 @@ Groups: src/app/components/admin/settings/settings.component.html - 297 + 321 src/app/components/admin/settings/settings.component.html - 324 + 348 src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html @@ -1390,14 +1412,14 @@ Default Edit Permissions src/app/components/admin/settings/settings.component.html - 309 + 333 Edit permissions also grant viewing permissions src/app/components/admin/settings/settings.component.html - 333 + 357 src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html @@ -1416,7 +1438,7 @@ Notifications src/app/components/admin/settings/settings.component.html - 341 + 365 src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.html @@ -1427,49 +1449,49 @@ Document processing src/app/components/admin/settings/settings.component.html - 344 + 368 Show notifications when new documents are detected src/app/components/admin/settings/settings.component.html - 348 + 372 Show notifications when document processing completes successfully src/app/components/admin/settings/settings.component.html - 349 + 373 Show notifications when document processing fails src/app/components/admin/settings/settings.component.html - 350 + 374 Suppress notifications on dashboard src/app/components/admin/settings/settings.component.html - 351 + 375 This will suppress all messages about document processing status on the dashboard. src/app/components/admin/settings/settings.component.html - 351 + 375 Cancel src/app/components/admin/settings/settings.component.html - 361 + 385 src/app/components/common/confirm-dialog/confirm-dialog.component.ts @@ -1550,11 +1572,150 @@ 81 + + Archive serial number + + src/app/components/admin/settings/settings.component.ts + 95 + + + src/app/components/document-detail/document-detail.component.html + 150 + + + + Correspondent + + src/app/components/admin/settings/settings.component.ts + 97 + + + src/app/components/document-detail/document-detail.component.html + 155 + + + src/app/components/document-list/bulk-editor/bulk-editor.component.html + 19 + + + src/app/components/document-list/document-list.component.html + 211 + + + src/app/components/document-list/filter-editor/filter-editor.component.html + 50 + + + src/app/data/document.ts + 46 + + + src/app/data/document.ts + 89 + + + + Document type + + src/app/components/admin/settings/settings.component.ts + 98 + + + src/app/components/document-detail/document-detail.component.html + 159 + + + src/app/components/document-list/bulk-editor/bulk-editor.component.html + 33 + + + src/app/components/document-list/document-list.component.html + 251 + + + src/app/components/document-list/filter-editor/filter-editor.component.html + 61 + + + src/app/data/document.ts + 50 + + + src/app/data/document.ts + 91 + + + + Storage path + + src/app/components/admin/settings/settings.component.ts + 99 + + + src/app/components/document-detail/document-detail.component.html + 163 + + + src/app/components/document-list/bulk-editor/bulk-editor.component.html + 47 + + + src/app/components/document-list/document-list.component.html + 260 + + + src/app/components/document-list/filter-editor/filter-editor.component.html + 72 + + + src/app/data/document.ts + 54 + + + + Tags + + src/app/components/admin/settings/settings.component.ts + 100 + + + src/app/components/app-frame/app-frame.component.html + 188 + + + src/app/components/app-frame/app-frame.component.html + 191 + + + src/app/components/common/input/tags/tags.component.ts + 80 + + + src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html + 94 + + + src/app/components/document-list/bulk-editor/bulk-editor.component.html + 5 + + + src/app/components/document-list/document-list.component.html + 224 + + + src/app/components/document-list/filter-editor/filter-editor.component.html + 39 + + + src/app/data/document.ts + 42 + + Error retrieving users src/app/components/admin/settings/settings.component.ts - 226 + 248 src/app/components/admin/users-groups/users-groups.component.ts @@ -1565,7 +1726,7 @@ Error retrieving groups src/app/components/admin/settings/settings.component.ts - 245 + 267 src/app/components/admin/users-groups/users-groups.component.ts @@ -1576,28 +1737,28 @@ Settings were saved successfully. src/app/components/admin/settings/settings.component.ts - 548 + 577 Settings were saved successfully. Reload is required to apply some changes. src/app/components/admin/settings/settings.component.ts - 552 + 581 Reload now src/app/components/admin/settings/settings.component.ts - 553 + 582 An error occurred while saving settings. src/app/components/admin/settings/settings.component.ts - 563 + 592 src/app/components/app-frame/app-frame.component.ts @@ -2598,11 +2759,11 @@ src/app/components/document-detail/document-detail.component.ts - 1112 + 1121 src/app/components/document-detail/document-detail.component.ts - 1477 + 1486 src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -2787,41 +2948,6 @@ 107 - - Tags - - src/app/components/app-frame/app-frame.component.html - 188 - - - src/app/components/app-frame/app-frame.component.html - 191 - - - src/app/components/common/input/tags/tags.component.ts - 80 - - - src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html - 94 - - - src/app/components/document-list/bulk-editor/bulk-editor.component.html - 5 - - - src/app/components/document-list/document-list.component.html - 224 - - - src/app/components/document-list/filter-editor/filter-editor.component.html - 39 - - - src/app/data/document.ts - 42 - - Document Types @@ -3228,7 +3354,7 @@ src/app/components/document-detail/document-detail.component.ts - 1065 + 1074 src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -3333,7 +3459,7 @@ src/app/components/document-detail/document-detail.component.ts - 1528 + 1537 @@ -3344,7 +3470,7 @@ src/app/components/document-detail/document-detail.component.ts - 1529 + 1538 @@ -3355,7 +3481,7 @@ src/app/components/document-detail/document-detail.component.ts - 1530 + 1539 @@ -4465,7 +4591,7 @@ src/app/components/document-detail/document-detail.component.html - 331 + 341 @@ -6991,7 +7117,7 @@ src/app/components/document-detail/document-detail.component.ts - 1476 + 1485 @@ -7045,102 +7171,18 @@ 90 - - Archive serial number - - src/app/components/document-detail/document-detail.component.html - 149 - - Date created - - src/app/components/document-detail/document-detail.component.html - 150 - - - - Correspondent src/app/components/document-detail/document-detail.component.html 152 - - src/app/components/document-list/bulk-editor/bulk-editor.component.html - 19 - - - src/app/components/document-list/document-list.component.html - 211 - - - src/app/components/document-list/filter-editor/filter-editor.component.html - 50 - - - src/app/data/document.ts - 46 - - - src/app/data/document.ts - 89 - - - - Document type - - src/app/components/document-detail/document-detail.component.html - 154 - - - src/app/components/document-list/bulk-editor/bulk-editor.component.html - 33 - - - src/app/components/document-list/document-list.component.html - 251 - - - src/app/components/document-list/filter-editor/filter-editor.component.html - 61 - - - src/app/data/document.ts - 50 - - - src/app/data/document.ts - 91 - - - - Storage path - - src/app/components/document-detail/document-detail.component.html - 156 - - - src/app/components/document-list/bulk-editor/bulk-editor.component.html - 47 - - - src/app/components/document-list/document-list.component.html - 260 - - - src/app/components/document-list/filter-editor/filter-editor.component.html - 72 - - - src/app/data/document.ts - 54 - Default src/app/components/document-detail/document-detail.component.html - 157 + 164 src/app/components/manage/saved-views/saved-views.component.html @@ -7151,14 +7193,14 @@ Content src/app/components/document-detail/document-detail.component.html - 261 + 271 Metadata src/app/components/document-detail/document-detail.component.html - 270 + 280 src/app/components/document-detail/metadata-collapse/metadata-collapse.component.ts @@ -7169,196 +7211,196 @@ Date modified src/app/components/document-detail/document-detail.component.html - 277 + 287 Date added src/app/components/document-detail/document-detail.component.html - 281 + 291 Media filename src/app/components/document-detail/document-detail.component.html - 285 + 295 Original filename src/app/components/document-detail/document-detail.component.html - 289 + 299 Original MD5 checksum src/app/components/document-detail/document-detail.component.html - 293 + 303 Original file size src/app/components/document-detail/document-detail.component.html - 297 + 307 Original mime type src/app/components/document-detail/document-detail.component.html - 301 + 311 Archive MD5 checksum src/app/components/document-detail/document-detail.component.html - 306 + 316 Archive file size src/app/components/document-detail/document-detail.component.html - 312 + 322 Original document metadata src/app/components/document-detail/document-detail.component.html - 321 + 331 Archived document metadata src/app/components/document-detail/document-detail.component.html - 324 + 334 Notes src/app/components/document-detail/document-detail.component.html - 343,346 + 353,356 History src/app/components/document-detail/document-detail.component.html - 354 + 364 Duplicates src/app/components/document-detail/document-detail.component.html - 376,380 + 386,390 Duplicate documents detected: src/app/components/document-detail/document-detail.component.html - 382 + 392 In trash src/app/components/document-detail/document-detail.component.html - 393 + 403 Save & next src/app/components/document-detail/document-detail.component.html - 422 + 432 Save & close src/app/components/document-detail/document-detail.component.html - 425 + 435 Document loading... src/app/components/document-detail/document-detail.component.html - 435 + 445 Enter Password src/app/components/document-detail/document-detail.component.html - 489 + 499 An error occurred loading content: src/app/components/document-detail/document-detail.component.ts - 432,434 + 441,443 Document changes detected src/app/components/document-detail/document-detail.component.ts - 471 + 480 The version of this document in your browser session appears older than the existing version. src/app/components/document-detail/document-detail.component.ts - 472 + 481 Saving the document here may overwrite other changes that were made. To restore the existing version, discard your changes or close the document. src/app/components/document-detail/document-detail.component.ts - 473 + 482 Ok src/app/components/document-detail/document-detail.component.ts - 475 + 484 Next document src/app/components/document-detail/document-detail.component.ts - 601 + 610 Previous document src/app/components/document-detail/document-detail.component.ts - 611 + 620 Close document src/app/components/document-detail/document-detail.component.ts - 619 + 628 src/app/services/open-documents.service.ts @@ -7369,67 +7411,67 @@ Save document src/app/components/document-detail/document-detail.component.ts - 626 + 635 Save and close / next src/app/components/document-detail/document-detail.component.ts - 635 + 644 Error retrieving metadata src/app/components/document-detail/document-detail.component.ts - 690 + 699 Error retrieving suggestions. src/app/components/document-detail/document-detail.component.ts - 745 + 754 Document "" saved successfully. src/app/components/document-detail/document-detail.component.ts - 954 + 963 src/app/components/document-detail/document-detail.component.ts - 978 + 987 Error saving document "" src/app/components/document-detail/document-detail.component.ts - 984 + 993 Error saving document src/app/components/document-detail/document-detail.component.ts - 1034 + 1043 Do you really want to move the document "" to the trash? src/app/components/document-detail/document-detail.component.ts - 1066 + 1075 Documents can be restored prior to permanent deletion. src/app/components/document-detail/document-detail.component.ts - 1067 + 1076 src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -7440,7 +7482,7 @@ Move to trash src/app/components/document-detail/document-detail.component.ts - 1069 + 1078 src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -7451,14 +7493,14 @@ Error deleting document src/app/components/document-detail/document-detail.component.ts - 1088 + 1097 Reprocess confirm src/app/components/document-detail/document-detail.component.ts - 1108 + 1117 src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -7469,102 +7511,102 @@ This operation will permanently recreate the archive file for this document. src/app/components/document-detail/document-detail.component.ts - 1109 + 1118 The archive file will be re-generated with the current settings. src/app/components/document-detail/document-detail.component.ts - 1110 + 1119 Reprocess operation for "" will begin in the background. Close and re-open or reload this document after the operation has completed to see new content. src/app/components/document-detail/document-detail.component.ts - 1120 + 1129 Error executing operation src/app/components/document-detail/document-detail.component.ts - 1131 + 1140 Error downloading document src/app/components/document-detail/document-detail.component.ts - 1180 + 1189 Page Fit src/app/components/document-detail/document-detail.component.ts - 1257 + 1266 PDF edit operation for "" will begin in the background. src/app/components/document-detail/document-detail.component.ts - 1495 + 1504 Error executing PDF edit operation src/app/components/document-detail/document-detail.component.ts - 1507 + 1516 Please enter the current password before attempting to remove it. src/app/components/document-detail/document-detail.component.ts - 1518 + 1527 Password removal operation for "" will begin in the background. src/app/components/document-detail/document-detail.component.ts - 1550 + 1559 Error executing password removal operation src/app/components/document-detail/document-detail.component.ts - 1564 + 1573 Print failed. src/app/components/document-detail/document-detail.component.ts - 1601 + 1610 Error loading document for printing. src/app/components/document-detail/document-detail.component.ts - 1613 + 1622 An error occurred loading tiff: src/app/components/document-detail/document-detail.component.ts - 1678 + 1687 src/app/components/document-detail/document-detail.component.ts - 1682 + 1691 From 50d676c59278354584a9dd7fbdac852d9adaf478 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Tue, 27 Jan 2026 09:01:13 -0800 Subject: [PATCH 26/33] Chore: Upgrade to Pytest 9 (#11898) --- pyproject.toml | 33 +++++++---- src/documents/tests/test_double_sided.py | 5 +- uv.lock | 74 ++++++++++++------------ 3 files changed, 61 insertions(+), 51 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 500461199..ac6c39b2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -114,15 +114,16 @@ testing = [ "daphne", "factory-boy~=3.3.1", "imagehash", - "pytest~=8.4.1", + "pytest~=9.0.0", "pytest-cov~=7.0.0", "pytest-django~=4.11.1", - "pytest-env", + "pytest-env~=1.2.0", "pytest-httpx", - "pytest-mock", - "pytest-rerunfailures", + "pytest-mock~=3.15.1", + #"pytest-randomly~=4.0.1", + "pytest-rerunfailures~=16.1", "pytest-sugar", - "pytest-xdist", + "pytest-xdist~=3.8.0", ] lint = [ @@ -260,11 +261,15 @@ write-changes = true ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish" skip = "src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/documents/tests/samples/*,*.po,*.json" -[tool.pytest.ini_options] -minversion = "8.0" -pythonpath = [ - "src", -] +[tool.pytest] +minversion = "9.0" +pythonpath = [ "src" ] + +strict_config = true +strict_markers = true +strict_parametrization_ids = true +strict_xfail = true + testpaths = [ "src/documents/tests/", "src/paperless/tests/", @@ -275,6 +280,7 @@ testpaths = [ "src/paperless_remote/tests/", "src/paperless_ai/tests", ] + addopts = [ "--pythonwarnings=all", "--cov", @@ -282,11 +288,14 @@ addopts = [ "--cov-report=xml", "--numprocesses=auto", "--maxprocesses=16", - "--quiet", + "--dist=loadscope", "--durations=50", + "--durations-min=0.5", "--junitxml=junit.xml", - "-o junit_family=legacy", + "-o", + "junit_family=legacy", ] + norecursedirs = [ "src/locale/", ".venv/", "src-ui/" ] DJANGO_SETTINGS_MODULE = "paperless.settings" diff --git a/src/documents/tests/test_double_sided.py b/src/documents/tests/test_double_sided.py index 5d068b735..32ca5ceab 100644 --- a/src/documents/tests/test_double_sided.py +++ b/src/documents/tests/test_double_sided.py @@ -224,17 +224,18 @@ class TestDoubleSided(DirectoriesMixin, FileSystemAssertsMixin, TestCase): THEN: - The collated file gets put into foo/bar """ + # TODO: parameterize this instead for path in [ Path("foo") / "bar" / "double-sided", Path("double-sided") / "foo" / "bar", ]: - with self.subTest(path=path): + with self.subTest(path=str(path)): # Ensure we get fresh directories for each run self.tearDown() self.setUp() self.create_staging_file() - self.consume_file("double-sided-odd.pdf", path / "foo.pdf") + self.consume_file("double-sided-odd.pdf", Path(path) / "foo.pdf") self.assertIsFile( self.dirs.consumption_dir / "foo" / "bar" / "foo-collated.pdf", ) diff --git a/uv.lock b/uv.lock index da7c721f5..960b5aaa3 100644 --- a/uv.lock +++ b/uv.lock @@ -3152,15 +3152,15 @@ dev = [ { name = "mkdocs-material", specifier = "~=9.7.0" }, { name = "pre-commit", specifier = "~=4.5.1" }, { name = "pre-commit-uv", specifier = "~=4.2.0" }, - { name = "pytest", specifier = "~=8.4.1" }, + { name = "pytest", specifier = "~=9.0.0" }, { name = "pytest-cov", specifier = "~=7.0.0" }, { name = "pytest-django", specifier = "~=4.11.1" }, - { name = "pytest-env" }, + { name = "pytest-env", specifier = "~=1.2.0" }, { name = "pytest-httpx" }, - { name = "pytest-mock" }, - { name = "pytest-rerunfailures" }, + { name = "pytest-mock", specifier = "~=3.15.1" }, + { name = "pytest-rerunfailures", specifier = "~=16.1" }, { name = "pytest-sugar" }, - { name = "pytest-xdist" }, + { name = "pytest-xdist", specifier = "~=3.8.0" }, { name = "ruff", specifier = "~=0.14.0" }, ] docs = [ @@ -3176,15 +3176,15 @@ testing = [ { name = "daphne" }, { name = "factory-boy", specifier = "~=3.3.1" }, { name = "imagehash" }, - { name = "pytest", specifier = "~=8.4.1" }, + { name = "pytest", specifier = "~=9.0.0" }, { name = "pytest-cov", specifier = "~=7.0.0" }, { name = "pytest-django", specifier = "~=4.11.1" }, - { name = "pytest-env" }, + { name = "pytest-env", specifier = "~=1.2.0" }, { name = "pytest-httpx" }, - { name = "pytest-mock" }, - { name = "pytest-rerunfailures" }, + { name = "pytest-mock", specifier = "~=3.15.1" }, + { name = "pytest-rerunfailures", specifier = "~=16.1" }, { name = "pytest-sugar" }, - { name = "pytest-xdist" }, + { name = "pytest-xdist", specifier = "~=3.8.0" }, ] typing = [ { name = "celery-types" }, @@ -3841,7 +3841,7 @@ wheels = [ [[package]] name = "pytest" -version = "8.4.2" +version = "9.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux')" }, @@ -3851,9 +3851,9 @@ dependencies = [ { name = "pygments", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "tomli", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] [[package]] @@ -3897,15 +3897,15 @@ wheels = [ [[package]] name = "pytest-httpx" -version = "0.35.0" +version = "0.36.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/89/5b12b7b29e3d0af3a4b9c071ee92fa25a9017453731a38f08ba01c280f4c/pytest_httpx-0.35.0.tar.gz", hash = "sha256:d619ad5d2e67734abfbb224c3d9025d64795d4b8711116b1a13f72a251ae511f", size = 54146, upload-time = "2024-11-28T19:16:54.237Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/5574834da9499066fa1a5ea9c336f94dba2eae02298d36dab192fcf95c86/pytest_httpx-0.36.0.tar.gz", hash = "sha256:9edb66a5fd4388ce3c343189bc67e7e1cb50b07c2e3fc83b97d511975e8a831b", size = 56793, upload-time = "2025-12-02T16:34:57.414Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/ed/026d467c1853dd83102411a78126b4842618e86c895f93528b0528c7a620/pytest_httpx-0.35.0-py3-none-any.whl", hash = "sha256:ee11a00ffcea94a5cbff47af2114d34c5b231c326902458deed73f9c459fd744", size = 19442, upload-time = "2024-11-28T19:16:52.787Z" }, + { url = "https://files.pythonhosted.org/packages/e2/d2/1eb1ea9c84f0d2033eb0b49675afdc71aa4ea801b74615f00f3c33b725e3/pytest_httpx-0.36.0-py3-none-any.whl", hash = "sha256:bd4c120bb80e142df856e825ec9f17981effb84d159f9fa29ed97e2357c3a9c8", size = 20229, upload-time = "2025-12-02T16:34:56.45Z" }, ] [[package]] @@ -5108,13 +5108,13 @@ dependencies = [ { name = "typing-extensions", marker = "sys_platform == 'darwin'" }, ] wheels = [ - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp310-none-macosx_11_0_arm64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp311-none-macosx_11_0_arm64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp312-none-macosx_11_0_arm64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp313-cp313t-macosx_11_0_arm64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp313-none-macosx_11_0_arm64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp314-cp314-macosx_11_0_arm64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp314-cp314t-macosx_11_0_arm64.whl" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp310-none-macosx_11_0_arm64.whl", hash = "sha256:bf1e68cfb935ae2046374ff02a7aa73dda70351b46342846f557055b3a540bf0" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:a52952a8c90a422c14627ea99b9826b7557203b46b4d0772d3ca5c7699692425" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:287242dd1f830846098b5eca847f817aa5c6015ea57ab4c1287809efea7b77eb" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8924d10d36eac8fe0652a060a03fc2ae52980841850b9a1a2ddb0f27a4f181cd" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:bcee64ae7aa65876ceeae6dcaebe75109485b213528c74939602208a20706e3f" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:defadbeb055cfcf5def58f70937145aecbd7a4bc295238ded1d0e85ae2cf0e1d" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:886f84b181f766f53265ba0a1d503011e60f53fff9d569563ef94f24160e1072" }, ] [[package]] @@ -5138,20 +5138,20 @@ dependencies = [ { name = "typing-extensions", marker = "sys_platform == 'linux'" }, ] wheels = [ - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp310-cp310-manylinux_2_28_aarch64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp310-cp310-manylinux_2_28_x86_64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp311-cp311-manylinux_2_28_aarch64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:10866c8a48c4aa5ae3f48538dc8a055b99c57d9c6af2bf5dd715374d9d6ddca3" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:7210713b66943fdbfcc237b2e782871b649123ac5d29f548ce8c85be4223ab38" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:0e611cfb16724e62252b67d31073bc5c490cb83e92ecdc1192762535e0e44487" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:3de2adb9b4443dc9210ef1f1b16da3647ace53553166d6360bbbd7edd6f16e4d" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3bf9b442a51a2948e41216a76d7ab00f0694cfcaaa51b6f9bcab57b7f89843e6" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:7417d8c565f219d3455654cb431c6d892a3eb40246055e14d645422de13b9ea1" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:3e532e553b37ee859205a9b2d1c7977fd6922f53bbb1b9bfdd5bdc00d1a60ed4" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:39b3dff6d8fba240ae0d1bede4ca11c2531ae3b47329206512d99e17907ff74b" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:01b1884f724977a20c7da2f640f1c7b37f4a2c117a7f4a6c1c0424d14cb86322" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:031a597147fa81b1e6d79ccf1ad3ccc7fafa27941d6cf26ff5caaa384fb20e92" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:65010ab4aacce6c9a1ddfc935f986c003ca8638ded04348fd326c3e74346237c" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:88adf5157db5da1d54b1c9fe4a6c1d20ceef00e75d854e206a87dbf69e3037dc" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:3ac2b8df2c55430e836dcda31940d47f1f5f94b8731057b6f20300ebea394dd9" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:5b688445f928f13563b7418b17c57e97bf955ab559cf73cd8f2b961f8572dbb3" }, ] [[package]] From 1f074390e4e3ef73eb182795d91c084b090d7863 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Tue, 27 Jan 2026 10:54:51 -0800 Subject: [PATCH 27/33] Feature: sharelink bundles (#11682) --- docs/configuration.md | 10 + docs/usage.md | 6 +- .../share-link-bundle-dialog.component.html | 129 +++++ .../share-link-bundle-dialog.component.scss | 0 ...share-link-bundle-dialog.component.spec.ts | 161 ++++++ .../share-link-bundle-dialog.component.ts | 118 ++++ ...e-link-bundle-manage-dialog.component.html | 156 +++++ ...e-link-bundle-manage-dialog.component.scss | 4 + ...ink-bundle-manage-dialog.component.spec.ts | 251 ++++++++ ...are-link-bundle-manage-dialog.component.ts | 177 ++++++ .../share-links-dialog.component.html | 2 +- .../share-links-dialog.component.ts | 13 +- .../bulk-editor/bulk-editor.component.html | 32 +- .../bulk-editor/bulk-editor.component.spec.ts | 141 +++++ .../bulk-editor/bulk-editor.component.ts | 56 ++ src-ui/src/app/data/share-link-bundle.ts | 53 ++ src-ui/src/app/data/share-link.ts | 12 + .../rest/share-link-bundle.service.spec.ts | 60 ++ .../rest/share-link-bundle.service.ts | 41 ++ src/documents/admin.py | 18 + src/documents/filters.py | 24 + .../migrations/0008_sharelinkbundle.py | 177 ++++++ src/documents/models.py | 108 ++++ src/documents/serialisers.py | 101 ++++ src/documents/tasks.py | 120 ++++ .../tests/test_migration_share_link_bundle.py | 51 ++ .../tests/test_share_link_bundles.py | 536 ++++++++++++++++++ src/documents/views.py | 186 +++++- src/paperless/settings.py | 12 + src/paperless/tests/test_settings.py | 23 + src/paperless/urls.py | 2 + 31 files changed, 2758 insertions(+), 22 deletions(-) create mode 100644 src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html create mode 100644 src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.scss create mode 100644 src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.spec.ts create mode 100644 src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.ts create mode 100644 src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html create mode 100644 src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.scss create mode 100644 src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.spec.ts create mode 100644 src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.ts create mode 100644 src-ui/src/app/data/share-link-bundle.ts create mode 100644 src-ui/src/app/services/rest/share-link-bundle.service.spec.ts create mode 100644 src-ui/src/app/services/rest/share-link-bundle.service.ts create mode 100644 src/documents/migrations/0008_sharelinkbundle.py create mode 100644 src/documents/tests/test_migration_share_link_bundle.py create mode 100644 src/documents/tests/test_share_link_bundles.py diff --git a/docs/configuration.md b/docs/configuration.md index 41d43d424..ef252ad4a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1617,6 +1617,16 @@ processing. This only has an effect if Defaults to `0 1 * * *`, once per day. +## Share links + +#### [`PAPERLESS_SHARE_LINK_BUNDLE_CLEANUP_CRON=`](#PAPERLESS_SHARE_LINK_BUNDLE_CLEANUP_CRON) {#PAPERLESS_SHARE_LINK_BUNDLE_CLEANUP_CRON} + +: Controls how often Paperless-ngx removes expired share link bundles (and their generated ZIP archives). + +: If set to the string "disable", expired bundles are not cleaned up automatically. + + Defaults to `0 2 * * *`, once per day at 02:00. + ## Binaries There are a few external software packages that Paperless expects to diff --git a/docs/usage.md b/docs/usage.md index 7da83a3e1..f652164da 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -308,12 +308,14 @@ or using [email](#workflow-action-email) or [webhook](#workflow-action-webhook) ### Share Links -"Share links" are shareable public links to files and can be created and managed under the 'Send' button on the document detail screen. +"Share links" are public links to files (or an archive of files) and can be created and managed under the 'Send' button on the document detail screen or from the bulk editor. -- Share links do not require a user to login and thus link directly to a file. +- Share links do not require a user to login and thus link directly to a file or bundled download. - Links are unique and are of the form `{paperless-url}/share/{randomly-generated-slug}`. - Links can optionally have an expiration time set. - After a link expires or is deleted users will be redirected to the regular paperless-ngx login. +- From the document detail screen you can create a share link for that single document. +- From the bulk editor you can create a **share link bundle** for any selection. Paperless-ngx prepares a ZIP archive in the background and exposes a single share link. You can revisit the "Manage share link bundles" dialog to monitor progress, retry failed bundles, or delete links. !!! tip diff --git a/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html b/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html new file mode 100644 index 000000000..b7fed28e1 --- /dev/null +++ b/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html @@ -0,0 +1,129 @@ + + + diff --git a/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.scss b/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.spec.ts b/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.spec.ts new file mode 100644 index 000000000..da4d93c6a --- /dev/null +++ b/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.spec.ts @@ -0,0 +1,161 @@ +import { Clipboard } from '@angular/cdk/clipboard' +import { + ComponentFixture, + TestBed, + fakeAsync, + tick, +} from '@angular/core/testing' +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' +import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' +import { FileVersion } from 'src/app/data/share-link' +import { + ShareLinkBundleStatus, + ShareLinkBundleSummary, +} from 'src/app/data/share-link-bundle' +import { ToastService } from 'src/app/services/toast.service' +import { environment } from 'src/environments/environment' +import { ShareLinkBundleDialogComponent } from './share-link-bundle-dialog.component' + +class MockToastService { + showInfo = jest.fn() + showError = jest.fn() +} + +describe('ShareLinkBundleDialogComponent', () => { + let component: ShareLinkBundleDialogComponent + let fixture: ComponentFixture + let clipboard: Clipboard + let toastService: MockToastService + let activeModal: NgbActiveModal + let originalApiBaseUrl: string + + beforeEach(() => { + originalApiBaseUrl = environment.apiBaseUrl + toastService = new MockToastService() + + TestBed.configureTestingModule({ + imports: [ + ShareLinkBundleDialogComponent, + NgxBootstrapIconsModule.pick(allIcons), + ], + providers: [ + NgbActiveModal, + { provide: ToastService, useValue: toastService }, + ], + }) + + fixture = TestBed.createComponent(ShareLinkBundleDialogComponent) + component = fixture.componentInstance + clipboard = TestBed.inject(Clipboard) + activeModal = TestBed.inject(NgbActiveModal) + fixture.detectChanges() + }) + + afterEach(() => { + jest.clearAllTimers() + environment.apiBaseUrl = originalApiBaseUrl + }) + + it('builds payload and emits confirm on submit', () => { + const confirmSpy = jest.spyOn(component.confirmClicked, 'emit') + component.documents = [ + { id: 1, title: 'Doc 1' } as any, + { id: 2, title: 'Doc 2' } as any, + ] + component.form.setValue({ + shareArchiveVersion: false, + expirationDays: 3, + }) + + component.submit() + + expect(component.payload).toEqual({ + document_ids: [1, 2], + file_version: FileVersion.Original, + expiration_days: 3, + }) + expect(component.buttonsEnabled).toBe(false) + expect(confirmSpy).toHaveBeenCalled() + + component.form.setValue({ + shareArchiveVersion: true, + expirationDays: 7, + }) + component.submit() + + expect(component.payload).toEqual({ + document_ids: [1, 2], + file_version: FileVersion.Archive, + expiration_days: 7, + }) + }) + + it('ignores submit when bundle already created', () => { + component.createdBundle = { id: 1 } as ShareLinkBundleSummary + const confirmSpy = jest.spyOn(component, 'confirm') + component.submit() + expect(confirmSpy).not.toHaveBeenCalled() + }) + + it('limits preview to ten documents', () => { + const docs = Array.from({ length: 12 }).map((_, index) => ({ + id: index + 1, + })) + component.documents = docs as any + + expect(component.selectionCount).toBe(12) + expect(component.documentPreview).toHaveLength(10) + expect(component.documentPreview[0].id).toBe(1) + }) + + it('copies share link and resets state after timeout', fakeAsync(() => { + const copySpy = jest.spyOn(clipboard, 'copy').mockReturnValue(true) + const bundle = { + slug: 'bundle-slug', + status: ShareLinkBundleStatus.Ready, + } as ShareLinkBundleSummary + + component.copy(bundle) + + expect(copySpy).toHaveBeenCalledWith(component.getShareUrl(bundle)) + expect(component.copied).toBe(true) + expect(toastService.showInfo).toHaveBeenCalled() + + tick(3000) + expect(component.copied).toBe(false) + })) + + it('generates share URLs based on API base URL', () => { + environment.apiBaseUrl = 'https://example.com/api/' + expect( + component.getShareUrl({ slug: 'abc' } as ShareLinkBundleSummary) + ).toBe('https://example.com/share/abc') + }) + + it('opens manage dialog when callback provided', () => { + const manageSpy = jest.fn() + component.onOpenManage = manageSpy + component.openManage() + expect(manageSpy).toHaveBeenCalled() + }) + + it('falls back to cancel when manage callback missing', () => { + const cancelSpy = jest.spyOn(component, 'cancel') + component.onOpenManage = undefined + component.openManage() + expect(cancelSpy).toHaveBeenCalled() + }) + + it('maps status and file version labels', () => { + expect(component.statusLabel(ShareLinkBundleStatus.Processing)).toContain( + 'Processing' + ) + expect(component.fileVersionLabel(FileVersion.Archive)).toContain('Archive') + }) + + it('closes dialog when cancel invoked', () => { + const closeSpy = jest.spyOn(activeModal, 'close') + component.cancel() + expect(closeSpy).toHaveBeenCalled() + }) +}) diff --git a/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.ts b/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.ts new file mode 100644 index 000000000..37aa70950 --- /dev/null +++ b/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.ts @@ -0,0 +1,118 @@ +import { Clipboard } from '@angular/cdk/clipboard' +import { CommonModule } from '@angular/common' +import { Component, Input, inject } from '@angular/core' +import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms' +import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' +import { Document } from 'src/app/data/document' +import { + FileVersion, + SHARE_LINK_EXPIRATION_OPTIONS, +} from 'src/app/data/share-link' +import { + SHARE_LINK_BUNDLE_FILE_VERSION_LABELS, + SHARE_LINK_BUNDLE_STATUS_LABELS, + ShareLinkBundleCreatePayload, + ShareLinkBundleStatus, + ShareLinkBundleSummary, +} from 'src/app/data/share-link-bundle' +import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe' +import { FileSizePipe } from 'src/app/pipes/file-size.pipe' +import { ToastService } from 'src/app/services/toast.service' +import { environment } from 'src/environments/environment' +import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component' + +@Component({ + selector: 'pngx-share-link-bundle-dialog', + templateUrl: './share-link-bundle-dialog.component.html', + imports: [ + CommonModule, + ReactiveFormsModule, + NgxBootstrapIconsModule, + FileSizePipe, + DocumentTitlePipe, + ], + providers: [], +}) +export class ShareLinkBundleDialogComponent extends ConfirmDialogComponent { + private readonly formBuilder = inject(FormBuilder) + private readonly clipboard = inject(Clipboard) + private readonly toastService = inject(ToastService) + + private _documents: Document[] = [] + + selectionCount = 0 + documentPreview: Document[] = [] + form: FormGroup = this.formBuilder.group({ + shareArchiveVersion: true, + expirationDays: [7], + }) + payload: ShareLinkBundleCreatePayload | null = null + + readonly expirationOptions = SHARE_LINK_EXPIRATION_OPTIONS + + createdBundle: ShareLinkBundleSummary | null = null + copied = false + onOpenManage?: () => void + readonly statuses = ShareLinkBundleStatus + + constructor() { + super() + this.loading = false + this.title = $localize`Create share link bundle` + this.btnCaption = $localize`Create link` + } + + @Input() + set documents(docs: Document[]) { + this._documents = docs.concat() + this.selectionCount = this._documents.length + this.documentPreview = this._documents.slice(0, 10) + } + + submit() { + if (this.createdBundle) return + this.payload = { + document_ids: this._documents.map((doc) => doc.id), + file_version: this.form.value.shareArchiveVersion + ? FileVersion.Archive + : FileVersion.Original, + expiration_days: this.form.value.expirationDays, + } + this.buttonsEnabled = false + super.confirm() + } + + getShareUrl(bundle: ShareLinkBundleSummary): string { + const apiURL = new URL(environment.apiBaseUrl) + return `${apiURL.origin}${apiURL.pathname.replace(/\/api\/$/, '/share/')}${ + bundle.slug + }` + } + + copy(bundle: ShareLinkBundleSummary): void { + const success = this.clipboard.copy(this.getShareUrl(bundle)) + if (success) { + this.copied = true + this.toastService.showInfo($localize`Share link copied to clipboard.`) + setTimeout(() => { + this.copied = false + }, 3000) + } + } + + openManage(): void { + if (this.onOpenManage) { + this.onOpenManage() + } else { + this.cancel() + } + } + + statusLabel(status: ShareLinkBundleSummary['status']): string { + return SHARE_LINK_BUNDLE_STATUS_LABELS[status] ?? status + } + + fileVersionLabel(version: FileVersion): string { + return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version + } +} diff --git a/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html b/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html new file mode 100644 index 000000000..2f2155412 --- /dev/null +++ b/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html @@ -0,0 +1,156 @@ + + + + + diff --git a/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.scss b/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.scss new file mode 100644 index 000000000..c8ffc4d5d --- /dev/null +++ b/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.scss @@ -0,0 +1,4 @@ +:host ::ng-deep .popover { + min-width: 300px; + max-width: 400px; + } diff --git a/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.spec.ts b/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.spec.ts new file mode 100644 index 000000000..113cd65a3 --- /dev/null +++ b/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.spec.ts @@ -0,0 +1,251 @@ +import { Clipboard } from '@angular/cdk/clipboard' +import { + ComponentFixture, + TestBed, + fakeAsync, + tick, +} from '@angular/core/testing' +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' +import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' +import { of, throwError } from 'rxjs' +import { FileVersion } from 'src/app/data/share-link' +import { + ShareLinkBundleStatus, + ShareLinkBundleSummary, +} from 'src/app/data/share-link-bundle' +import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service' +import { ToastService } from 'src/app/services/toast.service' +import { environment } from 'src/environments/environment' +import { ShareLinkBundleManageDialogComponent } from './share-link-bundle-manage-dialog.component' + +class MockShareLinkBundleService { + listAllBundles = jest.fn() + delete = jest.fn() + rebuildBundle = jest.fn() +} + +class MockToastService { + showInfo = jest.fn() + showError = jest.fn() +} + +describe('ShareLinkBundleManageDialogComponent', () => { + let component: ShareLinkBundleManageDialogComponent + let fixture: ComponentFixture + let service: MockShareLinkBundleService + let toastService: MockToastService + let clipboard: Clipboard + let activeModal: NgbActiveModal + let originalApiBaseUrl: string + + beforeEach(() => { + service = new MockShareLinkBundleService() + toastService = new MockToastService() + originalApiBaseUrl = environment.apiBaseUrl + + service.listAllBundles.mockReturnValue(of([])) + service.delete.mockReturnValue(of(true)) + service.rebuildBundle.mockReturnValue(of(sampleBundle())) + + TestBed.configureTestingModule({ + imports: [ + ShareLinkBundleManageDialogComponent, + NgxBootstrapIconsModule.pick(allIcons), + ], + providers: [ + NgbActiveModal, + { provide: ShareLinkBundleService, useValue: service }, + { provide: ToastService, useValue: toastService }, + ], + }) + + fixture = TestBed.createComponent(ShareLinkBundleManageDialogComponent) + component = fixture.componentInstance + clipboard = TestBed.inject(Clipboard) + activeModal = TestBed.inject(NgbActiveModal) + }) + + afterEach(() => { + component.ngOnDestroy() + fixture.destroy() + environment.apiBaseUrl = originalApiBaseUrl + jest.clearAllMocks() + }) + + const sampleBundle = (overrides: Partial = {}) => + ({ + id: 1, + slug: 'bundle-slug', + created: new Date().toISOString(), + document_count: 1, + documents: [1], + status: ShareLinkBundleStatus.Pending, + file_version: FileVersion.Archive, + last_error: undefined, + ...overrides, + }) as ShareLinkBundleSummary + + it('loads bundles on init and polls periodically', fakeAsync(() => { + const bundles = [sampleBundle({ status: ShareLinkBundleStatus.Ready })] + service.listAllBundles.mockReset() + service.listAllBundles + .mockReturnValueOnce(of(bundles)) + .mockReturnValue(of(bundles)) + + fixture.detectChanges() + tick() + + expect(service.listAllBundles).toHaveBeenCalledTimes(1) + expect(component.bundles).toEqual(bundles) + expect(component.loading).toBe(false) + expect(component.error).toBeNull() + + tick(5000) + expect(service.listAllBundles).toHaveBeenCalledTimes(2) + })) + + it('handles errors when loading bundles', fakeAsync(() => { + service.listAllBundles.mockReset() + service.listAllBundles + .mockReturnValueOnce(throwError(() => new Error('load fail'))) + .mockReturnValue(of([])) + + fixture.detectChanges() + tick() + + expect(component.error).toContain('Failed to load share link bundles.') + expect(toastService.showError).toHaveBeenCalled() + expect(component.loading).toBe(false) + + tick(5000) + expect(service.listAllBundles).toHaveBeenCalledTimes(2) + })) + + it('copies bundle links when ready', fakeAsync(() => { + jest.spyOn(clipboard, 'copy').mockReturnValue(true) + fixture.detectChanges() + tick() + + const readyBundle = sampleBundle({ + slug: 'ready-slug', + status: ShareLinkBundleStatus.Ready, + }) + component.copy(readyBundle) + + expect(clipboard.copy).toHaveBeenCalledWith( + component.getShareUrl(readyBundle) + ) + expect(component.copiedSlug).toBe('ready-slug') + expect(toastService.showInfo).toHaveBeenCalled() + + tick(3000) + expect(component.copiedSlug).toBeNull() + })) + + it('ignores copy requests for non-ready bundles', fakeAsync(() => { + const copySpy = jest.spyOn(clipboard, 'copy') + fixture.detectChanges() + tick() + component.copy(sampleBundle({ status: ShareLinkBundleStatus.Pending })) + expect(copySpy).not.toHaveBeenCalled() + })) + + it('deletes bundles and refreshes list', fakeAsync(() => { + service.listAllBundles.mockReturnValue(of([])) + service.delete.mockReturnValue(of(true)) + + fixture.detectChanges() + tick() + + component.delete(sampleBundle()) + tick() + + expect(service.delete).toHaveBeenCalled() + expect(toastService.showInfo).toHaveBeenCalledWith( + expect.stringContaining('deleted.') + ) + expect(service.listAllBundles).toHaveBeenCalledTimes(2) + expect(component.loading).toBe(false) + })) + + it('handles delete errors gracefully', fakeAsync(() => { + service.listAllBundles.mockReturnValue(of([])) + service.delete.mockReturnValue(throwError(() => new Error('delete fail'))) + + fixture.detectChanges() + tick() + + component.delete(sampleBundle()) + tick() + + expect(toastService.showError).toHaveBeenCalled() + expect(component.loading).toBe(false) + })) + + it('retries bundle build and replaces existing entry', fakeAsync(() => { + service.listAllBundles.mockReturnValue(of([])) + const updated = sampleBundle({ status: ShareLinkBundleStatus.Ready }) + service.rebuildBundle.mockReturnValue(of(updated)) + + fixture.detectChanges() + tick() + + component.bundles = [sampleBundle()] + component.retry(component.bundles[0]) + tick() + + expect(service.rebuildBundle).toHaveBeenCalledWith(updated.id) + expect(component.bundles[0].status).toBe(ShareLinkBundleStatus.Ready) + expect(toastService.showInfo).toHaveBeenCalled() + })) + + it('adds new bundle when retry returns unknown entry', fakeAsync(() => { + service.listAllBundles.mockReturnValue(of([])) + service.rebuildBundle.mockReturnValue( + of(sampleBundle({ id: 99, slug: 'new-slug' })) + ) + + fixture.detectChanges() + tick() + + component.bundles = [sampleBundle()] + component.retry({ id: 99 } as ShareLinkBundleSummary) + tick() + + expect(component.bundles.find((bundle) => bundle.id === 99)).toBeTruthy() + })) + + it('handles retry errors', fakeAsync(() => { + service.listAllBundles.mockReturnValue(of([])) + service.rebuildBundle.mockReturnValue(throwError(() => new Error('fail'))) + + fixture.detectChanges() + tick() + + component.retry(sampleBundle()) + tick() + + expect(toastService.showError).toHaveBeenCalled() + })) + + it('maps helpers and closes dialog', fakeAsync(() => { + service.listAllBundles.mockReturnValue(of([])) + fixture.detectChanges() + tick() + + expect(component.statusLabel(ShareLinkBundleStatus.Processing)).toContain( + 'Processing' + ) + expect(component.fileVersionLabel(FileVersion.Original)).toContain( + 'Original' + ) + + environment.apiBaseUrl = 'https://example.com/api/' + const url = component.getShareUrl(sampleBundle({ slug: 'sluggy' })) + expect(url).toBe('https://example.com/share/sluggy') + + const closeSpy = jest.spyOn(activeModal, 'close') + component.close() + expect(closeSpy).toHaveBeenCalled() + })) +}) diff --git a/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.ts b/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.ts new file mode 100644 index 000000000..6eef144f9 --- /dev/null +++ b/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.ts @@ -0,0 +1,177 @@ +import { Clipboard } from '@angular/cdk/clipboard' +import { CommonModule } from '@angular/common' +import { Component, OnDestroy, OnInit, inject } from '@angular/core' +import { NgbActiveModal, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap' +import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' +import { Subject, catchError, of, switchMap, takeUntil, timer } from 'rxjs' +import { FileVersion } from 'src/app/data/share-link' +import { + SHARE_LINK_BUNDLE_FILE_VERSION_LABELS, + SHARE_LINK_BUNDLE_STATUS_LABELS, + ShareLinkBundleStatus, + ShareLinkBundleSummary, +} from 'src/app/data/share-link-bundle' +import { FileSizePipe } from 'src/app/pipes/file-size.pipe' +import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service' +import { ToastService } from 'src/app/services/toast.service' +import { environment } from 'src/environments/environment' +import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' +import { ConfirmButtonComponent } from '../confirm-button/confirm-button.component' + +@Component({ + selector: 'pngx-share-link-bundle-manage-dialog', + templateUrl: './share-link-bundle-manage-dialog.component.html', + styleUrls: ['./share-link-bundle-manage-dialog.component.scss'], + imports: [ + ConfirmButtonComponent, + CommonModule, + NgbPopoverModule, + NgxBootstrapIconsModule, + FileSizePipe, + ], +}) +export class ShareLinkBundleManageDialogComponent + extends LoadingComponentWithPermissions + implements OnInit, OnDestroy +{ + private readonly activeModal = inject(NgbActiveModal) + private readonly shareLinkBundleService = inject(ShareLinkBundleService) + private readonly toastService = inject(ToastService) + private readonly clipboard = inject(Clipboard) + + title = $localize`Share link bundles` + + bundles: ShareLinkBundleSummary[] = [] + error: string | null = null + copiedSlug: string | null = null + + readonly statuses = ShareLinkBundleStatus + readonly fileVersions = FileVersion + + private readonly refresh$ = new Subject() + + ngOnInit(): void { + this.refresh$ + .pipe( + switchMap((silent) => { + if (!silent) { + this.loading = true + } + this.error = null + return this.shareLinkBundleService.listAllBundles().pipe( + catchError((error) => { + if (!silent) { + this.loading = false + } + this.error = $localize`Failed to load share link bundles.` + this.toastService.showError( + $localize`Error retrieving share link bundles.`, + error + ) + return of(null) + }) + ) + }), + takeUntil(this.unsubscribeNotifier) + ) + .subscribe((results) => { + if (results) { + this.bundles = results + this.copiedSlug = null + } + this.loading = false + }) + + this.triggerRefresh(false) + timer(5000, 5000) + .pipe(takeUntil(this.unsubscribeNotifier)) + .subscribe(() => this.triggerRefresh(true)) + } + + ngOnDestroy(): void { + super.ngOnDestroy() + } + + getShareUrl(bundle: ShareLinkBundleSummary): string { + const apiURL = new URL(environment.apiBaseUrl) + return `${apiURL.origin}${apiURL.pathname.replace(/\/api\/$/, '/share/')}${ + bundle.slug + }` + } + + copy(bundle: ShareLinkBundleSummary): void { + if (bundle.status !== ShareLinkBundleStatus.Ready) { + return + } + const success = this.clipboard.copy(this.getShareUrl(bundle)) + if (success) { + this.copiedSlug = bundle.slug + setTimeout(() => { + this.copiedSlug = null + }, 3000) + this.toastService.showInfo($localize`Share link copied to clipboard.`) + } + } + + delete(bundle: ShareLinkBundleSummary): void { + this.error = null + this.loading = true + this.shareLinkBundleService.delete(bundle).subscribe({ + next: () => { + this.toastService.showInfo($localize`Share link bundle deleted.`) + this.triggerRefresh(false) + }, + error: (e) => { + this.loading = false + this.toastService.showError( + $localize`Error deleting share link bundle.`, + e + ) + }, + }) + } + + retry(bundle: ShareLinkBundleSummary): void { + this.error = null + this.shareLinkBundleService.rebuildBundle(bundle.id).subscribe({ + next: (updated) => { + this.toastService.showInfo( + $localize`Share link bundle rebuild requested.` + ) + this.replaceBundle(updated) + }, + error: (e) => { + this.toastService.showError($localize`Error requesting rebuild.`, e) + }, + }) + } + + statusLabel(status: ShareLinkBundleStatus): string { + return SHARE_LINK_BUNDLE_STATUS_LABELS[status] ?? status + } + + fileVersionLabel(version: FileVersion): string { + return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version + } + + close(): void { + this.activeModal.close() + } + + private replaceBundle(updated: ShareLinkBundleSummary): void { + const index = this.bundles.findIndex((bundle) => bundle.id === updated.id) + if (index >= 0) { + this.bundles = [ + ...this.bundles.slice(0, index), + updated, + ...this.bundles.slice(index + 1), + ] + } else { + this.bundles = [updated, ...this.bundles] + } + } + + private triggerRefresh(silent: boolean): void { + this.refresh$.next(silent) + } +} diff --git a/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.html b/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.html index fe3f9b9c3..e41a897a8 100644 --- a/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.html +++ b/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.html @@ -51,7 +51,7 @@
    diff --git a/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.ts b/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.ts index ffe11808c..9df3d438b 100644 --- a/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.ts +++ b/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.ts @@ -4,7 +4,11 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { first } from 'rxjs' -import { FileVersion, ShareLink } from 'src/app/data/share-link' +import { + FileVersion, + SHARE_LINK_EXPIRATION_OPTIONS, + ShareLink, +} from 'src/app/data/share-link' import { ShareLinkService } from 'src/app/services/rest/share-link.service' import { ToastService } from 'src/app/services/toast.service' import { environment } from 'src/environments/environment' @@ -21,12 +25,7 @@ export class ShareLinksDialogComponent implements OnInit { private toastService = inject(ToastService) private clipboard = inject(Clipboard) - EXPIRATION_OPTIONS = [ - { label: $localize`1 day`, value: 1 }, - { label: $localize`7 days`, value: 7 }, - { label: $localize`30 days`, value: 30 }, - { label: $localize`Never`, value: null }, - ] + readonly expirationOptions = SHARE_LINK_EXPIRATION_OPTIONS @Input() title = $localize`Share Links` diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html index 2323929d1..6f3a84eee 100644 --- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html @@ -96,14 +96,36 @@ - @if (emailEnabled) { - - }
    +
    + +
    + + + + @if (emailEnabled) { + + } +
    +