Compare commits

..

31 Commits

Author SHA1 Message Date
shamoon
72df7fb1ee Move migration 2026-01-27 17:03:05 -08:00
shamoon
873a483843 Merge branch 'dev' into feature-pw-removal-workflow-action 2026-01-27 17:02:19 -08:00
shamoon
abc7a8a1c0 Move migration 2026-01-26 15:17:19 -08:00
shamoon
359460b3c0 Merge branch 'dev' into feature-pw-removal-workflow-action 2026-01-26 15:17:08 -08:00
shamoon
d971cdf004 Rename migration 2026-01-25 17:02:34 -08:00
shamoon
5e62aa64ed Merge branch 'dev' into feature-pw-removal-workflow-action 2026-01-25 17:01:54 -08:00
shamoon
bc406d612a Regenerate migration 2026-01-24 20:02:13 -08:00
shamoon
b12936c8c6 Merge branch 'dev' into feature-pw-removal-workflow-action 2026-01-24 20:01:32 -08:00
shamoon
f596294e1f Merge branch 'dev' into feature-pw-removal-workflow-action 2026-01-09 20:45:05 -08:00
shamoon
02e590c70c Add hint for plain text password storage 2025-12-30 12:49:02 -08:00
shamoon
3c53e4bab1 Add docstrings to workflow password removal tests 2025-12-30 12:44:01 -08:00
shamoon
880b3e6d15 Better, attempt removal later for ConsumableDocument 2025-12-30 12:44:00 -08:00
shamoon
f7a6f79c8b Update test_workflows.py 2025-12-28 21:45:01 -08:00
shamoon
87dc22fbf6 Update test_workflows.py 2025-12-28 21:41:51 -08:00
shamoon
2332b3f6ad and this 2025-12-28 21:02:02 -08:00
shamoon
5fbc985b67 simplify this 2025-12-28 21:00:06 -08:00
shamoon
7f95160a63 add api tests 2025-12-28 20:58:10 -08:00
shamoon
1aaf128bcb Enhancement: password removal workflow action 2025-12-28 20:05:46 -08:00
shamoon
10db1e6405 Change param order 2025-12-28 16:05:38 -08:00
shamoon
0e2611163b Fix docs 2025-12-28 16:05:38 -08:00
shamoon
b917db44ed Cover this last bit 2025-12-28 16:05:38 -08:00
shamoon
bca409d932 Add password removal confirm dialog, with options 2025-12-28 16:05:38 -08:00
shamoon
07d67b3299 whitespace yay 2025-12-28 16:05:38 -08:00
shamoon
5fca9bac50 Fix formatting issue in document-detail.spec.ts 2025-12-28 16:05:38 -08:00
shamoon
b21df970fd backend test coverage
Added a test for the remove_password function to ensure it deletes the original document when specified.
2025-12-28 16:05:38 -08:00
shamoon
833890d0ca fix frontend test coverage 2025-12-28 16:05:38 -08:00
shamoon
eb1708420e Just hide for non-owners 2025-12-28 16:05:38 -08:00
shamoon
3bb74772a9 Backend coverage 2025-12-28 16:05:38 -08:00
shamoon
402c9af81b Add test 2025-12-28 16:05:38 -08:00
shamoon
c1de78162b Add update_document flag to bulkEdit remove_password 2025-12-28 16:05:38 -08:00
shamoon
f888722a73 Basic remove password bulk edit action 2025-12-28 16:05:38 -08:00
40 changed files with 829 additions and 660 deletions

View File

@@ -46,13 +46,14 @@ jobs:
id: ref id: ref
run: | run: |
ref_name="${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}" ref_name="${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}"
# Sanitize by replacing / with - for use in tags and cache keys # Sanitize by replacing / with - for cache keys
sanitized_ref="${ref_name//\//-}" cache_ref="${ref_name//\//-}"
echo "ref_name=${ref_name}" echo "ref_name=${ref_name}"
echo "sanitized_ref=${sanitized_ref}" echo "cache_ref=${cache_ref}"
echo "name=${sanitized_ref}" >> $GITHUB_OUTPUT echo "name=${ref_name}" >> $GITHUB_OUTPUT
echo "cache-ref=${cache_ref}" >> $GITHUB_OUTPUT
- name: Check push permissions - name: Check push permissions
id: check-push id: check-push
env: env:
@@ -61,14 +62,12 @@ jobs:
# should-push: Should we push to GHCR? # should-push: Should we push to GHCR?
# True for: # True for:
# 1. Pushes (tags/dev/beta) - filtered via the workflow triggers # 1. Pushes (tags/dev/beta) - filtered via the workflow triggers
# 2. Manual dispatch - always push to GHCR # 2. Internal PRs where the branch name starts with 'feature-' - filtered here when a PR is synced
# 3. Internal PRs where the branch name starts with 'feature-' or 'fix-'
should_push="false" should_push="false"
if [[ "${{ github.event_name }}" == "push" ]]; then if [[ "${{ github.event_name }}" == "push" ]]; then
should_push="true" should_push="true"
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
should_push="true"
elif [[ "${{ github.event_name }}" == "pull_request" && "${{ github.event.pull_request.head.repo.full_name }}" == "${{ github.repository }}" ]]; then elif [[ "${{ github.event_name }}" == "pull_request" && "${{ github.event.pull_request.head.repo.full_name }}" == "${{ github.repository }}" ]]; then
if [[ "${REF_NAME}" == feature-* || "${REF_NAME}" == fix-* ]]; then if [[ "${REF_NAME}" == feature-* || "${REF_NAME}" == fix-* ]]; then
should_push="true" should_push="true"
@@ -140,9 +139,9 @@ jobs:
PNGX_TAG_VERSION=${{ steps.docker-meta.outputs.version }} PNGX_TAG_VERSION=${{ steps.docker-meta.outputs.version }}
outputs: type=image,name=${{ env.REGISTRY }}/${{ steps.repo.outputs.name }},push-by-digest=true,name-canonical=true,push=${{ steps.check-push.outputs.should-push }} outputs: type=image,name=${{ env.REGISTRY }}/${{ steps.repo.outputs.name }},push-by-digest=true,name-canonical=true,push=${{ steps.check-push.outputs.should-push }}
cache-from: | cache-from: |
type=registry,ref=${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}/cache/app:${{ steps.ref.outputs.name }}-${{ matrix.arch }} type=registry,ref=${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}/cache/app:${{ steps.ref.outputs.cache-ref }}-${{ matrix.arch }}
type=registry,ref=${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}/cache/app:dev-${{ matrix.arch }} type=registry,ref=${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}/cache/app:dev-${{ matrix.arch }}
cache-to: ${{ steps.check-push.outputs.should-push == 'true' && format('type=registry,mode=max,ref={0}/{1}/cache/app:{2}-{3}', env.REGISTRY, steps.repo.outputs.name, steps.ref.outputs.name, matrix.arch) || '' }} cache-to: ${{ steps.check-push.outputs.should-push == 'true' && format('type=registry,mode=max,ref={0}/{1}/cache/app:{2}-{3}', env.REGISTRY, steps.repo.outputs.name, steps.ref.outputs.cache-ref, matrix.arch) || '' }}
- name: Export digest - name: Export digest
if: steps.check-push.outputs.should-push == 'true' if: steps.check-push.outputs.should-push == 'true'
run: | run: |

View File

@@ -34,13 +34,3 @@ services:
ports: ports:
- "3143:3143" # IMAP - "3143:3143" # IMAP
restart: unless-stopped restart: unless-stopped
nginx:
image: docker.io/nginx:1.29-alpine
hostname: nginx
container_name: nginx
ports:
- "8080:8080"
restart: unless-stopped
volumes:
- ../../docs/assets:/usr/share/nginx/html/assets:ro
- ./test-nginx.conf:/etc/nginx/conf.d/default.conf:ro

View File

@@ -1,14 +0,0 @@
server {
listen 8080;
server_name localhost;
root /usr/share/nginx/html;
# Enable CORS for test requests
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, HEAD, OPTIONS' always;
location / {
try_files $uri $uri/ =404;
}
}

View File

@@ -582,7 +582,7 @@ document.
### Detecting duplicates {#fuzzy_duplicate} ### Detecting duplicates {#fuzzy_duplicate}
Paperless-ngx already catches and warns of exactly matching documents, Paperless already catches and prevents upload of exactly matching documents,
however a new scan of an existing document may not produce an exact bit for bit however a new scan of an existing document may not produce an exact bit for bit
duplicate. But the content should be exact or close, allowing detection. duplicate. But the content should be exact or close, allowing detection.

View File

