From 6f29d6432559e282fe1b067c246855e31ec81135 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 3 Feb 2025 11:59:45 -0800 Subject: [PATCH 01/12] Fix: allow empty email in profile (#9012) --- src/paperless/serialisers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/paperless/serialisers.py b/src/paperless/serialisers.py index fb1f511f7..cd9325c09 100644 --- a/src/paperless/serialisers.py +++ b/src/paperless/serialisers.py @@ -162,7 +162,7 @@ class SocialAccountSerializer(serializers.ModelSerializer): class ProfileSerializer(serializers.ModelSerializer): - email = serializers.EmailField(allow_null=False) + email = serializers.EmailField(allow_blank=True, required=False) password = ObfuscatedUserPasswordField(required=False, allow_null=False) auth_token = serializers.SlugRelatedField(read_only=True, slug_field="key") social_accounts = SocialAccountSerializer( From 49390c9427ff8cee9926522619544394aca859e9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Feb 2025 18:00:47 -0800 Subject: [PATCH 02/12] Chore(deps): Bump django-soft-delete in the django group (#9014) Bumps the django group with 1 update: [django-soft-delete](https://github.com/san4ezy/django_softdelete). Updates `django-soft-delete` from 1.0.16 to 1.0.18 - [Changelog](https://github.com/san4ezy/django_softdelete/blob/master/CHANGELOG.md) - [Commits](https://github.com/san4ezy/django_softdelete/commits) --- updated-dependencies: - dependency-name: django-soft-delete dependency-type: direct:production update-type: version-update:semver-patch dependency-group: django ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Pipfile.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index 1889d3d4a..d7294f472 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "3806c1dbfde8e9383e748c106c217170d6dcdbb8b95d573030b2294dab32d462" + "sha256": "6a7869231917d0cf6f5852520b5cb9b0df3802ed162b1a8107d0b1e1c37f0535" }, "pipfile-spec": 6, "requires": {}, @@ -589,12 +589,12 @@ }, "django-soft-delete": { "hashes": [ - "sha256:cc40398ccd869c75a6d6ba7f526e16c4afe2b0c0811c213a318d96bb4c58a787", - "sha256:fdaf2788d404930557f1300ce40bbd764f6938775a35a3175c66fe7778666093" + "sha256:603a29e82bbb7a5bada69f2754fad225ccd8cd7f485320ec06d0fc4e9dfddcf0", + "sha256:d2f9db449a4f008e9786f82fa4bafbe4075f7a0b3284844735007e988b2a4df6" ], "index": "pypi", "markers": "python_version >= '3.6'", - "version": "==1.0.16" + "version": "==1.0.18" }, "djangorestframework": { "hashes": [ @@ -2264,7 +2264,7 @@ "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8" ], - "markers": "python_version >= '3.8'", + "markers": "python_version < '3.11'", "version": "==4.12.2" }, "tzdata": { From 7ab779e78af05decdaa1ba4938fed9f632965b15 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Feb 2025 03:21:55 +0000 Subject: [PATCH 03/12] Chore(deps-dev): Bump the development group with 2 updates (#9013) * Chore(deps-dev): Bump the development group with 2 updates Bumps the development group with 2 updates: [ruff](https://github.com/astral-sh/ruff) and [mkdocs-material](https://github.com/squidfunk/mkdocs-material). Updates `ruff` from 0.9.3 to 0.9.4 - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.9.3...0.9.4) Updates `mkdocs-material` from 9.5.50 to 9.6.2 - [Release notes](https://github.com/squidfunk/mkdocs-material/releases) - [Changelog](https://github.com/squidfunk/mkdocs-material/blob/master/CHANGELOG) - [Commits](https://github.com/squidfunk/mkdocs-material/compare/9.5.50...9.6.2) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:development update-type: version-update:semver-patch dependency-group: development - dependency-name: mkdocs-material dependency-type: direct:development update-type: version-update:semver-minor dependency-group: development ... Signed-off-by: dependabot[bot] * Update .pre-commit-config.yaml --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- Pipfile.lock | 69 ++++++++++++++++++++--------------------- 2 files changed, 34 insertions(+), 37 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2ddffaf9f..10f9d0084 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -51,7 +51,7 @@ repos: - 'prettier-plugin-organize-imports@4.1.0' # Python hooks - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.3 + rev: v0.9.4 hooks: - id: ruff - id: ruff-format diff --git a/Pipfile.lock b/Pipfile.lock index d7294f472..1f74e6708 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -2837,19 +2837,19 @@ }, "babel": { "hashes": [ - "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b", - "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316" + "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", + "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2" ], "markers": "python_version >= '3.8'", - "version": "==2.16.0" + "version": "==2.17.0" }, "certifi": { "hashes": [ - "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", - "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db" + "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", + "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe" ], "markers": "python_version >= '3.6'", - "version": "==2024.12.14" + "version": "==2025.1.31" }, "cffi": { "hashes": [ @@ -3302,7 +3302,6 @@ "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb", "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb" ], - "index": "pypi", "markers": "python_version >= '3.7'", "version": "==3.1.5" }, @@ -3415,12 +3414,12 @@ }, "mkdocs-material": { "hashes": [ - "sha256:ae5fe16f3d7c9ccd05bb6916a7da7420cf99a9ce5e33debd9d40403a090d5825", - "sha256:f24100f234741f4d423a9d672a909d859668a4f404796be3cf035f10d6050385" + "sha256:71d90dbd63b393ad11a4d90151dfe3dcbfcd802c0f29ce80bebd9bbac6abc753", + "sha256:a3de1c5d4c745f10afa78b1a02f917b9dce0808fb206adc0f5bb48b58c1ca21f" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==9.5.50" + "version": "==9.6.2" }, "mkdocs-material-extensions": { "hashes": [ @@ -3658,11 +3657,11 @@ }, "pymdown-extensions": { "hashes": [ - "sha256:637951cbfbe9874ba28134fb3ce4b8bcadd6aca89ac4998ec29dcbafd554ae08", - "sha256:b65801996a0cd4f42a3110810c306c45b7313c09b0610a6f773730f2a9e3c96b" + "sha256:05e0bee73d64b9c71a4ae17c72abc2f700e8bc8403755a00580b49a4e9f189e9", + "sha256:41e576ce3f5d650be59e900e4ceff231e0aed2a88cf30acaee41e02f063a061b" ], "markers": "python_version >= '3.8'", - "version": "==10.14.1" + "version": "==10.14.3" }, "pyopenssl": { "hashes": [ @@ -3757,8 +3756,7 @@ "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" ], - "index": "pypi", - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.9.0.post0" }, "pywavelets": { @@ -3982,28 +3980,28 @@ }, "ruff": { "hashes": [ - "sha256:006e5de2621304c8810bcd2ee101587712fa93b4f955ed0985907a36c427e0c2", - "sha256:040ceb7f20791dfa0e78b4230ee9dce23da3b64dd5848e40e3bf3ab76468dcf4", - "sha256:2dc153c25e715be41bb228bc651c1e9b1a88d5c6e5ed0194fa0dfea02b026439", - "sha256:33866c3cc2a575cbd546f2cd02bdd466fed65118e4365ee538a3deffd6fcb730", - "sha256:5a5a46e09355695fbdbb30ed9889d6cf1c61b77b700a9fafc21b41f097bfbba4", - "sha256:646909a1e25e0dc28fbc529eab8eb7bb583079628e8cbe738192853dbbe43af5", - "sha256:7f39b879064c7d9670197d91124a75d118d00b0990586549949aae80cdc16624", - "sha256:800d773f6d4d33b0a3c60e2c6ae8f4c202ea2de056365acfa519aa48acf28e0b", - "sha256:8293f89985a090ebc3ed1064df31f3b4b56320cdfcec8b60d3295bddb955c22a", - "sha256:90230a6b8055ad47d3325e9ee8f8a9ae7e273078a66401ac66df68943ced029b", - "sha256:96a87ec31dc1044d8c2da2ebbed1c456d9b561e7d087734336518181b26b3aa5", - "sha256:9bb7554aca6f842645022fe2d301c264e6925baa708b392867b7a62645304df4", - "sha256:a187171e7c09efa4b4cc30ee5d0d55a8d6c5311b3e1b74ac5cb96cc89bafc43c", - "sha256:ba6eea4459dbd6b1be4e6bfc766079fb9b8dd2e5a35aff6baee4d9b1514ea519", - "sha256:c4bb09d2bbb394e3730d0918c00276e79b2de70ec2a5231cd4ebb51a57df9ba1", - "sha256:c59ab92f8e92d6725b7ded9d4a31be3ef42688a115c6d3da9457a5bda140e2b4", - "sha256:cabc332b7075a914ecea912cd1f3d4370489c8018f2c945a30bcc934e3bc06a6", - "sha256:eabe5eb2c19a42f4808c03b82bd313fc84d4e395133fb3fc1b1516170a31213c" + "sha256:05bebf4cdbe3ef75430d26c375773978950bbf4ee3c95ccb5448940dc092408e", + "sha256:1d4c8772670aecf037d1bf7a07c39106574d143b26cfe5ed1787d2f31e800214", + "sha256:37c892540108314a6f01f105040b5106aeb829fa5fb0561d2dcaf71485021137", + "sha256:433dedf6ddfdec7f1ac7575ec1eb9844fa60c4c8c2f8887a070672b8d353d34c", + "sha256:54499fb08408e32b57360f6f9de7157a5fec24ad79cb3f42ef2c3f3f728dfe2b", + "sha256:56acd6c694da3695a7461cc55775f3a409c3815ac467279dfa126061d84b314b", + "sha256:585792f1e81509e38ac5123492f8875fbc36f3ede8185af0a26df348e5154f41", + "sha256:64e73d25b954f71ff100bb70f39f1ee09e880728efb4250c632ceed4e4cdf706", + "sha256:6907ee3529244bb0ed066683e075f09285b38dd5b4039370df6ff06041ca19e7", + "sha256:6ce6743ed64d9afab4fafeaea70d3631b4d4b28b592db21a5c2d1f0ef52934bf", + "sha256:87c90c32357c74f11deb7fbb065126d91771b207bf9bfaaee01277ca59b574ec", + "sha256:a6c634fc6f5a0ceae1ab3e13c58183978185d131a29c425e4eaa9f40afe1e6d6", + "sha256:bfc5f1d7afeda8d5d37660eeca6d389b142d7f2b5a1ab659d9214ebd0e025231", + "sha256:d612dbd0f3a919a8cc1d12037168bfa536862066808960e0cc901404b77968f0", + "sha256:db1192ddda2200671f9ef61d9597fcef89d934f5d1705e571a93a67fb13a4402", + "sha256:de9edf2ce4b9ddf43fd93e20ef635a900e25f622f87ed6e3047a664d0e8f810e", + "sha256:e0c93e7d47ed951b9394cf352d6695b31498e68fd5782d6cbc282425655f687a", + "sha256:faa935fc00ae854d8b638c16a5f1ce881bc3f67446957dd6f2af440a5fc8526b" ], "index": "pypi", "markers": "python_version >= '3.7'", - "version": "==0.9.3" + "version": "==0.9.4" }, "scipy": { "hashes": [ @@ -4072,7 +4070,7 @@ "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.17.0" }, "sniffio": { @@ -4205,7 +4203,6 @@ "sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c", "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2" ], - "index": "pypi", "markers": "python_version >= '3.9'", "version": "==6.0.0" }, From 2103a499eba83addeb0a53d0ea4f6ce774ed7e35 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 3 Feb 2025 21:54:18 -0800 Subject: [PATCH 04/12] Enhancement: allow setting default pdf zoom (#9017) --- src-ui/messages.xlf | 404 ++++++++++-------- .../admin/settings/settings.component.html | 95 ++-- .../admin/settings/settings.component.spec.ts | 2 +- .../admin/settings/settings.component.ts | 11 + .../document-detail.component.html | 8 +- .../document-detail.component.scss | 3 + .../document-detail.component.spec.ts | 26 +- .../document-detail.component.ts | 20 +- src-ui/src/app/data/ui-settings.ts | 7 + 9 files changed, 332 insertions(+), 244 deletions(-) diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index d3c93684b..65e25d8ba 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -545,7 +545,7 @@ src/app/components/admin/settings/settings.component.html - 349 + 364 src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html @@ -944,60 +944,25 @@ 152 - - Document editing - - src/app/components/admin/settings/settings.component.html - 157 - - - - Use PDF viewer provided by the browser - - src/app/components/admin/settings/settings.component.html - 161 - - - - 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 - 161 - - - - Automatically remove inbox tag(s) on save - - src/app/components/admin/settings/settings.component.html - 167 - - - - Show document thumbnail during loading - - src/app/components/admin/settings/settings.component.html - 173 - - Update checking src/app/components/admin/settings/settings.component.html - 178 + 157 Enable update checking src/app/components/admin/settings/settings.component.html - 181 + 160 What's this? src/app/components/admin/settings/settings.component.html - 182 + 161 src/app/components/common/page-header/page-header.component.html @@ -1012,89 +977,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 - 186,188 + 165,167 No tracking data is collected by the app in any way. src/app/components/admin/settings/settings.component.html - 190 - - - - Bulk editing - - src/app/components/admin/settings/settings.component.html - 196 - - - - Show confirmation dialogs - - src/app/components/admin/settings/settings.component.html - 199 - - - - Apply on close - - src/app/components/admin/settings/settings.component.html - 200 - - - - Global search - - src/app/components/admin/settings/settings.component.html - 204 - - - src/app/components/app-frame/global-search/global-search.component.ts - 120 - - - - Do not include advanced search results - - src/app/components/admin/settings/settings.component.html - 207 - - - - Full search links to - - src/app/components/admin/settings/settings.component.html - 215 - - - - Title and content search - - src/app/components/admin/settings/settings.component.html - 219 - - - - Advanced search - - src/app/components/admin/settings/settings.component.html - 220 - - - src/app/components/app-frame/global-search/global-search.component.html - 24 - - - src/app/components/document-list/filter-editor/filter-editor.component.ts - 166 + 169 Saved Views src/app/components/admin/settings/settings.component.html - 227 + 175 src/app/components/app-frame/app-frame.component.html @@ -1113,14 +1010,77 @@ Show warning when closing saved views with unsaved changes src/app/components/admin/settings/settings.component.html - 230 + 178 + + + + Document editing + + src/app/components/admin/settings/settings.component.html + 184 + + + + Use PDF viewer provided by the browser + + src/app/components/admin/settings/settings.component.html + 188 + + + + 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 + 188 + + + + Default zoom: + + src/app/components/admin/settings/settings.component.html + 194 + + + + Fit width + + src/app/components/admin/settings/settings.component.html + 198 + + + + Fit page + + src/app/components/admin/settings/settings.component.html + 199 + + + + Only applies to the Paperless-ngx PDF viewer. + + src/app/components/admin/settings/settings.component.html + 201 + + + + Automatically remove inbox tag(s) on save + + src/app/components/admin/settings/settings.component.html + 207 + + + + Show document thumbnail during loading + + src/app/components/admin/settings/settings.component.html + 213 Notes src/app/components/admin/settings/settings.component.html - 234 + 217 src/app/components/document-list/document-list.component.html @@ -1139,14 +1099,82 @@ Enable notes src/app/components/admin/settings/settings.component.html - 237 + 220 + + + + Bulk editing + + src/app/components/admin/settings/settings.component.html + 224 + + + + Show confirmation dialogs + + src/app/components/admin/settings/settings.component.html + 227 + + + + Apply on close + + src/app/components/admin/settings/settings.component.html + 228 + + + + Global search + + src/app/components/admin/settings/settings.component.html + 232 + + + src/app/components/app-frame/global-search/global-search.component.ts + 120 + + + + Do not include advanced search results + + src/app/components/admin/settings/settings.component.html + 235 + + + + Full search links to + + src/app/components/admin/settings/settings.component.html + 243 + + + + Title and content search + + src/app/components/admin/settings/settings.component.html + 247 + + + + Advanced search + + src/app/components/admin/settings/settings.component.html + 248 + + + src/app/components/app-frame/global-search/global-search.component.html + 24 + + + src/app/components/document-list/filter-editor/filter-editor.component.ts + 166 Permissions src/app/components/admin/settings/settings.component.html - 247 + 262 src/app/components/common/edit-dialog/group-edit-dialog/group-edit-dialog.component.html @@ -1209,28 +1237,28 @@ Default Permissions src/app/components/admin/settings/settings.component.html - 250 + 265 Settings apply to this user account for objects (Tags, Mail Rules, etc.) created via the web UI src/app/components/admin/settings/settings.component.html - 254,256 + 269,271 Default Owner src/app/components/admin/settings/settings.component.html - 261 + 276 Objects without an owner can be viewed and edited by all users src/app/components/admin/settings/settings.component.html - 265 + 280 src/app/components/common/input/permissions/permissions-form/permissions-form.component.html @@ -1241,18 +1269,18 @@ Default View Permissions src/app/components/admin/settings/settings.component.html - 270 + 285 Users: src/app/components/admin/settings/settings.component.html - 275 + 290 src/app/components/admin/settings/settings.component.html - 302 + 317 src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html @@ -1283,11 +1311,11 @@ Groups: src/app/components/admin/settings/settings.component.html - 285 + 300 src/app/components/admin/settings/settings.component.html - 312 + 327 src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html @@ -1318,14 +1346,14 @@ Default Edit Permissions src/app/components/admin/settings/settings.component.html - 297 + 312 Edit permissions also grant viewing permissions src/app/components/admin/settings/settings.component.html - 321 + 336 src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html @@ -1344,56 +1372,56 @@ Notifications src/app/components/admin/settings/settings.component.html - 329 + 344 Document processing src/app/components/admin/settings/settings.component.html - 332 + 347 Show notifications when new documents are detected src/app/components/admin/settings/settings.component.html - 336 + 351 Show notifications when document processing completes successfully src/app/components/admin/settings/settings.component.html - 337 + 352 Show notifications when document processing fails src/app/components/admin/settings/settings.component.html - 338 + 353 Suppress notifications on dashboard src/app/components/admin/settings/settings.component.html - 339 + 354 This will suppress all messages about document processing status on the dashboard. src/app/components/admin/settings/settings.component.html - 339 + 354 Cancel src/app/components/admin/settings/settings.component.html - 350 + 365 src/app/components/common/confirm-dialog/confirm-dialog.component.ts @@ -1468,21 +1496,21 @@ Use system language src/app/components/admin/settings/settings.component.ts - 75 + 76 Use date format of display language src/app/components/admin/settings/settings.component.ts - 78 + 79 Error retrieving users src/app/components/admin/settings/settings.component.ts - 213 + 217 src/app/components/admin/users-groups/users-groups.component.ts @@ -1493,7 +1521,7 @@ Error retrieving groups src/app/components/admin/settings/settings.component.ts - 232 + 236 src/app/components/admin/users-groups/users-groups.component.ts @@ -1504,28 +1532,28 @@ Settings were saved successfully. src/app/components/admin/settings/settings.component.ts - 521 + 532 Settings were saved successfully. Reload is required to apply some changes. src/app/components/admin/settings/settings.component.ts - 525 + 536 Reload now src/app/components/admin/settings/settings.component.ts - 526 + 537 An error occurred while saving settings. src/app/components/admin/settings/settings.component.ts - 536 + 547 src/app/components/app-frame/app-frame.component.ts @@ -2505,19 +2533,19 @@ src/app/components/document-detail/document-detail.component.ts - 957 + 958 src/app/components/document-detail/document-detail.component.ts - 1310 + 1318 src/app/components/document-detail/document-detail.component.ts - 1349 + 1357 src/app/components/document-detail/document-detail.component.ts - 1390 + 1398 src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -3111,7 +3139,7 @@ src/app/components/document-detail/document-detail.component.ts - 910 + 911 src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -6237,7 +6265,7 @@ src/app/components/document-detail/document-detail.component.ts - 1367 + 1375 src/app/guards/dirty-saved-view.guard.ts @@ -6524,56 +6552,56 @@ An error occurred loading content: src/app/components/document-detail/document-detail.component.ts - 411,413 + 412,414 Document changes detected src/app/components/document-detail/document-detail.component.ts - 434 + 435 The version of this document in your browser session appears older than the existing version. src/app/components/document-detail/document-detail.component.ts - 435 + 436 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 - 436 + 437 Ok src/app/components/document-detail/document-detail.component.ts - 438 + 439 Next document src/app/components/document-detail/document-detail.component.ts - 545 + 546 Previous document src/app/components/document-detail/document-detail.component.ts - 555 + 556 Close document src/app/components/document-detail/document-detail.component.ts - 563 + 564 src/app/services/open-documents.service.ts @@ -6584,67 +6612,67 @@ Save document src/app/components/document-detail/document-detail.component.ts - 570 + 571 Save and close / next src/app/components/document-detail/document-detail.component.ts - 579 + 580 Error retrieving metadata src/app/components/document-detail/document-detail.component.ts - 631 + 632 Error retrieving suggestions. src/app/components/document-detail/document-detail.component.ts - 660 + 661 Document "" saved successfully. src/app/components/document-detail/document-detail.component.ts - 812 + 813 src/app/components/document-detail/document-detail.component.ts - 828 + 829 Error saving document "" src/app/components/document-detail/document-detail.component.ts - 834 + 835 Error saving document src/app/components/document-detail/document-detail.component.ts - 879 + 880 Do you really want to move the document "" to the trash? src/app/components/document-detail/document-detail.component.ts - 911 + 912 Documents can be restored prior to permanent deletion. src/app/components/document-detail/document-detail.component.ts - 912 + 913 src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -6655,7 +6683,7 @@ Move to trash src/app/components/document-detail/document-detail.component.ts - 914 + 915 src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -6666,14 +6694,14 @@ Error deleting document src/app/components/document-detail/document-detail.component.ts - 933 + 934 Reprocess confirm src/app/components/document-detail/document-detail.component.ts - 953 + 954 src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -6684,77 +6712,77 @@ This operation will permanently recreate the archive file for this document. src/app/components/document-detail/document-detail.component.ts - 954 + 955 The archive file will be re-generated with the current settings. src/app/components/document-detail/document-detail.component.ts - 955 + 956 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 - 965 + 966 Error executing operation src/app/components/document-detail/document-detail.component.ts - 976 + 977 Error downloading document src/app/components/document-detail/document-detail.component.ts - 1023 + 1024 Page Fit src/app/components/document-detail/document-detail.component.ts - 1095 + 1103 Split confirm src/app/components/document-detail/document-detail.component.ts - 1308 + 1316 This operation will split the selected document(s) into new documents. src/app/components/document-detail/document-detail.component.ts - 1309 + 1317 Split operation for "" will begin in the background. src/app/components/document-detail/document-detail.component.ts - 1325 + 1333 Error executing split operation src/app/components/document-detail/document-detail.component.ts - 1334 + 1342 Rotate confirm src/app/components/document-detail/document-detail.component.ts - 1347 + 1355 src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -6765,60 +6793,60 @@ This operation will permanently rotate the original version of the current document. src/app/components/document-detail/document-detail.component.ts - 1348 + 1356 Rotation of "" will begin in the background. Close and re-open the document after the operation has completed to see the changes. src/app/components/document-detail/document-detail.component.ts - 1364 + 1372 Error executing rotate operation src/app/components/document-detail/document-detail.component.ts - 1376 + 1384 Delete pages confirm src/app/components/document-detail/document-detail.component.ts - 1388 + 1396 This operation will permanently delete the selected pages from the original document. src/app/components/document-detail/document-detail.component.ts - 1389 + 1397 Delete pages operation for "" will begin in the background. Close and re-open or reload this document after the operation has completed to see the changes. src/app/components/document-detail/document-detail.component.ts - 1404 + 1412 Error executing delete pages operation src/app/components/document-detail/document-detail.component.ts - 1413 + 1421 An error occurred loading tiff: src/app/components/document-detail/document-detail.component.ts - 1453 + 1461 src/app/components/document-detail/document-detail.component.ts - 1457 + 1465 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 097015973..b8a46e57e 100644 --- a/src-ui/src/app/components/admin/settings/settings.component.html +++ b/src-ui/src/app/components/admin/settings/settings.component.html @@ -41,7 +41,7 @@
-

Appearance

+
Appearance
Display language @@ -154,28 +154,7 @@
-

Document editing

- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
-
-

Update checking

+
Update checking
@@ -193,7 +172,56 @@
-

Bulk editing

+
Saved Views
+
+
+ +
+
+ +
+
+
Document editing
+ +
+
+ +
+
+ +
+
+ Default zoom: +
+
+ +

Only applies to the Paperless-ngx PDF viewer.

+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
Notes
+
+
+ +
+
+ +
Bulk editing
@@ -201,7 +229,7 @@
-

Global search

+
Global search
@@ -224,19 +252,6 @@
-

Saved Views

-
-
- -
-
- -

Notes

-
-
- -
-
@@ -247,7 +262,7 @@ Permissions -

Default Permissions

+
Default Permissions
@@ -329,7 +344,7 @@ Notifications -

Document processing

+
Document processing
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 5f587cf9e..4f50e7453 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 @@ -212,7 +212,7 @@ describe('SettingsComponent', () => { expect(toastErrorSpy).toHaveBeenCalled() expect(storeSpy).toHaveBeenCalled() expect(appearanceSettingsSpy).not.toHaveBeenCalled() - expect(setSpy).toHaveBeenCalledTimes(28) + expect(setSpy).toHaveBeenCalledTimes(29) // succeed storeSpy.mockReturnValueOnce(of(true)) 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 9bd044f78..68f702cfa 100644 --- a/src-ui/src/app/components/admin/settings/settings.component.ts +++ b/src-ui/src/app/components/admin/settings/settings.component.ts @@ -63,6 +63,7 @@ import { PermissionsUserComponent } from '../../common/input/permissions/permiss import { SelectComponent } from '../../common/input/select/select.component' import { PageHeaderComponent } from '../../common/page-header/page-header.component' import { SystemStatusDialogComponent } from '../../common/system-status-dialog/system-status-dialog.component' +import { ZoomSetting } from '../../document-detail/document-detail.component' import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component' enum SettingsNavIDs { @@ -125,6 +126,7 @@ export class SettingsComponent defaultPermsEditUsers: new FormControl(null), defaultPermsEditGroups: new FormControl(null), useNativePdfViewer: new FormControl(null), + pdfViewerDefaultZoom: new FormControl(null), documentEditingRemoveInboxTags: new FormControl(null), documentEditingOverlayThumbnail: new FormControl(null), searchDbOnly: new FormControl(null), @@ -154,6 +156,8 @@ export class SettingsComponent public readonly GlobalSearchType = GlobalSearchType + public readonly ZoomSetting = ZoomSetting + get systemStatusHasErrors(): boolean { return ( this.systemStatus.database.status === SystemStatusItemStatus.ERROR || @@ -276,6 +280,9 @@ export class SettingsComponent useNativePdfViewer: this.settings.get( SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER ), + pdfViewerDefaultZoom: this.settings.get( + SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING + ), displayLanguage: this.settings.getLanguage(), dateLocale: this.settings.get(SETTINGS_KEYS.DATE_LOCALE), dateFormat: this.settings.get(SETTINGS_KEYS.DATE_FORMAT), @@ -435,6 +442,10 @@ export class SettingsComponent SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, this.settingsForm.value.useNativePdfViewer ) + this.settings.set( + SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING, + this.settingsForm.value.pdfViewerDefaultZoom + ) this.settings.set( SETTINGS_KEYS.DATE_LOCALE, this.settingsForm.value.dateLocale 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 02fec3cf7..a8e14c51d 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 @@ -9,9 +9,9 @@ }
- @for (setting of zoomSettings; track setting) { - } @@ -356,9 +356,9 @@ -
+
@if (showThumbnailOverlay) { - Document loading... + Document loading... }
diff --git a/src-ui/src/app/components/document-detail/document-detail.component.scss b/src-ui/src/app/components/document-detail/document-detail.component.scss index c00f7655e..3fc009020 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.scss +++ b/src-ui/src/app/components/document-detail/document-detail.component.scss @@ -85,5 +85,8 @@ textarea.rtl { > img { filter: blur(1px); + max-width: 100%; + object-fit: contain; + object-position: top; } } 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 229c4fd12..349e213aa 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 @@ -62,7 +62,10 @@ import { ToastService } from 'src/app/services/toast.service' import { environment } from 'src/environments/environment' import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component' import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component' -import { DocumentDetailComponent } from './document-detail.component' +import { + DocumentDetailComponent, + ZoomSetting, +} from './document-detail.component' const doc: Document = { id: 3, @@ -753,7 +756,7 @@ describe('DocumentDetailComponent', () => { it('should support zoom controls', () => { initNormally() - component.onZoomSelect({ target: { value: '1' } } as any) // from select + component.setZoom(ZoomSetting.One) // from select expect(component.previewZoomSetting).toEqual('1') component.increaseZoom() expect(component.previewZoomSetting).toEqual('1.5') @@ -761,18 +764,18 @@ describe('DocumentDetailComponent', () => { expect(component.previewZoomSetting).toEqual('2') component.decreaseZoom() expect(component.previewZoomSetting).toEqual('1.5') - component.onZoomSelect({ target: { value: '1' } } as any) // from select + component.setZoom(ZoomSetting.One) // from select component.decreaseZoom() expect(component.previewZoomSetting).toEqual('.75') - component.onZoomSelect({ target: { value: 'page-fit' } } as any) // from select + component.setZoom(ZoomSetting.PageFit) // from select expect(component.previewZoomScale).toEqual('page-fit') expect(component.previewZoomSetting).toEqual('1') component.increaseZoom() expect(component.previewZoomSetting).toEqual('1.5') expect(component.previewZoomScale).toEqual('page-width') - component.onZoomSelect({ target: { value: 'page-fit' } } as any) // from select + component.setZoom(ZoomSetting.PageFit) // from select expect(component.previewZoomScale).toEqual('page-fit') expect(component.previewZoomSetting).toEqual('1') component.decreaseZoom() @@ -780,6 +783,19 @@ describe('DocumentDetailComponent', () => { expect(component.previewZoomScale).toEqual('page-width') }) + it('should select correct zoom setting in dropdown', () => { + initNormally() + component.setZoom(ZoomSetting.PageFit) + expect(component.isZoomSelected(ZoomSetting.PageFit)).toBeTruthy() + expect(component.isZoomSelected(ZoomSetting.One)).toBeFalsy() + component.setZoom(ZoomSetting.PageWidth) + expect(component.isZoomSelected(ZoomSetting.One)).toBeTruthy() + expect(component.isZoomSelected(ZoomSetting.PageFit)).toBeFalsy() + component.setZoom(ZoomSetting.Quarter) + expect(component.isZoomSelected(ZoomSetting.Quarter)).toBeTruthy() + expect(component.isZoomSelected(ZoomSetting.PageFit)).toBeFalsy() + }) + it('should support updating notes dynamically', () => { const notes = [ { 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 c1a96c168..30e34d9cf 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 @@ -124,7 +124,7 @@ enum ContentRenderType { TIFF = 'tiff', } -enum ZoomSetting { +export enum ZoomSetting { PageFit = 'page-fit', PageWidth = 'page-width', Quarter = '.25', @@ -328,6 +328,7 @@ export class DocumentDetailComponent } ngOnInit(): void { + this.setZoom(this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING)) this.documentForm.valueChanges .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe(() => { @@ -1072,14 +1073,13 @@ export class DocumentDetailComponent } } - onZoomSelect(event: Event) { - const setting = (event.target as HTMLSelectElement)?.value as ZoomSetting - if (ZoomSetting.PageFit === setting) { - this.previewZoomSetting = ZoomSetting.One + setZoom(setting: ZoomSetting) { + if (ZoomSetting.PageFit === setting || ZoomSetting.PageWidth === setting) { this.previewZoomScale = setting + this.previewZoomSetting = ZoomSetting.One } else { - this.previewZoomScale = ZoomSetting.PageWidth this.previewZoomSetting = setting + this.previewZoomScale = ZoomSetting.PageWidth } } @@ -1089,6 +1089,14 @@ export class DocumentDetailComponent ) } + isZoomSelected(setting: ZoomSetting): boolean { + if (this.previewZoomScale === ZoomSetting.PageFit) { + return setting === ZoomSetting.PageFit + } + + return this.previewZoomSetting === setting + } + getZoomSettingTitle(setting: ZoomSetting): string { switch (setting) { case ZoomSetting.PageFit: diff --git a/src-ui/src/app/data/ui-settings.ts b/src-ui/src/app/data/ui-settings.ts index b8a319d9b..c5164d6e1 100644 --- a/src-ui/src/app/data/ui-settings.ts +++ b/src-ui/src/app/data/ui-settings.ts @@ -33,6 +33,8 @@ export const SETTINGS_KEYS = { DARK_MODE_THUMB_INVERTED: 'general-settings:dark-mode:thumb-inverted', THEME_COLOR: 'general-settings:theme:color', USE_NATIVE_PDF_VIEWER: 'general-settings:document-details:native-pdf-viewer', + PDF_VIEWER_ZOOM_SETTING: + 'general-settings:document-details:pdf-viewer-zoom-setting', DATE_LOCALE: 'general-settings:date-display:date-locale', DATE_FORMAT: 'general-settings:date-display:date-format', NOTIFICATIONS_CONSUMER_NEW_DOCUMENT: @@ -269,4 +271,9 @@ export const SETTINGS: UiSetting[] = [ type: 'boolean', default: false, }, + { + key: SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING, + type: 'string', + default: 'page-width', // ZoomSetting from 'document-detail.component' + }, ] From 3314c5982859609eea1635bfdb8545b7df1a7c07 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Thu, 6 Feb 2025 10:54:31 -0800 Subject: [PATCH 05/12] Tweak: more accurate classifier last trained time (#9004) --- src/documents/classifier.py | 16 ++++++++++++++++ src/documents/views.py | 17 ++++++----------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/documents/classifier.py b/src/documents/classifier.py index 72bf1f16c..5bc8be2c6 100644 --- a/src/documents/classifier.py +++ b/src/documents/classifier.py @@ -1,6 +1,7 @@ import logging import pickle import re +import time import warnings from collections.abc import Iterator from hashlib import sha256 @@ -141,6 +142,19 @@ class DocumentClassifier: ): raise IncompatibleClassifierVersionError("sklearn version update") + def set_last_checked(self) -> None: + # save a timestamp of the last time we checked for retraining to a file + with Path(settings.MODEL_FILE.with_suffix(".last_checked")).open("w") as f: + f.write(str(time.time())) + + def get_last_checked(self) -> float | None: + # load the timestamp of the last time we checked for retraining + try: + with Path(settings.MODEL_FILE.with_suffix(".last_checked")).open("r") as f: + return float(f.read()) + except FileNotFoundError: # pragma: no cover + return None + def save(self) -> None: target_file: Path = settings.MODEL_FILE target_file_temp: Path = target_file.with_suffix(".pickle.part") @@ -161,6 +175,7 @@ class DocumentClassifier: pickle.dump(self.storage_path_classifier, f) target_file_temp.rename(target_file) + self.set_last_checked() def train(self) -> bool: # Get non-inbox documents @@ -229,6 +244,7 @@ class DocumentClassifier: and self.last_doc_change_time >= latest_doc_change ) and self.last_auto_type_hash == hasher.digest(): logger.info("No updates since last training") + self.set_last_checked() # Set the classifier information into the cache # Caching for 50 minutes, so slightly less than the normal retrain time cache.set( diff --git a/src/documents/views.py b/src/documents/views.py index f98932a6f..24578179a 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -15,7 +15,6 @@ from urllib.parse import quote from urllib.parse import urlparse import pathvalidate -from django.apps import apps from django.conf import settings from django.contrib.auth.models import Group from django.contrib.auth.models import User @@ -2174,18 +2173,14 @@ class SystemStatusView(PassUserMixin): classifier_status = "WARNING" raise FileNotFoundError(classifier_error) classifier_status = "OK" - task_result_model = apps.get_model("django_celery_results", "taskresult") - result = ( - task_result_model.objects.filter( - task_name="documents.tasks.train_classifier", - status="SUCCESS", + classifier_last_trained = ( + make_aware( + datetime.fromtimestamp(classifier.get_last_checked()), ) - .order_by( - "-date_done", - ) - .first() + if settings.MODEL_FILE.exists() + and classifier.get_last_checked() is not None + else None ) - classifier_last_trained = result.date_done if result else None except Exception as e: if classifier_status is None: classifier_status = "ERROR" From 046d8456e29d6e96bfd1e150b80015df0c381272 Mon Sep 17 00:00:00 2001 From: XstreamGit Date: Thu, 6 Feb 2025 20:09:35 +0100 Subject: [PATCH 06/12] Tweak: improve date matching regex for dates after numbers (#8964) --- src/documents/parsers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/documents/parsers.py b/src/documents/parsers.py index 2d73dc63f..d840817e4 100644 --- a/src/documents/parsers.py +++ b/src/documents/parsers.py @@ -41,7 +41,7 @@ DATE_REGEX = re.compile( r"(\b|(?!=([_-])))(\d{1,2}[\. ]+[a-zéûäëčžúřěáíóńźçŞğü]{3,9} \d{4}|[a-zéûäëčžúřěáíóńźçŞğü]{3,9} \d{1,2}, \d{4})(\b|(?=([_-])))|" r"(\b|(?!=([_-])))([^\W\d_]{3,9} \d{1,2}, (\d{4}))(\b|(?=([_-])))|" r"(\b|(?!=([_-])))([^\W\d_]{3,9} \d{4})(\b|(?=([_-])))|" - r"(\b|(?!=([_-])))(\d{1,2}[^ ]{2}[\. ]+[^ ]{3,9}[ \.\/-]\d{4})(\b|(?=([_-])))|" + r"(\b|(?!=([_-])))(\d{1,2}[^ 0-9]{2}[\. ]+[^ ]{3,9}[ \.\/-]\d{4})(\b|(?=([_-])))|" r"(\b|(?!=([_-])))(\b\d{1,2}[ \.\/-][a-zéûäëčžúřěáíóńźçŞğü]{3}[ \.\/-]\d{4})(\b|(?=([_-])))", re.IGNORECASE, ) From 52ab07c67382508b785033dcc3cbb5ec0669da5c Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Thu, 6 Feb 2025 16:47:50 -0800 Subject: [PATCH 07/12] Fix: deselect and trigger refresh for deleted documents from bulk operations with "delete originals" (#8996) --- src-ui/messages.xlf | 346 ++++++++-------- src-ui/src/app/app.component.spec.ts | 22 +- src-ui/src/app/app.component.ts | 14 +- .../saved-view-widget.component.spec.ts | 14 +- .../saved-view-widget.component.ts | 6 +- .../statistics-widget.component.spec.ts | 10 +- .../statistics-widget.component.ts | 6 +- .../upload-file-widget.component.spec.ts | 26 +- .../upload-file-widget.component.ts | 35 +- .../bulk-editor/bulk-editor.component.spec.ts | 1 + .../bulk-editor/bulk-editor.component.ts | 3 + .../document-list.component.spec.ts | 25 +- .../document-list/document-list.component.ts | 10 +- .../websocket-documents-deleted-message.ts | 3 + ...ssage.ts => websocket-progress-message.ts} | 2 +- .../services/consumer-status.service.spec.ts | 326 --------------- .../services/upload-documents.service.spec.ts | 26 +- .../app/services/upload-documents.service.ts | 16 +- .../services/websocket-status.service.spec.ts | 375 ++++++++++++++++++ ...service.ts => websocket-status.service.ts} | 125 +++--- src/documents/bulk_edit.py | 4 + src/documents/plugins/helpers.py | 46 ++- src/paperless/consumers.py | 8 +- src/paperless/tests/test_websockets.py | 112 +++++- 24 files changed, 897 insertions(+), 664 deletions(-) create mode 100644 src-ui/src/app/data/websocket-documents-deleted-message.ts rename src-ui/src/app/data/{websocket-consumer-status-message.ts => websocket-progress-message.ts} (77%) delete mode 100644 src-ui/src/app/services/consumer-status.service.spec.ts create mode 100644 src-ui/src/app/services/websocket-status.service.spec.ts rename src-ui/src/app/services/{consumer-status.service.ts => websocket-status.service.ts} (71%) diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index 65e25d8ba..9983e6b55 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -2549,15 +2549,15 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 793 + 796 src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 826 + 829 src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 845 + 848 src/app/components/manage/custom-fields/custom-fields.component.ts @@ -3143,27 +3143,27 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 436 + 439 src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 476 + 479 src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 514 + 517 src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 552 + 555 src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 614 + 617 src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 747 + 750 @@ -6143,7 +6143,7 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 381 + 384 this string is used to separate processing, failed and added on the file upload widget @@ -6676,7 +6676,7 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 749 + 752 @@ -6687,7 +6687,7 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 751 + 754 @@ -6705,7 +6705,7 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 789 + 792 @@ -6786,7 +6786,7 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 823 + 826 @@ -6982,25 +6982,25 @@ Error executing bulk operation src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 285 + 288 "" src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 373 + 376 src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 379 + 382 "" and "" src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 375 + 378 This is for messages like 'modify "tag1" and "tag2"' @@ -7008,7 +7008,7 @@ and "" src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 383,385 + 386,388 this is for messages like 'modify "tag1", "tag2" and "tag3"' @@ -7016,14 +7016,14 @@ Confirm tags assignment src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 400 + 403 This operation will add the tag "" to selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 406 + 409 @@ -7032,14 +7032,14 @@ )"/> to selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 411,413 + 414,416 This operation will remove the tag "" from selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 419 + 422 @@ -7048,7 +7048,7 @@ )"/> from selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 424,426 + 427,429 @@ -7059,84 +7059,84 @@ )"/> on selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 428,432 + 431,435 Confirm correspondent assignment src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 469 + 472 This operation will assign the correspondent "" to selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 471 + 474 This operation will remove the correspondent from selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 473 + 476 Confirm document type assignment src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 507 + 510 This operation will assign the document type "" to selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 509 + 512 This operation will remove the document type from selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 511 + 514 Confirm storage path assignment src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 545 + 548 This operation will assign the storage path "" to selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 547 + 550 This operation will remove the storage path from selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 549 + 552 Confirm custom field assignment src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 578 + 581 This operation will assign the custom field "" to selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 584 + 587 @@ -7145,14 +7145,14 @@ )"/> to selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 589,591 + 592,594 This operation will remove the custom field "" from selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 597 + 600 @@ -7161,7 +7161,7 @@ )"/> from selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 602,604 + 605,607 @@ -7172,70 +7172,70 @@ )"/> on selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 606,610 + 609,613 Move selected document(s) to the trash? src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 748 + 751 This operation will permanently recreate the archive files for selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 790 + 793 The archive files will be re-generated with the current settings. src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 791 + 794 This operation will permanently rotate the original version of document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 824 + 827 Merge confirm src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 843 + 846 This operation will merge selected documents into a new document. src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 844 + 847 Merged document will be queued for consumption. src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 860 + 863 Custom fields updated. src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 882 + 885 Error updating custom fields. src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 891 + 894 @@ -7414,7 +7414,7 @@ src/app/components/document-list/document-list.component.ts - 310 + 314 @@ -7425,7 +7425,7 @@ src/app/components/document-list/document-list.component.ts - 303 + 307 @@ -7668,42 +7668,42 @@ Reset filters / selection src/app/components/document-list/document-list.component.ts - 291 + 295 Open first [selected] document src/app/components/document-list/document-list.component.ts - 319 + 323 Previous page src/app/components/document-list/document-list.component.ts - 335 + 339 Next page src/app/components/document-list/document-list.component.ts - 347 + 351 View "" saved successfully. src/app/components/document-list/document-list.component.ts - 379 + 383 View "" created successfully. src/app/components/document-list/document-list.component.ts - 422 + 426 @@ -9233,122 +9233,6 @@ 11 - - Document already exists. - - src/app/services/consumer-status.service.ts - 17 - - - - Document already exists. Note: existing document is in the trash. - - src/app/services/consumer-status.service.ts - 18 - - - - Document with ASN already exists. - - src/app/services/consumer-status.service.ts - 19 - - - - Document with ASN already exists. Note: existing document is in the trash. - - src/app/services/consumer-status.service.ts - 20 - - - - File not found. - - src/app/services/consumer-status.service.ts - 21 - - - - Pre-consume script does not exist. - - src/app/services/consumer-status.service.ts - 22 - - Pre-Consume is a term that appears like that in the documentation as well and does not need a specific translation - - - Error while executing pre-consume script. - - src/app/services/consumer-status.service.ts - 23 - - Pre-Consume is a term that appears like that in the documentation as well and does not need a specific translation - - - Post-consume script does not exist. - - src/app/services/consumer-status.service.ts - 24 - - Post-Consume is a term that appears like that in the documentation as well and does not need a specific translation - - - Error while executing post-consume script. - - src/app/services/consumer-status.service.ts - 25 - - Post-Consume is a term that appears like that in the documentation as well and does not need a specific translation - - - Received new file. - - src/app/services/consumer-status.service.ts - 26 - - - - File type not supported. - - src/app/services/consumer-status.service.ts - 27 - - - - Processing document... - - src/app/services/consumer-status.service.ts - 28 - - - - Generating thumbnail... - - src/app/services/consumer-status.service.ts - 29 - - - - Retrieving date from document... - - src/app/services/consumer-status.service.ts - 30 - - - - Saving document... - - src/app/services/consumer-status.service.ts - 31 - - - - Finished. - - src/app/services/consumer-status.service.ts - 32 - - You have unsaved changes to the document @@ -9664,6 +9548,122 @@ 70 + + Document already exists. + + src/app/services/websocket-status.service.ts + 23 + + + + Document already exists. Note: existing document is in the trash. + + src/app/services/websocket-status.service.ts + 24 + + + + Document with ASN already exists. + + src/app/services/websocket-status.service.ts + 25 + + + + Document with ASN already exists. Note: existing document is in the trash. + + src/app/services/websocket-status.service.ts + 26 + + + + File not found. + + src/app/services/websocket-status.service.ts + 27 + + + + Pre-consume script does not exist. + + src/app/services/websocket-status.service.ts + 28 + + Pre-Consume is a term that appears like that in the documentation as well and does not need a specific translation + + + Error while executing pre-consume script. + + src/app/services/websocket-status.service.ts + 29 + + Pre-Consume is a term that appears like that in the documentation as well and does not need a specific translation + + + Post-consume script does not exist. + + src/app/services/websocket-status.service.ts + 30 + + Post-Consume is a term that appears like that in the documentation as well and does not need a specific translation + + + Error while executing post-consume script. + + src/app/services/websocket-status.service.ts + 31 + + Post-Consume is a term that appears like that in the documentation as well and does not need a specific translation + + + Received new file. + + src/app/services/websocket-status.service.ts + 32 + + + + File type not supported. + + src/app/services/websocket-status.service.ts + 33 + + + + Processing document... + + src/app/services/websocket-status.service.ts + 34 + + + + Generating thumbnail... + + src/app/services/websocket-status.service.ts + 35 + + + + Retrieving date from document... + + src/app/services/websocket-status.service.ts + 36 + + + + Saving document... + + src/app/services/websocket-status.service.ts + 37 + + + + Finished. + + src/app/services/websocket-status.service.ts + 38 + + diff --git a/src-ui/src/app/app.component.spec.ts b/src-ui/src/app/app.component.spec.ts index 74626f847..bc59f78dc 100644 --- a/src-ui/src/app/app.component.spec.ts +++ b/src-ui/src/app/app.component.spec.ts @@ -18,20 +18,20 @@ import { ToastsComponent } from './components/common/toasts/toasts.component' import { FileDropComponent } from './components/file-drop/file-drop.component' import { DirtySavedViewGuard } from './guards/dirty-saved-view.guard' import { PermissionsGuard } from './guards/permissions.guard' -import { - ConsumerStatusService, - FileStatus, -} from './services/consumer-status.service' import { HotKeyService } from './services/hot-key.service' import { PermissionsService } from './services/permissions.service' import { SettingsService } from './services/settings.service' import { Toast, ToastService } from './services/toast.service' +import { + FileStatus, + WebsocketStatusService, +} from './services/websocket-status.service' describe('AppComponent', () => { let component: AppComponent let fixture: ComponentFixture let tourService: TourService - let consumerStatusService: ConsumerStatusService + let websocketStatusService: WebsocketStatusService let permissionsService: PermissionsService let toastService: ToastService let router: Router @@ -59,7 +59,7 @@ describe('AppComponent', () => { }).compileComponents() tourService = TestBed.inject(TourService) - consumerStatusService = TestBed.inject(ConsumerStatusService) + websocketStatusService = TestBed.inject(WebsocketStatusService) permissionsService = TestBed.inject(PermissionsService) settingsService = TestBed.inject(SettingsService) toastService = TestBed.inject(ToastService) @@ -90,7 +90,7 @@ describe('AppComponent', () => { const toastSpy = jest.spyOn(toastService, 'show') const fileStatusSubject = new Subject() jest - .spyOn(consumerStatusService, 'onDocumentConsumptionFinished') + .spyOn(websocketStatusService, 'onDocumentConsumptionFinished') .mockReturnValue(fileStatusSubject) component.ngOnInit() const status = new FileStatus() @@ -109,7 +109,7 @@ describe('AppComponent', () => { const toastSpy = jest.spyOn(toastService, 'show') const fileStatusSubject = new Subject() jest - .spyOn(consumerStatusService, 'onDocumentConsumptionFinished') + .spyOn(websocketStatusService, 'onDocumentConsumptionFinished') .mockReturnValue(fileStatusSubject) component.ngOnInit() fileStatusSubject.next(new FileStatus()) @@ -122,7 +122,7 @@ describe('AppComponent', () => { const toastSpy = jest.spyOn(toastService, 'show') const fileStatusSubject = new Subject() jest - .spyOn(consumerStatusService, 'onDocumentDetected') + .spyOn(websocketStatusService, 'onDocumentDetected') .mockReturnValue(fileStatusSubject) component.ngOnInit() fileStatusSubject.next(new FileStatus()) @@ -136,7 +136,7 @@ describe('AppComponent', () => { const toastSpy = jest.spyOn(toastService, 'show') const fileStatusSubject = new Subject() jest - .spyOn(consumerStatusService, 'onDocumentDetected') + .spyOn(websocketStatusService, 'onDocumentDetected') .mockReturnValue(fileStatusSubject) component.ngOnInit() fileStatusSubject.next(new FileStatus()) @@ -148,7 +148,7 @@ describe('AppComponent', () => { const toastSpy = jest.spyOn(toastService, 'showError') const fileStatusSubject = new Subject() jest - .spyOn(consumerStatusService, 'onDocumentConsumptionFailed') + .spyOn(websocketStatusService, 'onDocumentConsumptionFailed') .mockReturnValue(fileStatusSubject) component.ngOnInit() fileStatusSubject.next(new FileStatus()) diff --git a/src-ui/src/app/app.component.ts b/src-ui/src/app/app.component.ts index c89f5d4c2..a6c4702b7 100644 --- a/src-ui/src/app/app.component.ts +++ b/src-ui/src/app/app.component.ts @@ -6,7 +6,6 @@ import { ToastsComponent } from './components/common/toasts/toasts.component' import { FileDropComponent } from './components/file-drop/file-drop.component' import { SETTINGS_KEYS } from './data/ui-settings' import { ComponentRouterService } from './services/component-router.service' -import { ConsumerStatusService } from './services/consumer-status.service' import { HotKeyService } from './services/hot-key.service' import { PermissionAction, @@ -16,6 +15,7 @@ import { import { SettingsService } from './services/settings.service' import { TasksService } from './services/tasks.service' import { ToastService } from './services/toast.service' +import { WebsocketStatusService } from './services/websocket-status.service' @Component({ selector: 'pngx-root', @@ -35,7 +35,7 @@ export class AppComponent implements OnInit, OnDestroy { constructor( private settings: SettingsService, - private consumerStatusService: ConsumerStatusService, + private websocketStatusService: WebsocketStatusService, private toastService: ToastService, private router: Router, private tasksService: TasksService, @@ -51,7 +51,7 @@ export class AppComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { - this.consumerStatusService.disconnect() + this.websocketStatusService.disconnect() if (this.successSubscription) { this.successSubscription.unsubscribe() } @@ -76,9 +76,9 @@ export class AppComponent implements OnInit, OnDestroy { } ngOnInit(): void { - this.consumerStatusService.connect() + this.websocketStatusService.connect() - this.successSubscription = this.consumerStatusService + this.successSubscription = this.websocketStatusService .onDocumentConsumptionFinished() .subscribe((status) => { this.tasksService.reload() @@ -108,7 +108,7 @@ export class AppComponent implements OnInit, OnDestroy { } }) - this.failedSubscription = this.consumerStatusService + this.failedSubscription = this.websocketStatusService .onDocumentConsumptionFailed() .subscribe((status) => { this.tasksService.reload() @@ -121,7 +121,7 @@ export class AppComponent implements OnInit, OnDestroy { } }) - this.newDocumentSubscription = this.consumerStatusService + this.newDocumentSubscription = this.websocketStatusService .onDocumentDetected() .subscribe((status) => { this.tasksService.reload() diff --git a/src-ui/src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.spec.ts b/src-ui/src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.spec.ts index 5f66c68d6..621a90491 100644 --- a/src-ui/src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.spec.ts +++ b/src-ui/src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.spec.ts @@ -33,14 +33,14 @@ 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' import { SafeUrlPipe } from 'src/app/pipes/safeurl.pipe' -import { - ConsumerStatusService, - FileStatus, -} from 'src/app/services/consumer-status.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { PermissionsService } from 'src/app/services/permissions.service' import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' import { DocumentService } from 'src/app/services/rest/document.service' +import { + FileStatus, + WebsocketStatusService, +} from 'src/app/services/websocket-status.service' import { WidgetFrameComponent } from '../widget-frame/widget-frame.component' import { SavedViewWidgetComponent } from './saved-view-widget.component' @@ -112,7 +112,7 @@ describe('SavedViewWidgetComponent', () => { let component: SavedViewWidgetComponent let fixture: ComponentFixture let documentService: DocumentService - let consumerStatusService: ConsumerStatusService + let websocketStatusService: WebsocketStatusService let documentListViewService: DocumentListViewService let router: Router @@ -176,7 +176,7 @@ describe('SavedViewWidgetComponent', () => { }).compileComponents() documentService = TestBed.inject(DocumentService) - consumerStatusService = TestBed.inject(ConsumerStatusService) + websocketStatusService = TestBed.inject(WebsocketStatusService) documentListViewService = TestBed.inject(DocumentListViewService) router = TestBed.inject(Router) fixture = TestBed.createComponent(SavedViewWidgetComponent) @@ -235,7 +235,7 @@ describe('SavedViewWidgetComponent', () => { it('should reload on document consumption finished', () => { const fileStatusSubject = new Subject() jest - .spyOn(consumerStatusService, 'onDocumentConsumptionFinished') + .spyOn(websocketStatusService, 'onDocumentConsumptionFinished') .mockReturnValue(fileStatusSubject) const reloadSpy = jest.spyOn(component, 'reload') component.ngOnInit() diff --git a/src-ui/src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.ts b/src-ui/src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.ts index 7f6c5755b..32bf7a004 100644 --- a/src-ui/src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.ts +++ b/src-ui/src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.ts @@ -42,7 +42,6 @@ import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe' import { DocumentTypeNamePipe } from 'src/app/pipes/document-type-name.pipe' import { StoragePathNamePipe } from 'src/app/pipes/storage-path-name.pipe' import { UsernamePipe } from 'src/app/pipes/username.pipe' -import { ConsumerStatusService } from 'src/app/services/consumer-status.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { OpenDocumentsService } from 'src/app/services/open-documents.service' import { @@ -53,6 +52,7 @@ import { import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' import { DocumentService } from 'src/app/services/rest/document.service' import { SettingsService } from 'src/app/services/settings.service' +import { WebsocketStatusService } from 'src/app/services/websocket-status.service' import { WidgetFrameComponent } from '../widget-frame/widget-frame.component' @Component({ @@ -94,7 +94,7 @@ export class SavedViewWidgetComponent private documentService: DocumentService, private router: Router, private list: DocumentListViewService, - private consumerStatusService: ConsumerStatusService, + private websocketStatusService: WebsocketStatusService, public openDocumentsService: OpenDocumentsService, public documentListViewService: DocumentListViewService, public permissionsService: PermissionsService, @@ -124,7 +124,7 @@ export class SavedViewWidgetComponent ngOnInit(): void { this.reload() this.displayMode = this.savedView.display_mode ?? DisplayMode.TABLE - this.consumerStatusService + this.websocketStatusService .onDocumentConsumptionFinished() .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe(() => { diff --git a/src-ui/src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.spec.ts b/src-ui/src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.spec.ts index da0c2c083..48ca50a10 100644 --- a/src-ui/src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.spec.ts +++ b/src-ui/src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.spec.ts @@ -12,9 +12,9 @@ import { routes } from 'src/app/app-routing.module' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { PermissionsGuard } from 'src/app/guards/permissions.guard' import { - ConsumerStatusService, FileStatus, -} from 'src/app/services/consumer-status.service' + WebsocketStatusService, +} from 'src/app/services/websocket-status.service' import { environment } from 'src/environments/environment' import { WidgetFrameComponent } from '../widget-frame/widget-frame.component' import { StatisticsWidgetComponent } from './statistics-widget.component' @@ -23,7 +23,7 @@ describe('StatisticsWidgetComponent', () => { let component: StatisticsWidgetComponent let fixture: ComponentFixture let httpTestingController: HttpTestingController - let consumerStatusService: ConsumerStatusService + let websocketStatusService: WebsocketStatusService const fileStatusSubject = new Subject() beforeEach(async () => { @@ -44,9 +44,9 @@ describe('StatisticsWidgetComponent', () => { }).compileComponents() fixture = TestBed.createComponent(StatisticsWidgetComponent) - consumerStatusService = TestBed.inject(ConsumerStatusService) + websocketStatusService = TestBed.inject(WebsocketStatusService) jest - .spyOn(consumerStatusService, 'onDocumentConsumptionFinished') + .spyOn(websocketStatusService, 'onDocumentConsumptionFinished') .mockReturnValue(fileStatusSubject) component = fixture.componentInstance diff --git a/src-ui/src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.ts b/src-ui/src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.ts index f54852429..0669a3666 100644 --- a/src-ui/src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.ts +++ b/src-ui/src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.ts @@ -8,8 +8,8 @@ import { first, Subject, Subscription, takeUntil } from 'rxjs' import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component' import { FILTER_HAS_TAGS_ANY } from 'src/app/data/filter-rule-type' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' -import { ConsumerStatusService } from 'src/app/services/consumer-status.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service' +import { WebsocketStatusService } from 'src/app/services/websocket-status.service' import { environment } from 'src/environments/environment' import { WidgetFrameComponent } from '../widget-frame/widget-frame.component' @@ -51,7 +51,7 @@ export class StatisticsWidgetComponent constructor( private http: HttpClient, - private consumerStatusService: ConsumerStatusService, + private websocketConnectionService: WebsocketStatusService, private documentListViewService: DocumentListViewService ) { super() @@ -109,7 +109,7 @@ export class StatisticsWidgetComponent ngOnInit(): void { this.reload() - this.subscription = this.consumerStatusService + this.subscription = this.websocketConnectionService .onDocumentConsumptionFinished() .subscribe(() => { this.reload() diff --git a/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.spec.ts b/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.spec.ts index cc1591966..45ac9217a 100644 --- a/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.spec.ts +++ b/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.spec.ts @@ -12,13 +12,13 @@ import { NgbAlert, NgbCollapse } from '@ng-bootstrap/ng-bootstrap' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { routes } from 'src/app/app-routing.module' import { PermissionsGuard } from 'src/app/guards/permissions.guard' -import { - ConsumerStatusService, - FileStatus, - FileStatusPhase, -} from 'src/app/services/consumer-status.service' import { PermissionsService } from 'src/app/services/permissions.service' import { UploadDocumentsService } from 'src/app/services/upload-documents.service' +import { + FileStatus, + FileStatusPhase, + WebsocketStatusService, +} from 'src/app/services/websocket-status.service' import { UploadFileWidgetComponent } from './upload-file-widget.component' const FAILED_STATUSES = [new FileStatus()] @@ -42,7 +42,7 @@ const DEFAULT_STATUSES = [ describe('UploadFileWidgetComponent', () => { let component: UploadFileWidgetComponent let fixture: ComponentFixture - let consumerStatusService: ConsumerStatusService + let websocketStatusService: WebsocketStatusService let uploadDocumentsService: UploadDocumentsService beforeEach(async () => { @@ -65,7 +65,7 @@ describe('UploadFileWidgetComponent', () => { ], }).compileComponents() - consumerStatusService = TestBed.inject(ConsumerStatusService) + websocketStatusService = TestBed.inject(WebsocketStatusService) uploadDocumentsService = TestBed.inject(UploadDocumentsService) fixture = TestBed.createComponent(UploadFileWidgetComponent) component = fixture.componentInstance @@ -91,14 +91,14 @@ describe('UploadFileWidgetComponent', () => { }) it('should generate stats summary', () => { - mockConsumerStatuses(consumerStatusService) + mockConsumerStatuses(websocketStatusService) expect(component.getStatusSummary()).toEqual( 'Processing: 6, Failed: 1, Added: 4' ) }) it('should report an upload progress summary', () => { - mockConsumerStatuses(consumerStatusService) + mockConsumerStatuses(websocketStatusService) expect(component.getTotalUploadProgress()).toEqual(0.75) }) @@ -117,7 +117,7 @@ describe('UploadFileWidgetComponent', () => { }) it('should enforce a maximum number of alerts', () => { - mockConsumerStatuses(consumerStatusService) + mockConsumerStatuses(websocketStatusService) fixture.detectChanges() // 5 total, 1 hidden expect(fixture.debugElement.queryAll(By.directive(NgbAlert))).toHaveLength( @@ -131,19 +131,19 @@ describe('UploadFileWidgetComponent', () => { }) it('should allow dismissing an alert', () => { - const dismissSpy = jest.spyOn(consumerStatusService, 'dismiss') + const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss') component.dismiss(new FileStatus()) expect(dismissSpy).toHaveBeenCalled() }) it('should allow dismissing completed alerts', fakeAsync(() => { - mockConsumerStatuses(consumerStatusService) + mockConsumerStatuses(websocketStatusService) component.alertsExpanded = true fixture.detectChanges() jest .spyOn(component, 'getStatusCompleted') .mockImplementation(() => SUCCESS_STATUSES) - const dismissSpy = jest.spyOn(consumerStatusService, 'dismiss') + const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss') component.dismissCompleted() tick(1000) fixture.detectChanges() diff --git a/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.ts b/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.ts index f237ab7aa..f60cdce60 100644 --- a/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.ts +++ b/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.ts @@ -12,13 +12,13 @@ import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap' import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component' import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' -import { - ConsumerStatusService, - FileStatus, - FileStatusPhase, -} from 'src/app/services/consumer-status.service' import { SettingsService } from 'src/app/services/settings.service' import { UploadDocumentsService } from 'src/app/services/upload-documents.service' +import { + FileStatus, + FileStatusPhase, + WebsocketStatusService, +} from 'src/app/services/websocket-status.service' import { WidgetFrameComponent } from '../widget-frame/widget-frame.component' const MAX_ALERTS = 5 @@ -46,7 +46,7 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions { @ViewChildren(NgbAlert) alerts: QueryList constructor( - private consumerStatusService: ConsumerStatusService, + private websocketStatusService: WebsocketStatusService, private uploadDocumentsService: UploadDocumentsService, public settingsService: SettingsService ) { @@ -54,13 +54,13 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions { } getStatus() { - return this.consumerStatusService.getConsumerStatus().slice(0, MAX_ALERTS) + return this.websocketStatusService.getConsumerStatus().slice(0, MAX_ALERTS) } getStatusSummary() { let strings = [] let countUploadingAndProcessing = - this.consumerStatusService.getConsumerStatusNotCompleted().length + this.websocketStatusService.getConsumerStatusNotCompleted().length let countFailed = this.getStatusFailed().length let countSuccess = this.getStatusSuccess().length if (countUploadingAndProcessing > 0) { @@ -78,27 +78,30 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions { } getStatusHidden() { - if (this.consumerStatusService.getConsumerStatus().length < MAX_ALERTS) + if (this.websocketStatusService.getConsumerStatus().length < MAX_ALERTS) return [] - else return this.consumerStatusService.getConsumerStatus().slice(MAX_ALERTS) + else + return this.websocketStatusService.getConsumerStatus().slice(MAX_ALERTS) } getStatusUploading() { - return this.consumerStatusService.getConsumerStatus( + return this.websocketStatusService.getConsumerStatus( FileStatusPhase.UPLOADING ) } getStatusFailed() { - return this.consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED) + return this.websocketStatusService.getConsumerStatus(FileStatusPhase.FAILED) } getStatusSuccess() { - return this.consumerStatusService.getConsumerStatus(FileStatusPhase.SUCCESS) + return this.websocketStatusService.getConsumerStatus( + FileStatusPhase.SUCCESS + ) } getStatusCompleted() { - return this.consumerStatusService.getConsumerStatusCompleted() + return this.websocketStatusService.getConsumerStatusCompleted() } getTotalUploadProgress() { @@ -134,12 +137,12 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions { } dismiss(status: FileStatus) { - this.consumerStatusService.dismiss(status) + this.websocketStatusService.dismiss(status) } dismissCompleted() { this.getStatusCompleted().forEach((status) => - this.consumerStatusService.dismiss(status) + this.websocketStatusService.dismiss(status) ) } diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts index 21b8f4175..aa4a07d12 100644 --- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts @@ -1039,6 +1039,7 @@ describe('BulkEditorComponent', () => { httpTestingController.match( `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` ) // listAllFilteredIds + expect(documentListViewService.selected.size).toEqual(0) }) it('should support bulk download with archive, originals or both and file formatting', () => { diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts index 5750c4b2f..9864761fa 100644 --- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -268,6 +268,9 @@ export class BulkEditorComponent .pipe(first()) .subscribe({ next: () => { + if (args['delete_originals']) { + this.list.selected.clear() + } this.list.reload() this.list.reduceSelectionToFilter() this.list.selected.forEach((id) => { diff --git a/src-ui/src/app/components/document-list/document-list.component.spec.ts b/src-ui/src/app/components/document-list/document-list.component.spec.ts index 805a65846..13a938f59 100644 --- a/src-ui/src/app/components/document-list/document-list.component.spec.ts +++ b/src-ui/src/app/components/document-list/document-list.component.spec.ts @@ -38,16 +38,16 @@ import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe' import { FilterPipe } from 'src/app/pipes/filter.pipe' import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe' import { UsernamePipe } from 'src/app/pipes/username.pipe' -import { - ConsumerStatusService, - FileStatus, -} from 'src/app/services/consumer-status.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { PermissionsService } from 'src/app/services/permissions.service' import { DocumentService } from 'src/app/services/rest/document.service' import { SavedViewService } from 'src/app/services/rest/saved-view.service' import { SettingsService } from 'src/app/services/settings.service' import { ToastService } from 'src/app/services/toast.service' +import { + FileStatus, + WebsocketStatusService, +} from 'src/app/services/websocket-status.service' import { DocumentCardLargeComponent } from './document-card-large/document-card-large.component' import { DocumentCardSmallComponent } from './document-card-small/document-card-small.component' import { DocumentListComponent } from './document-list.component' @@ -81,7 +81,7 @@ describe('DocumentListComponent', () => { let fixture: ComponentFixture let documentListService: DocumentListViewService let documentService: DocumentService - let consumerStatusService: ConsumerStatusService + let websocketStatusService: WebsocketStatusService let savedViewService: SavedViewService let router: Router let activatedRoute: ActivatedRoute @@ -112,7 +112,7 @@ describe('DocumentListComponent', () => { documentListService = TestBed.inject(DocumentListViewService) documentService = TestBed.inject(DocumentService) - consumerStatusService = TestBed.inject(ConsumerStatusService) + websocketStatusService = TestBed.inject(WebsocketStatusService) savedViewService = TestBed.inject(SavedViewService) router = TestBed.inject(Router) activatedRoute = TestBed.inject(ActivatedRoute) @@ -128,13 +128,24 @@ describe('DocumentListComponent', () => { const reloadSpy = jest.spyOn(documentListService, 'reload') const fileStatusSubject = new Subject() jest - .spyOn(consumerStatusService, 'onDocumentConsumptionFinished') + .spyOn(websocketStatusService, 'onDocumentConsumptionFinished') .mockReturnValue(fileStatusSubject) fixture.detectChanges() fileStatusSubject.next(new FileStatus()) expect(reloadSpy).toHaveBeenCalled() }) + it('should reload on document deleted', () => { + const reloadSpy = jest.spyOn(documentListService, 'reload') + const documentDeletedSubject = new Subject() + jest + .spyOn(websocketStatusService, 'onDocumentDeleted') + .mockReturnValue(documentDeletedSubject) + fixture.detectChanges() + documentDeletedSubject.next(true) + expect(reloadSpy).toHaveBeenCalled() + }) + it('should show score sort fields on fulltext queries', () => { documentListService.filterRules = [ { diff --git a/src-ui/src/app/components/document-list/document-list.component.ts b/src-ui/src/app/components/document-list/document-list.component.ts index b845a524a..e1f71edbc 100644 --- a/src-ui/src/app/components/document-list/document-list.component.ts +++ b/src-ui/src/app/components/document-list/document-list.component.ts @@ -43,7 +43,6 @@ import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe' import { DocumentTypeNamePipe } from 'src/app/pipes/document-type-name.pipe' import { StoragePathNamePipe } from 'src/app/pipes/storage-path-name.pipe' import { UsernamePipe } from 'src/app/pipes/username.pipe' -import { ConsumerStatusService } from 'src/app/services/consumer-status.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { HotKeyService } from 'src/app/services/hot-key.service' import { OpenDocumentsService } from 'src/app/services/open-documents.service' @@ -51,6 +50,7 @@ import { PermissionsService } from 'src/app/services/permissions.service' import { SavedViewService } from 'src/app/services/rest/saved-view.service' import { SettingsService } from 'src/app/services/settings.service' import { ToastService } from 'src/app/services/toast.service' +import { WebsocketStatusService } from 'src/app/services/websocket-status.service' import { filterRulesDiffer, isFullTextFilterRule, @@ -113,7 +113,7 @@ export class DocumentListComponent private router: Router, private toastService: ToastService, private modalService: NgbModal, - private consumerStatusService: ConsumerStatusService, + private websocketStatusService: WebsocketStatusService, public openDocumentsService: OpenDocumentsService, public settingsService: SettingsService, private hotKeyService: HotKeyService, @@ -234,13 +234,17 @@ export class DocumentListComponent } ngOnInit(): void { - this.consumerStatusService + this.websocketStatusService .onDocumentConsumptionFinished() .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe(() => { this.list.reload() }) + this.websocketStatusService.onDocumentDeleted().subscribe(() => { + this.list.reload() + }) + this.route.paramMap .pipe( filter((params) => params.has('id')), // only on saved view e.g. /view/id diff --git a/src-ui/src/app/data/websocket-documents-deleted-message.ts b/src-ui/src/app/data/websocket-documents-deleted-message.ts new file mode 100644 index 000000000..11ded3781 --- /dev/null +++ b/src-ui/src/app/data/websocket-documents-deleted-message.ts @@ -0,0 +1,3 @@ +export interface WebsocketDocumentsDeletedMessage { + documents: number[] +} diff --git a/src-ui/src/app/data/websocket-consumer-status-message.ts b/src-ui/src/app/data/websocket-progress-message.ts similarity index 77% rename from src-ui/src/app/data/websocket-consumer-status-message.ts rename to src-ui/src/app/data/websocket-progress-message.ts index d1ac590b1..c8e37e232 100644 --- a/src-ui/src/app/data/websocket-consumer-status-message.ts +++ b/src-ui/src/app/data/websocket-progress-message.ts @@ -1,4 +1,4 @@ -export interface WebsocketConsumerStatusMessage { +export interface WebsocketProgressMessage { filename?: string task_id?: string current_progress?: number diff --git a/src-ui/src/app/services/consumer-status.service.spec.ts b/src-ui/src/app/services/consumer-status.service.spec.ts deleted file mode 100644 index b699f8772..000000000 --- a/src-ui/src/app/services/consumer-status.service.spec.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { - HttpEventType, - HttpResponse, - provideHttpClient, - withInterceptorsFromDi, -} from '@angular/common/http' -import { - HttpTestingController, - provideHttpClientTesting, -} from '@angular/common/http/testing' -import { TestBed } from '@angular/core/testing' -import WS from 'jest-websocket-mock' -import { environment } from 'src/environments/environment' -import { - ConsumerStatusService, - FILE_STATUS_MESSAGES, - FileStatusPhase, -} from './consumer-status.service' -import { DocumentService } from './rest/document.service' -import { SettingsService } from './settings.service' - -describe('ConsumerStatusService', () => { - let httpTestingController: HttpTestingController - let consumerStatusService: ConsumerStatusService - let documentService: DocumentService - let settingsService: SettingsService - - const server = new WS( - `${environment.webSocketProtocol}//${environment.webSocketHost}${environment.webSocketBaseUrl}status/`, - { jsonProtocol: true } - ) - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [], - providers: [ - ConsumerStatusService, - DocumentService, - SettingsService, - provideHttpClient(withInterceptorsFromDi()), - provideHttpClientTesting(), - ], - }) - - httpTestingController = TestBed.inject(HttpTestingController) - settingsService = TestBed.inject(SettingsService) - settingsService.currentUser = { - id: 1, - username: 'testuser', - is_superuser: false, - } - consumerStatusService = TestBed.inject(ConsumerStatusService) - documentService = TestBed.inject(DocumentService) - }) - - afterEach(() => { - httpTestingController.verify() - }) - - it('should update status on websocket processing progress', () => { - const task_id = '1234' - const status = consumerStatusService.newFileUpload('file.pdf') - expect(status.getProgress()).toEqual(0) - - consumerStatusService.connect() - - consumerStatusService - .onDocumentConsumptionFinished() - .subscribe((filestatus) => { - expect(filestatus.phase).toEqual(FileStatusPhase.SUCCESS) - }) - - consumerStatusService.onDocumentDetected().subscribe((filestatus) => { - expect(filestatus.phase).toEqual(FileStatusPhase.STARTED) - }) - - server.send({ - task_id, - filename: 'file.pdf', - current_progress: 50, - max_progress: 100, - document_id: 12, - status: 'WORKING', - }) - - expect(status.getProgress()).toBeCloseTo(0.6) // (0.8 * 50/100) + .2 - expect(consumerStatusService.getConsumerStatusNotCompleted()).toEqual([ - status, - ]) - - server.send({ - task_id, - filename: 'file.pdf', - current_progress: 100, - max_progress: 100, - document_id: 12, - status: 'SUCCESS', - message: FILE_STATUS_MESSAGES.finished, - }) - - expect(status.getProgress()).toEqual(1) - expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength( - 0 - ) - expect(consumerStatusService.getConsumerStatusCompleted()).toHaveLength(1) - - consumerStatusService.disconnect() - }) - - it('should update status on websocket failed progress', () => { - const task_id = '1234' - const status = consumerStatusService.newFileUpload('file.pdf') - status.taskId = task_id - consumerStatusService.connect() - - consumerStatusService - .onDocumentConsumptionFailed() - .subscribe((filestatus) => { - expect(filestatus.phase).toEqual(FileStatusPhase.FAILED) - }) - - server.send({ - task_id, - filename: 'file.pdf', - current_progress: 50, - max_progress: 100, - document_id: 12, - }) - - expect(consumerStatusService.getConsumerStatusNotCompleted()).toEqual([ - status, - ]) - - server.send({ - task_id, - filename: 'file.pdf', - current_progress: 50, - max_progress: 100, - document_id: 12, - status: 'FAILED', - message: FILE_STATUS_MESSAGES.document_already_exists, - }) - - expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength( - 0 - ) - expect(consumerStatusService.getConsumerStatusCompleted()).toHaveLength(1) - }) - - it('should update status on upload progress', () => { - const task_id = '1234' - const status = consumerStatusService.newFileUpload('file.pdf') - - documentService.uploadDocument({}).subscribe((event) => { - if (event.type === HttpEventType.Response) { - status.taskId = event.body['task_id'] - status.message = $localize`Upload complete, waiting...` - } else if (event.type === HttpEventType.UploadProgress) { - status.updateProgress( - FileStatusPhase.UPLOADING, - event.loaded, - event.total - ) - } - }) - - const req = httpTestingController.expectOne( - `${environment.apiBaseUrl}documents/post_document/` - ) - - req.event( - new HttpResponse({ - body: { - task_id, - }, - }) - ) - - req.event({ - type: HttpEventType.UploadProgress, - loaded: 100, - total: 300, - }) - - expect( - consumerStatusService.getConsumerStatus(FileStatusPhase.UPLOADING) - ).toEqual([status]) - expect(consumerStatusService.getConsumerStatus()).toEqual([status]) - expect(consumerStatusService.getConsumerStatusNotCompleted()).toEqual([ - status, - ]) - - req.event({ - type: HttpEventType.UploadProgress, - loaded: 300, - total: 300, - }) - - expect(status.getProgress()).toEqual(0.2) // 0.2 * 300/300 - }) - - it('should support dismiss completed', () => { - consumerStatusService.connect() - server.send({ - task_id: '1234', - filename: 'file.pdf', - current_progress: 100, - max_progress: 100, - document_id: 12, - status: 'SUCCESS', - message: 'finished', - }) - - expect(consumerStatusService.getConsumerStatusCompleted()).toHaveLength(1) - consumerStatusService.dismissCompleted() - expect(consumerStatusService.getConsumerStatusCompleted()).toHaveLength(0) - consumerStatusService.disconnect() - }) - - it('should support dismiss', () => { - const task_id = '1234' - const status = consumerStatusService.newFileUpload('file.pdf') - status.taskId = task_id - status.updateProgress(FileStatusPhase.UPLOADING, 50, 100) - - const status2 = consumerStatusService.newFileUpload('file2.pdf') - status2.updateProgress(FileStatusPhase.UPLOADING, 50, 100) - - expect( - consumerStatusService.getConsumerStatus(FileStatusPhase.UPLOADING) - ).toEqual([status, status2]) - expect(consumerStatusService.getConsumerStatus()).toEqual([status, status2]) - expect(consumerStatusService.getConsumerStatusNotCompleted()).toEqual([ - status, - status2, - ]) - - consumerStatusService.dismiss(status) - expect(consumerStatusService.getConsumerStatus()).toEqual([status2]) - - consumerStatusService.dismiss(status2) - expect(consumerStatusService.getConsumerStatus()).toHaveLength(0) - }) - - it('should support fail', () => { - const task_id = '1234' - const status = consumerStatusService.newFileUpload('file.pdf') - status.taskId = task_id - status.updateProgress(FileStatusPhase.UPLOADING, 50, 100) - expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength( - 1 - ) - expect(consumerStatusService.getConsumerStatusCompleted()).toHaveLength(0) - consumerStatusService.fail(status, 'fail') - expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength( - 0 - ) - expect(consumerStatusService.getConsumerStatusCompleted()).toHaveLength(1) - }) - - it('should notify of document created on status message without upload', () => { - let detected = false - consumerStatusService.onDocumentDetected().subscribe((filestatus) => { - expect(filestatus.phase).toEqual(FileStatusPhase.STARTED) - detected = true - }) - - consumerStatusService.connect() - server.send({ - task_id: '1234', - filename: 'file.pdf', - current_progress: 0, - max_progress: 100, - message: 'new_file', - status: 'STARTED', - }) - - consumerStatusService.disconnect() - expect(detected).toBeTruthy() - }) - - it('should notify of document in progress without upload', () => { - consumerStatusService.connect() - server.send({ - task_id: '1234', - filename: 'file.pdf', - current_progress: 50, - max_progress: 100, - docuement_id: 12, - status: 'WORKING', - }) - - consumerStatusService.disconnect() - expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength( - 1 - ) - }) - - it('should not notify current user if document has different expected owner', () => { - consumerStatusService.connect() - server.send({ - task_id: '1234', - filename: 'file1.pdf', - current_progress: 50, - max_progress: 100, - docuement_id: 12, - owner_id: 1, - status: 'WORKING', - }) - - server.send({ - task_id: '5678', - filename: 'file2.pdf', - current_progress: 50, - max_progress: 100, - docuement_id: 13, - owner_id: 2, - status: 'WORKING', - }) - - consumerStatusService.disconnect() - expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength( - 1 - ) - }) -}) diff --git a/src-ui/src/app/services/upload-documents.service.spec.ts b/src-ui/src/app/services/upload-documents.service.spec.ts index cf0812306..28fb5b2e0 100644 --- a/src-ui/src/app/services/upload-documents.service.spec.ts +++ b/src-ui/src/app/services/upload-documents.service.spec.ts @@ -9,11 +9,11 @@ import { } from '@angular/common/http/testing' import { TestBed } from '@angular/core/testing' import { environment } from 'src/environments/environment' -import { - ConsumerStatusService, - FileStatusPhase, -} from './consumer-status.service' import { UploadDocumentsService } from './upload-documents.service' +import { + FileStatusPhase, + WebsocketStatusService, +} from './websocket-status.service' const files = [ { @@ -45,14 +45,14 @@ const fileList = { describe('UploadDocumentsService', () => { let httpTestingController: HttpTestingController let uploadDocumentsService: UploadDocumentsService - let consumerStatusService: ConsumerStatusService + let websocketStatusService: WebsocketStatusService beforeEach(() => { TestBed.configureTestingModule({ imports: [], providers: [ UploadDocumentsService, - ConsumerStatusService, + WebsocketStatusService, provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting(), ], @@ -60,7 +60,7 @@ describe('UploadDocumentsService', () => { httpTestingController = TestBed.inject(HttpTestingController) uploadDocumentsService = TestBed.inject(UploadDocumentsService) - consumerStatusService = TestBed.inject(ConsumerStatusService) + websocketStatusService = TestBed.inject(WebsocketStatusService) }) afterEach(() => { @@ -80,11 +80,11 @@ describe('UploadDocumentsService', () => { it('updates progress during upload and failure', () => { uploadDocumentsService.uploadFiles(fileList) - expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength( + expect(websocketStatusService.getConsumerStatusNotCompleted()).toHaveLength( 2 ) expect( - consumerStatusService.getConsumerStatus(FileStatusPhase.UPLOADING) + websocketStatusService.getConsumerStatus(FileStatusPhase.UPLOADING) ).toHaveLength(0) const req = httpTestingController.match( @@ -98,7 +98,7 @@ describe('UploadDocumentsService', () => { }) expect( - consumerStatusService.getConsumerStatus(FileStatusPhase.UPLOADING) + websocketStatusService.getConsumerStatus(FileStatusPhase.UPLOADING) ).toHaveLength(1) }) @@ -110,7 +110,7 @@ describe('UploadDocumentsService', () => { ) expect( - consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED) + websocketStatusService.getConsumerStatus(FileStatusPhase.FAILED) ).toHaveLength(0) req[0].flush( @@ -122,7 +122,7 @@ describe('UploadDocumentsService', () => { ) expect( - consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED) + websocketStatusService.getConsumerStatus(FileStatusPhase.FAILED) ).toHaveLength(1) uploadDocumentsService.uploadFiles(fileList) @@ -140,7 +140,7 @@ describe('UploadDocumentsService', () => { ) expect( - consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED) + websocketStatusService.getConsumerStatus(FileStatusPhase.FAILED) ).toHaveLength(2) }) diff --git a/src-ui/src/app/services/upload-documents.service.ts b/src-ui/src/app/services/upload-documents.service.ts index 8a5e42b47..602e6d8ae 100644 --- a/src-ui/src/app/services/upload-documents.service.ts +++ b/src-ui/src/app/services/upload-documents.service.ts @@ -2,11 +2,11 @@ import { HttpEventType } from '@angular/common/http' import { Injectable } from '@angular/core' import { FileSystemFileEntry, NgxFileDropEntry } from 'ngx-file-drop' import { Subscription } from 'rxjs' -import { - ConsumerStatusService, - FileStatusPhase, -} from './consumer-status.service' import { DocumentService } from './rest/document.service' +import { + FileStatusPhase, + WebsocketStatusService, +} from './websocket-status.service' @Injectable({ providedIn: 'root', @@ -16,7 +16,7 @@ export class UploadDocumentsService { constructor( private documentService: DocumentService, - private consumerStatusService: ConsumerStatusService + private websocketStatusService: WebsocketStatusService ) {} onNgxFileDrop(files: NgxFileDropEntry[]) { @@ -37,7 +37,7 @@ export class UploadDocumentsService { private uploadFile(file: File) { let formData = new FormData() formData.append('document', file, file.name) - let status = this.consumerStatusService.newFileUpload(file.name) + let status = this.websocketStatusService.newFileUpload(file.name) status.message = $localize`Connecting...` @@ -61,11 +61,11 @@ export class UploadDocumentsService { error: (error) => { switch (error.status) { case 400: { - this.consumerStatusService.fail(status, error.error.document) + this.websocketStatusService.fail(status, error.error.document) break } default: { - this.consumerStatusService.fail( + this.websocketStatusService.fail( status, $localize`HTTP error: ${error.status} ${error.statusText}` ) diff --git a/src-ui/src/app/services/websocket-status.service.spec.ts b/src-ui/src/app/services/websocket-status.service.spec.ts new file mode 100644 index 000000000..d3bf71f7e --- /dev/null +++ b/src-ui/src/app/services/websocket-status.service.spec.ts @@ -0,0 +1,375 @@ +import { + HttpEventType, + HttpResponse, + provideHttpClient, + withInterceptorsFromDi, +} from '@angular/common/http' +import { + HttpTestingController, + provideHttpClientTesting, +} from '@angular/common/http/testing' +import { TestBed } from '@angular/core/testing' +import WS from 'jest-websocket-mock' +import { environment } from 'src/environments/environment' +import { DocumentService } from './rest/document.service' +import { SettingsService } from './settings.service' +import { + FILE_STATUS_MESSAGES, + FileStatusPhase, + WebsocketStatusService, + WebsocketStatusType, +} from './websocket-status.service' + +describe('ConsumerStatusService', () => { + let httpTestingController: HttpTestingController + let websocketStatusService: WebsocketStatusService + let documentService: DocumentService + let settingsService: SettingsService + + const server = new WS( + `${environment.webSocketProtocol}//${environment.webSocketHost}${environment.webSocketBaseUrl}status/`, + { jsonProtocol: true } + ) + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [], + providers: [ + WebsocketStatusService, + DocumentService, + SettingsService, + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ], + }) + + httpTestingController = TestBed.inject(HttpTestingController) + settingsService = TestBed.inject(SettingsService) + settingsService.currentUser = { + id: 1, + username: 'testuser', + is_superuser: false, + } + websocketStatusService = TestBed.inject(WebsocketStatusService) + documentService = TestBed.inject(DocumentService) + }) + + afterEach(() => { + httpTestingController.verify() + }) + + it('should update status on websocket processing progress', () => { + const task_id = '1234' + const status = websocketStatusService.newFileUpload('file.pdf') + expect(status.getProgress()).toEqual(0) + + websocketStatusService.connect() + + websocketStatusService + .onDocumentConsumptionFinished() + .subscribe((filestatus) => { + expect(filestatus.phase).toEqual(FileStatusPhase.SUCCESS) + }) + + websocketStatusService.onDocumentDetected().subscribe((filestatus) => { + expect(filestatus.phase).toEqual(FileStatusPhase.STARTED) + }) + + server.send({ + type: WebsocketStatusType.STATUS_UPDATE, + data: { + task_id, + filename: 'file.pdf', + current_progress: 50, + max_progress: 100, + document_id: 12, + status: 'WORKING', + }, + }) + + expect(status.getProgress()).toBeCloseTo(0.6) // (0.8 * 50/100) + .2 + expect(websocketStatusService.getConsumerStatusNotCompleted()).toEqual([ + status, + ]) + + server.send({ + type: WebsocketStatusType.STATUS_UPDATE, + data: { + task_id, + filename: 'file.pdf', + current_progress: 100, + max_progress: 100, + document_id: 12, + status: 'SUCCESS', + message: FILE_STATUS_MESSAGES.finished, + }, + }) + + expect(status.getProgress()).toEqual(1) + expect(websocketStatusService.getConsumerStatusNotCompleted()).toHaveLength( + 0 + ) + expect(websocketStatusService.getConsumerStatusCompleted()).toHaveLength(1) + + websocketStatusService.disconnect() + }) + + it('should update status on websocket failed progress', () => { + const task_id = '1234' + const status = websocketStatusService.newFileUpload('file.pdf') + status.taskId = task_id + websocketStatusService.connect() + + websocketStatusService + .onDocumentConsumptionFailed() + .subscribe((filestatus) => { + expect(filestatus.phase).toEqual(FileStatusPhase.FAILED) + }) + + server.send({ + type: WebsocketStatusType.STATUS_UPDATE, + data: { + task_id, + filename: 'file.pdf', + current_progress: 50, + max_progress: 100, + document_id: 12, + }, + }) + + expect(websocketStatusService.getConsumerStatusNotCompleted()).toEqual([ + status, + ]) + + server.send({ + type: WebsocketStatusType.STATUS_UPDATE, + data: { + task_id, + filename: 'file.pdf', + current_progress: 50, + max_progress: 100, + document_id: 12, + status: 'FAILED', + message: FILE_STATUS_MESSAGES.document_already_exists, + }, + }) + + expect(websocketStatusService.getConsumerStatusNotCompleted()).toHaveLength( + 0 + ) + expect(websocketStatusService.getConsumerStatusCompleted()).toHaveLength(1) + }) + + it('should update status on upload progress', () => { + const task_id = '1234' + const status = websocketStatusService.newFileUpload('file.pdf') + + documentService.uploadDocument({}).subscribe((event) => { + if (event.type === HttpEventType.Response) { + status.taskId = event.body['task_id'] + status.message = $localize`Upload complete, waiting...` + } else if (event.type === HttpEventType.UploadProgress) { + status.updateProgress( + FileStatusPhase.UPLOADING, + event.loaded, + event.total + ) + } + }) + + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}documents/post_document/` + ) + + req.event( + new HttpResponse({ + body: { + task_id, + }, + }) + ) + + req.event({ + type: HttpEventType.UploadProgress, + loaded: 100, + total: 300, + }) + + expect( + websocketStatusService.getConsumerStatus(FileStatusPhase.UPLOADING) + ).toEqual([status]) + expect(websocketStatusService.getConsumerStatus()).toEqual([status]) + expect(websocketStatusService.getConsumerStatusNotCompleted()).toEqual([ + status, + ]) + + req.event({ + type: HttpEventType.UploadProgress, + loaded: 300, + total: 300, + }) + + expect(status.getProgress()).toEqual(0.2) // 0.2 * 300/300 + }) + + it('should support dismiss completed', () => { + websocketStatusService.connect() + server.send({ + type: WebsocketStatusType.STATUS_UPDATE, + data: { + task_id: '1234', + filename: 'file.pdf', + current_progress: 100, + max_progress: 100, + document_id: 12, + status: 'SUCCESS', + message: 'finished', + }, + }) + + expect(websocketStatusService.getConsumerStatusCompleted()).toHaveLength(1) + websocketStatusService.dismissCompleted() + expect(websocketStatusService.getConsumerStatusCompleted()).toHaveLength(0) + websocketStatusService.disconnect() + }) + + it('should support dismiss', () => { + const task_id = '1234' + const status = websocketStatusService.newFileUpload('file.pdf') + status.taskId = task_id + status.updateProgress(FileStatusPhase.UPLOADING, 50, 100) + + const status2 = websocketStatusService.newFileUpload('file2.pdf') + status2.updateProgress(FileStatusPhase.UPLOADING, 50, 100) + + expect( + websocketStatusService.getConsumerStatus(FileStatusPhase.UPLOADING) + ).toEqual([status, status2]) + expect(websocketStatusService.getConsumerStatus()).toEqual([ + status, + status2, + ]) + expect(websocketStatusService.getConsumerStatusNotCompleted()).toEqual([ + status, + status2, + ]) + + websocketStatusService.dismiss(status) + expect(websocketStatusService.getConsumerStatus()).toEqual([status2]) + + websocketStatusService.dismiss(status2) + expect(websocketStatusService.getConsumerStatus()).toHaveLength(0) + }) + + it('should support fail', () => { + const task_id = '1234' + const status = websocketStatusService.newFileUpload('file.pdf') + status.taskId = task_id + status.updateProgress(FileStatusPhase.UPLOADING, 50, 100) + expect(websocketStatusService.getConsumerStatusNotCompleted()).toHaveLength( + 1 + ) + expect(websocketStatusService.getConsumerStatusCompleted()).toHaveLength(0) + websocketStatusService.fail(status, 'fail') + expect(websocketStatusService.getConsumerStatusNotCompleted()).toHaveLength( + 0 + ) + expect(websocketStatusService.getConsumerStatusCompleted()).toHaveLength(1) + }) + + it('should notify of document created on status message without upload', () => { + let detected = false + websocketStatusService.onDocumentDetected().subscribe((filestatus) => { + expect(filestatus.phase).toEqual(FileStatusPhase.STARTED) + detected = true + }) + + websocketStatusService.connect() + server.send({ + type: WebsocketStatusType.STATUS_UPDATE, + data: { + task_id: '1234', + filename: 'file.pdf', + current_progress: 0, + max_progress: 100, + message: 'new_file', + status: 'STARTED', + }, + }) + + websocketStatusService.disconnect() + expect(detected).toBeTruthy() + }) + + it('should notify of document in progress without upload', () => { + websocketStatusService.connect() + server.send({ + type: WebsocketStatusType.STATUS_UPDATE, + data: { + task_id: '1234', + filename: 'file.pdf', + current_progress: 50, + max_progress: 100, + docuement_id: 12, + status: 'WORKING', + }, + }) + + websocketStatusService.disconnect() + expect(websocketStatusService.getConsumerStatusNotCompleted()).toHaveLength( + 1 + ) + }) + + it('should not notify current user if document has different expected owner', () => { + websocketStatusService.connect() + server.send({ + type: WebsocketStatusType.STATUS_UPDATE, + data: { + task_id: '1234', + filename: 'file1.pdf', + current_progress: 50, + max_progress: 100, + docuement_id: 12, + owner_id: 1, + status: 'WORKING', + }, + }) + + server.send({ + type: WebsocketStatusType.STATUS_UPDATE, + data: { + task_id: '5678', + filename: 'file2.pdf', + current_progress: 50, + max_progress: 100, + docuement_id: 13, + owner_id: 2, + status: 'WORKING', + }, + }) + + websocketStatusService.disconnect() + expect(websocketStatusService.getConsumerStatusNotCompleted()).toHaveLength( + 1 + ) + }) + + it('should trigger deleted subject on document deleted', () => { + let deleted = false + websocketStatusService.onDocumentDeleted().subscribe(() => { + deleted = true + }) + + websocketStatusService.connect() + server.send({ + type: WebsocketStatusType.DOCUMENTS_DELETED, + data: { + documents: [1, 2, 3], + }, + }) + + websocketStatusService.disconnect() + expect(deleted).toBeTruthy() + }) +}) diff --git a/src-ui/src/app/services/consumer-status.service.ts b/src-ui/src/app/services/websocket-status.service.ts similarity index 71% rename from src-ui/src/app/services/consumer-status.service.ts rename to src-ui/src/app/services/websocket-status.service.ts index 40641ff81..13f82412f 100644 --- a/src-ui/src/app/services/consumer-status.service.ts +++ b/src-ui/src/app/services/websocket-status.service.ts @@ -1,9 +1,15 @@ import { Injectable } from '@angular/core' import { Subject } from 'rxjs' import { environment } from 'src/environments/environment' -import { WebsocketConsumerStatusMessage } from '../data/websocket-consumer-status-message' +import { WebsocketDocumentsDeletedMessage } from '../data/websocket-documents-deleted-message' +import { WebsocketProgressMessage } from '../data/websocket-progress-message' import { SettingsService } from './settings.service' +export enum WebsocketStatusType { + STATUS_UPDATE = 'status_update', + DOCUMENTS_DELETED = 'documents_deleted', +} + // see ProgressStatusOptions in src/documents/plugins/helpers.py export enum FileStatusPhase { STARTED = 0, @@ -85,7 +91,7 @@ export class FileStatus { @Injectable({ providedIn: 'root', }) -export class ConsumerStatusService { +export class WebsocketStatusService { constructor(private settingsService: SettingsService) {} private statusWebSocket: WebSocket @@ -95,6 +101,7 @@ export class ConsumerStatusService { private documentDetectedSubject = new Subject() private documentConsumptionFinishedSubject = new Subject() private documentConsumptionFailedSubject = new Subject() + private documentDeletedSubject = new Subject() private get(taskId: string, filename?: string) { let status = @@ -145,63 +152,75 @@ export class ConsumerStatusService { this.statusWebSocket = new WebSocket( `${environment.webSocketProtocol}//${environment.webSocketHost}${environment.webSocketBaseUrl}status/` ) - this.statusWebSocket.onmessage = (ev) => { - let statusMessage: WebsocketConsumerStatusMessage = JSON.parse(ev['data']) + this.statusWebSocket.onmessage = (ev: MessageEvent) => { + const { + type, + data: messageData, + }: { + type: WebsocketStatusType + data: WebsocketProgressMessage | WebsocketDocumentsDeletedMessage + } = JSON.parse(ev.data) - // fallback if backend didn't restrict message - if ( - statusMessage.owner_id && - statusMessage.owner_id !== this.settingsService.currentUser?.id && - !this.settingsService.currentUser?.is_superuser - ) { - return - } - - let statusMessageGet = this.get( - statusMessage.task_id, - statusMessage.filename - ) - let status = statusMessageGet.status - let created = statusMessageGet.created - - status.updateProgress( - FileStatusPhase.WORKING, - statusMessage.current_progress, - statusMessage.max_progress - ) - if ( - statusMessage.message && - statusMessage.message in FILE_STATUS_MESSAGES - ) { - status.message = FILE_STATUS_MESSAGES[statusMessage.message] - } else if (statusMessage.message) { - status.message = statusMessage.message - } - status.documentId = statusMessage.document_id - - if (statusMessage.status in FileStatusPhase) { - status.phase = FileStatusPhase[statusMessage.status] - } - - switch (status.phase) { - case FileStatusPhase.STARTED: - if (created) this.documentDetectedSubject.next(status) + switch (type) { + case WebsocketStatusType.DOCUMENTS_DELETED: + this.documentDeletedSubject.next(true) break - case FileStatusPhase.SUCCESS: - this.documentConsumptionFinishedSubject.next(status) - break - - case FileStatusPhase.FAILED: - this.documentConsumptionFailedSubject.next(status) - break - - default: + case WebsocketStatusType.STATUS_UPDATE: + this.handleProgressUpdate(messageData as WebsocketProgressMessage) break } } } + handleProgressUpdate(messageData: WebsocketProgressMessage) { + // fallback if backend didn't restrict message + if ( + messageData.owner_id && + messageData.owner_id !== this.settingsService.currentUser?.id && + !this.settingsService.currentUser?.is_superuser + ) { + return + } + + let statusMessageGet = this.get(messageData.task_id, messageData.filename) + let status = statusMessageGet.status + let created = statusMessageGet.created + + status.updateProgress( + FileStatusPhase.WORKING, + messageData.current_progress, + messageData.max_progress + ) + if (messageData.message && messageData.message in FILE_STATUS_MESSAGES) { + status.message = FILE_STATUS_MESSAGES[messageData.message] + } else if (messageData.message) { + status.message = messageData.message + } + status.documentId = messageData.document_id + + if (messageData.status in FileStatusPhase) { + status.phase = FileStatusPhase[messageData.status] + } + + switch (status.phase) { + case FileStatusPhase.STARTED: + if (created) this.documentDetectedSubject.next(status) + break + + case FileStatusPhase.SUCCESS: + this.documentConsumptionFinishedSubject.next(status) + break + + case FileStatusPhase.FAILED: + this.documentConsumptionFailedSubject.next(status) + break + + default: + break + } + } + fail(status: FileStatus, message: string) { status.message = message status.phase = FileStatusPhase.FAILED @@ -250,4 +269,8 @@ export class ConsumerStatusService { onDocumentDetected() { return this.documentDetectedSubject } + + onDocumentDeleted() { + return this.documentDeletedSubject + } } diff --git a/src/documents/bulk_edit.py b/src/documents/bulk_edit.py index f0522eddc..0aadcc295 100644 --- a/src/documents/bulk_edit.py +++ b/src/documents/bulk_edit.py @@ -24,6 +24,7 @@ from documents.models import Document from documents.models import DocumentType from documents.models import StoragePath from documents.permissions import set_permissions_for_object +from documents.plugins.helpers import DocumentsStatusManager from documents.tasks import bulk_update_documents from documents.tasks import consume_file from documents.tasks import update_document_content_maybe_archive_file @@ -219,6 +220,9 @@ def delete(doc_ids: list[int]) -> Literal["OK"]: with index.open_index_writer() as writer: for id in doc_ids: index.remove_document_by_id(writer, id) + + status_mgr = DocumentsStatusManager() + status_mgr.send_documents_deleted(doc_ids) except Exception as e: if "Data too long for column" in str(e): logger.warning( diff --git a/src/documents/plugins/helpers.py b/src/documents/plugins/helpers.py index 20380b852..3315ec60e 100644 --- a/src/documents/plugins/helpers.py +++ b/src/documents/plugins/helpers.py @@ -15,16 +15,14 @@ class ProgressStatusOptions(str, enum.Enum): FAILED = "FAILED" -class ProgressManager: +class BaseStatusManager: """ Handles sending of progress information via the channel layer, with proper management of the open/close of the layer to ensure messages go out and everything is cleaned up """ - def __init__(self, filename: str, task_id: str | None = None) -> None: - self.filename = filename + def __init__(self) -> None: self._channel: RedisPubSubChannelLayer | None = None - self.task_id = task_id def __enter__(self): self.open() @@ -49,6 +47,24 @@ class ProgressManager: async_to_sync(self._channel.flush) self._channel = None + def send(self, payload: dict[str, str | int | None]) -> None: + # Ensure the layer is open + self.open() + + # Just for IDEs + if TYPE_CHECKING: + assert self._channel is not None + + # Construct and send the update + async_to_sync(self._channel.group_send)("status_updates", payload) + + +class ProgressManager(BaseStatusManager): + def __init__(self, filename: str | None = None, task_id: str | None = None) -> None: + super().__init__() + self.filename = filename + self.task_id = task_id + def send_progress( self, status: ProgressStatusOptions, @@ -57,13 +73,6 @@ class ProgressManager: max_progress: int, extra_args: dict[str, str | int | None] | None = None, ) -> None: - # Ensure the layer is open - self.open() - - # Just for IDEs - if TYPE_CHECKING: - assert self._channel is not None - payload = { "type": "status_update", "data": { @@ -78,5 +87,16 @@ class ProgressManager: if extra_args is not None: payload["data"].update(extra_args) - # Construct and send the update - async_to_sync(self._channel.group_send)("status_updates", payload) + self.send(payload) + + +class DocumentsStatusManager(BaseStatusManager): + def send_documents_deleted(self, documents: list[int]) -> None: + payload = { + "type": "documents_deleted", + "data": { + "documents": documents, + }, + } + + self.send(payload) diff --git a/src/paperless/consumers.py b/src/paperless/consumers.py index cf1a3b548..c72b58aa7 100644 --- a/src/paperless/consumers.py +++ b/src/paperless/consumers.py @@ -41,4 +41,10 @@ class StatusConsumer(WebsocketConsumer): self.close() else: if self._is_owner_or_unowned(event["data"]): - self.send(json.dumps(event["data"])) + self.send(json.dumps(event)) + + def documents_deleted(self, event): + if not self._authenticated(): + self.close() + else: + self.send(json.dumps(event)) diff --git a/src/paperless/tests/test_websockets.py b/src/paperless/tests/test_websockets.py index bf838821a..5ba909d1c 100644 --- a/src/paperless/tests/test_websockets.py +++ b/src/paperless/tests/test_websockets.py @@ -5,6 +5,9 @@ from channels.testing import WebsocketCommunicator from django.test import TestCase from django.test import override_settings +from documents.plugins.helpers import DocumentsStatusManager +from documents.plugins.helpers import ProgressManager +from documents.plugins.helpers import ProgressStatusOptions from paperless.asgi import application TEST_CHANNEL_LAYERS = { @@ -22,6 +25,39 @@ class TestWebSockets(TestCase): self.assertFalse(connected) await communicator.disconnect() + @mock.patch("paperless.consumers.StatusConsumer.close") + @mock.patch("paperless.consumers.StatusConsumer._authenticated") + async def test_close_on_no_auth(self, _authenticated, mock_close): + _authenticated.return_value = True + + communicator = WebsocketCommunicator(application, "/ws/status/") + connected, subprotocol = await communicator.connect() + self.assertTrue(connected) + + message = {"type": "status_update", "data": {"task_id": "test"}} + + _authenticated.return_value = False + + channel_layer = get_channel_layer() + await channel_layer.group_send( + "status_updates", + message, + ) + await communicator.receive_nothing() + + mock_close.assert_called_once() + mock_close.reset_mock() + + message = {"type": "documents_deleted", "data": {"documents": [1, 2, 3]}} + + await channel_layer.group_send( + "status_updates", + message, + ) + await communicator.receive_nothing() + + mock_close.assert_called_once() + @mock.patch("paperless.consumers.StatusConsumer._authenticated") async def test_auth(self, _authenticated): _authenticated.return_value = True @@ -33,19 +69,19 @@ class TestWebSockets(TestCase): await communicator.disconnect() @mock.patch("paperless.consumers.StatusConsumer._authenticated") - async def test_receive(self, _authenticated): + async def test_receive_status_update(self, _authenticated): _authenticated.return_value = True communicator = WebsocketCommunicator(application, "/ws/status/") connected, subprotocol = await communicator.connect() self.assertTrue(connected) - message = {"task_id": "test"} + message = {"type": "status_update", "data": {"task_id": "test"}} channel_layer = get_channel_layer() await channel_layer.group_send( "status_updates", - {"type": "status_update", "data": message}, + message, ) response = await communicator.receive_json_from() @@ -53,3 +89,73 @@ class TestWebSockets(TestCase): self.assertEqual(response, message) await communicator.disconnect() + + @mock.patch("paperless.consumers.StatusConsumer._authenticated") + async def test_receive_documents_deleted(self, _authenticated): + _authenticated.return_value = True + + communicator = WebsocketCommunicator(application, "/ws/status/") + connected, subprotocol = await communicator.connect() + self.assertTrue(connected) + + message = {"type": "documents_deleted", "data": {"documents": [1, 2, 3]}} + + channel_layer = get_channel_layer() + await channel_layer.group_send( + "status_updates", + message, + ) + + response = await communicator.receive_json_from() + + self.assertEqual(response, message) + + await communicator.disconnect() + + @mock.patch("channels.layers.InMemoryChannelLayer.group_send") + def test_manager_send_progress(self, mock_group_send): + with ProgressManager(task_id="test") as manager: + manager.send_progress( + ProgressStatusOptions.STARTED, + "Test message", + 1, + 10, + extra_args={ + "foo": "bar", + }, + ) + + message = mock_group_send.call_args[0][1] + + self.assertEqual( + message, + { + "type": "status_update", + "data": { + "filename": None, + "task_id": "test", + "current_progress": 1, + "max_progress": 10, + "status": ProgressStatusOptions.STARTED, + "message": "Test message", + "foo": "bar", + }, + }, + ) + + @mock.patch("channels.layers.InMemoryChannelLayer.group_send") + def test_manager_send_documents_deleted(self, mock_group_send): + with DocumentsStatusManager() as manager: + manager.send_documents_deleted([1, 2, 3]) + + message = mock_group_send.call_args[0][1] + + self.assertEqual( + message, + { + "type": "documents_deleted", + "data": { + "documents": [1, 2, 3], + }, + }, + ) From e08606af6e686ab076850c5fae1aa162a6cbe0d5 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Thu, 6 Feb 2025 23:01:48 -0800 Subject: [PATCH 08/12] Enhancement: date picker and date filter dropdown improvements (#9033) --- .../e2e/document-list/document-list.spec.ts | 4 +- .../requests/api-document-list3.har | 4 +- src-ui/messages.xlf | 219 +++++++++++------- ...ustom-fields-query-dropdown.component.html | 9 +- ...ustom-fields-query-dropdown.component.scss | 6 + .../custom-fields-query-dropdown.component.ts | 2 + .../dates-dropdown.component.html | 66 ++++-- .../dates-dropdown.component.scss | 6 + .../dates-dropdown.component.spec.ts | 70 +++--- .../dates-dropdown.component.ts | 114 ++++----- .../common/input/date/date.component.html | 8 +- .../common/input/date/date.component.scss | 5 + .../common/input/date/date.component.ts | 2 + .../filter-editor.component.html | 8 +- .../filter-editor.component.spec.ts | 92 ++++++-- .../filter-editor/filter-editor.component.ts | 84 ++++--- src-ui/src/app/data/filter-rule-type.ts | 29 +++ src/documents/filters.py | 14 +- ...062_alter_savedviewfilterrule_rule_type.py | 69 ++++++ src/documents/models.py | 4 + 20 files changed, 561 insertions(+), 254 deletions(-) create mode 100644 src/documents/migrations/1062_alter_savedviewfilterrule_rule_type.py diff --git a/src-ui/e2e/document-list/document-list.spec.ts b/src-ui/e2e/document-list/document-list.spec.ts index 7719873d3..cd1a4c54e 100644 --- a/src-ui/e2e/document-list/document-list.spec.ts +++ b/src-ui/e2e/document-list/document-list.spec.ts @@ -83,9 +83,9 @@ test('date filtering', async ({ page }) => { await page.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' }) await page.goto('/documents') await page.getByRole('button', { name: 'Dates' }).click() - await page.getByRole('menuitem', { name: 'Last 3 months' }).first().click() + await page.getByRole('menuitem', { name: 'Within 3 months' }).first().click() await expect(page.locator('pngx-document-list')).toHaveText(/one document/i) - await page.getByRole('menuitem', { name: 'Last 3 months' }).first().click() + await page.getByRole('menuitem', { name: 'Within 3 months' }).first().click() await page.getByLabel('Datesselected').getByRole('button').first().click() await page.getByRole('combobox', { name: 'Select month' }).selectOption('12') await page.getByRole('combobox', { name: 'Select year' }).selectOption('2022') diff --git a/src-ui/e2e/document-list/requests/api-document-list3.har b/src-ui/e2e/document-list/requests/api-document-list3.har index 6d5d1808f..291915a65 100644 --- a/src-ui/e2e/document-list/requests/api-document-list3.har +++ b/src-ui/e2e/document-list/requests/api-document-list3.har @@ -3687,7 +3687,7 @@ "time": 1.501, "request": { "method": "GET", - "url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&created__date__gt=2022-12-11", + "url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&created__date__gte=2022-12-11", "httpVersion": "HTTP/1.1", "cookies": [], "headers": [ @@ -3721,7 +3721,7 @@ "value": "true" }, { - "name": "created__date__gt", + "name": "created__date__gte", "value": "2022-12-11" } ], diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index 9983e6b55..caab96d4b 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -1167,7 +1167,7 @@ src/app/components/document-list/filter-editor/filter-editor.component.ts - 166 + 170 @@ -3318,48 +3318,114 @@ 93 - - True + + Today + + src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html + 39 + + + src/app/components/common/dates-dropdown/dates-dropdown.component.html + 50 + + + src/app/components/common/dates-dropdown/dates-dropdown.component.html + 76 + + + src/app/components/common/dates-dropdown/dates-dropdown.component.html + 126 + + + src/app/components/common/dates-dropdown/dates-dropdown.component.html + 152 + + + src/app/components/common/input/date/date.component.html + 21 + + + + Close src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html 40 - src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html + src/app/components/common/dates-dropdown/dates-dropdown.component.html + 51 + + + src/app/components/common/dates-dropdown/dates-dropdown.component.html 77 + + src/app/components/common/dates-dropdown/dates-dropdown.component.html + 127 + + + src/app/components/common/dates-dropdown/dates-dropdown.component.html + 153 + + + src/app/components/common/input/date/date.component.html + 22 + + + src/app/components/document-detail/document-detail.component.html + 94 + + + src/app/components/document-detail/document-detail.component.ts + 1375 + + + src/app/guards/dirty-saved-view.guard.ts + 37 + + + + True src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html - 83 + 47 + + + src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html + 84 + + + src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html + 90 False src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html - 41 + 48 src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html - 78 + 85 src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html - 84 + 91 Search docs... src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html - 100 + 107 Any src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html - 132 + 139 src/app/components/common/filterable-dropdown/filterable-dropdown.component.html @@ -3370,7 +3436,7 @@ All src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html - 134 + 141 src/app/components/common/filterable-dropdown/filterable-dropdown.component.html @@ -3397,21 +3463,21 @@ Not src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html - 137 + 144 Add query src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html - 156 + 163 Add expression src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html - 159 + 166 @@ -3422,36 +3488,36 @@ src/app/components/common/dates-dropdown/dates-dropdown.component.html - 89 + 101 - - After + + From src/app/components/common/dates-dropdown/dates-dropdown.component.html 42 src/app/components/common/dates-dropdown/dates-dropdown.component.html - 106 + 118 - - Before + + To src/app/components/common/dates-dropdown/dates-dropdown.component.html - 62 + 68 src/app/components/common/dates-dropdown/dates-dropdown.component.html - 126 + 144 Added src/app/components/common/dates-dropdown/dates-dropdown.component.html - 74 + 86 src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts @@ -3470,41 +3536,33 @@ 93 - - Last 7 days + + Within 1 week src/app/components/common/dates-dropdown/dates-dropdown.component.ts 67 - - Last month + + Within 1 month src/app/components/common/dates-dropdown/dates-dropdown.component.ts 72 - - src/app/pipes/custom-date.pipe.ts - 19 - - - Last 3 months + + Within 3 months src/app/components/common/dates-dropdown/dates-dropdown.component.ts 77 - - Last year + + Within 1 year src/app/components/common/dates-dropdown/dates-dropdown.component.ts 82 - - src/app/pipes/custom-date.pipe.ts - 14 - Matching algorithm @@ -5071,14 +5129,14 @@ Invalid date. src/app/components/common/input/date/date.component.html - 25 + 31 Suggestions: src/app/components/common/input/date/date.component.html - 31 + 37 src/app/components/common/input/select/select.component.html @@ -5093,7 +5151,7 @@ Filter documents with this src/app/components/common/input/date/date.component.ts - 121 + 123 src/app/components/common/input/select/select.component.ts @@ -6257,21 +6315,6 @@ 70 - - Close - - src/app/components/document-detail/document-detail.component.html - 94 - - - src/app/components/document-detail/document-detail.component.ts - 1375 - - - src/app/guards/dirty-saved-view.guard.ts - 37 - - Previous @@ -6298,7 +6341,7 @@ src/app/components/document-list/filter-editor/filter-editor.component.ts - 154 + 158 src/app/data/document.ts @@ -6926,7 +6969,7 @@ src/app/components/document-list/filter-editor/filter-editor.component.ts - 162 + 166 @@ -7528,7 +7571,7 @@ src/app/components/document-list/filter-editor/filter-editor.component.ts - 159 + 163 src/app/data/document.ts @@ -7717,147 +7760,147 @@ Title & content src/app/components/document-list/filter-editor/filter-editor.component.ts - 157 + 161 More like src/app/components/document-list/filter-editor/filter-editor.component.ts - 172 + 176 equals src/app/components/document-list/filter-editor/filter-editor.component.ts - 178 + 182 is empty src/app/components/document-list/filter-editor/filter-editor.component.ts - 182 + 186 is not empty src/app/components/document-list/filter-editor/filter-editor.component.ts - 186 + 190 greater than src/app/components/document-list/filter-editor/filter-editor.component.ts - 190 + 194 less than src/app/components/document-list/filter-editor/filter-editor.component.ts - 194 + 198 Correspondent: src/app/components/document-list/filter-editor/filter-editor.component.ts - 226,228 + 230,232 Without correspondent src/app/components/document-list/filter-editor/filter-editor.component.ts - 230 + 234 Document type: src/app/components/document-list/filter-editor/filter-editor.component.ts - 236,238 + 240,242 Without document type src/app/components/document-list/filter-editor/filter-editor.component.ts - 240 + 244 Storage path: src/app/components/document-list/filter-editor/filter-editor.component.ts - 246,248 + 250,252 Without storage path src/app/components/document-list/filter-editor/filter-editor.component.ts - 250 + 254 Tag: src/app/components/document-list/filter-editor/filter-editor.component.ts - 254,256 + 258,260 Without any tag src/app/components/document-list/filter-editor/filter-editor.component.ts - 260 + 264 Custom fields query src/app/components/document-list/filter-editor/filter-editor.component.ts - 264 + 268 Title: src/app/components/document-list/filter-editor/filter-editor.component.ts - 267 + 271 ASN: src/app/components/document-list/filter-editor/filter-editor.component.ts - 270 + 274 Owner: src/app/components/document-list/filter-editor/filter-editor.component.ts - 273 + 277 Owner not in: src/app/components/document-list/filter-editor/filter-editor.component.ts - 276 + 280 Without an owner src/app/components/document-list/filter-editor/filter-editor.component.ts - 279 + 283 @@ -9149,6 +9192,13 @@ 36 + + Last year + + src/app/pipes/custom-date.pipe.ts + 14 + + %s years ago @@ -9156,6 +9206,13 @@ 15 + + Last month + + src/app/pipes/custom-date.pipe.ts + 19 + + %s months ago diff --git a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html index 2f119b074..742dd8e8a 100644 --- a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html +++ b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html @@ -29,10 +29,17 @@ + #d="ngbDatepicker" + [footerTemplate]="datePickerFooterTemplate" /> + +
+ + +
+
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Float || getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Integer) { } @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Boolean) { diff --git a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.scss b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.scss index f38bb4002..2f9e2c45e 100644 --- a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.scss +++ b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.scss @@ -41,3 +41,9 @@ min-width: 140px; } } + +.btn-group-xs { + > .btn { + border-radius: 0.15rem; + } +} diff --git a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts index 0fa7fe536..324fd27a5 100644 --- a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts +++ b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts @@ -241,6 +241,8 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm customFields: CustomField[] = [] + public readonly today: string = new Date().toISOString().split('T')[0] + constructor(protected customFieldsService: CustomFieldsService) { super() this.selectionModel = new CustomFieldQueriesModel() 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 dcab4606d..c3ff61ba8 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 @@ -1,5 +1,5 @@
- + +
+ + +
+
@@ -95,40 +107,52 @@ diff --git a/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.scss b/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.scss index e101a131d..ebd34b29a 100644 --- a/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.scss +++ b/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.scss @@ -41,3 +41,9 @@ } } } + +.btn-group-xs { + > .btn { + border-radius: 0.15rem; + } +} diff --git a/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.spec.ts b/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.spec.ts index 10762264a..1f6ee909e 100644 --- a/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.spec.ts +++ b/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.spec.ts @@ -61,7 +61,7 @@ describe('DatesDropdownComponent', () => { it('should support date input, emit change', fakeAsync(() => { let result: string - component.createdDateAfterChange.subscribe((date) => (result = date)) + component.createdDateFromChange.subscribe((date) => (result = date)) const input: HTMLInputElement = fixture.nativeElement.querySelector('input') input.value = '5/30/2023' input.dispatchEvent(new Event('change')) @@ -83,68 +83,68 @@ describe('DatesDropdownComponent', () => { let result: DateSelection component.datesSet.subscribe((date) => (result = date)) component.setCreatedRelativeDate(null) - component.setCreatedRelativeDate(RelativeDate.LAST_7_DAYS) + component.setCreatedRelativeDate(RelativeDate.WITHIN_1_WEEK) component.setAddedRelativeDate(null) - component.setAddedRelativeDate(RelativeDate.LAST_7_DAYS) + component.setAddedRelativeDate(RelativeDate.WITHIN_1_WEEK) tick(500) expect(result).toEqual({ - createdAfter: null, - createdBefore: null, - createdRelativeDateID: RelativeDate.LAST_7_DAYS, - addedAfter: null, - addedBefore: null, - addedRelativeDateID: RelativeDate.LAST_7_DAYS, + createdFrom: null, + createdTo: null, + createdRelativeDateID: RelativeDate.WITHIN_1_WEEK, + addedFrom: null, + addedTo: null, + addedRelativeDateID: RelativeDate.WITHIN_1_WEEK, }) })) it('should support report if active', () => { - component.createdRelativeDate = RelativeDate.LAST_7_DAYS + component.createdRelativeDate = RelativeDate.WITHIN_1_WEEK expect(component.isActive).toBeTruthy() component.createdRelativeDate = null - component.createdDateAfter = '2023-05-30' + component.createdDateFrom = '2023-05-30' expect(component.isActive).toBeTruthy() - component.createdDateAfter = null - component.createdDateBefore = '2023-05-30' + component.createdDateFrom = null + component.createdDateTo = '2023-05-30' expect(component.isActive).toBeTruthy() - component.createdDateBefore = null + component.createdDateTo = null - component.addedRelativeDate = RelativeDate.LAST_7_DAYS + component.addedRelativeDate = RelativeDate.WITHIN_1_WEEK expect(component.isActive).toBeTruthy() component.addedRelativeDate = null - component.addedDateAfter = '2023-05-30' + component.addedDateFrom = '2023-05-30' expect(component.isActive).toBeTruthy() - component.addedDateAfter = null - component.addedDateBefore = '2023-05-30' + component.addedDateFrom = null + component.addedDateTo = '2023-05-30' expect(component.isActive).toBeTruthy() - component.addedDateBefore = null + component.addedDateTo = null expect(component.isActive).toBeFalsy() }) it('should support reset', () => { - component.createdDateAfter = '2023-05-30' + component.createdDateFrom = '2023-05-30' component.reset() - expect(component.createdDateAfter).toBeNull() + expect(component.createdDateFrom).toBeNull() }) - it('should support clearAfter', () => { - component.createdDateAfter = '2023-05-30' - component.clearCreatedAfter() - expect(component.createdDateAfter).toBeNull() + it('should support clearFrom', () => { + component.createdDateFrom = '2023-05-30' + component.clearCreatedFrom() + expect(component.createdDateFrom).toBeNull() - component.addedDateAfter = '2023-05-30' - component.clearAddedAfter() - expect(component.addedDateAfter).toBeNull() + component.addedDateFrom = '2023-05-30' + component.clearAddedFrom() + expect(component.addedDateFrom).toBeNull() }) - it('should support clearBefore', () => { - component.createdDateBefore = '2023-05-30' - component.clearCreatedBefore() - expect(component.createdDateBefore).toBeNull() + it('should support clearTo', () => { + component.createdDateTo = '2023-05-30' + component.clearCreatedTo() + expect(component.createdDateTo).toBeNull() - component.addedDateBefore = '2023-05-30' - component.clearAddedBefore() - expect(component.addedDateBefore).toBeNull() + component.addedDateTo = '2023-05-30' + component.clearAddedTo() + expect(component.addedDateTo).toBeNull() }) it('should limit keyboard events', () => { 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 356ba510a..e7d506d18 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 @@ -23,19 +23,19 @@ import { popperOptionsReenablePreventOverflow } from 'src/app/utils/popper-optio import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component' export interface DateSelection { - createdBefore?: string - createdAfter?: string + createdTo?: string + createdFrom?: string createdRelativeDateID?: number - addedBefore?: string - addedAfter?: string + addedTo?: string + addedFrom?: string addedRelativeDateID?: number } export enum RelativeDate { - LAST_7_DAYS = 0, - LAST_MONTH = 1, - LAST_3_MONTHS = 2, - LAST_YEAR = 3, + WITHIN_1_WEEK = 0, + WITHIN_1_MONTH = 1, + WITHIN_3_MONTHS = 2, + WITHIN_1_YEAR = 3, } @Component({ @@ -63,23 +63,23 @@ export class DatesDropdownComponent implements OnInit, OnDestroy { relativeDates = [ { - id: RelativeDate.LAST_7_DAYS, - name: $localize`Last 7 days`, + id: RelativeDate.WITHIN_1_WEEK, + name: $localize`Within 1 week`, date: new Date().setDate(new Date().getDate() - 7), }, { - id: RelativeDate.LAST_MONTH, - name: $localize`Last month`, + id: RelativeDate.WITHIN_1_MONTH, + name: $localize`Within 1 month`, date: new Date().setMonth(new Date().getMonth() - 1), }, { - id: RelativeDate.LAST_3_MONTHS, - name: $localize`Last 3 months`, + id: RelativeDate.WITHIN_3_MONTHS, + name: $localize`Within 3 months`, date: new Date().setMonth(new Date().getMonth() - 3), }, { - id: RelativeDate.LAST_YEAR, - name: $localize`Last year`, + id: RelativeDate.WITHIN_1_YEAR, + name: $localize`Within 1 year`, date: new Date().setFullYear(new Date().getFullYear() - 1), }, ] @@ -88,16 +88,16 @@ export class DatesDropdownComponent implements OnInit, OnDestroy { // created @Input() - createdDateBefore: string + createdDateTo: string @Output() - createdDateBeforeChange = new EventEmitter() + createdDateToChange = new EventEmitter() @Input() - createdDateAfter: string + createdDateFrom: string @Output() - createdDateAfterChange = new EventEmitter() + createdDateFromChange = new EventEmitter() @Input() createdRelativeDate: RelativeDate @@ -107,16 +107,16 @@ export class DatesDropdownComponent implements OnInit, OnDestroy { // added @Input() - addedDateBefore: string + addedDateTo: string @Output() - addedDateBeforeChange = new EventEmitter() + addedDateToChange = new EventEmitter() @Input() - addedDateAfter: string + addedDateFrom: string @Output() - addedDateAfterChange = new EventEmitter() + addedDateFromChange = new EventEmitter() @Input() addedRelativeDate: RelativeDate @@ -133,14 +133,16 @@ export class DatesDropdownComponent implements OnInit, OnDestroy { @Input() disabled: boolean = false + public readonly today: string = new Date().toISOString().split('T')[0] + get isActive(): boolean { return ( this.createdRelativeDate !== null || - this.createdDateAfter?.length > 0 || - this.createdDateBefore?.length > 0 || + this.createdDateFrom?.length > 0 || + this.createdDateTo?.length > 0 || this.addedRelativeDate !== null || - this.addedDateAfter?.length > 0 || - this.addedDateBefore?.length > 0 + this.addedDateFrom?.length > 0 || + this.addedDateTo?.length > 0 ) } @@ -161,42 +163,42 @@ export class DatesDropdownComponent implements OnInit, OnDestroy { } reset() { - this.createdDateBefore = null - this.createdDateAfter = null + this.createdDateTo = null + this.createdDateFrom = null this.createdRelativeDate = null - this.addedDateBefore = null - this.addedDateAfter = null + this.addedDateTo = null + this.addedDateFrom = null this.addedRelativeDate = null this.onChange() } setCreatedRelativeDate(rd: RelativeDate) { - this.createdDateBefore = null - this.createdDateAfter = null + this.createdDateTo = null + this.createdDateFrom = null this.createdRelativeDate = this.createdRelativeDate == rd ? null : rd this.onChange() } setAddedRelativeDate(rd: RelativeDate) { - this.addedDateBefore = null - this.addedDateAfter = null + this.addedDateTo = null + this.addedDateFrom = null this.addedRelativeDate = this.addedRelativeDate == rd ? null : rd this.onChange() } onChange() { - this.createdDateBeforeChange.emit(this.createdDateBefore) - this.createdDateAfterChange.emit(this.createdDateAfter) + this.createdDateToChange.emit(this.createdDateTo) + this.createdDateFromChange.emit(this.createdDateFrom) this.createdRelativeDateChange.emit(this.createdRelativeDate) - this.addedDateBeforeChange.emit(this.addedDateBefore) - this.addedDateAfterChange.emit(this.addedDateAfter) + this.addedDateToChange.emit(this.addedDateTo) + this.addedDateFromChange.emit(this.addedDateFrom) this.addedRelativeDateChange.emit(this.addedRelativeDate) this.datesSet.emit({ - createdAfter: this.createdDateAfter, - createdBefore: this.createdDateBefore, + createdFrom: this.createdDateFrom, + createdTo: this.createdDateTo, createdRelativeDateID: this.createdRelativeDate, - addedAfter: this.addedDateAfter, - addedBefore: this.addedDateBefore, + addedFrom: this.addedDateFrom, + addedTo: this.addedDateTo, addedRelativeDateID: this.addedRelativeDate, }) } @@ -205,30 +207,30 @@ export class DatesDropdownComponent implements OnInit, OnDestroy { this.createdRelativeDate = null this.addedRelativeDate = null this.datesSetDebounce$.next({ - createdAfter: this.createdDateAfter, - createdBefore: this.createdDateBefore, - addedAfter: this.addedDateAfter, - addedBefore: this.addedDateBefore, + createdAfter: this.createdDateFrom, + createdBefore: this.createdDateTo, + addedAfter: this.addedDateFrom, + addedBefore: this.addedDateTo, }) } - clearCreatedBefore() { - this.createdDateBefore = null + clearCreatedTo() { + this.createdDateTo = null this.onChange() } - clearCreatedAfter() { - this.createdDateAfter = null + clearCreatedFrom() { + this.createdDateFrom = null this.onChange() } - clearAddedBefore() { - this.addedDateBefore = null + clearAddedTo() { + this.addedDateTo = null this.onChange() } - clearAddedAfter() { - this.addedDateAfter = null + clearAddedFrom() { + this.addedDateFrom = null this.onChange() } diff --git a/src-ui/src/app/components/common/input/date/date.component.html b/src-ui/src/app/components/common/input/date/date.component.html index 8f386e2c8..3221677fc 100644 --- a/src-ui/src/app/components/common/input/date/date.component.html +++ b/src-ui/src/app/components/common/input/date/date.component.html @@ -12,10 +12,16 @@
+ name="dp" [(ngModel)]="value" ngbDatepicker #datePicker="ngbDatepicker" #datePickerContent="ngModel" [disabled]="disabled" [footerTemplate]="datePickerFooterTemplate"> + +
+ + +
+
@if (showFilter) {
    + diff --git a/src-ui/src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.scss b/src-ui/src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.scss new file mode 100644 index 000000000..2332e710d --- /dev/null +++ b/src-ui/src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.scss @@ -0,0 +1,22 @@ +.dropdown-menu { + width: var(--pngx-toast-max-width); +} + +.dropdown-menu .scroll-list { + max-height: 500px; + overflow-y: auto; +} + +.dropdown-toggle::after { + display: none; +} + +.dropdown-item { + white-space: initial; +} + +@media screen and (max-width: 400px) { + :host ::ng-deep .dropdown-menu-end { + right: -3rem; + } +} diff --git a/src-ui/src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.spec.ts b/src-ui/src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.spec.ts new file mode 100644 index 000000000..33b948f30 --- /dev/null +++ b/src-ui/src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.spec.ts @@ -0,0 +1,112 @@ +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' +import { provideHttpClientTesting } from '@angular/common/http/testing' +import { + ComponentFixture, + TestBed, + discardPeriodicTasks, + fakeAsync, + flush, +} from '@angular/core/testing' +import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' +import { Subject } from 'rxjs' +import { Toast, ToastService } from 'src/app/services/toast.service' +import { ToastsDropdownComponent } from './toasts-dropdown.component' + +const toasts = [ + { + id: 'abc-123', + content: 'foo bar', + delay: 5000, + }, + { + id: 'def-123', + content: 'Error 1 content', + delay: 5000, + error: 'Error 1 string', + }, + { + id: 'ghi-123', + content: 'Error 2 content', + delay: 5000, + error: { + url: 'https://example.com', + status: 500, + statusText: 'Internal Server Error', + message: 'Internal server error 500 message', + error: { detail: 'Error 2 message details' }, + }, + }, +] + +describe('ToastsDropdownComponent', () => { + let component: ToastsDropdownComponent + let fixture: ComponentFixture + let toastService: ToastService + let toastsSubject: Subject = new Subject() + + beforeEach(async () => { + TestBed.configureTestingModule({ + imports: [ + ToastsDropdownComponent, + NgxBootstrapIconsModule.pick(allIcons), + ], + providers: [ + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ], + }).compileComponents() + + fixture = TestBed.createComponent(ToastsDropdownComponent) + toastService = TestBed.inject(ToastService) + jest.spyOn(toastService, 'getToasts').mockReturnValue(toastsSubject) + + component = fixture.componentInstance + + fixture.detectChanges() + }) + + it('should call getToasts and return toasts', fakeAsync(() => { + const spy = jest.spyOn(toastService, 'getToasts') + + component.ngOnInit() + toastsSubject.next(toasts) + fixture.detectChanges() + + expect(spy).toHaveBeenCalled() + expect(component.toasts).toContainEqual({ + id: 'abc-123', + content: 'foo bar', + delay: 5000, + }) + + component.ngOnDestroy() + flush() + discardPeriodicTasks() + })) + + it('should show a toast', fakeAsync(() => { + component.ngOnInit() + toastsSubject.next(toasts) + fixture.detectChanges() + + expect(fixture.nativeElement.textContent).toContain('foo bar') + + component.ngOnDestroy() + flush() + discardPeriodicTasks() + })) + + it('should toggle suppressPopupToasts', fakeAsync((finish) => { + component.ngOnInit() + fixture.detectChanges() + toastsSubject.next(toasts) + + const spy = jest.spyOn(toastService, 'suppressPopupToasts', 'set') + component.onOpenChange(true) + expect(spy).toHaveBeenCalledWith(true) + + component.ngOnDestroy() + flush() + discardPeriodicTasks() + })) +}) diff --git a/src-ui/src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.ts b/src-ui/src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.ts new file mode 100644 index 000000000..c04d758af --- /dev/null +++ b/src-ui/src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.ts @@ -0,0 +1,42 @@ +import { Component, OnDestroy, OnInit } from '@angular/core' +import { + NgbDropdownModule, + NgbProgressbarModule, +} from '@ng-bootstrap/ng-bootstrap' +import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' +import { Subscription } from 'rxjs' +import { Toast, ToastService } from 'src/app/services/toast.service' +import { ToastComponent } from '../../common/toast/toast.component' + +@Component({ + selector: 'pngx-toasts-dropdown', + templateUrl: './toasts-dropdown.component.html', + styleUrls: ['./toasts-dropdown.component.scss'], + imports: [ + ToastComponent, + NgbDropdownModule, + NgbProgressbarModule, + NgxBootstrapIconsModule, + ], +}) +export class ToastsDropdownComponent implements OnInit, OnDestroy { + constructor(public toastService: ToastService) {} + + private subscription: Subscription + + public toasts: Toast[] = [] + + ngOnDestroy(): void { + this.subscription?.unsubscribe() + } + + ngOnInit(): void { + this.subscription = this.toastService.getToasts().subscribe((toasts) => { + this.toasts = [...toasts] + }) + } + + onOpenChange(open: boolean): void { + this.toastService.suppressPopupToasts = open + } +} diff --git a/src-ui/src/app/components/common/toast/toast.component.html b/src-ui/src/app/components/common/toast/toast.component.html new file mode 100644 index 000000000..ede75ddea --- /dev/null +++ b/src-ui/src/app/components/common/toast/toast.component.html @@ -0,0 +1,56 @@ + + @if (autohide) { + + {{ toast.delayRemaining / 1000 | number: '1.0-0' }} seconds + } +
    + @if (!toast.error) { + + } + @if (toast.error) { + + } +
    +

    {{toast.content}}

    + @if (toast.error) { +
    +
    + @if (isDetailedError(toast.error)) { +
    +
    URL
    +
    {{ toast.error.url }}
    +
    Status
    +
    {{ toast.error.status }} {{ toast.error.statusText }}
    +
    Error
    +
    {{ getErrorText(toast.error) }}
    +
    + } +
    +
    + +
    +
    +
    +
    + } + @if (toast.action) { +

    + } +
    + +
    +
    diff --git a/src-ui/src/app/components/common/toast/toast.component.scss b/src-ui/src/app/components/common/toast/toast.component.scss new file mode 100644 index 000000000..3783445de --- /dev/null +++ b/src-ui/src/app/components/common/toast/toast.component.scss @@ -0,0 +1,20 @@ +::ng-deep .toast-body { + position: relative; +} + +::ng-deep .toast.error { + border-color: hsla(350, 79%, 40%, 0.4); // bg-danger +} + +::ng-deep .toast.error .toast-body { + background-color: hsla(350, 79%, 40%, 0.8); // bg-danger + border-top-left-radius: inherit; + border-top-right-radius: inherit; + border-bottom-left-radius: inherit; + border-bottom-right-radius: inherit; +} + +.progress { + background-color: var(--pngx-primary); + opacity: .07; +} diff --git a/src-ui/src/app/components/common/toast/toast.component.spec.ts b/src-ui/src/app/components/common/toast/toast.component.spec.ts new file mode 100644 index 000000000..c5d52a28f --- /dev/null +++ b/src-ui/src/app/components/common/toast/toast.component.spec.ts @@ -0,0 +1,104 @@ +import { + ComponentFixture, + discardPeriodicTasks, + fakeAsync, + flush, + TestBed, + tick, +} from '@angular/core/testing' + +import { Clipboard } from '@angular/cdk/clipboard' +import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' +import { ToastComponent } from './toast.component' + +const toast1 = { + content: 'Error 1 content', + delay: 5000, + error: 'Error 1 string', +} + +const toast2 = { + content: 'Error 2 content', + delay: 5000, + error: { + url: 'https://example.com', + status: 500, + statusText: 'Internal Server Error', + message: 'Internal server error 500 message', + error: { detail: 'Error 2 message details' }, + }, +} + +describe('ToastComponent', () => { + let component: ToastComponent + let fixture: ComponentFixture + let clipboard: Clipboard + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ToastComponent, NgxBootstrapIconsModule.pick(allIcons)], + }).compileComponents() + + fixture = TestBed.createComponent(ToastComponent) + clipboard = TestBed.inject(Clipboard) + component = fixture.componentInstance + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) + + it('should countdown toast', fakeAsync(() => { + component.toast = toast2 + fixture.detectChanges() + component.onShown(toast2) + tick(5000) + expect(component.toast.delayRemaining).toEqual(0) + flush() + discardPeriodicTasks() + })) + + it('should show an error if given with toast', fakeAsync(() => { + component.toast = toast1 + fixture.detectChanges() + + expect(fixture.nativeElement.querySelector('details')).not.toBeNull() + expect(fixture.nativeElement.textContent).toContain('Error 1 content') + + flush() + discardPeriodicTasks() + })) + + it('should show error details, support copy', fakeAsync(() => { + component.toast = toast2 + fixture.detectChanges() + + expect(fixture.nativeElement.querySelector('details')).not.toBeNull() + expect(fixture.nativeElement.textContent).toContain( + 'Error 2 message details' + ) + + const copySpy = jest.spyOn(clipboard, 'copy') + component.copyError(toast2.error) + expect(copySpy).toHaveBeenCalled() + + flush() + discardPeriodicTasks() + })) + + it('should parse error text, add ellipsis', () => { + expect(component.getErrorText(toast2.error)).toEqual( + 'Error 2 message details' + ) + expect(component.getErrorText({ error: 'Error string no detail' })).toEqual( + 'Error string no detail' + ) + expect(component.getErrorText('Error string')).toEqual('') + expect( + component.getErrorText({ error: { message: 'foo error bar' } }) + ).toContain('{"message":"foo error bar"}') + expect( + component.getErrorText({ error: new Array(205).join('a') }) + ).toContain('...') + }) +}) diff --git a/src-ui/src/app/components/common/toast/toast.component.ts b/src-ui/src/app/components/common/toast/toast.component.ts new file mode 100644 index 000000000..5ebfdbe82 --- /dev/null +++ b/src-ui/src/app/components/common/toast/toast.component.ts @@ -0,0 +1,76 @@ +import { Clipboard } from '@angular/cdk/clipboard' +import { DecimalPipe } from '@angular/common' +import { Component, EventEmitter, Input, Output } from '@angular/core' +import { + NgbProgressbarModule, + NgbToastModule, +} from '@ng-bootstrap/ng-bootstrap' +import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' +import { interval, take } from 'rxjs' +import { Toast } from 'src/app/services/toast.service' + +@Component({ + selector: 'pngx-toast', + imports: [ + DecimalPipe, + NgbToastModule, + NgbProgressbarModule, + NgxBootstrapIconsModule, + ], + templateUrl: './toast.component.html', + styleUrl: './toast.component.scss', +}) +export class ToastComponent { + @Input() toast: Toast + + @Input() autohide: boolean = true + + @Output() hidden: EventEmitter = new EventEmitter() + + @Output() close: EventEmitter = new EventEmitter() + + public copied: boolean = false + + constructor(private clipboard: Clipboard) {} + + onShown(toast: Toast) { + if (!this.autohide) return + + const refreshInterval = 150 + const delay = toast.delay - 500 // for fade animation + + interval(refreshInterval) + .pipe(take(Math.round(delay / refreshInterval))) + .subscribe((count) => { + toast.delayRemaining = Math.max( + 0, + delay - refreshInterval * (count + 1) + ) + }) + } + + public isDetailedError(error: any): boolean { + return ( + typeof error === 'object' && + 'status' in error && + 'statusText' in error && + 'url' in error && + 'message' in error && + 'error' in error + ) + } + + public copyError(error: any) { + this.clipboard.copy(JSON.stringify(error)) + this.copied = true + setTimeout(() => { + this.copied = false + }, 3000) + } + + getErrorText(error: any) { + let text: string = error.error?.detail ?? error.error ?? '' + if (typeof text === 'object') text = JSON.stringify(text) + return `${text.slice(0, 200)}${text.length > 200 ? '...' : ''}` + } +} diff --git a/src-ui/src/app/components/common/toasts/toasts.component.html b/src-ui/src/app/components/common/toasts/toasts.component.html index 36623161b..2178a2023 100644 --- a/src-ui/src/app/components/common/toasts/toasts.component.html +++ b/src-ui/src/app/components/common/toasts/toasts.component.html @@ -1,55 +1,3 @@ -@for (toast of toasts; track toast) { - - - {{ toast.delayRemaining / 1000 | number: '1.0-0' }} seconds -
    - @if (!toast.error) { - - } - @if (toast.error) { - - } -
    -

    {{toast.content}}

    - @if (toast.error) { -
    -
    - @if (isDetailedError(toast.error)) { -
    -
    URL
    -
    {{ toast.error.url }}
    -
    Status
    -
    {{ toast.error.status }} {{ toast.error.statusText }}
    -
    Error
    -
    {{ getErrorText(toast.error) }}
    -
    - } -
    -
    - -
    -
    -
    -
    - } - @if (toast.action) { -

    - } -
    - -
    -
    +@for (toast of toasts; track toast.id) { + } diff --git a/src-ui/src/app/components/common/toasts/toasts.component.scss b/src-ui/src/app/components/common/toasts/toasts.component.scss index 463f96495..e0a069dda 100644 --- a/src-ui/src/app/components/common/toasts/toasts.component.scss +++ b/src-ui/src/app/components/common/toasts/toasts.component.scss @@ -1,7 +1,7 @@ :host { position: fixed; top: 0; - right: 0; + right: calc(50% - (var(--pngx-toast-max-width) / 2)); margin: 0.3em; z-index: 1200; } @@ -9,24 +9,3 @@ .toast:not(.show) { display: block; // this corrects an ng-bootstrap bug that prevented animations } - -::ng-deep .toast-body { - position: relative; -} - -::ng-deep .toast.error { - border-color: hsla(350, 79%, 40%, 0.4); // bg-danger -} - -::ng-deep .toast.error .toast-body { - background-color: hsla(350, 79%, 40%, 0.8); // bg-danger - border-top-left-radius: inherit; - border-top-right-radius: inherit; - border-bottom-left-radius: inherit; - border-bottom-right-radius: inherit; -} - -.progress { - background-color: var(--pngx-primary); - opacity: .07; -} diff --git a/src-ui/src/app/components/common/toasts/toasts.component.spec.ts b/src-ui/src/app/components/common/toasts/toasts.component.spec.ts index 449396134..bbea04c9c 100644 --- a/src-ui/src/app/components/common/toasts/toasts.component.spec.ts +++ b/src-ui/src/app/components/common/toasts/toasts.component.spec.ts @@ -1,58 +1,33 @@ -import { Clipboard } from '@angular/cdk/clipboard' import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' import { provideHttpClientTesting } from '@angular/common/http/testing' -import { - ComponentFixture, - TestBed, - discardPeriodicTasks, - fakeAsync, - flush, - tick, -} from '@angular/core/testing' +import { ComponentFixture, TestBed } from '@angular/core/testing' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' -import { of } from 'rxjs' -import { ToastService } from 'src/app/services/toast.service' +import { Subject } from 'rxjs' +import { Toast, ToastService } from 'src/app/services/toast.service' import { ToastsComponent } from './toasts.component' -const toasts = [ - { - content: 'foo bar', - delay: 5000, +const toast = { + content: 'Error 2 content', + delay: 5000, + error: { + url: 'https://example.com', + status: 500, + statusText: 'Internal Server Error', + message: 'Internal server error 500 message', + error: { detail: 'Error 2 message details' }, }, - { - content: 'Error 1 content', - delay: 5000, - error: 'Error 1 string', - }, - { - content: 'Error 2 content', - delay: 5000, - error: { - url: 'https://example.com', - status: 500, - statusText: 'Internal Server Error', - message: 'Internal server error 500 message', - error: { detail: 'Error 2 message details' }, - }, - }, -] +} describe('ToastsComponent', () => { let component: ToastsComponent let fixture: ComponentFixture let toastService: ToastService - let clipboard: Clipboard + let toastSubject: Subject = new Subject() beforeEach(async () => { TestBed.configureTestingModule({ imports: [ToastsComponent, NgxBootstrapIconsModule.pick(allIcons)], providers: [ - { - provide: ToastService, - useValue: { - getToasts: () => of(toasts), - }, - }, provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting(), ], @@ -60,95 +35,37 @@ describe('ToastsComponent', () => { fixture = TestBed.createComponent(ToastsComponent) toastService = TestBed.inject(ToastService) - clipboard = TestBed.inject(Clipboard) + jest.replaceProperty(toastService, 'showToast', toastSubject) component = fixture.componentInstance fixture.detectChanges() }) - it('should call getToasts and return toasts', fakeAsync(() => { - const spy = jest.spyOn(toastService, 'getToasts') + it('should create', () => { + expect(component).toBeTruthy() + }) - component.ngOnInit() - fixture.detectChanges() + it('should close toast', () => { + component.toasts = [toast] + const closeToastSpy = jest.spyOn(toastService, 'closeToast') + component.closeToast() + expect(component.toasts).toEqual([]) + expect(closeToastSpy).toHaveBeenCalledWith(toast) + }) - expect(spy).toHaveBeenCalled() - expect(component.toasts).toContainEqual({ - content: 'foo bar', - delay: 5000, - }) - - component.ngOnDestroy() - flush() - discardPeriodicTasks() - })) - - it('should show a toast', fakeAsync(() => { - component.ngOnInit() - fixture.detectChanges() - - expect(fixture.nativeElement.textContent).toContain('foo bar') - - component.ngOnDestroy() - flush() - discardPeriodicTasks() - })) - - it('should countdown toast', fakeAsync(() => { - component.ngOnInit() - fixture.detectChanges() - component.onShow(toasts[0]) - tick(5000) - expect(component.toasts[0].delayRemaining).toEqual(0) - component.ngOnDestroy() - flush() - discardPeriodicTasks() - })) - - it('should show an error if given with toast', fakeAsync(() => { - component.ngOnInit() - fixture.detectChanges() - - expect(fixture.nativeElement.querySelector('details')).not.toBeNull() - expect(fixture.nativeElement.textContent).toContain('Error 1 content') - - component.ngOnDestroy() - flush() - discardPeriodicTasks() - })) - - it('should show error details, support copy', fakeAsync(() => { - component.ngOnInit() - fixture.detectChanges() - - expect(fixture.nativeElement.querySelector('details')).not.toBeNull() - expect(fixture.nativeElement.textContent).toContain( - 'Error 2 message details' + it('should unsubscribe', () => { + const unsubscribeSpy = jest.spyOn( + (component as any).subscription, + 'unsubscribe' ) - - const copySpy = jest.spyOn(clipboard, 'copy') - component.copyError(toasts[2].error) - expect(copySpy).toHaveBeenCalled() - component.ngOnDestroy() - flush() - discardPeriodicTasks() - })) + expect(unsubscribeSpy).toHaveBeenCalled() + }) - it('should parse error text, add ellipsis', () => { - expect(component.getErrorText(toasts[2].error)).toEqual( - 'Error 2 message details' - ) - expect(component.getErrorText({ error: 'Error string no detail' })).toEqual( - 'Error string no detail' - ) - expect(component.getErrorText('Error string')).toEqual('') - expect( - component.getErrorText({ error: { message: 'foo error bar' } }) - ).toContain('{"message":"foo error bar"}') - expect( - component.getErrorText({ error: new Array(205).join('a') }) - ).toContain('...') + it('should subscribe to toastService', () => { + component.ngOnInit() + toastSubject.next(toast) + expect(component.toasts).toEqual([toast]) }) }) diff --git a/src-ui/src/app/components/common/toasts/toasts.component.ts b/src-ui/src/app/components/common/toasts/toasts.component.ts index bb791de11..53b6e1895 100644 --- a/src-ui/src/app/components/common/toasts/toasts.component.ts +++ b/src-ui/src/app/components/common/toasts/toasts.component.ts @@ -1,92 +1,43 @@ -import { Clipboard } from '@angular/cdk/clipboard' -import { DecimalPipe } from '@angular/common' import { Component, OnDestroy, OnInit } from '@angular/core' import { + NgbAccordionModule, NgbProgressbarModule, - NgbToastModule, } from '@ng-bootstrap/ng-bootstrap' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' -import { Subscription, interval, take } from 'rxjs' +import { Subscription } from 'rxjs' import { Toast, ToastService } from 'src/app/services/toast.service' +import { ToastComponent } from '../toast/toast.component' @Component({ selector: 'pngx-toasts', templateUrl: './toasts.component.html', styleUrls: ['./toasts.component.scss'], imports: [ - DecimalPipe, - NgbToastModule, + ToastComponent, + NgbAccordionModule, NgbProgressbarModule, NgxBootstrapIconsModule, ], }) export class ToastsComponent implements OnInit, OnDestroy { - constructor( - public toastService: ToastService, - private clipboard: Clipboard - ) {} + constructor(public toastService: ToastService) {} private subscription: Subscription - public toasts: Toast[] = [] - - public copied: boolean = false - - public seconds: number = 0 + public toasts: Toast[] = [] // array to force change detection ngOnDestroy(): void { this.subscription?.unsubscribe() } ngOnInit(): void { - this.subscription = this.toastService.getToasts().subscribe((toasts) => { - this.toasts = toasts - this.toasts.forEach((t) => { - if (typeof t.error === 'string') { - try { - t.error = JSON.parse(t.error) - } catch (e) {} - } - }) + this.subscription = this.toastService.showToast.subscribe((toast) => { + this.toasts = toast ? [toast] : [] }) } - onShow(toast: Toast) { - const refreshInterval = 150 - const delay = toast.delay - 500 // for fade animation - - interval(refreshInterval) - .pipe(take(delay / refreshInterval)) - .subscribe((count) => { - toast.delayRemaining = Math.max( - 0, - delay - refreshInterval * (count + 1) - ) - }) - } - - public isDetailedError(error: any): boolean { - return ( - typeof error === 'object' && - 'status' in error && - 'statusText' in error && - 'url' in error && - 'message' in error && - 'error' in error - ) - } - - public copyError(error: any) { - this.clipboard.copy(JSON.stringify(error)) - this.copied = true - setTimeout(() => { - this.copied = false - }, 3000) - } - - getErrorText(error: any) { - let text: string = error.error?.detail ?? error.error ?? '' - if (typeof text === 'object') text = JSON.stringify(text) - return `${text.slice(0, 200)}${text.length > 200 ? '...' : ''}` + closeToast() { + this.toastService.closeToast(this.toasts[0]) + this.toasts = [] } } diff --git a/src-ui/src/app/services/toast.service.spec.ts b/src-ui/src/app/services/toast.service.spec.ts index 274ea9db6..ce50b165e 100644 --- a/src-ui/src/app/services/toast.service.spec.ts +++ b/src-ui/src/app/services/toast.service.spec.ts @@ -25,6 +25,33 @@ describe('ToastService', () => { }) }) + it('adds a unique id to toast on show', () => { + const toast = { + title: 'Title', + content: 'content', + delay: 5000, + } + toastService.show(toast) + + toastService.getToasts().subscribe((toasts) => { + expect(toasts[0].id).toBeDefined() + }) + }) + + it('parses error string to object on show', () => { + const toast = { + title: 'Title', + content: 'content', + delay: 5000, + error: 'Error string', + } + toastService.show(toast) + + toastService.getToasts().subscribe((toasts) => { + expect(toasts[0].error).toEqual('Error string') + }) + }) + it('creates toasts with defaults on showInfo and showError', () => { toastService.showInfo('Info toast') toastService.showError('Error toast') @@ -54,4 +81,29 @@ describe('ToastService', () => { expect(toasts).toHaveLength(0) }) }) + + it('clears all toasts on clearToasts', () => { + toastService.showInfo('Info toast') + toastService.showError('Error toast') + toastService.clearToasts() + + toastService.getToasts().subscribe((toasts) => { + expect(toasts).toHaveLength(0) + }) + }) + + it('suppresses popup toasts if suppressPopupToasts is true', (finish) => { + toastService.showToast.subscribe((toast) => { + expect(toast).not.toBeNull() + }) + toastService.showInfo('Info toast') + + toastService.showToast.subscribe((toast) => { + expect(toast).toBeNull() + finish() + }) + + toastService.suppressPopupToasts = true + toastService.showInfo('Info toast') + }) }) diff --git a/src-ui/src/app/services/toast.service.ts b/src-ui/src/app/services/toast.service.ts index 16c534b5c..b917bf94b 100644 --- a/src-ui/src/app/services/toast.service.ts +++ b/src-ui/src/app/services/toast.service.ts @@ -1,7 +1,10 @@ import { Injectable } from '@angular/core' import { Subject } from 'rxjs' +import { v4 as uuidv4 } from 'uuid' export interface Toast { + id?: string + content: string delay: number @@ -22,13 +25,32 @@ export interface Toast { }) export class ToastService { constructor() {} + _suppressPopupToasts: boolean + + set suppressPopupToasts(value: boolean) { + this._suppressPopupToasts = value + this.showToast.next(null) + } private toasts: Toast[] = [] private toastsSubject: Subject = new Subject() + public showToast: Subject = new Subject() + show(toast: Toast) { - this.toasts.push(toast) + if (!toast.id) { + toast.id = uuidv4() + } + if (typeof toast.error === 'string') { + try { + toast.error = JSON.parse(toast.error) + } catch (e) {} + } + this.toasts.unshift(toast) + if (!this._suppressPopupToasts) { + this.showToast.next(toast) + } this.toastsSubject.next(this.toasts) } @@ -46,7 +68,7 @@ export class ToastService { } closeToast(toast: Toast) { - let index = this.toasts.findIndex((t) => t == toast) + let index = this.toasts.findIndex((t) => t.id == toast.id) if (index > -1) { this.toasts.splice(index, 1) this.toastsSubject.next(this.toasts) @@ -56,4 +78,10 @@ export class ToastService { getToasts() { return this.toastsSubject } + + clearToasts() { + this.toasts = [] + this.toastsSubject.next(this.toasts) + this.showToast.next(null) + } } diff --git a/src-ui/src/main.ts b/src-ui/src/main.ts index 83aa12dc2..484a77c82 100644 --- a/src-ui/src/main.ts +++ b/src-ui/src/main.ts @@ -34,6 +34,7 @@ import { arrowRightShort, arrowUpRight, asterisk, + bell, bodyText, boxArrowUp, boxArrowUpRight, @@ -235,6 +236,7 @@ const icons = { arrowRightShort, arrowUpRight, asterisk, + bell, braces, bodyText, boxArrowUp, diff --git a/src-ui/src/styles.scss b/src-ui/src/styles.scss index 1257798b9..589356566 100644 --- a/src-ui/src/styles.scss +++ b/src-ui/src/styles.scss @@ -570,6 +570,10 @@ table.table { color: var(--bs-body-color); } +.toast { + --bs-toast-max-width: var(--pngx-toast-max-width); +} + .alert-primary { --bs-alert-color: var(--bs-primary); --bs-alert-bg: var(--pngx-primary-faded); diff --git a/src-ui/src/theme.scss b/src-ui/src/theme.scss index 9f3c9cbe9..fc8c13d3b 100644 --- a/src-ui/src/theme.scss +++ b/src-ui/src/theme.scss @@ -24,6 +24,10 @@ --pngx-bg-alt2: var(--bs-gray-200); --pngx-bg-disabled: #f7f7f7; --pngx-focus-alpha: 0.3; + --pngx-toast-max-width: 360px; + @media screen and (min-width: 1024px) { + --pngx-toast-max-width: 450px; + } } // Dark text colors allow for maintain contrast with theme color changes From a4999056054d24d6e8086a63a1e1d3e0a6871e35 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Thu, 6 Feb 2025 23:10:19 -0800 Subject: [PATCH 10/12] Fix mistake in process mail error string --- src-ui/messages.xlf | 4 ++-- src-ui/src/app/components/manage/mail/mail.component.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index 220f851c0..b0d6025c7 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -8328,8 +8328,8 @@ 227 - - Error processing mail account "") + + Error processing mail account "" src/app/components/manage/mail/mail.component.ts 232 diff --git a/src-ui/src/app/components/manage/mail/mail.component.ts b/src-ui/src/app/components/manage/mail/mail.component.ts index c97d7e893..8d4222516 100644 --- a/src-ui/src/app/components/manage/mail/mail.component.ts +++ b/src-ui/src/app/components/manage/mail/mail.component.ts @@ -229,7 +229,7 @@ export class MailComponent }, error: (e) => { this.toastService.showError( - $localize`Error processing mail account "${account.name}")`, + $localize`Error processing mail account "${account.name}"`, e ) }, From b274665e2103b216407c25b7d59df993d44c6bdf Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Thu, 6 Feb 2025 23:24:23 -0800 Subject: [PATCH 11/12] Tweak: add name to management toasts --- src-ui/messages.xlf | 4 ++-- .../manage/management-list/management-list.component.ts | 7 +++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index b0d6025c7..1072e89c6 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -8528,8 +8528,8 @@ 183 - - Successfully updated . + + Successfully updated "". src/app/components/manage/management-list/management-list.component.ts 198 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 ecb3e2519..7f7721485 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 @@ -21,7 +21,6 @@ import { MATCHING_ALGORITHMS, MatchingModel, } from 'src/app/data/matching-model' -import { ObjectWithId } from 'src/app/data/object-with-id' import { ObjectWithPermissions } from 'src/app/data/object-with-permissions' import { SortableDirective, @@ -56,7 +55,7 @@ export interface ManagementListColumn { } @Directive() -export abstract class ManagementListComponent +export abstract class ManagementListComponent extends LoadingComponentWithPermissions implements OnInit, OnDestroy { @@ -195,7 +194,7 @@ export abstract class ManagementListComponent activeModal.componentInstance.succeeded.subscribe(() => { this.reloadData() this.toastService.showInfo( - $localize`Successfully updated ${this.typeName}.` + $localize`Successfully updated ${this.typeName} "${object.name}".` ) }) activeModal.componentInstance.failed.subscribe((e) => { @@ -208,7 +207,7 @@ export abstract class ManagementListComponent abstract getDeleteMessage(object: T) - filterDocuments(object: ObjectWithId) { + filterDocuments(object: MatchingModel) { this.documentListViewService.quickFilter([ { rule_type: this.filterRuleType, value: object.id.toString() }, ]) From e560fa3be03d411dcb83de1d69cda7525df05cf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Steinbei=C3=9Fer?= <33968289+gothicVI@users.noreply.github.com> Date: Fri, 7 Feb 2025 18:12:03 +0100 Subject: [PATCH 12/12] Chore: Enable ruff FBT (#8645) --- .ruff.toml | 1 + src/documents/bulk_download.py | 9 ++- src/documents/bulk_edit.py | 3 + src/documents/file_handling.py | 5 +- src/documents/filters.py | 2 +- src/documents/index.py | 6 +- .../commands/convert_mariadb_uuid.py | 2 +- .../management/commands/document_consumer.py | 8 +- .../migrations/1012_fix_archive_files.py | 6 +- src/documents/models.py | 2 +- src/documents/parsers.py | 1 + src/documents/permissions.py | 2 +- src/documents/sanity_checker.py | 2 +- src/documents/signals/handlers.py | 4 + src/documents/tasks.py | 2 +- .../tests/test_api_filter_by_custom_fields.py | 1 + src/documents/tests/test_bulk_edit.py | 9 ++- src/documents/tests/test_consumer.py | 2 +- src/documents/tests/test_delayedquery.py | 6 +- .../tests/test_management_consumer.py | 2 +- .../tests/test_management_exporter.py | 2 +- src/documents/tests/test_matchables.py | 1 + src/documents/views.py | 4 +- src/paperless/views.py | 4 +- src/paperless_mail/mail.py | 35 +++++---- src/paperless_mail/tests/test_mail.py | 76 ++++++++++++++----- src/paperless_tesseract/parsers.py | 1 + 27 files changed, 133 insertions(+), 65 deletions(-) diff --git a/.ruff.toml b/.ruff.toml index d9ca6b321..a29b471c5 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -32,6 +32,7 @@ extend-select = [ "RUF", # https://docs.astral.sh/ruff/rules/#ruff-specific-rules-ruf "FLY", # https://docs.astral.sh/ruff/rules/#flynt-fly "PTH", # https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth + "FBT", # https://docs.astral.sh/ruff/rules/#flake8-boolean-trap-fbt ] ignore = ["DJ001", "SIM105", "RUF012"] diff --git a/src/documents/bulk_download.py b/src/documents/bulk_download.py index 25dfb5a14..5bdc3e74a 100644 --- a/src/documents/bulk_download.py +++ b/src/documents/bulk_download.py @@ -10,7 +10,7 @@ if TYPE_CHECKING: class BulkArchiveStrategy: - def __init__(self, zipf: ZipFile, follow_formatting: bool = False) -> None: + def __init__(self, zipf: ZipFile, *, follow_formatting: bool = False) -> None: self.zipf: ZipFile = zipf if follow_formatting: self.make_unique_filename: Callable[..., Path | str] = ( @@ -22,6 +22,7 @@ class BulkArchiveStrategy: def _filename_only( self, doc: Document, + *, archive: bool = False, folder: str = "", ) -> str: @@ -33,7 +34,10 @@ class BulkArchiveStrategy: """ counter = 0 while True: - filename: str = folder + doc.get_public_filename(archive, counter) + filename: str = folder + doc.get_public_filename( + archive=archive, + counter=counter, + ) if filename in self.zipf.namelist(): counter += 1 else: @@ -42,6 +46,7 @@ class BulkArchiveStrategy: def _formatted_filepath( self, doc: Document, + *, archive: bool = False, folder: str = "", ) -> Path: diff --git a/src/documents/bulk_edit.py b/src/documents/bulk_edit.py index 0aadcc295..f6adfc8a9 100644 --- a/src/documents/bulk_edit.py +++ b/src/documents/bulk_edit.py @@ -245,6 +245,7 @@ def reprocess(doc_ids: list[int]) -> Literal["OK"]: def set_permissions( doc_ids: list[int], set_permissions, + *, owner=None, merge=False, ) -> Literal["OK"]: @@ -309,6 +310,7 @@ def rotate(doc_ids: list[int], degrees: int) -> Literal["OK"]: def merge( doc_ids: list[int], + *, metadata_document_id: int | None = None, delete_originals: bool = False, user: User | None = None, @@ -387,6 +389,7 @@ def merge( def split( doc_ids: list[int], pages: list[list[int]], + *, delete_originals: bool = False, user: User | None = None, ) -> Literal["OK"]: diff --git a/src/documents/file_handling.py b/src/documents/file_handling.py index 4198ecabb..3d1a643df 100644 --- a/src/documents/file_handling.py +++ b/src/documents/file_handling.py @@ -43,7 +43,7 @@ def delete_empty_directories(directory, root): directory = os.path.normpath(os.path.dirname(directory)) -def generate_unique_filename(doc, archive_filename=False): +def generate_unique_filename(doc, *, archive_filename=False): """ Generates a unique filename for doc in settings.ORIGINALS_DIR. @@ -77,7 +77,7 @@ def generate_unique_filename(doc, archive_filename=False): while True: new_filename = generate_filename( doc, - counter, + counter=counter, archive_filename=archive_filename, ) if new_filename == old_filename: @@ -92,6 +92,7 @@ def generate_unique_filename(doc, archive_filename=False): def generate_filename( doc: Document, + *, counter=0, append_gpg=True, archive_filename=False, diff --git a/src/documents/filters.py b/src/documents/filters.py index 142f3f519..fab029312 100644 --- a/src/documents/filters.py +++ b/src/documents/filters.py @@ -97,7 +97,7 @@ class StoragePathFilterSet(FilterSet): class ObjectFilter(Filter): - def __init__(self, exclude=False, in_list=False, field_name=""): + def __init__(self, *, exclude=False, in_list=False, field_name=""): super().__init__() self.exclude = exclude self.in_list = in_list diff --git a/src/documents/index.py b/src/documents/index.py index 4c5afb505..4b11325ff 100644 --- a/src/documents/index.py +++ b/src/documents/index.py @@ -85,7 +85,7 @@ def get_schema() -> Schema: ) -def open_index(recreate=False) -> FileIndex: +def open_index(*, recreate=False) -> FileIndex: try: if exists_in(settings.INDEX_DIR) and not recreate: return open_dir(settings.INDEX_DIR, schema=get_schema()) @@ -101,7 +101,7 @@ def open_index(recreate=False) -> FileIndex: @contextmanager -def open_index_writer(optimize=False) -> AsyncWriter: +def open_index_writer(*, optimize=False) -> AsyncWriter: writer = AsyncWriter(open_index()) try: @@ -425,7 +425,7 @@ def autocomplete( def get_permissions_criterias(user: User | None = None) -> list: - user_criterias = [query.Term("has_owner", False)] + user_criterias = [query.Term("has_owner", text=False)] if user is not None: if user.is_superuser: # superusers see all docs user_criterias = [] diff --git a/src/documents/management/commands/convert_mariadb_uuid.py b/src/documents/management/commands/convert_mariadb_uuid.py index 4000e67cb..76ccf9e76 100644 --- a/src/documents/management/commands/convert_mariadb_uuid.py +++ b/src/documents/management/commands/convert_mariadb_uuid.py @@ -9,7 +9,7 @@ class Command(BaseCommand): # This code is taken almost entirely from https://github.com/wagtail/wagtail/pull/11912 with all credit to the original author. help = "Converts UUID columns from char type to the native UUID type used in MariaDB 10.7+ and Django 5.0+." - def convert_field(self, model, field_name, null=False): + def convert_field(self, model, field_name, *, null=False): if model._meta.get_field(field_name).model != model: # pragma: no cover # Field is inherited from a parent model return diff --git a/src/documents/management/commands/document_consumer.py b/src/documents/management/commands/document_consumer.py index 6b2706733..36dcc7706 100644 --- a/src/documents/management/commands/document_consumer.py +++ b/src/documents/management/commands/document_consumer.py @@ -248,15 +248,15 @@ class Command(BaseCommand): return if settings.CONSUMER_POLLING == 0 and INotify: - self.handle_inotify(directory, recursive, options["testing"]) + self.handle_inotify(directory, recursive, is_testing=options["testing"]) else: if INotify is None and settings.CONSUMER_POLLING == 0: # pragma: no cover logger.warning("Using polling as INotify import failed") - self.handle_polling(directory, recursive, options["testing"]) + self.handle_polling(directory, recursive, is_testing=options["testing"]) logger.debug("Consumer exiting.") - def handle_polling(self, directory, recursive, is_testing: bool): + def handle_polling(self, directory, recursive, *, is_testing: bool): logger.info(f"Polling directory for changes: {directory}") timeout = None @@ -283,7 +283,7 @@ class Command(BaseCommand): observer.stop() observer.join() - def handle_inotify(self, directory, recursive, is_testing: bool): + def handle_inotify(self, directory, recursive, *, is_testing: bool): logger.info(f"Using inotify to watch directory for changes: {directory}") timeout_ms = None diff --git a/src/documents/migrations/1012_fix_archive_files.py b/src/documents/migrations/1012_fix_archive_files.py index 1d12c439b..46951471e 100644 --- a/src/documents/migrations/1012_fix_archive_files.py +++ b/src/documents/migrations/1012_fix_archive_files.py @@ -84,7 +84,7 @@ def source_path(doc): return os.path.join(settings.ORIGINALS_DIR, fname) -def generate_unique_filename(doc, archive_filename=False): +def generate_unique_filename(doc, *, archive_filename=False): if archive_filename: old_filename = doc.archive_filename root = settings.ARCHIVE_DIR @@ -97,7 +97,7 @@ def generate_unique_filename(doc, archive_filename=False): while True: new_filename = generate_filename( doc, - counter, + counter=counter, archive_filename=archive_filename, ) if new_filename == old_filename: @@ -110,7 +110,7 @@ def generate_unique_filename(doc, archive_filename=False): return new_filename -def generate_filename(doc, counter=0, append_gpg=True, archive_filename=False): +def generate_filename(doc, *, counter=0, append_gpg=True, archive_filename=False): path = "" try: diff --git a/src/documents/models.py b/src/documents/models.py index e7d866e24..25e3c62fd 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -337,7 +337,7 @@ class Document(SoftDeleteModel, ModelWithOwner): def archive_file(self): return open(self.archive_path, "rb") - def get_public_filename(self, archive=False, counter=0, suffix=None) -> str: + def get_public_filename(self, *, archive=False, counter=0, suffix=None) -> str: """ Returns a sanitized filename for the document, not including any paths. """ diff --git a/src/documents/parsers.py b/src/documents/parsers.py index d840817e4..28d903fdd 100644 --- a/src/documents/parsers.py +++ b/src/documents/parsers.py @@ -133,6 +133,7 @@ def get_parser_class_for_mime_type(mime_type: str) -> type["DocumentParser"] | N def run_convert( input_file, output_file, + *, density=None, scale=None, alpha=None, diff --git a/src/documents/permissions.py b/src/documents/permissions.py index 464916ad4..4380c6994 100644 --- a/src/documents/permissions.py +++ b/src/documents/permissions.py @@ -58,7 +58,7 @@ def get_groups_with_only_permission(obj, codename): return Group.objects.filter(id__in=group_object_perm_group_ids).distinct() -def set_permissions_for_object(permissions: list[str], object, merge: bool = False): +def set_permissions_for_object(permissions: list[str], object, *, merge: bool = False): """ Set permissions for an object. The permissions are given as a list of strings in the format "action_modelname", e.g. "view_document". diff --git a/src/documents/sanity_checker.py b/src/documents/sanity_checker.py index 9d44ff345..28d2024e7 100644 --- a/src/documents/sanity_checker.py +++ b/src/documents/sanity_checker.py @@ -57,7 +57,7 @@ class SanityCheckFailedException(Exception): pass -def check_sanity(progress=False) -> SanityCheckMessages: +def check_sanity(*, progress=False) -> SanityCheckMessages: messages = SanityCheckMessages() present_files = { diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py index 4885910fd..1c4d36694 100644 --- a/src/documents/signals/handlers.py +++ b/src/documents/signals/handlers.py @@ -85,6 +85,7 @@ def _suggestion_printer( def set_correspondent( sender, document: Document, + *, logging_group=None, classifier: DocumentClassifier | None = None, replace=False, @@ -140,6 +141,7 @@ def set_correspondent( def set_document_type( sender, document: Document, + *, logging_group=None, classifier: DocumentClassifier | None = None, replace=False, @@ -196,6 +198,7 @@ def set_document_type( def set_tags( sender, document: Document, + *, logging_group=None, classifier: DocumentClassifier | None = None, replace=False, @@ -251,6 +254,7 @@ def set_tags( def set_storage_path( sender, document: Document, + *, logging_group=None, classifier: DocumentClassifier | None = None, replace=False, diff --git a/src/documents/tasks.py b/src/documents/tasks.py index 8b0cbf249..d8539d1ab 100644 --- a/src/documents/tasks.py +++ b/src/documents/tasks.py @@ -63,7 +63,7 @@ def index_optimize(): writer.commit(optimize=True) -def index_reindex(progress_bar_disable=False): +def index_reindex(*, progress_bar_disable=False): documents = Document.objects.all() ix = index.open_index(recreate=True) diff --git a/src/documents/tests/test_api_filter_by_custom_fields.py b/src/documents/tests/test_api_filter_by_custom_fields.py index deb97bf29..70d43dfde 100644 --- a/src/documents/tests/test_api_filter_by_custom_fields.py +++ b/src/documents/tests/test_api_filter_by_custom_fields.py @@ -165,6 +165,7 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase): self, query: list, reference_predicate: Callable[[DocumentWrapper], bool], + *, match_nothing_ok=False, ): """ diff --git a/src/documents/tests/test_bulk_edit.py b/src/documents/tests/test_bulk_edit.py index 7fde5f8ee..4a7145d34 100644 --- a/src/documents/tests/test_bulk_edit.py +++ b/src/documents/tests/test_bulk_edit.py @@ -535,7 +535,12 @@ class TestPDFActions(DirectoriesMixin, TestCase): metadata_document_id = self.doc1.id user = User.objects.create(username="test_user") - result = bulk_edit.merge(doc_ids, None, False, user) + result = bulk_edit.merge( + doc_ids, + metadata_document_id=None, + delete_originals=False, + user=user, + ) expected_filename = ( f"{'_'.join([str(doc_id) for doc_id in doc_ids])[:100]}_merged.pdf" @@ -638,7 +643,7 @@ class TestPDFActions(DirectoriesMixin, TestCase): doc_ids = [self.doc2.id] pages = [[1, 2], [3]] user = User.objects.create(username="test_user") - result = bulk_edit.split(doc_ids, pages, False, user) + result = bulk_edit.split(doc_ids, pages, delete_originals=False, user=user) self.assertEqual(mock_consume_file.call_count, 2) consume_file_args, _ = mock_consume_file.call_args self.assertEqual(consume_file_args[1].title, "B (split 2)") diff --git a/src/documents/tests/test_consumer.py b/src/documents/tests/test_consumer.py index aa452e15b..a862d7fa0 100644 --- a/src/documents/tests/test_consumer.py +++ b/src/documents/tests/test_consumer.py @@ -233,7 +233,7 @@ class FaultyGenericExceptionParser(_BaseTestParser): raise Exception("Generic exception.") -def fake_magic_from_file(file, mime=False): +def fake_magic_from_file(file, *, mime=False): if mime: if file.name.startswith("invalid_pdf"): return "application/octet-stream" diff --git a/src/documents/tests/test_delayedquery.py b/src/documents/tests/test_delayedquery.py index 1895bd6c6..3ee4fb15d 100644 --- a/src/documents/tests/test_delayedquery.py +++ b/src/documents/tests/test_delayedquery.py @@ -10,7 +10,7 @@ class TestDelayedQuery(TestCase): super().setUp() # all tests run without permission criteria, so has_no_owner query will always # be appended. - self.has_no_owner = query.Or([query.Term("has_owner", False)]) + self.has_no_owner = query.Or([query.Term("has_owner", text=False)]) def _get_testset__id__in(self, param, field): return ( @@ -43,12 +43,12 @@ class TestDelayedQuery(TestCase): def test_get_permission_criteria(self): # tests contains tuples of user instances and the expected filter tests = ( - (None, [query.Term("has_owner", False)]), + (None, [query.Term("has_owner", text=False)]), (User(42, username="foo", is_superuser=True), []), ( User(42, username="foo", is_superuser=False), [ - query.Term("has_owner", False), + query.Term("has_owner", text=False), query.Term("owner_id", 42), query.Term("viewer_id", "42"), ], diff --git a/src/documents/tests/test_management_consumer.py b/src/documents/tests/test_management_consumer.py index 7e2707403..808216d3d 100644 --- a/src/documents/tests/test_management_consumer.py +++ b/src/documents/tests/test_management_consumer.py @@ -93,7 +93,7 @@ class ConsumerThreadMixin(DocumentConsumeDelayMixin): else: print("Consumed a perfectly valid file.") # noqa: T201 - def slow_write_file(self, target, incomplete=False): + def slow_write_file(self, target, *, incomplete=False): with open(self.sample_file, "rb") as f: pdf_bytes = f.read() diff --git a/src/documents/tests/test_management_exporter.py b/src/documents/tests/test_management_exporter.py index 0a79b6cd7..eec2fcd4b 100644 --- a/src/documents/tests/test_management_exporter.py +++ b/src/documents/tests/test_management_exporter.py @@ -188,7 +188,7 @@ class TestExportImport( return manifest - def test_exporter(self, use_filename_format=False): + def test_exporter(self, *, use_filename_format=False): shutil.rmtree(os.path.join(self.dirs.media_dir, "documents")) shutil.copytree( os.path.join(os.path.dirname(__file__), "samples", "documents"), diff --git a/src/documents/tests/test_matchables.py b/src/documents/tests/test_matchables.py index 9ca23e53d..180cf77ed 100644 --- a/src/documents/tests/test_matchables.py +++ b/src/documents/tests/test_matchables.py @@ -23,6 +23,7 @@ class _TestMatchingBase(TestCase): match_algorithm: str, should_match: Iterable[str], no_match: Iterable[str], + *, case_sensitive: bool = False, ): for klass in (Tag, Correspondent, DocumentType): diff --git a/src/documents/views.py b/src/documents/views.py index 24578179a..f23c1b953 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -1608,7 +1608,7 @@ class BulkDownloadView(GenericAPIView): strategy_class = ArchiveOnlyStrategy with zipfile.ZipFile(temp.name, "w", compression) as zipf: - strategy = strategy_class(zipf, follow_filename_format) + strategy = strategy_class(zipf, follow_formatting=follow_filename_format) for document in documents: strategy.add_document(document) @@ -1872,7 +1872,7 @@ class SharedLinkView(View): ) -def serve_file(doc: Document, use_archive: bool, disposition: str): +def serve_file(*, doc: Document, use_archive: bool, disposition: str): if use_archive: file_handle = doc.archive_file filename = doc.get_public_filename(archive=True) diff --git a/src/paperless/views.py b/src/paperless/views.py index bcabd182f..6d297c49b 100644 --- a/src/paperless/views.py +++ b/src/paperless/views.py @@ -148,7 +148,7 @@ class UserViewSet(ModelViewSet): ).first() if authenticator is not None: delete_and_cleanup(request, authenticator) - return Response(True) + return Response(data=True) else: return HttpResponseNotFound("TOTP not found") @@ -262,7 +262,7 @@ class TOTPView(GenericAPIView): ).first() if authenticator is not None: delete_and_cleanup(request, authenticator) - return Response(True) + return Response(data=True) else: return HttpResponseNotFound("TOTP not found") diff --git a/src/paperless_mail/mail.py b/src/paperless_mail/mail.py index e25c4f227..cf35ea6cb 100644 --- a/src/paperless_mail/mail.py +++ b/src/paperless_mail/mail.py @@ -121,7 +121,7 @@ class MarkReadMailAction(BaseMailAction): return {"seen": False} def post_consume(self, M: MailBox, message_uid: str, parameter: str): - M.flag(message_uid, [MailMessageFlags.SEEN], True) + M.flag(message_uid, [MailMessageFlags.SEEN], value=True) class MoveMailAction(BaseMailAction): @@ -142,7 +142,7 @@ class FlagMailAction(BaseMailAction): return {"flagged": False} def post_consume(self, M: MailBox, message_uid: str, parameter: str): - M.flag(message_uid, [MailMessageFlags.FLAGGED], True) + M.flag(message_uid, [MailMessageFlags.FLAGGED], value=True) class TagMailAction(BaseMailAction): @@ -150,7 +150,7 @@ class TagMailAction(BaseMailAction): A mail action that tags mails after processing. """ - def __init__(self, parameter: str, supports_gmail_labels: bool): + def __init__(self, parameter: str, *, supports_gmail_labels: bool): # The custom tag should look like "apple:" if "apple:" in parameter.lower(): _, self.color = parameter.split(":") @@ -188,19 +188,19 @@ class TagMailAction(BaseMailAction): M.flag( message_uid, set(itertools.chain(*APPLE_MAIL_TAG_COLORS.values())), - False, + value=False, ) # Set new $MailFlagBits - M.flag(message_uid, APPLE_MAIL_TAG_COLORS.get(self.color), True) + M.flag(message_uid, APPLE_MAIL_TAG_COLORS.get(self.color), value=True) # Set the general \Flagged # This defaults to the "red" flag in AppleMail and # "stars" in Thunderbird or GMail - M.flag(message_uid, [MailMessageFlags.FLAGGED], True) + M.flag(message_uid, [MailMessageFlags.FLAGGED], value=True) elif self.keyword: - M.flag(message_uid, [self.keyword], True) + M.flag(message_uid, [self.keyword], value=True) else: raise MailError("No keyword specified.") @@ -268,7 +268,7 @@ def apply_mail_action( mailbox_login(M, account) M.folder.set(rule.folder) - action = get_rule_action(rule, supports_gmail_labels) + action = get_rule_action(rule, supports_gmail_labels=supports_gmail_labels) try: action.post_consume(M, message_uid, rule.action_parameter) except errors.ImapToolsError: @@ -356,7 +356,7 @@ def queue_consumption_tasks( ).delay() -def get_rule_action(rule: MailRule, supports_gmail_labels: bool) -> BaseMailAction: +def get_rule_action(rule: MailRule, *, supports_gmail_labels: bool) -> BaseMailAction: """ Returns a BaseMailAction instance for the given rule. """ @@ -370,12 +370,15 @@ def get_rule_action(rule: MailRule, supports_gmail_labels: bool) -> BaseMailActi elif rule.action == MailRule.MailAction.MARK_READ: return MarkReadMailAction() elif rule.action == MailRule.MailAction.TAG: - return TagMailAction(rule.action_parameter, supports_gmail_labels) + return TagMailAction( + rule.action_parameter, + supports_gmail_labels=supports_gmail_labels, + ) else: raise NotImplementedError("Unknown action.") # pragma: no cover -def make_criterias(rule: MailRule, supports_gmail_labels: bool): +def make_criterias(rule: MailRule, *, supports_gmail_labels: bool): """ Returns criteria to be applied to MailBox.fetch for the given rule. """ @@ -393,7 +396,10 @@ def make_criterias(rule: MailRule, supports_gmail_labels: bool): if rule.filter_body: criterias["body"] = rule.filter_body - rule_query = get_rule_action(rule, supports_gmail_labels).get_criteria() + rule_query = get_rule_action( + rule, + supports_gmail_labels=supports_gmail_labels, + ).get_criteria() if isinstance(rule_query, dict): if len(rule_query) or len(criterias): return AND(**rule_query, **criterias) @@ -563,7 +569,7 @@ class MailAccountHandler(LoggingMixin): total_processed_files += self._handle_mail_rule( M, rule, - supports_gmail_labels, + supports_gmail_labels=supports_gmail_labels, ) except Exception as e: self.log.exception( @@ -588,6 +594,7 @@ class MailAccountHandler(LoggingMixin): self, M: MailBox, rule: MailRule, + *, supports_gmail_labels: bool, ): folders = [rule.folder] @@ -616,7 +623,7 @@ class MailAccountHandler(LoggingMixin): f"does not exist in account {rule.account}", ) from err - criterias = make_criterias(rule, supports_gmail_labels) + criterias = make_criterias(rule, supports_gmail_labels=supports_gmail_labels) self.log.debug( f"Rule {rule}: Searching folder with criteria {criterias}", diff --git a/src/paperless_mail/tests/test_mail.py b/src/paperless_mail/tests/test_mail.py index 2311c3009..a73f9cf34 100644 --- a/src/paperless_mail/tests/test_mail.py +++ b/src/paperless_mail/tests/test_mail.py @@ -124,7 +124,7 @@ class BogusMailBox(AbstractContextManager): if username != self.USERNAME or access_token != self.ACCESS_TOKEN: raise MailboxLoginError("BAD", "OK") - def fetch(self, criteria, mark_seen, charset="", bulk=True): + def fetch(self, criteria, mark_seen, charset="", *, bulk=True): msg = self.messages criteria = str(criteria).strip("()").split(" ") @@ -190,7 +190,7 @@ class BogusMailBox(AbstractContextManager): raise Exception -def fake_magic_from_buffer(buffer, mime=False): +def fake_magic_from_buffer(buffer, *, mime=False): if mime: if "PDF" in str(buffer): return "application/pdf" @@ -206,6 +206,7 @@ class MessageBuilder: def create_message( self, + *, attachments: int | list[_AttachmentDef] = 1, body: str = "", subject: str = "the subject", @@ -783,12 +784,18 @@ class TestMail( ) self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3) - self.assertEqual(len(self.mailMocker.bogus_mailbox.fetch("UNSEEN", False)), 2) + self.assertEqual( + len(self.mailMocker.bogus_mailbox.fetch("UNSEEN", mark_seen=False)), + 2, + ) self.mail_account_handler.handle_mail_account(account) self.mailMocker.apply_mail_actions() - self.assertEqual(len(self.mailMocker.bogus_mailbox.fetch("UNSEEN", False)), 0) + self.assertEqual( + len(self.mailMocker.bogus_mailbox.fetch("UNSEEN", mark_seen=False)), + 0, + ) self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3) def test_handle_mail_account_delete(self): @@ -853,7 +860,7 @@ class TestMail( self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3) self.assertEqual( - len(self.mailMocker.bogus_mailbox.fetch("UNFLAGGED", False)), + len(self.mailMocker.bogus_mailbox.fetch("UNFLAGGED", mark_seen=False)), 2, ) @@ -861,7 +868,7 @@ class TestMail( self.mailMocker.apply_mail_actions() self.assertEqual( - len(self.mailMocker.bogus_mailbox.fetch("UNFLAGGED", False)), + len(self.mailMocker.bogus_mailbox.fetch("UNFLAGGED", mark_seen=False)), 1, ) self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3) @@ -934,7 +941,12 @@ class TestMail( self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3) self.assertEqual( - len(self.mailMocker.bogus_mailbox.fetch("UNKEYWORD processed", False)), + len( + self.mailMocker.bogus_mailbox.fetch( + "UNKEYWORD processed", + mark_seen=False, + ), + ), 2, ) @@ -943,7 +955,12 @@ class TestMail( self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3) self.assertEqual( - len(self.mailMocker.bogus_mailbox.fetch("UNKEYWORD processed", False)), + len( + self.mailMocker.bogus_mailbox.fetch( + "UNKEYWORD processed", + mark_seen=False, + ), + ), 0, ) @@ -967,12 +984,18 @@ class TestMail( self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3) criteria = NOT(gmail_label="processed") - self.assertEqual(len(self.mailMocker.bogus_mailbox.fetch(criteria, False)), 2) + self.assertEqual( + len(self.mailMocker.bogus_mailbox.fetch(criteria, mark_seen=False)), + 2, + ) self.mail_account_handler.handle_mail_account(account) self.mailMocker.apply_mail_actions() - self.assertEqual(len(self.mailMocker.bogus_mailbox.fetch(criteria, False)), 0) + self.assertEqual( + len(self.mailMocker.bogus_mailbox.fetch(criteria, mark_seen=False)), + 0, + ) self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3) def test_tag_mail_action_applemail_wrong_input(self): @@ -980,7 +1003,7 @@ class TestMail( MailError, TagMailAction, "apple:black", - False, + supports_gmail_labels=False, ) def test_handle_mail_account_tag_applemail(self): @@ -1002,7 +1025,7 @@ class TestMail( self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3) self.assertEqual( - len(self.mailMocker.bogus_mailbox.fetch("UNFLAGGED", False)), + len(self.mailMocker.bogus_mailbox.fetch("UNFLAGGED", mark_seen=False)), 2, ) @@ -1010,7 +1033,7 @@ class TestMail( self.mailMocker.apply_mail_actions() self.assertEqual( - len(self.mailMocker.bogus_mailbox.fetch("UNFLAGGED", False)), + len(self.mailMocker.bogus_mailbox.fetch("UNFLAGGED", mark_seen=False)), 0, ) self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3) @@ -1324,13 +1347,19 @@ class TestMail( self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3) self.mailMocker._queue_consumption_tasks_mock.assert_not_called() - self.assertEqual(len(self.mailMocker.bogus_mailbox.fetch("UNSEEN", False)), 2) + self.assertEqual( + len(self.mailMocker.bogus_mailbox.fetch("UNSEEN", mark_seen=False)), + 2, + ) self.mail_account_handler.handle_mail_account(account) self.mailMocker.apply_mail_actions() self.assertEqual(self.mailMocker._queue_consumption_tasks_mock.call_count, 2) - self.assertEqual(len(self.mailMocker.bogus_mailbox.fetch("UNSEEN", False)), 0) + self.assertEqual( + len(self.mailMocker.bogus_mailbox.fetch("UNSEEN", mark_seen=False)), + 0, + ) self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3) def test_auth_plain_fallback_fails_still(self): @@ -1390,13 +1419,19 @@ class TestMail( self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3) self.assertEqual(self.mailMocker._queue_consumption_tasks_mock.call_count, 0) - self.assertEqual(len(self.mailMocker.bogus_mailbox.fetch("UNSEEN", False)), 2) + self.assertEqual( + len(self.mailMocker.bogus_mailbox.fetch("UNSEEN", mark_seen=False)), + 2, + ) self.mail_account_handler.handle_mail_account(account) self.mailMocker.apply_mail_actions() self.assertEqual(self.mailMocker._queue_consumption_tasks_mock.call_count, 2) - self.assertEqual(len(self.mailMocker.bogus_mailbox.fetch("UNSEEN", False)), 0) + self.assertEqual( + len(self.mailMocker.bogus_mailbox.fetch("UNSEEN", mark_seen=False)), + 0, + ) self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3) def test_disabled_rule(self): @@ -1425,12 +1460,15 @@ class TestMail( self.mailMocker.apply_mail_actions() self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3) - self.assertEqual(len(self.mailMocker.bogus_mailbox.fetch("UNSEEN", False)), 2) + self.assertEqual( + len(self.mailMocker.bogus_mailbox.fetch("UNSEEN", mark_seen=False)), + 2, + ) self.mail_account_handler.handle_mail_account(account) self.mailMocker.apply_mail_actions() self.assertEqual( - len(self.mailMocker.bogus_mailbox.fetch("UNSEEN", False)), + len(self.mailMocker.bogus_mailbox.fetch("UNSEEN", mark_seen=False)), 2, ) # still 2 diff --git a/src/paperless_tesseract/parsers.py b/src/paperless_tesseract/parsers.py index e7968a61e..a8be899f5 100644 --- a/src/paperless_tesseract/parsers.py +++ b/src/paperless_tesseract/parsers.py @@ -214,6 +214,7 @@ class RasterisedDocumentParser(DocumentParser): mime_type, output_file, sidecar_file, + *, safe_fallback=False, ): if TYPE_CHECKING: