Compare commits

...

12 Commits

Author SHA1 Message Date
Trenton H
efd65300a1 Experiments with using adrf for a few views 2026-01-28 11:43:32 -08:00
GitHub Actions
5af0d1da26 Auto translate strings 2026-01-28 16:27:11 +00:00
shamoon
3281ec2401 Documentation: update duplicates note 2026-01-28 08:25:16 -08:00
shamoon
dc9061eb97 Chore: refactor zoom and editor mode to use enums 2026-01-28 08:25:16 -08:00
Trenton H
6859e7e3c2 Chore: Resolve more flaky tests (#11920) 2026-01-28 16:13:27 +00:00
Jan Kleine
3e645bd9e2 Tweak: increase minimum screen width before inserting padding (#11926) 2026-01-28 15:57:47 +00:00
GitHub Actions
09d39de200 Auto translate strings 2026-01-28 15:55:01 +00:00
Jan Kleine
94231dbb0f Enhancement: Add setting for default PDF Editor mode (#11927)
---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-01-28 15:53:14 +00:00
Trenton H
2f76350023 Chore: Push manually dispatched images to the registry (#11925) 2026-01-28 15:47:32 +00:00
Pierre Nédélec
4cbe56e3af Chore: Http interceptors refactor (#11923)
---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-01-28 07:18:48 -08:00
Trenton H
01b21377af Chore: Use a local http server instead of external to reduce flakiness (#11916) 2026-01-28 03:57:12 +00:00
Pierre Nédélec
56b5d838d7 Chore: remove deprecated Angular method (#11919)
---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-01-27 16:58:38 -08:00
30 changed files with 660 additions and 414 deletions

View File

@@ -46,14 +46,13 @@ 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 cache keys # Sanitize by replacing / with - for use in tags and cache keys
cache_ref="${ref_name//\//-}" sanitized_ref="${ref_name//\//-}"
echo "ref_name=${ref_name}" echo "ref_name=${ref_name}"
echo "cache_ref=${cache_ref}" echo "sanitized_ref=${sanitized_ref}"
echo "name=${ref_name}" >> $GITHUB_OUTPUT echo "name=${sanitized_ref}" >> $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:
@@ -62,12 +61,14 @@ 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. Internal PRs where the branch name starts with 'feature-' - filtered here when a PR is synced # 2. Manual dispatch - always push to GHCR
# 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"
@@ -139,9 +140,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.cache-ref }}-${{ matrix.arch }} 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: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.cache-ref, 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) || '' }}
- 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,3 +34,13 @@ 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

@@ -0,0 +1,14 @@
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 already catches and prevents upload of exactly matching documents, Paperless-ngx already catches and warns 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,6 +16,7 @@ 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",
@@ -300,6 +301,14 @@ 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">386</context> <context context-type="linenumber">400</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,28 +1201,72 @@
<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">263</context> <context context-type="linenumber">264</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">266</context> <context context-type="linenumber">267</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">267</context> <context context-type="linenumber">268</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">271</context> <context context-type="linenumber">285</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>
@@ -1241,14 +1285,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">274</context> <context context-type="linenumber">288</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">283</context> <context context-type="linenumber">297</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>
@@ -1311,28 +1355,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">286</context> <context context-type="linenumber">300</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">290,292</context> <context context-type="linenumber">304,306</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">297</context> <context context-type="linenumber">311</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">301</context> <context context-type="linenumber">315</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>
@@ -1343,18 +1387,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">306</context> <context context-type="linenumber">320</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">311</context> <context context-type="linenumber">325</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">338</context> <context context-type="linenumber">352</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>
@@ -1385,11 +1429,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">321</context> <context context-type="linenumber">335</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">348</context> <context context-type="linenumber">362</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>
@@ -1420,14 +1464,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">333</context> <context context-type="linenumber">347</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">357</context> <context context-type="linenumber">371</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>
@@ -1446,7 +1490,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">365</context> <context context-type="linenumber">379</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>
@@ -1457,49 +1501,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">368</context> <context context-type="linenumber">382</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">372</context> <context context-type="linenumber">386</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">373</context> <context context-type="linenumber">387</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">374</context> <context context-type="linenumber">388</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">375</context> <context context-type="linenumber">389</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">375</context> <context context-type="linenumber">389</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">385</context> <context context-type="linenumber">399</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>
@@ -1570,21 +1614,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">78</context> <context context-type="linenumber">79</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">81</context> <context context-type="linenumber">82</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">95</context> <context context-type="linenumber">96</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>
@@ -1595,7 +1639,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">97</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>
@@ -1626,7 +1670,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">98</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>
@@ -1657,7 +1701,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">99</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/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
@@ -1684,7 +1728,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">100</context> <context context-type="linenumber">101</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>
@@ -1723,7 +1767,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">248</context> <context context-type="linenumber">252</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>
@@ -1734,7 +1778,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">267</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/admin/users-groups/users-groups.component.ts</context> <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context>
@@ -1745,28 +1789,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">577</context> <context context-type="linenumber">588</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">581</context> <context context-type="linenumber">592</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">582</context> <context context-type="linenumber">593</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">592</context> <context context-type="linenumber">603</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>
@@ -2775,11 +2819,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">1121</context> <context context-type="linenumber">1108</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">1486</context> <context context-type="linenumber">1473</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>
@@ -3370,7 +3414,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">1074</context> <context context-type="linenumber">1061</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>
@@ -3475,7 +3519,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">1537</context> <context context-type="linenumber">1524</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6661109599266152398" datatype="html"> <trans-unit id="6661109599266152398" datatype="html">
@@ -3486,7 +3530,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">1538</context> <context context-type="linenumber">1525</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5162686434580248853" datatype="html"> <trans-unit id="5162686434580248853" datatype="html">
@@ -3497,7 +3541,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">1539</context> <context context-type="linenumber">1526</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8157388568390631653" datatype="html"> <trans-unit id="8157388568390631653" datatype="html">
@@ -6012,20 +6056,6 @@
<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">
@@ -7373,17 +7403,6 @@
<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">
@@ -7619,56 +7638,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">441,443</context> <context context-type="linenumber">428,430</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">480</context> <context context-type="linenumber">467</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">481</context> <context context-type="linenumber">468</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">482</context> <context context-type="linenumber">469</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">484</context> <context context-type="linenumber">471</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">610</context> <context context-type="linenumber">597</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">620</context> <context context-type="linenumber">607</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">628</context> <context context-type="linenumber">615</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>
@@ -7679,67 +7698,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">635</context> <context context-type="linenumber">622</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">644</context> <context context-type="linenumber">631</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">699</context> <context context-type="linenumber">686</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">754</context> <context context-type="linenumber">741</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">963</context> <context context-type="linenumber">950</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">987</context> <context context-type="linenumber">974</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">993</context> <context context-type="linenumber">980</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">1043</context> <context context-type="linenumber">1030</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">1075</context> <context context-type="linenumber">1062</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">1076</context> <context context-type="linenumber">1063</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>
@@ -7750,7 +7769,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">1078</context> <context context-type="linenumber">1065</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>
@@ -7761,14 +7780,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">1097</context> <context context-type="linenumber">1084</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">1117</context> <context context-type="linenumber">1104</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>
@@ -7779,102 +7798,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">1118</context> <context context-type="linenumber">1105</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">1119</context> <context context-type="linenumber">1106</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">1129</context> <context context-type="linenumber">1116</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">1140</context> <context context-type="linenumber">1127</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">1189</context> <context context-type="linenumber">1176</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">1266</context> <context context-type="linenumber">1253</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">1504</context> <context context-type="linenumber">1491</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">1516</context> <context context-type="linenumber">1503</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">1527</context> <context context-type="linenumber">1514</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">1559</context> <context context-type="linenumber">1546</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">1573</context> <context context-type="linenumber">1560</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">1610</context> <context context-type="linenumber">1597</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">1622</context> <context context-type="linenumber">1609</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">1687</context> <context context-type="linenumber">1674</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">1691</context> <context context-type="linenumber">1678</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4958946940233632319" datatype="html"> <trans-unit id="4958946940233632319" datatype="html">

View File

@@ -259,6 +259,7 @@
</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">
@@ -268,6 +269,19 @@
</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(31) expect(setSpy).toHaveBeenCalledTimes(32)
// succeed // succeed
storeSpy.mockReturnValueOnce(of(true)) storeSpy.mockReturnValueOnce(of(true))

View File

@@ -64,8 +64,9 @@ 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/document-detail.component' import { ZoomSetting } from '../../document-detail/zoom-setting'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component' import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
enum SettingsNavIDs { enum SettingsNavIDs {
@@ -163,6 +164,7 @@ 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([]),
@@ -196,6 +198,8 @@ 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 {
@@ -314,6 +318,9 @@ 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),
@@ -483,6 +490,10 @@ 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: 366px) and (max-width: 768px) { @media screen and (min-width: 376px) 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

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

View File

@@ -8,8 +8,11 @@ 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
@@ -19,11 +22,6 @@ 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',
@@ -39,12 +37,15 @@ 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 = PdfEditorEditMode.Create editMode: PdfEditorEditMode = this.settingsService.get(
SETTINGS_KEYS.PDF_EDITOR_DEFAULT_EDIT_MODE
)
deleteOriginal: boolean = false deleteOriginal: boolean = false
includeMetadata: boolean = true includeMetadata: boolean = true

View File

@@ -69,10 +69,8 @@ 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 { import { DocumentDetailComponent } from './document-detail.component'
DocumentDetailComponent, import { ZoomSetting } from './zoom-setting'
ZoomSetting,
} from './document-detail.component'
const doc: Document = { const doc: Document = {
id: 3, id: 3,

View File

@@ -106,16 +106,15 @@ 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 { import { PdfEditorEditMode } from '../common/pdf-editor/pdf-editor-edit-mode'
PDFEditorComponent, import { PDFEditorComponent } from '../common/pdf-editor/pdf-editor.component'
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,
@@ -137,18 +136,6 @@ 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',

View File

@@ -0,0 +1,11 @@
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,3 +1,5 @@
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 {
@@ -74,6 +76,8 @@ 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',
@@ -295,11 +299,16 @@ export const SETTINGS: UiSetting[] = [
{ {
key: SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING, key: SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING,
type: 'string', type: 'string',
default: 'page-width', // ZoomSetting from 'document-detail.component' default: ZoomSetting.PageWidth,
}, },
{ {
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

@@ -1,30 +1,41 @@
import { HttpEvent, HttpRequest } from '@angular/common/http' import {
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 { ApiVersionInterceptor } from './api-version.interceptor' import { withApiVersionInterceptor } from './api-version.interceptor'
describe('ApiVersionInterceptor', () => { describe('ApiVersionInterceptor', () => {
let interceptor: ApiVersionInterceptor let httpClient: HttpClient
let httpMock: HttpTestingController
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ApiVersionInterceptor], providers: [
provideHttpClient(withInterceptors([withApiVersionInterceptor])),
provideHttpClientTesting(),
],
}) })
interceptor = TestBed.inject(ApiVersionInterceptor) httpClient = TestBed.inject(HttpClient)
httpMock = TestBed.inject(HttpTestingController)
}) })
it('should add api version to headers', () => { it('should add api version to headers', () => {
interceptor.intercept(new HttpRequest('GET', 'https://example.com'), { httpClient.get('https://example.com').subscribe()
handle: (request) => { const request = httpMock.expectOne('https://example.com')
const header = request.headers['lazyUpdate'][0] const header = request.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,27 +1,20 @@
import { import {
HttpEvent, HttpEvent,
HttpHandler, HttpHandlerFn,
HttpInterceptor, HttpInterceptorFn,
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'
@Injectable() export const withApiVersionInterceptor: HttpInterceptorFn = (
export class ApiVersionInterceptor implements HttpInterceptor {
constructor() {}
intercept(
request: HttpRequest<unknown>, request: HttpRequest<unknown>,
next: HttpHandler next: HttpHandlerFn
): Observable<HttpEvent<unknown>> { ): Observable<HttpEvent<unknown>> => {
request = request.clone({ request = request.clone({
setHeaders: { setHeaders: {
Accept: `application/json; version=${environment.apiVersion}`, Accept: `application/json; version=${environment.apiVersion}`,
}, },
}) })
return next(request)
return next.handle(request)
}
} }

View File

@@ -1,35 +1,52 @@
import { HttpEvent, HttpRequest } from '@angular/common/http' import {
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 { of } from 'rxjs' import { withCsrfInterceptor } from './csrf.interceptor'
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: [CsrfInterceptor, Meta, CookieService], providers: [
Meta,
CookieService,
provideHttpClient(withInterceptors([withCsrfInterceptor])),
provideHttpClientTesting(),
],
}) })
meta = TestBed.inject(Meta) meta = TestBed.inject(Meta)
cookieService = TestBed.inject(CookieService) cookieService = TestBed.inject(CookieService)
interceptor = TestBed.inject(CsrfInterceptor) httpClient = TestBed.inject(HttpClient)
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'), {
handle: (request) => { httpClient.get('https://example.com').subscribe()
expect(request.headers['lazyUpdate'][0]['name']).toEqual('X-CSRFToken') const request = httpMock.expectOne('https://example.com')
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,28 +1,26 @@
import { import {
HttpEvent, HttpEvent,
HttpHandler, HttpHandlerFn,
HttpInterceptor, HttpInterceptorFn,
HttpRequest, HttpRequest,
} from '@angular/common/http' } from '@angular/common/http'
import { inject, Injectable } from '@angular/core' import { inject } 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'
@Injectable() export const withCsrfInterceptor: HttpInterceptorFn = (
export class CsrfInterceptor implements HttpInterceptor {
private cookieService: CookieService = inject(CookieService)
private meta: Meta = inject(Meta)
intercept(
request: HttpRequest<unknown>, request: HttpRequest<unknown>,
next: HttpHandler next: HttpHandlerFn
): Observable<HttpEvent<unknown>> { ): Observable<HttpEvent<unknown>> => {
const cookieService: CookieService = inject(CookieService)
const meta: Meta = inject(Meta)
let prefix = '' let prefix = ''
if (this.meta.getTag('name=cookie_prefix')) { if (meta.getTag('name=cookie_prefix')) {
prefix = this.meta.getTag('name=cookie_prefix').content prefix = meta.getTag('name=cookie_prefix').content
} }
let csrfToken = this.cookieService.get(`${prefix}csrftoken`) let csrfToken = cookieService.get(`${prefix}csrftoken`)
if (csrfToken) { if (csrfToken) {
request = request.clone({ request = request.clone({
setHeaders: { setHeaders: {
@@ -30,7 +28,5 @@ export class CsrfInterceptor implements HttpInterceptor {
}, },
}) })
} }
return next(request)
return next.handle(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,15 +151,14 @@ 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 { ApiVersionInterceptor } from './app/interceptors/api-version.interceptor' import { withApiVersionInterceptor } from './app/interceptors/api-version.interceptor'
import { CsrfInterceptor } from './app/interceptors/csrf.interceptor' import { withCsrfInterceptor } 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'
@@ -237,11 +236,11 @@ registerLocaleData(localeUk)
registerLocaleData(localeZh) registerLocaleData(localeZh)
registerLocaleData(localeZhHant) registerLocaleData(localeZhHant)
function initializeApp(settings: SettingsService) { function initializeApp() {
return () => { const settings = inject(SettingsService)
return settings.initializeSettings() return settings.initializeSettings()
}
} }
const icons = { const icons = {
airplane, airplane,
archive, archive,
@@ -363,10 +362,6 @@ const icons = {
xLg, xLg,
} }
if (environment.production) {
enableProdMode()
}
bootstrapApplication(AppComponent, { bootstrapApplication(AppComponent, {
providers: [ providers: [
provideZoneChangeDetection(), provideZoneChangeDetection(),
@@ -383,24 +378,9 @@ 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 },
@@ -412,6 +392,10 @@ bootstrapApplication(AppComponent, {
CorrespondentNamePipe, CorrespondentNamePipe,
DocumentTypeNamePipe, DocumentTypeNamePipe,
StoragePathNamePipe, StoragePathNamePipe,
provideHttpClient(withInterceptorsFromDi(), withFetch()), provideHttpClient(
withInterceptorsFromDi(),
withInterceptors([withCsrfInterceptor, withApiVersionInterceptor]),
withFetch()
),
], ],
}).catch((err) => console.error(err)) }).catch((err) => console.error(err))

View File

@@ -501,9 +501,22 @@ 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)
# Start with no timeout (wait indefinitely for first event) # Calculate appropriate timeout for watch loop
# unless in testing mode # In polling mode, rust_timeout must be significantly longer than poll_delay_ms
timeout_ms = testing_timeout_ms if is_testing else 0 # to ensure poll cycles can complete before timing out
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()
@@ -543,7 +556,13 @@ 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 short timeout to check stop flag # In testing, use appropriate timeout based on watch mode
if use_polling:
# 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 timeout_ms = testing_timeout_ms
else: # pragma: nocover else: # pragma: nocover
# No pending files, wait indefinitely # No pending files, wait indefinitely

View File

@@ -114,6 +114,30 @@ 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."""
@@ -724,7 +748,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(0.5) # Give thread time to start sleep(2.0) # Give thread time to start
return thread return thread
try: try:
@@ -767,7 +791,8 @@ 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
@@ -788,9 +813,12 @@ 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
@@ -816,7 +844,7 @@ class TestCommandWatch:
f.flush() f.flush()
sleep(0.05) sleep(0.05)
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
@@ -837,7 +865,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")
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
@@ -868,11 +896,10 @@ 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,
@@ -882,7 +909,8 @@ 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)
@@ -890,9 +918,9 @@ class TestCommandWatchPolling:
target = consumption_dir / "document.pdf" target = consumption_dir / "document.pdf"
shutil.copy(sample_pdf, target) shutil.copy(sample_pdf, target)
# Wait for: poll interval + stability delay + another poll + margin # Actively wait for consumption
# CI can be slow, so use generous timeout # Polling needs: interval (0.5s) + stability (0.1s) + next poll (0.5s) + margin
sleep(3.0) wait_for_mock_call(mock_consume_file_delay.delay, timeout_s=5.0)
if thread.exception: if thread.exception:
raise thread.exception raise thread.exception
@@ -919,7 +947,8 @@ 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
@@ -948,7 +977,8 @@ 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

@@ -89,3 +89,11 @@ 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="https://docs.paperless-ngx.com/assets/logo_full_white.svg" alt="This image should not be shown."> <img src="http://localhost:8080/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="https://docs.paperless-ngx.com/assets/logo_full_white.svg" alt="This image should not be shown."> <img src="http://localhost:8080/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,6 +6,8 @@ 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,71 +38,107 @@ def extract_text(pdf_path: Path) -> str:
class MailAttachmentMock: class MailAttachmentMock:
def __init__(self, payload, content_id): def __init__(self, payload: bytes, content_id: str) -> None:
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 TestUrlCanary: class TestNginxService:
""" """
Verify certain URLs are still available so testing is valid still Verify the local nginx server is responding correctly.
These tests validate that the test infrastructure is working properly
before running the actual parser tests that depend on HTTP resources.
""" """
def test_online_image_exception_on_not_available(self): def test_non_existent_resource_returns_404(
self,
nginx_base_url: str,
) -> None:
""" """
GIVEN: GIVEN:
- Fresh start - Local nginx server is running
WHEN: WHEN:
- nonexistent image is requested - A non-existent resource is requested
THEN: THEN:
- An exception shall be thrown - An HTTP 404 status code shall be returned
"""
"""
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(
"https://docs.paperless-ngx.com/assets/non-existent.png", f"{nginx_base_url}/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_is_online_image_still_available(self): def test_valid_resource_is_available(
self,
nginx_base_url: str,
) -> None:
""" """
GIVEN: GIVEN:
- Fresh start - Local nginx server is running
WHEN: WHEN:
- A public image used in the html sample file is requested - A valid test fixture resource is requested
THEN: THEN:
- No exception shall be thrown - The resource shall be returned with HTTP 200 status code
- 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(
"https://docs.paperless-ngx.com/assets/logo_full_white.svg", f"{nginx_base_url}/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, hash_size=18): def imagehash(file: Path, hash_size: int = 18) -> str:
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(
@@ -112,14 +148,15 @@ 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:
- Fresh start - A simple text email file
- Mocked PDF generation returning a known PDF
WHEN: WHEN:
- The Thumbnail is requested - The thumbnail is requested
THEN: THEN:
- The returned thumbnail image file is as expected - The returned thumbnail image file shall match the expected hash
""" """
mock_generate_pdf = mocker.patch( mock_generate_pdf = mocker.patch(
"paperless_mail.parsers.MailDocumentParser.generate_pdf", "paperless_mail.parsers.MailDocumentParser.generate_pdf",
@@ -134,22 +171,28 @@ 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 {simple_txt_email_thumbnail_file}" f"Created thumbnail {thumb} differs from expected file "
f"{simple_txt_email_thumbnail_file}"
) )
def test_tika_parse_successful(self, mail_parser: MailDocumentParser): def test_tika_parse_successful(self, mail_parser: MailDocumentParser) -> None:
""" """
GIVEN: GIVEN:
- Fresh start - HTML content to parse
- Tika server is running
WHEN: WHEN:
- tika parsing is called - Tika parsing is called
THEN: THEN:
- a web request to tika shall be done and the reply es returned - A web request to Tika shall be made
- The parsed text content shall be returned
""" """
html = '<html><head><meta http-equiv="content-type" content="text/html; charset=UTF-8"></head><body><p>Some Text</p></body></html>' 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()
@@ -160,14 +203,17 @@ 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 of pdfs - PDF generation is requested with HTML file requiring merging
THEN: THEN:
- gotenberg is called to merge files and the resulting file is returned - Gotenberg shall be called to merge files
- 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",
@@ -200,16 +246,17 @@ 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:
- Fresh start - An HTML email file
WHEN: WHEN:
- pdf generation from simple eml file is requested - PDF generation from the email file is requested
THEN: THEN:
- Gotenberg is called and the resulting file is returned and look as expected. - Gotenberg shall be called to generate the PDF
- 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
@@ -217,7 +264,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, so use in # Archive includes the HTML content
assert expected_archive_text in archive_text assert expected_archive_text in archive_text
# Check the thumbnail # Check the thumbnail
@@ -227,9 +274,12 @@ 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 should always look the same. # The created PDF is not reproducible, but the converted image
# 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 looks different. Check if {generated_thumbnail} looks weird." f"PDF thumbnail differs from expected. "
f"Generated: {generated_thumbnail}, "
f"Hash: {generated_thumbnail_hash} vs {expected_hash}"
) )

View File

@@ -1,7 +1,12 @@
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
@@ -15,11 +20,9 @@ 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
@@ -39,6 +42,8 @@ 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(
@@ -66,71 +71,75 @@ from paperless_mail.tasks import process_mail_accounts
), ),
) )
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): def get_permissions(self) -> list[Any]:
if self.action == "test": if self.action == "test":
# Test action does not require object level permissions return [IsAuthenticated()]
self.permission_classes = (IsAuthenticated,)
return super().get_permissions() return super().get_permissions()
@action(methods=["post"], detail=False) @action(methods=["post"], detail=False)
def test(self, request): async def test(self, request: Request) -> Response | HttpResponseBadRequest:
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)
# account exists, use the password from there instead of *** and refresh_token / expiration # Validation must be wrapped because of sync DB validators
await sync_to_async(serializer.is_valid)(raise_exception=True)
validated_data: dict[str, Any] = serializer.validated_data
if ( if (
len(serializer.validated_data.get("password").replace("*", "")) == 0 len(str(validated_data.get("password", "")).replace("*", "")) == 0
and request.data["id"] is not None and request.data.get("id") is not None
): ):
existing_account = MailAccount.objects.get(pk=request.data["id"]) existing_account = await MailAccount.objects.aget(pk=request.data["id"])
serializer.validated_data["password"] = existing_account.password validated_data.update(
serializer.validated_data["account_type"] = existing_account.account_type {
serializer.validated_data["refresh_token"] = existing_account.refresh_token "password": existing_account.password,
serializer.validated_data["expiration"] = existing_account.expiration "account_type": existing_account.account_type,
"refresh_token": existing_account.refresh_token,
"expiration": existing_account.expiration,
},
)
account = MailAccount(**serializer.validated_data) account = MailAccount(**validated_data)
def _blocking_imap_test() -> bool:
with get_mailbox( with get_mailbox(
account.imap_server, account.imap_server,
account.imap_port, account.imap_port,
account.imap_security, account.imap_security,
) as M: ) as m_box:
try:
if ( if (
account.is_token account.is_token
and account.expiration is not None and account.expiration
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
mailbox_login(M, account) try:
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)
def process(self, request, pk=None): async def process(self, request: Request, pk: int | None = None) -> Response:
account = self.get_object() # FIX: Use aget_object() provided by adrf to avoid SynchronousOnlyOperation
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"})
@@ -144,21 +153,38 @@ 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)
def bulk_delete(self, request): async def bulk_delete(
mail_ids = request.data.get("mail_ids", []) self,
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)
for mail in mails: # Store objects to delete after verification
if not has_perms_owner_aware(request.user, "delete_processedmail", mail): to_delete: list[ProcessedMail] = []
# 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")
mail.delete() to_delete.append(mail)
# 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})
@@ -178,77 +204,74 @@ class MailRuleViewSet(ModelViewSet, PassUserMixin):
responses={200: None}, responses={200: None},
), ),
) )
class OauthCallbackView(GenericAPIView): class OauthCallbackView(APIView):
permission_classes = (IsAuthenticated,) permission_classes = (IsAuthenticated,)
def get(self, request, format=None): async def get(
if not ( self,
request.user and request.user.has_perms(["paperless_mail.add_mailaccount"]) request: Request,
): ) -> 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",
) )
logger = logging.getLogger("paperless_mail") code: str | None = request.query_params.get("code")
code = request.query_params.get("code") state: str | None = request.query_params.get("state")
# Gmail passes scope as a query param, Outlook does not scope: str | None = request.query_params.get("scope")
scope = request.query_params.get("scope")
if code is None: if not code or not state:
logger.error( return HttpResponseBadRequest("Invalid request parameters")
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):
logger.error( return HttpResponseBadRequest("Invalid OAuth state")
f"Invalid oauth callback request received state: {state}, expected: {oauth_manager.state}",
)
return HttpResponseBadRequest("Invalid request, see logs for more detail")
try: try:
if scope is not None and "google" in scope: defaults: dict[str, Any] = {
# Google "username": "",
"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 = { defaults.update(
{
"name": f"Gmail OAuth {timezone.now()}", "name": f"Gmail OAuth {timezone.now()}",
"username": "",
"imap_security": MailAccount.ImapSecurity.SSL,
"imap_port": 993,
"account_type": account_type, "account_type": account_type,
} },
result = oauth_manager.get_gmail_access_token(code) )
result = await sync_to_async(oauth_manager.get_gmail_access_token)(code)
elif scope is None: else:
# Outlook
account_type = MailAccount.MailAccountType.OUTLOOK_OAUTH account_type = MailAccount.MailAccountType.OUTLOOK_OAUTH
imap_server = "outlook.office365.com" imap_server = "outlook.office365.com"
defaults = { defaults.update(
{
"name": f"Outlook OAuth {timezone.now()}", "name": f"Outlook OAuth {timezone.now()}",
"username": "",
"imap_security": MailAccount.ImapSecurity.SSL,
"imap_port": 993,
"account_type": account_type, "account_type": account_type,
} },
)
result = await sync_to_async(oauth_manager.get_outlook_access_token)(
code,
)
result = oauth_manager.get_outlook_access_token(code) account, _ = await MailAccount.objects.aupdate_or_create(
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=refresh_token, refresh_token=result["refresh_token"],
expiration=timezone.now() + timedelta(seconds=expires_in), defaults={
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,6 +16,20 @@ 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"
@@ -203,6 +217,15 @@ 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"
@@ -2919,6 +2942,7 @@ 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'" },
@@ -3067,6 +3091,7 @@ 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" },