@@ -16,7 +16,6 @@ classifiers = [
# This will allow testing to not install a webserver, mysql, etc # This will allow testing to not install a webserver, mysql, etc
dependencies = [ dependencies = [
"adrf~=0.1.12",
"azure-ai-documentintelligence>=1.0.2", "azure-ai-documentintelligence>=1.0.2",
"babel>=2.17", "babel>=2.17",
"bleach~=6.3.0", "bleach~=6.3.0",
@@ -301,14 +300,6 @@ norecursedirs = [ "src/locale/", ".venv/", "src-ui/" ]
DJANGO_SETTINGS_MODULE = "paperless.settings" DJANGO_SETTINGS_MODULE = "paperless.settings"
markers = [
"live: Integration tests requiring external services (Gotenberg, Tika, nginx, etc)",
"nginx: Tests that make HTTP requests to the local nginx service",
"gotenberg: Tests requiring Gotenberg service",
"tika: Tests requiring Tika service",
"greenmail: Tests requiring Greenmail service",
]
[tool.pytest_env] [tool.pytest_env]
PAPERLESS_DISABLE_DBHANDLER = "true" PAPERLESS_DISABLE_DBHANDLER = "true"
PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache" PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"

View File

@@ -561,7 +561,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">400</context> <context context-type="linenumber">386</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html</context>
@@ -1201,72 +1201,28 @@
<source>Bulk editing</source> <source>Bulk editing</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">264</context> <context context-type="linenumber">263</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8158899674926420054" datatype="html"> <trans-unit id="8158899674926420054" datatype="html">
<source>Show confirmation dialogs</source> <source>Show confirmation dialogs</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">267</context> <context context-type="linenumber">266</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="290238406234356122" datatype="html"> <trans-unit id="290238406234356122" datatype="html">
<source>Apply on close</source> <source>Apply on close</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">268</context> <context context-type="linenumber">267</context>
</context-group>
</trans-unit>
<trans-unit id="5084275925647254161" datatype="html">
<source>PDF Editor</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">272</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">66</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1472</context>
</context-group>
</trans-unit>
<trans-unit id="1577733187050997705" datatype="html">
<source>Default editing mode</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">275</context>
</context-group>
</trans-unit>
<trans-unit id="7273640930165035289" datatype="html">
<source>Create new document(s)</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">279</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context>
<context context-type="linenumber">82</context>
</context-group>
</trans-unit>
<trans-unit id="8035757452478567832" datatype="html">
<source>Update existing document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">280</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context>
<context context-type="linenumber">87</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8104421162933956065" datatype="html"> <trans-unit id="8104421162933956065" datatype="html">
<source>Notes</source> <source>Notes</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">285</context> <context context-type="linenumber">271</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
@@ -1285,14 +1241,14 @@
<source>Enable notes</source> <source>Enable notes</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">288</context> <context context-type="linenumber">274</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7314814725704332646" datatype="html"> <trans-unit id="7314814725704332646" datatype="html">
<source>Permissions</source> <source>Permissions</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">297</context> <context context-type="linenumber">283</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/group-edit-dialog/group-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/group-edit-dialog/group-edit-dialog.component.html</context>
@@ -1355,28 +1311,28 @@
<source>Default Permissions</source> <source>Default Permissions</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">300</context> <context context-type="linenumber">286</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6544153565064275581" datatype="html"> <trans-unit id="6544153565064275581" datatype="html">
<source> Settings apply to this user account for objects (Tags, Mail Rules, etc. but not documents) created via the web UI. </source> <source> Settings apply to this user account for objects (Tags, Mail Rules, etc. but not documents) created via the web UI. </source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">304,306</context> <context context-type="linenumber">290,292</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4292903881380648974" datatype="html"> <trans-unit id="4292903881380648974" datatype="html">
<source>Default Owner</source> <source>Default Owner</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">311</context> <context context-type="linenumber">297</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="734147282056744882" datatype="html"> <trans-unit id="734147282056744882" datatype="html">
<source>Objects without an owner can be viewed and edited by all users</source> <source>Objects without an owner can be viewed and edited by all users</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">315</context> <context context-type="linenumber">301</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/permissions/permissions-form/permissions-form.component.html</context> <context context-type="sourcefile">src/app/components/common/input/permissions/permissions-form/permissions-form.component.html</context>
@@ -1387,18 +1343,18 @@
<source>Default View Permissions</source> <source>Default View Permissions</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">320</context> <context context-type="linenumber">306</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2191775412581217688" datatype="html"> <trans-unit id="2191775412581217688" datatype="html">
<source>Users:</source> <source>Users:</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">325</context> <context context-type="linenumber">311</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">352</context> <context context-type="linenumber">338</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
@@ -1429,11 +1385,11 @@
<source>Groups:</source> <source>Groups:</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">335</context> <context context-type="linenumber">321</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">362</context> <context context-type="linenumber">348</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
@@ -1464,14 +1420,14 @@
<source>Default Edit Permissions</source> <source>Default Edit Permissions</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">347</context> <context context-type="linenumber">333</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3728984448750213892" datatype="html"> <trans-unit id="3728984448750213892" datatype="html">
<source>Edit permissions also grant viewing permissions</source> <source>Edit permissions also grant viewing permissions</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">371</context> <context context-type="linenumber">357</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
@@ -1490,7 +1446,7 @@
<source>Notifications</source> <source>Notifications</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">379</context> <context context-type="linenumber">365</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.html</context>
@@ -1501,49 +1457,49 @@
<source>Document processing</source> <source>Document processing</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">382</context> <context context-type="linenumber">368</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3656786776644872398" datatype="html"> <trans-unit id="3656786776644872398" datatype="html">
<source>Show notifications when new documents are detected</source> <source>Show notifications when new documents are detected</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">386</context> <context context-type="linenumber">372</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6057053428592387613" datatype="html"> <trans-unit id="6057053428592387613" datatype="html">
<source>Show notifications when document processing completes successfully</source> <source>Show notifications when document processing completes successfully</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">387</context> <context context-type="linenumber">373</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="370315664367425513" datatype="html"> <trans-unit id="370315664367425513" datatype="html">
<source>Show notifications when document processing fails</source> <source>Show notifications when document processing fails</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">388</context> <context context-type="linenumber">374</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6838309441164918531" datatype="html"> <trans-unit id="6838309441164918531" datatype="html">
<source>Suppress notifications on dashboard</source> <source>Suppress notifications on dashboard</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">389</context> <context context-type="linenumber">375</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2741919327232918179" datatype="html"> <trans-unit id="2741919327232918179" datatype="html">
<source>This will suppress all messages about document processing status on the dashboard.</source> <source>This will suppress all messages about document processing status on the dashboard.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">389</context> <context context-type="linenumber">375</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2159130950882492111" datatype="html"> <trans-unit id="2159130950882492111" datatype="html">
<source>Cancel</source> <source>Cancel</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">399</context> <context context-type="linenumber">385</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/confirm-dialog/confirm-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/confirm-dialog/confirm-dialog.component.ts</context>
@@ -1614,21 +1570,21 @@
<source>Use system language</source> <source>Use system language</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
<context context-type="linenumber">79</context> <context context-type="linenumber">78</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7729897675462249787" datatype="html"> <trans-unit id="7729897675462249787" datatype="html">
<source>Use date format of display language</source> <source>Use date format of display language</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
<context context-type="linenumber">82</context> <context context-type="linenumber">81</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1379170675585571971" datatype="html"> <trans-unit id="1379170675585571971" datatype="html">
<source>Archive serial number</source> <source>Archive serial number</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
<context context-type="linenumber">96</context> <context context-type="linenumber">95</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
@@ -1639,7 +1595,7 @@
<source>Correspondent</source> <source>Correspondent</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
<context context-type="linenumber">98</context> <context context-type="linenumber">97</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
@@ -1670,7 +1626,7 @@
<source>Document type</source> <source>Document type</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
<context context-type="linenumber">99</context> <context context-type="linenumber">98</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
@@ -1701,7 +1657,7 @@
<source>Storage path</source> <source>Storage path</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
<context context-type="linenumber">100</context> <context context-type="linenumber">99</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
@@ -1728,7 +1684,7 @@
<source>Tags</source> <source>Tags</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
<context context-type="linenumber">101</context> <context context-type="linenumber">100</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
@@ -1767,7 +1723,7 @@
<source>Error retrieving users</source> <source>Error retrieving users</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
<context context-type="linenumber">252</context> <context context-type="linenumber">248</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context> <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context>
@@ -1778,7 +1734,7 @@
<source>Error retrieving groups</source> <source>Error retrieving groups</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
<context context-type="linenumber">271</context> <context context-type="linenumber">267</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context> <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context>
@@ -1789,28 +1745,28 @@
<source>Settings were saved successfully.</source> <source>Settings were saved successfully.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
<context context-type="linenumber">588</context> <context context-type="linenumber">577</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="525012668859298131" datatype="html"> <trans-unit id="525012668859298131" datatype="html">
<source>Settings were saved successfully. Reload is required to apply some changes.</source> <source>Settings were saved successfully. Reload is required to apply some changes.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
<context context-type="linenumber">592</context> <context context-type="linenumber">581</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8491974984518503778" datatype="html"> <trans-unit id="8491974984518503778" datatype="html">
<source>Reload now</source> <source>Reload now</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
<context context-type="linenumber">593</context> <context context-type="linenumber">582</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3011185103048412841" datatype="html"> <trans-unit id="3011185103048412841" datatype="html">
<source>An error occurred while saving settings.</source> <source>An error occurred while saving settings.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
<context context-type="linenumber">603</context> <context context-type="linenumber">592</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
@@ -2819,11 +2775,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1108</context> <context context-type="linenumber">1121</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1473</context> <context context-type="linenumber">1486</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -3414,7 +3370,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1061</context> <context context-type="linenumber">1074</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -3519,7 +3475,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1524</context> <context context-type="linenumber">1537</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6661109599266152398" datatype="html"> <trans-unit id="6661109599266152398" datatype="html">
@@ -3530,7 +3486,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1525</context> <context context-type="linenumber">1538</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5162686434580248853" datatype="html"> <trans-unit id="5162686434580248853" datatype="html">
@@ -3541,7 +3497,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1526</context> <context context-type="linenumber">1539</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8157388568390631653" datatype="html"> <trans-unit id="8157388568390631653" datatype="html">
@@ -6056,6 +6012,20 @@
<context context-type="linenumber">70</context> <context context-type="linenumber">70</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7273640930165035289" datatype="html">
<source>Create new document(s)</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context>
<context context-type="linenumber">82</context>
</context-group>
</trans-unit>
<trans-unit id="8035757452478567832" datatype="html">
<source>Update existing document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context>
<context context-type="linenumber">87</context>
</context-group>
</trans-unit>
<trans-unit id="7248454234750442816" datatype="html"> <trans-unit id="7248454234750442816" datatype="html">
<source>Copy metadata</source> <source>Copy metadata</source>
<context-group purpose="location"> <context-group purpose="location">
@@ -7403,6 +7373,17 @@
<context context-type="linenumber">69</context> <context context-type="linenumber">69</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5084275925647254161" datatype="html">
<source>PDF Editor</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">66</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1485</context>
</context-group>
</trans-unit>
<trans-unit id="2336375155355449543" datatype="html"> <trans-unit id="2336375155355449543" datatype="html">
<source>Remove Password</source> <source>Remove Password</source>
<context-group purpose="location"> <context-group purpose="location">
@@ -7638,56 +7619,56 @@
<source>An error occurred loading content: <x id="PH" equiv-text="err.message ?? err.toString()"/></source> <source>An error occurred loading content: <x id="PH" equiv-text="err.message ?? err.toString()"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">428,430</context> <context context-type="linenumber">441,443</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3200733026060976258" datatype="html"> <trans-unit id="3200733026060976258" datatype="html">
<source>Document changes detected</source> <source>Document changes detected</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">467</context> <context context-type="linenumber">480</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2887155916749964" datatype="html"> <trans-unit id="2887155916749964" datatype="html">
<source>The version of this document in your browser session appears older than the existing version.</source> <source>The version of this document in your browser session appears older than the existing version.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">468</context> <context context-type="linenumber">481</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="237142428785956348" datatype="html"> <trans-unit id="237142428785956348" datatype="html">
<source>Saving the document here may overwrite other changes that were made. To restore the existing version, discard your changes or close the document.</source> <source>Saving the document here may overwrite other changes that were made. To restore the existing version, discard your changes or close the document.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">469</context> <context context-type="linenumber">482</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8720977247725652816" datatype="html"> <trans-unit id="8720977247725652816" datatype="html">
<source>Ok</source> <source>Ok</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">471</context> <context context-type="linenumber">484</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6142395741265832184" datatype="html"> <trans-unit id="6142395741265832184" datatype="html">
<source>Next document</source> <source>Next document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">597</context> <context context-type="linenumber">610</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="651985345816518480" datatype="html"> <trans-unit id="651985345816518480" datatype="html">
<source>Previous document</source> <source>Previous document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">607</context> <context context-type="linenumber">620</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2885986061416655600" datatype="html"> <trans-unit id="2885986061416655600" datatype="html">
<source>Close document</source> <source>Close document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">615</context> <context context-type="linenumber">628</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context> <context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
@@ -7698,67 +7679,67 @@
<source>Save document</source> <source>Save document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">622</context> <context context-type="linenumber">635</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1784543155727940353" datatype="html"> <trans-unit id="1784543155727940353" datatype="html">
<source>Save and close / next</source> <source>Save and close / next</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">631</context> <context context-type="linenumber">644</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5758784066858623886" datatype="html"> <trans-unit id="5758784066858623886" datatype="html">
<source>Error retrieving metadata</source> <source>Error retrieving metadata</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">686</context> <context context-type="linenumber">699</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3456881259945295697" datatype="html"> <trans-unit id="3456881259945295697" datatype="html">
<source>Error retrieving suggestions.</source> <source>Error retrieving suggestions.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">741</context> <context context-type="linenumber">754</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2194092841814123758" datatype="html"> <trans-unit id="2194092841814123758" datatype="html">
<source>Document &quot;<x id="PH" equiv-text="newValues.title"/>&quot; saved successfully.</source> <source>Document &quot;<x id="PH" equiv-text="newValues.title"/>&quot; saved successfully.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">950</context> <context context-type="linenumber">963</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">974</context> <context context-type="linenumber">987</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6626387786259219838" datatype="html"> <trans-unit id="6626387786259219838" datatype="html">
<source>Error saving document &quot;<x id="PH" equiv-text="this.document.title"/>&quot;</source> <source>Error saving document &quot;<x id="PH" equiv-text="this.document.title"/>&quot;</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">980</context> <context context-type="linenumber">993</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="448882439049417053" datatype="html"> <trans-unit id="448882439049417053" datatype="html">
<source>Error saving document</source> <source>Error saving document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1030</context> <context context-type="linenumber">1043</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8410796510716511826" datatype="html"> <trans-unit id="8410796510716511826" datatype="html">
<source>Do you really want to move the document &quot;<x id="PH" equiv-text="this.document.title"/>&quot; to the trash?</source> <source>Do you really want to move the document &quot;<x id="PH" equiv-text="this.document.title"/>&quot; to the trash?</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1062</context> <context context-type="linenumber">1075</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="282586936710748252" datatype="html"> <trans-unit id="282586936710748252" datatype="html">
<source>Documents can be restored prior to permanent deletion.</source> <source>Documents can be restored prior to permanent deletion.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1063</context> <context context-type="linenumber">1076</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -7769,7 +7750,7 @@
<source>Move to trash</source> <source>Move to trash</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1065</context> <context context-type="linenumber">1078</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -7780,14 +7761,14 @@
<source>Error deleting document</source> <source>Error deleting document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1084</context> <context context-type="linenumber">1097</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="619486176823357521" datatype="html"> <trans-unit id="619486176823357521" datatype="html">
<source>Reprocess confirm</source> <source>Reprocess confirm</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1104</context> <context context-type="linenumber">1117</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -7798,102 +7779,102 @@
<source>This operation will permanently recreate the archive file for this document.</source> <source>This operation will permanently recreate the archive file for this document.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1105</context> <context context-type="linenumber">1118</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="302054111564709516" datatype="html"> <trans-unit id="302054111564709516" datatype="html">
<source>The archive file will be re-generated with the current settings.</source> <source>The archive file will be re-generated with the current settings.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1106</context> <context context-type="linenumber">1119</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8251197608401006898" datatype="html"> <trans-unit id="8251197608401006898" datatype="html">
<source>Reprocess operation for &quot;<x id="PH" equiv-text="this.document.title"/>&quot; will begin in the background. Close and re-open or reload this document after the operation has completed to see new content.</source> <source>Reprocess operation for &quot;<x id="PH" equiv-text="this.document.title"/>&quot; will begin in the background. Close and re-open or reload this document after the operation has completed to see new content.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1116</context> <context context-type="linenumber">1129</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4409560272830824468" datatype="html"> <trans-unit id="4409560272830824468" datatype="html">
<source>Error executing operation</source> <source>Error executing operation</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1127</context> <context context-type="linenumber">1140</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6030453331794586802" datatype="html"> <trans-unit id="6030453331794586802" datatype="html">
<source>Error downloading document</source> <source>Error downloading document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1176</context> <context context-type="linenumber">1189</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4458954481601077369" datatype="html"> <trans-unit id="4458954481601077369" datatype="html">
<source>Page Fit</source> <source>Page Fit</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1253</context> <context context-type="linenumber">1266</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4663705961777238777" datatype="html"> <trans-unit id="4663705961777238777" datatype="html">
<source>PDF edit operation for &quot;<x id="PH" equiv-text="this.document.title"/>&quot; will begin in the background.</source> <source>PDF edit operation for &quot;<x id="PH" equiv-text="this.document.title"/>&quot; will begin in the background.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1491</context> <context context-type="linenumber">1504</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="9043972994040261999" datatype="html"> <trans-unit id="9043972994040261999" datatype="html">
<source>Error executing PDF edit operation</source> <source>Error executing PDF edit operation</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1503</context> <context context-type="linenumber">1516</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6172690334763056188" datatype="html"> <trans-unit id="6172690334763056188" datatype="html">
<source>Please enter the current password before attempting to remove it.</source> <source>Please enter the current password before attempting to remove it.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1514</context> <context context-type="linenumber">1527</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="968660764814228922" datatype="html"> <trans-unit id="968660764814228922" datatype="html">
<source>Password removal operation for &quot;<x id="PH" equiv-text="this.document.title"/>&quot; will begin in the background.</source> <source>Password removal operation for &quot;<x id="PH" equiv-text="this.document.title"/>&quot; will begin in the background.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1546</context> <context context-type="linenumber">1559</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2282118435712883014" datatype="html"> <trans-unit id="2282118435712883014" datatype="html">
<source>Error executing password removal operation</source> <source>Error executing password removal operation</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1560</context> <context context-type="linenumber">1573</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3740891324955700797" datatype="html"> <trans-unit id="3740891324955700797" datatype="html">
<source>Print failed.</source> <source>Print failed.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1597</context> <context context-type="linenumber">1610</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6457245677384603573" datatype="html"> <trans-unit id="6457245677384603573" datatype="html">
<source>Error loading document for printing.</source> <source>Error loading document for printing.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1609</context> <context context-type="linenumber">1622</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6085793215710522488" datatype="html"> <trans-unit id="6085793215710522488" datatype="html">
<source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source> <source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1674</context> <context context-type="linenumber">1687</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1678</context> <context context-type="linenumber">1691</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4958946940233632319" datatype="html"> <trans-unit id="4958946940233632319" datatype="html">

View File

@@ -259,7 +259,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-xl-6 ps-xl-5"> <div class="col-xl-6 ps-xl-5">
<h5 class="mt-3" i18n>Bulk editing</h5> <h5 class="mt-3" i18n>Bulk editing</h5>
<div class="row mb-3"> <div class="row mb-3">
@@ -269,19 +268,6 @@
</div> </div>
</div> </div>
<h5 class="mt-3" i18n>PDF Editor</h5>
<div class="row">
<div class="col-md-3 col-form-label pt-0">
<span i18n>Default editing mode</span>
</div>
<div class="col">
<select class="form-select" formControlName="pdfEditorDefaultEditMode">
<option [ngValue]="PdfEditorEditMode.Create" i18n>Create new document(s)</option>
<option [ngValue]="PdfEditorEditMode.Update" i18n>Update existing document</option>
</select>
</div>
</div>
<h5 class="mt-3" i18n>Notes</h5> <h5 class="mt-3" i18n>Notes</h5>
<div class="row mb-3"> <div class="row mb-3">
<div class="col"> <div class="col">

View File

@@ -251,7 +251,7 @@ describe('SettingsComponent', () => {
expect(toastErrorSpy).toHaveBeenCalled() expect(toastErrorSpy).toHaveBeenCalled()
expect(storeSpy).toHaveBeenCalled() expect(storeSpy).toHaveBeenCalled()
expect(appearanceSettingsSpy).not.toHaveBeenCalled() expect(appearanceSettingsSpy).not.toHaveBeenCalled()
expect(setSpy).toHaveBeenCalledTimes(32) expect(setSpy).toHaveBeenCalledTimes(31)
// succeed // succeed
storeSpy.mockReturnValueOnce(of(true)) storeSpy.mockReturnValueOnce(of(true))

View File

@@ -64,9 +64,8 @@ import { PermissionsGroupComponent } from '../../common/input/permissions/permis
import { PermissionsUserComponent } from '../../common/input/permissions/permissions-user/permissions-user.component' import { PermissionsUserComponent } from '../../common/input/permissions/permissions-user/permissions-user.component'
import { SelectComponent } from '../../common/input/select/select.component' import { SelectComponent } from '../../common/input/select/select.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component' import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { PdfEditorEditMode } from '../../common/pdf-editor/pdf-editor-edit-mode'
import { SystemStatusDialogComponent } from '../../common/system-status-dialog/system-status-dialog.component' import { SystemStatusDialogComponent } from '../../common/system-status-dialog/system-status-dialog.component'
import { ZoomSetting } from '../../document-detail/zoom-setting' import { ZoomSetting } from '../../document-detail/document-detail.component'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component' import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
enum SettingsNavIDs { enum SettingsNavIDs {
@@ -164,7 +163,6 @@ export class SettingsComponent
defaultPermsEditGroups: new FormControl(null), defaultPermsEditGroups: new FormControl(null),
useNativePdfViewer: new FormControl(null), useNativePdfViewer: new FormControl(null),
pdfViewerDefaultZoom: new FormControl(null), pdfViewerDefaultZoom: new FormControl(null),
pdfEditorDefaultEditMode: new FormControl(null),
documentEditingRemoveInboxTags: new FormControl(null), documentEditingRemoveInboxTags: new FormControl(null),
documentEditingOverlayThumbnail: new FormControl(null), documentEditingOverlayThumbnail: new FormControl(null),
documentDetailsHiddenFields: new FormControl([]), documentDetailsHiddenFields: new FormControl([]),
@@ -198,8 +196,6 @@ export class SettingsComponent
public readonly ZoomSetting = ZoomSetting public readonly ZoomSetting = ZoomSetting
public readonly PdfEditorEditMode = PdfEditorEditMode
public readonly documentDetailFieldOptions = documentDetailFieldOptions public readonly documentDetailFieldOptions = documentDetailFieldOptions
get systemStatusHasErrors(): boolean { get systemStatusHasErrors(): boolean {
@@ -318,9 +314,6 @@ export class SettingsComponent
pdfViewerDefaultZoom: this.settings.get( pdfViewerDefaultZoom: this.settings.get(
SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING
), ),
pdfEditorDefaultEditMode: this.settings.get(
SETTINGS_KEYS.PDF_EDITOR_DEFAULT_EDIT_MODE
),
displayLanguage: this.settings.getLanguage(), displayLanguage: this.settings.getLanguage(),
dateLocale: this.settings.get(SETTINGS_KEYS.DATE_LOCALE), dateLocale: this.settings.get(SETTINGS_KEYS.DATE_LOCALE),
dateFormat: this.settings.get(SETTINGS_KEYS.DATE_FORMAT), dateFormat: this.settings.get(SETTINGS_KEYS.DATE_FORMAT),
@@ -490,10 +483,6 @@ export class SettingsComponent
SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING, SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING,
this.settingsForm.value.pdfViewerDefaultZoom this.settingsForm.value.pdfViewerDefaultZoom
) )
this.settings.set(
SETTINGS_KEYS.PDF_EDITOR_DEFAULT_EDIT_MODE,
this.settingsForm.value.pdfEditorDefaultEditMode
)
this.settings.set( this.settings.set(
SETTINGS_KEYS.DATE_LOCALE, SETTINGS_KEYS.DATE_LOCALE,
this.settingsForm.value.dateLocale this.settingsForm.value.dateLocale

View File

@@ -248,7 +248,7 @@ main {
} }
} }
@media screen and (min-width: 376px) and (max-width: 768px) { @media screen and (min-width: 366px) and (max-width: 768px) {
.navbar-toggler { .navbar-toggler {
// compensate for 2 buttons on the right // compensate for 2 buttons on the right
margin-right: 45px; margin-right: 45px;

View File

@@ -430,6 +430,24 @@
</div> </div>
</div> </div>
} }
@case (WorkflowActionType.PasswordRemoval) {
<div class="row">
<div class="col">
<p class="small" i18n>
One or more passwords separated by commas or new lines. The workflow will try them in order until one succeeds.
</p>
<pngx-input-textarea
i18n-title
title="Passwords"
formControlName="passwords"
rows="4"
[error]="error?.actions?.[i]?.passwords"
hint="Passwords are stored in plain text. Use with caution."
i18n-hint
></pngx-input-textarea>
</div>
</div>
}
} }
</div> </div>
</ng-template> </ng-template>

View File

@@ -139,6 +139,10 @@ export const WORKFLOW_ACTION_OPTIONS = [
id: WorkflowActionType.Webhook, id: WorkflowActionType.Webhook,
name: $localize`Webhook`, name: $localize`Webhook`,
}, },
{
id: WorkflowActionType.PasswordRemoval,
name: $localize`Password removal`,
},
] ]
export enum TriggerFilterType { export enum TriggerFilterType {
@@ -1202,6 +1206,7 @@ export class WorkflowEditDialogComponent
headers: new FormControl(action.webhook?.headers), headers: new FormControl(action.webhook?.headers),
include_document: new FormControl(!!action.webhook?.include_document), include_document: new FormControl(!!action.webhook?.include_document),
}), }),
passwords: new FormControl(action.passwords),
}), }),
{ emitEvent } { emitEvent }
) )

