Compare commits

...
Author SHA1 Message Date
stumpylog c200ef8646 Handles a bad client sending malformed JSON or non-int primary keys 2026-08-27 09:42:39 -07:00
Trenton Holmes 1c253c369d Perf: avoid per-instance CustomField reload in DocumentMetadataOverrides
send_websocket_document_updated calls document.refresh_from_db()
before building overrides, which drops the custom_fields prefetch
(and its select_related("field")) set up by the view's queryset.
DocumentMetadataOverrides.from_document() then lazily reloads field
once per custom field instance. Since from_document() can't rely on
the caller having a prefetched document, select_related explicitly at
the point of use instead.
2026-08-27 09:42:39 -07:00
Trenton Holmes fd9aea0340 Perf: reuse resolved CustomField objects across drf-writable-nested's per-item revalidation
drf-writable-nested's update_or_create_reverse_relations rebuilds a
fresh serializer -- and fresh field instances -- per custom_fields item
while matching existing vs. new instances during save(), so the
per-instance lookup cache alone only helped the first validation pass.
It passes the same context dict (by reference) to every one of those
serializers, so stash resolved CustomField objects there instead:
later passes reuse them for free rather than re-querying.
2026-08-27 09:42:39 -07:00
Trenton Holmes ffdd49bd47 Perf: batch CustomField lookups when validating a document's custom_fields
DocumentSerializer.custom_fields validates each item's field id via a
plain PrimaryKeyRelatedField, which issues one SELECT per custom field
per validation pass (discussion #13690). Batch-resolve all field ids in
one query and cache them on the field instance so per-item validation
is free instead of re-querying.
2026-08-27 09:42:39 -07:00
02b2f55248 New Crowdin translations by GitHub Action (#13811)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-08-27 00:28:39 -07:00
shamoon a2ea3b9e76 Update SECURITY.md 2026-08-27 00:21:03 -07:00
shamoon a298568962 Security: harden is_public_ip 2026-08-27 00:21:03 -07:00
shamoon f6f37898e8 Chore: use PinnedIMAP4 2026-08-27 00:21:02 -07:00
GitHub Actions 0cfe33720a Auto translate strings 2026-08-27 04:29:13 +00:00
shamoonandGitHub 14b17ddf8d Fix: navbar brand anchor size + Safari position jitter (#13810) 2026-08-26 21:28:01 -07:00
github-actions[bot]andGitHub 9009fbb4e9 Documentation: Add v3.1.0 changelog (#13809) 2026-08-26 19:13:04 -07:00
shamoon 94651d817d Merge branch 'dev' 2026-08-26 18:42:21 -07:00
shamoon 1630e78aac Bump version to 3.1.0 2026-08-26 18:07:04 -07:00
4e79553489 New Crowdin translations by GitHub Action (#13496)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-08-26 18:06:05 -07:00
shamoon f24d58a383 Tweak: true center search in navbar 2026-08-26 18:02:14 -07:00
shamoonandGitHub d486dd1205 Fix: prevent config autocomplete craziness (#13808) 2026-08-26 17:46:34 -07:00
shamoon 406bc1a233 Fix: defer add_nested_tags in ai workflow to avoid losing unsaved changes 2026-08-26 14:18:40 -07:00
GitHub Actions 808746c28c Auto translate strings 2026-08-26 21:03:06 +00:00
shamoon ff6d73ce17 Fix missing clear button on select choices 2026-08-26 14:01:04 -07:00
shamoonandGitHub b4068d38bc Fix/performance: prevent token re-use in dropdown filtering, also a perf thing (#13804) 2026-08-26 13:18:09 -07:00
shamoon 139739c2a2 Fix: handle social account sync groups claim sent as str 2026-08-26 12:37:29 -07:00
shamoon aad75b4981 Docs: clarify app config trust 2026-08-26 01:39:50 -07:00
127 changed files with 162044 additions and 92180 deletions
+4 -1
View File
@@ -59,10 +59,13 @@ The following are not generally considered vulnerabilities unless accompanied by
- large uploads or resource usage that do not bypass documented limits or privileges
- IDOR / access control claims regarding the ability to attach an un-viewable object to a document. This is expected behavior.
- claims based solely on the presence of a library, framework feature or code pattern without a working exploit
- reports that rely on admin-level access, workflow-editing privileges, shell access, or other high-trust roles unless they demonstrate an unintended privilege boundary bypass
- pickle deserialization of internal data from trusted components such as the Redis-compatible broker or Paperless-ngx data directory
- users with permission to edit users granting themselves additional privileges; this is expected behavior for that trusted permission
- reports that rely on admin-level access, application-configuration access, workflow-editing privileges, shell access, or other high-trust roles unless they demonstrate an unintended privilege boundary bypass
- optional webhook, mail, AI, OCR, or integration behavior described without a product-level vulnerability
- missing limits or hardening settings presented without concrete impact
- generic AI or static-analysis output that is not confirmed against the current codebase and a real deployment scenario
- metadata names visible in a document's custom storage path, even when the user cannot access the underlying metadata object; this is expected behavior
- the ability to attach objects that a user cannot access to a document by ID is an intentional design choice, and not considered a vulnerability
## Transparency
+215
View File
@@ -1,5 +1,220 @@
# Changelog
## paperless-ngx 3.1.0
### Features / Enhancements
- Enhancement: Apply AI suggestions workflow action [@shamoon](https://github.com/shamoon) ([#13639](https://github.com/paperless-ngx/paperless-ngx/pull/13639))
- Enhancement: welcome widget visual tweaks [@shamoon](https://github.com/shamoon) ([#13794](https://github.com/paperless-ngx/paperless-ngx/pull/13794))
- Enhancement: support using remote OCR engines selectively [@shamoon](https://github.com/shamoon) ([#13633](https://github.com/paperless-ngx/paperless-ngx/pull/13633))
- Enhancement: websocket heartbeat [@oktupol](https://github.com/oktupol) ([#13739](https://github.com/paperless-ngx/paperless-ngx/pull/13739))
- Enhancement: more v3 ui tweaks [@shamoon](https://github.com/shamoon) ([#13774](https://github.com/paperless-ngx/paperless-ngx/pull/13774))
- Tweak: better support long list of views in documents list [@shamoon](https://github.com/shamoon) ([#13769](https://github.com/paperless-ngx/paperless-ngx/pull/13769))
- QoL: add count badge to versions dropdown [@shamoon](https://github.com/shamoon) ([#13753](https://github.com/paperless-ngx/paperless-ngx/pull/13753))
- Tweakhancement: add jitter to IMAP polling schedule [@shamoon](https://github.com/shamoon) ([#13734](https://github.com/paperless-ngx/paperless-ngx/pull/13734))
- Enhancement: sync OIDC groups to superuser and staff roles [@BeSovereign](https://github.com/BeSovereign) ([#13060](https://github.com/paperless-ngx/paperless-ngx/pull/13060))
- Enhancement: merge documents as versions [@shamoon](https://github.com/shamoon) ([#13515](https://github.com/paperless-ngx/paperless-ngx/pull/13515))
- Tweak: adjust modal proportions for small screens [@shamoon](https://github.com/shamoon) ([#13728](https://github.com/paperless-ngx/paperless-ngx/pull/13728))
- Refactor: render paperless\_ai prompts via Jinja2 templates instead of f-strings [@stumpylog](https://github.com/stumpylog) ([#13698](https://github.com/paperless-ngx/paperless-ngx/pull/13698))
- Tweak: small visual tweaks / improvements \& fixes [@shamoon](https://github.com/shamoon) ([#13700](https://github.com/paperless-ngx/paperless-ngx/pull/13700))
- Enhancement: prefer existing tags, types, correspondents, and storage paths in AI suggestions [@stumpylog](https://github.com/stumpylog) ([#13676](https://github.com/paperless-ngx/paperless-ngx/pull/13676))
- Tweak: tweak permissions menu labels for shared user-dependent views [@shamoon](https://github.com/shamoon) ([#13685](https://github.com/paperless-ngx/paperless-ngx/pull/13685))
- Feature: Allow selection of compression type and and level during export [@stumpylog](https://github.com/stumpylog) ([#13661](https://github.com/paperless-ngx/paperless-ngx/pull/13661))
- Enhancement: customizable icons for saved views [@shamoon](https://github.com/shamoon) ([#13388](https://github.com/paperless-ngx/paperless-ngx/pull/13388))
- Enhancement: Add --url argument to document\_fuzzy\_match to improve output [@lukyjay](https://github.com/lukyjay) ([#13123](https://github.com/paperless-ngx/paperless-ngx/pull/13123))
- Feature: Updates remote OCR parser to respect the OCR mode setting [@stumpylog](https://github.com/stumpylog) ([#13408](https://github.com/paperless-ngx/paperless-ngx/pull/13408))
- Tweak: improve no ML suggestions UX [@shamoon](https://github.com/shamoon) ([#13621](https://github.com/paperless-ngx/paperless-ngx/pull/13621))
- Performance: reduce memory and I/O overhead of the document exporter during zip exports [@stumpylog](https://github.com/stumpylog) ([#13490](https://github.com/paperless-ngx/paperless-ngx/pull/13490))
- QoL: make name button text on attribute pages selectable [@shamoon](https://github.com/shamoon) ([#13592](https://github.com/paperless-ngx/paperless-ngx/pull/13592))
- Performance: More efficient mail fetching [@stumpylog](https://github.com/stumpylog) ([#13432](https://github.com/paperless-ngx/paperless-ngx/pull/13432))
### Bug Fixes
- Fix: prevent config autocomplete craziness [@shamoon](https://github.com/shamoon) ([#13808](https://github.com/paperless-ngx/paperless-ngx/pull/13808))
- Fix/performance: prevent token reuse in dropdown filtering, also a perf thing [@shamoon](https://github.com/shamoon) ([#13804](https://github.com/paperless-ngx/paperless-ngx/pull/13804))
- Fix: exclude version documents from bulk edit "all" [@shamoon](https://github.com/shamoon) ([#13791](https://github.com/paperless-ngx/paperless-ngx/pull/13791))
- Fix: fix bottom mobile nav buttons on Android [@shamoon](https://github.com/shamoon) ([#13780](https://github.com/paperless-ngx/paperless-ngx/pull/13780))
- Fix: lazy import guardian modules to fix search language setting [@shamoon](https://github.com/shamoon) ([#13768](https://github.com/paperless-ngx/paperless-ngx/pull/13768))
- Fix: version indexing fixes [@shamoon](https://github.com/shamoon) ([#13737](https://github.com/paperless-ngx/paperless-ngx/pull/13737))
- Fix: append charset to file response for text files [@shamoon](https://github.com/shamoon) ([#13759](https://github.com/paperless-ngx/paperless-ngx/pull/13759))
- Chore: pin Apache Tika images to 3.3.1 [@shamoon](https://github.com/shamoon) ([#13758](https://github.com/paperless-ngx/paperless-ngx/pull/13758))
- Fix: align bulk edit object perms with document model [@shamoon](https://github.com/shamoon) ([#13757](https://github.com/paperless-ngx/paperless-ngx/pull/13757))
- Fix: use selected version for doc detail emailing [@shamoon](https://github.com/shamoon) ([#13738](https://github.com/paperless-ngx/paperless-ngx/pull/13738))
- Fix: hide version delete button without global perms [@shamoon](https://github.com/shamoon) ([#13735](https://github.com/paperless-ngx/paperless-ngx/pull/13735))
- Fix: dont re-render path template when checking collisions [@shamoon](https://github.com/shamoon) ([#13718](https://github.com/paperless-ngx/paperless-ngx/pull/13718))
- Fix: DocumentClassifierSchema bounds [@shamoon](https://github.com/shamoon) ([#13707](https://github.com/paperless-ngx/paperless-ngx/pull/13707))
- Fix: remove shadow around attribute pages [@shamoon](https://github.com/shamoon) ([#13696](https://github.com/paperless-ngx/paperless-ngx/pull/13696))
- Zen: correct dropdown corner radius visual defect [@shamoon](https://github.com/shamoon) ([#13695](https://github.com/paperless-ngx/paperless-ngx/pull/13695))
- Fix: handle Android keyboard popper overlay [@shamoon](https://github.com/shamoon) ([#13694](https://github.com/paperless-ngx/paperless-ngx/pull/13694))
- Fix: only show create when there is text, hide set values if no fields in cf bulk edit dropdown [@shamoon](https://github.com/shamoon) ([#13688](https://github.com/paperless-ngx/paperless-ngx/pull/13688))
- Fix: reopen a fresh Tantivy index per write to prevent orphaned segment files [@stumpylog](https://github.com/stumpylog) ([#13682](https://github.com/paperless-ngx/paperless-ngx/pull/13682))
- Fix: fix validation of workflow title assignment [@maxtruxa](https://github.com/maxtruxa) ([#13659](https://github.com/paperless-ngx/paperless-ngx/pull/13659))
- Fix: dont clip search dropdown on mobile [@shamoon](https://github.com/shamoon) ([#13675](https://github.com/paperless-ngx/paperless-ngx/pull/13675))
- Fix: include sharelink bundle perms in WebUI [@shamoon](https://github.com/shamoon) ([#13664](https://github.com/paperless-ngx/paperless-ngx/pull/13664))
- Fix: add pagination to saved views management page [@shamoon](https://github.com/shamoon) ([#13646](https://github.com/paperless-ngx/paperless-ngx/pull/13646))
- Fix: fixes for workflow assign custom field values [@shamoon](https://github.com/shamoon) ([#13630](https://github.com/paperless-ngx/paperless-ngx/pull/13630))
- Fix: deny deactivated users in permission filtering and auto-login [@stumpylog](https://github.com/stumpylog) ([#13623](https://github.com/paperless-ngx/paperless-ngx/pull/13623))
- Fix: check bulk mail delete permissions for the whole batch up front [@stumpylog](https://github.com/stumpylog) ([#13620](https://github.com/paperless-ngx/paperless-ngx/pull/13620))
- Fix: Allow DRF to validate the maximum API key length [@stumpylog](https://github.com/stumpylog) ([#13614](https://github.com/paperless-ngx/paperless-ngx/pull/13614))
- Fix: render PDF form values in annotation layer [@shamoon](https://github.com/shamoon) ([#13607](https://github.com/paperless-ngx/paperless-ngx/pull/13607))
- QoL: disable name button without perms [@shamoon](https://github.com/shamoon) ([#13606](https://github.com/paperless-ngx/paperless-ngx/pull/13606))
- Fix: prevent debounce overwrites in advanced search field, also improve Esc behavior [@shamoon](https://github.com/shamoon) ([#13602](https://github.com/paperless-ngx/paperless-ngx/pull/13602))
- Fix: raise ParseError on remote OCR failure instead of silently continuing [@stumpylog](https://github.com/stumpylog) ([#13574](https://github.com/paperless-ngx/paperless-ngx/pull/13574))
- Fix: use selected version when creating share links [@shamoon](https://github.com/shamoon) ([#13571](https://github.com/paperless-ngx/paperless-ngx/pull/13571))
- Fix: reject bulk edit permissions requests without the correct key [@shamoon](https://github.com/shamoon) ([#13563](https://github.com/paperless-ngx/paperless-ngx/pull/13563))
- Fix: correctly serve app logo specified in env [@shamoon](https://github.com/shamoon) ([#13561](https://github.com/paperless-ngx/paperless-ngx/pull/13561))
- Fix: prevent workflow passwords field type error [@shamoon](https://github.com/shamoon) ([#13552](https://github.com/paperless-ngx/paperless-ngx/pull/13552))
- Fix: correct Firefox print regression [@shamoon](https://github.com/shamoon) ([#13543](https://github.com/paperless-ngx/paperless-ngx/pull/13543))
- Fix: hide some saved view operations on management page without permissions [@shamoon](https://github.com/shamoon) ([#13542](https://github.com/paperless-ngx/paperless-ngx/pull/13542))
- Fix: disable pdfjs selection rendering [@shamoon](https://github.com/shamoon) ([#13538](https://github.com/paperless-ngx/paperless-ngx/pull/13538))
- Fix: hide sidebar drag grips with insufficient permissions [@shamoon](https://github.com/shamoon) ([#13536](https://github.com/paperless-ngx/paperless-ngx/pull/13536))
- Fix: don't re-queue a consume-folder file that is already queued and awaiting consumption [@stumpylog](https://github.com/stumpylog) ([#13526](https://github.com/paperless-ngx/paperless-ngx/pull/13526))
- Fix: correct multi-search non-adjacent queries [@shamoon](https://github.com/shamoon) ([#13504](https://github.com/paperless-ngx/paperless-ngx/pull/13504))
- Fix: Content-Disposition filename normalization [@shamoon](https://github.com/shamoon) ([#13514](https://github.com/paperless-ngx/paperless-ngx/pull/13514))
- Fix: prevent duplicated text query with multiple date queries [@shamoon](https://github.com/shamoon) ([#13522](https://github.com/paperless-ngx/paperless-ngx/pull/13522))
- Fix: crash filtering document link custom fields with an unset or unrelated field present [@ggouzi](https://github.com/ggouzi) ([#13518](https://github.com/paperless-ngx/paperless-ngx/pull/13518))
- Fix: parse unpadded yyyy-mm-dd date input regardless of locale [@Se1foo](https://github.com/Se1foo) ([#13501](https://github.com/paperless-ngx/paperless-ngx/pull/13501))
### Documentation
- Documentation: clarify default OCR mode changes in v3 [@shamoon](https://github.com/shamoon) ([#13666](https://github.com/paperless-ngx/paperless-ngx/pull/13666))
- Fix: fixes for workflow assign custom field values [@shamoon](https://github.com/shamoon) ([#13630](https://github.com/paperless-ngx/paperless-ngx/pull/13630))
- Documentation: add wiki links for AI stuff and parser plugins [@shamoon](https://github.com/shamoon) ([#13626](https://github.com/paperless-ngx/paperless-ngx/pull/13626))
### Maintenance
- Chore(deps): Bump the actions group across 1 directory with 20 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13481](https://github.com/paperless-ngx/paperless-ngx/pull/13481))
### Dependencies
<details>
<summary>23 changes</summary>
- Chore: Upgrade Docker image to Python 3.14 [@stumpylog](https://github.com/stumpylog) ([#13721](https://github.com/paperless-ngx/paperless-ngx/pull/13721))
- Chore(deps): Bump the uv group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13709](https://github.com/paperless-ngx/paperless-ngx/pull/13709))
- Chore: update, reorg some npm deps [@shamoon](https://github.com/shamoon) ([#13716](https://github.com/paperless-ngx/paperless-ngx/pull/13716))
- Chore: update fpdf2 to 2.8.8 [@shamoon](https://github.com/shamoon) ([#13629](https://github.com/paperless-ngx/paperless-ngx/pull/13629))
- Chore: update pnpm, add blockExoticSubdeps [@shamoon](https://github.com/shamoon) ([#13628](https://github.com/paperless-ngx/paperless-ngx/pull/13628))
- Chore(deps): Bump h2 from 4.3.0 to 4.4.1 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13593](https://github.com/paperless-ngx/paperless-ngx/pull/13593))
- Chore(deps): Bump pdfjs-dist from 6.1.200 to 6.2.108 in /src-ui in the npm\_and\_yarn group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13594](https://github.com/paperless-ngx/paperless-ngx/pull/13594))
- Chore(deps): Bump cryptography from 48.0.1 to 50.0.0 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13588](https://github.com/paperless-ngx/paperless-ngx/pull/13588))
- Chore(deps): Bump the utilities-patch group across 1 directory with 6 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13539](https://github.com/paperless-ngx/paperless-ngx/pull/13539))
- Chore(deps): Bump the utilities-minor group across 1 directory with 20 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13535](https://github.com/paperless-ngx/paperless-ngx/pull/13535))
- Chore(deps): Bump aiohttp from 3.14.1 to 3.14.3 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13537](https://github.com/paperless-ngx/paperless-ngx/pull/13537))
- Chore(deps-dev): Bump zensical from 0.0.47 to 0.0.51 in the development group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13533](https://github.com/paperless-ngx/paperless-ngx/pull/13533))
- Chore(deps-dev): Bump postcss from 8.5.22 to 8.5.25 in /src/paperless\_mail/templates in the npm\_and\_yarn group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13534](https://github.com/paperless-ngx/paperless-ngx/pull/13534))
- Chore(deps): Bump the pre-commit-dependencies group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13532](https://github.com/paperless-ngx/paperless-ngx/pull/13532))
- Chore: ruff 0.16 upgrade [@stumpylog](https://github.com/stumpylog) ([#13531](https://github.com/paperless-ngx/paperless-ngx/pull/13531))
- Chore(deps): Bump the actions group across 1 directory with 20 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13481](https://github.com/paperless-ngx/paperless-ngx/pull/13481))
- docker(deps): bump astral-sh/uv from 0.11.28-python3.12-trixie-slim to 0.11.32-python3.12-trixie-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#13472](https://github.com/paperless-ngx/paperless-ngx/pull/13472))
- docker-compose(deps): bump nginx from 1.31.2-alpine to 1.31.3-alpine in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#13470](https://github.com/paperless-ngx/paperless-ngx/pull/13470))
- docker-compose(deps): Bump greenmail/standalone from 2.1.9 to 2.1.11 in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#13469](https://github.com/paperless-ngx/paperless-ngx/pull/13469))
- Chore(deps): Bump the frontend-angular-dependencies group across 1 directory with 18 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13476](https://github.com/paperless-ngx/paperless-ngx/pull/13476))
- Chore(deps-dev): Bump @playwright/test from 1.61.1 to 1.62.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#13480](https://github.com/paperless-ngx/paperless-ngx/pull/13480))
- Chore(deps-dev): Bump the frontend-eslint-dependencies group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13477](https://github.com/paperless-ngx/paperless-ngx/pull/13477))
- Chore(deps-dev): Bump @types/node from 26.1.0 to 26.1.1 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#13479](https://github.com/paperless-ngx/paperless-ngx/pull/13479))
</details>
### All App Changes
<details>
<summary>91 changes</summary>
- Fix: prevent config autocomplete craziness [@shamoon](https://github.com/shamoon) ([#13808](https://github.com/paperless-ngx/paperless-ngx/pull/13808))
- Fix/performance: prevent token reuse in dropdown filtering, also a perf thing [@shamoon](https://github.com/shamoon) ([#13804](https://github.com/paperless-ngx/paperless-ngx/pull/13804))
- Enhancement: Apply AI suggestions workflow action [@shamoon](https://github.com/shamoon) ([#13639](https://github.com/paperless-ngx/paperless-ngx/pull/13639))
- Fix: exclude version documents from bulk edit "all" [@shamoon](https://github.com/shamoon) ([#13791](https://github.com/paperless-ngx/paperless-ngx/pull/13791))
- Enhancement: welcome widget visual tweaks [@shamoon](https://github.com/shamoon) ([#13794](https://github.com/paperless-ngx/paperless-ngx/pull/13794))
- Performance: fetch note authors with prefetch instead of one query each [@shamoon](https://github.com/shamoon) ([#13790](https://github.com/paperless-ngx/paperless-ngx/pull/13790))
- Tweak: more misc UI tweaks [@shamoon](https://github.com/shamoon) ([#13783](https://github.com/paperless-ngx/paperless-ngx/pull/13783))
- Enhancement: support using remote OCR engines selectively [@shamoon](https://github.com/shamoon) ([#13633](https://github.com/paperless-ngx/paperless-ngx/pull/13633))
- Fix: fix bottom mobile nav buttons on Android [@shamoon](https://github.com/shamoon) ([#13780](https://github.com/paperless-ngx/paperless-ngx/pull/13780))
- Enhancement: websocket heartbeat [@oktupol](https://github.com/oktupol) ([#13739](https://github.com/paperless-ngx/paperless-ngx/pull/13739))
- Fix: lazy import guardian modules to fix search language setting [@shamoon](https://github.com/shamoon) ([#13768](https://github.com/paperless-ngx/paperless-ngx/pull/13768))
- Enhancement: more v3 ui tweaks [@shamoon](https://github.com/shamoon) ([#13774](https://github.com/paperless-ngx/paperless-ngx/pull/13774))
- Chore: add some missing UI accessibility labels [@shamoon](https://github.com/shamoon) ([#13772](https://github.com/paperless-ngx/paperless-ngx/pull/13772))
- Chore: refactor permission checkbox live changes [@shamoon](https://github.com/shamoon) ([#13771](https://github.com/paperless-ngx/paperless-ngx/pull/13771))
- Tweak: better support long list of views in documents list [@shamoon](https://github.com/shamoon) ([#13769](https://github.com/paperless-ngx/paperless-ngx/pull/13769))
- Fix: version indexing fixes [@shamoon](https://github.com/shamoon) ([#13737](https://github.com/paperless-ngx/paperless-ngx/pull/13737))
- Fix: append charset to file response for text files [@shamoon](https://github.com/shamoon) ([#13759](https://github.com/paperless-ngx/paperless-ngx/pull/13759))
- Fix: align bulk edit object perms with document model [@shamoon](https://github.com/shamoon) ([#13757](https://github.com/paperless-ngx/paperless-ngx/pull/13757))
- QoL: add count badge to versions dropdown [@shamoon](https://github.com/shamoon) ([#13753](https://github.com/paperless-ngx/paperless-ngx/pull/13753))
- Tweakhancement: add jitter to IMAP polling schedule [@shamoon](https://github.com/shamoon) ([#13734](https://github.com/paperless-ngx/paperless-ngx/pull/13734))
- Fix: use selected version for doc detail emailing [@shamoon](https://github.com/shamoon) ([#13738](https://github.com/paperless-ngx/paperless-ngx/pull/13738))
- Fix: hide version delete button without global perms [@shamoon](https://github.com/shamoon) ([#13735](https://github.com/paperless-ngx/paperless-ngx/pull/13735))
- Enhancement: sync OIDC groups to superuser and staff roles [@BeSovereign](https://github.com/BeSovereign) ([#13060](https://github.com/paperless-ngx/paperless-ngx/pull/13060))
- Enhancement: merge documents as versions [@shamoon](https://github.com/shamoon) ([#13515](https://github.com/paperless-ngx/paperless-ngx/pull/13515))
- Tweak: adjust modal proportions for small screens [@shamoon](https://github.com/shamoon) ([#13728](https://github.com/paperless-ngx/paperless-ngx/pull/13728))
- Refactor: render paperless\_ai prompts via Jinja2 templates instead of f-strings [@stumpylog](https://github.com/stumpylog) ([#13698](https://github.com/paperless-ngx/paperless-ngx/pull/13698))
- Chore(deps): Bump the uv group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13709](https://github.com/paperless-ngx/paperless-ngx/pull/13709))
- Fix: dont re-render path template when checking collisions [@shamoon](https://github.com/shamoon) ([#13718](https://github.com/paperless-ngx/paperless-ngx/pull/13718))
- Chore: update, reorg some npm deps [@shamoon](https://github.com/shamoon) ([#13716](https://github.com/paperless-ngx/paperless-ngx/pull/13716))
- Fix: DocumentClassifierSchema bounds [@shamoon](https://github.com/shamoon) ([#13707](https://github.com/paperless-ngx/paperless-ngx/pull/13707))
- Tweak: small visual tweaks / improvements \& fixes [@shamoon](https://github.com/shamoon) ([#13700](https://github.com/paperless-ngx/paperless-ngx/pull/13700))
- Fix: remove shadow around attribute pages [@shamoon](https://github.com/shamoon) ([#13696](https://github.com/paperless-ngx/paperless-ngx/pull/13696))
- Zen: correct dropdown corner radius visual defect [@shamoon](https://github.com/shamoon) ([#13695](https://github.com/paperless-ngx/paperless-ngx/pull/13695))
- Fix: handle Android keyboard popper overlay [@shamoon](https://github.com/shamoon) ([#13694](https://github.com/paperless-ngx/paperless-ngx/pull/13694))
- Enhancement: prefer existing tags, types, correspondents, and storage paths in AI suggestions [@stumpylog](https://github.com/stumpylog) ([#13676](https://github.com/paperless-ngx/paperless-ngx/pull/13676))
- Fix: only show create when there is text, hide set values if no fields in cf bulk edit dropdown [@shamoon](https://github.com/shamoon) ([#13688](https://github.com/paperless-ngx/paperless-ngx/pull/13688))
- Fix: reopen a fresh Tantivy index per write to prevent orphaned segment files [@stumpylog](https://github.com/stumpylog) ([#13682](https://github.com/paperless-ngx/paperless-ngx/pull/13682))
- Tweak: tweak permissions menu labels for shared user-dependent views [@shamoon](https://github.com/shamoon) ([#13685](https://github.com/paperless-ngx/paperless-ngx/pull/13685))
- Fix: fix validation of workflow title assignment [@maxtruxa](https://github.com/maxtruxa) ([#13659](https://github.com/paperless-ngx/paperless-ngx/pull/13659))
- Feature: Allow selection of compression type and and level during export [@stumpylog](https://github.com/stumpylog) ([#13661](https://github.com/paperless-ngx/paperless-ngx/pull/13661))
- Fix: dont clip search dropdown on mobile [@shamoon](https://github.com/shamoon) ([#13675](https://github.com/paperless-ngx/paperless-ngx/pull/13675))
- Fix: include sharelink bundle perms in WebUI [@shamoon](https://github.com/shamoon) ([#13664](https://github.com/paperless-ngx/paperless-ngx/pull/13664))
- Enhancement: customizable icons for saved views [@shamoon](https://github.com/shamoon) ([#13388](https://github.com/paperless-ngx/paperless-ngx/pull/13388))
- Enhancement: Add --url argument to document\_fuzzy\_match to improve output [@lukyjay](https://github.com/lukyjay) ([#13123](https://github.com/paperless-ngx/paperless-ngx/pull/13123))
- Feature: Updates remote OCR parser to respect the OCR mode setting [@stumpylog](https://github.com/stumpylog) ([#13408](https://github.com/paperless-ngx/paperless-ngx/pull/13408))
- Performance: pass document chat queries as a QuerySet instead of a materialized.list [@stumpylog](https://github.com/stumpylog) ([#13638](https://github.com/paperless-ngx/paperless-ngx/pull/13638))
- Fix: add pagination to saved views management page [@shamoon](https://github.com/shamoon) ([#13646](https://github.com/paperless-ngx/paperless-ngx/pull/13646))
- Fix: fixes for workflow assign custom field values [@shamoon](https://github.com/shamoon) ([#13630](https://github.com/paperless-ngx/paperless-ngx/pull/13630))
- Fix: deny deactivated users in permission filtering and auto-login [@stumpylog](https://github.com/stumpylog) ([#13623](https://github.com/paperless-ngx/paperless-ngx/pull/13623))
- Chore: update fpdf2 to 2.8.8 [@shamoon](https://github.com/shamoon) ([#13629](https://github.com/paperless-ngx/paperless-ngx/pull/13629))
- Chore: update pnpm, add blockExoticSubdeps [@shamoon](https://github.com/shamoon) ([#13628](https://github.com/paperless-ngx/paperless-ngx/pull/13628))
- Fix: check bulk mail delete permissions for the whole batch up front [@stumpylog](https://github.com/stumpylog) ([#13620](https://github.com/paperless-ngx/paperless-ngx/pull/13620))
- Tweak: improve no ML suggestions UX [@shamoon](https://github.com/shamoon) ([#13621](https://github.com/paperless-ngx/paperless-ngx/pull/13621))
- Fix: Allow DRF to validate the maximum API key length [@stumpylog](https://github.com/stumpylog) ([#13614](https://github.com/paperless-ngx/paperless-ngx/pull/13614))
- Performance: unify permission-filtering backends, fixes Correspondent/Tag list slowness [@stumpylog](https://github.com/stumpylog) ([#13601](https://github.com/paperless-ngx/paperless-ngx/pull/13601))
- Performance: generalize permitted\_document\_ids into permitted\_object\_ids for any model [@stumpylog](https://github.com/stumpylog) ([#13578](https://github.com/paperless-ngx/paperless-ngx/pull/13578))
- Fix: render PDF form values in annotation layer [@shamoon](https://github.com/shamoon) ([#13607](https://github.com/paperless-ngx/paperless-ngx/pull/13607))
- QoL: disable name button without perms [@shamoon](https://github.com/shamoon) ([#13606](https://github.com/paperless-ngx/paperless-ngx/pull/13606))
- Fix: prevent debounce overwrites in advanced search field, also improve Esc behavior [@shamoon](https://github.com/shamoon) ([#13602](https://github.com/paperless-ngx/paperless-ngx/pull/13602))
- Performance: reduce memory and I/O overhead of the document exporter during zip exports [@stumpylog](https://github.com/stumpylog) ([#13490](https://github.com/paperless-ngx/paperless-ngx/pull/13490))
- Chore(deps): Bump h2 from 4.3.0 to 4.4.1 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13593](https://github.com/paperless-ngx/paperless-ngx/pull/13593))
- Chore(deps): Bump pdfjs-dist from 6.1.200 to 6.2.108 in /src-ui in the npm\_and\_yarn group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13594](https://github.com/paperless-ngx/paperless-ngx/pull/13594))
- QoL: make name button text on attribute pages selectable [@shamoon](https://github.com/shamoon) ([#13592](https://github.com/paperless-ngx/paperless-ngx/pull/13592))
- Fix: raise ParseError on remote OCR failure instead of silently continuing [@stumpylog](https://github.com/stumpylog) ([#13574](https://github.com/paperless-ngx/paperless-ngx/pull/13574))
- Fix: use selected version when creating share links [@shamoon](https://github.com/shamoon) ([#13571](https://github.com/paperless-ngx/paperless-ngx/pull/13571))
- Chore: specify AI chat refine template [@shamoon](https://github.com/shamoon) ([#13564](https://github.com/paperless-ngx/paperless-ngx/pull/13564))
- Fix: reject bulk edit permissions requests without the correct key [@shamoon](https://github.com/shamoon) ([#13563](https://github.com/paperless-ngx/paperless-ngx/pull/13563))
- Fix: correctly serve app logo specified in env [@shamoon](https://github.com/shamoon) ([#13561](https://github.com/paperless-ngx/paperless-ngx/pull/13561))
- Fix: prevent workflow passwords field type error [@shamoon](https://github.com/shamoon) ([#13552](https://github.com/paperless-ngx/paperless-ngx/pull/13552))
- Chore(deps): Bump the utilities-patch group across 1 directory with 6 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13539](https://github.com/paperless-ngx/paperless-ngx/pull/13539))
- Chore(deps): Bump the utilities-minor group across 1 directory with 20 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13535](https://github.com/paperless-ngx/paperless-ngx/pull/13535))
- Performance: More efficient mail fetching [@stumpylog](https://github.com/stumpylog) ([#13432](https://github.com/paperless-ngx/paperless-ngx/pull/13432))
- Performance: eliminate per-document guardian permission-check causing high CPU on document lists [@stumpylog](https://github.com/stumpylog) ([#13505](https://github.com/paperless-ngx/paperless-ngx/pull/13505))
- Fix: correct Firefox print regression [@shamoon](https://github.com/shamoon) ([#13543](https://github.com/paperless-ngx/paperless-ngx/pull/13543))
- Fix: hide some saved view operations on management page without permissions [@shamoon](https://github.com/shamoon) ([#13542](https://github.com/paperless-ngx/paperless-ngx/pull/13542))
- Chore(deps): Bump aiohttp from 3.14.1 to 3.14.3 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13537](https://github.com/paperless-ngx/paperless-ngx/pull/13537))
- Chore(deps-dev): Bump zensical from 0.0.47 to 0.0.51 in the development group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13533](https://github.com/paperless-ngx/paperless-ngx/pull/13533))
- Fix: disable pdfjs selection rendering [@shamoon](https://github.com/shamoon) ([#13538](https://github.com/paperless-ngx/paperless-ngx/pull/13538))
- Fix: hide sidebar drag grips with insufficient permissions [@shamoon](https://github.com/shamoon) ([#13536](https://github.com/paperless-ngx/paperless-ngx/pull/13536))
- Chore(deps-dev): Bump postcss from 8.5.22 to 8.5.25 in /src/paperless\_mail/templates in the npm\_and\_yarn group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13534](https://github.com/paperless-ngx/paperless-ngx/pull/13534))
- Chore: ruff 0.16 upgrade [@stumpylog](https://github.com/stumpylog) ([#13531](https://github.com/paperless-ngx/paperless-ngx/pull/13531))
- Fix: don't re-queue a consume-folder file that is already queued and awaiting consumption [@stumpylog](https://github.com/stumpylog) ([#13526](https://github.com/paperless-ngx/paperless-ngx/pull/13526))
- Fix: correct multi-search non-adjacent queries [@shamoon](https://github.com/shamoon) ([#13504](https://github.com/paperless-ngx/paperless-ngx/pull/13504))
- Fix: Content-Disposition filename normalization [@shamoon](https://github.com/shamoon) ([#13514](https://github.com/paperless-ngx/paperless-ngx/pull/13514))
- Fix: prevent duplicated text query with multiple date queries [@shamoon](https://github.com/shamoon) ([#13522](https://github.com/paperless-ngx/paperless-ngx/pull/13522))
- Fix: crash filtering document link custom fields with an unset or unrelated field present [@ggouzi](https://github.com/ggouzi) ([#13518](https://github.com/paperless-ngx/paperless-ngx/pull/13518))
- Fix: parse unpadded yyyy-mm-dd date input regardless of locale [@Se1foo](https://github.com/Se1foo) ([#13501](https://github.com/paperless-ngx/paperless-ngx/pull/13501))
- Chore(deps): Bump the frontend-angular-dependencies group across 1 directory with 18 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13476](https://github.com/paperless-ngx/paperless-ngx/pull/13476))
- Chore(deps-dev): Bump @playwright/test from 1.61.1 to 1.62.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#13480](https://github.com/paperless-ngx/paperless-ngx/pull/13480))
- Chore(deps-dev): Bump the frontend-eslint-dependencies group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13477](https://github.com/paperless-ngx/paperless-ngx/pull/13477))
- Chore(deps-dev): Bump @types/node from 26.1.0 to 26.1.1 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#13479](https://github.com/paperless-ngx/paperless-ngx/pull/13479))
</details>
## paperless-ngx 3.0.5
### Bug Fixes
+10
View File
@@ -8,6 +8,12 @@ common [OCR](#ocr) related settings and some frontend settings. If set, these wi
preference over the settings via environment variables. If not set, the environment setting
or applicable default will be utilized instead.
!!! warning
Changing configuration from the UI requires the `AppConfig` permission, which applies
instance-wide and should be treated as an admin-level permission. See
[global permissions](usage.md#global-permissions).
- If you run paperless on docker, `paperless.conf` is not used.
Rather, configure paperless by copying necessary options to
`docker-compose.env`.
@@ -1125,6 +1131,10 @@ they use underscores instead of dashes.
so specifying invalid options may prevent paperless from consuming
any documents. Use with caution!
These arguments are passed directly to OCRmyPDF, so this setting should only
be changed by trusted users. This applies to the `AppConfig` permission as well,
which allows setting these arguments from the UI.
Specify arguments as a JSON dictionary. Keep note of lower case
booleans and double quoted parameter names and strings. Examples:
+21 -21
View File
@@ -427,27 +427,27 @@ Global permissions define what areas of the app and API endpoints users can acce
determine if a user can create, edit, delete or view _any_ documents, but individual documents themselves
still have "object-level" permissions.
| Type | Details |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| AppConfig | _Change_ or higher permissions grants access to the "Application Configuration" area. |
| Correspondent | Add, edit, delete or view Correspondents. |
| CustomField | Add, edit, delete or view Custom Fields. |
| Document | Add, edit, delete or view Documents. |
| DocumentType | Add, edit, delete or view Document Types. |
| Group | Add, edit, delete or view Groups. |
| GlobalStatistics | View aggregate object counts and statistics. This does not grant access to view individual documents. |
| MailAccount | Add, edit, delete or view Mail Accounts. |
| MailRule | Add, edit, delete or view Mail Rules. |
| Note | Add, edit, delete or view Notes. |
| PaperlessTask | View or dismiss (_Change_) File Tasks. |
| SavedView | Add, edit, delete or view Saved Views. |
| ShareLink | Add, delete or view Share Links. |
| StoragePath | Add, edit, delete or view Storage Paths. |
| SystemMonitoring | View the system status dialog, tasks summary and their API endpoints. Admin users also retain system status access. |
| Tag | Add, edit, delete or view Tags. |
| UISettings | Add, edit, delete or view the UI settings that are used by the web app.<br/>:warning: **Users that will access the web UI must be granted at least _View_ permissions.** |
| User | Add, edit, delete or view other user accounts via Settings > Users & Groups and `/api/users/`. These permissions are not needed for users to edit their own profile via "My Profile" or `/api/profile/`. |
| Workflow | Add, edit, delete or view Workflows.<br/>Note that Workflows are global; all users who can access workflows see the same set. Workflows have other permission implications — see [Workflow permissions](#workflow-permissions). |
| Type | Details |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| AppConfig | _Change_ or higher permissions grants access to the "Application Configuration" area.<br/>:warning: **This is a trusted, admin-level permission.** Application configuration applies instance-wide and some settings, such as OCR arguments, are passed directly to the underlying processing tools. |
| Correspondent | Add, edit, delete or view Correspondents. |
| CustomField | Add, edit, delete or view Custom Fields. |
| Document | Add, edit, delete or view Documents. |
| DocumentType | Add, edit, delete or view Document Types. |
| Group | Add, edit, delete or view Groups. |
| GlobalStatistics | View aggregate object counts and statistics. This does not grant access to view individual documents. |
| MailAccount | Add, edit, delete or view Mail Accounts. |
| MailRule | Add, edit, delete or view Mail Rules. |
| Note | Add, edit, delete or view Notes. |
| PaperlessTask | View or dismiss (_Change_) File Tasks. |
| SavedView | Add, edit, delete or view Saved Views. |
| ShareLink | Add, delete or view Share Links. |
| StoragePath | Add, edit, delete or view Storage Paths. |
| SystemMonitoring | View the system status dialog, tasks summary and their API endpoints. Admin users also retain system status access. |
| Tag | Add, edit, delete or view Tags. |
| UISettings | Add, edit, delete or view the UI settings that are used by the web app.<br/>:warning: **Users that will access the web UI must be granted at least _View_ permissions.** |
| User | Add, edit, delete or view other user accounts via Settings > Users & Groups and `/api/users/`. These permissions are not needed for users to edit their own profile via "My Profile" or `/api/profile/`. |
| Workflow | Add, edit, delete or view Workflows.<br/>Note that Workflows are global; all users who can access workflows see the same set. Workflows have other permission implications — see [Workflow permissions](#workflow-permissions). |
#### Detailed Explanation of Object Permissions {#object-permissions}
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "paperless-ngx"
version = "3.0.5"
version = "3.1.0"
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
readme = "README.md"
requires-python = ">=3.11"
+63 -56
View File
@@ -296,11 +296,11 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">89</context>
<context context-type="linenumber">91</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">91</context>
<context context-type="linenumber">93</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/dashboard.component.html</context>
@@ -323,11 +323,11 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">98</context>
<context context-type="linenumber">100</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">100</context>
<context context-type="linenumber">102</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html</context>
@@ -374,16 +374,16 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">56</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">280</context>
<context context-type="linenumber">58</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">282</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">284</context>
</context-group>
</trans-unit>
<trans-unit id="5890330709052835856" datatype="html">
<source>The dashboard can be used to show saved views, such as an &apos;Inbox&apos;. Views are found under Manage &gt; Saved Views once you have created some.</source>
@@ -727,11 +727,11 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">316</context>
<context context-type="linenumber">318</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">318</context>
<context context-type="linenumber">320</context>
</context-group>
</trans-unit>
<trans-unit id="2272120016352772836" datatype="html">
@@ -1142,11 +1142,11 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">240</context>
<context context-type="linenumber">242</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">242</context>
<context context-type="linenumber">244</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.html</context>
@@ -1723,7 +1723,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">211</context>
<context context-type="linenumber">213</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
@@ -1836,11 +1836,11 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">303</context>
<context context-type="linenumber">305</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">305</context>
<context context-type="linenumber">307</context>
</context-group>
</trans-unit>
<trans-unit id="8492095365580052820" datatype="html">
@@ -2617,11 +2617,11 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">264</context>
<context context-type="linenumber">266</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">266</context>
<context context-type="linenumber">268</context>
</context-group>
</trans-unit>
<trans-unit id="3818027200170621545" datatype="html">
@@ -2978,11 +2978,11 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">294</context>
<context context-type="linenumber">296</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">296</context>
<context context-type="linenumber">298</context>
</context-group>
</trans-unit>
<trans-unit id="4569276013106377105" datatype="html">
@@ -3291,104 +3291,104 @@
<source>by Paperless-ngx</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">23,24</context>
<context context-type="linenumber">24,25</context>
</context-group>
</trans-unit>
<trans-unit id="7228136119811576789" datatype="html">
<source>User menu</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">40</context>
<context context-type="linenumber">42</context>
</context-group>
</trans-unit>
<trans-unit id="2448391510242468907" datatype="html">
<source>Logged in as <x id="INTERPOLATION" equiv-text="{{this.settingsService.displayName}}"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">48,49</context>
<context context-type="linenumber">50,51</context>
</context-group>
</trans-unit>
<trans-unit id="2127032578120864096" datatype="html">
<source>My Profile</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">52</context>
<context context-type="linenumber">54</context>
</context-group>
</trans-unit>
<trans-unit id="3797778920049399855" datatype="html">
<source>Logout</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">59</context>
<context context-type="linenumber">61</context>
</context-group>
</trans-unit>
<trans-unit id="4895326106573044490" datatype="html">
<source>Documentation</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">64</context>
<context context-type="linenumber">66</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">324,325</context>
<context context-type="linenumber">326,327</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">327</context>
<context context-type="linenumber">329</context>
</context-group>
</trans-unit>
<trans-unit id="472206565520537964" datatype="html">
<source>Saved views</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">108,109</context>
<context context-type="linenumber">110,111</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">138,139</context>
<context context-type="linenumber">140,141</context>
</context-group>
</trans-unit>
<trans-unit id="6988090220128974198" datatype="html">
<source>Open documents</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">147,148</context>
<context context-type="linenumber">149,150</context>
</context-group>
</trans-unit>
<trans-unit id="5687256342387781369" datatype="html">
<source>Close all</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">169</context>
<context context-type="linenumber">171</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">171</context>
<context context-type="linenumber">173</context>
</context-group>
</trans-unit>
<trans-unit id="3897348120591552265" datatype="html">
<source>Manage</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">180,181</context>
<context context-type="linenumber">182,183</context>
</context-group>
</trans-unit>
<trans-unit id="8008131619909556709" datatype="html">
<source>Attributes</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">188</context>
<context context-type="linenumber">190</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">190</context>
<context context-type="linenumber">192</context>
</context-group>
</trans-unit>
<trans-unit id="7437910965833684826" datatype="html">
<source>Correspondents</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">216</context>
<context context-type="linenumber">218</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
@@ -3407,7 +3407,7 @@
<source>Document types</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">221</context>
<context context-type="linenumber">223</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.ts</context>
@@ -3418,7 +3418,7 @@
<source>Storage paths</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">226</context>
<context context-type="linenumber">228</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.ts</context>
@@ -3429,7 +3429,7 @@
<source>Custom fields</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">231</context>
<context context-type="linenumber">233</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
@@ -3448,11 +3448,11 @@
<source>Workflows</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">249</context>
<context context-type="linenumber">251</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">251</context>
<context context-type="linenumber">253</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
@@ -3463,78 +3463,78 @@
<source>Mail</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">257</context>
<context context-type="linenumber">259</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">259</context>
<context context-type="linenumber">261</context>
</context-group>
</trans-unit>
<trans-unit id="7844706011418789951" datatype="html">
<source>Administration</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">274,275</context>
<context context-type="linenumber">276,277</context>
</context-group>
</trans-unit>
<trans-unit id="3008420115644088420" datatype="html">
<source>Configuration</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">287</context>
<context context-type="linenumber">289</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">289</context>
<context context-type="linenumber">291</context>
</context-group>
</trans-unit>
<trans-unit id="1534029177398918729" datatype="html">
<source>GitHub</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">334</context>
<context context-type="linenumber">336</context>
</context-group>
</trans-unit>
<trans-unit id="4112664765954374539" datatype="html">
<source>is available.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">344</context>
<context context-type="linenumber">346</context>
</context-group>
</trans-unit>
<trans-unit id="1175891574282637937" datatype="html">
<source>Click to view.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">344</context>
<context context-type="linenumber">346</context>
</context-group>
</trans-unit>
<trans-unit id="9811291095862612" datatype="html">
<source>Paperless-ngx can automatically check for updates</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">348</context>
<context context-type="linenumber">350</context>
</context-group>
</trans-unit>
<trans-unit id="894819944961861800" datatype="html">
<source> How does this work? </source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">356</context>
<context context-type="linenumber">358</context>
</context-group>
</trans-unit>
<trans-unit id="509090351011426949" datatype="html">
<source>Update available</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">368</context>
<context context-type="linenumber">370</context>
</context-group>
</trans-unit>
<trans-unit id="1329827712962827905" datatype="html">
<source>Configure update checking</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">373</context>
<context context-type="linenumber">375</context>
</context-group>
</trans-unit>
<trans-unit id="1542489069631984294" datatype="html">
@@ -4639,7 +4639,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/select/select.component.html</context>
<context context-type="linenumber">53</context>
<context context-type="linenumber">58</context>
</context-group>
</trans-unit>
<trans-unit id="5324147361912094446" datatype="html">
@@ -6488,7 +6488,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/select/select.component.html</context>
<context context-type="linenumber">71</context>
<context context-type="linenumber">76</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/tags/tags.component.html</context>
@@ -6614,6 +6614,13 @@
</context-group>
<note priority="1" from="description">Used for both types, correspondents, storage paths</note>
</trans-unit>
<trans-unit id="6945988051184690124" datatype="html">
<source>Remove item</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/select/select.component.html</context>
<context context-type="linenumber">41</context>
</context-group>
</trans-unit>
<trans-unit id="3686284950598311784" datatype="html">
<source>Private</source>
<context-group purpose="location">
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paperless-ngx-ui",
"version": "3.0.5",
"version": "3.1.0",
"scripts": {
"preinstall": "npx only-allow pnpm",
"ng": "ng",
@@ -6,7 +6,7 @@
infoLink="configuration">
</pngx-page-header>
<form [formGroup]="configForm" (ngSubmit)="saveConfig()" class="pb-4">
<form [formGroup]="configForm" (ngSubmit)="saveConfig()" class="pb-4" autocomplete="off">
<ul ngbNav #nav="ngbNav" class="nav-tabs">
@for (category of optionCategories; track category) {
@@ -44,7 +44,7 @@
@case (ConfigOptionType.String) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
@case (ConfigOptionType.JSON) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
@case (ConfigOptionType.File) { <pngx-input-file [formControlName]="option.key" (upload)="uploadFile($event, option.key)" [error]="errors[option.key]"></pngx-input-file> }
@case (ConfigOptionType.Password) { <pngx-input-password [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-password> }
@case (ConfigOptionType.Password) { <pngx-input-password [formControlName]="option.key" [error]="errors[option.key]" autocomplete="new-password"></pngx-input-password> }
}
</div>
@if (option.note) {
@@ -4,27 +4,29 @@
(click)="closeMobileSearch(); toggleMenuCollapsed()">
<i-bs width="1.5em" height="1.5em" name="list"></i-bs>
</button>
<a class="navbar-brand d-flex align-items-center me-0 ps-md-3 py-0 order-sm-0"
<div class="navbar-brand-container order-sm-0">
<a class="navbar-brand d-flex align-items-center me-0 ps-md-3 py-0"
[ngClass]="{ 'slim': slimSidebarEnabled, '' : !slimSidebarEnabled }"
routerLink="/dashboard"
tourAnchor="tour.intro">
@if (!hasCustomBranding) {
<pngx-logo extra_classes="navbar-official-logo px-1" height="2.4rem"></pngx-logo>
<pngx-brand-mark class="brand-mark brand-mark-slim d-none"></pngx-brand-mark>
} @else {
@if (customAppLogo) {
<img class="brand-logo" [src]="customAppLogo" alt="" />
@if (!hasCustomBranding) {
<pngx-logo extra_classes="navbar-official-logo px-1" height="2.4rem"></pngx-logo>
<pngx-brand-mark class="brand-mark brand-mark-slim d-none"></pngx-brand-mark>
} @else {
<pngx-brand-mark class="brand-mark"></pngx-brand-mark>
}
<div class="brand-copy ms-2 text-truncate" [class.d-md-none]="slimSidebarEnabled">
<span class="brand-title text-truncate">{{ appTitle }}</span>
@if (customAppTitle) {
<span class="byline text-uppercase font-monospace" i18n>by Paperless-ngx</span>
@if (customAppLogo) {
<img class="brand-logo" [src]="customAppLogo" alt="" />
} @else {
<pngx-brand-mark class="brand-mark"></pngx-brand-mark>
}
</div>
}
</a>
<div class="brand-copy ms-2 text-truncate" [class.d-md-none]="slimSidebarEnabled">
<span class="brand-title text-truncate">{{ appTitle }}</span>
@if (customAppTitle) {
<span class="byline text-uppercase font-monospace" i18n>by Paperless-ngx</span>
}
</div>
}
</a>
</div>
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 me-sm-auto order-3 order-sm-1"
[class.mobile-hidden]="mobileSearchHidden()">
<div class="col-12 header-search mx-auto">
@@ -37,7 +39,7 @@
}
<pngx-toasts-dropdown></pngx-toasts-dropdown>
<li ngbDropdown class="nav-item dropdown">
<button class="btn navbar-action border-0" id="userDropdown" ngbDropdownToggle aria-label="User menu" i18n-aria-label>
<button class="btn navbar-action border-0 me-1 me-md-2" id="userDropdown" ngbDropdownToggle aria-label="User menu" i18n-aria-label>
<i-bs width="1.3em" height="1.3em" name="person-circle"></i-bs>
<span class="small ms-2 d-none d-sm-inline">
{{this.settingsService.displayName}}
@@ -362,6 +362,7 @@ main {
}
::ng-deep .navbar-official-logo {
will-change: filter; // Safari repaints the whole navbar on filter change without this
filter: drop-shadow(0 1px 2px rgba(var(--pngx-navbar-brand-shadow-rgb), .3));
transition: filter .15s ease-in-out;
@@ -436,6 +437,19 @@ main {
padding-right: .5rem;
}
// true center the search with equal flex widths
@media (min-width: 768px) {
.navbar-brand-container,
.navbar > ul {
display: flex; // so the brand link stays as wide as its contents
flex: 1 1 0;
}
.navbar > ul {
justify-content: flex-end;
}
}
:host ::ng-deep .navbar-action {
display: inline-flex;
align-items: center;
@@ -476,6 +490,7 @@ main {
text-align: left;
}
.navbar-brand-container,
.navbar-brand {
grid-area: brand;
min-width: 0;
@@ -40,6 +40,10 @@ describe('PasswordComponent', () => {
// expect(component.value).toEqual('foo')
})
it('should not offer itself to browser autofill by default', () => {
expect(input.getAttribute('autocomplete')).toEqual('off')
})
it('should support toggling field visibility', () => {
expect(input.type).toEqual('password')
component.showReveal = true
@@ -25,7 +25,7 @@ export class PasswordComponent extends AbstractInputComponent<string> {
showReveal: boolean = false
@Input()
autocomplete: string
autocomplete: string = 'off'
public textVisible: boolean = false
@@ -36,11 +36,16 @@
(focus)="clearLastSearchTerm()"
(clear)="clearLastSearchTerm()"
(blur)="onBlur()">
<ng-template ng-label-tmp let-item="item">
@if (iconField && item[iconField]) {
<i-bs class="me-2" [name]="item[iconField]"></i-bs>
<ng-template ng-label-tmp let-item="item" let-clear="clear">
@if (multiple && !disabled) {
<span class="ng-value-icon left" role="button" tabindex="0" (click)="clear(item)" (keydown.enter)="clear(item)" aria-label="Remove item" i18n-aria-label>×</span>
}
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
<span class="ng-value-label" [title]="item[bindLabel]">
@if (iconField && item[iconField]) {
<i-bs class="me-2" [name]="item[iconField]"></i-bs>
}
{{item[bindLabel]}}
</span>
</ng-template>
<ng-template ng-option-tmp let-item="item">
@if (iconField && item[iconField]) {
@@ -24,6 +24,12 @@ describe('TextComponent', () => {
input = component.inputField.nativeElement
})
it('should not offer itself to browser autofill by default', () => {
expect(
component.inputField.nativeElement.getAttribute('autocomplete')
).toEqual('off')
})
it('should support use of input field', () => {
expect(component.value).toBeUndefined()
input.value = 'foo'
@@ -28,7 +28,7 @@ import { AbstractInputComponent } from '../abstract-input'
})
export class TextComponent extends AbstractInputComponent<string> {
@Input()
autocomplete: string
autocomplete: string = 'off'
@Input()
placeholder: string = ''
+29
View File
@@ -15,6 +15,35 @@ describe('text search utilities', () => {
expect(matchesSearchText('taxes 2026', 'tax receipt')).toBeFalsy()
})
it('does not let two terms match the same word', () => {
expect(matchesSearchText('Another Tag', 'another tag th')).toBeFalsy()
expect(matchesSearchText('Another Tag', 'another tag ag')).toBeFalsy()
expect(matchesSearchText('Another Tag', 'another tag e')).toBeFalsy()
expect(matchesSearchText('Another Tag', 'another tag')).toBeTruthy()
expect(matchesSearchText('Another Tag', 'tag another')).toBeTruthy()
})
it('matches a single term anywhere in the value', () => {
expect(matchesSearchText('Another Tag', 'anoth')).toBeTruthy()
expect(matchesSearchText('Another Tag', 'th')).toBeTruthy()
})
it('treats punctuation as a separator on both sides', () => {
expect(matchesSearchText('medical-history', 'medical history')).toBeTruthy()
expect(matchesSearchText('medical history', 'medical-history')).toBeTruthy()
expect(matchesSearchText('medical-history', 'medical dental')).toBeFalsy()
})
it('matches longer terms first so they cannot be starved', () => {
expect(matchesSearchText('tagger tag', 'tag tagger')).toBeTruthy()
})
it('handles a query with no usable terms', () => {
expect(matchesSearchText('Another Tag', '')).toBeTruthy()
// Still filters, so the dropdown can offer to create a tag named "---"
expect(matchesSearchText('Another Tag', '---')).toBeFalsy()
})
it('matches a large set of tag names without blocking input', () => {
const tagNames = Array.from(
{ length: 1280 },
+34 -9
View File
@@ -3,13 +3,18 @@ import { diacritics } from 'normalize-diacritics/diacritics'
export type SearchTextValue =
string | number | boolean | bigint | null | undefined
const NON_ASCII = /[^\x00-\x7F]/
const SEPARATORS = /[^\p{L}\p{N}]+/u
export function normalizeSearchText(value: SearchTextValue): string {
const normalized = diacritics.reduce(
(text, replacement) => {
return text.replace(replacement.diacritics, replacement.letter)
},
String(value ?? '')
)
const text = String(value ?? '')
// Nothing in the table matches ASCII, so skip normaliation
if (!NON_ASCII.test(text)) return text.toLocaleLowerCase()
const normalized = diacritics.reduce((text, replacement) => {
return text.replace(replacement.diacritics, replacement.letter)
}, text)
return normalized.toLocaleLowerCase()
}
@@ -18,8 +23,28 @@ export function matchesSearchText(
value: SearchTextValue,
searchText: SearchTextValue
): boolean {
const normalizedValue = normalizeSearchText(value)
const searchTerms = normalizeSearchText(searchText).trim().split(/\s+/)
const query = normalizeSearchText(searchText)
const terms = query.split(SEPARATORS).filter(Boolean)
return searchTerms.every((term) => normalizedValue.includes(term))
// Empty or punctuation-only query, nothing to split into terms
if (terms.length === 0) {
return normalizeSearchText(value).includes(query.trim())
}
const words = normalizeSearchText(value).split(SEPARATORS).filter(Boolean)
const claimed = new Array<boolean>(words.length).fill(false)
// Each term takes a word of its own, longest first, so that "another tag th"
// doesn't match "Another Tag" by finding the "th" inside "another"
return terms
.sort((a, b) => b.length - a.length)
.every((term) => {
for (let i = 0; i < words.length; i++) {
if (!claimed[i] && words[i].includes(term)) {
claimed[i] = true
return true
}
}
return false
})
}
+1 -1
View File
@@ -8,7 +8,7 @@ export const environment = {
apiVersion: '10', // match src/paperless/settings.py
appTitle: DEFAULT_APP_TITLE,
tag: 'prod',
version: '3.0.5',
version: '3.1.0',
webSocketHost: window.location.host,
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
webSocketBaseUrl: base_url.pathname + 'ws/',
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -129,7 +129,7 @@ class DocumentMetadataOverrides:
)
overrides.custom_fields = {
custom_field.field.id: custom_field.value
for custom_field in doc.custom_fields.all()
for custom_field in doc.custom_fields.select_related("field").all()
}
groups_with_perms = get_groups_with_perms(
+101 -1
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import logging
import math
import re
from collections.abc import Iterable
from datetime import datetime
from datetime import timedelta
from decimal import Decimal
@@ -876,8 +877,106 @@ def validate_documentlink_targets(user, doc_ids):
)
# drf-writable-nested revalidates a document's custom_fields more than once
# per request: once as the ordinary nested list, then again per-item while
# matching existing vs. new CustomFieldInstance rows during save() -- and
# that second pass builds a brand new serializer (and field) instance per
# item (see its update_or_create_reverse_relations / _get_serializer_for_field),
# so a cache on the field instance alone only helps the first pass. It does,
# however, explicitly pass `context=self.context` to every one of those
# fresh serializers -- the *same* dict object the outer DocumentSerializer
# is using, not a copy. That context dict is already request-scoped (DRF
# builds it fresh per request via get_serializer_context()), so stashing the
# resolved CustomField objects there -- rather than in some new global/
# thread-local cache -- lets every later pass reuse them for free while
# staying entirely within DRF's existing, already-request-scoped machinery.
_CUSTOM_FIELD_CONTEXT_CACHE_KEY = "_custom_field_lookup_cache"
class _CachingCustomFieldPrimaryKeyField(serializers.PrimaryKeyRelatedField):
"""
Resolves CustomField ids with as few queries as possible: a per-instance
cache for repeat lookups on this exact field instance, backed by a
shared cache on the serializer context (see _CUSTOM_FIELD_CONTEXT_CACHE_KEY
above) so later, separately-instantiated fields for the same request
reuse what was already resolved instead of re-querying.
"""
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self._cache: dict[int, CustomField] = {}
def _shared_cache(self) -> dict[int, CustomField]:
return self.context.setdefault(_CUSTOM_FIELD_CONTEXT_CACHE_KEY, {})
@staticmethod
def _normalize_pk(data: Any) -> int | None:
"""
Returns `data` coerced to the int a valid CustomField pk would be,
or None if `data` isn't a plausible pk (wrong type, unhashable,
non-numeric, or a bool -- DRF itself rejects bools as pks since
`True == 1` would otherwise silently match). None tells callers to
leave `data` alone and let `super().to_internal_value()` report the
normal validation error instead of touching the cache/queryset with
it directly.
"""
if isinstance(data, bool):
return None
try:
return int(data)
except (TypeError, ValueError):
return None
def prefetch(self, ids: Iterable[Any]) -> None:
shared_cache = self._shared_cache()
candidates = {pk for i in ids if (pk := self._normalize_pk(i)) is not None}
missing = {
i for i in candidates if i not in self._cache and i not in shared_cache
}
if missing:
for obj in self.get_queryset().filter(pk__in=missing):
shared_cache[obj.pk] = obj
for i in candidates:
obj = shared_cache.get(i)
if obj is not None:
self._cache[i] = obj
def to_internal_value(self, data: Any) -> CustomField:
pk = self._normalize_pk(data)
if pk is None:
return super().to_internal_value(data)
if pk in self._cache:
return self._cache[pk]
shared_cache = self._shared_cache()
if pk in shared_cache:
obj = shared_cache[pk]
self._cache[pk] = obj
return obj
obj: CustomField = super().to_internal_value(data)
self._cache[obj.pk] = obj
shared_cache[obj.pk] = obj
return obj
class CustomFieldInstanceListSerializer(serializers.ListSerializer):
def to_internal_value(self, data: Any) -> list[Any]:
if isinstance(data, list):
field_ids = []
for item in data:
if not isinstance(item, dict) or "field" not in item:
continue
try:
hash(item["field"])
except TypeError:
continue
field_ids.append(item["field"])
if field_ids:
self.child.fields["field"].prefetch(field_ids)
return super().to_internal_value(data)
class CustomFieldInstanceSerializer(serializers.ModelSerializer[CustomFieldInstance]):
field = serializers.PrimaryKeyRelatedField(queryset=CustomField.objects.all())
field = _CachingCustomFieldPrimaryKeyField(queryset=CustomField.objects.all())
value = ReadWriteSerializerMethodField(allow_null=True)
def create(self, validated_data):
@@ -978,6 +1077,7 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer[CustomFieldInsta
class Meta:
model = CustomFieldInstance
list_serializer_class = CustomFieldInstanceListSerializer
fields = [
"value",
"field",
@@ -5,7 +5,9 @@ from unittest.mock import ANY
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User
from django.db import connection
from django.test import override_settings
from django.test.utils import CaptureQueriesContext
from guardian.shortcuts import assign_perm
from rest_framework import status
from rest_framework.test import APITestCase
@@ -13,6 +15,9 @@ from rest_framework.test import APITestCase
from documents.models import CustomField
from documents.models import CustomFieldInstance
from documents.models import Document
from documents.serialisers import CustomFieldInstanceSerializer
from documents.serialisers import DocumentSerializer
from documents.tests.factories import DocumentFactory
from documents.tests.utils import DirectoriesMixin
@@ -530,6 +535,136 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
doc.refresh_from_db()
self.assertEqual(len(doc.custom_fields.all()), 10)
def test_document_serializer_custom_fields_validation_batches_field_lookup(
self,
) -> None:
"""
GIVEN:
- A document is being validated with several custom field values
at once (as happens on every PATCH/PUT/POST)
WHEN:
- The serializer is validated
THEN:
- The referenced CustomField objects are resolved with a single
query, not one query per custom field
"""
doc = DocumentFactory(mime_type="application/pdf")
custom_fields = [
CustomField.objects.create(
name=f"Test Custom Field {i}",
data_type=CustomField.FieldDataType.STRING,
)
for i in range(5)
]
serializer = DocumentSerializer(
doc,
data={
"custom_fields": [
{"field": custom_field.id, "value": "test value"}
for custom_field in custom_fields
],
},
partial=True,
)
with CaptureQueriesContext(connection) as ctx:
self.assertTrue(serializer.is_valid(), serializer.errors)
custom_field_lookups = [
query
for query in ctx.captured_queries
if 'FROM "documents_customfield" WHERE "documents_customfield"."id"'
in query["sql"]
]
self.assertEqual(
len(custom_field_lookups),
1,
"Expected a single batched query to resolve the custom fields, "
f"got {len(custom_field_lookups)}: {custom_field_lookups}",
)
def test_custom_field_lookup_reuses_shared_context_cache(self) -> None:
"""
GIVEN:
- A CustomField has already been resolved once, by a serializer
sharing a given `context` dict
WHEN:
- A second, separately-instantiated CustomFieldInstanceSerializer
validates the same field id, sharing that same context
(this is what drf-writable-nested does: it rebuilds a fresh
serializer -- and fresh field instances -- per item while
matching existing vs. new instances during save())
THEN:
- No additional query is issued to resolve the CustomField
"""
custom_field = CustomField.objects.create(
name="Test Custom Field",
data_type=CustomField.FieldDataType.STRING,
)
context: dict = {}
first_pass = CustomFieldInstanceSerializer(
data={"field": custom_field.id, "value": "a"},
context=context,
)
self.assertTrue(first_pass.is_valid(), first_pass.errors)
second_pass = CustomFieldInstanceSerializer(
data={"field": custom_field.id, "value": "b"},
context=context,
)
with CaptureQueriesContext(connection) as ctx:
self.assertTrue(second_pass.is_valid(), second_pass.errors)
custom_field_lookups = [
query
for query in ctx.captured_queries
if 'FROM "documents_customfield" WHERE "documents_customfield"."id"'
in query["sql"]
]
self.assertEqual(
len(custom_field_lookups),
0,
"Expected the second, separately-instantiated serializer to reuse "
f"the already-resolved CustomField, got: {custom_field_lookups}",
)
def test_custom_field_validation_rejects_malformed_field_value(self) -> None:
"""
GIVEN:
- A document is being validated with a malformed custom_fields
entry whose "field" value is neither a valid CustomField id
nor a type DRF's own PrimaryKeyRelatedField can safely reject
on its own (unhashable, or a non-numeric scalar)
WHEN:
- The serializer is validated
THEN:
- A normal validation error is raised, not an unhandled
TypeError/ValueError escaping past DRF's validation layer
"""
doc = DocumentFactory(mime_type="application/pdf")
bad_field_values = {
"unhashable-list": [],
"unhashable-dict": {},
"non-numeric-scalar": "abc",
}
for case_id, bad_field_value in bad_field_values.items():
with self.subTest(case_id):
serializer = DocumentSerializer(
doc,
data={
"custom_fields": [
{"field": bad_field_value, "value": "test value"},
],
},
partial=True,
)
self.assertFalse(serializer.is_valid())
self.assertIn("custom_fields", serializer.errors)
def test_change_custom_field_instance_value(self) -> None:
"""
GIVEN:
+58
View File
@@ -0,0 +1,58 @@
from django.db import connection
from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from documents.data_models import DocumentMetadataOverrides
from documents.models import CustomField
from documents.models import CustomFieldInstance
from documents.tests.factories import DocumentFactory
from documents.tests.utils import DirectoriesMixin
class TestDocumentMetadataOverridesFromDocument(DirectoriesMixin, TestCase):
def test_from_document_batches_custom_field_lookup_after_refresh_from_db(
self,
) -> None:
"""
GIVEN:
- A document has several custom field values
- The document instance has just been refreshed from the database,
which drops any prefetched related objects (as
send_websocket_document_updated does before building overrides)
WHEN:
- DocumentMetadataOverrides.from_document() reads the document's
custom field values
THEN:
- The referenced CustomField objects are resolved with a single
query, not one query per custom field
"""
doc = DocumentFactory(mime_type="application/pdf")
for i in range(5):
CustomFieldInstance.objects.create(
document=doc,
field=CustomField.objects.create(
name=f"Test Custom Field {i}",
data_type=CustomField.FieldDataType.STRING,
),
value_text="value",
)
doc.refresh_from_db()
with CaptureQueriesContext(connection) as ctx:
overrides = DocumentMetadataOverrides.from_document(doc)
self.assertEqual(len(overrides.custom_fields), 5)
unbatched_field_lookups = [
query
for query in ctx.captured_queries
if 'FROM "documents_customfield" WHERE "documents_customfield"."id"'
in query["sql"]
]
self.assertEqual(
unbatched_field_lookups,
[],
"Expected CustomField data to come from the CustomFieldInstance "
"join, not a separate per-instance lookup, "
f"got: {unbatched_field_lookups}",
)
+35
View File
@@ -5573,6 +5573,41 @@ class TestApplyAISuggestionsWorkflowAction(
self.doc.refresh_from_db()
return changed
def test_fields_persist_when_tags_are_applied_in_the_same_run(self) -> None:
"""
GIVEN:
- A document that already has a filename, as any consumed document does
- Suggestions carrying both a document type and tags
WHEN:
- The suggestions are applied
THEN:
- The document type is still set after the tags are added
Adding tags fires m2m_changed, and update_filename_and_move_files
refreshes the document from the database. Assigning fields and then
adding tags before saving loses those assignments, and only for
documents with a filename, so it does not reproduce on a bare
Document.objects.create().
"""
self.doc.filename = "originals/original.pdf"
self.doc.save(update_fields=["filename"])
action = self.make_action(ai_create_missing=True)
changed = self.apply(action)
self.assertIn("document_type", changed)
self.assertIn("tags", changed)
self.assertIsNotNone(
self.doc.document_type,
"document_type was reported as applied but did not persist",
)
self.assertEqual(self.doc.document_type.name, "Suggested Document Type")
self.assertEqual(self.doc.correspondent.name, "Existing Correspondent")
self.assertCountEqual(
[t.name for t in self.doc.tags.all()],
["Existing Tag", "Suggested Tag"],
)
def test_document_added_trigger_queues_task(self) -> None:
"""
GIVEN:
+7 -3
View File
@@ -226,20 +226,20 @@ def apply_ai_suggestions_to_document(
document.created = created
updated_fields.append("created")
tags_to_add: list[Tag] = []
if AISuggestionField.TAGS in selected:
choice = suggestions["tags"]
names = choice["new_names"]
tags = resolve_tags(
tags_to_add = resolve_tags(
names,
resolve_tag_ids(choice["existing_ids"], owner)
+ match_tags_by_name(names, owner),
create_missing=create_missing,
owner=owner,
)
if tags:
if tags_to_add:
# Suggested tags are always added, so overwrite_existing
# does not really apply here
document.add_nested_tags(tags)
updated_fields.append("tags")
if updated_fields:
@@ -249,6 +249,10 @@ def apply_ai_suggestions_to_document(
]
document.save(update_fields=[*direct_updated_fields, "modified"])
# Tags at the end so m2m_changed doesn't trigger db and overwrite other changes
if tags_to_add:
document.add_nested_tags(tags_to_add)
logger.info(
"Applied AI suggestions %s to document %s",
updated_fields or "(none)",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More