mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-31 06:57:16 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74abdf8ff8 | ||
|
|
ce5d5c33b0 | ||
|
|
d9d44520bd | ||
|
|
500ff563cf | ||
|
|
7f314b5149 | ||
|
|
132918821d | ||
|
|
512a3fe196 | ||
|
|
b01e0368b7 | ||
|
|
1c82bd15c5 | ||
|
|
cbac71c165 | ||
|
|
8780bcd5c7 | ||
|
|
9d2416c435 | ||
|
|
a98d0669e4 | ||
|
|
bda506968b | ||
|
|
4ccb34a70b | ||
|
|
0a466c9fcf | ||
|
|
c9cc4f427d |
+1
-4
@@ -59,13 +59,10 @@ The following are not generally considered vulnerabilities unless accompanied by
|
|||||||
- large uploads or resource usage that do not bypass documented limits or privileges
|
- large uploads or resource usage that do not bypass documented limits or privileges
|
||||||
- IDOR / access control claims regarding the ability to attach an un-viewable object to a document. This is expected behavior.
|
- IDOR / access control claims regarding the ability to attach an un-viewable object to a document. This is expected behavior.
|
||||||
- claims based solely on the presence of a library, framework feature or code pattern without a working exploit
|
- claims based solely on the presence of a library, framework feature or code pattern without a working exploit
|
||||||
- pickle deserialization of internal data from trusted components such as the Redis-compatible broker or Paperless-ngx data directory
|
- reports that rely on admin-level access, workflow-editing privileges, shell access, or other high-trust roles unless they demonstrate an unintended privilege boundary bypass
|
||||||
- users with permission to edit users granting themselves additional privileges; this is expected behavior for that trusted permission
|
|
||||||
- reports that rely on admin-level access, application-configuration access, workflow-editing privileges, shell access, or other high-trust roles unless they demonstrate an unintended privilege boundary bypass
|
|
||||||
- optional webhook, mail, AI, OCR, or integration behavior described without a product-level vulnerability
|
- optional webhook, mail, AI, OCR, or integration behavior described without a product-level vulnerability
|
||||||
- missing limits or hardening settings presented without concrete impact
|
- missing limits or hardening settings presented without concrete impact
|
||||||
- generic AI or static-analysis output that is not confirmed against the current codebase and a real deployment scenario
|
- generic AI or static-analysis output that is not confirmed against the current codebase and a real deployment scenario
|
||||||
- metadata names visible in a document's custom storage path, even when the user cannot access the underlying metadata object; this is expected behavior
|
|
||||||
- the ability to attach objects that a user cannot access to a document by ID is an intentional design choice, and not considered a vulnerability
|
- the ability to attach objects that a user cannot access to a document by ID is an intentional design choice, and not considered a vulnerability
|
||||||
|
|
||||||
## Transparency
|
## Transparency
|
||||||
|
|||||||
@@ -1,220 +1,5 @@
|
|||||||
# 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
|
||||||
|
|||||||
@@ -8,12 +8,6 @@ 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`.
|
||||||
@@ -1131,10 +1125,6 @@ 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:
|
||||||
|
|
||||||
|
|||||||
+2
-31
@@ -428,8 +428,8 @@ determine if a user can create, edit, delete or view _any_ documents, but indivi
|
|||||||
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.<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. |
|
| AppConfig | _Change_ or higher permissions grants access to the "Application Configuration" area. |
|
||||||
| 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. |
|
||||||
@@ -667,35 +667,6 @@ The action takes no options, its presence is what enables remote OCR for a match
|
|||||||
If the remote engine is not configured, or does not support the document's file type, the document is
|
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.
|
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/).
|
||||||
|
|||||||
+1
-2
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "paperless-ngx"
|
name = "paperless-ngx"
|
||||||
version = "3.1.0"
|
version = "3.0.5"
|
||||||
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"
|
||||||
@@ -40,7 +40,6 @@ dependencies = [
|
|||||||
"djangorestframework~=3.16",
|
"djangorestframework~=3.16",
|
||||||
"drf-spectacular~=0.30",
|
"drf-spectacular~=0.30",
|
||||||
"drf-spectacular-sidecar~=2026.7.1",
|
"drf-spectacular-sidecar~=2026.7.1",
|
||||||
"drf-writable-nested~=0.7.1",
|
|
||||||
"filelock~=3.32.0",
|
"filelock~=3.32.0",
|
||||||
"flower~=2.0.1",
|
"flower~=2.0.1",
|
||||||
"gotenberg-client~=0.14.0",
|
"gotenberg-client~=0.14.0",
|
||||||
|
|||||||
+198
-291
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paperless-ngx-ui",
|
"name": "paperless-ngx-ui",
|
||||||
"version": "3.1.0",
|
"version": "3.0.5",
|
||||||
"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" autocomplete="off">
|
<form [formGroup]="configForm" (ngSubmit)="saveConfig()" class="pb-4">
|
||||||
|
|
||||||
<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) {
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
@case (ConfigOptionType.String) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
@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.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.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> }
|
@case (ConfigOptionType.Password) { <pngx-input-password [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-password> }
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (option.note) {
|
@if (option.note) {
|
||||||
|
|||||||
@@ -4,19 +4,22 @@
|
|||||||
(click)="closeMobileSearch(); toggleMenuCollapsed()">
|
(click)="closeMobileSearch(); toggleMenuCollapsed()">
|
||||||
<i-bs width="1.5em" height="1.5em" name="list"></i-bs>
|
<i-bs width="1.5em" height="1.5em" name="list"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
<div class="navbar-brand-container order-sm-0">
|
<a class="navbar-brand d-flex align-items-center me-0 ps-md-3 py-0 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>
|
||||||
<pngx-brand-mark class="brand-mark brand-mark-slim d-none"></pngx-brand-mark>
|
<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">
|
||||||
|
<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 {
|
} @else {
|
||||||
@if (customAppLogo) {
|
@if (customAppLogo) {
|
||||||
<img class="brand-logo" [src]="customAppLogo" alt="" />
|
<img class="brand-logo" [src]="customAppLogo" alt="" />
|
||||||
} @else {
|
} @else {
|
||||||
<pngx-brand-mark class="brand-mark"></pngx-brand-mark>
|
<svg class="brand-mark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1.5em" height="1.5em" fill="currentColor">
|
||||||
|
<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>
|
||||||
}
|
}
|
||||||
<div class="brand-copy ms-2 text-truncate" [class.d-md-none]="slimSidebarEnabled">
|
<div class="brand-copy ms-2 text-truncate" [class.d-md-none]="slimSidebarEnabled">
|
||||||
<span class="brand-title text-truncate">{{ appTitle }}</span>
|
<span class="brand-title text-truncate">{{ appTitle }}</span>
|
||||||
@@ -26,7 +29,6 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
|
||||||
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 me-sm-auto order-3 order-sm-1"
|
<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 mx-auto">
|
<div class="col-12 header-search mx-auto">
|
||||||
@@ -39,7 +41,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 me-1 me-md-2" id="userDropdown" ngbDropdownToggle aria-label="User menu" i18n-aria-label>
|
<button class="btn navbar-action border-0" 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}}
|
||||||
@@ -73,7 +75,7 @@
|
|||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
|
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
|
||||||
[ngClass]="slimSidebarEnabled ? 'slim' : 'expanded'" [class.animating]="slimSidebarAnimating()"
|
[ngClass]="slimSidebarEnabled ? 'slim' : 'col-md-3 col-lg-2 col-xxxl-1'" [class.animating]="slimSidebarAnimating()"
|
||||||
[ngbCollapse]="isMenuCollapsed()">
|
[ngbCollapse]="isMenuCollapsed()">
|
||||||
@if (canSaveSettings) {
|
@if (canSaveSettings) {
|
||||||
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()" [aria-label]="slimSidebarEnabled ? 'Expand sidebar' : 'Collapse sidebar'" i18n-aria-label>
|
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()" [aria-label]="slimSidebarEnabled ? 'Expand sidebar' : 'Collapse sidebar'" i18n-aria-label>
|
||||||
@@ -90,7 +92,7 @@
|
|||||||
<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]="!slimSidebarPopoversEnabled" 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 class="nav-link-label"><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>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||||
@@ -99,7 +101,7 @@
|
|||||||
(click)="closeMenu()"
|
(click)="closeMenu()"
|
||||||
ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" 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 class="nav-link-label"><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>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -170,7 +172,7 @@
|
|||||||
[class.text-truncate]="!slimSidebarEnabled" (click)="closeAll()"
|
[class.text-truncate]="!slimSidebarEnabled" (click)="closeAll()"
|
||||||
ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" 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 class="nav-link-label"><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>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
@@ -189,7 +191,7 @@
|
|||||||
[routerLinkActiveOptions]="{ exact: !(slimSidebarEnabled || attributesSectionsCollapsed) }" (click)="closeMenu()"
|
[routerLinkActiveOptions]="{ exact: !(slimSidebarEnabled || attributesSectionsCollapsed) }" (click)="closeMenu()"
|
||||||
ngbPopover="Attributes" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" 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="nav-link-label 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>
|
||||||
@if (!slimSidebarEnabled && canSaveSettings) {
|
@if (!slimSidebarEnabled && canSaveSettings) {
|
||||||
<button
|
<button
|
||||||
@@ -210,27 +212,27 @@
|
|||||||
<ul class="nav flex-column">
|
<ul class="nav flex-column">
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }">
|
||||||
<a class="nav-link" routerLink="attributes/tags" routerLinkActive="active" (click)="closeMenu()">
|
<a class="nav-link" routerLink="attributes/tags" routerLinkActive="active" (click)="closeMenu()">
|
||||||
<i-bs class="me-2" name="tags"></i-bs><span class="nav-link-label"><ng-container i18n>Tags</ng-container></span>
|
<i-bs class="me-2" name="tags"></i-bs><span><ng-container i18n>Tags</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
||||||
<a class="nav-link" routerLink="attributes/correspondents" routerLinkActive="active" (click)="closeMenu()">
|
<a class="nav-link" routerLink="attributes/correspondents" routerLinkActive="active" (click)="closeMenu()">
|
||||||
<i-bs class="me-2" name="person"></i-bs><span class="nav-link-label"><ng-container i18n>Correspondents</ng-container></span>
|
<i-bs class="me-2" name="person"></i-bs><span><ng-container i18n>Correspondents</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
|
||||||
<a class="nav-link" routerLink="attributes/documenttypes" routerLinkActive="active" (click)="closeMenu()">
|
<a class="nav-link" routerLink="attributes/documenttypes" routerLinkActive="active" (click)="closeMenu()">
|
||||||
<i-bs class="me-2" name="hash"></i-bs><span class="nav-link-label"><ng-container i18n>Document types</ng-container></span>
|
<i-bs class="me-2" name="hash"></i-bs><span><ng-container i18n>Document types</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
|
||||||
<a class="nav-link" routerLink="attributes/storagepaths" routerLinkActive="active" (click)="closeMenu()">
|
<a class="nav-link" routerLink="attributes/storagepaths" routerLinkActive="active" (click)="closeMenu()">
|
||||||
<i-bs class="me-2" name="folder"></i-bs><span class="nav-link-label"><ng-container i18n>Storage paths</ng-container></span>
|
<i-bs class="me-2" name="folder"></i-bs><span><ng-container i18n>Storage paths</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
|
||||||
<a class="nav-link" routerLink="attributes/customfields" routerLinkActive="active" (click)="closeMenu()">
|
<a class="nav-link" routerLink="attributes/customfields" routerLinkActive="active" (click)="closeMenu()">
|
||||||
<i-bs class="me-2" name="ui-radios"></i-bs><span class="nav-link-label"><ng-container i18n>Custom fields</ng-container></span>
|
<i-bs class="me-2" name="ui-radios"></i-bs><span><ng-container i18n>Custom fields</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -241,7 +243,7 @@
|
|||||||
<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]="!slimSidebarPopoversEnabled" 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 class="nav-link-label"><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>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link"
|
<li class="nav-item app-link"
|
||||||
@@ -250,7 +252,7 @@
|
|||||||
<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]="!slimSidebarPopoversEnabled" 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 class="nav-link-label"><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>
|
||||||
</li>
|
</li>
|
||||||
<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 }"
|
||||||
@@ -258,14 +260,14 @@
|
|||||||
<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]="!slimSidebarPopoversEnabled" 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 class="nav-link-label"><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]="!slimSidebarPopoversEnabled" 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 class="nav-link-label"><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>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -281,21 +283,21 @@
|
|||||||
<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]="!slimSidebarPopoversEnabled" 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 class="nav-link-label"><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]="!slimSidebarPopoversEnabled" 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 class="nav-link-label"><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]="!slimSidebarPopoversEnabled" 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 class="nav-link-label"><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>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link"
|
<li class="nav-item app-link"
|
||||||
@@ -304,10 +306,9 @@
|
|||||||
<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]="!slimSidebarPopoversEnabled" 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 class="nav-link-label"><ng-container i18n>Tasks</ng-container></span>
|
<i-bs class="me-2" name="list-task"></i-bs><span><ng-container i18n>Tasks</ng-container>@if (tasksService.needsAttentionTasks.length > 0) {
|
||||||
@if (tasksService.needsAttentionTasks.length > 0 && !slimSidebarEnabled) {
|
<span><span class="badge bg-danger ms-2 d-inline">{{tasksService.needsAttentionTasks.length}}</span></span>
|
||||||
<span class="badge bg-danger ms-2 d-inline flex-shrink-0">{{tasksService.needsAttentionTasks.length}}</span>
|
}</span>
|
||||||
}
|
|
||||||
@if (tasksService.needsAttentionTasks.length > 0 && slimSidebarEnabled) {
|
@if (tasksService.needsAttentionTasks.length > 0 && slimSidebarEnabled) {
|
||||||
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.needsAttentionTasks.length}}</span>
|
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.needsAttentionTasks.length}}</span>
|
||||||
}
|
}
|
||||||
@@ -318,7 +319,7 @@
|
|||||||
<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]="!slimSidebarPopoversEnabled" 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 class="nav-link-label"><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>
|
||||||
}
|
}
|
||||||
@@ -389,7 +390,7 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main role="main" class="ms-sm-auto px-md-4" [class.mobile-search-hidden]="mobileSearchHidden()"
|
<main role="main" class="ms-sm-auto px-md-4" [class.mobile-search-hidden]="mobileSearchHidden()"
|
||||||
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-sidebar-expanded'">
|
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-md-9 col-lg-10 col-xxxl-11'">
|
||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
border-right: 1px solid color-mix(in srgb, var(--bs-border-color) 65%, transparent);
|
border-right: 1px solid color-mix(in srgb, var(--bs-border-color) 65%, transparent);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
--pngx-sidebar-width: 100%;
|
--pngx-sidebar-width: 100%;
|
||||||
width: var(--pngx-sidebar-width);
|
|
||||||
max-width: var(--pngx-sidebar-width);
|
max-width: var(--pngx-sidebar-width);
|
||||||
transition: all .2s ease;
|
transition: all .2s ease;
|
||||||
|
|
||||||
@@ -33,10 +32,17 @@
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// These come from the col-* classes for non-slim sidebar, needed for animation
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
&.expanded {
|
--pngx-sidebar-width: 25%;
|
||||||
--pngx-sidebar-width: var(--pngx-sidebar-expanded-width);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
--pngx-sidebar-width: 16.66666667%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 2400px) {
|
||||||
|
--pngx-sidebar-width: 8.33333333%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 767.98px) {
|
@media (max-width: 767.98px) {
|
||||||
@@ -103,16 +109,6 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media(min-width: 768px) {
|
@media(min-width: 768px) {
|
||||||
// hide scrollbars on browsers that take up layout width
|
|
||||||
// :host-context since <html> is outside the component
|
|
||||||
:host-context(.pngx-classic-scrollbars) .sidebar.slim {
|
|
||||||
scrollbar-width: none;
|
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar.slim {
|
.sidebar.slim {
|
||||||
max-width: 55px;
|
max-width: 55px;
|
||||||
|
|
||||||
@@ -129,19 +125,6 @@ main {
|
|||||||
.sidebar-heading span {
|
.sidebar-heading span {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link,
|
|
||||||
.nav-anchor {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding-left: 0;
|
|
||||||
padding-right: 0;
|
|
||||||
|
|
||||||
i-bs {
|
|
||||||
margin-right: 0 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.slim:not(.animating) ~ main.col-slim {
|
.sidebar.slim:not(.animating) ~ main.col-slim {
|
||||||
@@ -254,26 +237,6 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar .nav-link:has(> .nav-link-label) {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
min-width: 0;
|
|
||||||
|
|
||||||
> i-bs {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
> .nav-link-label {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar:not(.slim):not(.animating) .nav-link > .nav-link-label {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar .nav-anchor, .sidebar .nav-label {
|
.sidebar .nav-anchor, .sidebar .nav-label {
|
||||||
padding: .25rem .7rem;
|
padding: .25rem .7rem;
|
||||||
}
|
}
|
||||||
@@ -399,7 +362,6 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
::ng-deep .navbar-official-logo {
|
::ng-deep .navbar-official-logo {
|
||||||
will-change: filter; // prevent resizing the filter region on hover and nudging the logo a pixel
|
|
||||||
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;
|
||||||
|
|
||||||
@@ -412,8 +374,6 @@ main {
|
|||||||
width: 1.65rem;
|
width: 1.65rem;
|
||||||
height: 1.65rem;
|
height: 1.65rem;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
will-change: filter; // prevent resizing the filter region on hover and nudging the logo a pixel
|
|
||||||
filter: drop-shadow(0 2px 3px rgba(var(--pngx-navbar-brand-shadow-rgb), 0));
|
|
||||||
transition: filter .15s ease-in-out;
|
transition: filter .15s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,8 +406,6 @@ main {
|
|||||||
max-width: 5rem;
|
max-width: 5rem;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
will-change: filter; // prevent resizing the filter region on hover and nudging the logo a pixel
|
|
||||||
filter: drop-shadow(0 2px 3px rgba(var(--pngx-navbar-brand-shadow-rgb), 0));
|
|
||||||
transition: filter .15s ease-in-out, transform .15s ease-in-out;
|
transition: filter .15s ease-in-out, transform .15s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,7 +417,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) 85%, var(--bs-primary)) !important;
|
fill: color-mix(in srgb, var(--pngx-primary-text-contrast) 70%, var(--bs-primary)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text {
|
.text {
|
||||||
@@ -478,19 +436,6 @@ 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;
|
||||||
@@ -531,7 +476,6 @@ main {
|
|||||||
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;
|
||||||
|
|||||||
@@ -543,27 +543,6 @@ describe('AppFrameComponent', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should only flag scrollbars that take up layout width', () => {
|
|
||||||
const offsetWidth = jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get')
|
|
||||||
jest.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(100)
|
|
||||||
|
|
||||||
offsetWidth.mockReturnValue(115)
|
|
||||||
component['detectClassicScrollbars']()
|
|
||||||
expect(
|
|
||||||
window.document.documentElement.classList.contains(
|
|
||||||
'pngx-classic-scrollbars'
|
|
||||||
)
|
|
||||||
).toBeTruthy()
|
|
||||||
|
|
||||||
offsetWidth.mockReturnValue(100)
|
|
||||||
component['detectClassicScrollbars']()
|
|
||||||
expect(
|
|
||||||
window.document.documentElement.classList.contains(
|
|
||||||
'pngx-classic-scrollbars'
|
|
||||||
)
|
|
||||||
).toBeFalsy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should collapse attributes sections when enabling slim sidebar', () => {
|
it('should collapse attributes sections when enabling slim sidebar', () => {
|
||||||
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
|
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
|
||||||
settingsService.set(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED, [])
|
settingsService.set(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED, [])
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ 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'
|
||||||
@@ -62,7 +61,6 @@ const SCROLL_THRESHOLD = 16
|
|||||||
imports: [
|
imports: [
|
||||||
GlobalSearchComponent,
|
GlobalSearchComponent,
|
||||||
LogoComponent,
|
LogoComponent,
|
||||||
BrandMarkComponent,
|
|
||||||
DocumentTitlePipe,
|
DocumentTitlePipe,
|
||||||
IfPermissionsDirective,
|
IfPermissionsDirective,
|
||||||
ToastsDropdownComponent,
|
ToastsDropdownComponent,
|
||||||
@@ -118,7 +116,6 @@ export class AppFrameComponent
|
|||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.lastScrollY = window.scrollY
|
this.lastScrollY = window.scrollY
|
||||||
this.detectClassicScrollbars()
|
|
||||||
|
|
||||||
if (this.settingsService.get(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED)) {
|
if (this.settingsService.get(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED)) {
|
||||||
this.checkForUpdates()
|
this.checkForUpdates()
|
||||||
@@ -344,22 +341,6 @@ export class AppFrameComponent
|
|||||||
this.lastScrollY = currentScrollY
|
this.lastScrollY = currentScrollY
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Flag for browsers whose scrollbars take up layout width. Remove me
|
|
||||||
* some day, I hope.
|
|
||||||
*/
|
|
||||||
private detectClassicScrollbars(): void {
|
|
||||||
const probe = document.createElement('div')
|
|
||||||
probe.style.cssText =
|
|
||||||
'position:absolute;top:-9999px;width:100px;height:100px;overflow:scroll'
|
|
||||||
document.body.appendChild(probe)
|
|
||||||
document.documentElement.classList.toggle(
|
|
||||||
'pngx-classic-scrollbars',
|
|
||||||
probe.offsetWidth > probe.clientWidth
|
|
||||||
)
|
|
||||||
probe.remove()
|
|
||||||
}
|
|
||||||
|
|
||||||
private isMobileViewport(): boolean {
|
private isMobileViewport(): boolean {
|
||||||
return window.innerWidth < 768
|
return window.innerWidth < 768
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
[ngModel]="query()"
|
[ngModel]="query()"
|
||||||
(ngModelChange)="onQueryChange($event)"
|
(ngModelChange)="queryDebounce.next($event)"
|
||||||
(keydown)="searchInputKeyDown($event)"
|
(keydown)="searchInputKeyDown($event)"
|
||||||
ngbDropdownAnchor>
|
ngbDropdownAnchor>
|
||||||
<div class="position-absolute top-50 end-0 translate-middle">
|
<div class="position-absolute top-50 end-0 translate-middle">
|
||||||
|
|||||||
@@ -272,19 +272,6 @@ describe('GlobalSearchComponent', () => {
|
|||||||
expect(advancedSearchSpy).toHaveBeenCalled()
|
expect(advancedSearchSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should set query immediately and run full search on enter without waiting for debounce', () => {
|
|
||||||
jest.useFakeTimers()
|
|
||||||
const searchSpy = jest.spyOn(searchService, 'globalSearch')
|
|
||||||
searchSpy.mockReturnValue(of({} as any))
|
|
||||||
const fullSearchSpy = jest.spyOn(component, 'runFullSearch')
|
|
||||||
component.onQueryChange('test')
|
|
||||||
expect(component.query()).toBe('test')
|
|
||||||
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
|
|
||||||
expect(fullSearchSpy).toHaveBeenCalled()
|
|
||||||
expect(searchSpy).not.toHaveBeenCalled()
|
|
||||||
jest.useRealTimers()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should search on query debounce', () => {
|
it('should search on query debounce', () => {
|
||||||
jest.useFakeTimers()
|
jest.useFakeTimers()
|
||||||
const query = 'test'
|
const query = 'test'
|
||||||
|
|||||||
@@ -119,12 +119,6 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public onQueryChange(text: string) {
|
|
||||||
// set immediately so Enter / the full search button work without waiting for the debounce
|
|
||||||
this.query.set(text)
|
|
||||||
this.queryDebounce.next(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
public ngOnInit() {
|
public ngOnInit() {
|
||||||
this.hotkeyService
|
this.hotkeyService
|
||||||
.addShortcut({ keys: '/', description: $localize`Global search` })
|
.addShortcut({ keys: '/', description: $localize`Global search` })
|
||||||
|
|||||||
-1
@@ -94,7 +94,6 @@ export class MailAccountEditDialogComponent extends EditDialogComponent<MailAcco
|
|||||||
this.testActive = false
|
this.testActive = false
|
||||||
this.testResult.set('danger')
|
this.testResult.set('danger')
|
||||||
this.alertTimeout = setTimeout(() => this.testResultAlert.close(), 5000)
|
this.alertTimeout = setTimeout(() => this.testResultAlert.close(), 5000)
|
||||||
this.error = e.error
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
-39
@@ -462,45 +462,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|||||||
+2
-124
@@ -22,7 +22,6 @@ 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'
|
||||||
@@ -50,7 +49,6 @@ 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,
|
||||||
@@ -241,15 +239,14 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
SCHEDULE_DATE_FIELD_OPTIONS
|
SCHEDULE_DATE_FIELD_OPTIONS
|
||||||
)
|
)
|
||||||
|
|
||||||
// Email, remote OCR and AI all disabled
|
// Email 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(
|
WORKFLOW_ACTION_OPTIONS.filter(
|
||||||
(a) =>
|
(a) =>
|
||||||
a.id !== WorkflowActionType.Email &&
|
a.id !== WorkflowActionType.Email &&
|
||||||
a.id !== WorkflowActionType.RemoteOcr &&
|
a.id !== WorkflowActionType.RemoteOcr
|
||||||
a.id !== WorkflowActionType.ApplyAiSuggestions
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -347,125 +344,6 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
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()
|
||||||
|
|||||||
-62
@@ -30,7 +30,6 @@ 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'
|
||||||
@@ -153,37 +152,6 @@ export const WORKFLOW_ACTION_OPTIONS = [
|
|||||||
id: WorkflowActionType.RemoteOcr,
|
id: WorkflowActionType.RemoteOcr,
|
||||||
name: $localize`Remote OCR`,
|
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 {
|
||||||
@@ -608,24 +576,6 @@ export class WorkflowEditDialogComponent
|
|||||||
allowed = allowed.filter((a) => a.id !== WorkflowActionType.RemoteOcr)
|
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 (
|
if (
|
||||||
this.allowedActionTypes?.length === allowed.length &&
|
this.allowedActionTypes?.length === allowed.length &&
|
||||||
this.allowedActionTypes.every((a, i) => a.id === allowed[i].id)
|
this.allowedActionTypes.every((a, i) => a.id === allowed[i].id)
|
||||||
@@ -1277,11 +1227,6 @@ 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 }
|
||||||
)
|
)
|
||||||
@@ -1371,10 +1316,6 @@ export class WorkflowEditDialogComponent
|
|||||||
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)
|
||||||
@@ -1428,9 +1369,6 @@ 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)
|
||||||
|
|||||||
@@ -47,8 +47,8 @@
|
|||||||
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-1"></i-bs><span>{{document.title}}</span>
|
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-1"></i-bs><span>{{document.title}}</span>
|
||||||
</a>
|
</a>
|
||||||
} @else {
|
} @else {
|
||||||
<span class="badge bg-light text-muted">
|
<span class="badge bg-light text-muted" (click)="unselect(document)" (mousedown)="$event.stopImmediatePropagation()" type="button" title="Remove link" i18n-title>
|
||||||
<i-bs width="0.9em" height="0.9em" name="exclamation-triangle-fill" class="me-1"></i-bs><span i18n>Unavailable</span>
|
<i-bs width="0.9em" height="0.9em" name="exclamation-triangle-fill" class="me-1"></i-bs><span i18n>Not found</span>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -151,23 +151,6 @@ describe('DocumentLinkComponent', () => {
|
|||||||
expect(component.selectedDocuments).toEqual([])
|
expect(component.selectedDocuments).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should preserve and neutrally label unavailable document IDs', async () => {
|
|
||||||
jest.spyOn(documentService, 'getFew').mockReturnValue(
|
|
||||||
of({
|
|
||||||
count: 0,
|
|
||||||
all: [],
|
|
||||||
results: [],
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
component.writeValue([99])
|
|
||||||
await fixture.whenStable()
|
|
||||||
|
|
||||||
expect(component.selectedDocuments).toEqual([{ id: 99 }])
|
|
||||||
expect(fixture.nativeElement.textContent).toContain('Unavailable')
|
|
||||||
expect(fixture.nativeElement.textContent).not.toContain('Not found')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should support unselect', () => {
|
it('should support unselect', () => {
|
||||||
const getSpy = jest.spyOn(documentService, 'getFew')
|
const getSpy = jest.spyOn(documentService, 'getFew')
|
||||||
getSpy.mockImplementation((ids) => {
|
getSpy.mockImplementation((ids) => {
|
||||||
@@ -184,15 +167,6 @@ describe('DocumentLinkComponent', () => {
|
|||||||
expect(component.selectedDocuments).toEqual([documents[1]])
|
expect(component.selectedDocuments).toEqual([documents[1]])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not unselect documents when disabled', () => {
|
|
||||||
component.disabled = true
|
|
||||||
component.selectedDocuments = [documents[0]]
|
|
||||||
|
|
||||||
component.unselect(documents[0])
|
|
||||||
|
|
||||||
expect(component.selectedDocuments).toEqual([documents[0]])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should use correct compare, trackBy functions', () => {
|
it('should use correct compare, trackBy functions', () => {
|
||||||
expect(component.compareDocuments(documents[0], { id: 1 })).toBeTruthy()
|
expect(component.compareDocuments(documents[0], { id: 1 })).toBeTruthy()
|
||||||
expect(component.compareDocuments(documents[0], { id: 2 })).toBeFalsy()
|
expect(component.compareDocuments(documents[0], { id: 2 })).toBeFalsy()
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export class DocumentLinkComponent
|
|||||||
.subscribe((documentResults) => {
|
.subscribe((documentResults) => {
|
||||||
this.loading.set(false)
|
this.loading.set(false)
|
||||||
this.selectedDocuments = documentIDs.map(
|
this.selectedDocuments = documentIDs.map(
|
||||||
(id) => documentResults.results.find((d) => d.id === id) ?? { id }
|
(id) => documentResults.results.find((d) => d.id === id) ?? {}
|
||||||
)
|
)
|
||||||
super.writeValue(documentIDs)
|
super.writeValue(documentIDs)
|
||||||
})
|
})
|
||||||
@@ -142,8 +142,6 @@ export class DocumentLinkComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
unselect(document: Document): void {
|
unselect(document: Document): void {
|
||||||
if (this.disabled) return
|
|
||||||
|
|
||||||
this.selectedDocuments = this.selectedDocuments.filter(
|
this.selectedDocuments = this.selectedDocuments.filter(
|
||||||
(d) => d && d.id !== document.id
|
(d) => d && d.id !== document.id
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -40,10 +40,6 @@ 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 = 'off'
|
autocomplete: string
|
||||||
|
|
||||||
public textVisible: boolean = false
|
public textVisible: boolean = false
|
||||||
|
|
||||||
|
|||||||
@@ -36,16 +36,11 @@
|
|||||||
(focus)="clearLastSearchTerm()"
|
(focus)="clearLastSearchTerm()"
|
||||||
(clear)="clearLastSearchTerm()"
|
(clear)="clearLastSearchTerm()"
|
||||||
(blur)="onBlur()">
|
(blur)="onBlur()">
|
||||||
<ng-template ng-label-tmp let-item="item" let-clear="clear">
|
<ng-template ng-label-tmp let-item="item">
|
||||||
@if (multiple && !disabled) {
|
|
||||||
<span class="ng-value-icon left" role="button" tabindex="0" (click)="clear(item)" (keydown.enter)="clear(item)" aria-label="Remove item" i18n-aria-label>×</span>
|
|
||||||
}
|
|
||||||
<span class="ng-value-label" [title]="item[bindLabel]">
|
|
||||||
@if (iconField && item[iconField]) {
|
@if (iconField && item[iconField]) {
|
||||||
<i-bs class="me-2" [name]="item[iconField]"></i-bs>
|
<i-bs class="me-2" [name]="item[iconField]"></i-bs>
|
||||||
}
|
}
|
||||||
{{item[bindLabel]}}
|
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
|
||||||
</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,12 +24,6 @@ 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 = 'off'
|
autocomplete: string
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
placeholder: string = ''
|
placeholder: string = ''
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 545 B |
@@ -1,14 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
+4
-9
@@ -25,34 +25,29 @@
|
|||||||
|
|
||||||
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
|
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
|
||||||
<div class="list-group list-group-flush small pb-0">
|
<div class="list-group list-group-flush small pb-0">
|
||||||
@if (novelSuggestions === 0 && reusableSuggestions === 0) {
|
@if (totalSuggestions === 0) {
|
||||||
<div class="list-group-item text-muted fst-italic">
|
<div class="list-group-item text-muted fst-italic">
|
||||||
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
|
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (suggestions()?.suggested_tags?.length > 0) {
|
@if (suggestions()?.suggested_tags.length > 0) {
|
||||||
<small class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="tags"></i-bs><ng-container i18n>Tags</ng-container></small>
|
<small class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="tags"></i-bs><ng-container i18n>Tags</ng-container></small>
|
||||||
@for (tag of suggestions().suggested_tags; track tag) {
|
@for (tag of suggestions().suggested_tags; track tag) {
|
||||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)">{{ tag }}</button>
|
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)">{{ tag }}</button>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@if (suggestions()?.suggested_document_types?.length > 0) {
|
@if (suggestions()?.suggested_document_types.length > 0) {
|
||||||
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="hash"></i-bs><ng-container i18n>Document Types</ng-container></div>
|
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="hash"></i-bs><ng-container i18n>Document Types</ng-container></div>
|
||||||
@for (type of suggestions().suggested_document_types; track type) {
|
@for (type of suggestions().suggested_document_types; track type) {
|
||||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)">{{ type }}</button>
|
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)">{{ type }}</button>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@if (suggestions()?.suggested_correspondents?.length > 0) {
|
@if (suggestions()?.suggested_correspondents.length > 0) {
|
||||||
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="person"></i-bs><ng-container i18n>Correspondents</ng-container></div>
|
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="person"></i-bs><ng-container i18n>Correspondents</ng-container></div>
|
||||||
@for (correspondent of suggestions().suggested_correspondents; track correspondent) {
|
@for (correspondent of suggestions().suggested_correspondents; track correspondent) {
|
||||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button>
|
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@if (reusableSuggestions > 0) {
|
|
||||||
<div class="list-group-item text-muted fst-italic">
|
|
||||||
<small class="text-muted small fst-italic" i18n>{reusableSuggestions, plural, =1 {1 existing value suggested below} other {{{reusableSuggestions}} existing values suggested below}}</small>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-101
@@ -21,115 +21,15 @@ describe('SuggestionsDropdownComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should exclude suggested storage path names from totalSuggestions', () => {
|
it('should calculate totalSuggestions', () => {
|
||||||
fixture.componentRef.setInput('suggestions', {
|
fixture.componentRef.setInput('suggestions', {
|
||||||
suggested_correspondents: ['John Doe'],
|
suggested_correspondents: ['John Doe'],
|
||||||
suggested_tags: ['Tag1', 'Tag2'],
|
suggested_tags: ['Tag1', 'Tag2'],
|
||||||
suggested_document_types: ['Type1'],
|
suggested_document_types: ['Type1'],
|
||||||
suggested_storage_paths: ['Finance/Invoices'],
|
|
||||||
})
|
})
|
||||||
expect(component.totalSuggestions).toBe(4)
|
expect(component.totalSuggestions).toBe(4)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should count suggestions when a category is absent from the response', () => {
|
|
||||||
fixture.componentRef.setInput('suggestions', {
|
|
||||||
suggested_tags: ['Tag1'],
|
|
||||||
})
|
|
||||||
expect(component.totalSuggestions).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should count reused values the document does not have yet', () => {
|
|
||||||
fixture.componentRef.setInput('suggestions', {
|
|
||||||
tags: [1, 2, 3],
|
|
||||||
correspondents: [10],
|
|
||||||
document_types: [20],
|
|
||||||
suggested_tags: ['NewTag'],
|
|
||||||
})
|
|
||||||
fixture.componentRef.setInput('appliedTags', [2])
|
|
||||||
fixture.componentRef.setInput('appliedDocumentType', 20)
|
|
||||||
|
|
||||||
// tags 1 and 3 are not applied yet, correspondent 10 is not set, tag 2 and
|
|
||||||
// document type 20 already are.
|
|
||||||
expect(component.reusableSuggestions).toBe(3)
|
|
||||||
expect(component.novelSuggestions).toBe(1)
|
|
||||||
expect(component.totalSuggestions).toBe(4)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should not count reused values that are already applied', () => {
|
|
||||||
fixture.componentRef.setInput('suggestions', {
|
|
||||||
tags: [1],
|
|
||||||
correspondents: [10],
|
|
||||||
document_types: [20],
|
|
||||||
})
|
|
||||||
fixture.componentRef.setInput('appliedTags', [1])
|
|
||||||
fixture.componentRef.setInput('appliedCorrespondent', 10)
|
|
||||||
fixture.componentRef.setInput('appliedDocumentType', 20)
|
|
||||||
|
|
||||||
expect(component.totalSuggestions).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should point at the fields when suggestions are all reused', () => {
|
|
||||||
// The dropdown lists only values to create, so a response made entirely of
|
|
||||||
// reused existing objects used to render as "No novel suggestions".
|
|
||||||
fixture.componentRef.setInput('aiEnabled', true)
|
|
||||||
fixture.componentRef.setInput('suggestions', {
|
|
||||||
tags: [1, 2],
|
|
||||||
suggested_tags: [],
|
|
||||||
suggested_correspondents: [],
|
|
||||||
suggested_document_types: [],
|
|
||||||
})
|
|
||||||
fixture.detectChanges()
|
|
||||||
component.clickSuggest()
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
expect(fixture.nativeElement.textContent).toContain(
|
|
||||||
'2 existing values suggested below'
|
|
||||||
)
|
|
||||||
expect(fixture.nativeElement.textContent).not.toContain(
|
|
||||||
'No novel suggestions'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should account for reused values alongside values to create', () => {
|
|
||||||
// The badge counts both, but only the novel names are listed here, so the
|
|
||||||
// dropdown has to say where the rest of the count came from.
|
|
||||||
fixture.componentRef.setInput('aiEnabled', true)
|
|
||||||
fixture.componentRef.setInput('suggestions', {
|
|
||||||
tags: [6, 3],
|
|
||||||
suggested_tags: ['Arbitration', 'New York'],
|
|
||||||
suggested_correspondents: [],
|
|
||||||
suggested_document_types: [],
|
|
||||||
})
|
|
||||||
fixture.detectChanges()
|
|
||||||
component.clickSuggest()
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
expect(component.totalSuggestions).toBe(4)
|
|
||||||
expect(fixture.nativeElement.textContent).toContain('Arbitration')
|
|
||||||
expect(fixture.nativeElement.textContent).toContain(
|
|
||||||
'2 existing values suggested below'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should count classic (non-AI) suggestions, which are ids only', () => {
|
|
||||||
// /api/documents/{id}/suggestions/ returns only id arrays and no
|
|
||||||
// suggested_* keys at all, so every one of its suggestions is a reused
|
|
||||||
// existing object - including storage paths.
|
|
||||||
fixture.componentRef.setInput('suggestions', {
|
|
||||||
correspondents: [4],
|
|
||||||
tags: [6, 3],
|
|
||||||
document_types: [2],
|
|
||||||
storage_paths: [7],
|
|
||||||
dates: ['2005-01-01'],
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(component.novelSuggestions).toBe(0)
|
|
||||||
expect(component.totalSuggestions).toBe(5)
|
|
||||||
|
|
||||||
fixture.componentRef.setInput('appliedStoragePath', 7)
|
|
||||||
expect(component.totalSuggestions).toBe(4)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should show when a completed request returned no suggestions', () => {
|
it('should show when a completed request returned no suggestions', () => {
|
||||||
fixture.componentRef.setInput('suggestions', {
|
fixture.componentRef.setInput('suggestions', {
|
||||||
correspondents: [],
|
correspondents: [],
|
||||||
|
|||||||
+5
-42
@@ -25,11 +25,6 @@ export class SuggestionsDropdownComponent {
|
|||||||
readonly loading = input(false)
|
readonly loading = input(false)
|
||||||
readonly disabled = input(false)
|
readonly disabled = input(false)
|
||||||
|
|
||||||
readonly appliedTags = input<number[]>([])
|
|
||||||
readonly appliedCorrespondent = input<number>(null)
|
|
||||||
readonly appliedDocumentType = input<number>(null)
|
|
||||||
readonly appliedStoragePath = input<number>(null)
|
|
||||||
|
|
||||||
@Output()
|
@Output()
|
||||||
getSuggestions: EventEmitter<SuggestionsDropdownComponent> =
|
getSuggestions: EventEmitter<SuggestionsDropdownComponent> =
|
||||||
new EventEmitter()
|
new EventEmitter()
|
||||||
@@ -59,44 +54,12 @@ export class SuggestionsDropdownComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get novelSuggestions(): number {
|
|
||||||
return (
|
|
||||||
(this.suggestions()?.suggested_correspondents?.length ?? 0) +
|
|
||||||
(this.suggestions()?.suggested_tags?.length ?? 0) +
|
|
||||||
(this.suggestions()?.suggested_document_types?.length ?? 0)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
get reusableSuggestions(): number {
|
|
||||||
const correspondent = this.appliedCorrespondent()
|
|
||||||
const documentType = this.appliedDocumentType()
|
|
||||||
const storagePath = this.appliedStoragePath()
|
|
||||||
// Storage paths count here but not in novelSuggestions: an existing one
|
|
||||||
// can be applied from the field, a suggested name cannot create one.
|
|
||||||
return (
|
|
||||||
this.countUnapplied(this.suggestions()?.tags, this.appliedTags()) +
|
|
||||||
this.countUnapplied(
|
|
||||||
this.suggestions()?.correspondents,
|
|
||||||
correspondent ? [correspondent] : []
|
|
||||||
) +
|
|
||||||
this.countUnapplied(
|
|
||||||
this.suggestions()?.document_types,
|
|
||||||
documentType ? [documentType] : []
|
|
||||||
) +
|
|
||||||
this.countUnapplied(
|
|
||||||
this.suggestions()?.storage_paths,
|
|
||||||
storagePath ? [storagePath] : []
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
get totalSuggestions(): number {
|
get totalSuggestions(): number {
|
||||||
return this.novelSuggestions + this.reusableSuggestions
|
return (
|
||||||
}
|
this.suggestions()?.suggested_correspondents?.length +
|
||||||
|
this.suggestions()?.suggested_tags?.length +
|
||||||
private countUnapplied(suggested: number[], applied: number[]): number {
|
this.suggestions()?.suggested_document_types?.length || 0
|
||||||
return (suggested ?? []).filter((id) => !(applied ?? []).includes(id))
|
)
|
||||||
.length
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get noSuggestions(): boolean {
|
get noSuggestions(): boolean {
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
<input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload>
|
<input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload>
|
||||||
</form>
|
</form>
|
||||||
@if (getStatus().length > 0) {
|
@if (getStatus().length > 0) {
|
||||||
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none consumer-status-list" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-sidebar-expanded'">
|
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none consumer-status-list" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-md-3 offset-lg-2'">
|
||||||
<div class="col col-lg-4 col-xl-3 ps-0 pe-0 ps-lg-3 pe-lg-0 pe-auto overflow-y-scroll">
|
<div class="col col-lg-4 col-xl-3 ps-0 pe-0 ps-lg-3 pe-lg-0 pe-auto overflow-y-scroll">
|
||||||
<div class="card shadow-sm consumer-status-card">
|
<div class="card shadow-sm consumer-status-card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|||||||
+7
-15
@@ -1,18 +1,10 @@
|
|||||||
<ngb-alert class="welcome-widget bg-light shadow-sm pe-3 position-relative overflow-hidden" type="primary" [dismissible]="true" (closed)="dismiss.emit(true)">
|
<ngb-alert class="pe-3" type="primary" [dismissible]="true" (closed)="dismiss.emit(true)">
|
||||||
<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>
|
<h4 class="alert-heading"><ng-container i18n>Paperless-ngx is running!</ng-container> 🎉</h4>
|
||||||
<div class="d-flex align-items-start gap-3 gap-md-4 position-relative">
|
|
||||||
<pngx-brand-mark class="d-none d-sm-block flex-shrink-0 text-primary" width="3rem" height="3rem"></pngx-brand-mark>
|
|
||||||
<div class="flex-grow-1">
|
|
||||||
<h4 class="alert-heading text-primary fw-semibold"><ng-container i18n>Paperless-ngx is running!</ng-container> 🎉</h4>
|
|
||||||
<div class="welcome-copy text-dark">
|
|
||||||
<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 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>
|
<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>
|
||||||
</div>
|
<hr>
|
||||||
<hr class="opacity-100">
|
<div class="d-flex align-items-end">
|
||||||
<div class="d-flex flex-column flex-sm-row align-items-sm-center gap-3">
|
<p class="lead fs-6 m-0"><em i18n>Thanks for being a part of the Paperless-ngx community!</em></p>
|
||||||
<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-auto flex-shrink-0" (click)="tourService.start()"><ng-container i18n>Start the tour</ng-container> →</button>
|
||||||
<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> →</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ngb-alert>
|
</ngb-alert>
|
||||||
|
|||||||
-16
@@ -1,16 +0,0 @@
|
|||||||
.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
-2
@@ -1,13 +1,12 @@
|
|||||||
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, BrandMarkComponent],
|
imports: [NgbAlertModule],
|
||||||
})
|
})
|
||||||
export class WelcomeWidgetComponent {
|
export class WelcomeWidgetComponent {
|
||||||
readonly tourService = inject(TourService)
|
readonly tourService = inject(TourService)
|
||||||
|
|||||||
@@ -134,10 +134,6 @@
|
|||||||
[loading]="suggestionsLoading()"
|
[loading]="suggestionsLoading()"
|
||||||
[suggestions]="suggestions()"
|
[suggestions]="suggestions()"
|
||||||
[aiEnabled]="aiEnabled"
|
[aiEnabled]="aiEnabled"
|
||||||
[appliedTags]="documentForm.value.tags"
|
|
||||||
[appliedCorrespondent]="documentForm.value.correspondent"
|
|
||||||
[appliedDocumentType]="documentForm.value.document_type"
|
|
||||||
[appliedStoragePath]="documentForm.value.storage_path"
|
|
||||||
(getSuggestions)="getSuggestions()"
|
(getSuggestions)="getSuggestions()"
|
||||||
(addTag)="createTag($event)"
|
(addTag)="createTag($event)"
|
||||||
(addDocumentType)="createDocumentType($event)"
|
(addDocumentType)="createDocumentType($event)"
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { DeviceDetectorService } from 'ngx-device-detector'
|
import { DeviceDetectorService } from 'ngx-device-detector'
|
||||||
import { Subject, of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import { routes } from 'src/app/app-routing.module'
|
import { routes } from 'src/app/app-routing.module'
|
||||||
import { Correspondent } from 'src/app/data/correspondent'
|
import { Correspondent } from 'src/app/data/correspondent'
|
||||||
import { CustomFieldDataType } from 'src/app/data/custom-field'
|
import { CustomFieldDataType } from 'src/app/data/custom-field'
|
||||||
@@ -1444,26 +1444,6 @@ describe('DocumentDetailComponent', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should reset the suggestions loading state if the document changes mid-request', () => {
|
|
||||||
const getSetting = settingsService.get.bind(settingsService)
|
|
||||||
jest
|
|
||||||
.spyOn(settingsService, 'get')
|
|
||||||
.mockImplementation((key) =>
|
|
||||||
key === SETTINGS_KEYS.AI_ENABLED ? true : getSetting(key)
|
|
||||||
)
|
|
||||||
const pending = new Subject<any>()
|
|
||||||
jest
|
|
||||||
.spyOn(documentService, 'getAiSuggestions')
|
|
||||||
.mockReturnValue(pending.asObservable())
|
|
||||||
initNormally()
|
|
||||||
expect(component.suggestionsLoading()).toBeTruthy()
|
|
||||||
|
|
||||||
// the in-flight request is cancelled, e.g. by a websocket-driven reload
|
|
||||||
component.docChangeNotifier.next(component.documentId())
|
|
||||||
|
|
||||||
expect(component.suggestionsLoading()).toBeFalsy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should show error if needed for get suggestions', () => {
|
it('should show error if needed for get suggestions', () => {
|
||||||
const suggestionsSpy = jest.spyOn(documentService, 'getSuggestions')
|
const suggestionsSpy = jest.spyOn(documentService, 'getSuggestions')
|
||||||
const errorSpy = jest.spyOn(toastService, 'showError')
|
const errorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ import {
|
|||||||
debounceTime,
|
debounceTime,
|
||||||
distinctUntilChanged,
|
distinctUntilChanged,
|
||||||
filter,
|
filter,
|
||||||
finalize,
|
|
||||||
first,
|
first,
|
||||||
map,
|
map,
|
||||||
switchMap,
|
switchMap,
|
||||||
@@ -1017,15 +1016,16 @@ export class DocumentDetailComponent
|
|||||||
.pipe(
|
.pipe(
|
||||||
first(),
|
first(),
|
||||||
takeUntil(this.unsubscribeNotifier),
|
takeUntil(this.unsubscribeNotifier),
|
||||||
takeUntil(this.docChangeNotifier),
|
takeUntil(this.docChangeNotifier)
|
||||||
finalize(() => this.suggestionsLoading.set(false))
|
|
||||||
)
|
)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (result) => {
|
next: (result) => {
|
||||||
this.suggestions.set(result)
|
this.suggestions.set(result)
|
||||||
|
this.suggestionsLoading.set(false)
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.suggestions.set(null)
|
this.suggestions.set(null)
|
||||||
|
this.suggestionsLoading.set(false)
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
$localize`Error retrieving suggestions.`,
|
$localize`Error retrieving suggestions.`,
|
||||||
error
|
error
|
||||||
|
|||||||
@@ -114,23 +114,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div ngbDropdownMenu aria-labelledby="dropdownSend" class="shadow">
|
<div ngbDropdownMenu aria-labelledby="dropdownSend" class="shadow">
|
||||||
@if (permissionService.currentUserCan(PermissionAction.Add, PermissionType.ShareLinkBundle)) {
|
|
||||||
<button ngbDropdownItem (click)="createShareLinkBundle()" [disabled]="!canSendSelection">
|
<button ngbDropdownItem (click)="createShareLinkBundle()" [disabled]="!canSendSelection">
|
||||||
<i-bs name="link" class="me-1"></i-bs><ng-container i18n>Create a share link bundle</ng-container>
|
<i-bs name="link" class="me-1"></i-bs><ng-container i18n>Create a share link bundle</ng-container>
|
||||||
</button>
|
</button>
|
||||||
}
|
|
||||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.ShareLinkBundle)) {
|
|
||||||
<button ngbDropdownItem (click)="manageShareLinkBundles()">
|
<button ngbDropdownItem (click)="manageShareLinkBundles()">
|
||||||
<i-bs name="list-ul" class="me-1"></i-bs><ng-container i18n>Manage share link bundles</ng-container>
|
<i-bs name="list-ul" class="me-1"></i-bs><ng-container i18n>Manage share link bundles</ng-container>
|
||||||
</button>
|
</button>
|
||||||
}
|
|
||||||
@if (
|
|
||||||
emailEnabled &&
|
|
||||||
(permissionService.currentUserCan(PermissionAction.Add, PermissionType.ShareLinkBundle) ||
|
|
||||||
permissionService.currentUserCan(PermissionAction.View, PermissionType.ShareLinkBundle))
|
|
||||||
) {
|
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
}
|
|
||||||
@if (emailEnabled) {
|
@if (emailEnabled) {
|
||||||
<button ngbDropdownItem (click)="emailSelected()" [disabled]="!canSendSelection">
|
<button ngbDropdownItem (click)="emailSelected()" [disabled]="!canSendSelection">
|
||||||
<i-bs name="envelope" class="me-1"></i-bs><ng-container i18n>Email</ng-container>
|
<i-bs name="envelope" class="me-1"></i-bs><ng-container i18n>Email</ng-container>
|
||||||
|
|||||||
@@ -19,11 +19,7 @@ import { StoragePath } from 'src/app/data/storage-path'
|
|||||||
import { Tag } from 'src/app/data/tag'
|
import { Tag } from 'src/app/data/tag'
|
||||||
import { FilterPipe } from 'src/app/pipes/filter.pipe'
|
import { FilterPipe } from 'src/app/pipes/filter.pipe'
|
||||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||||
import {
|
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||||
PermissionAction,
|
|
||||||
PermissionsService,
|
|
||||||
PermissionType,
|
|
||||||
} from 'src/app/services/permissions.service'
|
|
||||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||||
@@ -256,54 +252,6 @@ describe('BulkEditorComponent', () => {
|
|||||||
).toBe(true)
|
).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should only show permitted share link bundle actions', () => {
|
|
||||||
permissionsService.initialize(
|
|
||||||
[
|
|
||||||
permissionsService.getPermissionCode(
|
|
||||||
PermissionAction.Add,
|
|
||||||
PermissionType.ShareLinkBundle
|
|
||||||
),
|
|
||||||
],
|
|
||||||
{ is_superuser: false } as any
|
|
||||||
)
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
expect(fixture.nativeElement.textContent).toContain(
|
|
||||||
'Create a share link bundle'
|
|
||||||
)
|
|
||||||
expect(fixture.nativeElement.textContent).not.toContain(
|
|
||||||
'Manage share link bundles'
|
|
||||||
)
|
|
||||||
|
|
||||||
permissionsService.initialize(
|
|
||||||
[
|
|
||||||
permissionsService.getPermissionCode(
|
|
||||||
PermissionAction.View,
|
|
||||||
PermissionType.ShareLinkBundle
|
|
||||||
),
|
|
||||||
],
|
|
||||||
{ is_superuser: false } as any
|
|
||||||
)
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
expect(fixture.nativeElement.textContent).not.toContain(
|
|
||||||
'Create a share link bundle'
|
|
||||||
)
|
|
||||||
expect(fixture.nativeElement.textContent).toContain(
|
|
||||||
'Manage share link bundles'
|
|
||||||
)
|
|
||||||
|
|
||||||
permissionsService.initialize([], { is_superuser: false } as any)
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
expect(fixture.nativeElement.textContent).not.toContain(
|
|
||||||
'Create a share link bundle'
|
|
||||||
)
|
|
||||||
expect(fixture.nativeElement.textContent).not.toContain(
|
|
||||||
'Manage share link bundles'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should apply selection data to correspondents menu', () => {
|
it('should apply selection data to correspondents menu', () => {
|
||||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export class BulkEditorComponent
|
|||||||
private toastService = inject(ToastService)
|
private toastService = inject(ToastService)
|
||||||
private storagePathService = inject(StoragePathService)
|
private storagePathService = inject(StoragePathService)
|
||||||
private customFieldService = inject(CustomFieldsService)
|
private customFieldService = inject(CustomFieldsService)
|
||||||
public readonly permissionService = inject(PermissionsService)
|
private permissionService = inject(PermissionsService)
|
||||||
private savedViewService = inject(SavedViewService)
|
private savedViewService = inject(SavedViewService)
|
||||||
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
|
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
|
||||||
|
|
||||||
|
|||||||
@@ -8,17 +8,6 @@ export enum WorkflowActionType {
|
|||||||
PasswordRemoval = 5,
|
PasswordRemoval = 5,
|
||||||
MoveToTrash = 6,
|
MoveToTrash = 6,
|
||||||
RemoteOcr = 7,
|
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 {
|
||||||
@@ -113,10 +102,4 @@ 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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,35 +15,6 @@ 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 },
|
||||||
|
|||||||
@@ -3,18 +3,13 @@ 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 text = String(value ?? '')
|
const normalized = diacritics.reduce(
|
||||||
|
(text, replacement) => {
|
||||||
// Nothing in the table matches ASCII, so skip normaliation
|
|
||||||
if (!NON_ASCII.test(text)) return text.toLocaleLowerCase()
|
|
||||||
|
|
||||||
const normalized = diacritics.reduce((text, replacement) => {
|
|
||||||
return text.replace(replacement.diacritics, replacement.letter)
|
return text.replace(replacement.diacritics, replacement.letter)
|
||||||
}, text)
|
},
|
||||||
|
String(value ?? '')
|
||||||
|
)
|
||||||
|
|
||||||
return normalized.toLocaleLowerCase()
|
return normalized.toLocaleLowerCase()
|
||||||
}
|
}
|
||||||
@@ -23,28 +18,8 @@ export function matchesSearchText(
|
|||||||
value: SearchTextValue,
|
value: SearchTextValue,
|
||||||
searchText: SearchTextValue
|
searchText: SearchTextValue
|
||||||
): boolean {
|
): boolean {
|
||||||
const query = normalizeSearchText(searchText)
|
const normalizedValue = normalizeSearchText(value)
|
||||||
const terms = query.split(SEPARATORS).filter(Boolean)
|
const searchTerms = normalizeSearchText(searchText).trim().split(/\s+/)
|
||||||
|
|
||||||
// Empty or punctuation-only query, nothing to split into terms
|
return searchTerms.every((term) => normalizedValue.includes(term))
|
||||||
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
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.1.0',
|
version: '3.0.5',
|
||||||
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/',
|
||||||
|
|||||||
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1136
-2307
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1135
-2306
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1162
-2333
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1325
-2496
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+2378
-3548
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1356
-2526
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1170
-2341
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1135
-2306
File diff suppressed because it is too large
Load Diff
+1136
-2307
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
@@ -12,11 +12,9 @@
|
|||||||
margin-right: 0 !important;
|
margin-right: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main.col-lg-10 {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
width: 100% !important;
|
|
||||||
padding-left: 0 !important;
|
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-10
@@ -52,10 +52,20 @@ $grid-breakpoints: (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 2400px) {
|
||||||
|
.col-xxxl-1 {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 8.33333333%;
|
||||||
|
}
|
||||||
|
.col-xxxl-11 {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width:91.66666667%
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Paperless-ngx styles
|
// Paperless-ngx styles
|
||||||
body {
|
body {
|
||||||
--pngx-body-font-size: 0.875rem;
|
--pngx-body-font-size: 0.875rem;
|
||||||
--pngx-sidebar-expanded-width: clamp(15rem, 16.66666667vw, 20rem);
|
|
||||||
font-size: var(--pngx-body-font-size);
|
font-size: var(--pngx-body-font-size);
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
letter-spacing: -0.005em;
|
letter-spacing: -0.005em;
|
||||||
@@ -97,15 +107,6 @@ body {
|
|||||||
.col-slim {
|
.col-slim {
|
||||||
padding-left: calc(56px + $grid-gutter-width) !important;
|
padding-left: calc(56px + $grid-gutter-width) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.col-sidebar-expanded {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
width: calc(100% - var(--pngx-sidebar-expanded-width));
|
|
||||||
}
|
|
||||||
|
|
||||||
.offset-sidebar-expanded {
|
|
||||||
margin-left: var(--pngx-sidebar-expanded-width);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
svg.logo {
|
svg.logo {
|
||||||
|
|||||||
+36
-19
@@ -27,7 +27,7 @@ from documents.models import DocumentType
|
|||||||
from documents.models import PaperlessTask
|
from documents.models import PaperlessTask
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.permissions import set_permissions_for_object
|
from documents.permissions import set_permissions_for_objects
|
||||||
from documents.plugins.helpers import DocumentsStatusManager
|
from documents.plugins.helpers import DocumentsStatusManager
|
||||||
from documents.tasks import bulk_update_documents
|
from documents.tasks import bulk_update_documents
|
||||||
from documents.tasks import consume_file
|
from documents.tasks import consume_file
|
||||||
@@ -305,18 +305,30 @@ def modify_custom_fields(
|
|||||||
else [(field, None) for field in add_custom_fields]
|
else [(field, None) for field in add_custom_fields]
|
||||||
)
|
)
|
||||||
|
|
||||||
custom_fields = CustomField.objects.filter(
|
custom_fields_by_id: dict[int, CustomField] = {
|
||||||
|
cf.id: cf
|
||||||
|
for cf in CustomField.objects.filter(
|
||||||
id__in=[int(field) for field, _ in add_custom_fields],
|
id__in=[int(field) for field, _ in add_custom_fields],
|
||||||
).distinct()
|
)
|
||||||
|
}
|
||||||
|
# Deferred, not `.only()`: these objects get cached onto the FK
|
||||||
|
# descriptor of newly-created CustomFieldInstance rows below, and
|
||||||
|
# downstream post_save receivers (e.g. the filename-generation signal)
|
||||||
|
# touch other Document fields -- `.only("pk")` would just turn that into
|
||||||
|
# a deferred-field reload per document, trading one N+1 for another.
|
||||||
|
# `content` is the one field guaranteed to be both large (full OCR text)
|
||||||
|
# and unused by anything this function or its receivers touch.
|
||||||
|
docs_by_id: dict[int, Document] = {
|
||||||
|
doc.id: doc
|
||||||
|
for doc in Document.objects.filter(id__in=affected_docs).defer("content")
|
||||||
|
}
|
||||||
for field_id, value in add_custom_fields:
|
for field_id, value in add_custom_fields:
|
||||||
for doc_id in affected_docs:
|
custom_field = custom_fields_by_id[field_id]
|
||||||
defaults = {}
|
|
||||||
custom_field = custom_fields.get(id=field_id)
|
|
||||||
if custom_field:
|
|
||||||
value_field = CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[
|
value_field = CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[
|
||||||
custom_field.data_type
|
custom_field.data_type
|
||||||
]
|
]
|
||||||
defaults[value_field] = value
|
for doc_id in affected_docs:
|
||||||
|
defaults = {value_field: value}
|
||||||
if (
|
if (
|
||||||
custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK
|
custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK
|
||||||
and value
|
and value
|
||||||
@@ -324,14 +336,18 @@ def modify_custom_fields(
|
|||||||
):
|
):
|
||||||
# Prevent self-linking
|
# Prevent self-linking
|
||||||
continue
|
continue
|
||||||
|
# Pass the already-resolved objects, not bare ids: this caches
|
||||||
|
# them on the FK descriptor of any newly-created instance, so a
|
||||||
|
# later `.field`/`.document` access (e.g. auditlog's post_save
|
||||||
|
# receiver calling `str(instance)`, which touches `.field.name`)
|
||||||
|
# doesn't trigger its own per-instance re-fetch.
|
||||||
CustomFieldInstance.objects.update_or_create(
|
CustomFieldInstance.objects.update_or_create(
|
||||||
document_id=doc_id,
|
document=docs_by_id[doc_id],
|
||||||
field_id=field_id,
|
field=custom_field,
|
||||||
defaults=defaults,
|
defaults=defaults,
|
||||||
)
|
)
|
||||||
if custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
|
if custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
|
||||||
doc = Document.objects.get(id=doc_id)
|
reflect_doclinks(docs_by_id[doc_id], custom_field, value)
|
||||||
reflect_doclinks(doc, custom_field, value)
|
|
||||||
|
|
||||||
# For doc link fields that are being removed, remove symmetrical links
|
# For doc link fields that are being removed, remove symmetrical links
|
||||||
for doclink_being_removed_instance in CustomFieldInstance.objects.filter(
|
for doclink_being_removed_instance in CustomFieldInstance.objects.filter(
|
||||||
@@ -339,12 +355,10 @@ def modify_custom_fields(
|
|||||||
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"):
|
||||||
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=Document.objects.get(
|
document=docs_by_id[doclink_being_removed_instance.document_id],
|
||||||
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,
|
||||||
)
|
)
|
||||||
@@ -430,10 +444,13 @@ def set_permissions(
|
|||||||
else:
|
else:
|
||||||
qs.update(owner=owner)
|
qs.update(owner=owner)
|
||||||
|
|
||||||
for doc in qs:
|
|
||||||
set_permissions_for_object(permissions=set_permissions, object=doc, merge=merge)
|
|
||||||
|
|
||||||
affected_docs = list(qs.values_list("pk", flat=True))
|
affected_docs = list(qs.values_list("pk", flat=True))
|
||||||
|
set_permissions_for_objects(
|
||||||
|
permissions=set_permissions,
|
||||||
|
model=Document,
|
||||||
|
pks=affected_docs,
|
||||||
|
merge=merge,
|
||||||
|
)
|
||||||
|
|
||||||
bulk_update_documents.apply_async(
|
bulk_update_documents.apply_async(
|
||||||
kwargs={"document_ids": affected_docs},
|
kwargs={"document_ids": affected_docs},
|
||||||
|
|||||||
+10
-56
@@ -1,9 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import logging
|
import logging
|
||||||
import pickle
|
import pickle
|
||||||
import uuid
|
|
||||||
from binascii import hexlify
|
from binascii import hexlify
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -57,8 +55,6 @@ LLM_CACHE_CLASSIFIER_VERSION: Final[int] = 1001
|
|||||||
CACHE_1_MINUTE: Final[int] = 60
|
CACHE_1_MINUTE: Final[int] = 60
|
||||||
CACHE_5_MINUTES: Final[int] = 5 * CACHE_1_MINUTE
|
CACHE_5_MINUTES: Final[int] = 5 * CACHE_1_MINUTE
|
||||||
CACHE_50_MINUTES: Final[int] = 50 * CACHE_1_MINUTE
|
CACHE_50_MINUTES: Final[int] = 50 * CACHE_1_MINUTE
|
||||||
# Deliberately longer than any entry it names
|
|
||||||
LLM_CACHE_GENERATION_TIMEOUT: Final[int] = 2 * CACHE_50_MINUTES
|
|
||||||
|
|
||||||
read_cache = caches["read-cache"]
|
read_cache = caches["read-cache"]
|
||||||
|
|
||||||
@@ -210,40 +206,12 @@ def refresh_suggestions_cache(
|
|||||||
cache.touch(doc_key, timeout)
|
cache.touch(doc_key, timeout)
|
||||||
|
|
||||||
|
|
||||||
def invalidate_suggestions_cache(document_id: int) -> None:
|
|
||||||
"""Invalidate classifier-generated suggestions for a document."""
|
|
||||||
cache.delete(get_suggestion_cache_key(document_id))
|
|
||||||
|
|
||||||
|
|
||||||
def _llm_generation_key(document_id: int) -> str:
|
|
||||||
return f"{get_suggestion_cache_key(document_id)}_llm_generation"
|
|
||||||
|
|
||||||
|
|
||||||
def _llm_variant_key(document_id: int, backend: str) -> str:
|
|
||||||
"""Cache key for one LLM configuration and permission scope.
|
|
||||||
|
|
||||||
``backend`` identifies the variant - model, endpoint, output language and
|
|
||||||
requesting user.
|
|
||||||
|
|
||||||
Generating the token on first use lets invalidate_llm_suggestions_cache()
|
|
||||||
be no-op for documents that never had AI suggestions.
|
|
||||||
"""
|
|
||||||
generation_key = _llm_generation_key(document_id)
|
|
||||||
generation = cache.get_or_set(
|
|
||||||
generation_key,
|
|
||||||
lambda: uuid.uuid4().hex,
|
|
||||||
timeout=LLM_CACHE_GENERATION_TIMEOUT,
|
|
||||||
)
|
|
||||||
cache.touch(generation_key, LLM_CACHE_GENERATION_TIMEOUT)
|
|
||||||
backend_hash = hashlib.sha256(backend.encode()).hexdigest()[:16]
|
|
||||||
return f"{get_suggestion_cache_key(document_id)}_llm_{generation}_{backend_hash}"
|
|
||||||
|
|
||||||
|
|
||||||
def get_llm_suggestion_cache(
|
def get_llm_suggestion_cache(
|
||||||
document_id: int,
|
document_id: int,
|
||||||
backend: str,
|
backend: str,
|
||||||
) -> SuggestionCacheData | None:
|
) -> SuggestionCacheData | None:
|
||||||
data: SuggestionCacheData = cache.get(_llm_variant_key(document_id, backend))
|
doc_key = get_suggestion_cache_key(document_id)
|
||||||
|
data: SuggestionCacheData = cache.get(doc_key)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
data
|
data
|
||||||
@@ -266,8 +234,9 @@ def set_llm_suggestions_cache(
|
|||||||
Cache LLM-generated suggestions using a backend-specific identifier
|
Cache LLM-generated suggestions using a backend-specific identifier
|
||||||
(e.g. 'openai-like:gpt-4').
|
(e.g. 'openai-like:gpt-4').
|
||||||
"""
|
"""
|
||||||
|
doc_key = get_suggestion_cache_key(document_id)
|
||||||
cache.set(
|
cache.set(
|
||||||
_llm_variant_key(document_id, backend),
|
doc_key,
|
||||||
SuggestionCacheData(
|
SuggestionCacheData(
|
||||||
classifier_version=LLM_CACHE_CLASSIFIER_VERSION,
|
classifier_version=LLM_CACHE_CLASSIFIER_VERSION,
|
||||||
classifier_hash=backend,
|
classifier_hash=backend,
|
||||||
@@ -277,31 +246,17 @@ def set_llm_suggestions_cache(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def refresh_llm_suggestions_cache(
|
|
||||||
document_id: int,
|
|
||||||
backend: str,
|
|
||||||
*,
|
|
||||||
timeout: int = CACHE_50_MINUTES,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Refreshes the expiration of one cached LLM suggestion variant.
|
|
||||||
"""
|
|
||||||
cache.touch(_llm_variant_key(document_id, backend), timeout)
|
|
||||||
|
|
||||||
|
|
||||||
def invalidate_llm_suggestions_cache(
|
def invalidate_llm_suggestions_cache(
|
||||||
document_id: int,
|
document_id: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Invalidate every LLM suggestion variant for a document.
|
Invalidate the LLM suggestions cache for a specific document and backend.
|
||||||
"""
|
"""
|
||||||
generation_key = _llm_generation_key(document_id)
|
doc_key = get_suggestion_cache_key(document_id)
|
||||||
if cache.get(generation_key) is not None:
|
data: SuggestionCacheData = cache.get(doc_key)
|
||||||
cache.set(
|
|
||||||
generation_key,
|
if data:
|
||||||
uuid.uuid4().hex,
|
cache.delete(doc_key)
|
||||||
timeout=LLM_CACHE_GENERATION_TIMEOUT,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_metadata_cache_key(document_id: int) -> str:
|
def get_metadata_cache_key(document_id: int) -> str:
|
||||||
@@ -402,4 +357,3 @@ def clear_document_caches(document_id: int) -> None:
|
|||||||
get_thumbnail_modified_key(document_id),
|
get_thumbnail_modified_key(document_id),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
invalidate_llm_suggestions_cache(document_id)
|
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ class DocumentMetadataOverrides:
|
|||||||
)
|
)
|
||||||
overrides.custom_fields = {
|
overrides.custom_fields = {
|
||||||
custom_field.field.id: custom_field.value
|
custom_field.field.id: custom_field.value
|
||||||
for custom_field in doc.custom_fields.all()
|
for custom_field in doc.custom_fields.select_related("field").all()
|
||||||
}
|
}
|
||||||
|
|
||||||
groups_with_perms = get_groups_with_perms(
|
groups_with_perms = get_groups_with_perms(
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
# 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",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -766,7 +766,6 @@ class PaperlessTask(ModelWithOwner):
|
|||||||
REPROCESS_DOCUMENT = "reprocess_document", _("Reprocess Document")
|
REPROCESS_DOCUMENT = "reprocess_document", _("Reprocess Document")
|
||||||
BUILD_SHARE_LINK = "build_share_link", _("Build Share Link")
|
BUILD_SHARE_LINK = "build_share_link", _("Build Share Link")
|
||||||
BULK_DELETE = "bulk_delete", _("Bulk Delete")
|
BULK_DELETE = "bulk_delete", _("Bulk Delete")
|
||||||
APPLY_AI_SUGGESTIONS = "apply_ai_suggestions", _("Apply AI Suggestions")
|
|
||||||
|
|
||||||
COMPLETE_STATUSES = (
|
COMPLETE_STATUSES = (
|
||||||
Status.SUCCESS,
|
Status.SUCCESS,
|
||||||
@@ -1675,18 +1674,6 @@ class WorkflowAction(models.Model):
|
|||||||
7,
|
7,
|
||||||
_("Remote OCR"),
|
_("Remote OCR"),
|
||||||
)
|
)
|
||||||
APPLY_AI_SUGGESTIONS = (
|
|
||||||
8,
|
|
||||||
_("Apply AI suggestions"),
|
|
||||||
)
|
|
||||||
|
|
||||||
class AISuggestionField(models.TextChoices):
|
|
||||||
TITLE = ("title", _("Title"))
|
|
||||||
TAGS = ("tags", _("Tags"))
|
|
||||||
CORRESPONDENT = ("correspondent", _("Correspondent"))
|
|
||||||
DOCUMENT_TYPE = ("document_type", _("Document type"))
|
|
||||||
STORAGE_PATH = ("storage_path", _("Storage path"))
|
|
||||||
CREATED = ("created", _("Created date"))
|
|
||||||
|
|
||||||
type = models.PositiveSmallIntegerField(
|
type = models.PositiveSmallIntegerField(
|
||||||
_("Workflow Action Type"),
|
_("Workflow Action Type"),
|
||||||
@@ -1925,33 +1912,6 @@ class WorkflowAction(models.Model):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
ai_suggestion_fields = models.JSONField(
|
|
||||||
_("AI suggestion fields"),
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
help_text=_(
|
|
||||||
"Which of the AI-suggested fields to apply to the document.",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
ai_create_missing = models.BooleanField(
|
|
||||||
_("create missing objects"),
|
|
||||||
default=False,
|
|
||||||
help_text=_(
|
|
||||||
"Create suggested tags, correspondents, document types and storage "
|
|
||||||
"paths that do not already exist instead of skipping them.",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
ai_overwrite_existing = models.BooleanField(
|
|
||||||
_("overwrite existing values"),
|
|
||||||
default=False,
|
|
||||||
help_text=_(
|
|
||||||
"Apply suggestions even if the document already has a value for that "
|
|
||||||
"field. Tags are always added to, never replaced.",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("workflow action")
|
verbose_name = _("workflow action")
|
||||||
verbose_name_plural = _("workflow actions")
|
verbose_name_plural = _("workflow actions")
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user