View File

@@ -1,4 +0,0 @@
export enum PdfEditorEditMode {
Update = 'update',
Create = 'create',
}

View File

@@ -8,11 +8,8 @@ import { FormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer' import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { DocumentService } from 'src/app/services/rest/document.service' import { DocumentService } from 'src/app/services/rest/document.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component'
import { PdfEditorEditMode } from './pdf-editor-edit-mode'
interface PageOperation { interface PageOperation {
page: number page: number
@@ -22,6 +19,11 @@ interface PageOperation {
loaded?: boolean loaded?: boolean
} }
export enum PdfEditorEditMode {
Update = 'update',
Create = 'create',
}
@Component({ @Component({
selector: 'pngx-pdf-editor', selector: 'pngx-pdf-editor',
templateUrl: './pdf-editor.component.html', templateUrl: './pdf-editor.component.html',
@@ -37,15 +39,12 @@ export class PDFEditorComponent extends ConfirmDialogComponent {
public PdfEditorEditMode = PdfEditorEditMode public PdfEditorEditMode = PdfEditorEditMode
private documentService = inject(DocumentService) private documentService = inject(DocumentService)
private readonly settingsService = inject(SettingsService)
activeModal: NgbActiveModal = inject(NgbActiveModal) activeModal: NgbActiveModal = inject(NgbActiveModal)
documentID: number documentID: number
pages: PageOperation[] = [] pages: PageOperation[] = []
totalPages = 0 totalPages = 0
editMode: PdfEditorEditMode = this.settingsService.get( editMode: PdfEditorEditMode = PdfEditorEditMode.Create
SETTINGS_KEYS.PDF_EDITOR_DEFAULT_EDIT_MODE
)
deleteOriginal: boolean = false deleteOriginal: boolean = false
includeMetadata: boolean = true includeMetadata: boolean = true

View File

@@ -69,8 +69,10 @@ import { environment } from 'src/environments/environment'
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component' import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component' import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
import { DocumentDetailComponent } from './document-detail.component' import {
import { ZoomSetting } from './zoom-setting' DocumentDetailComponent,
ZoomSetting,
} from './document-detail.component'
const doc: Document = { const doc: Document = {
id: 3, id: 3,

View File

@@ -106,15 +106,16 @@ import { TextComponent } from '../common/input/text/text.component'
import { TextAreaComponent } from '../common/input/textarea/textarea.component' import { TextAreaComponent } from '../common/input/textarea/textarea.component'
import { UrlComponent } from '../common/input/url/url.component' import { UrlComponent } from '../common/input/url/url.component'
import { PageHeaderComponent } from '../common/page-header/page-header.component' import { PageHeaderComponent } from '../common/page-header/page-header.component'
import { PdfEditorEditMode } from '../common/pdf-editor/pdf-editor-edit-mode' import {
import { PDFEditorComponent } from '../common/pdf-editor/pdf-editor.component' PDFEditorComponent,
PdfEditorEditMode,
} from '../common/pdf-editor/pdf-editor.component'
import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-links-dialog.component' import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-links-dialog.component'
import { SuggestionsDropdownComponent } from '../common/suggestions-dropdown/suggestions-dropdown.component' import { SuggestionsDropdownComponent } from '../common/suggestions-dropdown/suggestions-dropdown.component'
import { DocumentHistoryComponent } from '../document-history/document-history.component' import { DocumentHistoryComponent } from '../document-history/document-history.component'
import { DocumentNotesComponent } from '../document-notes/document-notes.component' import { DocumentNotesComponent } from '../document-notes/document-notes.component'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component' import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
import { MetadataCollapseComponent } from './metadata-collapse/metadata-collapse.component' import { MetadataCollapseComponent } from './metadata-collapse/metadata-collapse.component'
import { ZoomSetting } from './zoom-setting'
enum DocumentDetailNavIDs { enum DocumentDetailNavIDs {
Details = 1, Details = 1,
@@ -136,6 +137,18 @@ enum ContentRenderType {
TIFF = 'tiff', TIFF = 'tiff',
} }
export enum ZoomSetting {
PageFit = 'page-fit',
PageWidth = 'page-width',
Quarter = '.25',
Half = '.5',
ThreeQuarters = '.75',
One = '1',
OneAndHalf = '1.5',
Two = '2',
Three = '3',
}
@Component({ @Component({
selector: 'pngx-document-detail', selector: 'pngx-document-detail',
templateUrl: './document-detail.component.html', templateUrl: './document-detail.component.html',
@@ -170,6 +183,7 @@ enum ContentRenderType {
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
PdfViewerModule, PdfViewerModule,
TextAreaComponent, TextAreaComponent,
PasswordRemovalConfirmDialogComponent,
RouterModule, RouterModule,
], ],
}) })

View File

@@ -1,11 +0,0 @@
export enum ZoomSetting {
PageFit = 'page-fit',
PageWidth = 'page-width',
Quarter = '.25',
Half = '.5',
ThreeQuarters = '.75',
One = '1',
OneAndHalf = '1.5',
Two = '2',
Three = '3',
}

View File

@@ -1,5 +1,3 @@
import { PdfEditorEditMode } from '../components/common/pdf-editor/pdf-editor-edit-mode'
import { ZoomSetting } from '../components/document-detail/zoom-setting'
import { User } from './user' import { User } from './user'
export interface UiSettings { export interface UiSettings {
@@ -76,8 +74,6 @@ export const SETTINGS_KEYS = {
'general-settings:document-details:hidden-fields', 'general-settings:document-details:hidden-fields',
SEARCH_DB_ONLY: 'general-settings:search:db-only', SEARCH_DB_ONLY: 'general-settings:search:db-only',
SEARCH_FULL_TYPE: 'general-settings:search:more-link', SEARCH_FULL_TYPE: 'general-settings:search:more-link',
PDF_EDITOR_DEFAULT_EDIT_MODE:
'general-settings:document-editing:default-edit-mode',
EMPTY_TRASH_DELAY: 'trash_delay', EMPTY_TRASH_DELAY: 'trash_delay',
GMAIL_OAUTH_URL: 'gmail_oauth_url', GMAIL_OAUTH_URL: 'gmail_oauth_url',
OUTLOOK_OAUTH_URL: 'outlook_oauth_url', OUTLOOK_OAUTH_URL: 'outlook_oauth_url',
@@ -299,16 +295,11 @@ export const SETTINGS: UiSetting[] = [
{ {
key: SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING, key: SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING,
type: 'string', type: 'string',
default: ZoomSetting.PageWidth, default: 'page-width', // ZoomSetting from 'document-detail.component'
}, },
{ {
key: SETTINGS_KEYS.AI_ENABLED, key: SETTINGS_KEYS.AI_ENABLED,
type: 'boolean', type: 'boolean',
default: false, default: false,
}, },
{
key: SETTINGS_KEYS.PDF_EDITOR_DEFAULT_EDIT_MODE,
type: 'string',
default: PdfEditorEditMode.Create,
},
] ]

View File

@@ -5,6 +5,7 @@ export enum WorkflowActionType {
Removal = 2, Removal = 2,
Email = 3, Email = 3,
Webhook = 4, Webhook = 4,
PasswordRemoval = 5,
} }
export interface WorkflowActionEmail extends ObjectWithId { export interface WorkflowActionEmail extends ObjectWithId {
@@ -97,4 +98,6 @@ export interface WorkflowAction extends ObjectWithId {
email?: WorkflowActionEmail email?: WorkflowActionEmail
webhook?: WorkflowActionWebhook webhook?: WorkflowActionWebhook
passwords?: string
} }

View File

@@ -1,41 +1,30 @@
import { import { HttpEvent, HttpRequest } from '@angular/common/http'
HttpClient,
provideHttpClient,
withInterceptors,
} from '@angular/common/http'
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing' import { TestBed } from '@angular/core/testing'
import { of } from 'rxjs'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { withApiVersionInterceptor } from './api-version.interceptor' import { ApiVersionInterceptor } from './api-version.interceptor'
describe('ApiVersionInterceptor', () => { describe('ApiVersionInterceptor', () => {
let httpClient: HttpClient let interceptor: ApiVersionInterceptor
let httpMock: HttpTestingController
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [ApiVersionInterceptor],
provideHttpClient(withInterceptors([withApiVersionInterceptor])),
provideHttpClientTesting(),
],
}) })
httpClient = TestBed.inject(HttpClient) interceptor = TestBed.inject(ApiVersionInterceptor)
httpMock = TestBed.inject(HttpTestingController)
}) })
it('should add api version to headers', () => { it('should add api version to headers', () => {
httpClient.get('https://example.com').subscribe() interceptor.intercept(new HttpRequest('GET', 'https://example.com'), {
const request = httpMock.expectOne('https://example.com') handle: (request) => {
const header = request.request.headers['lazyUpdate'][0] const header = request.headers['lazyUpdate'][0]
expect(header.name).toEqual('Accept')
expect(header.name).toEqual('Accept') expect(header.value).toEqual(
expect(header.value).toEqual( `application/json; version=${environment.apiVersion}`
`application/json; version=${environment.apiVersion}` )
) return of({} as HttpEvent<any>)
request.flush({}) },
})
}) })
}) })

View File

@@ -1,20 +1,27 @@
import { import {
HttpEvent, HttpEvent,
HttpHandlerFn, HttpHandler,
HttpInterceptorFn, HttpInterceptor,
HttpRequest, HttpRequest,
} from '@angular/common/http' } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { Observable } from 'rxjs' import { Observable } from 'rxjs'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
export const withApiVersionInterceptor: HttpInterceptorFn = ( @Injectable()
request: HttpRequest<unknown>, export class ApiVersionInterceptor implements HttpInterceptor {
next: HttpHandlerFn constructor() {}
): Observable<HttpEvent<unknown>> => {
request = request.clone({ intercept(
setHeaders: { request: HttpRequest<unknown>,
Accept: `application/json; version=${environment.apiVersion}`, next: HttpHandler
}, ): Observable<HttpEvent<unknown>> {
}) request = request.clone({
return next(request) setHeaders: {
Accept: `application/json; version=${environment.apiVersion}`,
},
})
return next.handle(request)
}
} }

View File

@@ -1,52 +1,35 @@
import { import { HttpEvent, HttpRequest } from '@angular/common/http'
HttpClient,
provideHttpClient,
withInterceptors,
} from '@angular/common/http'
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing' import { TestBed } from '@angular/core/testing'
import { Meta } from '@angular/platform-browser' import { Meta } from '@angular/platform-browser'
import { CookieService } from 'ngx-cookie-service' import { CookieService } from 'ngx-cookie-service'
import { withCsrfInterceptor } from './csrf.interceptor' import { of } from 'rxjs'
import { CsrfInterceptor } from './csrf.interceptor'
describe('CsrfInterceptor', () => { describe('CsrfInterceptor', () => {
let interceptor: CsrfInterceptor
let meta: Meta let meta: Meta
let cookieService: CookieService let cookieService: CookieService
let httpClient: HttpClient
let httpMock: HttpTestingController
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [CsrfInterceptor, Meta, CookieService],
Meta,
CookieService,
provideHttpClient(withInterceptors([withCsrfInterceptor])),
provideHttpClientTesting(),
],
}) })
meta = TestBed.inject(Meta) meta = TestBed.inject(Meta)
cookieService = TestBed.inject(CookieService) cookieService = TestBed.inject(CookieService)
httpClient = TestBed.inject(HttpClient) interceptor = TestBed.inject(CsrfInterceptor)
httpMock = TestBed.inject(HttpTestingController)
}) })
it('should get csrf token', () => { it('should get csrf token', () => {
meta.addTag({ name: 'cookie_prefix', content: 'ngx-' }, true) meta.addTag({ name: 'cookie_prefix', content: 'ngx-' }, true)
const cookieServiceSpy = jest.spyOn(cookieService, 'get') const cookieServiceSpy = jest.spyOn(cookieService, 'get')
cookieServiceSpy.mockReturnValue('csrftoken') cookieServiceSpy.mockReturnValue('csrftoken')
interceptor.intercept(new HttpRequest('GET', 'https://example.com'), {
httpClient.get('https://example.com').subscribe() handle: (request) => {
const request = httpMock.expectOne('https://example.com') expect(request.headers['lazyUpdate'][0]['name']).toEqual('X-CSRFToken')
return of({} as HttpEvent<any>)
expect(request.request.headers['lazyUpdate'][0]['name']).toEqual( },
'X-CSRFToken' })
)
expect(cookieServiceSpy).toHaveBeenCalled() expect(cookieServiceSpy).toHaveBeenCalled()
request.flush({})
}) })
}) })

