Compare commits

..
Author SHA1 Message Date
Crowdin Bot 9b6d8107ac New Crowdin translations by GitHub Action 2026-08-27 04:30:08 +00: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
GitHub Actions cafed919a1 Auto translate strings 2026-08-26 16:50:29 +00:00
shamoonandGitHub 549afde1fd Enhancement: Apply AI suggestions workflow action (#13639) 2026-08-26 16:41:21 +00:00
GitHub Actions a8576d827e Auto translate strings 2026-08-26 14:29:05 +00:00
shamoonandGitHub 7330c4d9cb Fix: exclude version documents from bulk edit "all" (#13791) 2026-08-26 14:27:31 +00:00
shamoon aad75b4981 Docs: clarify app config trust 2026-08-26 01:39:50 -07:00
GitHub Actions 2e7cd14964 Auto translate strings 2026-08-26 04:48:45 +00:00
shamoonandGitHub 5090ffe39e Enhancement: welcome widget visual tweaks (#13794) 2026-08-25 21:47:13 -07:00
GitHub Actions 35d40b51cb Auto translate strings 2026-08-25 21:37:07 +00:00
shamoonandGitHub 78025df405 Performance: fetch note authors with prefetch instead of one query each (#13790) 2026-08-25 14:35:22 -07:00
shamoon e24db7023a Tweak: adjust brand leaf opacity 2026-08-25 10:44:49 -07:00
shamoon f5ddc14588 Fix: also check global change_mailaccount with test 2026-08-25 10:44:49 -07:00
GitHub Actions 492424f7f2 Auto translate strings 2026-08-25 00:07:03 +00:00
shamoonandGitHub cd525819d7 Tweak: more misc UI tweaks (#13783) 2026-08-24 17:05:29 -07:00
GitHub Actions 2609327e9c Auto translate strings 2026-08-24 21:44:25 +00:00
shamoonandGitHub b90ccf910f Finally, the remote ocr workflow (#13637)
* Ok! Backend stuff for the remote ocr workflow

* Frotnend workflow stuff

* And docs

* Fix dynamic action fields thing

* Actually, fix the action dropdown thing

* Fix this validation thing, and we have to check existing actions

* Fix migration
2026-08-24 14:43:05 -07:00
shamoonandGitHub c93c996edf Remote ocr reprocess (#13636)
* Backend stuff for remote ocr reprocess, add to bulk edit pass in from ui settings

* Ok, frontend reprocess remote option

* Docs
2026-08-24 14:43:05 -07:00
shamoonandGitHub 7f1609332a Allow parsers to declare uses remote, and remote ocr_mode (#13634)
* uses_remote_service + allow_remote to allow opt-in / out of remote OCR

* Add to parser dev docs

* remote_ocr_mode config setting

* Checks for remote_ocr_mode and fix import

* Update config.component.spec.ts

* More tests for remote_ocr_mode

* Docs for remote_ocr_mode

* Ok, wire up the remote_ocr_mode with allow_remote for consumer

* Update consumer.py

* Format remote OCR mode check tests

* Use get_choice_from_env
2026-08-24 14:43:04 -07:00
GitHub Actions a1f20c9fe7 Auto translate strings 2026-08-24 21:19:19 +00:00
shamoonandGitHub 4fd1c60731 Enhancement: support using remote OCR engines selectively (#13633)
* Backend changes and migration for remote OCR Config

* Backend tests

* Frontend stuff, with sections

* Docs

* Update test_tesseract_parser.py

* Actually we cant use this any more, in case settings are in app config

* Dont mark entire test file for db, use a mock for empty engine settings
2026-08-24 14:17:52 -07:00
GitHub Actions ba83e5b39a Auto translate strings 2026-08-24 18:28:51 +00:00
shamoonandGitHub 1497bd33a1 Fix: fix bottom mobile nav buttons on Android (#13780) 2026-08-24 11:27:26 -07:00
190 changed files with 165862 additions and 93193 deletions
+2 -1
View File
@@ -302,7 +302,8 @@ The following methods are supported:
- `delete` - `delete`
- No `parameters` required - No `parameters` required
- `reprocess` - `reprocess`
- No `parameters` required - Optional `parameters`: `{ "remote_ocr": true }` to send the documents to the
remote OCR engine, see [Remote OCR](usage.md#remote-ocr). Defaults to false.
- `set_permissions` - `set_permissions`
- Requires `parameters`: - Requires `parameters`:
- `"set_permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or - `"set_permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or
+215
View File
@@ -1,5 +1,220 @@
# Changelog # 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 ## paperless-ngx 3.0.5
### Bug Fixes ### Bug Fixes
+22
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 preference over the settings via environment variables. If not set, the environment setting
or applicable default will be utilized instead. 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. - If you run paperless on docker, `paperless.conf` is not used.
Rather, configure paperless by copying necessary options to Rather, configure paperless by copying necessary options to
`docker-compose.env`. `docker-compose.env`.
@@ -1125,6 +1131,10 @@ they use underscores instead of dashes.
so specifying invalid options may prevent paperless from consuming so specifying invalid options may prevent paperless from consuming
any documents. Use with caution! 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 Specify arguments as a JSON dictionary. Keep note of lower case
booleans and double quoted parameter names and strings. Examples: booleans and double quoted parameter names and strings. Examples:
@@ -2066,6 +2076,18 @@ password. All of these options come from their similarly-named [Django settings]
Defaults to None. Defaults to None.
#### [`PAPERLESS_REMOTE_OCR_MODE=<str>`](#PAPERLESS_REMOTE_OCR_MODE) {#PAPERLESS_REMOTE_OCR_MODE}
: Which documents are sent to the remote OCR engine.
- `always`: every document of a supported file type is sent to the remote
engine, bypassing the local OCR engine.
- `workflow_only`: documents are processed locally unless a workflow
explicitly enables remote OCR for them, letting you use the remote engine
selectively.
Defaults to "always".
## AI {#ai} ## AI {#ai}
#### [`PAPERLESS_AI_ENABLED=<bool>`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED} #### [`PAPERLESS_AI_ENABLED=<bool>`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED}
+14
View File
@@ -456,6 +456,20 @@ def score(
return 10 return 10
``` ```
**Remote services**
If your parser sends document content to a remote service, declare it:
```python
class MyCustomParser:
uses_remote_service = True
```
Paperless-ngx excludes such parsers when the document being consumed has not
been marked for remote processing, so users can keep remote OCR off by default
and enable it selectively with a workflow. Parsers that do not declare the
attribute are treated as fully local and are always considered.
**Archive and rendition flags** **Archive and rendition flags**
```python ```python
+72 -22
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 determine if a user can create, edit, delete or view _any_ documents, but individual documents themselves
still have "object-level" permissions. still have "object-level" permissions.
| Type | Details | | Type | Details |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| AppConfig | _Change_ or higher permissions grants access to the "Application Configuration" area. | | 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. | | Correspondent | Add, edit, delete or view Correspondents. |
| CustomField | Add, edit, delete or view Custom Fields. | | CustomField | Add, edit, delete or view Custom Fields. |
| Document | Add, edit, delete or view Documents. | | Document | Add, edit, delete or view Documents. |
| DocumentType | Add, edit, delete or view Document Types. | | DocumentType | Add, edit, delete or view Document Types. |
| Group | Add, edit, delete or view Groups. | | Group | Add, edit, delete or view Groups. |
| GlobalStatistics | View aggregate object counts and statistics. This does not grant access to view individual documents. | | GlobalStatistics | View aggregate object counts and statistics. This does not grant access to view individual documents. |
| MailAccount | Add, edit, delete or view Mail Accounts. | | MailAccount | Add, edit, delete or view Mail Accounts. |
| MailRule | Add, edit, delete or view Mail Rules. | | MailRule | Add, edit, delete or view Mail Rules. |
| Note | Add, edit, delete or view Notes. | | Note | Add, edit, delete or view Notes. |
| PaperlessTask | View or dismiss (_Change_) File Tasks. | | PaperlessTask | View or dismiss (_Change_) File Tasks. |
| SavedView | Add, edit, delete or view Saved Views. | | SavedView | Add, edit, delete or view Saved Views. |
| ShareLink | Add, delete or view Share Links. | | ShareLink | Add, delete or view Share Links. |
| StoragePath | Add, edit, delete or view Storage Paths. | | 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. | | 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. | | 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.** | | 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/`. | | 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). | | 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} #### Detailed Explanation of Object Permissions {#object-permissions}
@@ -654,6 +654,48 @@ happened while it was still encrypted, that original version will likewise be mi
**Current limitation**: Passwords are stored as a simple list without descriptions. To handle **Current limitation**: Passwords are stored as a simple list without descriptions. To handle
multiple PDF types with different passwords, create separate workflows for each use case. multiple PDF types with different passwords, create separate workflows for each use case.
##### Remote OCR {#workflow-action-remote-ocr}
"Remote OCR" actions send the document to the configured remote OCR engine instead of processing it
locally. To use remote OCR selectively, set the [remote OCR mode](configuration.md#PAPERLESS_REMOTE_OCR_MODE)
to `workflow_only` then add this action to a workflow that matches only the documents you
want sent to the remote engine. See [Remote OCR](#remote-ocr) for the engine setup. The action only works with
a **Consumption Started** trigger.
The action takes no options, its presence is what enables remote OCR for a matching document.
If the remote engine is not configured, or does not support the document's file type, the document is
processed locally instead and a warning is written to the log.
##### Apply AI Suggestions {#workflow-action-apply-ai-suggestions}
"Apply AI Suggestions" actions ask the configured AI service for title and metadata suggestions,
the same as the AI suggestions shown on the document detail page, except applied automatically and in bulk.
It requires [AI features](configuration.md#ai) to be enabled. You can specify:
- Which suggestions to apply: title, tags, correspondent, document type, storage path and / or created
date. Suggestions for fields you did not select are discarded.
- Whether to create missing items. By default only tags, correspondents and document types that
already exist are assigned and any other suggestion is dropped. With this enabled, suggested items
that do not exist are created. Storage paths are never created.
- Whether to overwrite existing values. By default a field is only filled in if it is currently empty.
Note that documents almost always already have a title and created date, so if you select those you
will usually want to enable this too. Tags are an exception: suggested tags are always added and
never replace the document's existing tags.
The action works with every trigger **except Consumption Started**, because suggestions are made from
the document's text, which does not exist until after the document has been processed.
Because the query to the AI service is slow, the action is queued and runs in the background rather
than as part of the workflow run itself. The document is updated once the suggestions come back.
!!! warning
Every matching document results in a query to the AI service, which may incur costs and have privacy
implications. Queries can be slow, so a workflow matching a large number of documents can occupy the
task queue, and delay consumption of new documents, etc. Consider narrowing the trigger filters,
running in small batches and / or increasing workers.
#### Workflow placeholders #### Workflow placeholders
Titles and webhook payloads can be generated by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/). Titles and webhook payloads can be generated by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
@@ -1090,11 +1132,19 @@ Paperless-ngx supports performing OCR on documents using remote services. At the
[Microsoft's Azure "Document Intelligence" service](https://azure.microsoft.com/en-us/products/ai-services/ai-document-intelligence). [Microsoft's Azure "Document Intelligence" service](https://azure.microsoft.com/en-us/products/ai-services/ai-document-intelligence).
This is of course a paid service (with a free tier) which requires an Azure account and subscription. Azure AI is not affiliated with This is of course a paid service (with a free tier) which requires an Azure account and subscription. Azure AI is not affiliated with
Paperless-ngx in any way. When enabled, Paperless-ngx will automatically send appropriate documents to Azure for OCR processing, bypassing Paperless-ngx in any way. When enabled, Paperless-ngx will automatically send appropriate documents to Azure for OCR processing, bypassing
the local OCR engine. See the [configuration](configuration.md#PAPERLESS_REMOTE_OCR_ENGINE) options for more details. the local OCR engine. See the [configuration](configuration.md#PAPERLESS_REMOTE_OCR_ENGINE) options for more details. These
settings can be supplied as environment variables or via **Application Configuration**.
Additionally, when using a commercial service with this feature, consider both potential costs as well as any associated file size Additionally, when using a commercial service with this feature, consider both potential costs as well as any associated file size
or page limitations (e.g. with a free tier). or page limitations (e.g. with a free tier).
By default, every document of a supported file type is sent to the remote engine. To use it more selectively, set the
[remote OCR mode](configuration.md#PAPERLESS_REMOTE_OCR_MODE) to `workflow_only`. Documents are then processed locally
unless a [remote OCR workflow action](#workflow-action-remote-ocr) enables it for them, so you can limit the remote
engine to particular documents.
Setting the mode to `workflow_only` also allows the **Reprocess** actions to selectively use remote OCR for individual documents.
## Architecture ## Architecture
Paperless-ngx consists of the following components: Paperless-ngx consists of the following components:
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "paperless-ngx" 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" description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
+495 -324
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "paperless-ngx-ui", "name": "paperless-ngx-ui",
"version": "3.0.5", "version": "3.1.0",
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm", "preinstall": "npx only-allow pnpm",
"ng": "ng", "ng": "ng",
@@ -6,7 +6,7 @@
infoLink="configuration"> infoLink="configuration">
</pngx-page-header> </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"> <ul ngbNav #nav="ngbNav" class="nav-tabs">
@for (category of optionCategories; track category) { @for (category of optionCategories; track category) {
@@ -14,43 +14,48 @@
<a ngbNavLink>{{category}}</a> <a ngbNavLink>{{category}}</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<div class="p-3"> <div class="p-3">
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2"> @for (section of getCategorySections(category); track section) {
@for (option of getCategoryOptions(category); track option.key) { @if (section) {
<div class="col"> <h5 class="mt-4 mb-3">{{section}}</h5>
<div class="card bg-light"> }
<div class="card-body"> <div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
<div class="card-title d-flex align-items-center"> @for (option of getCategoryOptions(category, section); track option.key) {
<h6 class="mb-0"> <div class="col">
{{option.title}} <div class="card bg-light">
</h6> <div class="card-body">
<a class="btn btn-sm btn-link" title="Read the documentation about this setting" i18n-title [href]="getDocsUrl(option.config_key)" target="_blank" referrerpolicy="no-referrer"> <div class="card-title d-flex align-items-center">
<i-bs name="info-circle"></i-bs> <h6 class="mb-0">
</a> {{option.title}}
@if (isSet(option.key)) { </h6>
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)"> <a class="btn btn-sm btn-link" title="Read the documentation about this setting" i18n-title [href]="getDocsUrl(option.config_key)" target="_blank" referrerpolicy="no-referrer">
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container> <i-bs name="info-circle"></i-bs>
</button> </a>
@if (isSet(option.key)) {
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)">
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
</button>
}
</div>
<div class="mb-n3">
@switch (option.type) {
@case (ConfigOptionType.Select) { <pngx-input-select [formControlName]="option.key" [error]="errors[option.key]" [items]="option.choices" [allowNull]="true"></pngx-input-select> }
@case (ConfigOptionType.Number) { <pngx-input-number [formControlName]="option.key" [error]="errors[option.key]" [showAdd]="false"></pngx-input-number> }
@case (ConfigOptionType.Boolean) { <pngx-input-switch [formControlName]="option.key" [error]="errors[option.key]" [showUnsetNote]="true" [horizontal]="true" title="Enable" i18n-title></pngx-input-switch> }
@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]" autocomplete="new-password"></pngx-input-password> }
}
</div>
@if (option.note) {
<div class="form-text fst-italic">{{option.note}}</div>
} }
</div> </div>
<div class="mb-n3">
@switch (option.type) {
@case (ConfigOptionType.Select) { <pngx-input-select [formControlName]="option.key" [error]="errors[option.key]" [items]="option.choices" [allowNull]="true"></pngx-input-select> }
@case (ConfigOptionType.Number) { <pngx-input-number [formControlName]="option.key" [error]="errors[option.key]" [showAdd]="false"></pngx-input-number> }
@case (ConfigOptionType.Boolean) { <pngx-input-switch [formControlName]="option.key" [error]="errors[option.key]" [showUnsetNote]="true" [horizontal]="true" title="Enable" i18n-title></pngx-input-switch> }
@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> }
}
</div>
@if (option.note) {
<div class="form-text fst-italic">{{option.note}}</div>
}
</div> </div>
</div> </div>
</div> }
} </div>
</div> }
</div> </div>
</ng-template> </ng-template>
</li> </li>
@@ -8,7 +8,11 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select' import { NgSelectModule } from '@ng-select/ng-select'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of, throwError } from 'rxjs' import { of, throwError } from 'rxjs'
import { OutputTypeConfig } from 'src/app/data/paperless-config' import {
ConfigCategory,
ConfigSection,
OutputTypeConfig,
} from 'src/app/data/paperless-config'
import { ConfigService } from 'src/app/services/config.service' import { ConfigService } from 'src/app/services/config.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
@@ -158,4 +162,24 @@ describe('ConfigComponent', () => {
component.resetOption('barcodes_enabled') component.resetOption('barcodes_enabled')
expect(component.configForm.get('barcodes_enabled').value).toBeNull() expect(component.configForm.get('barcodes_enabled').value).toBeNull()
}) })
it('should group options into sections within a category, or not', () => {
const sections = component.getCategorySections(ConfigCategory.OCR)
expect(sections).toEqual([null, ConfigSection.RemoteOCR])
expect(
component
.getCategoryOptions(ConfigCategory.OCR)
.map((option) => option.key)
).toContain('output_type')
expect(
component
.getCategoryOptions(ConfigCategory.OCR, ConfigSection.RemoteOCR)
.map((option) => option.key)
).toEqual([
'remote_ocr_engine',
'remote_ocr_api_key',
'remote_ocr_endpoint',
'remote_ocr_mode',
])
})
}) })
@@ -74,8 +74,20 @@ export class ConfigComponent
return Object.values(ConfigCategory) return Object.values(ConfigCategory)
} }
getCategoryOptions(category: string): ConfigOption[] { getCategorySections(category: string): string[] {
return PaperlessConfigOptions.filter((o) => o.category === category) return [
...new Set(
PaperlessConfigOptions.filter((o) => o.category === category).map(
(o) => o.section ?? null // null means no section
)
),
]
}
getCategoryOptions(category: string, section: string = null): ConfigOption[] {
return PaperlessConfigOptions.filter(
(o) => o.category === category && (o.section ?? null) === section
)
} }
initialConfig: PaperlessConfig initialConfig: PaperlessConfig
@@ -2,36 +2,34 @@
<button class="navbar-toggler d-md-none collapsed border-0" type="button" data-toggle="collapse" <button class="navbar-toggler d-md-none collapsed border-0" type="button" data-toggle="collapse"
data-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation" data-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation"
(click)="closeMobileSearch(); toggleMenuCollapsed()"> (click)="closeMobileSearch(); toggleMenuCollapsed()">
<span class="navbar-toggler-icon"></span> <i-bs width="1.5em" height="1.5em" name="list"></i-bs>
</button> </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 }" [ngClass]="{ 'slim': slimSidebarEnabled, '' : !slimSidebarEnabled }"
routerLink="/dashboard" routerLink="/dashboard"
tourAnchor="tour.intro"> tourAnchor="tour.intro">
@if (!hasCustomBranding) { @if (!hasCustomBranding) {
<pngx-logo extra_classes="navbar-official-logo px-1" height="2.4rem"></pngx-logo> <pngx-logo extra_classes="navbar-official-logo px-1" height="2.4rem"></pngx-logo>
<svg class="brand-mark brand-mark-slim d-none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1.5em" height="1.5em" fill="currentColor"> <pngx-brand-mark class="brand-mark brand-mark-slim d-none"></pngx-brand-mark>
<path d="M341,949.1c-6.9-20.3-20.7-61.2-21.9-61-199.6-88.9-182.5-229.8-134.3-347.5,30,137.2,268.8,148.9,146.2,336-.9,2.2,10,27.8,19.5,51.3,22.7-51.9,58.6-115.5,55.8-120.8C178,398.7,724.9,299,807.1,18.5c83,251.5,53.1,659.8-377.4,814.9-2,1.4-63.5,148.6-66.9,150.2-.2-2.1-33.2,2.9-30.1-8.7,1.6-7,4.8-16.2,8.2-25.6h0v-.2h.1ZM323.1,846.2c48.3-71.9-12.7-120.8-56.9-152.2,81.2,107.4,66.4,120.8,56.9,152.2h0Z"/>
</svg>
} @else {
@if (customAppLogo) {
<img class="brand-logo" [src]="customAppLogo" alt="" />
} @else { } @else {
<svg class="brand-mark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1.5em" height="1.5em" fill="currentColor"> @if (customAppLogo) {
<path d="M341,949.1c-6.9-20.3-20.7-61.2-21.9-61-199.6-88.9-182.5-229.8-134.3-347.5,30,137.2,268.8,148.9,146.2,336-.9,2.2,10,27.8,19.5,51.3,22.7-51.9,58.6-115.5,55.8-120.8C178,398.7,724.9,299,807.1,18.5c83,251.5,53.1,659.8-377.4,814.9-2,1.4-63.5,148.6-66.9,150.2-.2-2.1-33.2,2.9-30.1-8.7,1.6-7,4.8-16.2,8.2-25.6h0v-.2h.1ZM323.1,846.2c48.3-71.9-12.7-120.8-56.9-152.2,81.2,107.4,66.4,120.8,56.9,152.2h0Z"/> <img class="brand-logo" [src]="customAppLogo" alt="" />
</svg> } @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>
} }
</div> <div class="brand-copy ms-2 text-truncate" [class.d-md-none]="slimSidebarEnabled">
} <span class="brand-title text-truncate">{{ appTitle }}</span>
</a> @if (customAppTitle) {
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-3 me-sm-auto order-3 order-sm-1" <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()"> [class.mobile-hidden]="mobileSearchHidden()">
<div class="col-12 header-search"> <div class="col-12 header-search mx-auto">
<pngx-global-search></pngx-global-search> <pngx-global-search></pngx-global-search>
</div> </div>
</div> </div>
@@ -41,7 +39,7 @@
} }
<pngx-toasts-dropdown></pngx-toasts-dropdown> <pngx-toasts-dropdown></pngx-toasts-dropdown>
<li ngbDropdown class="nav-item 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> <i-bs width="1.3em" height="1.3em" name="person-circle"></i-bs>
<span class="small ms-2 d-none d-sm-inline"> <span class="small ms-2 d-none d-sm-inline">
{{this.settingsService.displayName}} {{this.settingsService.displayName}}
@@ -90,7 +88,7 @@
<ul class="nav flex-column"> <ul class="nav flex-column">
<li class="nav-item app-link"> <li class="nav-item app-link">
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="house"></i-bs><span><ng-container i18n>Dashboard</ng-container></span> <i-bs class="me-2" name="house"></i-bs><span><ng-container i18n>Dashboard</ng-container></span>
</a> </a>
@@ -99,7 +97,7 @@
<a class="nav-link" routerLink="documents" routerLinkActive="active" <a class="nav-link" routerLink="documents" routerLinkActive="active"
[routerLinkActiveOptions]="{ paths: 'exact', queryParams: 'ignored', matrixParams: 'ignored', fragment: 'ignored' }" [routerLinkActiveOptions]="{ paths: 'exact', queryParams: 'ignored', matrixParams: 'ignored', fragment: 'ignored' }"
(click)="closeMenu()" (click)="closeMenu()"
ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="files"></i-bs><span><ng-container i18n>Documents</ng-container></span> <i-bs class="me-2" name="files"></i-bs><span><ng-container i18n>Documents</ng-container></span>
</a> </a>
@@ -118,7 +116,7 @@
(cdkDragEnded)="onDragEnd($event)"> (cdkDragEnded)="onDragEnd($event)">
<a class="nav-link" routerLink="view/{{view.id}}" <a class="nav-link" routerLink="view/{{view.id}}"
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name" routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name"
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
popoverClass="popover-slim"> popoverClass="popover-slim">
<i-bs class="me-2" [name]="view.icon || 'funnel'"></i-bs><span><div class="d-inline-flex view-name"><span class="overflow-hidden" [class.text-wrap]="!slimSidebarEnabled">{{view.name}}</span></div> <i-bs class="me-2" [name]="view.icon || 'funnel'"></i-bs><span><div class="d-inline-flex view-name"><span class="overflow-hidden" [class.text-wrap]="!slimSidebarEnabled">{{view.name}}</span></div>
@if (showSidebarCounts && !slimSidebarEnabled) { @if (showSidebarCounts && !slimSidebarEnabled) {
@@ -156,7 +154,7 @@
<li class="nav-item w-100 app-link"> <li class="nav-item w-100 app-link">
<a class="nav-link app-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="documents/{{d.id}}" <a class="nav-link app-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="documents/{{d.id}}"
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="d.title | documentTitle" routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="d.title | documentTitle"
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
popoverClass="popover-slim"> popoverClass="popover-slim">
<i-bs class="me-2" name="file-text"></i-bs><span>{{d.title | documentTitle}}</span> <i-bs class="me-2" name="file-text"></i-bs><span>{{d.title | documentTitle}}</span>
<span class="close flex-column justify-content-center" <span class="close flex-column justify-content-center"
@@ -170,7 +168,7 @@
<li class="nav-item w-100 app-link"> <li class="nav-item w-100 app-link">
<button type="button" class="nav-link nav-link-action app-link w-100 text-start" <button type="button" class="nav-link nav-link-action app-link w-100 text-start"
[class.text-truncate]="!slimSidebarEnabled" (click)="closeAll()" [class.text-truncate]="!slimSidebarEnabled" (click)="closeAll()"
ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="x"></i-bs><span><ng-container i18n>Close all</ng-container></span> <i-bs class="me-2" name="x"></i-bs><span><ng-container i18n>Close all</ng-container></span>
</button> </button>
@@ -189,7 +187,7 @@
<div class="d-flex align-items-center attributes-row"> <div class="d-flex align-items-center attributes-row">
<a class="nav-link flex-fill" routerLink="attributes" routerLinkActive="active" <a class="nav-link flex-fill" routerLink="attributes" routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: !(slimSidebarEnabled || attributesSectionsCollapsed) }" (click)="closeMenu()" [routerLinkActiveOptions]="{ exact: !(slimSidebarEnabled || attributesSectionsCollapsed) }" (click)="closeMenu()"
ngbPopover="Attributes" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Attributes" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs name="stack"></i-bs><span class="ms-2"><ng-container i18n>Attributes</ng-container></span> <i-bs name="stack"></i-bs><span class="ms-2"><ng-container i18n>Attributes</ng-container></span>
</a> </a>
@@ -241,7 +239,7 @@
} }
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
<a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="window-stack"></i-bs><span><ng-container i18n>Saved Views</ng-container></span> <i-bs class="me-2" name="window-stack"></i-bs><span><ng-container i18n>Saved Views</ng-container></span>
</a> </a>
@@ -250,7 +248,7 @@
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
tourAnchor="tour.workflows"> tourAnchor="tour.workflows">
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="boxes"></i-bs><span><ng-container i18n>Workflows</ng-container></span> <i-bs class="me-2" name="boxes"></i-bs><span><ng-container i18n>Workflows</ng-container></span>
</a> </a>
@@ -258,14 +256,14 @@
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }" <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
tourAnchor="tour.mail"> tourAnchor="tour.mail">
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail" <a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim"> triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="envelope"></i-bs><span><ng-container i18n>Mail</ng-container></span> <i-bs class="me-2" name="envelope"></i-bs><span><ng-container i18n>Mail</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
<a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash" <a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim"> triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="trash"></i-bs><span><ng-container i18n>Trash</ng-container></span> <i-bs class="me-2" name="trash"></i-bs><span><ng-container i18n>Trash</ng-container></span>
</a> </a>
@@ -281,21 +279,21 @@
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }" <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }"
tourAnchor="tour.settings"> tourAnchor="tour.settings">
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="gear"></i-bs><span><ng-container i18n>Settings</ng-container></span> <i-bs class="me-2" name="gear"></i-bs><span><ng-container i18n>Settings</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }">
<a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="sliders2-vertical"></i-bs><span><ng-container i18n>Configuration</ng-container></span> <i-bs class="me-2" name="sliders2-vertical"></i-bs><span><ng-container i18n>Configuration</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="people"></i-bs><span><ng-container i18n>Users & Groups</ng-container></span> <i-bs class="me-2" name="people"></i-bs><span><ng-container i18n>Users & Groups</ng-container></span>
</a> </a>
@@ -304,7 +302,7 @@
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }"
tourAnchor="tour.file-tasks"> tourAnchor="tour.file-tasks">
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="list-task"></i-bs><span><ng-container i18n>Tasks</ng-container>@if (tasksService.needsAttentionTasks.length > 0) { <i-bs class="me-2" name="list-task"></i-bs><span><ng-container i18n>Tasks</ng-container>@if (tasksService.needsAttentionTasks.length > 0) {
<span><span class="badge bg-danger ms-2 d-inline">{{tasksService.needsAttentionTasks.length}}</span></span> <span><span class="badge bg-danger ms-2 d-inline">{{tasksService.needsAttentionTasks.length}}</span></span>
@@ -317,26 +315,26 @@
@if (permissionsService.isAdmin()) { @if (permissionsService.isAdmin()) {
<li class="nav-item app-link"> <li class="nav-item app-link">
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs" <a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim"> triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="text-left"></i-bs><span><ng-container i18n>Logs</ng-container></span> <i-bs class="me-2" name="text-left"></i-bs><span><ng-container i18n>Logs</ng-container></span>
</a> </a>
</li> </li>
} }
<li class="nav-item mt-2" tourAnchor="tour.outro"> <li class="nav-item mt-2" tourAnchor="tour.outro">
<a class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap text-decoration-none" <a class="text-muted small d-flex align-items-center flex-wrap text-decoration-none nav-anchor"
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation" target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim"> triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="d-flex me-2" name="question-circle"></i-bs><span><ng-container i18n>Documentation</ng-container></span> <i-bs class="d-flex me-2" name="question-circle"></i-bs><span><ng-container i18n>Documentation</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled"> <li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
<div class="px-3 py-0 text-muted small d-flex align-items-center flex-wrap"> <div class="text-muted small d-flex align-items-center flex-wrap nav-label">
<div class="me-3"> <div class="me-3">
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer" <a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer"
href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim"> triggers="mouseenter:mouseleave" popoverClass="popover-slim">
{{ versionString }} {{ versionString }}
</a> </a>
@@ -67,6 +67,23 @@
main.mobile-search-hidden { main.mobile-search-hidden {
padding-top: 56px; padding-top: 56px;
} }
.navbar-toggler {
padding-left: calc(12px - (1.5em * 2.5 / 16));
}
}
.search-container {
padding-left: 1rem;
padding-right: 1rem;
}
// Below sm the search gets its own full-width row, so line it up with main's content edge
@media (max-width: 575.98px) {
.search-container {
padding-left: 12px;
padding-right: 12px;
}
} }
main { main {
@@ -97,7 +114,7 @@ main {
li.nav-item span.badge { li.nav-item span.badge {
display: inline-block; display: inline-block;
margin-right: 2px; margin-right: -4px;
} }
} }
@@ -141,18 +158,11 @@ main {
} }
} }
.sidebar.slim {
li.nav-item span.badge {
display: inline-block;
margin-right: 2px;
}
}
.sidebar-slim-toggler { .sidebar-slim-toggler {
display: block; display: block;
position: fixed; position: fixed;
left: calc(var(--pngx-sidebar-width) - 12px); left: calc(var(--pngx-sidebar-width) - 12px);
top: 72px; bottom: 16px;
z-index: 996; z-index: 996;
--bs-btn-padding-x: 0.35rem; --bs-btn-padding-x: 0.35rem;
--bs-btn-padding-y: 0.125rem; --bs-btn-padding-y: 0.125rem;
@@ -227,6 +237,10 @@ main {
} }
} }
.sidebar .nav-anchor, .sidebar .nav-label {
padding: .25rem .7rem;
}
.attributes-row { .attributes-row {
border-radius: .55rem; border-radius: .55rem;
margin: .1rem 0; margin: .1rem 0;
@@ -348,6 +362,7 @@ main {
} }
::ng-deep .navbar-official-logo { ::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)); filter: drop-shadow(0 1px 2px rgba(var(--pngx-navbar-brand-shadow-rgb), .3));
transition: filter .15s ease-in-out; transition: filter .15s ease-in-out;
@@ -403,7 +418,7 @@ main {
:host ::ng-deep .navbar-official-logo { :host ::ng-deep .navbar-official-logo {
.leaf { .leaf {
fill: color-mix(in srgb, var(--pngx-primary-text-contrast) 70%, var(--bs-primary)) !important; fill: color-mix(in srgb, var(--pngx-primary-text-contrast) 85%, var(--bs-primary)) !important;
} }
.text { .text {
@@ -422,6 +437,19 @@ main {
padding-right: .5rem; 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 { :host ::ng-deep .navbar-action {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -444,7 +472,7 @@ main {
.header-search { .header-search {
width: 100%; width: 100%;
max-width: 44rem; max-width: 55rem;
} }
@media screen and (max-width: 575.98px) { @media screen and (max-width: 575.98px) {
@@ -458,9 +486,11 @@ main {
.navbar-toggler { .navbar-toggler {
grid-area: toggler; grid-area: toggler;
color: var(--pngx-primary-text-contrast);
text-align: left; text-align: left;
} }
.navbar-brand-container,
.navbar-brand { .navbar-brand {
grid-area: brand; grid-area: brand;
min-width: 0; min-width: 0;
@@ -45,6 +45,7 @@ import { TasksService } from 'src/app/services/tasks.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { ChatComponent } from '../chat/chat/chat.component' import { ChatComponent } from '../chat/chat/chat.component'
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
import { LogoComponent } from '../common/logo/logo.component' import { LogoComponent } from '../common/logo/logo.component'
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component' import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
import { DocumentDetailComponent } from '../document-detail/document-detail.component' import { DocumentDetailComponent } from '../document-detail/document-detail.component'
@@ -61,6 +62,7 @@ const SCROLL_THRESHOLD = 16
imports: [ imports: [
GlobalSearchComponent, GlobalSearchComponent,
LogoComponent, LogoComponent,
BrandMarkComponent,
DocumentTitlePipe, DocumentTitlePipe,
IfPermissionsDirective, IfPermissionsDirective,
ToastsDropdownComponent, ToastsDropdownComponent,
@@ -278,6 +280,10 @@ export class AppFrameComponent
}) })
} }
get slimSidebarPopoversEnabled(): boolean {
return this.slimSidebarEnabled && !this.isMobileViewport()
}
get attributesSectionsCollapsed(): boolean { get attributesSectionsCollapsed(): boolean {
this.settingsService.trackChanges() this.settingsService.trackChanges()
return this.settingsService return this.settingsService
@@ -0,0 +1,28 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
</button>
</div>
<div class="modal-body">
@if (messageBold) {
<p class="text-break"><b>{{messageBold}}</b></p>
}
@if (message) {
<p class="mb-0 text-break" [innerHTML]="message"></p>
}
@if (showRemoteOcr) {
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" id="reprocessRemoteOcr" [(ngModel)]="remoteOcr" />
<label class="form-check-label" for="reprocessRemoteOcr" i18n>Use remote OCR</label>
<div class="form-text" i18n>Sends the document to the configured remote OCR service, which may incur costs.</div>
</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
{{btnCaption}}
</button>
</div>
@@ -0,0 +1,72 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { RemoteOCRModeConfig } from 'src/app/data/paperless-config'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { SettingsService } from 'src/app/services/settings.service'
import { ReprocessConfirmDialogComponent } from './reprocess-confirm-dialog.component'
describe('ReprocessConfirmDialogComponent', () => {
let component: ReprocessConfirmDialogComponent
let fixture: ComponentFixture<ReprocessConfirmDialogComponent>
let settingsService: SettingsService
const createComponent = (configured: boolean, mode: string) => {
settingsService.set(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED, configured)
settingsService.set(SETTINGS_KEYS.REMOTE_OCR_MODE, mode)
fixture = TestBed.createComponent(ReprocessConfirmDialogComponent)
component = fixture.componentInstance
fixture.detectChanges()
}
beforeEach(async () => {
TestBed.configureTestingModule({
providers: [
NgbActiveModal,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
imports: [ReprocessConfirmDialogComponent],
}).compileComponents()
settingsService = TestBed.inject(SettingsService)
})
it('should not request remote OCR by default', () => {
createComponent(true, RemoteOCRModeConfig.WORKFLOW_ONLY)
expect(component.remoteOcr).toBeFalsy()
})
it('should not offer remote OCR when no engine is configured', () => {
createComponent(false, RemoteOCRModeConfig.WORKFLOW_ONLY)
expect(component.showRemoteOcr).toBeFalsy()
expect(
fixture.nativeElement.querySelector('#reprocessRemoteOcr')
).toBeNull()
})
it('should not offer remote OCR when it already handles every document', () => {
createComponent(true, RemoteOCRModeConfig.ALWAYS)
expect(component.showRemoteOcr).toBeFalsy()
expect(
fixture.nativeElement.querySelector('#reprocessRemoteOcr')
).toBeNull()
})
it('should offer remote OCR when configured and selective', () => {
createComponent(true, RemoteOCRModeConfig.WORKFLOW_ONLY)
expect(component.showRemoteOcr).toBeTruthy()
const checkbox = fixture.nativeElement.querySelector('#reprocessRemoteOcr')
expect(checkbox).not.toBeNull()
checkbox.click()
fixture.detectChanges()
expect(component.remoteOcr).toBeTruthy()
})
})
@@ -0,0 +1,20 @@
import { Component, inject } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { SettingsService } from 'src/app/services/settings.service'
import { ConfirmDialogComponent } from '../confirm-dialog.component'
@Component({
selector: 'pngx-reprocess-confirm-dialog',
templateUrl: './reprocess-confirm-dialog.component.html',
imports: [FormsModule],
})
export class ReprocessConfirmDialogComponent extends ConfirmDialogComponent {
private settings = inject(SettingsService)
remoteOcr: boolean = false
public get showRemoteOcr(): boolean {
// Hidden when it is not configured, or when it already handles every document anyway.
return this.settings.remoteOCRIsSelectable
}
}
@@ -455,6 +455,52 @@
</div> </div>
</div> </div>
} }
@case (WorkflowActionType.RemoteOcr) {
<div class="row">
<div class="col">
<p class="text-muted small" i18n>The document will be sent to the configured remote OCR service. May incur costs.</p>
</div>
</div>
}
@case (WorkflowActionType.ApplyAiSuggestions) {
<div class="row">
<div class="col">
<p class="text-muted small" i18n>The document will be sent to the configured AI service for suggestions. Consider costs and privacy.</p>
<pngx-input-select
i18n-title
title="Apply suggestions for"
[items]="aiSuggestionFieldOptions"
[multiple]="true"
formControlName="ai_suggestion_fields"
[error]="error?.actions?.[i]?.ai_suggestion_fields"
hint="Suggestions for fields that are not selected are discarded."
i18n-hint
></pngx-input-select>
</div>
</div>
<div class="row">
<div class="col-md-6">
<pngx-input-switch
[horizontal]="true"
i18n-title
title="Create missing items"
formControlName="ai_create_missing"
hint="Create suggested tags, correspondents and document types that do not exist yet."
i18n-hint
></pngx-input-switch>
</div>
<div class="col-md-6">
<pngx-input-switch
[horizontal]="true"
i18n-title
title="Overwrite existing values"
formControlName="ai_overwrite_existing"
hint="Apply suggestions even if the document already has a value. Tags are always added, never replaced."
i18n-hint
></pngx-input-switch>
</div>
</div>
}
} }
</div> </div>
</ng-template> </ng-template>
@@ -22,6 +22,7 @@ import {
} from 'src/app/data/matching-model' } from 'src/app/data/matching-model'
import { Workflow } from 'src/app/data/workflow' import { Workflow } from 'src/app/data/workflow'
import { import {
AISuggestionField,
WorkflowAction, WorkflowAction,
WorkflowActionType, WorkflowActionType,
} from 'src/app/data/workflow-action' } from 'src/app/data/workflow-action'
@@ -29,6 +30,7 @@ import {
DocumentSource, DocumentSource,
WorkflowTriggerType, WorkflowTriggerType,
} from 'src/app/data/workflow-trigger' } from 'src/app/data/workflow-trigger'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive' import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { CorrespondentService } from 'src/app/services/rest/correspondent.service' import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
@@ -48,6 +50,7 @@ import { TagsComponent } from '../../input/tags/tags.component'
import { TextComponent } from '../../input/text/text.component' import { TextComponent } from '../../input/text/text.component'
import { EditDialogMode } from '../edit-dialog.component' import { EditDialogMode } from '../edit-dialog.component'
import { import {
AI_SUGGESTION_FIELD_OPTIONS,
DOCUMENT_SOURCE_OPTIONS, DOCUMENT_SOURCE_OPTIONS,
SCHEDULE_DATE_FIELD_OPTIONS, SCHEDULE_DATE_FIELD_OPTIONS,
TriggerFilterType, TriggerFilterType,
@@ -224,7 +227,12 @@ describe('WorkflowEditDialogComponent', () => {
).toEqual('Document Added') ).toEqual('Document Added')
expect(component.getTriggerTypeOptionName(null)).toEqual('') expect(component.getTriggerTypeOptionName(null)).toEqual('')
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS) expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS)
expect(component.actionTypeOptions).toEqual(WORKFLOW_ACTION_OPTIONS) // Remote OCR is absent until the workflow has a consumption trigger
expect(component.actionTypeOptions).toEqual(
WORKFLOW_ACTION_OPTIONS.filter(
(a) => a.id !== WorkflowActionType.RemoteOcr
)
)
expect( expect(
component.getActionTypeOptionName(WorkflowActionType.Assignment) component.getActionTypeOptionName(WorkflowActionType.Assignment)
).toEqual('Assignment') ).toEqual('Assignment')
@@ -233,14 +241,231 @@ describe('WorkflowEditDialogComponent', () => {
SCHEDULE_DATE_FIELD_OPTIONS SCHEDULE_DATE_FIELD_OPTIONS
) )
// Email disabled // Email, remote OCR and AI all disabled
jest.spyOn(settingsService, 'get').mockReturnValue(false) jest.spyOn(settingsService, 'get').mockReturnValue(false)
component.ngOnInit() component.ngOnInit()
expect(component.actionTypeOptions).toEqual( expect(component.actionTypeOptions).toEqual(
WORKFLOW_ACTION_OPTIONS.filter((a) => a.id !== WorkflowActionType.Email) WORKFLOW_ACTION_OPTIONS.filter(
(a) =>
a.id !== WorkflowActionType.Email &&
a.id !== WorkflowActionType.RemoteOcr &&
a.id !== WorkflowActionType.ApplyAiSuggestions
)
) )
}) })
it('should offer remote OCR only for consumption workflows', () => {
jest.spyOn(settingsService, 'get').mockReturnValue(true)
// A consumption trigger makes the action reachable
component.object = {
name: 'Workflow 1',
order: 0,
enabled: true,
triggers: [{ type: WorkflowTriggerType.Consumption }],
actions: [],
} as Workflow
component.ngOnInit()
expect(component.actionTypeOptions.map((a) => a.id)).toContain(
WorkflowActionType.RemoteOcr
)
// Any other trigger type runs after the document has been parsed
component.object = {
name: 'Workflow 2',
order: 0,
enabled: true,
triggers: [{ type: WorkflowTriggerType.DocumentAdded }],
actions: [],
} as Workflow
component.ngOnInit()
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
WorkflowActionType.RemoteOcr
)
})
it('should offer remote OCR on a trigger added to a new workflow', () => {
jest.spyOn(settingsService, 'get').mockReturnValue(true)
component.ngOnInit()
// Nothing for the action to apply to yet
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
WorkflowActionType.RemoteOcr
)
// addTrigger creates the form field with emitEvent false, so the options
// have to be computed on read rather than cached from valueChanges
component.addTrigger()
expect(component.actionTypeOptions.map((a) => a.id)).toContain(
WorkflowActionType.RemoteOcr
)
// Switching that trigger to a type that runs after parsing removes it
component.triggerFields
.at(0)
.get('type')
.setValue(WorkflowTriggerType.DocumentAdded)
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
WorkflowActionType.RemoteOcr
)
})
it('should keep remote OCR listed when an action already uses it', () => {
jest.spyOn(settingsService, 'get').mockReturnValue(true)
// Otherwise changing the trigger would silently blank the selection
component.object = {
name: 'Workflow 1',
order: 0,
enabled: true,
triggers: [{ type: WorkflowTriggerType.DocumentAdded }],
actions: [{ type: WorkflowActionType.RemoteOcr }],
} as Workflow
component.ngOnInit()
expect(component.actionTypeOptions.map((a) => a.id)).toContain(
WorkflowActionType.RemoteOcr
)
})
it('should not offer remote OCR when no engine is configured', () => {
jest
.spyOn(settingsService, 'get')
.mockImplementation((key) => key !== SETTINGS_KEYS.REMOTE_OCR_CONFIGURED)
component.object = {
name: 'Workflow 1',
order: 0,
enabled: true,
triggers: [{ type: WorkflowTriggerType.Consumption }],
actions: [],
} as Workflow
component.ngOnInit()
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
WorkflowActionType.RemoteOcr
)
})
it('should offer apply AI suggestions unless every trigger is consumption', () => {
jest.spyOn(settingsService, 'get').mockReturnValue(true)
// Consumption runs before the document has been parsed, so there would be
// no content to make suggestions from
component.object = {
name: 'Workflow 1',
order: 0,
enabled: true,
triggers: [{ type: WorkflowTriggerType.Consumption }],
actions: [],
} as Workflow
component.ngOnInit()
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
WorkflowActionType.ApplyAiSuggestions
)
// A second, usable trigger is enough
component.object = {
name: 'Workflow 2',
order: 0,
enabled: true,
triggers: [
{ type: WorkflowTriggerType.Consumption },
{ type: WorkflowTriggerType.DocumentAdded },
],
actions: [],
} as Workflow
component.ngOnInit()
expect(component.actionTypeOptions.map((a) => a.id)).toContain(
WorkflowActionType.ApplyAiSuggestions
)
})
it('should keep apply AI suggestions listed when an action already uses it', () => {
jest.spyOn(settingsService, 'get').mockReturnValue(true)
// Otherwise changing the trigger would silently blank the selection
component.object = {
name: 'Workflow 1',
order: 0,
enabled: true,
triggers: [{ type: WorkflowTriggerType.Consumption }],
actions: [{ type: WorkflowActionType.ApplyAiSuggestions }],
} as Workflow
component.ngOnInit()
expect(component.actionTypeOptions.map((a) => a.id)).toContain(
WorkflowActionType.ApplyAiSuggestions
)
})
it('should not offer apply AI suggestions when AI is disabled', () => {
jest
.spyOn(settingsService, 'get')
.mockImplementation((key) => key !== SETTINGS_KEYS.AI_ENABLED)
component.object = {
name: 'Workflow 1',
order: 0,
enabled: true,
triggers: [{ type: WorkflowTriggerType.DocumentAdded }],
actions: [],
} as Workflow
component.ngOnInit()
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
WorkflowActionType.ApplyAiSuggestions
)
})
it('should create form fields for apply AI suggestions options', () => {
component.object = {
name: 'Workflow 1',
order: 0,
enabled: true,
triggers: [{ type: WorkflowTriggerType.DocumentAdded }],
actions: [
{
type: WorkflowActionType.ApplyAiSuggestions,
ai_suggestion_fields: [
AISuggestionField.Title,
AISuggestionField.Tags,
],
ai_create_missing: true,
ai_overwrite_existing: true,
},
],
} as Workflow
component.ngOnInit()
const action = component.actionFields.at(0)
expect(action.get('ai_suggestion_fields').value).toEqual([
AISuggestionField.Title,
AISuggestionField.Tags,
])
expect(action.get('ai_create_missing').value).toBeTruthy()
expect(action.get('ai_overwrite_existing').value).toBeTruthy()
expect(component.aiSuggestionFieldOptions).toEqual(
AI_SUGGESTION_FIELD_OPTIONS
)
})
it('should default apply AI suggestions options on a new action', () => {
component.object = {
name: 'Workflow 1',
order: 0,
enabled: true,
triggers: [{ type: WorkflowTriggerType.DocumentAdded }],
actions: [],
} as Workflow
component.addAction()
const action = component.actionFields.at(component.actionFields.length - 1)
expect(action.get('ai_suggestion_fields').value).toEqual([])
expect(action.get('ai_create_missing').value).toBeFalsy()
expect(action.get('ai_overwrite_existing').value).toBeFalsy()
})
it('should support add and remove triggers and actions', () => { it('should support add and remove triggers and actions', () => {
component.object = workflow component.object = workflow
component.addTrigger() component.addTrigger()
@@ -30,6 +30,7 @@ import { StoragePath } from 'src/app/data/storage-path'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { Workflow } from 'src/app/data/workflow' import { Workflow } from 'src/app/data/workflow'
import { import {
AISuggestionField,
WorkflowAction, WorkflowAction,
WorkflowActionType, WorkflowActionType,
} from 'src/app/data/workflow-action' } from 'src/app/data/workflow-action'
@@ -148,6 +149,41 @@ export const WORKFLOW_ACTION_OPTIONS = [
id: WorkflowActionType.MoveToTrash, id: WorkflowActionType.MoveToTrash,
name: $localize`Move to trash`, name: $localize`Move to trash`,
}, },
{
id: WorkflowActionType.RemoteOcr,
name: $localize`Remote OCR`,
},
{
id: WorkflowActionType.ApplyAiSuggestions,
name: $localize`Apply AI suggestions`,
},
]
export const AI_SUGGESTION_FIELD_OPTIONS = [
{
id: AISuggestionField.Title,
name: $localize`Title`,
},
{
id: AISuggestionField.Tags,
name: $localize`Tags`,
},
{
id: AISuggestionField.Correspondent,
name: $localize`Correspondent`,
},
{
id: AISuggestionField.DocumentType,
name: $localize`Document type`,
},
{
id: AISuggestionField.StoragePath,
name: $localize`Storage path`,
},
{
id: AISuggestionField.Created,
name: $localize`Created date`,
},
] ]
export enum TriggerFilterType { export enum TriggerFilterType {
@@ -504,8 +540,6 @@ export class WorkflowEditDialogComponent
expandedItem: number = null expandedItem: number = null
readonly allowedActionTypes = signal([])
private readonly triggerFilterOptionsMap = new WeakMap< private readonly triggerFilterOptionsMap = new WeakMap<
FormArray, FormArray,
TriggerFilterOption[] TriggerFilterOption[]
@@ -548,13 +582,58 @@ export class WorkflowEditDialogComponent
this.checkRemovalActionFields.bind(this) this.checkRemovalActionFields.bind(this)
) )
this.checkRemovalActionFields(this.objectForm.value) this.checkRemovalActionFields(this.objectForm.value)
this.allowedActionTypes.set( }
this.settingsService.get(SETTINGS_KEYS.EMAIL_ENABLED)
? WORKFLOW_ACTION_OPTIONS private allowedActionTypes: typeof WORKFLOW_ACTION_OPTIONS = null
: WORKFLOW_ACTION_OPTIONS.filter(
(a) => a.id !== WorkflowActionType.Email private getAllowedActionTypes() {
) let allowed = WORKFLOW_ACTION_OPTIONS
)
if (!this.settingsService.get(SETTINGS_KEYS.EMAIL_ENABLED)) {
allowed = allowed.filter((a) => a.id !== WorkflowActionType.Email)
}
// Remote OCR is decided before the document is parsed, so it is only
// offered for workflows that run at consumption.
const formWorkflow: Workflow = this.objectForm?.value
const remoteOcrUsable =
this.settingsService.get(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED) &&
(formWorkflow?.triggers?.some(
(trigger) => trigger.type === WorkflowTriggerType.Consumption
) ||
formWorkflow?.actions?.some(
(action) => action.type === WorkflowActionType.RemoteOcr
))
if (!remoteOcrUsable) {
allowed = allowed.filter((a) => a.id !== WorkflowActionType.RemoteOcr)
}
// Only available after consumption. Unlike remote OCR this is hidden only
// once every trigger is consumption, so it stays offered on a workflow
// that has no triggers yet.
const aiSuggestionsUsable =
this.settingsService.get(SETTINGS_KEYS.AI_ENABLED) &&
(!formWorkflow?.triggers?.length ||
formWorkflow.triggers.some(
(trigger) => trigger.type !== WorkflowTriggerType.Consumption
) ||
formWorkflow.actions?.some(
(action) => action.type === WorkflowActionType.ApplyAiSuggestions
))
if (!aiSuggestionsUsable) {
allowed = allowed.filter(
(a) => a.id !== WorkflowActionType.ApplyAiSuggestions
)
}
if (
this.allowedActionTypes?.length === allowed.length &&
this.allowedActionTypes.every((a, i) => a.id === allowed[i].id)
) {
return this.allowedActionTypes
}
this.allowedActionTypes = allowed
return allowed
} }
private checkRemovalActionFields(formWorkflow: Workflow) { private checkRemovalActionFields(formWorkflow: Workflow) {
@@ -1198,6 +1277,11 @@ export class WorkflowEditDialogComponent
passwords: new FormControl( passwords: new FormControl(
this.formatPasswords(action.passwords ?? []) this.formatPasswords(action.passwords ?? [])
), ),
ai_suggestion_fields: new FormControl(
action.ai_suggestion_fields ?? []
),
ai_create_missing: new FormControl(!!action.ai_create_missing),
ai_overwrite_existing: new FormControl(!!action.ai_overwrite_existing),
}), }),
{ emitEvent } { emitEvent }
) )
@@ -1279,13 +1363,18 @@ export class WorkflowEditDialogComponent
get actionTypeOptions() { get actionTypeOptions() {
this.settingsService.trackChanges() this.settingsService.trackChanges()
return this.allowedActionTypes() // Computed on read rather than cached
return this.getAllowedActionTypes()
} }
getActionTypeOptionName(type: WorkflowActionType): string { getActionTypeOptionName(type: WorkflowActionType): string {
return this.actionTypeOptions.find((t) => t.id === type)?.name ?? '' return this.actionTypeOptions.find((t) => t.id === type)?.name ?? ''
} }
get aiSuggestionFieldOptions() {
return AI_SUGGESTION_FIELD_OPTIONS
}
addAction() { addAction() {
if (!this.object) { if (!this.object) {
this.object = Object.assign({}, this.objectForm.value) this.object = Object.assign({}, this.objectForm.value)
@@ -1339,6 +1428,9 @@ export class WorkflowEditDialogComponent
include_document: false, include_document: false,
}, },
passwords: [], passwords: [],
ai_suggestion_fields: [],
ai_create_missing: false,
ai_overwrite_existing: false,
} }
this.object.actions.push(action) this.object.actions.push(action)
this.createActionField(action) this.createActionField(action)
@@ -40,6 +40,10 @@ describe('PasswordComponent', () => {
// expect(component.value).toEqual('foo') // 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', () => { it('should support toggling field visibility', () => {
expect(input.type).toEqual('password') expect(input.type).toEqual('password')
component.showReveal = true component.showReveal = true
@@ -25,7 +25,7 @@ export class PasswordComponent extends AbstractInputComponent<string> {
showReveal: boolean = false showReveal: boolean = false
@Input() @Input()
autocomplete: string autocomplete: string = 'off'
public textVisible: boolean = false public textVisible: boolean = false
@@ -36,11 +36,16 @@
(focus)="clearLastSearchTerm()" (focus)="clearLastSearchTerm()"
(clear)="clearLastSearchTerm()" (clear)="clearLastSearchTerm()"
(blur)="onBlur()"> (blur)="onBlur()">
<ng-template ng-label-tmp let-item="item"> <ng-template ng-label-tmp let-item="item" let-clear="clear">
@if (iconField && item[iconField]) { @if (multiple && !disabled) {
<i-bs class="me-2" [name]="item[iconField]"></i-bs> <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-template ng-option-tmp let-item="item"> <ng-template ng-option-tmp let-item="item">
@if (iconField && item[iconField]) { @if (iconField && item[iconField]) {
@@ -24,6 +24,12 @@ describe('TextComponent', () => {
input = component.inputField.nativeElement 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', () => { it('should support use of input field', () => {
expect(component.value).toBeUndefined() expect(component.value).toBeUndefined()
input.value = 'foo' input.value = 'foo'
@@ -28,7 +28,7 @@ import { AbstractInputComponent } from '../abstract-input'
}) })
export class TextComponent extends AbstractInputComponent<string> { export class TextComponent extends AbstractInputComponent<string> {
@Input() @Input()
autocomplete: string autocomplete: string = 'off'
@Input() @Input()
placeholder: string = '' placeholder: string = ''
@@ -0,0 +1,3 @@
<svg class="w-100 h-100 d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" fill="currentColor" aria-hidden="true">
<path d="M341,949.1c-6.9-20.3-20.7-61.2-21.9-61-199.6-88.9-182.5-229.8-134.3-347.5,30,137.2,268.8,148.9,146.2,336-.9,2.2,10,27.8,19.5,51.3,22.7-51.9,58.6-115.5,55.8-120.8C178,398.7,724.9,299,807.1,18.5c83,251.5,53.1,659.8-377.4,814.9-2,1.4-63.5,148.6-66.9,150.2-.2-2.1-33.2,2.9-30.1-8.7,1.6-7,4.8-16.2,8.2-25.6h0v-.2h.1ZM323.1,846.2c48.3-71.9-12.7-120.8-56.9-152.2,81.2,107.4,66.4,120.8,56.9,152.2h0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 545 B

@@ -0,0 +1,14 @@
import { Component, input } from '@angular/core'
@Component({
selector: 'pngx-brand-mark',
templateUrl: './brand-mark.component.html',
host: {
'[style.width]': 'width()',
'[style.height]': 'height()',
},
})
export class BrandMarkComponent {
readonly width = input<string>(null)
readonly height = input<string>(null)
}
@@ -1,6 +1,6 @@
<div class="row pt-3 pb-3 pb-md-2 align-items-center"> <div class="row pt-3 pb-2 align-items-center">
<div class="col-md text-truncate"> <div class="col-md text-truncate">
<h3 class="d-flex align-items-center mb-1" style="line-height: 1.4"> <h3 class="d-flex align-items-center mb-2 mb-md-1" style="line-height: 1.4">
<span class="text-truncate">{{title()}}</span> <span class="text-truncate">{{title()}}</span>
@if (id()) { @if (id()) {
<span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyID()"> <span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyID()">
@@ -12,10 +12,10 @@
</span> </span>
} }
@if (subTitle()) { @if (subTitle()) {
<span class="h6 mb-0 mt-1 d-block d-md-inline fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle()}}</span> <span class="page-subtitle h6 mb-0 mt-1 fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle()}}</span>
} }
@if (info()) { @if (info()) {
<button class="btn btn-sm btn-link text-muted p-0 p-md-2" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true"> <button class="btn btn-sm btn-link text-muted p-0 ms-2 p-md-2 ms-md-0" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true">
<i-bs name="question-circle"></i-bs> <i-bs name="question-circle"></i-bs>
</button> </button>
<ng-template #infoPopover> <ng-template #infoPopover>
@@ -1,5 +1,6 @@
h3 { h3 {
min-height: calc(1.325rem + 0.9vw); min-height: calc(1.325rem + 0.9vw);
flex-wrap: wrap;
.badge { .badge {
font-size: 0.65rem; font-size: 0.65rem;
@@ -7,6 +8,16 @@ h3 {
} }
} }
// Drop the subtitle onto its own and shrink it
@media (max-width: 767.98px) {
h3 > .page-subtitle {
flex: 0 0 100%;
margin-top: 0 !important;
font-size: .7rem;
line-height: 1.3 !important;
}
}
:host { :host {
display: block; display: block;
margin-bottom: .35rem; margin-bottom: .35rem;
@@ -1,10 +1,18 @@
<ngb-alert class="pe-3" type="primary" [dismissible]="true" (closed)="dismiss.emit(true)"> <ngb-alert class="welcome-widget bg-light shadow-sm pe-3 position-relative overflow-hidden" type="primary" [dismissible]="true" (closed)="dismiss.emit(true)">
<h4 class="alert-heading"><ng-container i18n>Paperless-ngx is running!</ng-container> 🎉</h4> <pngx-brand-mark class="welcome-watermark d-none d-md-block position-absolute top-50 translate-middle-y text-primary pe-none" width="26rem" height="26rem"></pngx-brand-mark>
<p i18n>You're ready to start uploading documents! Explore the various features of this web app on your own, or start a quick tour using the button below.</p> <div class="d-flex align-items-start gap-3 gap-md-4 position-relative">
<p i18n>More detail on how to use and configure Paperless-ngx is always available in the <a href="https://docs.paperless-ngx.com" target="_blank">documentation</a>.</p> <pngx-brand-mark class="d-none d-sm-block flex-shrink-0 text-primary" width="3rem" height="3rem"></pngx-brand-mark>
<hr> <div class="flex-grow-1">
<div class="d-flex align-items-end"> <h4 class="alert-heading text-primary fw-semibold"><ng-container i18n>Paperless-ngx is running!</ng-container> 🎉</h4>
<p class="lead fs-6 m-0"><em i18n>Thanks for being a part of the Paperless-ngx community!</em></p> <div class="welcome-copy text-dark">
<button class="btn btn-primary ms-auto flex-shrink-0" (click)="tourService.start()"><ng-container i18n>Start the tour</ng-container> &rarr;</button> <p i18n>You're ready to start uploading documents! Explore the various features of this web app on your own, or start a quick tour using the button below.</p>
<p class="mb-0" i18n>More detail on how to use and configure Paperless-ngx is always available in the <a href="https://docs.paperless-ngx.com" target="_blank">documentation</a>.</p>
</div>
<hr class="opacity-100">
<div class="d-flex flex-column flex-sm-row align-items-sm-center gap-3">
<p class="m-0 text-secondary"><em i18n>Thanks for being a part of the Paperless-ngx community!</em></p>
<button class="btn btn-primary ms-sm-auto flex-shrink-0 align-self-start align-self-sm-auto" (click)="tourService.start()"><ng-container i18n>Start the tour</ng-container> &rarr;</button>
</div>
</div>
</div> </div>
</ngb-alert> </ngb-alert>
@@ -0,0 +1,16 @@
.welcome-widget {
border-color: color-mix(in srgb, var(--bs-primary) 50%, transparent);
hr {
border-top-color: color-mix(in srgb, var(--bs-primary) 25%, transparent);
}
}
.welcome-copy {
max-width: 68ch;
}
.welcome-watermark {
right: -6rem;
opacity: .07;
}
@@ -1,12 +1,13 @@
import { Component, EventEmitter, Output, inject } from '@angular/core' import { Component, EventEmitter, Output, inject } from '@angular/core'
import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap' import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'
import { TourService } from 'ngx-ui-tour-ng-bootstrap' import { TourService } from 'ngx-ui-tour-ng-bootstrap'
import { BrandMarkComponent } from '../../../common/logo/brand-mark/brand-mark.component'
@Component({ @Component({
selector: 'pngx-welcome-widget', selector: 'pngx-welcome-widget',
templateUrl: './welcome-widget.component.html', templateUrl: './welcome-widget.component.html',
styleUrls: ['./welcome-widget.component.scss'], styleUrls: ['./welcome-widget.component.scss'],
imports: [NgbAlertModule], imports: [NgbAlertModule, BrandMarkComponent],
}) })
export class WelcomeWidgetComponent { export class WelcomeWidgetComponent {
readonly tourService = inject(TourService) readonly tourService = inject(TourService)
@@ -963,12 +963,24 @@ describe('DocumentDetailComponent', () => {
component.reprocess() component.reprocess()
const modalCloseSpy = jest.spyOn(openModal, 'close') const modalCloseSpy = jest.spyOn(openModal, 'close')
openModal.componentInstance.confirmClicked.next() openModal.componentInstance.confirmClicked.next()
expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] }) expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] }, false)
expect(modalSpy).toHaveBeenCalled() expect(modalSpy).toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalled() expect(toastSpy).toHaveBeenCalled()
expect(modalCloseSpy).toHaveBeenCalled() expect(modalCloseSpy).toHaveBeenCalled()
}) })
it('should pass remote OCR choice when reprocessing', () => {
initNormally()
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
reprocessSpy.mockReturnValue(of(true))
let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
component.reprocess()
openModal.componentInstance.remoteOcr = true
openModal.componentInstance.confirmClicked.next()
expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] }, true)
})
it('should show error if redo ocr call fails', () => { it('should show error if redo ocr call fails', () => {
initNormally() initNormally()
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments') const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
@@ -97,6 +97,7 @@ import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
import * as UTIF from 'utif' import * as UTIF from 'utif'
import { DocumentDetailFieldID } from '../admin/settings/settings.component' import { DocumentDetailFieldID } from '../admin/settings/settings.component'
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
import { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-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 { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component' import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
@@ -1398,7 +1399,7 @@ export class DocumentDetailComponent
} }
reprocess() { reprocess() {
let modal = this.modalService.open(ConfirmDialogComponent, { let modal = this.modalService.open(ReprocessConfirmDialogComponent, {
backdrop: 'static', backdrop: 'static',
}) })
modal.componentInstance.title = $localize`Reprocess confirm` modal.componentInstance.title = $localize`Reprocess confirm`
@@ -1409,7 +1410,10 @@ export class DocumentDetailComponent
modal.componentInstance.confirmClicked.subscribe(() => { modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled.set(false)
this.documentsService this.documentsService
.reprocessDocuments({ documents: [this.document().id] }) .reprocessDocuments(
{ documents: [this.document().id] },
modal.componentInstance.remoteOcr
)
.subscribe({ .subscribe({
next: () => { next: () => {
this.toastService.showInfo( this.toastService.showInfo(
@@ -1122,6 +1122,7 @@ describe('BulkEditorComponent', () => {
req.flush(true) req.flush(true)
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: [3, 4], documents: [3, 4],
remote_ocr: false,
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
@@ -52,6 +52,7 @@ import { flattenTags } from 'src/app/utils/flatten-tags'
import { queryParamsFromFilterRules } from 'src/app/utils/query-params' import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
import { MergeAsVersionsConfirmDialogComponent } from '../../common/confirm-dialog/merge-as-versions-confirm-dialog/merge-as-versions-confirm-dialog.component' import { MergeAsVersionsConfirmDialogComponent } from '../../common/confirm-dialog/merge-as-versions-confirm-dialog/merge-as-versions-confirm-dialog.component'
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component' import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
import { ReprocessConfirmDialogComponent } from '../../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component' import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component' import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component' import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
@@ -916,7 +917,7 @@ export class BulkEditorComponent
} }
reprocessSelected() { reprocessSelected() {
let modal = this.modalService.open(ConfirmDialogComponent, { let modal = this.modalService.open(ReprocessConfirmDialogComponent, {
backdrop: 'static', backdrop: 'static',
}) })
modal.componentInstance.title = $localize`Reprocess confirm` modal.componentInstance.title = $localize`Reprocess confirm`
@@ -930,7 +931,10 @@ export class BulkEditorComponent
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled.set(false)
this.executeDocumentAction( this.executeDocumentAction(
modal, modal,
this.documentService.reprocessDocuments(this.getSelectionQuery()) this.documentService.reprocessDocuments(
this.getSelectionQuery(),
modal.componentInstance.remoteOcr
)
) )
}) })
} }
+55
View File
@@ -54,6 +54,10 @@ export const ConfigCategory = {
AI: $localize`AI Settings`, AI: $localize`AI Settings`,
} }
export const ConfigSection = {
RemoteOCR: $localize`Remote OCR`,
}
export const LLMEmbeddingBackendConfig = { export const LLMEmbeddingBackendConfig = {
OPENAI_LIKE: 'openai-like', OPENAI_LIKE: 'openai-like',
HUGGINGFACE: 'huggingface', HUGGINGFACE: 'huggingface',
@@ -65,6 +69,15 @@ export const LLMBackendConfig = {
OLLAMA: 'ollama', OLLAMA: 'ollama',
} }
export const RemoteOCREngineConfig = {
AZURE_AI: 'azureai',
}
export const RemoteOCRModeConfig = {
ALWAYS: 'always',
WORKFLOW_ONLY: 'workflow_only',
}
export interface ConfigOption { export interface ConfigOption {
key: string key: string
title: string title: string
@@ -72,6 +85,7 @@ export interface ConfigOption {
choices?: Array<{ id: string; name: string }> choices?: Array<{ id: string; name: string }>
config_key?: string config_key?: string
category: string category: string
section?: string
note?: string note?: string
} }
@@ -181,6 +195,43 @@ export const PaperlessConfigOptions: ConfigOption[] = [
config_key: 'PAPERLESS_OCR_USER_ARGS', config_key: 'PAPERLESS_OCR_USER_ARGS',
category: ConfigCategory.OCR, category: ConfigCategory.OCR,
}, },
{
key: 'remote_ocr_engine',
title: $localize`Remote OCR Engine`,
type: ConfigOptionType.Select,
choices: mapToItems(RemoteOCREngineConfig),
config_key: 'PAPERLESS_REMOTE_OCR_ENGINE',
category: ConfigCategory.OCR,
section: ConfigSection.RemoteOCR,
note: $localize`Enabling remote OCR sends documents to a third-party service for processing. Consider the privacy implications as well as potential costs before enabling.`,
},
{
key: 'remote_ocr_api_key',
title: $localize`Remote OCR API Key`,
type: ConfigOptionType.Password,
config_key: 'PAPERLESS_REMOTE_OCR_API_KEY',
category: ConfigCategory.OCR,
section: ConfigSection.RemoteOCR,
},
{
key: 'remote_ocr_endpoint',
title: $localize`Remote OCR Endpoint`,
type: ConfigOptionType.String,
config_key: 'PAPERLESS_REMOTE_OCR_ENDPOINT',
category: ConfigCategory.OCR,
section: ConfigSection.RemoteOCR,
note: $localize`Required when using the Azure AI engine.`,
},
{
key: 'remote_ocr_mode',
title: $localize`Remote OCR Mode`,
type: ConfigOptionType.Select,
choices: mapToItems(RemoteOCRModeConfig),
config_key: 'PAPERLESS_REMOTE_OCR_MODE',
category: ConfigCategory.OCR,
section: ConfigSection.RemoteOCR,
note: $localize`Which documents are sent to the remote engine. Use 'workflow_only' to keep remote OCR off unless a workflow enables it for a document.`,
},
{ {
key: 'app_logo', key: 'app_logo',
title: $localize`Application Logo`, title: $localize`Application Logo`,
@@ -398,6 +449,10 @@ export interface PaperlessConfig extends ObjectWithId {
barcode_enable_tag: boolean barcode_enable_tag: boolean
barcode_tag_mapping: object barcode_tag_mapping: object
barcode_tag_split: boolean barcode_tag_split: boolean
remote_ocr_engine: string
remote_ocr_api_key: string
remote_ocr_endpoint: string
remote_ocr_mode: string
ai_enabled: boolean ai_enabled: boolean
llm_embedding_backend: string llm_embedding_backend: string
llm_embedding_model: string llm_embedding_model: string
+13
View File
@@ -1,5 +1,6 @@
import { PdfEditorEditMode } from '../components/common/pdf-editor/pdf-editor-edit-mode' import { PdfEditorEditMode } from '../components/common/pdf-editor/pdf-editor-edit-mode'
import { PdfZoomScale } from '../components/common/pdf-viewer/pdf-viewer.types' import { PdfZoomScale } from '../components/common/pdf-viewer/pdf-viewer.types'
import { RemoteOCRModeConfig } from './paperless-config'
import { User } from './user' import { User } from './user'
export interface UiSettings { export interface UiSettings {
@@ -94,6 +95,8 @@ export const SETTINGS_KEYS = {
OUTLOOK_OAUTH_URL: 'outlook_oauth_url', OUTLOOK_OAUTH_URL: 'outlook_oauth_url',
EMAIL_ENABLED: 'email_enabled', EMAIL_ENABLED: 'email_enabled',
AI_ENABLED: 'ai_enabled', AI_ENABLED: 'ai_enabled',
REMOTE_OCR_CONFIGURED: 'remote_ocr:configured',
REMOTE_OCR_MODE: 'remote_ocr:mode',
} }
export const SETTINGS: UiSetting[] = [ export const SETTINGS: UiSetting[] = [
@@ -347,4 +350,14 @@ export const SETTINGS: UiSetting[] = [
type: 'string', type: 'string',
default: PdfEditorEditMode.Create, default: PdfEditorEditMode.Create,
}, },
{
key: SETTINGS_KEYS.REMOTE_OCR_CONFIGURED,
type: 'boolean',
default: false,
},
{
key: SETTINGS_KEYS.REMOTE_OCR_MODE,
type: 'string',
default: RemoteOCRModeConfig.ALWAYS,
},
] ]
+18
View File
@@ -7,6 +7,18 @@ export enum WorkflowActionType {
Webhook = 4, Webhook = 4,
PasswordRemoval = 5, PasswordRemoval = 5,
MoveToTrash = 6, MoveToTrash = 6,
RemoteOcr = 7,
ApplyAiSuggestions = 8,
}
// see src/documents/models.py AISuggestionField
export enum AISuggestionField {
Title = 'title',
Tags = 'tags',
Correspondent = 'correspondent',
DocumentType = 'document_type',
StoragePath = 'storage_path',
Created = 'created',
} }
export interface WorkflowActionEmail extends ObjectWithId { export interface WorkflowActionEmail extends ObjectWithId {
@@ -101,4 +113,10 @@ export interface WorkflowAction extends ObjectWithId {
webhook?: WorkflowActionWebhook webhook?: WorkflowActionWebhook
passwords?: string[] passwords?: string[]
ai_suggestion_fields?: AISuggestionField[]
ai_create_missing?: boolean
ai_overwrite_existing?: boolean
} }
@@ -284,6 +284,21 @@ describe(`DocumentService`, () => {
expect(req.request.method).toEqual('POST') expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: ids, documents: ids,
remote_ocr: false,
})
})
it('should request remote OCR when reprocessing with it enabled', () => {
const ids = [1, 2, 3]
subscription = service
.reprocessDocuments({ documents: ids }, true)
.subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/reprocess/`
)
expect(req.request.body).toEqual({
documents: ids,
remote_ocr: true,
}) })
}) })
@@ -349,9 +349,13 @@ export class DocumentService extends AbstractPaperlessService<Document> {
}) })
} }
reprocessDocuments(selection: DocumentSelectionQuery) { reprocessDocuments(
selection: DocumentSelectionQuery,
remoteOcr: boolean = false
) {
return this.http.post(this.getResourceUrl(null, 'reprocess'), { return this.http.post(this.getResourceUrl(null, 'reprocess'), {
...selection, ...selection,
remote_ocr: remoteOcr,
}) })
} }
@@ -13,6 +13,7 @@ import { environment } from 'src/environments/environment'
import { CustomFieldDataType } from '../data/custom-field' import { CustomFieldDataType } from '../data/custom-field'
import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document' import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
import { SavedView } from '../data/saved-view' import { SavedView } from '../data/saved-view'
import { RemoteOCRModeConfig } from '../data/paperless-config'
import { SETTINGS_KEYS, UiSettings } from '../data/ui-settings' import { SETTINGS_KEYS, UiSettings } from '../data/ui-settings'
import { PermissionsService } from './permissions.service' import { PermissionsService } from './permissions.service'
import { CustomFieldsService } from './rest/custom-fields.service' import { CustomFieldsService } from './rest/custom-fields.service'
@@ -434,4 +435,26 @@ describe('SettingsService', () => {
).name ).name
).toEqual(customFields[0].name) ).toEqual(customFields[0].name)
}) })
it('should offer remote OCR only when configured and selective', () => {
settingsService.set(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED, false)
settingsService.set(
SETTINGS_KEYS.REMOTE_OCR_MODE,
RemoteOCRModeConfig.WORKFLOW_ONLY
)
expect(settingsService.remoteOCRIsSelectable).toBeFalsy()
// configured, but already handling every document
settingsService.set(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED, true)
settingsService.set(
SETTINGS_KEYS.REMOTE_OCR_MODE,
RemoteOCRModeConfig.ALWAYS
)
expect(settingsService.remoteOCRIsSelectable).toBeFalsy()
settingsService.set(
SETTINGS_KEYS.REMOTE_OCR_MODE,
RemoteOCRModeConfig.WORKFLOW_ONLY
)
expect(settingsService.remoteOCRIsSelectable).toBeTruthy()
})
}) })
@@ -19,6 +19,7 @@ import {
} from 'src/app/utils/color' } from 'src/app/utils/color'
import { DEFAULT_APP_TITLE, environment } from 'src/environments/environment' import { DEFAULT_APP_TITLE, environment } from 'src/environments/environment'
import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document' import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
import { RemoteOCRModeConfig } from '../data/paperless-config'
import { SavedView } from '../data/saved-view' import { SavedView } from '../data/saved-view'
import { import {
PAPERLESS_GREEN_HEX, PAPERLESS_GREEN_HEX,
@@ -687,6 +688,17 @@ export class SettingsService {
return this.settingIsSet(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED) return this.settingIsSet(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED)
} }
/**
* Offering remote OCR as a choice only makes sense when an engine
* is configured but is not already handling every document.
*/
get remoteOCRIsSelectable(): boolean {
return (
this.get(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED) &&
this.get(SETTINGS_KEYS.REMOTE_OCR_MODE) !== RemoteOCRModeConfig.ALWAYS
)
}
offerTour(): boolean { offerTour(): boolean {
return this.dashboardIsEmpty() && !this.get(SETTINGS_KEYS.TOUR_COMPLETE) return this.dashboardIsEmpty() && !this.get(SETTINGS_KEYS.TOUR_COMPLETE)
} }
+29
View File
@@ -15,6 +15,35 @@ describe('text search utilities', () => {
expect(matchesSearchText('taxes 2026', 'tax receipt')).toBeFalsy() 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', () => { it('matches a large set of tag names without blocking input', () => {
const tagNames = Array.from( const tagNames = Array.from(
{ length: 1280 }, { length: 1280 },
+34 -9
View File
@@ -3,13 +3,18 @@ import { diacritics } from 'normalize-diacritics/diacritics'
export type SearchTextValue = export type SearchTextValue =
string | number | boolean | bigint | null | undefined string | number | boolean | bigint | null | undefined
const NON_ASCII = /[^\x00-\x7F]/
const SEPARATORS = /[^\p{L}\p{N}]+/u
export function normalizeSearchText(value: SearchTextValue): string { export function normalizeSearchText(value: SearchTextValue): string {
const normalized = diacritics.reduce( const text = String(value ?? '')
(text, replacement) => {
return text.replace(replacement.diacritics, replacement.letter) // Nothing in the table matches ASCII, so skip normaliation
}, if (!NON_ASCII.test(text)) return text.toLocaleLowerCase()
String(value ?? '')
) const normalized = diacritics.reduce((text, replacement) => {
return text.replace(replacement.diacritics, replacement.letter)
}, text)
return normalized.toLocaleLowerCase() return normalized.toLocaleLowerCase()
} }
@@ -18,8 +23,28 @@ export function matchesSearchText(
value: SearchTextValue, value: SearchTextValue,
searchText: SearchTextValue searchText: SearchTextValue
): boolean { ): boolean {
const normalizedValue = normalizeSearchText(value) const query = normalizeSearchText(searchText)
const searchTerms = normalizeSearchText(searchText).trim().split(/\s+/) 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 apiVersion: '10', // match src/paperless/settings.py
appTitle: DEFAULT_APP_TITLE, appTitle: DEFAULT_APP_TITLE,
tag: 'prod', tag: 'prod',
version: '3.0.5', version: '3.1.0',
webSocketHost: window.location.host, webSocketHost: window.location.host,
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:', webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
webSocketBaseUrl: base_url.pathname + '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
+2
View File
@@ -118,6 +118,7 @@ import {
journalBookmarkFill, journalBookmarkFill,
journals, journals,
link, link,
list,
listNested, listNested,
listTask, listTask,
listUl, listUl,
@@ -365,6 +366,7 @@ const icons = {
journalBookmarkFill, journalBookmarkFill,
journals, journals,
link, link,
list,
listNested, listNested,
listTask, listTask,
listUl, listUl,
+1
View File
@@ -536,6 +536,7 @@ ul.pagination {
} }
.nav-underline { .nav-underline {
gap: calc(0.5 * var(--bs-nav-underline-gap)) var(--bs-nav-underline-gap);
.nav-link { .nav-link {
&.active, &:hover, &:focus { &.active, &:hover, &:focus {
color: var(--bs-primary); color: var(--bs-primary);
+41 -66
View File
@@ -305,74 +305,46 @@ def modify_custom_fields(
else [(field, None) for field in add_custom_fields] else [(field, None) for field in add_custom_fields]
) )
custom_fields_by_id: dict[int, CustomField] = { custom_fields = CustomField.objects.filter(
cf.id: cf id__in=[int(field) for field, _ in add_custom_fields],
for cf in CustomField.objects.filter( ).distinct()
id__in=[int(field) for field, _ in add_custom_fields],
)
}
# Deferred, not `.only()`: signal receivers touch other Document fields,
# and `.only("pk")` would just turn that into a per-document reload.
# `content` is the one field both large and unused here. Skipped
# entirely for a remove-only call -- the removal pass below resolves
# its own documents.
docs_by_id: dict[int, Document] = (
{
doc.id: doc
for doc in Document.objects.filter(id__in=affected_docs).defer("content")
}
if add_custom_fields
else {}
)
for field_id, value in add_custom_fields: for field_id, value in add_custom_fields:
custom_field = custom_fields_by_id[field_id]
value_field = CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[
custom_field.data_type
]
for doc_id in affected_docs: for doc_id in affected_docs:
defaults = {value_field: value} defaults = {}
if ( custom_field = custom_fields.get(id=field_id)
custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK if custom_field:
and value value_field = CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[
and doc_id in value custom_field.data_type
): ]
# Prevent self-linking defaults[value_field] = value
continue if (
# Not update_or_create(): it fetches an existing row via plain custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK
# `.get()` before calling .save(), so a signal receiver touching and value
# `.field`/`.document` (e.g. auditlog) on that save re-fetches and doc_id in value
# per instance regardless of what's passed in as lookup kwargs. ):
# Assigning the cached objects ourselves before .save() avoids # Prevent self-linking
# that for both the create and update case. continue
try: CustomFieldInstance.objects.update_or_create(
instance = CustomFieldInstance.objects.get( document_id=doc_id,
document=docs_by_id[doc_id], field_id=field_id,
field=custom_field, defaults=defaults,
) )
except CustomFieldInstance.DoesNotExist:
instance = CustomFieldInstance(
document=docs_by_id[doc_id],
field=custom_field,
)
instance.document = docs_by_id[doc_id]
instance.field = custom_field
for attr, val in defaults.items():
setattr(instance, attr, val)
instance.save()
if custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK: if custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
reflect_doclinks(docs_by_id[doc_id], custom_field, value) doc = Document.objects.get(id=doc_id)
reflect_doclinks(doc, custom_field, value)
# For doc link fields being removed, remove symmetrical links. # For doc link fields that are being removed, remove symmetrical links
# select_related here avoids resolving every affected document up front.
for doclink_being_removed_instance in CustomFieldInstance.objects.filter( for doclink_being_removed_instance in CustomFieldInstance.objects.filter(
document_id__in=affected_docs, document_id__in=affected_docs,
field__id__in=remove_custom_fields, field__id__in=remove_custom_fields,
field__data_type=CustomField.FieldDataType.DOCUMENTLINK, field__data_type=CustomField.FieldDataType.DOCUMENTLINK,
value_document_ids__isnull=False, value_document_ids__isnull=False,
).select_related("field", "document"): ):
for target_doc_id in doclink_being_removed_instance.value: for target_doc_id in doclink_being_removed_instance.value:
remove_doclink( remove_doclink(
document=doclink_being_removed_instance.document, document=Document.objects.get(
id=doclink_being_removed_instance.document.id,
),
field=doclink_being_removed_instance.field, field=doclink_being_removed_instance.field,
target_doc_id=target_doc_id, target_doc_id=target_doc_id,
) )
@@ -427,10 +399,16 @@ def delete(doc_ids: list[int]) -> Literal["OK"]:
return "OK" return "OK"
def reprocess(doc_ids: list[int]) -> Literal["OK"]: def reprocess(doc_ids: list[int], *, remote_ocr: bool = False) -> Literal["OK"]:
"""
Re-run parsing for the given documents.
Consumption workflows do not run here, so ``remote_ocr`` is how the user
asks for the remote engine when it is not configured to handle everything.
"""
for document_id in doc_ids: for document_id in doc_ids:
update_document_content_maybe_archive_file.apply_async( update_document_content_maybe_archive_file.apply_async(
kwargs={"document_id": document_id}, kwargs={"document_id": document_id, "remote_ocr": remote_ocr},
headers={"trigger_source": PaperlessTask.TriggerSource.MANUAL}, headers={"trigger_source": PaperlessTask.TriggerSource.MANUAL},
) )
@@ -1199,13 +1177,10 @@ def remove_doclink(
""" """
Removes a 'symmetrical' link to `document` from the target document's existing custom field instance Removes a 'symmetrical' link to `document` from the target document's existing custom field instance
""" """
# select_related: a signal receiver (auditlog) touches .document/.field target_doc_field_instance = CustomFieldInstance.objects.filter(
# on save() below -- without this, that's a per-call reload query. document_id=target_doc_id,
target_doc_field_instance = ( field=field,
CustomFieldInstance.objects.filter(document_id=target_doc_id, field=field) ).first()
.select_related("document", "field")
.first()
)
if ( if (
target_doc_field_instance is not None target_doc_field_instance is not None
and document.id in target_doc_field_instance.value and document.id in target_doc_field_instance.value
+18
View File
@@ -53,6 +53,7 @@ from documents.utils import copy_basic_file_stats
from documents.utils import copy_file_with_basic_stats from documents.utils import copy_file_with_basic_stats
from documents.utils import run_subprocess from documents.utils import run_subprocess
from paperless.config import OcrConfig from paperless.config import OcrConfig
from paperless.config import RemoteOCRConfig
from paperless.models import ArchiveFileGenerationChoices from paperless.models import ArchiveFileGenerationChoices
from paperless.parsers import ParserContext from paperless.parsers import ParserContext
from paperless.parsers import ParserProtocol from paperless.parsers import ParserProtocol
@@ -451,12 +452,19 @@ class ConsumerPlugin(
except Exception as e: except Exception as e:
self.log.error(f"Error attempting to clean PDF: {e}") self.log.error(f"Error attempting to clean PDF: {e}")
# Workflows have already run at this point, so the metadata knows
# whether this document was singled out for remote OCR
allow_remote = (
self.metadata.remote_ocr or RemoteOCRConfig().remote_ocr_by_default
)
# Based on the mime type, get the parser for that type # Based on the mime type, get the parser for that type
parser_class: type[ParserProtocol] | None = ( parser_class: type[ParserProtocol] | None = (
get_parser_registry().get_parser_for_file( get_parser_registry().get_parser_for_file(
mime_type, mime_type,
self.filename, self.filename,
self.working_copy, self.working_copy,
allow_remote=allow_remote,
) )
) )
if not parser_class: if not parser_class:
@@ -465,6 +473,16 @@ class ConsumerPlugin(
f"Unsupported mime type {mime_type}", f"Unsupported mime type {mime_type}",
) )
if self.metadata.remote_ocr and not getattr(
parser_class,
"uses_remote_service",
False,
):
self.log.warning(
"Remote OCR was requested for this document but no remote "
"parser is available for it, processing locally instead.",
)
# Notify all listeners that we're going to do some work. # Notify all listeners that we're going to do some work.
document_consumption_started.send( document_consumption_started.send(
+3
View File
@@ -34,6 +34,7 @@ class DocumentMetadataOverrides:
skip_asn_if_exists: bool = False skip_asn_if_exists: bool = False
version_label: str | None = None version_label: str | None = None
actor_id: int | None = None actor_id: int | None = None
remote_ocr: bool = False
def update(self, other: "DocumentMetadataOverrides") -> "DocumentMetadataOverrides": def update(self, other: "DocumentMetadataOverrides") -> "DocumentMetadataOverrides":
""" """
@@ -57,6 +58,8 @@ class DocumentMetadataOverrides:
self.actor_id = other.actor_id self.actor_id = other.actor_id
if other.skip_asn_if_exists: if other.skip_asn_if_exists:
self.skip_asn_if_exists = True self.skip_asn_if_exists = True
if other.remote_ocr:
self.remote_ocr = True
if other.version_label is not None: if other.version_label is not None:
self.version_label = other.version_label self.version_label = other.version_label
@@ -0,0 +1,30 @@
# Generated by Django 5.2.16 on 2026-08-10 17:27
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("documents", "0023_savedview_icon"),
]
operations = [
migrations.AlterField(
model_name="workflowaction",
name="type",
field=models.PositiveSmallIntegerField(
choices=[
(1, "Assignment"),
(2, "Removal"),
(3, "Email"),
(4, "Webhook"),
(5, "Password removal"),
(6, "Move to trash"),
(7, "Remote OCR"),
],
default=1,
verbose_name="Workflow Action Type",
),
),
]
@@ -0,0 +1,84 @@
# Generated by Django 5.2.16 on 2026-08-10 18:26
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("documents", "0024_alter_workflowaction_type"),
]
operations = [
migrations.AddField(
model_name="workflowaction",
name="ai_create_missing",
field=models.BooleanField(
default=False,
help_text="Create suggested tags, correspondents, document types and storage paths that do not already exist instead of skipping them.",
verbose_name="create missing objects",
),
),
migrations.AddField(
model_name="workflowaction",
name="ai_overwrite_existing",
field=models.BooleanField(
default=False,
help_text="Apply suggestions even if the document already has a value for that field. Tags are always added to, never replaced.",
verbose_name="overwrite existing values",
),
),
migrations.AddField(
model_name="workflowaction",
name="ai_suggestion_fields",
field=models.JSONField(
blank=True,
help_text="Which of the AI-suggested fields to apply to the document.",
null=True,
verbose_name="AI suggestion fields",
),
),
migrations.AlterField(
model_name="workflowaction",
name="type",
field=models.PositiveSmallIntegerField(
choices=[
(1, "Assignment"),
(2, "Removal"),
(3, "Email"),
(4, "Webhook"),
(5, "Password removal"),
(6, "Move to trash"),
(7, "Remote OCR"),
(8, "Apply AI suggestions"),
],
default=1,
verbose_name="Workflow Action Type",
),
),
migrations.AlterField(
model_name="paperlesstask",
name="task_type",
field=models.CharField(
choices=[
("consume_file", "Consume File"),
("train_classifier", "Train Classifier"),
("sanity_check", "Sanity Check"),
("index_optimize", "Index Optimize"),
("mail_fetch", "Mail Fetch"),
("llm_index", "LLM Index"),
("empty_trash", "Empty Trash"),
("check_workflows", "Check Workflows"),
("bulk_update", "Bulk Update"),
("reprocess_document", "Reprocess Document"),
("build_share_link", "Build Share Link"),
("bulk_delete", "Bulk Delete"),
("apply_ai_suggestions", "Apply AI Suggestions"),
],
db_index=True,
help_text="The kind of work being performed",
max_length=50,
verbose_name="Task Type",
),
),
]

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