View File

@@ -1,32 +1,36 @@
import { import {
HttpEvent, HttpEvent,
HttpHandlerFn, HttpHandler,
HttpInterceptorFn, HttpInterceptor,
HttpRequest, HttpRequest,
} from '@angular/common/http' } from '@angular/common/http'
import { inject } from '@angular/core' import { inject, Injectable } from '@angular/core'
import { Meta } from '@angular/platform-browser' import { Meta } from '@angular/platform-browser'
import { CookieService } from 'ngx-cookie-service' import { CookieService } from 'ngx-cookie-service'
import { Observable } from 'rxjs' import { Observable } from 'rxjs'
export const withCsrfInterceptor: HttpInterceptorFn = ( @Injectable()
request: HttpRequest<unknown>, export class CsrfInterceptor implements HttpInterceptor {
next: HttpHandlerFn private cookieService: CookieService = inject(CookieService)
): Observable<HttpEvent<unknown>> => { private meta: Meta = inject(Meta)
const cookieService: CookieService = inject(CookieService)
const meta: Meta = inject(Meta)
let prefix = '' intercept(
if (meta.getTag('name=cookie_prefix')) { request: HttpRequest<unknown>,
prefix = meta.getTag('name=cookie_prefix').content next: HttpHandler
): Observable<HttpEvent<unknown>> {
let prefix = ''
if (this.meta.getTag('name=cookie_prefix')) {
prefix = this.meta.getTag('name=cookie_prefix').content
}
let csrfToken = this.cookieService.get(`${prefix}csrftoken`)
if (csrfToken) {
request = request.clone({
setHeaders: {
'X-CSRFToken': csrfToken,
},
})
}
return next.handle(request)
} }
let csrfToken = cookieService.get(`${prefix}csrftoken`)
if (csrfToken) {
request = request.clone({
setHeaders: {
'X-CSRFToken': csrfToken,
},
})
}
return next(request)
} }

View File

@@ -1,16 +1,16 @@
import { import {
APP_INITIALIZER,
enableProdMode,
importProvidersFrom, importProvidersFrom,
inject,
provideAppInitializer,
provideZoneChangeDetection, provideZoneChangeDetection,
} from '@angular/core' } from '@angular/core'
import { DragDropModule } from '@angular/cdk/drag-drop' import { DragDropModule } from '@angular/cdk/drag-drop'
import { DatePipe, registerLocaleData } from '@angular/common' import { DatePipe, registerLocaleData } from '@angular/common'
import { import {
HTTP_INTERCEPTORS,
provideHttpClient, provideHttpClient,
withFetch, withFetch,
withInterceptors,
withInterceptorsFromDi, withInterceptorsFromDi,
} from '@angular/common/http' } from '@angular/common/http'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
@@ -151,14 +151,15 @@ import { AppComponent } from './app/app.component'
import { DirtyDocGuard } from './app/guards/dirty-doc.guard' import { DirtyDocGuard } from './app/guards/dirty-doc.guard'
import { DirtySavedViewGuard } from './app/guards/dirty-saved-view.guard' import { DirtySavedViewGuard } from './app/guards/dirty-saved-view.guard'
import { PermissionsGuard } from './app/guards/permissions.guard' import { PermissionsGuard } from './app/guards/permissions.guard'
import { withApiVersionInterceptor } from './app/interceptors/api-version.interceptor' import { ApiVersionInterceptor } from './app/interceptors/api-version.interceptor'
import { withCsrfInterceptor } from './app/interceptors/csrf.interceptor' import { CsrfInterceptor } from './app/interceptors/csrf.interceptor'
import { DocumentTitlePipe } from './app/pipes/document-title.pipe' import { DocumentTitlePipe } from './app/pipes/document-title.pipe'
import { FilterPipe } from './app/pipes/filter.pipe' import { FilterPipe } from './app/pipes/filter.pipe'
import { UsernamePipe } from './app/pipes/username.pipe' import { UsernamePipe } from './app/pipes/username.pipe'
import { SettingsService } from './app/services/settings.service' import { SettingsService } from './app/services/settings.service'
import { LocalizedDateParserFormatter } from './app/utils/ngb-date-parser-formatter' import { LocalizedDateParserFormatter } from './app/utils/ngb-date-parser-formatter'
import { ISODateAdapter } from './app/utils/ngb-iso-date-adapter' import { ISODateAdapter } from './app/utils/ngb-iso-date-adapter'
import { environment } from './environments/environment'
import localeAf from '@angular/common/locales/af' import localeAf from '@angular/common/locales/af'
import localeAr from '@angular/common/locales/ar' import localeAr from '@angular/common/locales/ar'
@@ -236,11 +237,11 @@ registerLocaleData(localeUk)
registerLocaleData(localeZh) registerLocaleData(localeZh)
registerLocaleData(localeZhHant) registerLocaleData(localeZhHant)
function initializeApp() { function initializeApp(settings: SettingsService) {
const settings = inject(SettingsService) return () => {
return settings.initializeSettings() return settings.initializeSettings()
}
} }
const icons = { const icons = {
airplane, airplane,
archive, archive,
@@ -362,6 +363,10 @@ const icons = {
xLg, xLg,
} }
if (environment.production) {
enableProdMode()
}
bootstrapApplication(AppComponent, { bootstrapApplication(AppComponent, {
providers: [ providers: [
provideZoneChangeDetection(), provideZoneChangeDetection(),
@@ -378,9 +383,24 @@ bootstrapApplication(AppComponent, {
DragDropModule, DragDropModule,
NgxBootstrapIconsModule.pick(icons) NgxBootstrapIconsModule.pick(icons)
), ),
provideAppInitializer(initializeApp), {
provide: APP_INITIALIZER,
useFactory: initializeApp,
deps: [SettingsService],
multi: true,
},
DatePipe, DatePipe,
CookieService, CookieService,
{
provide: HTTP_INTERCEPTORS,
useClass: CsrfInterceptor,
multi: true,
},
{
provide: HTTP_INTERCEPTORS,
useClass: ApiVersionInterceptor,
multi: true,
},
FilterPipe, FilterPipe,
DocumentTitlePipe, DocumentTitlePipe,
{ provide: NgbDateAdapter, useClass: ISODateAdapter }, { provide: NgbDateAdapter, useClass: ISODateAdapter },
@@ -392,10 +412,6 @@ bootstrapApplication(AppComponent, {
CorrespondentNamePipe, CorrespondentNamePipe,
DocumentTypeNamePipe, DocumentTypeNamePipe,
StoragePathNamePipe, StoragePathNamePipe,
provideHttpClient( provideHttpClient(withInterceptorsFromDi(), withFetch()),
withInterceptorsFromDi(),
withInterceptors([withCsrfInterceptor, withApiVersionInterceptor]),
withFetch()
),
], ],
}).catch((err) => console.error(err)) }).catch((err) => console.error(err))

View File

@@ -501,22 +501,9 @@ class Command(BaseCommand):
stability_timeout_ms = int(stability_delay * 1000) stability_timeout_ms = int(stability_delay * 1000)
testing_timeout_ms = int(self.testing_timeout_s * 1000) testing_timeout_ms = int(self.testing_timeout_s * 1000)
# Calculate appropriate timeout for watch loop # Start with no timeout (wait indefinitely for first event)
# In polling mode, rust_timeout must be significantly longer than poll_delay_ms # unless in testing mode
# to ensure poll cycles can complete before timing out timeout_ms = testing_timeout_ms if is_testing else 0
if is_testing:
if use_polling:
# For polling: timeout must be at least 3x the poll interval to allow
# multiple poll cycles. This prevents timeouts from interfering with
# the polling mechanism.
min_polling_timeout_ms = poll_delay_ms * 3
timeout_ms = max(min_polling_timeout_ms, testing_timeout_ms)
else:
# For native watching, use short timeout to check stop flag
timeout_ms = testing_timeout_ms
else:
# Not testing, wait indefinitely for first event
timeout_ms = 0
self.stop_flag.clear() self.stop_flag.clear()
@@ -556,14 +543,8 @@ class Command(BaseCommand):
# Check pending files at stability interval # Check pending files at stability interval
timeout_ms = stability_timeout_ms timeout_ms = stability_timeout_ms
elif is_testing: elif is_testing:
# In testing, use appropriate timeout based on watch mode # In testing, use short timeout to check stop flag
if use_polling: timeout_ms = testing_timeout_ms
# For polling: ensure timeout allows polls to complete
min_polling_timeout_ms = poll_delay_ms * 3
timeout_ms = max(min_polling_timeout_ms, testing_timeout_ms)
else:
# For native watching, use short timeout to check stop flag
timeout_ms = testing_timeout_ms
else: # pragma: nocover else: # pragma: nocover
# No pending files, wait indefinitely # No pending files, wait indefinitely
timeout_ms = 0 timeout_ms = 0

View File

@@ -0,0 +1,38 @@
# Generated by Django 5.2.7 on 2025-12-29 03:56
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("documents", "0008_sharelinkbundle"),
]
operations = [
migrations.AddField(
model_name="workflowaction",
name="passwords",
field=models.TextField(
blank=True,
help_text="Passwords to try when removing PDF protection. Separate with commas or new lines.",
null=True,
verbose_name="passwords",
),
),
migrations.AlterField(
model_name="workflowaction",
name="type",
field=models.PositiveIntegerField(
choices=[
(1, "Assignment"),
(2, "Removal"),
(3, "Email"),
(4, "Webhook"),
(5, "Password removal"),
],
default=1,
verbose_name="Workflow Action Type",
),
),
]

View File

@@ -1405,6 +1405,10 @@ class WorkflowAction(models.Model):
4, 4,
_("Webhook"), _("Webhook"),
) )
PASSWORD_REMOVAL = (
5,
_("Password removal"),
)
type = models.PositiveIntegerField( type = models.PositiveIntegerField(
_("Workflow Action Type"), _("Workflow Action Type"),
@@ -1634,6 +1638,15 @@ class WorkflowAction(models.Model):
verbose_name=_("webhook"), verbose_name=_("webhook"),
) )
passwords = models.TextField(
_("passwords"),
null=True,
blank=True,
help_text=_(
"Passwords to try when removing PDF protection. Separate with commas or new lines.",
),
)
class Meta: class Meta:
verbose_name = _("workflow action") verbose_name = _("workflow action")
verbose_name_plural = _("workflow actions") verbose_name_plural = _("workflow actions")

View File

@@ -2613,6 +2613,7 @@ class WorkflowActionSerializer(serializers.ModelSerializer):
"remove_change_groups", "remove_change_groups",
"email", "email",
"webhook", "webhook",
"passwords",
] ]
def validate(self, attrs): def validate(self, attrs):
@@ -2669,6 +2670,20 @@ class WorkflowActionSerializer(serializers.ModelSerializer):
"Webhook data is required for webhook actions", "Webhook data is required for webhook actions",
) )
if (
"type" in attrs
and attrs["type"] == WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL
):
passwords = attrs.get("passwords")
if passwords is None or not isinstance(passwords, str):
raise serializers.ValidationError(
"Passwords are required for password removal actions",
)
if not passwords.strip():
raise serializers.ValidationError(
"Passwords are required for password removal actions",
)
return attrs return attrs

View File

@@ -48,6 +48,7 @@ from documents.permissions import get_objects_for_user_owner_aware
from documents.templating.utils import convert_format_str_to_template_format from documents.templating.utils import convert_format_str_to_template_format
from documents.workflows.actions import build_workflow_action_context from documents.workflows.actions import build_workflow_action_context
from documents.workflows.actions import execute_email_action from documents.workflows.actions import execute_email_action
from documents.workflows.actions import execute_password_removal_action
from documents.workflows.actions import execute_webhook_action from documents.workflows.actions import execute_webhook_action
from documents.workflows.mutations import apply_assignment_to_document from documents.workflows.mutations import apply_assignment_to_document
from documents.workflows.mutations import apply_assignment_to_overrides from documents.workflows.mutations import apply_assignment_to_overrides
@@ -822,6 +823,8 @@ def run_workflows(
logging_group, logging_group,
original_file, original_file,
) )
elif action.type == WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL:
execute_password_removal_action(action, document, logging_group)
if not use_overrides: if not use_overrides:
# limit title to 128 characters # limit title to 128 characters

View File

@@ -838,3 +838,57 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.action.refresh_from_db() self.action.refresh_from_db()
self.assertEqual(self.action.assign_title, "Patched Title") self.assertEqual(self.action.assign_title, "Patched Title")
def test_password_action_passwords_field(self):
"""
GIVEN:
- Nothing
WHEN:
- A workflow password removal action is created with passwords set
THEN:
- The passwords field is correctly stored and retrieved
"""
passwords = "password1,password2\npassword3"
response = self.client.post(
"/api/workflow_actions/",
{
"type": WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
"passwords": passwords,
},
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data["passwords"], passwords)
def test_password_action_no_passwords_field(self):
"""
GIVEN:
- Nothing
WHEN:
- A workflow password removal action is created with no passwords set
- A workflow password removal action is created with passwords set to empty string
THEN:
- The required validation error is raised
"""
response = self.client.post(
"/api/workflow_actions/",
{
"type": WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
},
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(
"Passwords are required",
str(response.data["non_field_errors"][0]),
)
response = self.client.post(
"/api/workflow_actions/",
{
"type": WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
"passwords": "",
},
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(
"Passwords are required",
str(response.data["non_field_errors"][0]),
)

View File

@@ -114,30 +114,6 @@ def mock_supported_extensions(mocker: MockerFixture) -> MagicMock:
) )
def wait_for_mock_call(
mock_obj: MagicMock,
timeout_s: float = 5.0,
poll_interval_s: float = 0.1,
) -> bool:
"""
Actively wait for a mock to be called.
Args:
mock_obj: The mock object to check (e.g., mock.delay)
timeout_s: Maximum time to wait in seconds
poll_interval_s: How often to check in seconds
Returns:
True if mock was called within timeout, False otherwise
"""
start_time = monotonic()
while monotonic() - start_time < timeout_s:
if mock_obj.called:
return True
sleep(poll_interval_s)
return False
class TestTrackedFile: class TestTrackedFile:
"""Tests for the TrackedFile dataclass.""" """Tests for the TrackedFile dataclass."""
@@ -748,7 +724,7 @@ def start_consumer(
thread = ConsumerThread(consumption_dir, scratch_dir, **kwargs) thread = ConsumerThread(consumption_dir, scratch_dir, **kwargs)
threads.append(thread) threads.append(thread)
thread.start() thread.start()
sleep(2.0) # Give thread time to start sleep(0.5) # Give thread time to start
return thread return thread
try: try:
@@ -791,8 +767,7 @@ class TestCommandWatch:
target = consumption_dir / "document.pdf" target = consumption_dir / "document.pdf"
shutil.copy(sample_pdf, target) shutil.copy(sample_pdf, target)
sleep(0.5)
wait_for_mock_call(mock_consume_file_delay.delay, timeout_s=2.0)
if thread.exception: if thread.exception:
raise thread.exception raise thread.exception
@@ -813,12 +788,9 @@ class TestCommandWatch:
thread = start_consumer() thread = start_consumer()
sleep(0.5)
target = consumption_dir / "document.pdf" target = consumption_dir / "document.pdf"
shutil.move(temp_location, target) shutil.move(temp_location, target)
sleep(0.5)
wait_for_mock_call(mock_consume_file_delay.delay, timeout_s=2.0)
if thread.exception: if thread.exception:
raise thread.exception raise thread.exception
@@ -844,7 +816,7 @@ class TestCommandWatch:
f.flush() f.flush()
sleep(0.05) sleep(0.05)
wait_for_mock_call(mock_consume_file_delay.delay, timeout_s=2.0) sleep(0.5)
if thread.exception: if thread.exception:
raise thread.exception raise thread.exception
@@ -865,7 +837,7 @@ class TestCommandWatch:
(consumption_dir / "._document.pdf").write_bytes(b"test") (consumption_dir / "._document.pdf").write_bytes(b"test")
shutil.copy(sample_pdf, consumption_dir / "valid.pdf") shutil.copy(sample_pdf, consumption_dir / "valid.pdf")
wait_for_mock_call(mock_consume_file_delay.delay, timeout_s=2.0) sleep(0.5)
if thread.exception: if thread.exception:
raise thread.exception raise thread.exception
@@ -896,10 +868,11 @@ class TestCommandWatch:
assert not thread.is_alive() assert not thread.is_alive()
@pytest.mark.django_db
class TestCommandWatchPolling: class TestCommandWatchPolling:
"""Tests for polling mode.""" """Tests for polling mode."""
@pytest.mark.django_db
@pytest.mark.flaky(reruns=2)
def test_polling_mode_works( def test_polling_mode_works(
self, self,
consumption_dir: Path, consumption_dir: Path,
@@ -909,8 +882,7 @@ class TestCommandWatchPolling:
) -> None: ) -> None:
""" """
Test polling mode detects files. Test polling mode detects files.
Note: At times, there appears to be a timing issue, where delay has not yet been called, hence this is marked as flaky.
Uses active waiting with timeout to handle CI delays and polling timing.
""" """
# Use shorter polling interval for faster test # Use shorter polling interval for faster test
thread = start_consumer(polling_interval=0.5, stability_delay=0.1) thread = start_consumer(polling_interval=0.5, stability_delay=0.1)
@@ -918,9 +890,9 @@ class TestCommandWatchPolling:
target = consumption_dir / "document.pdf" target = consumption_dir / "document.pdf"
shutil.copy(sample_pdf, target) shutil.copy(sample_pdf, target)
# Actively wait for consumption # Wait for: poll interval + stability delay + another poll + margin
# Polling needs: interval (0.5s) + stability (0.1s) + next poll (0.5s) + margin # CI can be slow, so use generous timeout
wait_for_mock_call(mock_consume_file_delay.delay, timeout_s=5.0) sleep(3.0)
if thread.exception: if thread.exception:
raise thread.exception raise thread.exception
@@ -947,8 +919,7 @@ class TestCommandWatchRecursive:
target = subdir / "document.pdf" target = subdir / "document.pdf"
shutil.copy(sample_pdf, target) shutil.copy(sample_pdf, target)
sleep(0.5)
wait_for_mock_call(mock_consume_file_delay.delay, timeout_s=2.0)
if thread.exception: if thread.exception:
raise thread.exception raise thread.exception
@@ -977,8 +948,7 @@ class TestCommandWatchRecursive:
target = subdir / "document.pdf" target = subdir / "document.pdf"
shutil.copy(sample_pdf, target) shutil.copy(sample_pdf, target)
sleep(0.5)
wait_for_mock_call(mock_consume_file_delay.delay, timeout_s=2.0)
if thread.exception: if thread.exception:
raise thread.exception raise thread.exception

View File

@@ -2,6 +2,7 @@ import datetime
import json import json
import shutil import shutil
import socket import socket
import tempfile
from datetime import timedelta from datetime import timedelta
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -60,6 +61,7 @@ from documents.tests.utils import DirectoriesMixin
from documents.tests.utils import DummyProgressManager from documents.tests.utils import DummyProgressManager
from documents.tests.utils import FileSystemAssertsMixin from documents.tests.utils import FileSystemAssertsMixin
from documents.tests.utils import SampleDirMixin from documents.tests.utils import SampleDirMixin
from documents.workflows.actions import execute_password_removal_action
from paperless_mail.models import MailAccount from paperless_mail.models import MailAccount
from paperless_mail.models import MailRule from paperless_mail.models import MailRule
@@ -3716,6 +3718,196 @@ class TestWorkflows(
mock_post.assert_called_once() mock_post.assert_called_once()
@mock.patch("documents.bulk_edit.remove_password")
def test_password_removal_action_attempts_multiple_passwords(
self,
mock_remove_password,
):
"""
GIVEN:
- Workflow password removal action
- Multiple passwords provided
WHEN:
- Document updated triggering the workflow
THEN:
- Password removal is attempted until one succeeds
"""
doc = Document.objects.create(
title="Protected",
checksum="pw-checksum",
)
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
)
action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
passwords="wrong, right\n extra ",
)
workflow = Workflow.objects.create(name="Password workflow")
workflow.triggers.add(trigger)
workflow.actions.add(action)
mock_remove_password.side_effect = [
ValueError("wrong password"),
"OK",
]
run_workflows(trigger.type, doc)
assert mock_remove_password.call_count == 2
mock_remove_password.assert_has_calls(
[
mock.call(
[doc.id],
password="wrong",
update_document=True,
user=doc.owner,
),
mock.call(
[doc.id],
password="right",
update_document=True,
user=doc.owner,
),
],
)
@mock.patch("documents.bulk_edit.remove_password")
def test_password_removal_action_fails_without_correct_password(
self,
mock_remove_password,
):
"""
GIVEN:
- Workflow password removal action
- No correct password provided
WHEN:
- Document updated triggering the workflow
THEN:
- Password removal is attempted for all passwords and fails
"""
doc = Document.objects.create(
title="Protected",
checksum="pw-checksum-2",
)
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
)
action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
passwords=" \n , ",
)
workflow = Workflow.objects.create(name="Password workflow missing passwords")
workflow.triggers.add(trigger)
workflow.actions.add(action)
run_workflows(trigger.type, doc)
mock_remove_password.assert_not_called()
@mock.patch("documents.bulk_edit.remove_password")
def test_password_removal_action_skips_without_passwords(
self,
mock_remove_password,
):
"""
GIVEN:
- Workflow password removal action with no passwords
WHEN:
- Workflow is run
THEN:
- Password removal is not attempted
"""
doc = Document.objects.create(
title="Protected",
checksum="pw-checksum-2",
)
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
)
action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
passwords="",
)
workflow = Workflow.objects.create(name="Password workflow missing passwords")
workflow.triggers.add(trigger)
workflow.actions.add(action)
run_workflows(trigger.type, doc)
mock_remove_password.assert_not_called()
@mock.patch("documents.bulk_edit.remove_password")
def test_password_removal_consumable_document_deferred(
self,
mock_remove_password,
):
"""
GIVEN:
- Workflow password removal action
- Simulated consumption trigger (a ConsumableDocument is used)
WHEN:
- Document consumption is finished
THEN:
- Password removal is attempted
"""
action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
passwords="first, second",
)
temp_dir = Path(tempfile.mkdtemp())
original_file = temp_dir / "file.pdf"
original_file.write_bytes(b"pdf content")
consumable = ConsumableDocument(
source=DocumentSource.ApiUpload,
original_file=original_file,
)
execute_password_removal_action(action, consumable, logging_group=None)
mock_remove_password.assert_not_called()
mock_remove_password.side_effect = [
ValueError("bad password"),
"OK",
]
doc = Document.objects.create(
checksum="pw-checksum-consumed",
title="Protected",
)
document_consumption_finished.send(
sender=self.__class__,
document=doc,
)
assert mock_remove_password.call_count == 2
mock_remove_password.assert_has_calls(
[
mock.call(
[doc.id],
password="first",
update_document=True,
user=doc.owner,
),
mock.call(
[doc.id],
password="second",
update_document=True,
user=doc.owner,
),
],
)
# ensure handler disconnected after first run
document_consumption_finished.send(
sender=self.__class__,
document=doc,
)
assert mock_remove_password.call_count == 2
class TestWebhookSend: class TestWebhookSend:
def test_send_webhook_data_or_json( def test_send_webhook_data_or_json(

View File

@@ -1,4 +1,5 @@
import logging import logging
import re
from pathlib import Path from pathlib import Path
from django.conf import settings from django.conf import settings
@@ -14,6 +15,7 @@ from documents.models import Document
from documents.models import DocumentType from documents.models import DocumentType
from documents.models import WorkflowAction from documents.models import WorkflowAction
from documents.models import WorkflowTrigger from documents.models import WorkflowTrigger
from documents.signals import document_consumption_finished
from documents.templating.workflows import parse_w_workflow_placeholders from documents.templating.workflows import parse_w_workflow_placeholders
from documents.workflows.webhooks import send_webhook from documents.workflows.webhooks import send_webhook
@@ -265,3 +267,74 @@ def execute_webhook_action(
f"Error occurred sending webhook: {e}", f"Error occurred sending webhook: {e}",
extra={"group": logging_group}, extra={"group": logging_group},
) )
def execute_password_removal_action(
action: WorkflowAction,
document: Document | ConsumableDocument,
logging_group,
) -> None:
"""
Try to remove a password from a document using the configured list.
"""
passwords = action.passwords
if not passwords:
logger.warning(
"Password removal action %s has no passwords configured",
action.pk,
extra={"group": logging_group},
)
return
passwords = [
password.strip()
for password in re.split(r"[,\n]", passwords)
if password.strip()
]
if isinstance(document, ConsumableDocument):
# hook the consumption-finished signal to attempt password removal later
def handler(sender, **kwargs):
consumed_document: Document = kwargs.get("document")
if consumed_document is not None:
execute_password_removal_action(
action,
consumed_document,
logging_group,
)
document_consumption_finished.disconnect(handler)
document_consumption_finished.connect(handler, weak=False)
return
# import here to avoid circular dependency
from documents.bulk_edit import remove_password
for password in passwords:
try:
remove_password(
[document.id],
password=password,
update_document=True,
user=document.owner,
)
logger.info(
"Removed password from document %s using workflow action %s",
document.pk,
action.pk,
extra={"group": logging_group},
)
return
except ValueError as e:
logger.warning(
"Password removal failed for document %s with supplied password: %s",
document.pk,
e,
extra={"group": logging_group},
)
logger.error(
"Password removal failed for document %s after trying all provided passwords",
document.pk,
extra={"group": logging_group},
)

View File

@@ -89,11 +89,3 @@ def greenmail_mail_account(db: None) -> Generator[MailAccount, None, None]:
@pytest.fixture() @pytest.fixture()
def mail_account_handler() -> MailAccountHandler: def mail_account_handler() -> MailAccountHandler:
return MailAccountHandler() return MailAccountHandler()
@pytest.fixture(scope="session")
def nginx_base_url() -> Generator[str, None, None]:
"""
The base URL for the nginx HTTP server we expect to be alive
"""
yield "http://localhost:8080"

View File

@@ -55,7 +55,7 @@ Content-Transfer-Encoding: 7bit
<p>Some Text</p> <p>Some Text</p>
<p> <p>
<img src="cid:part1.pNdUSz0s.D3NqVtPg@example.de" alt="Has to be rewritten to work.."> <img src="cid:part1.pNdUSz0s.D3NqVtPg@example.de" alt="Has to be rewritten to work..">
<img src="http://localhost:8080/assets/logo_full_white.svg" alt="This image should not be shown."> <img src="https://docs.paperless-ngx.com/assets/logo_full_white.svg" alt="This image should not be shown.">
</p> </p>
<p>and an embedded image.<br> <p>and an embedded image.<br>

View File

@@ -6,7 +6,7 @@
<p>Some Text</p> <p>Some Text</p>
<p> <p>
<img src="cid:part1.pNdUSz0s.D3NqVtPg@example.de" alt="Has to be rewritten to work.."> <img src="cid:part1.pNdUSz0s.D3NqVtPg@example.de" alt="Has to be rewritten to work..">
<img src="http://localhost:8080/assets/logo_full_white.svg" alt="This image should not be shown."> <img src="https://docs.paperless-ngx.com/assets/logo_full_white.svg" alt="This image should not be shown.">
</p> </p>
<p>and an embedded image.<br> <p>and an embedded image.<br>

View File

@@ -6,8 +6,6 @@ from paperless_mail.models import MailAccount
from paperless_mail.models import MailRule from paperless_mail.models import MailRule
@pytest.mark.live
@pytest.mark.greenmail
@pytest.mark.django_db @pytest.mark.django_db
class TestMailGreenmail: class TestMailGreenmail:
""" """

View File

@@ -17,7 +17,7 @@ from paperless_mail.parsers import MailDocumentParser
def extract_text(pdf_path: Path) -> str: def extract_text(pdf_path: Path) -> str:
""" """
Using pdftotext from poppler, extracts the text of a PDF into a file, Using pdftotext from poppler, extracts the text of a PDF into a file,
then reads the file contents and returns it. then reads the file contents and returns it
""" """
with tempfile.NamedTemporaryFile( with tempfile.NamedTemporaryFile(
mode="w+", mode="w+",
@@ -38,107 +38,71 @@ def extract_text(pdf_path: Path) -> str:
class MailAttachmentMock: class MailAttachmentMock:
def __init__(self, payload: bytes, content_id: str) -> None: def __init__(self, payload, content_id):
self.payload = payload self.payload = payload
self.content_id = content_id self.content_id = content_id
self.content_type = "image/png" self.content_type = "image/png"
@pytest.mark.live
@pytest.mark.nginx
@pytest.mark.skipif( @pytest.mark.skipif(
"PAPERLESS_CI_TEST" not in os.environ, "PAPERLESS_CI_TEST" not in os.environ,
reason="No Gotenberg/Tika servers to test with", reason="No Gotenberg/Tika servers to test with",
) )
class TestNginxService: class TestUrlCanary:
""" """
Verify the local nginx server is responding correctly. Verify certain URLs are still available so testing is valid still
These tests validate that the test infrastructure is working properly
before running the actual parser tests that depend on HTTP resources.
""" """
def test_non_existent_resource_returns_404( def test_online_image_exception_on_not_available(self):
self,
nginx_base_url: str,
) -> None:
""" """
GIVEN: GIVEN:
- Local nginx server is running - Fresh start
WHEN: WHEN:
- A non-existent resource is requested - nonexistent image is requested
THEN: THEN:
- An HTTP 404 status code shall be returned - An exception shall be thrown
"""
"""
A public image is used in the html sample file. We have no control
whether this image stays online forever, so here we check if we can detect if is not
available anymore.
""" """
resp = httpx.get( resp = httpx.get(
f"{nginx_base_url}/assets/non-existent.png", "https://docs.paperless-ngx.com/assets/non-existent.png",
timeout=5.0,
) )
with pytest.raises(httpx.HTTPStatusError) as exec_info: with pytest.raises(httpx.HTTPStatusError) as exec_info:
resp.raise_for_status() resp.raise_for_status()
assert exec_info.value.response.status_code == httpx.codes.NOT_FOUND assert exec_info.value.response.status_code == httpx.codes.NOT_FOUND
def test_valid_resource_is_available( def test_is_online_image_still_available(self):
self,
nginx_base_url: str,
) -> None:
""" """
GIVEN: GIVEN:
- Local nginx server is running - Fresh start
WHEN: WHEN:
- A valid test fixture resource is requested - A public image used in the html sample file is requested
THEN: THEN:
- The resource shall be returned with HTTP 200 status code - No exception shall be thrown
- The response shall contain the expected content type
""" """
"""
A public image is used in the html sample file. We have no control
whether this image stays online forever, so here we check if it is still there
"""
# Now check the URL used in samples/sample.html
resp = httpx.get( resp = httpx.get(
f"{nginx_base_url}/assets/logo_full_white.svg", "https://docs.paperless-ngx.com/assets/logo_full_white.svg",
timeout=5.0,
) )
resp.raise_for_status() resp.raise_for_status()
assert resp.status_code == httpx.codes.OK
assert "svg" in resp.headers.get("content-type", "").lower()
def test_server_connectivity(
self,
nginx_base_url: str,
) -> None:
"""
GIVEN:
- Local test fixtures server should be running
WHEN:
- A request is made to the server root
THEN:
- The server shall respond without connection errors
"""
try:
resp = httpx.get(
nginx_base_url,
timeout=5.0,
follow_redirects=True,
)
# We don't care about the status code, just that we can connect
assert resp.status_code in {200, 404, 403}
except httpx.ConnectError as e:
pytest.fail(
f"Cannot connect to nginx server at {nginx_base_url}. "
f"Ensure the nginx container is running via docker-compose.ci-test.yml. "
f"Error: {e}",
)
@pytest.mark.live
@pytest.mark.gotenberg
@pytest.mark.tika
@pytest.mark.nginx
@pytest.mark.skipif( @pytest.mark.skipif(
"PAPERLESS_CI_TEST" not in os.environ, "PAPERLESS_CI_TEST" not in os.environ,
reason="No Gotenberg/Tika servers to test with", reason="No Gotenberg/Tika servers to test with",
) )
class TestParserLive: class TestParserLive:
@staticmethod @staticmethod
def imagehash(file: Path, hash_size: int = 18) -> str: def imagehash(file, hash_size=18):
return f"{average_hash(Image.open(file), hash_size)}" return f"{average_hash(Image.open(file), hash_size)}"
def test_get_thumbnail( def test_get_thumbnail(
@@ -148,15 +112,14 @@ class TestParserLive:
simple_txt_email_file: Path, simple_txt_email_file: Path,
simple_txt_email_pdf_file: Path, simple_txt_email_pdf_file: Path,
simple_txt_email_thumbnail_file: Path, simple_txt_email_thumbnail_file: Path,
) -> None: ):
""" """
GIVEN: GIVEN:
- A simple text email file - Fresh start
- Mocked PDF generation returning a known PDF
WHEN: WHEN:
- The thumbnail is requested - The Thumbnail is requested
THEN: THEN:
- The returned thumbnail image file shall match the expected hash - The returned thumbnail image file is as expected
""" """
mock_generate_pdf = mocker.patch( mock_generate_pdf = mocker.patch(
"paperless_mail.parsers.MailDocumentParser.generate_pdf", "paperless_mail.parsers.MailDocumentParser.generate_pdf",
@@ -171,28 +134,22 @@ class TestParserLive:
assert self.imagehash(thumb) == self.imagehash( assert self.imagehash(thumb) == self.imagehash(
simple_txt_email_thumbnail_file, simple_txt_email_thumbnail_file,
), ( ), (
f"Created thumbnail {thumb} differs from expected file " f"Created Thumbnail {thumb} differs from expected file {simple_txt_email_thumbnail_file}"
f"{simple_txt_email_thumbnail_file}"
) )
def test_tika_parse_successful(self, mail_parser: MailDocumentParser) -> None: def test_tika_parse_successful(self, mail_parser: MailDocumentParser):
""" """
GIVEN: GIVEN:
- HTML content to parse - Fresh start
- Tika server is running
WHEN: WHEN:
- Tika parsing is called - tika parsing is called
THEN: THEN:
- A web request to Tika shall be made - a web request to tika shall be done and the reply es returned
- The parsed text content shall be returned
""" """
html = ( html = '<html><head><meta http-equiv="content-type" content="text/html; charset=UTF-8"></head><body><p>Some Text</p></body></html>'
'<html><head><meta http-equiv="content-type" '
'content="text/html; charset=UTF-8"></head>'
"<body><p>Some Text</p></body></html>"
)
expected_text = "Some Text" expected_text = "Some Text"
# Check successful parsing
parsed = mail_parser.tika_parse(html) parsed = mail_parser.tika_parse(html)
assert expected_text == parsed.strip() assert expected_text == parsed.strip()
@@ -203,17 +160,14 @@ class TestParserLive:
html_email_file: Path, html_email_file: Path,
merged_pdf_first: Path, merged_pdf_first: Path,
merged_pdf_second: Path, merged_pdf_second: Path,
) -> None: ):
""" """
GIVEN: GIVEN:
- Intermediary PDFs to be merged - Intermediary pdfs to be merged
- An HTML email file
WHEN: WHEN:
- PDF generation is requested with HTML file requiring merging - pdf generation is requested with html file requiring merging of pdfs
THEN: THEN:
- Gotenberg shall be called to merge files - gotenberg is called to merge files and the resulting file is returned
- The resulting merged PDF shall be returned
- The merged PDF shall contain text from both source PDFs
""" """
mock_generate_pdf_from_html = mocker.patch( mock_generate_pdf_from_html = mocker.patch(
"paperless_mail.parsers.MailDocumentParser.generate_pdf_from_html", "paperless_mail.parsers.MailDocumentParser.generate_pdf_from_html",
@@ -246,17 +200,16 @@ class TestParserLive:
html_email_file: Path, html_email_file: Path,
html_email_pdf_file: Path, html_email_pdf_file: Path,
html_email_thumbnail_file: Path, html_email_thumbnail_file: Path,
) -> None: ):
""" """
GIVEN: GIVEN:
- An HTML email file - Fresh start
WHEN: WHEN:
- PDF generation from the email file is requested - pdf generation from simple eml file is requested
THEN: THEN:
- Gotenberg shall be called to generate the PDF - Gotenberg is called and the resulting file is returned and look as expected.
- The archive PDF shall contain the expected content
- The generated thumbnail shall match the expected image hash
""" """
util_call_with_backoff(mail_parser.parse, [html_email_file, "message/rfc822"]) util_call_with_backoff(mail_parser.parse, [html_email_file, "message/rfc822"])
# Check the archive PDF # Check the archive PDF
@@ -264,7 +217,7 @@ class TestParserLive:
archive_text = extract_text(archive_path) archive_text = extract_text(archive_path)
expected_archive_text = extract_text(html_email_pdf_file) expected_archive_text = extract_text(html_email_pdf_file)
# Archive includes the HTML content # Archive includes the HTML content, so use in
assert expected_archive_text in archive_text assert expected_archive_text in archive_text
# Check the thumbnail # Check the thumbnail
@@ -274,12 +227,9 @@ class TestParserLive:
) )
generated_thumbnail_hash = self.imagehash(generated_thumbnail) generated_thumbnail_hash = self.imagehash(generated_thumbnail)
# The created PDF is not reproducible, but the converted image # The created pdf is not reproducible. But the converted image should always look the same.
# should always look the same
expected_hash = self.imagehash(html_email_thumbnail_file) expected_hash = self.imagehash(html_email_thumbnail_file)
assert generated_thumbnail_hash == expected_hash, ( assert generated_thumbnail_hash == expected_hash, (
f"PDF thumbnail differs from expected. " f"PDF looks different. Check if {generated_thumbnail} looks weird."
f"Generated: {generated_thumbnail}, "
f"Hash: {generated_thumbnail_hash} vs {expected_hash}"
) )

View File

@@ -1,12 +1,7 @@
import datetime import datetime
import logging import logging
from datetime import timedelta from datetime import timedelta
from typing import Any
from adrf.views import APIView
from adrf.viewsets import ModelViewSet
from adrf.viewsets import ReadOnlyModelViewSet
from asgiref.sync import sync_to_async
from django.http import HttpResponseBadRequest from django.http import HttpResponseBadRequest
from django.http import HttpResponseForbidden from django.http import HttpResponseForbidden
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
@@ -20,9 +15,11 @@ from httpx_oauth.oauth2 import GetAccessTokenError
from rest_framework import serializers from rest_framework import serializers
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.filters import OrderingFilter from rest_framework.filters import OrderingFilter
from rest_framework.generics import GenericAPIView
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from rest_framework.viewsets import ReadOnlyModelViewSet
from documents.filters import ObjectOwnedOrGrantedPermissionsFilter from documents.filters import ObjectOwnedOrGrantedPermissionsFilter
from documents.permissions import PaperlessObjectPermissions from documents.permissions import PaperlessObjectPermissions
@@ -42,8 +39,6 @@ from paperless_mail.serialisers import MailRuleSerializer
from paperless_mail.serialisers import ProcessedMailSerializer from paperless_mail.serialisers import ProcessedMailSerializer
from paperless_mail.tasks import process_mail_accounts from paperless_mail.tasks import process_mail_accounts
logger: logging.Logger = logging.getLogger("paperless_mail")
@extend_schema_view( @extend_schema_view(
test=extend_schema( test=extend_schema(
@@ -71,75 +66,71 @@ logger: logging.Logger = logging.getLogger("paperless_mail")
), ),
) )
class MailAccountViewSet(ModelViewSet, PassUserMixin): class MailAccountViewSet(ModelViewSet, PassUserMixin):
model = MailAccount
queryset = MailAccount.objects.all().order_by("pk") queryset = MailAccount.objects.all().order_by("pk")
serializer_class = MailAccountSerializer serializer_class = MailAccountSerializer
pagination_class = StandardPagination pagination_class = StandardPagination
permission_classes = (IsAuthenticated, PaperlessObjectPermissions) permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
filter_backends = (ObjectOwnedOrGrantedPermissionsFilter,) filter_backends = (ObjectOwnedOrGrantedPermissionsFilter,)
def get_permissions(self) -> list[Any]: def get_permissions(self):
if self.action == "test": if self.action == "test":
return [IsAuthenticated()] # Test action does not require object level permissions
self.permission_classes = (IsAuthenticated,)
return super().get_permissions() return super().get_permissions()
@action(methods=["post"], detail=False) @action(methods=["post"], detail=False)
async def test(self, request: Request) -> Response | HttpResponseBadRequest: def test(self, request):
logger = logging.getLogger("paperless_mail")
request.data["name"] = datetime.datetime.now().isoformat() request.data["name"] = datetime.datetime.now().isoformat()
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
# Validation must be wrapped because of sync DB validators # account exists, use the password from there instead of *** and refresh_token / expiration
await sync_to_async(serializer.is_valid)(raise_exception=True)
validated_data: dict[str, Any] = serializer.validated_data
if ( if (
len(str(validated_data.get("password", "")).replace("*", "")) == 0 len(serializer.validated_data.get("password").replace("*", "")) == 0
and request.data.get("id") is not None and request.data["id"] is not None
): ):
existing_account = await MailAccount.objects.aget(pk=request.data["id"]) existing_account = MailAccount.objects.get(pk=request.data["id"])
validated_data.update( serializer.validated_data["password"] = existing_account.password
{ serializer.validated_data["account_type"] = existing_account.account_type
"password": existing_account.password, serializer.validated_data["refresh_token"] = existing_account.refresh_token
"account_type": existing_account.account_type, serializer.validated_data["expiration"] = existing_account.expiration
"refresh_token": existing_account.refresh_token,
"expiration": existing_account.expiration,
},
)
account = MailAccount(**validated_data) account = MailAccount(**serializer.validated_data)
with get_mailbox(
def _blocking_imap_test() -> bool: account.imap_server,
with get_mailbox( account.imap_port,
account.imap_server, account.imap_security,
account.imap_port, ) as M:
account.imap_security, try:
) as m_box:
if ( if (
account.is_token account.is_token
and account.expiration and account.expiration is not None
and account.expiration < timezone.now() and account.expiration < timezone.now()
): ):
oauth_manager = PaperlessMailOAuth2Manager() oauth_manager = PaperlessMailOAuth2Manager()
if oauth_manager.refresh_account_oauth_token(existing_account): if oauth_manager.refresh_account_oauth_token(existing_account):
# User is not changing password and token needs to be refreshed # User is not changing password and token needs to be refreshed
existing_account.refresh_from_db()
account.password = existing_account.password account.password = existing_account.password
else: else:
raise MailError("Unable to refresh oauth token") raise MailError("Unable to refresh oauth token")
mailbox_login(m_box, account)
return True
try: mailbox_login(M, account)
await sync_to_async(_blocking_imap_test, thread_sensitive=False)() return Response({"success": True})
return Response({"success": True}) except MailError as e:
except MailError as e: logger.error(
logger.error(f"Mail account {account} test failed: {e}") f"Mail account {account} test failed: {e}",
return HttpResponseBadRequest("Unable to connect to server") )
return HttpResponseBadRequest("Unable to connect to server")
@action(methods=["post"], detail=True) @action(methods=["post"], detail=True)
async def process(self, request: Request, pk: int | None = None) -> Response: def process(self, request, pk=None):
# FIX: Use aget_object() provided by adrf to avoid SynchronousOnlyOperation account = self.get_object()
account = await self.aget_object()
process_mail_accounts.delay([account.pk]) process_mail_accounts.delay([account.pk])
return Response({"result": "OK"}) return Response({"result": "OK"})
@@ -153,38 +144,21 @@ class ProcessedMailViewSet(ReadOnlyModelViewSet, PassUserMixin):
ObjectOwnedOrGrantedPermissionsFilter, ObjectOwnedOrGrantedPermissionsFilter,
) )
filterset_class = ProcessedMailFilterSet filterset_class = ProcessedMailFilterSet
queryset = ProcessedMail.objects.all().order_by("-processed") queryset = ProcessedMail.objects.all().order_by("-processed")
@action(methods=["post"], detail=False) @action(methods=["post"], detail=False)
async def bulk_delete( def bulk_delete(self, request):
self, mail_ids = request.data.get("mail_ids", [])
request: Request,
) -> Response | HttpResponseBadRequest | HttpResponseForbidden:
mail_ids: list[int] = request.data.get("mail_ids", [])
if not isinstance(mail_ids, list) or not all( if not isinstance(mail_ids, list) or not all(
isinstance(i, int) for i in mail_ids isinstance(i, int) for i in mail_ids
): ):
return HttpResponseBadRequest("mail_ids must be a list of integers") return HttpResponseBadRequest("mail_ids must be a list of integers")
mails = ProcessedMail.objects.filter(id__in=mail_ids)
# Store objects to delete after verification for mail in mails:
to_delete: list[ProcessedMail] = [] if not has_perms_owner_aware(request.user, "delete_processedmail", mail):
# We must verify permissions for every requested ID
async for mail in ProcessedMail.objects.filter(id__in=mail_ids):
can_delete = await sync_to_async(has_perms_owner_aware)(
request.user,
"delete_processedmail",
mail,
)
if not can_delete:
# This is what the test is looking for: 403 on permission failure
return HttpResponseForbidden("Insufficient permissions") return HttpResponseForbidden("Insufficient permissions")
to_delete.append(mail) mail.delete()
# Only perform deletions if all items passed the permission check
for mail in to_delete:
await mail.adelete()
return Response({"result": "OK", "deleted_mail_ids": mail_ids}) return Response({"result": "OK", "deleted_mail_ids": mail_ids})
@@ -204,74 +178,77 @@ class MailRuleViewSet(ModelViewSet, PassUserMixin):
responses={200: None}, responses={200: None},
), ),
) )
class OauthCallbackView(APIView): class OauthCallbackView(GenericAPIView):
permission_classes = (IsAuthenticated,) permission_classes = (IsAuthenticated,)
async def get( def get(self, request, format=None):
self, if not (
request: Request, request.user and request.user.has_perms(["paperless_mail.add_mailaccount"])
) -> Response | HttpResponseBadRequest | HttpResponseRedirect: ):
has_perm = await sync_to_async(request.user.has_perm)(
"paperless_mail.add_mailaccount",
)
if not has_perm:
return HttpResponseBadRequest( return HttpResponseBadRequest(
"You do not have permission to add mail accounts", "You do not have permission to add mail accounts",
) )
code: str | None = request.query_params.get("code") logger = logging.getLogger("paperless_mail")
state: str | None = request.query_params.get("state") code = request.query_params.get("code")
scope: str | None = request.query_params.get("scope") # Gmail passes scope as a query param, Outlook does not
scope = request.query_params.get("scope")
if not code or not state: if code is None:
return HttpResponseBadRequest("Invalid request parameters") logger.error(
f"Invalid oauth callback request, code: {code}, scope: {scope}",
)
return HttpResponseBadRequest("Invalid request, see logs for more detail")
oauth_manager = PaperlessMailOAuth2Manager( oauth_manager = PaperlessMailOAuth2Manager(
state=request.session.get("oauth_state"), state=request.session.get("oauth_state"),
) )
state = request.query_params.get("state", "")
if not oauth_manager.validate_state(state): if not oauth_manager.validate_state(state):
return HttpResponseBadRequest("Invalid OAuth state") logger.error(
f"Invalid oauth callback request received state: {state}, expected: {oauth_manager.state}",
)
return HttpResponseBadRequest("Invalid request, see logs for more detail")
try: try:
defaults: dict[str, Any] = { if scope is not None and "google" in scope:
"username": "", # Google
"imap_security": MailAccount.ImapSecurity.SSL,
"imap_port": 993,
}
if scope and "google" in scope:
account_type = MailAccount.MailAccountType.GMAIL_OAUTH account_type = MailAccount.MailAccountType.GMAIL_OAUTH
imap_server = "imap.gmail.com" imap_server = "imap.gmail.com"
defaults.update( defaults = {
{ "name": f"Gmail OAuth {timezone.now()}",
"name": f"Gmail OAuth {timezone.now()}", "username": "",
"account_type": account_type, "imap_security": MailAccount.ImapSecurity.SSL,
}, "imap_port": 993,
) "account_type": account_type,
result = await sync_to_async(oauth_manager.get_gmail_access_token)(code) }
else: result = oauth_manager.get_gmail_access_token(code)
elif scope is None:
# Outlook
account_type = MailAccount.MailAccountType.OUTLOOK_OAUTH account_type = MailAccount.MailAccountType.OUTLOOK_OAUTH
imap_server = "outlook.office365.com" imap_server = "outlook.office365.com"
defaults.update( defaults = {
{ "name": f"Outlook OAuth {timezone.now()}",
"name": f"Outlook OAuth {timezone.now()}", "username": "",
"account_type": account_type, "imap_security": MailAccount.ImapSecurity.SSL,
}, "imap_port": 993,
) "account_type": account_type,
result = await sync_to_async(oauth_manager.get_outlook_access_token)( }
code,
)
account, _ = await MailAccount.objects.aupdate_or_create( result = oauth_manager.get_outlook_access_token(code)
access_token = result["access_token"]
refresh_token = result["refresh_token"]
expires_in = result["expires_in"]
account, _ = MailAccount.objects.update_or_create(
password=access_token,
is_token=True,
imap_server=imap_server, imap_server=imap_server,
refresh_token=result["refresh_token"], refresh_token=refresh_token,
defaults={ expiration=timezone.now() + timedelta(seconds=expires_in),
**defaults, defaults=defaults,
"password": result["access_token"],
"is_token": True,
"expiration": timezone.now()
+ timedelta(seconds=result["expires_in"]),
},
) )
return HttpResponseRedirect( return HttpResponseRedirect(
f"{oauth_manager.oauth_redirect_url}?oauth_success=1&account_id={account.pk}", f"{oauth_manager.oauth_redirect_url}?oauth_success=1&account_id={account.pk}",

25
uv.lock generated
View File

@@ -16,20 +16,6 @@ supported-markers = [
"sys_platform == 'linux'", "sys_platform == 'linux'",
] ]
[[package]]
name = "adrf"
version = "0.1.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "async-property", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "djangorestframework", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/10/fe/573d7ec8805aec8e13459451f81398c6ac819882496e82fb6b4ae96c2762/adrf-0.1.12.tar.gz", hash = "sha256:e7aa49e5406b168f040f1a12cafb606e98fdd5467314240a9c42dbe63200d2c1", size = 17181, upload-time = "2025-11-24T03:25:44.337Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/47/14006c4045f818cf625d2357e823a15b4bb7fab8dd81a40acd23af41ee53/adrf-0.1.12-py3-none-any.whl", hash = "sha256:e9d1f343b82158f4c528c0809c9635a27ceef4c37d3d8e61b8096c8eeded616d", size = 20199, upload-time = "2025-11-24T03:25:43.291Z" },
]
[[package]] [[package]]
name = "aiohappyeyeballs" name = "aiohappyeyeballs"
version = "2.6.1" version = "2.6.1"
@@ -217,15 +203,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/d1/69d02ce34caddb0a7ae088b84c356a625a93cd4ff57b2f97644c03fad905/asgiref-3.9.2-py3-none-any.whl", hash = "sha256:0b61526596219d70396548fc003635056856dba5d0d086f86476f10b33c75960", size = 23788, upload-time = "2025-09-23T15:00:53.627Z" }, { url = "https://files.pythonhosted.org/packages/c7/d1/69d02ce34caddb0a7ae088b84c356a625a93cd4ff57b2f97644c03fad905/asgiref-3.9.2-py3-none-any.whl", hash = "sha256:0b61526596219d70396548fc003635056856dba5d0d086f86476f10b33c75960", size = 23788, upload-time = "2025-09-23T15:00:53.627Z" },
] ]
[[package]]
name = "async-property"
version = "0.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a7/12/900eb34b3af75c11b69d6b78b74ec0fd1ba489376eceb3785f787d1a0a1d/async_property-0.2.2.tar.gz", hash = "sha256:17d9bd6ca67e27915a75d92549df64b5c7174e9dc806b30a3934dc4ff0506380", size = 16523, upload-time = "2023-07-03T17:21:55.688Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/80/9f608d13b4b3afcebd1dd13baf9551c95fc424d6390e4b1cfd7b1810cd06/async_property-0.2.2-py2.py3-none-any.whl", hash = "sha256:8924d792b5843994537f8ed411165700b27b2bd966cefc4daeefc1253442a9d7", size = 9546, upload-time = "2023-07-03T17:21:54.293Z" },
]
[[package]] [[package]]
name = "async-timeout" name = "async-timeout"
version = "5.0.1" version = "5.0.1"
@@ -2942,7 +2919,6 @@ name = "paperless-ngx"
version = "2.20.5" version = "2.20.5"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "adrf", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "azure-ai-documentintelligence", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "azure-ai-documentintelligence", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "babel", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "babel", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "bleach", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "bleach", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
@@ -3091,7 +3067,6 @@ typing = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "adrf", specifier = "~=0.1.12" },
{ name = "azure-ai-documentintelligence", specifier = ">=1.0.2" }, { name = "azure-ai-documentintelligence", specifier = ">=1.0.2" },
{ name = "babel", specifier = ">=2.17" }, { name = "babel", specifier = ">=2.17" },
{ name = "bleach", specifier = "~=6.3.0" }, { name = "bleach", specifier = "~=6.3.0" },