Compare commits

..
Author SHA1 Message Date
shamoon 30e95a6d28 Fix migration 2026-11-13 09:20:47 -08:00
shamoon 8c50a5ee7b Merge branch 'dev' into feature/ocr-templates 2026-11-13 09:09:39 -08:00
Johannes Hagerandshamoon b63ea77543 Enhancement: render document notes as markdown (#14626)
---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-11-13 09:08:20 -08:00
GitHub Actions bfd5628dcc Auto translate strings 2026-11-12 21:29:27 +00:00
shamoon 3dbeca603e Feature: manual workflow trigger (#14704) 2026-11-12 13:28:10 -08:00
Trenton H 62d729e301 Fix: Allow AI chat filters over more than 32700 documents (#14754) 2026-11-12 12:08:59 -08:00
Trenton H a1d76f3a19 Fix: Enforce CRLF when writing an email message to the file (#14745) 2026-11-12 00:18:43 +00:00
GitHub Actions f5ca9bbcb6 Auto translate strings 2026-11-11 23:51:43 +00:00
shamoon 60a35edee1 Enhancement: make bulk metadata operations run async (#14711) 2026-11-11 15:50:15 -08:00
Trenton H 7cdcd71a6d Performance: load a cached Hugging Face embedding model without contacting the Hub (#14728)
Every chat question and AI suggestion request builds the embedding model, and sentence-transformers
checks the Hub for a newer revision each time. With the model already cached that check cost about
2 seconds per request (0.2 seconds without it), and it fails outright when the Hub is unreachable.

Load from the local cache first and only download when the model is missing. A full rebuild still
checks the Hub, so a new model release is picked up together with re-embedding every document
instead of silently mixing revisions.
2026-11-11 21:53:27 +00:00
shamoon 759f9cd22b Fix: restore image without archive version thumbnail generation (#14741) 2026-11-11 10:12:47 -08:00
Trenton H d05e96cda2 Fix: Blacklist certain OCR user arguments that paperless itself controls/uses (#14734) 2026-11-11 03:13:20 +00:00
shamoon fefce7be1b Fix: remove redundant ui text filters (#14736) 2026-11-10 18:06:51 -08:00
GitHub Actions cc65226d6d Auto translate strings 2026-11-11 00:55:05 +00:00
shamoon 00096e0252 Fix: fix allowed_hosts default (#14735) 2026-11-11 00:53:49 +00:00
shamoon 2829e78009 Chore: add agents.md, update contributing guidelines (#14713) 2026-11-10 23:35:41 +00:00
dependabot[bot]andstumpylog d7d4014bbd Chore(deps): Bump the pre-commit-dependencies group with 4 updates (#14636)
* Chore(deps): Bump the pre-commit-dependencies group with 4 updates

Bumps the pre-commit-dependencies group with 4 updates: [https://github.com/rbubley/mirrors-prettier](https://github.com/rbubley/mirrors-prettier), prettier, [https://github.com/astral-sh/ruff-pre-commit](https://github.com/astral-sh/ruff-pre-commit) and [https://github.com/tox-dev/pyproject-fmt](https://github.com/tox-dev/pyproject-fmt).


Updates `https://github.com/rbubley/mirrors-prettier` from v3.9.9 to 3.9.10
- [Commits](https://github.com/rbubley/mirrors-prettier/compare/v3.9.9...v3.9.10)

Updates `prettier` from 3.9.9 to 3.9.10

Updates `https://github.com/astral-sh/ruff-pre-commit` from v0.16.9 to 0.17.2
- [Release notes](https://github.com/astral-sh/ruff-pre-commit/releases)
- [Commits](https://github.com/astral-sh/ruff-pre-commit/compare/v0.16.9...v0.17.2)

Updates `https://github.com/tox-dev/pyproject-fmt` from v2.29.4 to 2.30.3
- [Release notes](https://github.com/tox-dev/pyproject-fmt/releases)
- [Commits](https://github.com/tox-dev/pyproject-fmt/compare/v2.29.4...v2.30.3)

---
updated-dependencies:
- dependency-name: https://github.com/rbubley/mirrors-prettier
  dependency-version: 3.9.10
  dependency-type: direct:production
  dependency-group: pre-commit-dependencies
- dependency-name: prettier
  dependency-version: 3.9.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: pre-commit-dependencies
- dependency-name: https://github.com/astral-sh/ruff-pre-commit
  dependency-version: 0.17.2
  dependency-type: direct:production
  dependency-group: pre-commit-dependencies
- dependency-name: https://github.com/tox-dev/pyproject-fmt
  dependency-version: 2.30.3
  dependency-type: direct:production
  dependency-group: pre-commit-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>

* Linting fixes

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: stumpylog <797416+stumpylog@users.noreply.github.com>
2026-11-10 17:49:06 +00:00
shamoon a139bdff46 Fixhancement: add automatic clearing of sessions (#14722) 2026-11-10 08:09:25 -08:00
shamoon 886761a53b sonar 2026-11-09 09:32:40 -08:00
shamoon 198b165f26 Dont confuse image archive pdf with actual image 2026-11-09 09:21:47 -08:00
shamoon e63f27a712 Crap missed versioning stuff, default to latest but allow specific 2026-11-09 09:19:30 -08:00
shamoon 3ce5f8bc69 Ensure reindex happens after extraction on manual runs 2026-11-09 09:01:20 -08:00
shamoon 58cdd4f19a fix migration 2026-11-09 08:44:09 -08:00
shamoon 5c8c0db08f preview should also require global view doc 2026-11-09 08:43:34 -08:00
shamoon 5c28d8282d Merge branch 'dev' into feature/ocr-templates 2026-11-09 08:40:39 -08:00
GitHub Actions 936b1b3bfa Auto translate strings 2026-11-09 06:53:56 +00:00
shamoon e0c33b3d39 Tweak: chat reference tweaks (#14723) 2026-11-08 22:52:34 -08:00
shamoon 32bc5522e9 Update SECURITY.md 2026-11-07 12:24:29 -08:00
GitHub Actions d5698329f5 Auto translate strings 2026-11-07 20:22:08 +00:00
shamoon 2e0d92d8f5 Fix: two dropdown-related UI fixes (#14717) 2026-11-07 12:20:31 -08:00
shamoon 0736c07d8e Fix: use filtered documents for statistics ASN max (#14710) 2026-11-07 07:52:23 -08:00
shamoon 53d9aa34a1 FIx: make config view permissions consistent (#14709) 2026-11-07 07:52:12 -08:00
GitHub Actions 19fdc3b815 Auto translate strings 2026-11-07 02:45:43 +00:00
5357be2d43 Performance: stop embedding every document ID in selection_data queries (#14597)
* Fix: count selection_data from one pass over the filtered documents

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Chore: use document type ids in the bulk edit selection_data test

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Batch search selection data queries

Co-Authored-By: Codex <267193182+codex@users.noreply.github.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
2026-11-06 18:44:18 -08:00
GitHub Actions 2642cefd35 Auto translate strings 2026-11-06 20:20:35 +00:00
shamoonandCodex 8aa99a381a Performance: replace django-treenode with adjacency-list tags (#14612)
---------

Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
2026-11-06 20:18:59 +00:00
Trenton H d14cfc93f4 Performance: use small vec0 chunks and larger pages for the LLM index (#14678)
The vec0 default of 1024 rows per vector chunk makes each chunk a
multi-megabyte blob. On spinning disks, indexing a document took about
2.5 seconds and re-indexing one about 13 seconds. With 32 rows per
chunk the same operations take about 35 and 180 milliseconds, with the
write-ahead log a third of the size and no cost in query speed or file
size.

Pair that with 64 KiB SQLite pages, which made cold scans about 35%
faster and warm ones about 45% faster at no write cost once the chunks
are small. The page size is set before the file enters WAL mode, so it
applies to new files.
2026-11-06 10:20:03 -08:00
shamoon 53e386ec81 Chore: avoid stale lock from failed apt process on CI (#14701) 2026-11-06 09:29:56 -08:00
shamoon aede2e6a5a Merge branch 'dev' into feature/ocr-templates 2026-11-06 08:46:46 -08:00
shamoon fa792d33c2 Fix icon imports 2026-11-06 08:46:42 -08:00
shamoonandCodex 6fd91beae2 Chore: CI apt caching for flakiness (#14647)
---------

Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
2026-11-06 16:36:46 +00:00
Trenton H a4543c03dc Fix: release the LLM index read lock before streaming the chat answer (#14674)
Document chat held the shared index read lock for the whole request,
including the LLM stream, which can take minutes. A compaction or
migration swap waiting for exclusive access could time out behind it
and be skipped until the next run.

Now that the answer is synthesized from the nodes already retrieved,
nothing after the vector search touches the store. Hold the lock only
while loading the index and searching, and release it before synthesis.
2026-11-06 07:03:44 -08:00
Trenton H 45a2ac4879 Fix: retrieve once per document chat question (#14673)
Document chat searched the vector store twice per question: once
explicitly to build the reference list, and again inside
RetrieverQueryEngine.query(), which retrieves before synthesizing. That
meant two query-embedding calls and two full vector searches each time.

The engine added nothing beyond the retriever call (no node
postprocessors are configured), so drop it and synthesize directly from
the nodes already retrieved. The streaming response is unchanged.
2026-11-06 07:03:43 -08:00
GitHub Actions 8bc9db816a Auto translate strings 2026-11-06 03:07:21 +00:00
shamoon c5239f9f74 Fixhancement: rework saved view widget queue processing to expected (#14699)
* activateSavedViewWithDocuments, move most of loadSavedView to a helper

* Consolidate the page size thing

* Pass widget's documents to list service fpr next / previous

* Tests for the page size + widget click

* Fix close from a dashboard widget list
2026-11-05 19:06:10 -08:00
shamoon e88303cc16 Fix: apply PDF/A conversion once for mail archives (#14698) 2026-11-05 18:03:36 -08:00
shamoon cb3513c6f3 Fix default MFA trusted AMR setting (#14693) 2026-11-04 23:46:15 +00:00
dependabot[bot] 4468a510a6 Chore(deps): Bump the actions group across 1 directory with 17 updates (#14691)
Bumps the actions group with 17 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [actions/setup-python](https://github.com/actions/setup-python) | `7.0.0` | `7.1.0` |
| [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) | `10.3.2` | `10.3.3` |
| [docker/login-action](https://github.com/docker/login-action) | `4.6.0` | `4.7.0` |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `7.0.1` | `7.0.2` |
| [actions/download-artifact](https://github.com/actions/download-artifact) | `8.0.1` | `8.0.2` |
| [actions/setup-node](https://github.com/actions/setup-node) | `7.0.0` | `7.1.0` |
| [j178/prek-action](https://github.com/j178/prek-action) | `3.0.0` | `3.1.0` |
| [release-drafter/release-drafter](https://github.com/release-drafter/release-drafter) | `7.7.0` | `7.10.0` |
| [shogo82148/actions-upload-release-asset](https://github.com/shogo82148/actions-upload-release-asset) | `1.10.4` | `1.10.7` |
| [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) | `0.6.4` | `0.8.0` |
| [github/codeql-action/upload-sarif](https://github.com/github/codeql-action) | `4.38.2` | `4.39.1` |
| [stumpylog/image-cleaner-action/ephemeral](https://github.com/stumpylog/image-cleaner-action) | `0.13.0` | `0.14.0` |
| [stumpylog/image-cleaner-action/untagged](https://github.com/stumpylog/image-cleaner-action) | `0.13.0` | `0.14.0` |
| [github/codeql-action/init](https://github.com/github/codeql-action) | `4.38.2` | `4.39.1` |
| [github/codeql-action/analyze](https://github.com/github/codeql-action) | `4.38.2` | `4.39.1` |
| [crowdin/github-action](https://github.com/crowdin/github-action) | `3.3.0` | `3.5.0` |
| [stefanzweifel/git-auto-commit-action](https://github.com/stefanzweifel/git-auto-commit-action) | `7.2.0` | `7.2.1` |



Updates `actions/setup-python` from 7.0.0 to 7.1.0
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/5fda3b95a4ea91299a34e894583c3862153e4b97...6d4fd22b20465ccc0f7354476588e8df7c9ca0e0)

Updates `astral-sh/setup-uv` from 10.3.2 to 10.3.3
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/07a047e608db84b3d8c06cd9a7c7dcc9c355c18e...68559e99cc807c1eb7e9238fd43a15bba460eec2)

Updates `docker/login-action` from 4.6.0 to 4.7.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/dbcb813823bdd20940b903addbd779551569679f...81c6f93d4bc991e510b342a00b419b3bb4efc5c6)

Updates `actions/upload-artifact` from 7.0.1 to 7.0.2
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/043fb46d1a93c77aae656e7c1c64a875d1fc6a0a...cf430e030ddbb5b0abf93d22962f4752f3646cd9)

Updates `actions/download-artifact` from 8.0.1 to 8.0.2
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c...9000827ccba6bdab643e8b6fd33ac0654aef8333)

Updates `actions/setup-node` from 7.0.0 to 7.1.0
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/820762786026740c76f36085b0efc47a31fe5020...949feb2413d6458794dcd2491c4babbbce0c15c1)

Updates `j178/prek-action` from 3.0.0 to 3.1.0
- [Release notes](https://github.com/j178/prek-action/releases)
- [Commits](https://github.com/j178/prek-action/compare/4e14d07f9231acabce116ccfca13b13dd9755ece...44924fe9ef4a6f88253c4b6b1bf11c689d3ce53d)

Updates `release-drafter/release-drafter` from 7.7.0 to 7.10.0
- [Release notes](https://github.com/release-drafter/release-drafter/releases)
- [Commits](https://github.com/release-drafter/release-drafter/compare/34d80673e067bdc0c24568d3af899c216adcfaa9...978bb0b30c5ee8cdd332ced253758157150d43f0)

Updates `shogo82148/actions-upload-release-asset` from 1.10.4 to 1.10.7
- [Release notes](https://github.com/shogo82148/actions-upload-release-asset/releases)
- [Commits](https://github.com/shogo82148/actions-upload-release-asset/compare/aaba0f56bdbc1071f4af234d5cb16055e8a400de...73f83b114970a3485e1423ecc1fb93d887c9b096)

Updates `zizmorcore/zizmor-action` from 0.6.4 to 0.8.0
- [Release notes](https://github.com/zizmorcore/zizmor-action/releases)
- [Commits](https://github.com/zizmorcore/zizmor-action/compare/cc914d7f3750a2d13d75c7f184a1060aa0e9d482...53290badfa369cb0bf7ad16342cb62c2fa47ac7b)

Updates `github/codeql-action/upload-sarif` from 4.38.2 to 4.39.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2...9b3254085494b55a0f7c4c2b3d2d52cc712ce370)

Updates `stumpylog/image-cleaner-action/ephemeral` from 0.13.0 to 0.14.0
- [Release notes](https://github.com/stumpylog/image-cleaner-action/releases)
- [Changelog](https://github.com/stumpylog/image-cleaner-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/stumpylog/image-cleaner-action/compare/21f875bab2376314e0525c614e923c669434f0e4...9796867b2a7994f0a3b6b1f2e38a6579f502c977)

Updates `stumpylog/image-cleaner-action/untagged` from 0.13.0 to 0.14.0
- [Release notes](https://github.com/stumpylog/image-cleaner-action/releases)
- [Changelog](https://github.com/stumpylog/image-cleaner-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/stumpylog/image-cleaner-action/compare/21f875bab2376314e0525c614e923c669434f0e4...9796867b2a7994f0a3b6b1f2e38a6579f502c977)

Updates `github/codeql-action/init` from 4.38.2 to 4.39.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2...9b3254085494b55a0f7c4c2b3d2d52cc712ce370)

Updates `github/codeql-action/analyze` from 4.38.2 to 4.39.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2...9b3254085494b55a0f7c4c2b3d2d52cc712ce370)

Updates `crowdin/github-action` from 3.3.0 to 3.5.0
- [Release notes](https://github.com/crowdin/github-action/releases)
- [Commits](https://github.com/crowdin/github-action/compare/9c23991700c0ec5256fd41089b9d9d7d540e424e...959dc74ac104447731cba99a016f85d0ae719788)

Updates `stefanzweifel/git-auto-commit-action` from 7.2.0 to 7.2.1
- [Release notes](https://github.com/stefanzweifel/git-auto-commit-action/releases)
- [Changelog](https://github.com/stefanzweifel/git-auto-commit-action/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stefanzweifel/git-auto-commit-action/compare/4a55954c782fc1ea30b9056cd3e7a2b40ca8887d...d0b01107b5db882071f96de5dd4b54961ea93f4c)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: astral-sh/setup-uv
  dependency-version: 10.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: docker/login-action
  dependency-version: 4.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/download-artifact
  dependency-version: 8.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/setup-node
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: j178/prek-action
  dependency-version: 3.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: release-drafter/release-drafter
  dependency-version: 7.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: shogo82148/actions-upload-release-asset
  dependency-version: 1.10.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: zizmorcore/zizmor-action
  dependency-version: 0.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.39.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: stumpylog/image-cleaner-action/ephemeral
  dependency-version: 0.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: stumpylog/image-cleaner-action/untagged
  dependency-version: 0.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: github/codeql-action/init
  dependency-version: 4.39.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.39.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: crowdin/github-action
  dependency-version: 3.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: stefanzweifel/git-auto-commit-action
  dependency-version: 7.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-04 13:40:41 -08:00
dependabot[bot] 09d70ed7da docker(deps): bump astral-sh/uv (#14635)
Bumps [astral-sh/uv](https://github.com/astral-sh/uv) from 0.13.5-python3.14-trixie-slim to 0.13.7-python3.14-trixie-slim.
- [Release notes](https://github.com/astral-sh/uv/releases)
- [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/uv/compare/0.13.5...0.13.7)

---
updated-dependencies:
- dependency-name: astral-sh/uv
  dependency-version: 0.13.6-python3.14-trixie-slim
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-04 12:30:59 -08:00
GitHub Actions 057aee8bbd Auto translate strings 2026-11-04 19:07:00 +00:00
shamoon fa83a4c5dc Fix: check descendant tag permissions on delete (#14687) 2026-11-04 11:05:48 -08:00
shamoon 22746aa202 Fix: lock rows for first-user signup (#14688) 2026-11-04 16:33:28 +00:00
shamoon 137342c1f9 Documentation: clarify metadata exposure reporting scope 2026-11-04 07:29:41 -08:00
GitHub Actions 0ae5e2d3d4 Auto translate strings 2026-11-04 14:47:43 +00:00
shamoon b7b0de100f Fix: prevent ID badge wrapping under title on mobile (#14682) 2026-11-04 06:46:22 -08:00
Trenton H 8531d3e0c9 chore(deps): bump django-allauth to 65.21.1 (#14672)
django-allauth 65.20.1 -> 65.21.1. Security fixes:

- MFA: authentication records from an inactive user's passwordless WebAuthn login could carry over to a later login in the same session, skipping MFA
- Rate limits with multiple windows for the same scope shared a cache entry, allowing longer windows to be bypassed
- Passwordless WebAuthn registration and login now always require user verification (MFA_WEBAUTHN_USER_VERIFICATION)
- Headless: reaching the phone verification attempt limit now aborts pending flows

Released 2026-10-30, inside the usual 7 day dependency cooldown, taken deliberately for the security fixes.
2026-11-04 01:25:10 +00:00
Trenton H 05f7545a4a chore(deps): bump dev tooling (#14671)
- prek 0.5.3 -> 0.5.7
- ruff 0.16.9 -> 0.16.10
- faker 40.39.0 -> 40.43.0
- pytest-env 1.7.1 -> 1.8.1
- pytest-mock 3.15.1 -> 3.17.0
- time-machine 3.5.1 -> 3.6.0
- zensical 0.0.65 -> 0.0.73
- python-dotenv 1.2.3 -> 1.2.4 (pulled in by the above)
2026-11-04 00:53:22 +00:00
shamoon ade5901a4d Update for new icons 2026-11-02 21:46:45 -08:00
shamoon 2b620a2823 Merge branch 'dev' into feature/ocr-templates 2026-11-02 21:44:11 -08:00
shamoon 45b8977623 Merge branch 'dev' into feature/ocr-templates 2026-10-29 15:46:31 -07:00
shamoon 800748d5ab Merge branch 'dev' into feature/ocr-templates 2026-10-25 10:29:32 -07:00
shamoon 182b0b4712 Merge branch 'dev' into feature/ocr-templates 2026-10-22 15:39:03 -07:00
shamoon c718636f79 Update usage.md 2026-10-20 17:07:09 -07:00
shamoonandOpenAI Codex 54f7f43f35 Reuse OCR page renders and simplify value conversion
Co-authored-by: OpenAI Codex <codex@openai.com>
2026-10-20 17:07:00 -07:00
shamoonandOpenAI Codex 94da3d4c57 Report actual OCR extraction outcomes instead of existing field values
Co-authored-by: OpenAI Codex <codex@openai.com>
2026-10-20 17:02:23 -07:00
shamoonandOpenAI Codex 453e89d6dd Prepare and atomically apply OCR template field updates
Co-authored-by: OpenAI Codex <codex@openai.com>
2026-10-20 17:00:03 -07:00
shamoonandOpenAI Codex 6562415894 Unify OCR zone extraction for preview and saved templates
Co-authored-by: OpenAI Codex <codex@openai.com>
2026-10-20 16:58:10 -07:00
shamoon 257c33453d Update test_api_ocr_templates.py 2026-10-20 16:49:39 -07:00
shamoon 87913411ef Update serialisers.py 2026-10-20 16:49:35 -07:00
shamoon cec393dcae Update views.py 2026-10-20 16:49:32 -07:00
shamoon f5526d5700 Improve backend tests 2026-10-20 16:47:48 -07:00
shamoon 2534d3c16b Absurd comments 2026-10-20 16:46:21 -07:00
shamoon 95c8bf2058 Cleanup the serializers and views 2026-10-20 16:45:04 -07:00
shamoon 930d23956b Up frontend coverage 2026-10-20 16:34:22 -07:00
shamoon d2e6834602 Make sort order stable 2026-10-20 16:28:31 -07:00
shamoon a7bf04ddee Update spacing 2026-10-20 16:17:56 -07:00
shamoon 8ab2bf63aa Merge branch 'dev' into feature/ocr-templates 2026-10-20 16:16:31 -07:00
shamoon c63a7e25e7 Update docs 2026-10-20 15:37:08 -07:00
shamoon 9171a1aa6a Changeup the icon 2026-10-20 15:33:01 -07:00
shamoon 55c5d9121f Yes, cute little split button that opens the dialog 2026-10-20 15:31:48 -07:00
shamoon e4efb62667 And bulk edit 2026-10-20 14:02:04 -07:00
shamoon f0597a7c54 doc detail use it this way 2026-10-20 14:00:25 -07:00
shamoon bbdc016a32 Backend for manual tempalte choice 2026-10-20 13:51:54 -07:00
shamoon eae392469f Update the frontend for the matching stuff 2026-10-20 13:41:55 -07:00
shamoon bedefd85f2 Remove the handlers, make the matching work 2026-10-20 13:39:09 -07:00
shamoon bd73fe1e80 Update the serializer 2026-10-20 13:31:08 -07:00
shamoon 8b77a924dc Make it a matchingmodel 2026-10-20 13:29:42 -07:00
shamoon 1bdeb7ebcd Mas sonar 2026-10-20 13:15:17 -07:00
shamoon fa3acfe13f Sure sonar 2026-10-20 13:12:26 -07:00
shamoon 2bc22b14bd Make perms accordion 2026-10-20 13:03:04 -07:00
shamoon dbce8c1409 User perms on frontend 2026-10-20 11:25:48 -07:00
shamoon e976b2ec7a Tests 2026-10-20 11:23:44 -07:00
shamoon 05da0ba5a3 Apply only visible templates 2026-10-20 11:15:31 -07:00
shamoon 4ff2cb9c72 Make them owned, duh
Update 0028_ocrtemplate_ocrtemplatezone.py
2026-10-20 11:15:30 -07:00
shamoon 77a7fb40ec safe regex 2026-10-20 11:05:49 -07:00
shamoon 8100a8e1eb Lol and run zone ocr 2026-10-20 11:04:17 -07:00
shamoon 1e334903f3 And test zone 2026-10-20 11:01:11 -07:00
shamoon a958d89bf4 Add perms to document_page_image 2026-10-20 10:58:54 -07:00
shamoon 8b3295fce0 Docs 2026-10-20 10:42:16 -07:00
shamoon 9783c4995f Fix the tests for now 2026-10-20 10:42:12 -07:00
shamoon e89495dc02 Frontend use the bulk edit 2026-10-20 10:32:48 -07:00
shamoon 8bb2f88015 Backend work for proper bulk zone ocr 2026-10-20 10:20:28 -07:00
shamoon 7d9b8c770d Cleanup doc detail run zone ocr 2026-10-20 10:16:58 -07:00
shamoon 3296fd7cb5 Formatting, remove hard-coded alpha stuff 2026-10-20 10:14:03 -07:00
shamoon bff41907c2 Move back controls, drop (lol) dnd 2026-10-20 10:08:43 -07:00
shamoon 592f882394 Get rid of the x for the picker 2026-10-20 10:06:40 -07:00
shamoon 29ea9cec66 Gosh so much cleanup 2026-10-20 10:03:55 -07:00
shamoon 123df2e826 Pngx conventions 2026-10-20 09:50:29 -07:00
shamoon 639827ec65 Lol, no not deu+eng default 2026-10-20 09:45:30 -07:00
shamoon b3106e408c Lots of enum cleanup 2026-10-20 09:44:37 -07:00
shamoon ea31b6a043 Make it close not leave page 2026-10-20 09:38:29 -07:00
shamoon 0572b54579 Proper zoneless stuff 2026-10-20 09:35:39 -07:00
shamoon 2afec3e0b7 Lots of dead code 2026-10-20 09:31:59 -07:00
shamoon 57019bccbe Cleanup save now that we're modal 2026-10-20 09:30:12 -07:00
shamoon 26b3964a20 Cleanup the geometry stuff 2026-10-20 09:26:42 -07:00
shamoon a2eeadca7f Fix the delete handler 2026-10-20 09:24:26 -07:00
shamoon ba5474ba81 Lol, just one icon 2026-10-20 09:20:06 -07:00
shamoon e3592804bd Embiggen 2026-10-20 09:18:36 -07:00
shamoon 2d54927be8 Fuck it, make it a modal like literally everything else, drop the routes 2026-10-20 09:14:52 -07:00
shamoon 1d2832ac30 Update ocr-template-editor.component.ts 2026-10-20 09:05:24 -07:00
shamoon 3321ba46d6 Automatically update the combine string when changing zone name 2026-10-20 09:00:17 -07:00
shamoon bf316cba8a Dont fail if one bulk fails, collect results separately 2026-10-20 08:57:24 -07:00
shamoon 36c31b3a73 Also prevent editing template while saving 2026-10-20 08:47:26 -07:00
shamoon d10d303980 Warn about losing changes, disable while zone ocr running 2026-10-20 08:36:22 -07:00
shamoon a51e6a71f4 Fix reload after running zone ocr 2026-10-20 08:20:44 -07:00
shamoon ebd8dba3bb Fix the no zones note 2026-10-20 08:20:25 -07:00
shamoon 3ed9586347 Remove the create link from doc details 2026-10-20 08:11:40 -07:00
shamoon 7055f7a745 Fix perms test 2026-10-20 08:07:46 -07:00
shamoon 8f7500ba64 Fix the signal thing 2026-10-20 08:03:33 -07:00
shamoon 816774815c Add a little warning for empty zones 2026-10-20 07:39:47 -07:00
shamoon c5e6748771 Use braces icon instead 2026-10-20 07:18:01 -07:00
shamoon 9b42170554 Make the floating controls movable 2026-10-20 07:15:18 -07:00
shamoon d2ed0c55a4 dirtycheck 2026-10-19 23:12:09 -07:00
shamoon 4d31063082 Default to empty field 2026-10-19 23:07:12 -07:00
shamoon a4fb9bf2b0 Drop the silly mode toggle 2026-10-19 23:04:35 -07:00
shamoon e028257da5 Cleanup the zone combine stuff 2026-10-19 22:55:23 -07:00
shamoon 68eb4fe96d Make the zone list more useful, show colors 2026-10-19 22:43:12 -07:00
shamoon d2d434535b Remove the slop quick create CF stuff 2026-10-19 22:36:17 -07:00
shamoon 1d75c0c99b Use pngx inputs, move test button, generally make less terrible 2026-10-19 22:31:33 -07:00
shamoon 1de1261728 Ok thats a better start, two tabs 2026-10-19 22:23:50 -07:00
shamoon f5f33e0744 make the ui usable 2026-10-19 22:06:11 -07:00
shamoon c9d62afa50 Make it hideable too 2026-10-19 21:43:10 -07:00
shamoon b5a9353780 Move migration 2026-10-19 21:39:26 -07:00
shamoon cf6d16edcd Merge branch 'dev' into feature/ocr-templates 2026-10-19 21:37:23 -07:00
shamoon 551445773a Use SVG, get rid of all the manual redraw stuff 2026-09-09 12:43:11 -07:00
shamoon c54cb4a84b Use a real canvas interaction model
[skip ci]
2026-09-09 12:43:11 -07:00
shamoon af61044b63 Fix some permissions stuff, show error on toggle 2026-09-09 12:43:11 -07:00
shamoon 1ef5a6d3cf Get rid of absurd alert 2026-09-09 12:43:11 -07:00
shamoon 4da311f91f Proper data types
[skip ci]
2026-09-09 12:43:10 -07:00
shamoon cbb84649c7 Pull out the zone list into a sub-component 2026-09-09 12:43:10 -07:00
shamoon 8da734a495 Extract the geometry stuff a bit 2026-09-09 12:43:10 -07:00
shamoon 351671011d Remove afterviewinit, fix page change, custom date format per zone 2026-09-09 12:43:09 -07:00
15c95c251d Feature: OCR Templates (#13043)
[skip ci]

Signed-off-by: dependabot[bot] <support@github.com>
Co-Authored-By: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-Authored-By: stumpylog <797416+stumpylog@users.noreply.github.com>
Co-Authored-By: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-Authored-By: shamoon <4887959+shamoon@users.noreply.github.com>
2026-09-09 12:43:09 -07:00
163 changed files with 14672 additions and 1644 deletions

No files matched your search

@@ -1,5 +1,23 @@
title: "[Feature Request] " title: "[Feature Request] "
body: body:
- type: markdown
attributes:
value: |
#### Use of AI tools
If this request was generated, in whole or in part, by an AI tool or agent you **must** say so using the field below. Failing to clearly disclose this is a violation of our [Code of Conduct](https://github.com/paperless-ngx/paperless-ngx/blob/main/CODE_OF_CONDUCT.md).
The project prefers thoughtful, well-described ideas to AI-generated content. Please describe the problem, your use case, the desired behavior, and who would benefit.
- type: dropdown
id: ai-usage
attributes:
label: AI usage
description: Was any part of this request written by an AI tool or agent?
options:
- No AI tools were used
- Yes, AI tools helped write this request
validations:
required: true
- type: textarea - type: textarea
id: description id: description
attributes: attributes:
+16
View File
@@ -1,5 +1,21 @@
title: "[Support] " title: "[Support] "
body: body:
- type: markdown
attributes:
value: |
#### Use of AI tools
If this post was generated, in whole or in part, by an AI tool or agent you **must** say so using the field below. Failing to clearly disclose this is a violation of our [Code of Conduct](https://github.com/paperless-ngx/paperless-ngx/blob/main/CODE_OF_CONDUCT.md).
- type: dropdown
id: ai-usage
attributes:
label: AI usage
description: Was any part of this post written by an AI tool or agent?
options:
- No AI tools were used
- Yes, AI tools helped write this post
validations:
required: true
- type: textarea - type: textarea
id: description id: description
attributes: attributes:
+34 -34
View File
@@ -1,53 +1,53 @@
name: Install apt packages name: Install apt packages
description: > description: >
Installs apt packages with a per-attempt timeout and retries. The hosted runner's Azure apt mirror intermittently serves archives at a crawl, which apt's own timeout does not catch. A stuck attempt is killed and retried, which usually lands on a healthy mirror node. Installs apt packages with bounded download retries and Ubuntu mirror fallback. Caches downloaded packages as an optimization; apt resolves dependencies on every run.
inputs: inputs:
packages: packages:
description: Space separated list of packages to install description: Space separated list of package names to install
required: true required: true
attempts: attempts:
description: Maximum number of attempts description: Maximum number of download attempts
default: "3" default: "3"
attempt-timeout: attempt-timeout:
description: Seconds before a single attempt is killed description: Seconds before a single download attempt is killed
default: "180" default: "180"
runs: runs:
using: composite using: composite
steps: steps:
- name: Identify apt cache
id: cache-key
shell: bash
env:
PACKAGES: ${{ inputs.packages }}
run: |
. /etc/os-release
packages_hash=$(printf '%s' "$PACKAGES" | sha256sum | cut -d ' ' -f 1)
# Cached downloads are optional: apt fetches any missing/newer packages.
# Refresh weekly, including inside job containers without ImageVersion.
echo "key=apt-v2-${ID}-${VERSION_ID}-$(dpkg --print-architecture)-$(date -u +%G-%V)-${packages_hash}" >> "$GITHUB_OUTPUT"
- name: Restore apt packages
id: cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
${{ runner.temp }}/apt-packages/*.deb
${{ runner.temp }}/apt-packages/.keep
key: ${{ steps.cache-key.outputs.key }}
- name: Install ${{ inputs.packages }} - name: Install ${{ inputs.packages }}
shell: bash shell: bash
env: env:
PACKAGES: ${{ inputs.packages }} PACKAGES: ${{ inputs.packages }}
ATTEMPTS: ${{ inputs.attempts }} ATTEMPTS: ${{ inputs.attempts }}
ATTEMPT_TIMEOUT: ${{ inputs.attempt-timeout }} ATTEMPT_TIMEOUT: ${{ inputs.attempt-timeout }}
run: | CACHE_DIR: ${{ runner.temp }}/apt-packages
SUDO="" run: bash "$GITHUB_ACTION_PATH/install.sh"
if [ "$(id -u)" -ne 0 ]; then # Save immediately: a later test failure must not discard downloaded packages.
SUDO="sudo" - name: Save apt packages
fi if: steps.cache.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
attempt_install() { with:
$SUDO apt-get update -q path: |
# Intentionally unquoted, this is a list of package names ${{ runner.temp }}/apt-packages/*.deb
$SUDO apt-get install -q --yes --no-install-recommends $PACKAGES ${{ runner.temp }}/apt-packages/.keep
} key: ${{ steps.cache-key.outputs.key }}
export -f attempt_install
export SUDO PACKAGES
for attempt in $(seq 1 "${ATTEMPTS}"); do
echo "::group::apt install attempt ${attempt}/${ATTEMPTS}"
start=$(date +%s)
if timeout --kill-after=10 "${ATTEMPT_TIMEOUT}" bash -c attempt_install; then
echo "::endgroup::"
echo "Installed in $(( $(date +%s) - start ))s"
exit 0
fi
echo "::endgroup::"
echo "::warning::apt install attempt ${attempt}/${ATTEMPTS} failed or exceeded ${ATTEMPT_TIMEOUT}s"
# A killed attempt can leave dpkg half configured
$SUDO dpkg --configure -a || true
done
echo "::error::apt install failed after ${ATTEMPTS} attempts"
exit 1
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
# Run as root in the timed child process. Only downloads may be interrupted.
if [ "${1:-}" = download ]; then
download_archive_dir=$2
shift 2
apt-get update -q
# Keep apt itself under timeout supervision, including the kill grace period.
exec apt-get install -q --yes --no-install-recommends --download-only \
-o "Dir::Cache::archives=$download_archive_dir" "$@"
fi
SUDO=()
if [ "$(id -u)" -ne 0 ]; then
SUDO=(sudo)
fi
read -r -a requested_packages <<< "$PACKAGES"
mkdir -p "$CACHE_DIR"
touch "$CACHE_DIR/.keep"
use_ubuntu_mirrors() {
# Hosted runners use a mirror list; job containers can point directly at Azure.
# Handle both layouts, leaving other repositories and signature checks intact.
if [ -f /etc/apt/apt-mirrors.txt ]; then
"${SUDO[@]}" sed -i '\|azure\.archive\.ubuntu\.com|d' /etc/apt/apt-mirrors.txt
fi
for source in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do
if [ -f "$source" ]; then
"${SUDO[@]}" sed -i 's|azure\.archive\.ubuntu\.com|archive.ubuntu.com|g' "$source"
fi
done
}
for attempt in $(seq 1 "$ATTEMPTS"); do
echo "::group::apt download attempt ${attempt}/${ATTEMPTS}"
# Run timeout as root so it can signal the root-owned apt processes.
if "${SUDO[@]}" timeout --kill-after=10 "$ATTEMPT_TIMEOUT" bash "$0" download "$CACHE_DIR" "${requested_packages[@]}"; then
echo "::endgroup::"
# Apt handles aliases (e.g. libmagic1 -> libmagic1t64) and dependencies.
"${SUDO[@]}" apt-get install -q --yes --no-install-recommends --no-download \
-o "Dir::Cache::archives=$CACHE_DIR" "${requested_packages[@]}"
# Allow the unprivileged cache action to read archives downloaded as root.
find "$CACHE_DIR" -maxdepth 1 -name '*.deb' -exec "${SUDO[@]}" chmod a+r {} +
exit 0
fi
echo "::endgroup::"
echo "::warning::apt download attempt ${attempt}/${ATTEMPTS} failed or exceeded ${ATTEMPT_TIMEOUT}s"
if [ "$attempt" -eq 1 ] && [ "$ATTEMPTS" -gt 1 ]; then
echo "Switching from Azure to Ubuntu archive mirrors for remaining attempts"
use_ubuntu_mirrors
fi
done
echo "::error::apt download failed after ${ATTEMPTS} attempts"
exit 1
+5 -4
View File
@@ -77,6 +77,7 @@ jobs:
- 'uv.lock' - 'uv.lock'
- 'docker/compose/docker-compose.ci-test.yml' - 'docker/compose/docker-compose.ci-test.yml'
- '.github/workflows/ci-backend.yml' - '.github/workflows/ci-backend.yml'
- '.github/actions/apt-install/**'
test: test:
needs: changes needs: changes
if: needs.changes.outputs.backend_changed == 'true' if: needs.changes.outputs.backend_changed == 'true'
@@ -99,11 +100,11 @@ jobs:
docker compose --file docker/compose/docker-compose.ci-test.yml up --detach docker compose --file docker/compose/docker-compose.ci-test.yml up --detach
- name: Set up Python - name: Set up Python
id: setup-python id: setup-python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with: with:
python-version: "${{ matrix.python-version }}" python-version: "${{ matrix.python-version }}"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
@@ -169,11 +170,11 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Set up Python - name: Set up Python
id: setup-python id: setup-python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with: with:
python-version: "${{ env.DEFAULT_PYTHON }}" python-version: "${{ env.DEFAULT_PYTHON }}"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
+6 -6
View File
@@ -109,7 +109,7 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -155,7 +155,7 @@ jobs:
echo "${digest}" > "/tmp/digests/digest-${{ matrix.arch }}.txt" echo "${digest}" > "/tmp/digests/digest-${{ matrix.arch }}.txt"
- name: Upload digest - name: Upload digest
if: steps.check-push.outputs.should-push == 'true' if: steps.check-push.outputs.should-push == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with: with:
name: digests-${{ matrix.arch }} name: digests-${{ matrix.arch }}
path: /tmp/digests/digest-${{ matrix.arch }}.txt path: /tmp/digests/digest-${{ matrix.arch }}.txt
@@ -173,7 +173,7 @@ jobs:
packages: write packages: write
steps: steps:
- name: Download digests - name: Download digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with: with:
path: /tmp/digests path: /tmp/digests
pattern: digest-*.txt pattern: digest-*.txt
@@ -185,20 +185,20 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub - name: Login to Docker Hub
if: needs.build-arch.outputs.push-external == 'true' if: needs.build-arch.outputs.push-external == 'true'
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to Quay.io - name: Login to Quay.io
if: needs.build-arch.outputs.push-external == 'true' if: needs.build-arch.outputs.push-external == 'true'
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with: with:
registry: quay.io registry: quay.io
username: ${{ secrets.QUAY_USERNAME }} username: ${{ secrets.QUAY_USERNAME }}
+2 -2
View File
@@ -75,11 +75,11 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Set up Python - name: Set up Python
id: setup-python id: setup-python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }} python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
+8 -7
View File
@@ -69,6 +69,7 @@ jobs:
frontend: frontend:
- 'src-ui/**' - 'src-ui/**'
- '.github/workflows/ci-frontend.yml' - '.github/workflows/ci-frontend.yml'
- '.github/actions/apt-install/**'
install-dependencies: install-dependencies:
needs: changes needs: changes
if: needs.changes.outputs.frontend_changed == 'true' if: needs.changes.outputs.frontend_changed == 'true'
@@ -86,7 +87,7 @@ jobs:
with: with:
package_json_file: src-ui/package.json package_json_file: src-ui/package.json
- name: Use Node.js 24 - name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with: with:
node-version: 24.x node-version: 24.x
cache: 'pnpm' cache: 'pnpm'
@@ -118,7 +119,7 @@ jobs:
with: with:
package_json_file: src-ui/package.json package_json_file: src-ui/package.json
- name: Use Node.js 24 - name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with: with:
node-version: 24.x node-version: 24.x
cache: 'pnpm' cache: 'pnpm'
@@ -157,7 +158,7 @@ jobs:
with: with:
package_json_file: src-ui/package.json package_json_file: src-ui/package.json
- name: Use Node.js 24 - name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with: with:
node-version: 24.x node-version: 24.x
cache: 'pnpm' cache: 'pnpm'
@@ -206,18 +207,18 @@ jobs:
with: with:
package_json_file: src-ui/package.json package_json_file: src-ui/package.json
- name: Use Node.js 24 - name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with: with:
node-version: 24.x node-version: 24.x
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml' cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Set up Python - name: Set up Python
id: setup-python id: setup-python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with: with:
python-version: '3.12' python-version: '3.12'
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
with: with:
version: '0.13.x' version: '0.13.x'
enable-cache: false enable-cache: false
@@ -261,7 +262,7 @@ jobs:
with: with:
package_json_file: src-ui/package.json package_json_file: src-ui/package.json
- name: Use Node.js 24 - name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with: with:
node-version: 24.x node-version: 24.x
cache: 'pnpm' cache: 'pnpm'
+2 -2
View File
@@ -22,8 +22,8 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Install Python - name: Install Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with: with:
python-version: "3.14" python-version: "3.14"
- name: Run prek - name: Run prek
uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0 uses: j178/prek-action@44924fe9ef4a6f88253c4b6b1bf11c689d3ce53d # v3.1.0
+9 -9
View File
@@ -44,7 +44,7 @@ jobs:
with: with:
package_json_file: src-ui/package.json package_json_file: src-ui/package.json
- name: Use Node.js 24 - name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with: with:
node-version: 24.x node-version: 24.x
package-manager-cache: false package-manager-cache: false
@@ -55,11 +55,11 @@ jobs:
# ---- Backend Setup ---- # ---- Backend Setup ----
- name: Set up Python - name: Set up Python
id: setup-python id: setup-python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }} python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: false enable-cache: false
@@ -137,7 +137,7 @@ jobs:
sudo chown -R 1000:1000 paperless-ngx/ sudo chown -R 1000:1000 paperless-ngx/
tar -cJf paperless-ngx.tar.xz paperless-ngx/ tar -cJf paperless-ngx.tar.xz paperless-ngx/
- name: Upload release artifact - name: Upload release artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with: with:
name: release name: release
path: dist/paperless-ngx.tar.xz path: dist/paperless-ngx.tar.xz
@@ -155,7 +155,7 @@ jobs:
version: ${{ steps.get-version.outputs.version }} version: ${{ steps.get-version.outputs.version }}
steps: steps:
- name: Download release artifact - name: Download release artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with: with:
name: release name: release
path: ./ path: ./
@@ -172,7 +172,7 @@ jobs:
fi fi
- name: Create release and changelog - name: Create release and changelog
id: create-release id: create-release
uses: release-drafter/release-drafter@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7.7.0 uses: release-drafter/release-drafter@978bb0b30c5ee8cdd332ced253758157150d43f0 # v7.10.0
with: with:
name: Paperless-ngx ${{ steps.get-version.outputs.version }} name: Paperless-ngx ${{ steps.get-version.outputs.version }}
tag: ${{ steps.get-version.outputs.version }} tag: ${{ steps.get-version.outputs.version }}
@@ -183,7 +183,7 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload release archive - name: Upload release archive
uses: shogo82148/actions-upload-release-asset@aaba0f56bdbc1071f4af234d5cb16055e8a400de # v1.10.4 uses: shogo82148/actions-upload-release-asset@73f83b114970a3485e1423ecc1fb93d887c9b096 # v1.10.7
with: with:
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}
upload_url: ${{ steps.create-release.outputs.upload_url }} upload_url: ${{ steps.create-release.outputs.upload_url }}
@@ -209,11 +209,11 @@ jobs:
persist-credentials: true # for pushing changelog branch persist-credentials: true # for pushing changelog branch
- name: Set up Python - name: Set up Python
id: setup-python id: setup-python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }} python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: false enable-cache: false
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Run zizmor - name: Run zizmor
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 uses: zizmorcore/zizmor-action@53290badfa369cb0bf7ad16342cb62c2fa47ac7b # v0.8.0
semgrep: semgrep:
name: Semgrep CE name: Semgrep CE
runs-on: ubuntu-26.04 runs-on: ubuntu-26.04
@@ -45,7 +45,7 @@ jobs:
- name: Run Semgrep - name: Run Semgrep
run: semgrep scan --config auto --sarif-output results.sarif run: semgrep scan --config auto --sarif-output results.sarif
- name: Upload results to GitHub code scanning - name: Upload results to GitHub code scanning
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
if: always() if: always()
with: with:
sarif_file: results.sarif sarif_file: results.sarif
+2 -2
View File
@@ -29,7 +29,7 @@ jobs:
steps: steps:
- name: Clean temporary images - name: Clean temporary images
if: "${{ env.TOKEN != '' }}" if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/ephemeral@21f875bab2376314e0525c614e923c669434f0e4 # v0.13.0 uses: stumpylog/image-cleaner-action/ephemeral@9796867b2a7994f0a3b6b1f2e38a6579f502c977 # v0.14.0
with: with:
token: "${{ env.TOKEN }}" token: "${{ env.TOKEN }}"
owner: "${{ github.repository_owner }}" owner: "${{ github.repository_owner }}"
@@ -56,7 +56,7 @@ jobs:
steps: steps:
- name: Clean untagged images - name: Clean untagged images
if: "${{ env.TOKEN != '' }}" if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/untagged@21f875bab2376314e0525c614e923c669434f0e4 # v0.13.0 uses: stumpylog/image-cleaner-action/untagged@9796867b2a7994f0a3b6b1f2e38a6579f502c977 # v0.14.0
with: with:
token: "${{ env.TOKEN }}" token: "${{ env.TOKEN }}"
owner: "${{ github.repository_owner }}" owner: "${{ github.repository_owner }}"
+2 -2
View File
@@ -39,7 +39,7 @@ jobs:
persist-credentials: false persist-credentials: false
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # If you wish to specify custom queries, you can do so here or in a config file.
@@ -47,4 +47,4 @@ jobs:
# Prefix the list here with "+" to use these queries and those in the config file. # Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main # queries: ./path/to/local/query, your-org/your-repo/queries@main
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
token: ${{ secrets.PNGX_BOT_PAT }} token: ${{ secrets.PNGX_BOT_PAT }}
persist-credentials: false persist-credentials: false
- name: crowdin action - name: crowdin action
uses: crowdin/github-action@9c23991700c0ec5256fd41089b9d9d7d540e424e # v3.3.0 uses: crowdin/github-action@959dc74ac104447731cba99a016f85d0ae719788 # v3.5.0
with: with:
upload_translations: false upload_translations: false
download_translations: true download_translations: true
+1 -1
View File
@@ -19,6 +19,6 @@ jobs:
if: github.event_name == 'pull_request_target' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login != 'dependabot' if: github.event_name == 'pull_request_target' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login != 'dependabot'
steps: steps:
- name: Label PR with release-drafter - name: Label PR with release-drafter
uses: release-drafter/release-drafter@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7.7.0 uses: release-drafter/release-drafter@978bb0b30c5ee8cdd332ced253758157150d43f0 # v7.10.0
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+4 -4
View File
@@ -23,14 +23,14 @@ jobs:
persist-credentials: true # for pushing translation branch persist-credentials: true # for pushing translation branch
- name: Set up Python - name: Set up Python
id: setup-python id: setup-python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
- name: Install system dependencies - name: Install system dependencies
timeout-minutes: 12 timeout-minutes: 12
uses: $/.github/actions/apt-install uses: $/.github/actions/apt-install
with: with:
packages: gettext packages: gettext
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
@@ -48,7 +48,7 @@ jobs:
with: with:
package_json_file: src-ui/package.json package_json_file: src-ui/package.json
- name: Use Node.js 24 - name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with: with:
node-version: 24.x node-version: 24.x
cache: 'pnpm' cache: 'pnpm'
@@ -68,7 +68,7 @@ jobs:
cd src-ui cd src-ui
pnpm run ng extract-i18n pnpm run ng extract-i18n
- name: Commit changes - name: Commit changes
uses: stefanzweifel/git-auto-commit-action@4a55954c782fc1ea30b9056cd3e7a2b40ca8887d # v7.2.0 uses: stefanzweifel/git-auto-commit-action@d0b01107b5db882071f96de5dd4b54961ea93f4c # v7.2.1
with: with:
file_pattern: 'src-ui/messages.xlf src/locale/en_US/LC_MESSAGES/django.po' file_pattern: 'src-ui/messages.xlf src/locale/en_US/LC_MESSAGES/django.po'
commit_message: "Auto translate strings" commit_message: "Auto translate strings"
+4 -4
View File
@@ -38,7 +38,7 @@ repos:
- json - json
# See https://github.com/prettier/prettier/issues/15742 for the fork reason # See https://github.com/prettier/prettier/issues/15742 for the fork reason
- repo: https://github.com/rbubley/mirrors-prettier - repo: https://github.com/rbubley/mirrors-prettier
rev: 'v3.9.9' rev: 'v3.9.10'
hooks: hooks:
- id: prettier - id: prettier
types_or: types_or:
@@ -46,16 +46,16 @@ repos:
- ts - ts
- markdown - markdown
additional_dependencies: additional_dependencies:
- prettier@3.9.9 - prettier@3.9.10
- 'prettier-plugin-organize-imports@4.3.0' - 'prettier-plugin-organize-imports@4.3.0'
# Python hooks # Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.9 rev: v0.17.2
hooks: hooks:
- id: ruff-check - id: ruff-check
- id: ruff-format - id: ruff-format
- repo: https://github.com/tox-dev/pyproject-fmt - repo: https://github.com/tox-dev/pyproject-fmt
rev: "v2.29.4" rev: "v2.30.3"
hooks: hooks:
- id: pyproject-fmt - id: pyproject-fmt
additional_dependencies: [tomli] additional_dependencies: [tomli]
+44
View File
@@ -0,0 +1,44 @@
# Guidance for AI agents
Read [CONTRIBUTING.md](CONTRIBUTING.md) before working on this repository, especially [Use of AI Tools](CONTRIBUTING.md#use-of-ai-tools). These contribution rules apply to all agent-assisted work.
## Contribution boundaries
- Do not open or submit pull requests, including "on behalf of" a human. The project does not accept PRs from AI agents. Prepare local changes for a human to review, verify, and submit personally.
- The project does not accept PRs that are entirely or mostly AI-derived. Human submission and disclosure do not override this rule.
- AI-generated content must be clearly disclosed as described in the contributing guidelines. When handing work back to a human, identify what was generated or changed with AI assistance so they can disclose it accurately.
- Trust matters. Encourage first-time contributors to discuss their proposed change before preparing a PR, especially for large changes or changes involving AI-generated code. New features and enhancements should almost always target an existing feature request with community interest and discussion.
- The project prefers thoughtful, well-described ideas to AI-generated pull requests. Help contributors describe their problem, use case, desired behavior, and who would benefit. An implementation is not required for a valuable contribution, and a flawed implementation can obscure a useful idea before it receives proper discussion.
- AI tools have lowered the effort needed to produce a PR, but reviewing and maintaining it still requires substantial work. Encourage discussion of the idea before investing in implementation and review.
- Issue reports must describe observed behavior, reproduction steps, and relevant logs. Do not add speculative root causes, code analysis, or proposed fixes to agent-generated reports.
## Security reports
- AI-assisted security reports must meet the legitimacy and verification requirements in [SECURITY.md](SECURITY.md#ai-assisted-submissions). A human must verify a real Paperless-ngx code path, concrete reproduction steps, affected versions, and realistic security impact before submission.
- Account for existing permission checks, configuration requirements, and trust boundaries. Speculation, scanner output, or concerns about third-party software or deployment choices without a demonstrated Paperless-ngx vulnerability do not meet this threshold and may be closed without extended discussion.
- Disclose AI assistance and use the private vulnerability reporting channel specified in [SECURITY.md](SECURITY.md#reporting-a-vulnerability).
## Working on changes
- Read the relevant code, tests, and documentation before editing. Paperless-ngx is complex: trace interactions between affected components rather than assuming a change is isolated.
- Keep changes focused on the requested work and follow existing patterns. Avoid unrelated refactoring, formatting, dependency updates, or generated-file changes.
- Consider effects on permissions, document processing, background tasks, storage, and the API when those areas are involved.
- Add or update meaningful tests for behavior changes and regressions. Do not weaken tests or checks to make a change pass.
- Update user or developer documentation when behavior or setup changes.
- Keep credentials and private documents out of commits, fixtures, logs, and examples.
## Repository and validation
- `src/`: Django back end.
- `src-ui/`: Angular front end, managed with `pnpm`.
- `docs/`: Documentation.
- `docker/` and `scripts/`: Deployment and development tooling.
- Base functional changes on `dev`; documentation-only changes may target `main`.
Follow [the development documentation](docs/development.md) for environment setup and testing. Common checks after setup are:
- Back end: run `uv run pytest` from `src/` (use a test path to focus on the affected area).
- Front end: run `pnpm test` and `pnpm lint` from `src-ui/`.
- Formatting and other pre-commit checks: run `uv run prek run --files <changed-files>` from the repository root.
When handing work back, summarize the changes, the checks actually run and their results, and any remaining uncertainty. Clearly state when a check could not be run; do not claim verification that did not happen. The human contributor must understand and independently verify the contribution before submitting it.
+16 -3
View File
@@ -4,6 +4,18 @@ If you feel like contributing to the project, please do! Bug fixes and improveme
⚠️ Please note: Pull requests that implement a new feature or enhancement _should almost always target an existing feature request_ with evidence of community interest and discussion. This is in order to balance the work of implementing and maintaining new features / enhancements. Pull requests that are opened without meeting this requirement may not be merged. ⚠️ Please note: Pull requests that implement a new feature or enhancement _should almost always target an existing feature request_ with evidence of community interest and discussion. This is in order to balance the work of implementing and maintaining new features / enhancements. Pull requests that are opened without meeting this requirement may not be merged.
## Trust and collaboration
Paperless-ngx places a high value on trust between contributors and maintainers. If you have not contributed before, consider starting with a discussion before preparing a pull request, especially for a large contribution or one involving AI-generated code.
Paperless-ngx is complex, and understanding how its components interact takes time. A change that appears correct in isolation can have consequences elsewhere in the application. Contributors should understand the code they submit, explain how it fits into the project, and work with maintainers through review. Starting with smaller, focused contributions can help build that understanding and trust.
We prefer thoughtful, well-described ideas to AI-generated pull requests. You do not need to supply an implementation for your contribution to be valuable. Explain the problem you have encountered, your use case, the behavior you would like to see, and who would benefit. Concrete examples, alternatives you have considered, and relevant constraints help the community discuss and refine the idea.
AI tools have lowered the effort needed to produce a pull request, but evaluating one still requires substantial maintainer time. Maintainers still need to establish whether the implementation is correct, fits the project, and can be maintained over time.
A flawed implementation can also obscure a good idea: discussion becomes focused on problems with the generated code before the community has considered whether the underlying proposal is useful. Starting with the idea gives it a fair discussion on its own merits and lets contributors and maintainers agree on an approach before investing in implementation and review.
If you want to implement something big: If you want to implement something big:
- As above, please start with a discussion! Maybe something similar is already in development and we can make it happen together. - As above, please start with a discussion! Maybe something similar is already in development and we can make it happen together.
@@ -61,9 +73,10 @@ This project does not specifically prohibit the use of AI tools or agents _durin
1. Any content — code, pull request descriptions, issue reports or comments — that was generated in whole or in part by an AI tool or agent **must be clearly disclosed as such**. Failing to clearly disclose this is considered a violation of our [Code of Conduct](CODE_OF_CONDUCT.md) and may result in your contributions being closed and further action being taken. 1. Any content — code, pull request descriptions, issue reports or comments — that was generated in whole or in part by an AI tool or agent **must be clearly disclosed as such**. Failing to clearly disclose this is considered a violation of our [Code of Conduct](CODE_OF_CONDUCT.md) and may result in your contributions being closed and further action being taken.
2. AI-generated code must not violate copyright protections. 2. AI-generated code must not violate copyright protections.
3. We will not accept PRs that are entirely or mostly AI-derived. 3. We will not accept PRs opened or submitted by AI agents, including agents acting "on behalf of" a human. A human contributor must personally review, verify, and submit the contribution and take responsibility for it throughout review.
4. Issue reports must describe _observed behavior only_: what happened, how to reproduce it and the relevant logs. Reports generated by an AI agent should not include code analysis, speculation about the root cause or suggest fixes. 4. We will not accept PRs that are entirely or mostly AI-derived.
5. Contributors are responsible for everything they submit, regardless of how it was produced. Please do not open issues or PRs you have not verified yourself. 5. Issue reports must describe _observed behavior only_: what happened, how to reproduce it and the relevant logs. Reports generated by an AI agent should not include code analysis, speculation about the root cause or suggest fixes.
6. Contributors are responsible for everything they submit, regardless of how it was produced. Please do not open issues or PRs you have not verified yourself.
# Translating Paperless-ngx # Translating Paperless-ngx
+1 -1
View File
@@ -30,7 +30,7 @@ RUN set -eux \
# Purpose: Installs s6-overlay and rootfs # Purpose: Installs s6-overlay and rootfs
# Comments: # Comments:
# - Don't leave anything extra in here either # - Don't leave anything extra in here either
FROM ghcr.io/astral-sh/uv:0.13.5-python3.14-trixie-slim@sha256:d8e732839dfc4225a1ff698803b478b2223c05636c055d3add41ab9dbec0f315 AS s6-overlay-base FROM ghcr.io/astral-sh/uv:0.13.7-python3.14-trixie-slim@sha256:e80c05a310400cfc90af00b5b7271ef9ad24816e3265e1162af8a678e1aad099 AS s6-overlay-base
WORKDIR /usr/src/s6 WORKDIR /usr/src/s6
+4 -1
View File
@@ -56,6 +56,9 @@ We may close reports that are:
The following are not generally considered vulnerabilities unless accompanied by a concrete, reproducible impact in Paperless-ngx: The following are not generally considered vulnerabilities unless accompanied by a concrete, reproducible impact in Paperless-ngx:
- password length inferred from masked mail-account passwords by users who already have permission to view the account, without disclosure of the password or token itself
- disclosure limited to document IDs, existence, page counts, checksums, or modification/deletion timestamps, without access to document contents or an unintended privileged action
- authenticated access to non-secret application settings, such as service endpoint URLs, model names, or configured environment variable names, without disclosure of credentials or secret values
- 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
@@ -66,7 +69,7 @@ The following are not generally considered vulnerabilities unless accompanied by
- 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 - metadata names or normalized words visible in a document's custom storage path or through search/autocomplete for documents the user can access, 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
@@ -39,7 +39,6 @@ declare -a scope=(
"${site_packages}/allauth" "${site_packages}/allauth"
"${site_packages}/drf_spectacular" "${site_packages}/drf_spectacular"
"${site_packages}/drf_spectacular_sidecar" "${site_packages}/drf_spectacular_sidecar"
"${site_packages}/treenode"
"${site_packages}/compression_middleware" "${site_packages}/compression_middleware"
) )
+2
View File
@@ -565,6 +565,8 @@ Specify `rebuild` to build the index from scratch from all documents in the data
this the first time you enable the feature, or after changing the embedding backend or this the first time you enable the feature, or after changing the embedding backend or
model. model.
When using the `huggingface` embedding backend, a model that is already downloaded is loaded from the local cache without contacting the Hugging Face Hub. Only `rebuild` checks the Hub for a newer revision of the model, so a model update is picked up together with re-embedding every document. If the model is not cached yet, it is downloaded on first use.
Specify `update` to incrementally index new and changed documents. This is what the Specify `update` to incrementally index new and changed documents. This is what the
scheduled task runs. scheduled task runs.
+22 -11
View File
@@ -501,20 +501,20 @@ See the [Django project documentation on the settings](https://docs.djangoprojec
#### [`PAPERLESS_ALLOWED_HOSTS=<comma-separated-list>`](#PAPERLESS_ALLOWED_HOSTS) {#PAPERLESS_ALLOWED_HOSTS} #### [`PAPERLESS_ALLOWED_HOSTS=<comma-separated-list>`](#PAPERLESS_ALLOWED_HOSTS) {#PAPERLESS_ALLOWED_HOSTS}
: If you're planning on putting Paperless on the open internet, then : If you expose Paperless to the internet, set `PAPERLESS_URL` to its public URL
you really should set this value to the domain name you're using. or list its public hostname in `PAPERLESS_ALLOWED_HOSTS` to help protect against
Failing to do so leaves you open to HTTP host header attacks. HTTP Host header attacks.
You can read more about this in [the Django project's documentation](https://docs.djangoproject.com/en/4.1/topics/security/#host-header-validation) You can read more about this in [the Django project's documentation](https://docs.djangoproject.com/en/4.1/topics/security/#host-header-validation)
Just remember that this is a comma-separated list, so Separate multiple hostnames with commas, as in `example.com,www.example.com`.
"example.com" is fine, as is "example.com,www.example.com", but
NOT " example.com" or "example.com,"
Can also be set using PAPERLESS_URL (see above). - With neither setting configured, Paperless accepts any host.
- With only `PAPERLESS_URL` set, Paperless accepts the hostname from that URL
"localhost" is always allowed for docker healthcheck and `localhost` (used by the Docker health check).
- `PAPERLESS_ALLOWED_HOSTS` adds hosts to `localhost` and the hostname from
Defaults to "\*", which is all hosts. `PAPERLESS_URL`, if set. A blank list adds no hosts.
- With `PAPERLESS_ALLOWED_HOSTS=*`, Paperless accepts any host, even when
`PAPERLESS_URL` is set.
#### [`PAPERLESS_CORS_ALLOWED_HOSTS=<comma-separated-list>`](#PAPERLESS_CORS_ALLOWED_HOSTS) {#PAPERLESS_CORS_ALLOWED_HOSTS} #### [`PAPERLESS_CORS_ALLOWED_HOSTS=<comma-separated-list>`](#PAPERLESS_CORS_ALLOWED_HOSTS) {#PAPERLESS_CORS_ALLOWED_HOSTS}
@@ -873,6 +873,14 @@ If both the [PAPERLESS_ACCOUNT_DEFAULT_GROUPS](#PAPERLESS_ACCOUNT_DEFAULT_GROUPS
Defaults to 1209600 (2 weeks) Defaults to 1209600 (2 weeks)
#### [`PAPERLESS_SESSION_CLEANUP_TASK_CRON=<cron expression>`](#PAPERLESS_SESSION_CLEANUP_TASK_CRON) {#PAPERLESS_SESSION_CLEANUP_TASK_CRON}
: Configures the schedule to remove expired sessions from the session store.
Django does not prune these automatically, so without this they would
accumulate indefinitely.
Defaults to `0 3 * * *`, once per day at 03:00.
#### [`PAPERLESS_TOKEN_THROTTLE_RATE=<rate>`](#PAPERLESS_TOKEN_THROTTLE_RATE) {#PAPERLESS_TOKEN_THROTTLE_RATE} #### [`PAPERLESS_TOKEN_THROTTLE_RATE=<rate>`](#PAPERLESS_TOKEN_THROTTLE_RATE) {#PAPERLESS_TOKEN_THROTTLE_RATE}
: Rate limit for the API token authentication endpoint (`/api/token/`), used to mitigate brute-force login attempts. : Rate limit for the API token authentication endpoint (`/api/token/`), used to mitigate brute-force login attempts.
@@ -1149,6 +1157,9 @@ they use underscores instead of dashes.
be changed by trusted users. This applies to the `AppConfig` permission as well, be changed by trusted users. This applies to the `AppConfig` permission as well,
which allows setting these arguments from the UI. which allows setting these arguments from the UI.
Arguments paperless manages itself (`input_file_or_options`, `output_file`
and `sidecar`) are rejected by the API and ignored if set here.
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:
+112 -6
View File
@@ -109,7 +109,7 @@ Think of versions as **file history** for a document.
Paperless-ngx includes management lists for tags, correspondents, document types Paperless-ngx includes management lists for tags, correspondents, document types
and more. These areas allow you to view, add, edit, delete and manage permissions and more. These areas allow you to view, add, edit, delete and manage permissions
for these objects. You can also manage saved views, mail accounts, mail rules, for these objects. You can also manage saved views, mail accounts, mail rules,
workflows and more from the management sections. workflows, [OCR templates](#ocr-templates) and more from the management sections.
### Nested Tags ### Nested Tags
@@ -493,7 +493,7 @@ fields and permissions, which will be merged.
#### Types {#workflow-trigger-types} #### Types {#workflow-trigger-types}
Currently, there are four events that correspond to workflow trigger 'types': Currently, there are five events that correspond to workflow trigger 'types':
1. **Consumption Started**: _before_ a document is consumed, so events can include filters by source (mail, consumption 1. **Consumption Started**: _before_ a document is consumed, so events can include filters by source (mail, consumption
folder or API), file path, file name, mail rule folder or API), file path, file name, mail rule
@@ -505,8 +505,10 @@ Currently, there are four events that correspond to workflow trigger 'types':
4. **Scheduled**: a scheduled trigger that can be used to run workflows at a specific time. The date used can be either the document 4. **Scheduled**: a scheduled trigger that can be used to run workflows at a specific time. The date used can be either the document
added, created, updated date or you can specify a (date) custom field. You can also specify a day offset from the date (positive added, created, updated date or you can specify a (date) custom field. You can also specify a day offset from the date (positive
offsets will trigger after the date, negative offsets will trigger before). offsets will trigger after the date, negative offsets will trigger before).
5. **Manual**: a trigger that only runs when you explicitly choose to run the workflow against one or more existing documents. See
[running a workflow manually](#running-a-workflow-manually) below.
The following flow diagram illustrates the four document trigger types: The following flow diagram illustrates the document trigger types:
```mermaid ```mermaid
flowchart TD flowchart TD
@@ -526,6 +528,10 @@ flowchart TD
matching matching
trigger(s)"} trigger(s)"}
manual{"Matching
'Manual'
trigger(s)"}
A[New Document] --> consumption A[New Document] --> consumption
consumption --> |Yes| C[Workflow Actions Run] consumption --> |Yes| C[Workflow Actions Run]
consumption --> |No| D consumption --> |No| D
@@ -543,6 +549,10 @@ flowchart TD
scheduled --> |Yes| N[Workflow Actions Run] scheduled --> |Yes| N[Workflow Actions Run]
scheduled --> |No| O[Document Saved] scheduled --> |No| O[Document Saved]
N --> O N --> O
P[User Runs Workflow] --> manual
manual --> |Yes| Q[Workflow Actions Run]
manual --> |No| R[Document Saved]
Q --> R
``` ```
#### Filters {#workflow-trigger-filters} #### Filters {#workflow-trigger-filters}
@@ -554,9 +564,9 @@ Workflows allow you to filter by:
- File path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for - File path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for
example, automatically assigning documents to different owners based on the upload directory. example, automatically assigning documents to different owners based on the upload directory.
- Mail rule. Choosing this option will force 'mail fetch' to be the workflow source. - Mail rule. Choosing this option will force 'mail fetch' to be the workflow source.
- Content matching (`Added`, `Updated` and `Scheduled` triggers only). Filter document content using the matching settings. - Content matching (`Added`, `Updated`, `Scheduled` and `Manual` triggers only). Filter document content using the matching settings.
There are also 'advanced' filters available for `Added`, `Updated` and `Scheduled` triggers: There are also 'advanced' filters available for `Added`, `Updated`, `Scheduled` and `Manual` triggers:
- Any Tags: Filter for documents with any of the specified tags. - Any Tags: Filter for documents with any of the specified tags.
- All Tags: Filter for documents with all of the specified tags. - All Tags: Filter for documents with all of the specified tags.
@@ -569,6 +579,21 @@ There are also 'advanced' filters available for `Added`, `Updated` and `Schedule
- Not Storage paths: Filter documents without any of these storage paths. - Not Storage paths: Filter documents without any of these storage paths.
- Custom field query: Filter documents with a custom field query (the same as used for the document list filters). - Custom field query: Filter documents with a custom field query (the same as used for the document list filters).
#### Running a workflow manually {#running-a-workflow-manually}
A workflow with a **Manual** trigger does not run automatically. Instead, it can be run on demand against existing
documents from the web UI:
- From the document list, select one or more documents and choose **Run workflow** from the "Actions" menu.
- From an individual document, choose **Run workflow** from the "Actions" menu.
You will be asked which workflow to run. By default the workflow's trigger filters still apply, so documents that do
not match are skipped. You can instead choose to **run on all selected documents**, which ignores the trigger filters
and runs the workflow's actions on every selected document.
Manual runs happen in the background. As with scheduled workflows, the document list is not refreshed automatically when
they finish, so you may need to reload the list to see the results.
### Workflow Actions ### Workflow Actions
#### Types {#workflow-action-types} #### Types {#workflow-action-types}
@@ -649,7 +674,7 @@ new version of the same document, this time with normal OCR/text extraction appl
a password-protected file added with this trigger will briefly exist as an un-OCR'd version before a password-protected file added with this trigger will briefly exist as an un-OCR'd version before
the properly processed version is created. the properly processed version is created.
**Document Added**, **Document Updated**, **Scheduled**: these triggers run against a document that **Document Added**, **Document Updated**, **Scheduled**, **Manual**: these triggers run against a document that
already exists, so password removal happens immediately: the decrypted content is queued for already exists, so password removal happens immediately: the decrypted content is queued for
consumption as a new version right away. Note that if the document's initial consumption also consumption as a new version right away. Note that if the document's initial consumption also
happened while it was still encrypted, that original version will likewise be missing OCR text. happened while it was still encrypted, that original version will likewise be missing OCR text.
@@ -795,6 +820,87 @@ The following custom field types are supported:
- `Document Link`: reference(s) to other document(s) displayed as links, automatically creates a symmetrical link in reverse - `Document Link`: reference(s) to other document(s) displayed as links, automatically creates a symmetrical link in reverse
- `Select`: a pre-defined list of strings from which the user can choose - `Select`: a pre-defined list of strings from which the user can choose
## OCR Templates {#ocr-templates}
OCR templates read text from fixed areas ("zones") of a document and write it to a
[custom field](#custom-fields), or to the document's title, archive serial number or created date. They are
useful for documents that always share the same layout, such as bills from a particular utility. They allow you
to draw a zone around the account number once, and every new bill gets its account number filled in.
Like tags or document types, each template has a [matching algorithm](advanced_usage.md#matching) that
decides which documents it automatically applies to (or not), based on their content.
### Creating a template
1. Open **OCR Templates** under "Manage" and choose **Add Template**.
2. Give the template a name and set up its matching, for example **Any word** with the utility's name. Choose
**None** for a template that should only ever be run manually.
3. Search for a sample document with the layout the template is for. Its pages are shown next to the template
settings.
4. Draw zones on the document. Click a zone to select and edit it.
5. Choose the field each zone fills.
6. Use **Test** to run OCR on the selected zone of the sample document and see the text it finds and the value
that would be written.
Zones are positioned relative to the sample document's page, so templates work best when the documents they
match have an identical layout. Keep the matching specific enough that it only catches documents with that
layout.
### Zone settings
- **Page**: the page the zone is on. Turn on **Last page** for zones that are always on the final page,
regardless of how many pages a document has.
- **OCR language**: the Tesseract languages used to read the zone. Languages other than the defaults must be
installed, see [`PAPERLESS_OCR_LANGUAGES`](configuration.md#PAPERLESS_OCR_LANGUAGES).
- **Transform**: post-processing applied to the text, such as stripping whitespace, keeping only numbers,
parsing a date or reading a QR code or barcode instead of text.
- **Date format**: for the "Parse date" transform, the format of the date using
[Python date codes](https://docs.python.org/3/library/datetime.html#format-codes), e.g. `%d.%m.%Y`.
Leave it empty to detect the format automatically.
- **Validation regex**: a regular expression the whole value must match. Values that do not match are not
written.
Zones can fill custom fields of type text, long text, URL, date, integer, number, monetary or boolean.
Document link and select fields are not supported.
### Combining zones
When several zones fill the same field, a **Combined value** setting appears for that field. Use it to arrange
the zones' values with `{Zone name}` tokens and any text in between, for example `{Start} - {End}`. If it
is left empty, the values are joined with spaces in zone order.
### When templates run
When a document is added, every enabled template that matches its content runs automatically. A template is
only used for a document if the document's owner can view the template, so users' templates apply to their own
documents, and unowned templates apply to everyone's. Templates run in name order, so if two templates fill the
same field, the later one wins.
Turn off **Enabled** to pause a template without deleting it. Disabled templates never run automatically, but
can still be run manually.
### Running templates manually
To run templates on documents that are already in Paperless-ngx, open the **Actions** menu on the document
details page:
- **Run Zone OCR** runs every enabled template that matches the document.
- The button next to it opens a dialog where you can choose one template to run
instead. A template you choose runs even if it does not match the document or is disabled.
Both actions use the file version selected in document details. The latest version is selected by default.
Extracted values update the document's metadata, which is shared across its versions.
The bulk editor's **Run Zone OCR** action opens the same dialog for the selected documents. Bulk runs use
the latest version of each document and are processed in the background.
!!! warning
Zone OCR overwrites the fields it fills. A zone that finds no value leaves its field unchanged.
Managing OCR templates requires the OCR template permissions. Running zone OCR on documents requires
permission to change them, and choosing a template requires permission to view it.
## PDF Actions ## PDF Actions
Paperless-ngx supports basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files). When viewing an individual document you can Paperless-ngx supports basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files). When viewing an individual document you can
+5 -11
View File
@@ -27,7 +27,7 @@ dependencies = [
# WARNING: django does not use semver. # WARNING: django does not use semver.
# Only patch versions are guaranteed to not introduce breaking changes. # Only patch versions are guaranteed to not introduce breaking changes.
"django~=5.2.18", "django~=5.2.18",
"django-allauth[mfa,socialaccount]~=65.20.0", "django-allauth[mfa,socialaccount]~=65.21.1",
"django-auditlog~=3.4.1", "django-auditlog~=3.4.1",
"django-cachalot~=2.9.0", "django-cachalot~=2.9.0",
"django-compression-middleware~=0.5.0", "django-compression-middleware~=0.5.0",
@@ -38,7 +38,6 @@ dependencies = [
"django-multiselectfield~=1.0.1", "django-multiselectfield~=1.0.1",
"django-rich~=2.2.0", "django-rich~=2.2.0",
"django-soft-delete~=1.0.18", "django-soft-delete~=1.0.18",
"django-treenode>=0.24",
"djangorestframework~=3.16", "djangorestframework~=3.16",
"drf-spectacular~=0.30", "drf-spectacular~=0.30",
"drf-spectacular-sidecar>=2026.10.1,<2026.11", "drf-spectacular-sidecar>=2026.10.1,<2026.11",
@@ -112,14 +111,14 @@ lint = [
testing = [ testing = [
"daphne", "daphne",
"factory-boy~=3.3.1", "factory-boy~=3.3.1",
"faker>=40.36,<40.40", "faker>=40.36,<40.44",
"imagehash", "imagehash",
"pytest~=9.1.1", "pytest~=9.1.1",
"pytest-cov~=7.1.0", "pytest-cov~=7.1.0",
"pytest-django>=4.12,<4.15", "pytest-django>=4.12,<4.15",
"pytest-env~=1.7.0", "pytest-env~=1.8.1",
"pytest-httpx", "pytest-httpx",
"pytest-mock~=3.15.1", "pytest-mock~=3.17.0",
"pytest-randomly~=5.0.0", "pytest-randomly~=5.0.0",
"pytest-rerunfailures~=16.4", "pytest-rerunfailures~=16.4",
"pytest-sugar", "pytest-sugar",
@@ -213,12 +212,7 @@ extend-select = [
"UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up "UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up
"W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w "W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
] ]
ignore = [ ignore = [ "DJ001", "ICN001", "PLC0415", "RUF012", "SIM105" ]
"DJ001",
"PLC0415",
"RUF012",
"SIM105",
]
# Migrations # Migrations
per-file-ignores."*/migrations/*.py" = [ per-file-ignores."*/migrations/*.py" = [
"E501", "E501",
+425 -296
View File
File diff suppressed because it is too large. Load diff
+14 -1
View File
@@ -13,6 +13,7 @@ import { DocumentDetailComponent } from './components/document-detail/document-d
import { DocumentListComponent } from './components/document-list/document-list.component' import { DocumentListComponent } from './components/document-list/document-list.component'
import { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.component' import { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.component'
import { MailComponent } from './components/manage/mail/mail.component' import { MailComponent } from './components/manage/mail/mail.component'
import { OcrTemplatesComponent } from './components/manage/ocr-templates/ocr-templates.component'
import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component' import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component'
import { ShareLinksComponent } from './components/manage/share-links/share-links.component' import { ShareLinksComponent } from './components/manage/share-links/share-links.component'
import { WorkflowsComponent } from './components/manage/workflows/workflows.component' import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
@@ -37,7 +38,7 @@ export const routes: Routes = [
path: 'dashboard', path: 'dashboard',
component: DashboardComponent, component: DashboardComponent,
data: { data: {
componentName: 'AppFrameComponent', componentName: 'DashboardComponent',
}, },
}, },
{ {
@@ -275,6 +276,18 @@ export const routes: Routes = [
componentName: 'WorkflowsComponent', componentName: 'WorkflowsComponent',
}, },
}, },
{
path: 'ocr-templates',
component: OcrTemplatesComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.OcrTemplate,
},
componentName: 'OcrTemplatesComponent',
},
},
{ {
path: 'mail', path: 'mail',
component: MailComponent, component: MailComponent,
@@ -113,6 +113,7 @@ const sidebarItemLabels: Record<HideableSidebarItemID, string> = {
[HideableSidebarItemID.SavedViews]: $localize`Saved Views`, [HideableSidebarItemID.SavedViews]: $localize`Saved Views`,
[HideableSidebarItemID.ShareLinks]: $localize`Share Links`, [HideableSidebarItemID.ShareLinks]: $localize`Share Links`,
[HideableSidebarItemID.Workflows]: $localize`Workflows`, [HideableSidebarItemID.Workflows]: $localize`Workflows`,
[HideableSidebarItemID.OcrTemplates]: $localize`OCR Templates`,
[HideableSidebarItemID.Mail]: $localize`Mail`, [HideableSidebarItemID.Mail]: $localize`Mail`,
[HideableSidebarItemID.Documentation]: $localize`Documentation`, [HideableSidebarItemID.Documentation]: $localize`Documentation`,
} }
@@ -7,10 +7,15 @@ import {
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule } from '@angular/forms' import { FormsModule } from '@angular/forms'
import { By } from '@angular/platform-browser' import { By } from '@angular/platform-browser'
import { Router } from '@angular/router' import {
ActivatedRoute,
convertToParamMap,
ParamMap,
Router,
} from '@angular/router'
import { RouterTestingModule } from '@angular/router/testing' import { RouterTestingModule } from '@angular/router/testing'
import { NgbModal, NgbModalRef, NgbModule } from '@ng-bootstrap/ng-bootstrap' import { NgbModal, NgbModalRef, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { of, throwError } from 'rxjs' import { BehaviorSubject, of, throwError } from 'rxjs'
import { routes } from 'src/app/app-routing.module' import { routes } from 'src/app/app-routing.module'
import { import {
PaperlessTask, PaperlessTask,
@@ -159,6 +164,7 @@ const sectionCountResponse = {
} }
describe('TasksComponent', () => { describe('TasksComponent', () => {
let queryParams: BehaviorSubject<ParamMap>
let component: TasksComponent let component: TasksComponent
let fixture: ComponentFixture<TasksComponent> let fixture: ComponentFixture<TasksComponent>
let tasksService: TasksService let tasksService: TasksService
@@ -169,6 +175,7 @@ describe('TasksComponent', () => {
let toastService: ToastService let toastService: ToastService
beforeEach(async () => { beforeEach(async () => {
queryParams = new BehaviorSubject(convertToParamMap({}))
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [ imports: [
NgbModule, NgbModule,
@@ -181,6 +188,17 @@ describe('TasksComponent', () => {
ConfirmDialogComponent, ConfirmDialogComponent,
], ],
providers: [ providers: [
{
provide: ActivatedRoute,
useValue: {
queryParamMap: queryParams.asObservable(),
snapshot: {
get queryParamMap() {
return queryParams.value
},
},
},
},
{ {
provide: PermissionsService, provide: PermissionsService,
useValue: { useValue: {
@@ -219,7 +237,11 @@ describe('TasksComponent', () => {
}) })
// answers task list requests with the fixture tasks matching their status filter // answers task list requests with the fixture tasks matching their status filter
function flushTaskLists(pageSize: number, count?: number) { function flushTaskLists(
pageSize: number,
count?: number,
filteredTasks: PaperlessTask[] = tasks
) {
httpTestingController httpTestingController
.match( .match(
(req) => (req) =>
@@ -228,7 +250,9 @@ describe('TasksComponent', () => {
) )
.forEach((req) => { .forEach((req) => {
const statuses = req.request.params.getAll('status') const statuses = req.request.params.getAll('status')
const results = tasks.filter((task) => statuses.includes(task.status)) const results = filteredTasks.filter((task) =>
statuses.includes(task.status)
)
req.flush({ count: count ?? results.length, results }) req.flush({ count: count ?? results.length, results })
}) })
} }
@@ -238,6 +262,71 @@ describe('TasksComponent', () => {
flushTaskLists(25, count) flushTaskLists(25, count)
} }
it('consumes a task link on load into the normal resettable Task ID filter', () => {
jest
.spyOn(tasksService, 'list')
.mockReturnValue(of({ count: 0, results: [] }))
jest
.spyOn(tasksService, 'statusCounts')
.mockReturnValue(of(sectionCountResponse))
jest.spyOn(router, 'navigate').mockResolvedValue(true)
queryParams.next(convertToParamMap({ task_id: 'linked-task' }))
fixture.destroy()
fixture = TestBed.createComponent(TasksComponent)
component = fixture.componentInstance
fixture.detectChanges()
expect(router.navigate).toHaveBeenCalledTimes(1)
expect(router.navigate).toHaveBeenCalledWith(
[],
expect.objectContaining({
queryParams: { task_id: null },
replaceUrl: true,
})
)
expect(component.filterTargetName).toBe('Task ID')
expect(component.filterText).toBe('linked-task')
expect(component.isFiltered).toBe(true)
component.setSection(TaskSection.InProgress)
expect(tasksService.list).toHaveBeenLastCalledWith(1, 25, {
task_id: 'linked-task',
status: [PaperlessTaskStatus.Pending, PaperlessTaskStatus.Started],
})
component.resetFilters()
expect(component.filterText).toBe('')
expect(component.isFiltered).toBe(false)
expect(tasksService.list).toHaveBeenLastCalledWith(1, 25, {
acknowledged: false,
status: [PaperlessTaskStatus.Pending, PaperlessTaskStatus.Started],
})
expect(router.navigate).toHaveBeenCalledTimes(1)
})
it('supports manually filtering by Task ID and clearing the text filter', () => {
jest
.spyOn(tasksService, 'list')
.mockReturnValue(of({ count: 0, results: [] }))
jest
.spyOn(tasksService, 'statusCounts')
.mockReturnValue(of(sectionCountResponse))
component.setSection(TaskSection.InProgress)
component.setFilterTarget(TaskFilterTargetID.TaskId)
component.filterText = tasks[0].task_id
jest.advanceTimersByTime(150)
expect(tasksService.list).toHaveBeenLastCalledWith(1, 25, {
task_id: tasks[0].task_id,
status: [PaperlessTaskStatus.Pending, PaperlessTaskStatus.Started],
})
component.pagedTasks.set(tasks)
expect(component.tasksForSection(TaskSection.NeedsAttention)).toEqual([
tasks[0],
])
component.resetFilter()
expect(tasksService.list).toHaveBeenLastCalledWith(1, 25, {
acknowledged: false,
status: [PaperlessTaskStatus.Pending, PaperlessTaskStatus.Started],
})
})
it('should display task sections with counts', () => { it('should display task sections with counts', () => {
expect(component.selectedSection).toBe(TaskSection.All) expect(component.selectedSection).toBe(TaskSection.All)
expect(component.selectedTaskType).toBeNull() expect(component.selectedTaskType).toBeNull()
@@ -771,6 +860,7 @@ describe('TasksComponent', () => {
input.nativeElement.value = '191092' input.nativeElement.value = '191092'
input.nativeElement.dispatchEvent(new Event('input')) input.nativeElement.dispatchEvent(new Event('input'))
jest.advanceTimersByTime(150) jest.advanceTimersByTime(150)
flushTaskLists(10, undefined, [tasks[1]])
fixture.detectChanges() fixture.detectChanges()
expect(component.filterText).toEqual('191092') expect(component.filterText).toEqual('191092')
expect(component.tasksForSection(TaskSection.NeedsAttention)).toHaveLength( expect(component.tasksForSection(TaskSection.NeedsAttention)).toHaveLength(
@@ -782,6 +872,7 @@ describe('TasksComponent', () => {
component.setSection(TaskSection.InProgress) component.setSection(TaskSection.InProgress)
component.filterText = 'system' component.filterText = 'system'
jest.advanceTimersByTime(150) jest.advanceTimersByTime(150)
flushTaskLists(25, undefined, [tasks[6]])
expect(component.tasksForSection(TaskSection.InProgress)).toHaveLength(1) expect(component.tasksForSection(TaskSection.InProgress)).toHaveLength(1)
expect(component.tasksForSection(TaskSection.InProgress)[0].task_type).toBe( expect(component.tasksForSection(TaskSection.InProgress)[0].task_type).toBe(
@@ -815,6 +906,7 @@ describe('TasksComponent', () => {
input.nativeElement.value = 'duplicate' input.nativeElement.value = 'duplicate'
input.nativeElement.dispatchEvent(new Event('input')) input.nativeElement.dispatchEvent(new Event('input'))
jest.advanceTimersByTime(150) jest.advanceTimersByTime(150)
flushTaskLists(25, undefined, [tasks[0], tasks[1]])
fixture.detectChanges() fixture.detectChanges()
expect(component.filterText).toEqual('duplicate') expect(component.filterText).toEqual('duplicate')
expect(component.tasksForSection(TaskSection.NeedsAttention)).toHaveLength( expect(component.tasksForSection(TaskSection.NeedsAttention)).toHaveLength(
@@ -822,6 +914,53 @@ describe('TasksComponent', () => {
) )
}) })
it('should show server-matched names when the displayed source is translated', () => {
component.setSection(TaskSection.InProgress)
flushTaskLists(25)
component.filterText = 'email'
jest.advanceTimersByTime(150)
const request = httpTestingController.expectOne(
(req) =>
req.url === `${environment.apiBaseUrl}tasks/` &&
req.params.get('name') === 'email' &&
req.params.get('page_size') === '25'
)
const translatedTask = {
...tasks[5],
trigger_source_display: 'E-Mail',
}
request.flush({ count: 1, results: [translatedTask] })
fixture.detectChanges()
expect(component.totalTasks()).toBe(1)
expect(component.visibleTasks).toEqual([translatedTask])
})
it('should show server-matched duplicates when the displayed result is translated', () => {
component.setSection(TaskSection.NeedsAttention)
flushTaskLists(25)
jest
.spyOn(component, 'taskResultMessage')
.mockReturnValue('Duplikat von Document #311')
component.setFilterTarget(TaskFilterTargetID.Result)
component.filterText = 'duplicate'
jest.advanceTimersByTime(150)
const request = httpTestingController.expectOne(
(req) =>
req.url === `${environment.apiBaseUrl}tasks/` &&
req.params.get('result') === 'duplicate' &&
req.params.get('page_size') === '25'
)
request.flush({ count: 1, results: [tasks[1]] })
fixture.detectChanges()
expect(component.totalTasks()).toBe(1)
expect(component.visibleTasks).toEqual([tasks[1]])
expect(fixture.nativeElement.textContent).toContain('191092.pdf')
})
it('should prefer explicit reason in the result message', () => { it('should prefer explicit reason in the result message', () => {
expect( expect(
component.taskResultMessage({ component.taskResultMessage({
@@ -1,7 +1,7 @@
import { JsonPipe, NgTemplateOutlet } from '@angular/common' import { JsonPipe, NgTemplateOutlet } from '@angular/common'
import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core' import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { Router, RouterLink } from '@angular/router' import { ActivatedRoute, Router, RouterLink } from '@angular/router'
import { import {
NgbCollapseModule, NgbCollapseModule,
NgbDropdownModule, NgbDropdownModule,
@@ -47,11 +47,13 @@ export enum TaskSection {
export enum TaskFilterTargetID { export enum TaskFilterTargetID {
Name, Name,
Result, Result,
TaskId,
} }
const FILTER_TARGETS = [ const FILTER_TARGETS = [
{ id: TaskFilterTargetID.Name, name: $localize`Name` }, { id: TaskFilterTargetID.Name, name: $localize`Name` },
{ id: TaskFilterTargetID.Result, name: $localize`Result` }, { id: TaskFilterTargetID.Result, name: $localize`Result` },
{ id: TaskFilterTargetID.TaskId, name: $localize`Task ID` },
] ]
const SECTION_LABELS = { const SECTION_LABELS = {
@@ -161,6 +163,7 @@ export class TasksComponent
tasksService = inject(TasksService) tasksService = inject(TasksService)
private modalService = inject(NgbModal) private modalService = inject(NgbModal)
private readonly router = inject(Router) private readonly router = inject(Router)
private readonly route = inject(ActivatedRoute)
private readonly toastService = inject(ToastService) private readonly toastService = inject(ToastService)
readonly TaskSection = TaskSection readonly TaskSection = TaskSection
@@ -275,6 +278,17 @@ export class TasksComponent
} }
ngOnInit() { ngOnInit() {
const taskId = this.route.snapshot.queryParamMap.get('task_id')
if (taskId) {
this.filterTargetID = TaskFilterTargetID.TaskId
this._filterText = taskId
this.router.navigate([], {
relativeTo: this.route,
queryParams: { task_id: null },
queryParamsHandling: 'merge',
replaceUrl: true,
})
}
this.tasksService.reload() this.tasksService.reload()
this.reloadPage() this.reloadPage()
timer(5000, 5000) timer(5000, 5000)
@@ -636,17 +650,12 @@ export class TasksComponent
const query = this._filterText.toLowerCase() const query = this._filterText.toLowerCase()
if (this.filterTargetID == TaskFilterTargetID.Name) { if (this.filterTargetID === TaskFilterTargetID.TaskId) {
return [ return task.task_id.toLowerCase() === query
this.taskDisplayName(task),
task.task_type_display,
task.trigger_source_display,
]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(query))
} }
return this.taskResultMessage(task)?.toLowerCase().includes(query) ?? false // Text filtering now handled API-side
return true
} }
private tasksForOptionCounts({ private tasksForOptionCounts({
@@ -706,9 +715,15 @@ export class TasksComponent
} }
if (this._filterText.length) { if (this._filterText.length) {
params[ const parameter = {
this.filterTargetID === TaskFilterTargetID.Name ? 'name' : 'result' [TaskFilterTargetID.Name]: 'name',
] = this._filterText [TaskFilterTargetID.Result]: 'result',
[TaskFilterTargetID.TaskId]: 'task_id',
}[this.filterTargetID]
params[parameter] = this._filterText
if (this.filterTargetID === TaskFilterTargetID.TaskId) {
delete params.acknowledged
}
} }
return params return params
@@ -289,6 +289,17 @@
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Workflows" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Workflows, $event)"></pngx-input-switch> <pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Workflows" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Workflows, $event)"></pngx-input-switch>
} }
</li> </li>
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.OcrTemplates) && !settingsService.organizingSidebarItems()"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.OcrTemplate }">
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.OcrTemplates)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="ocr-templates" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="OCR Templates" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<pngx-bs-icon class="me-2" name="bounding-box-circles"></pngx-bs-icon><span class="nav-link-label"><ng-container i18n>OCR Templates</ng-container></span>
</a>
@if (settingsService.organizingSidebarItems()) {
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="OCR Templates" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.OcrTemplates)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.OcrTemplates, $event)"></pngx-input-switch>
}
</li>
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail) && !settingsService.organizingSidebarItems()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }" <li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail) && !settingsService.organizingSidebarItems()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
tourAnchor="tour.mail"> tourAnchor="tour.mail">
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail" <a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
@@ -11,8 +11,10 @@ import { ActivatedRoute, Router } from '@angular/router'
import { RouterTestingModule } from '@angular/router/testing' import { RouterTestingModule } from '@angular/router/testing'
import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap' import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap' import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
import { of, throwError } from 'rxjs' import { of, Subject, throwError } from 'rxjs'
import { routes } from 'src/app/app-routing.module' import { routes } from 'src/app/app-routing.module'
import { BulkOperationStatus } from 'src/app/data/bulk-operation'
import { PaperlessTaskStatus } from 'src/app/data/paperless-task'
import { SavedView } from 'src/app/data/saved-view' import { SavedView } from 'src/app/data/saved-view'
import { HideableSidebarItemID, SETTINGS_KEYS } from 'src/app/data/ui-settings' import { HideableSidebarItemID, SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
@@ -21,10 +23,11 @@ import {
DjangoMessageLevel, DjangoMessageLevel,
DjangoMessagesService, DjangoMessagesService,
} from 'src/app/services/django-messages.service' } from 'src/app/services/django-messages.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { OpenDocumentsService } from 'src/app/services/open-documents.service' import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import { import {
PermissionType,
PermissionsService, PermissionsService,
PermissionType,
} from 'src/app/services/permissions.service' } from 'src/app/services/permissions.service'
import { RemoteVersionService } from 'src/app/services/rest/remote-version.service' import { RemoteVersionService } from 'src/app/services/rest/remote-version.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service' import { SavedViewService } from 'src/app/services/rest/saved-view.service'
@@ -95,6 +98,7 @@ describe('AppFrameComponent', () => {
let savedViewSpy let savedViewSpy
let modalService: NgbModal let modalService: NgbModal
let maybeRefreshSpy let maybeRefreshSpy
let bulkEvents: Subject<BulkOperationStatus>
let tasksService: TasksService let tasksService: TasksService
beforeEach(async () => { beforeEach(async () => {
@@ -182,6 +186,10 @@ describe('AppFrameComponent', () => {
savedViewSpy = jest.spyOn(savedViewService, 'reload') savedViewSpy = jest.spyOn(savedViewService, 'reload')
maybeRefreshSpy = jest.spyOn(savedViewService, 'maybeRefreshDocumentCounts') maybeRefreshSpy = jest.spyOn(savedViewService, 'maybeRefreshDocumentCounts')
bulkEvents = new Subject<BulkOperationStatus>()
jest
.spyOn(TestBed.inject(WebsocketStatusService), 'onBulkOperationStatus')
.mockReturnValue(bulkEvents.asObservable())
fixture = TestBed.createComponent(AppFrameComponent) fixture = TestBed.createComponent(AppFrameComponent)
component = fixture.componentInstance component = fixture.componentInstance
@@ -190,6 +198,78 @@ describe('AppFrameComponent', () => {
fixture.detectChanges() fixture.detectChanges()
}) })
it('refreshes and notifies on completion without client-side task tracking', () => {
const list = TestBed.inject(DocumentListViewService)
const reload = jest.spyOn(list, 'reload').mockImplementation(() => {})
jest.spyOn(list, 'reduceSelectionToFilter').mockImplementation(() => {})
const tasksReload = jest
.spyOn(tasksService, 'reload')
.mockImplementation(() => {})
const success = jest.spyOn(toastService, 'showSuccess')
const refresh = jest
.spyOn(openDocumentsService, 'refreshDocument')
.mockImplementation(() => {})
jest
.spyOn(openDocumentsService, 'getOpenDocuments')
.mockReturnValue([document])
tasksReload.mockClear()
maybeRefreshSpy.mockClear()
bulkEvents.next({
task_id: 'bulk-task',
operation: 'set_permissions',
status: PaperlessTaskStatus.Started,
})
expect(reload).not.toHaveBeenCalled()
bulkEvents.next({
task_id: 'bulk-task',
operation: 'set_permissions',
status: PaperlessTaskStatus.Success,
})
expect(reload).toHaveBeenCalledTimes(1)
expect(reload).toHaveBeenCalledWith(null, false)
expect(refresh).toHaveBeenCalledWith(document.id)
expect(maybeRefreshSpy).toHaveBeenCalledTimes(1)
expect(tasksReload).toHaveBeenCalledTimes(1)
expect(success).toHaveBeenCalledWith('Bulk operation completed.')
fixture.destroy()
bulkEvents.next({
task_id: 'bulk-task',
operation: 'set_permissions',
status: PaperlessTaskStatus.Success,
})
expect(reload).toHaveBeenCalledTimes(1)
})
it('refreshes and reports failed or revoked bulk operations', () => {
const list = TestBed.inject(DocumentListViewService)
const reload = jest.spyOn(list, 'reload').mockImplementation(() => {})
jest.spyOn(list, 'reduceSelectionToFilter').mockImplementation(() => {})
jest.spyOn(tasksService, 'reload').mockImplementation(() => {})
const error = jest.spyOn(toastService, 'showError')
const success = jest.spyOn(toastService, 'showSuccess')
for (const status of [
PaperlessTaskStatus.Failure,
PaperlessTaskStatus.Revoked,
]) {
bulkEvents.next({
task_id: 'bulk-task',
operation: 'set_permissions',
status,
error:
status === PaperlessTaskStatus.Revoked
? 'Task expired before execution.'
: 'Could not update',
})
}
expect(reload).toHaveBeenCalledTimes(2)
expect(error).toHaveBeenCalledWith(expect.any(String), 'Could not update')
expect(error).toHaveBeenCalledWith(
'Bulk operation revoked.',
'Task expired before execution.'
)
expect(success).not.toHaveBeenCalled()
})
it('should initialize the saved view service', () => { it('should initialize the saved view service', () => {
expect(savedViewSpy).toHaveBeenCalled() expect(savedViewSpy).toHaveBeenCalled()
}) })
@@ -293,6 +373,7 @@ describe('AppFrameComponent', () => {
HideableSidebarItemID.Dashboard, HideableSidebarItemID.Dashboard,
HideableSidebarItemID.Workflows, HideableSidebarItemID.Workflows,
HideableSidebarItemID.ShareLinks, HideableSidebarItemID.ShareLinks,
HideableSidebarItemID.OcrTemplates,
]) ])
fixture.detectChanges() fixture.detectChanges()
@@ -308,6 +389,10 @@ describe('AppFrameComponent', () => {
fixture.nativeElement.querySelector('[routerLink="share-links"]') fixture.nativeElement.querySelector('[routerLink="share-links"]')
.parentElement.classList .parentElement.classList
).toContain('d-none') ).toContain('d-none')
expect(
fixture.nativeElement.querySelector('[routerLink="ocr-templates"]')
.parentElement.classList
).toContain('d-none')
expect( expect(
fixture.nativeElement.querySelector('[routerLink="mail"]').parentElement fixture.nativeElement.querySelector('[routerLink="mail"]').parentElement
.classList .classList
@@ -320,12 +405,21 @@ describe('AppFrameComponent', () => {
]) ])
settingsService.sidebarHiddenItemsEditing.set([ settingsService.sidebarHiddenItemsEditing.set([
HideableSidebarItemID.Dashboard, HideableSidebarItemID.Dashboard,
HideableSidebarItemID.OcrTemplates,
]) ])
fixture.detectChanges() fixture.detectChanges()
expect( expect(
fixture.nativeElement.querySelectorAll('pngx-input-switch').length fixture.nativeElement.querySelectorAll('pngx-input-switch').length
).toBe(6) ).toBe(7)
const ocrTemplatesLink = fixture.nativeElement.querySelector(
'[routerLink="ocr-templates"]'
)
expect(ocrTemplatesLink.parentElement.classList).not.toContain('d-none')
expect(ocrTemplatesLink.classList).toContain('opacity-50')
expect(
ocrTemplatesLink.parentElement.querySelector('pngx-input-switch')
).not.toBeNull()
expect( expect(
fixture.nativeElement.querySelector('[routerLink="dashboard"]') fixture.nativeElement.querySelector('[routerLink="dashboard"]')
.parentElement.classList .parentElement.classList
@@ -8,12 +8,14 @@ import {
import { NgClass } from '@angular/common' import { NgClass } from '@angular/common'
import { import {
Component, Component,
DestroyRef,
HostListener, HostListener,
inject, inject,
OnInit, OnInit,
signal, signal,
viewChild, viewChild,
} from '@angular/core' } from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { FormsModule } from '@angular/forms' import { FormsModule } from '@angular/forms'
import { ActivatedRoute, Router, RouterModule } from '@angular/router' import { ActivatedRoute, Router, RouterModule } from '@angular/router'
import { import {
@@ -24,10 +26,11 @@ import {
NgbPopoverModule, NgbPopoverModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap' import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
import { Observable } from 'rxjs' import { filter, Observable } from 'rxjs'
import { first } from 'rxjs/operators' import { first } from 'rxjs/operators'
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component' import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
import { Document } from 'src/app/data/document' import { Document } from 'src/app/data/document'
import { PaperlessTaskStatus } from 'src/app/data/paperless-task'
import { SavedView } from 'src/app/data/saved-view' import { SavedView } from 'src/app/data/saved-view'
import { import {
CollapsibleSection, CollapsibleSection,
@@ -41,6 +44,7 @@ import {
DjangoMessageLevel, DjangoMessageLevel,
DjangoMessagesService, DjangoMessagesService,
} from 'src/app/services/django-messages.service' } from 'src/app/services/django-messages.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { OpenDocumentsService } from 'src/app/services/open-documents.service' import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import { import {
PermissionAction, PermissionAction,
@@ -101,6 +105,8 @@ export class AppFrameComponent
extends ComponentWithPermissions extends ComponentWithPermissions
implements OnInit, ComponentCanDeactivate implements OnInit, ComponentCanDeactivate
{ {
private destroyRef = inject(DestroyRef)
private documentList = inject(DocumentListViewService)
router = inject(Router) router = inject(Router)
private activatedRoute = inject(ActivatedRoute) private activatedRoute = inject(ActivatedRoute)
private openDocumentsService = inject(OpenDocumentsService) private openDocumentsService = inject(OpenDocumentsService)
@@ -164,6 +170,46 @@ export class AppFrameComponent
} }
ngOnInit(): void { ngOnInit(): void {
this.websocketStatusService
.onBulkOperationStatus()
.pipe(
filter(({ status }) =>
[
PaperlessTaskStatus.Success,
PaperlessTaskStatus.Failure,
PaperlessTaskStatus.Revoked,
].includes(status)
),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(({ status, error }) => {
this.documentList.reload(null, false)
this.documentList.reduceSelectionToFilter()
// maybe someday we can optimize to only refresh affected documents
this.openDocumentsService.getOpenDocuments().forEach((doc) => {
this.openDocumentsService.refreshDocument(doc.id)
})
this.savedViewService.maybeRefreshDocumentCounts()
if (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.PaperlessTask
)
) {
this.tasksService.reload()
}
if (status === PaperlessTaskStatus.Success) {
this.toastService.showSuccess($localize`Bulk operation completed.`)
} else if (status === PaperlessTaskStatus.Revoked) {
this.toastService.showError($localize`Bulk operation revoked.`, error)
} else {
this.toastService.showError(
$localize`Bulk operation failed. Some changes may already have been applied.`,
error
)
}
})
this.lastScrollY = window.scrollY this.lastScrollY = window.scrollY
this.detectClassicScrollbars() this.detectClassicScrollbars()
@@ -42,11 +42,16 @@
<span class="text-break">{{ message.content }}</span> <span class="text-break">{{ message.content }}</span>
} }
@if (message.role === 'assistant' && !message.isStreaming && message.references?.length) { @if (message.role === 'assistant' && !message.isStreaming && message.references?.length) {
<div class="chat-references list-group mt-3"> <div class="chat-references mt-2 pt-2 border-top">
<div class="list-group-item text-muted small py-1" i18n>Sources</div> <div class="d-flex align-items-center text-muted mb-1">
@for (reference of message.references; track reference.id) { <span i18n>Sources</span>
<a class="list-group-item list-group-item-action d-flex align-items-center text-primary" [routerLink]="['/documents', reference.id]" [title]="reference.title"> @if (hiddenReferenceCount(message) > 0 && !expandedReferences().has(message)) {
<pngx-bs-icon width="0.9em" height="0.9em" name="file-text" class="me-2 flex-shrink-0"></pngx-bs-icon><span class="text-truncate">{{ reference.title }}</span> <button type="button" class="badge rounded-pill bg-body-secondary text-body-secondary fw-normal border-0 ms-2" (click)="expandReferences(message)" i18n>+{{ hiddenReferenceCount(message) }} more</button>
}
</div>
@for (reference of visibleReferences(message); track reference.id) {
<a class="reference-link d-flex align-items-center lh-sm text-primary text-decoration-none py-1" [routerLink]="['/documents', reference.id]" [title]="reference.title">
<pngx-bs-icon width="0.8em" height="0.8em" name="file-text" class="me-2 flex-shrink-0"></pngx-bs-icon><span class="text-truncate">{{ reference.title }}</span>
</a> </a>
} }
</div> </div>
@@ -12,6 +12,10 @@
display: none; display: none;
} }
.message .flex-grow-1 {
min-width: 0;
}
.dropdown-item { .dropdown-item {
white-space: initial; white-space: initial;
} }
@@ -14,6 +14,7 @@ import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon
import { MarkdownPipe } from 'src/app/pipes/markdown.pipe' import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'
import { import {
ChatMessage, ChatMessage,
ChatReference,
ChatService, ChatService,
parseChatResponse, parseChatResponse,
} from 'src/app/services/chat.service' } from 'src/app/services/chat.service'
@@ -40,8 +41,29 @@ export class ChatComponent implements OnInit {
readonly input = signal('') readonly input = signal('')
readonly documentId = signal<number>(undefined) readonly documentId = signal<number>(undefined)
readonly slowResponse = signal(false) readonly slowResponse = signal(false)
readonly expandedReferences = signal<Set<ChatMessage>>(new Set())
public popperOptions = pngxPopperOptions public popperOptions = pngxPopperOptions
static readonly COLLAPSED_REFERENCE_COUNT = 2
public visibleReferences(message: ChatMessage): ChatReference[] {
if (this.expandedReferences().has(message)) {
return message.references
}
return message.references.slice(0, ChatComponent.COLLAPSED_REFERENCE_COUNT)
}
public hiddenReferenceCount(message: ChatMessage): number {
return Math.max(
0,
message.references.length - ChatComponent.COLLAPSED_REFERENCE_COUNT
)
}
public expandReferences(message: ChatMessage): void {
this.expandedReferences.update((expanded) => new Set(expanded).add(message))
}
private chatService: ChatService = inject(ChatService) private chatService: ChatService = inject(ChatService)
private router: Router = inject(Router) private router: Router = inject(Router)
@@ -16,6 +16,7 @@ import basket from 'bootstrap-icons/icons/basket.svg'
import bell from 'bootstrap-icons/icons/bell.svg' import bell from 'bootstrap-icons/icons/bell.svg'
import bodyText from 'bootstrap-icons/icons/body-text.svg' import bodyText from 'bootstrap-icons/icons/body-text.svg'
import bookmark from 'bootstrap-icons/icons/bookmark.svg' import bookmark from 'bootstrap-icons/icons/bookmark.svg'
import boundingBoxCircles from 'bootstrap-icons/icons/bounding-box-circles.svg'
import boxArrowInRight from 'bootstrap-icons/icons/box-arrow-in-right.svg' import boxArrowInRight from 'bootstrap-icons/icons/box-arrow-in-right.svg'
import boxArrowUpRight from 'bootstrap-icons/icons/box-arrow-up-right.svg' import boxArrowUpRight from 'bootstrap-icons/icons/box-arrow-up-right.svg'
import boxArrowUp from 'bootstrap-icons/icons/box-arrow-up.svg' import boxArrowUp from 'bootstrap-icons/icons/box-arrow-up.svg'
@@ -77,6 +78,7 @@ import fileEarmarkMinus from 'bootstrap-icons/icons/file-earmark-minus.svg'
import fileEarmarkPerson from 'bootstrap-icons/icons/file-earmark-person.svg' import fileEarmarkPerson from 'bootstrap-icons/icons/file-earmark-person.svg'
import fileEarmarkPlus from 'bootstrap-icons/icons/file-earmark-plus.svg' import fileEarmarkPlus from 'bootstrap-icons/icons/file-earmark-plus.svg'
import fileEarmarkRichtext from 'bootstrap-icons/icons/file-earmark-richtext.svg' import fileEarmarkRichtext from 'bootstrap-icons/icons/file-earmark-richtext.svg'
import fileEarmarkRuled from 'bootstrap-icons/icons/file-earmark-ruled.svg'
import fileEarmarkSpreadsheet from 'bootstrap-icons/icons/file-earmark-spreadsheet.svg' import fileEarmarkSpreadsheet from 'bootstrap-icons/icons/file-earmark-spreadsheet.svg'
import fileEarmark from 'bootstrap-icons/icons/file-earmark.svg' import fileEarmark from 'bootstrap-icons/icons/file-earmark.svg'
import fileText from 'bootstrap-icons/icons/file-text.svg' import fileText from 'bootstrap-icons/icons/file-text.svg'
@@ -118,6 +120,7 @@ import personFill from 'bootstrap-icons/icons/person-fill.svg'
import personLock from 'bootstrap-icons/icons/person-lock.svg' import personLock from 'bootstrap-icons/icons/person-lock.svg'
import personSquare from 'bootstrap-icons/icons/person-square.svg' import personSquare from 'bootstrap-icons/icons/person-square.svg'
import person from 'bootstrap-icons/icons/person.svg' import person from 'bootstrap-icons/icons/person.svg'
import playCircle from 'bootstrap-icons/icons/play-circle.svg'
import playFill from 'bootstrap-icons/icons/play-fill.svg' import playFill from 'bootstrap-icons/icons/play-fill.svg'
import plusCircle from 'bootstrap-icons/icons/plus-circle.svg' import plusCircle from 'bootstrap-icons/icons/plus-circle.svg'
import plus from 'bootstrap-icons/icons/plus.svg' import plus from 'bootstrap-icons/icons/plus.svg'
@@ -130,6 +133,7 @@ import search from 'bootstrap-icons/icons/search.svg'
import send from 'bootstrap-icons/icons/send.svg' import send from 'bootstrap-icons/icons/send.svg'
import shop from 'bootstrap-icons/icons/shop.svg' import shop from 'bootstrap-icons/icons/shop.svg'
import slashCircle from 'bootstrap-icons/icons/slash-circle.svg' import slashCircle from 'bootstrap-icons/icons/slash-circle.svg'
import sliders2 from 'bootstrap-icons/icons/sliders2.svg'
import sliders2Vertical from 'bootstrap-icons/icons/sliders2-vertical.svg' import sliders2Vertical from 'bootstrap-icons/icons/sliders2-vertical.svg'
import sortAlphaDown from 'bootstrap-icons/icons/sort-alpha-down.svg' import sortAlphaDown from 'bootstrap-icons/icons/sort-alpha-down.svg'
import sortAlphaUpAlt from 'bootstrap-icons/icons/sort-alpha-up-alt.svg' import sortAlphaUpAlt from 'bootstrap-icons/icons/sort-alpha-up-alt.svg'
@@ -178,6 +182,7 @@ export const icons: Record<string, string> = {
braces, braces,
bodyText, bodyText,
bookmark, bookmark,
boundingBoxCircles,
boxArrowInRight, boxArrowInRight,
boxArrowUp, boxArrowUp,
boxArrowUpRight, boxArrowUpRight,
@@ -239,6 +244,7 @@ export const icons: Record<string, string> = {
fileEarmarkPerson, fileEarmarkPerson,
fileEarmarkPlus, fileEarmarkPlus,
fileEarmarkRichtext, fileEarmarkRichtext,
fileEarmarkRuled,
fileEarmarkSpreadsheet, fileEarmarkSpreadsheet,
files, files,
fileText, fileText,
@@ -279,6 +285,7 @@ export const icons: Record<string, string> = {
personFillLock, personFillLock,
personLock, personLock,
personSquare, personSquare,
playCircle,
playFill, playFill,
plus, plus,
plusCircle, plusCircle,
@@ -290,6 +297,7 @@ export const icons: Record<string, string> = {
search, search,
send, send,
slashCircle, slashCircle,
sliders2,
shop, shop,
sliders2Vertical, sliders2Vertical,
sortAlphaDown, sortAlphaDown,
@@ -0,0 +1,32 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
</button>
</div>
<div class="modal-body">
@if (message) {
<p class="text-break" [innerHTML]="message"></p>
}
<div class="mb-3">
<label class="form-label" for="runWorkflowSelect" i18n>Workflow</label>
<select class="form-select" id="runWorkflowSelect" [(ngModel)]="workflowId" (ngModelChange)="workflowSelected()">
<option [ngValue]="null" disabled i18n>Select a workflow</option>
@for (workflow of workflows; track workflow.id) {
<option [ngValue]="workflow.id">{{workflow.name}}</option>
}
</select>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="runWorkflowForce" [(ngModel)]="force" />
<label class="form-check-label" for="runWorkflowForce" i18n>Run on all selected documents</label>
<div class="form-text" i18n>Ignore the workflow's trigger filters and run on every selected document.</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
{{btnCaption}}
</button>
</div>
@@ -0,0 +1,42 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { RunWorkflowConfirmDialogComponent } from './run-workflow-confirm-dialog.component'
describe('RunWorkflowConfirmDialogComponent', () => {
let component: RunWorkflowConfirmDialogComponent
let fixture: ComponentFixture<RunWorkflowConfirmDialogComponent>
beforeEach(async () => {
TestBed.configureTestingModule({
providers: [NgbActiveModal],
imports: [RunWorkflowConfirmDialogComponent],
}).compileComponents()
fixture = TestBed.createComponent(RunWorkflowConfirmDialogComponent)
component = fixture.componentInstance
component.workflows = [{ id: 5, name: 'Manual workflow' } as any]
fixture.detectChanges()
})
it('should disable confirm until a workflow is selected and not force by default', () => {
expect(component.confirmButtonEnabled).toBeFalsy()
expect(component.force).toBeFalsy()
component.workflowId = 5
component.workflowSelected()
expect(component.confirmButtonEnabled).toBeTruthy()
component.workflowId = null
component.workflowSelected()
expect(component.confirmButtonEnabled).toBeFalsy()
})
it('should render an option per workflow', () => {
const options = fixture.nativeElement.querySelectorAll(
'#runWorkflowSelect option'
)
// placeholder option + one per workflow
expect(options).toHaveLength(2)
expect(options[1].textContent).toContain('Manual workflow')
})
})
@@ -0,0 +1,24 @@
import { Component } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { Workflow } from 'src/app/data/workflow'
import { ConfirmDialogComponent } from '../confirm-dialog.component'
@Component({
selector: 'pngx-run-workflow-confirm-dialog',
templateUrl: './run-workflow-confirm-dialog.component.html',
imports: [FormsModule],
})
export class RunWorkflowConfirmDialogComponent extends ConfirmDialogComponent {
public workflows: Workflow[] = []
public workflowId: number = null
// i.e. ignore the workflow's trigger filters
public force: boolean = false
override confirmButtonEnabled = false
public workflowSelected(): void {
this.confirmButtonEnabled = !!this.workflowId
}
}
@@ -0,0 +1,30 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
</button>
</div>
<div class="modal-body">
@if (messageBold) {
<p class="text-break"><b>{{messageBold}}</b></p>
}
@if (message) {
<p class="text-break" [innerHTML]="message"></p>
}
<pngx-input-select
[items]="templates()"
[(ngModel)]="template"
[allowNull]="true"
title="OCR template"
i18n-title
placeholder="All matching templates"
i18n-placeholder
></pngx-input-select>
</div>
<div class="modal-footer">
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()">
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled()">
{{btnCaption}}
</button>
</div>
@@ -0,0 +1,52 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { of } from 'rxjs'
import { MATCH_NONE } from 'src/app/data/matching-model'
import { OcrTemplate } from 'src/app/data/ocr-template'
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
import { ZoneOcrConfirmDialogComponent } from './zone-ocr-confirm-dialog.component'
const templates: OcrTemplate[] = [
{
id: 1,
name: 'Invoices',
match: '',
matching_algorithm: MATCH_NONE,
sample_document: null,
source_width: 0,
source_height: 0,
enabled: true,
zones: [],
},
]
describe('ZoneOcrConfirmDialogComponent', () => {
let component: ZoneOcrConfirmDialogComponent
let fixture: ComponentFixture<ZoneOcrConfirmDialogComponent>
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
NgbActiveModal,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
imports: [ZoneOcrConfirmDialogComponent],
})
jest
.spyOn(TestBed.inject(OcrTemplateService), 'listAll')
.mockReturnValue(of({ count: 1, all: [1], results: templates }))
fixture = TestBed.createComponent(ZoneOcrConfirmDialogComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should offer templates and default to all matching templates', () => {
expect(component.templates()).toEqual(templates)
expect(component.template).toBeNull()
expect(fixture.nativeElement.textContent).toContain('OCR template')
})
})
@@ -0,0 +1,31 @@
import { Component, OnInit, inject, signal } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { takeUntil } from 'rxjs'
import { OcrTemplate } from 'src/app/data/ocr-template'
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
import { SelectComponent } from '../../input/select/select.component'
import { ConfirmDialogComponent } from '../confirm-dialog.component'
@Component({
selector: 'pngx-zone-ocr-confirm-dialog',
templateUrl: './zone-ocr-confirm-dialog.component.html',
imports: [FormsModule, SelectComponent],
})
export class ZoneOcrConfirmDialogComponent
extends ConfirmDialogComponent
implements OnInit
{
private readonly ocrTemplateService = inject(OcrTemplateService)
public readonly templates = signal<OcrTemplate[]>([])
/** The template to run, or null for every template that matches. */
template: number | null = null
ngOnInit() {
this.ocrTemplateService
.listAll()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((r) => this.templates.set(r.results))
}
}
@@ -164,7 +164,7 @@
<pngx-input-text i18n-title title="Filter path" formControlName="filter_path" horizontal="true" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case-normalized." [error]="error?.filter_path"></pngx-input-text> <pngx-input-text i18n-title title="Filter path" formControlName="filter_path" horizontal="true" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case-normalized." [error]="error?.filter_path"></pngx-input-text>
<pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules()" horizontal="true" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select> <pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules()" horizontal="true" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select>
} }
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) { @if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled || formGroup.get('type').value === WorkflowTriggerType.Manual) {
<pngx-input-select i18n-title title="Content matching algorithm" horizontal="true" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select> <pngx-input-select i18n-title title="Content matching algorithm" horizontal="true" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
@if (matchingPatternRequired(formGroup)) { @if (matchingPatternRequired(formGroup)) {
<pngx-input-text i18n-title title="Content matching pattern" horizontal="true" formControlName="match" [error]="error?.match"></pngx-input-text> <pngx-input-text i18n-title title="Content matching pattern" horizontal="true" formControlName="match" [error]="error?.match"></pngx-input-text>
@@ -175,7 +175,7 @@
} }
</div> </div>
</div> </div>
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) { @if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled || formGroup.get('type').value === WorkflowTriggerType.Manual) {
<div class="row mt-3"> <div class="row mt-3">
<div class="col"> <div class="col">
<div class="trigger-filters mb-3"> <div class="trigger-filters mb-3">
@@ -123,6 +123,10 @@ export const WORKFLOW_TYPE_OPTIONS = [
id: WorkflowTriggerType.Scheduled, id: WorkflowTriggerType.Scheduled,
name: $localize`Scheduled`, name: $localize`Scheduled`,
}, },
{
id: WorkflowTriggerType.Manual,
name: $localize`Manual`,
},
] ]
export const WORKFLOW_ACTION_OPTIONS = [ export const WORKFLOW_ACTION_OPTIONS = [
@@ -36,11 +36,11 @@
[appendTo]="appendTo" [appendTo]="appendTo"
[loading]="loading()" [loading]="loading()"
[typeahead]="documentsInput$" [typeahead]="documentsInput$"
(mousedown)="$event.stopImmediatePropagation()" (mousedown)="appendTo && $event.stopImmediatePropagation()"
(change)="onChange(selectedDocumentIDs)"> (change)="onChange(selectedDocumentIDs)">
<ng-template ng-label-tmp let-document="item"> <ng-template ng-label-tmp let-document="item">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
@if (!disabled) { @if (!disabled && multiple) {
<button class="btn p-0 lh-1" (click)="unselect(document)" (mousedown)="$event.stopImmediatePropagation()" type="button" title="Remove link" i18n-title><pngx-bs-icon name="x"></pngx-bs-icon></button> <button class="btn p-0 lh-1" (click)="unselect(document)" (mousedown)="$event.stopImmediatePropagation()" type="button" title="Remove link" i18n-title><pngx-bs-icon name="x"></pngx-bs-icon></button>
} }
@if (document.title) { @if (document.title) {
@@ -81,6 +81,9 @@ export class DocumentLinkComponent
@Input() @Input()
placeholder: string = $localize`Search for documents` placeholder: string = $localize`Search for documents`
@Input()
multiple: boolean = true
/** /**
* Parent for ng-select dropdown, needed to prevent close on click. * Parent for ng-select dropdown, needed to prevent close on click.
*/ */
@@ -97,6 +97,7 @@ describe('SelectComponent', () => {
let createNewVal let createNewVal
component.createNew.subscribe((v) => (createNewVal = v)) component.createNew.subscribe((v) => (createNewVal = v))
expect(component.allowCreateNew).toBeTruthy() expect(component.allowCreateNew).toBeTruthy()
const closeSpy = jest.spyOn(component.ngSelectComponent, 'close')
component.onSearch({ term: 'foo' }) component.onSearch({ term: 'foo' })
component.addItem(undefined) component.addItem(undefined)
expect(createNewVal).toEqual('foo') expect(createNewVal).toEqual('foo')
@@ -105,6 +106,8 @@ describe('SelectComponent', () => {
component.onSearch({ term: 'baz' }) component.onSearch({ term: 'baz' })
component.clickNew() component.clickNew()
expect(createNewVal).toEqual('baz') expect(createNewVal).toEqual('baz')
// dropdown should close when creating a new item so it isn't left open behind the modal
expect(closeSpy).toHaveBeenCalled()
}) })
it('should search items by independent normalized terms', () => { it('should search items by independent normalized terms', () => {
@@ -4,6 +4,7 @@ import {
forwardRef, forwardRef,
Input, Input,
Output, Output,
ViewChild,
} from '@angular/core' } from '@angular/core'
import { import {
FormsModule, FormsModule,
@@ -11,7 +12,7 @@ import {
ReactiveFormsModule, ReactiveFormsModule,
} from '@angular/forms' } from '@angular/forms'
import { RouterModule } from '@angular/router' import { RouterModule } from '@angular/router'
import { NgSelectModule } from '@ng-select/ng-select' import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select'
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component' import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
import { matchesSearchText } from 'src/app/utils/text-search' import { matchesSearchText } from 'src/app/utils/text-search'
import { AbstractInputComponent } from '../abstract-input' import { AbstractInputComponent } from '../abstract-input'
@@ -124,6 +125,9 @@ export class SelectComponent extends AbstractInputComponent<number | string> {
@Output() @Output()
filterDocuments = new EventEmitter<any[]>() filterDocuments = new EventEmitter<any[]>()
@ViewChild(NgSelectComponent)
ngSelectComponent: NgSelectComponent
public addItemRef: (name) => void public addItemRef: (name) => void
private _lastSearchTerm: string private _lastSearchTerm: string
@@ -150,11 +154,13 @@ export class SelectComponent extends AbstractInputComponent<number | string> {
if (name) this.createNew.next(name) if (name) this.createNew.next(name)
else this.createNew.next(this._lastSearchTerm) else this.createNew.next(this._lastSearchTerm)
this.clearLastSearchTerm() this.clearLastSearchTerm()
this.ngSelectComponent?.close()
} }
clickNew() { clickNew() {
this.createNew.next(this._lastSearchTerm) this.createNew.next(this._lastSearchTerm)
this.clearLastSearchTerm() this.clearLastSearchTerm()
this.ngSelectComponent?.close()
} }
clearLastSearchTerm() { clearLastSearchTerm() {
@@ -1,16 +1,18 @@
<div class="row pt-3 pb-2 align-items-center"> <div class="row pt-3 pb-2 align-items-center">
<div class="col-md text-truncate"> <div class="col-md text-truncate">
<h3 class="d-flex align-items-center mb-2 mb-md-1" style="line-height: 1.4"> <h3 class="d-flex align-items-center mb-2 mb-md-1" style="line-height: 1.4">
<span class="text-truncate">{{title()}}</span> <span class="d-flex align-items-center mw-100">
@if (id()) { <span class="text-truncate">{{title()}}</span>
<button type="button" class="badge border-0 bg-primary text-primary-text-contrast ms-3 small fs-normal" (click)="copyID()" title="Copy ID" i18n-title> @if (id()) {
@if (copied()) { <button type="button" class="badge border-0 bg-primary text-primary-text-contrast ms-3 small fs-normal flex-shrink-0 text-nowrap" (click)="copyID()" title="Copy ID" i18n-title>
<pngx-bs-icon width="1em" height="1em" name="clipboard-check" class="me-1"></pngx-bs-icon><ng-container i18n>Copied!</ng-container> @if (copied()) {
} @else { <pngx-bs-icon width="1em" height="1em" name="clipboard-check" class="me-1"></pngx-bs-icon><ng-container i18n>Copied!</ng-container>
ID: {{id()}} } @else {
} ID: {{id()}}
</button> }
} </button>
}
</span>
@if (subTitle()) { @if (subTitle()) {
<span class="page-subtitle h6 mb-0 mt-1 fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle()}}</span> <span class="page-subtitle h6 mb-0 mt-1 fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle()}}</span>
} }
@@ -62,13 +62,13 @@
} @else { } @else {
@switch (field) { @switch (field) {
@case (DisplayField.ADDED) { @case (DisplayField.ADDED) {
<a routerLink="/documents/{{doc.id}}" class="btn-link text-dark text-decoration-none py-2 py-md-3" title="Open document" i18n-title>{{doc.added | customDate}}</a> <a routerLink="/documents/{{doc.id}}" (click)="activateViewForNavigation()" class="btn-link text-dark text-decoration-none py-2 py-md-3" title="Open document" i18n-title>{{doc.added | customDate}}</a>
} }
@case (DisplayField.CREATED) { @case (DisplayField.CREATED) {
<a routerLink="/documents/{{doc.id}}" class="btn-link text-dark text-decoration-none py-2 py-md-3" title="Open document" i18n-title>{{doc.created | customDate}}</a> <a routerLink="/documents/{{doc.id}}" (click)="activateViewForNavigation()" class="btn-link text-dark text-decoration-none py-2 py-md-3" title="Open document" i18n-title>{{doc.created | customDate}}</a>
} }
@case (DisplayField.TITLE) { @case (DisplayField.TITLE) {
<a routerLink="/documents/{{doc.id}}" title="Open document" i18n-title class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.title | documentTitle}}</a> <a routerLink="/documents/{{doc.id}}" (click)="activateViewForNavigation()" title="Open document" i18n-title class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.title | documentTitle}}</a>
} }
@case (DisplayField.CORRESPONDENT) { @case (DisplayField.CORRESPONDENT) {
@if (doc.correspondent) { @if (doc.correspondent) {
@@ -106,7 +106,7 @@
} }
@case (DisplayField.NOTES) { @case (DisplayField.NOTES) {
@if (doc.notes.length) { @if (doc.notes.length) {
<a routerLink="/documents/{{doc.id}}/notes" class="btn btn-sm p-0"> <a routerLink="/documents/{{doc.id}}/notes" (click)="activateViewForNavigation()" class="btn btn-sm p-0">
<span class="badge rounded-pill bg-light border text-primary"> <span class="badge rounded-pill bg-light border text-primary">
<pngx-bs-icon width="1.2em" height="1.2em" class="ms-1 me-1" name="chat-left-text"></pngx-bs-icon> <pngx-bs-icon width="1.2em" height="1.2em" class="ms-1 me-1" name="chat-left-text"></pngx-bs-icon>
{{doc.notes.length}}</span> {{doc.notes.length}}</span>
@@ -150,6 +150,7 @@
<pngx-document-card-small <pngx-document-card-small
class="p-0" class="p-0"
[ngStyle]="{ opacity: show() ? 1 : 1 - (i * 1/documents().length) }" [ngStyle]="{ opacity: show() ? 1 : 1 - (i * 1/documents().length) }"
(click)="activateViewForNavigation()"
(dblClickDocument)="openDocumentDetail(d)" (dblClickDocument)="openDocumentDetail(d)"
[document]="d" [document]="d"
[displayFields]="displayFields()" [displayFields]="displayFields()"
@@ -173,6 +174,7 @@
} }
@for (d of documents(); track d.id; let i = $index) { @for (d of documents(); track d.id; let i = $index) {
<pngx-document-card-large <pngx-document-card-large
(click)="activateViewForNavigation()"
(dblClickDocument)="openDocumentDetail(d)" (dblClickDocument)="openDocumentDetail(d)"
[document]="d" [document]="d"
[ngStyle]="{ opacity: show() ? 1 : 1 - (i * 1/documents().length) }" [ngStyle]="{ opacity: show() ? 1 : 1 - (i * 1/documents().length) }"
@@ -16,7 +16,11 @@ import { routes } from 'src/app/app-routing.module'
import { CustomFieldDisplayComponent } from 'src/app/components/common/custom-field-display/custom-field-display.component' import { CustomFieldDisplayComponent } from 'src/app/components/common/custom-field-display/custom-field-display.component'
import { PreviewPopupComponent } from 'src/app/components/common/preview-popup/preview-popup.component' import { PreviewPopupComponent } from 'src/app/components/common/preview-popup/preview-popup.component'
import { CustomFieldDataType } from 'src/app/data/custom-field' import { CustomFieldDataType } from 'src/app/data/custom-field'
import { DisplayField, DisplayMode } from 'src/app/data/document' import {
DEFAULT_DASHBOARD_VIEW_PAGE_SIZE,
DisplayField,
DisplayMode,
} from 'src/app/data/document'
import { import {
FILTER_CORRESPONDENT, FILTER_CORRESPONDENT,
FILTER_DOCUMENT_TYPE, FILTER_DOCUMENT_TYPE,
@@ -277,10 +281,40 @@ describe('SavedViewWidgetComponent', () => {
it('should navigate to document', () => { it('should navigate to document', () => {
const routerSpy = jest.spyOn(router, 'navigate') const routerSpy = jest.spyOn(router, 'navigate')
const activateSpy = jest.spyOn(
documentListViewService,
'activateSavedViewWithDocuments'
)
component.openDocumentDetail(documentResults[0]) component.openDocumentDetail(documentResults[0])
expect(activateSpy).toHaveBeenCalled()
expect(routerSpy).toHaveBeenCalledWith(['documents', documentResults[0].id]) expect(routerSpy).toHaveBeenCalledWith(['documents', documentResults[0].id])
}) })
it('should hand its documents to the list service when opening a document', () => {
jest.spyOn(documentService, 'listFiltered').mockReturnValue(
of({
all: [2, 3],
count: 25,
results: documentResults,
})
)
jest.spyOn(router, 'navigateByUrl').mockResolvedValue(true)
const activateSpy = jest
.spyOn(documentListViewService, 'activateSavedViewWithDocuments')
.mockImplementation(() => {})
component.ngOnInit()
fixture.detectChanges()
fixture.debugElement
.query(By.css('a[title="Open document"]'))
.nativeElement.click()
expect(activateSpy).toHaveBeenCalledWith(savedView, documentResults, 25, 20)
})
it('should fall back to the default dashboard page size', () => {
component.savedView = { ...savedView, page_size: undefined }
expect(component.pageSize).toEqual(DEFAULT_DASHBOARD_VIEW_PAGE_SIZE)
})
it('should navigate via quickfilter on click tag', () => { it('should navigate via quickfilter on click tag', () => {
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter') const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
component.clickTag(11, new MouseEvent('click')) component.clickTag(11, new MouseEvent('click'))
@@ -132,7 +132,7 @@ export class SavedViewWidgetComponent
ngOnInit(): void { ngOnInit(): void {
this.placeholderRows = Array.from( this.placeholderRows = Array.from(
{ {
length: this.savedView?.page_size ?? DEFAULT_DASHBOARD_VIEW_PAGE_SIZE, length: this.pageSize,
}, },
(_, index) => index (_, index) => index
) )
@@ -187,7 +187,7 @@ export class SavedViewWidgetComponent
this.documentService this.documentService
.listFiltered( .listFiltered(
1, 1,
this.savedView?.page_size ?? DEFAULT_DASHBOARD_VIEW_PAGE_SIZE, this.pageSize,
this.savedView.sort_field, this.savedView.sort_field,
this.savedView.sort_reverse, this.savedView.sort_reverse,
this.savedView.filter_rules, this.savedView.filter_rules,
@@ -292,7 +292,24 @@ export class SavedViewWidgetComponent
]) ])
} }
get pageSize(): number {
return this.savedView?.page_size ?? DEFAULT_DASHBOARD_VIEW_PAGE_SIZE
}
/**
* Pass the widget's documents to the list service to enable next / previous.
*/
activateViewForNavigation() {
this.documentListViewService.activateSavedViewWithDocuments(
this.savedView,
this.documents(),
this.count(),
this.pageSize
)
}
openDocumentDetail(document: Document) { openDocumentDetail(document: Document) {
this.activateViewForNavigation()
this.router.navigate(['documents', document.id]) this.router.navigate(['documents', document.id])
} }
@@ -49,6 +49,12 @@
<pngx-bs-icon width="1em" height="1em" name="arrow-counterclockwise" class="me-2"></pngx-bs-icon><span i18n>Reprocess</span> <pngx-bs-icon width="1em" height="1em" name="arrow-counterclockwise" class="me-2"></pngx-bs-icon><span i18n>Reprocess</span>
</button> </button>
@if (manualWorkflows().length > 0) {
<button ngbDropdownItem (click)="runWorkflow()" [disabled]="!userCanEdit || !userIsOwner">
<pngx-bs-icon width="1em" height="1em" name="play-circle" class="me-2"></pngx-bs-icon><span i18n>Run workflow</span>
</button>
}
<button ngbDropdownItem (click)="printDocument()" [hidden]="useNativePdfViewer || isMobile"> <button ngbDropdownItem (click)="printDocument()" [hidden]="useNativePdfViewer || isMobile">
<pngx-bs-icon width="1em" height="1em" name="printer" class="me-2"></pngx-bs-icon><span i18n>Print</span> <pngx-bs-icon width="1em" height="1em" name="printer" class="me-2"></pngx-bs-icon><span i18n>Print</span>
</button> </button>
@@ -61,6 +67,22 @@
<pngx-bs-icon name="pencil" class="me-2"></pngx-bs-icon><ng-container i18n>PDF Editor</ng-container> <pngx-bs-icon name="pencil" class="me-2"></pngx-bs-icon><ng-container i18n>PDF Editor</ng-container>
</button> </button>
<div class="d-flex" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.OcrTemplate }">
<button ngbDropdownItem (click)="runZoneOcr()" [disabled]="!userCanEdit || networkActive()">
<pngx-bs-icon width="1em" height="1em" name="bounding-box-circles" class="me-2"></pngx-bs-icon><span i18n>Run Zone OCR</span>
</button>
<button
ngbDropdownItem
class="w-auto flex-shrink-0 px-2"
(click)="runZoneOcrWithOptions()"
[disabled]="!userCanEdit || networkActive()"
title="Choose OCR template"
i18n-title
>
<pngx-bs-icon width="1em" height="1em" name="sliders2"></pngx-bs-icon>
</button>
</div>
@if (userIsOwner && (requiresPassword || password)) { @if (userIsOwner && (requiresPassword || password)) {
<button ngbDropdownItem (click)="removePassword()" [disabled]="!password"> <button ngbDropdownItem (click)="removePassword()" [disabled]="!password">
<pngx-bs-icon name="unlock" class="me-2"></pngx-bs-icon><ng-container i18n>Remove Password</ng-container> <pngx-bs-icon name="unlock" class="me-2"></pngx-bs-icon><ng-container i18n>Remove Password</ng-container>
@@ -90,7 +112,7 @@
<div class="row"> <div class="row">
<div class="col-md-6 col-xl-5 mb-4"> <div class="col-md-6 col-xl-5 mb-4">
<form [formGroup]='documentForm' (ngSubmit)="save()"> <form [formGroup]='documentForm' (ngSubmit)="save()" [attr.inert]="zoneOcrRunning() ? '' : null" [attr.aria-busy]="zoneOcrRunning()">
<div class="btn-toolbar mb-1 pb-3 gap-2 row-gap-2 border-bottom"> <div class="btn-toolbar mb-1 pb-3 gap-2 row-gap-2 border-bottom">
<div class="btn-group"> <div class="btn-group">
@@ -940,6 +940,20 @@ describe('DocumentDetailComponent', () => {
expect(navigateSpy).toHaveBeenCalledWith(['view', 77]) expect(navigateSpy).toHaveBeenCalledWith(['view', 77])
}) })
it('should close back to the dashboard even if a saved view is active', () => {
initNormally()
jest
.spyOn(componentRouterService, 'getComponentURLBefore')
.mockReturnValue('dashboard')
jest
.spyOn(documentListViewService, 'activeSavedViewId', 'get')
.mockReturnValue(77)
const navigateSpy = jest.spyOn(router, 'navigate')
component.close()
expect(navigateSpy).toHaveBeenCalledWith(['dashboard'])
expect(navigateSpy).not.toHaveBeenCalledWith(['view', 77])
})
it('should not close if e.g. user-cancelled', () => { it('should not close if e.g. user-cancelled', () => {
initNormally() initNormally()
jest.spyOn(openDocumentsService, 'closeDocument').mockReturnValue(of(false)) jest.spyOn(openDocumentsService, 'closeDocument').mockReturnValue(of(false))
@@ -1016,6 +1030,27 @@ describe('DocumentDetailComponent', () => {
expect(modalCloseSpy).toHaveBeenCalled() expect(modalCloseSpy).toHaveBeenCalled()
}) })
it('should run a workflow on the document, confirm and close modal', () => {
initNormally()
const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit')
bulkEditSpy.mockReturnValue(of(true))
let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
const toastSpy = jest.spyOn(toastService, 'showInfo')
component.runWorkflow()
const modalCloseSpy = jest.spyOn(openModal, 'close')
openModal.componentInstance.workflowId = 5
openModal.componentInstance.force = true
openModal.componentInstance.confirmClicked.next()
expect(bulkEditSpy).toHaveBeenCalledWith(
{ documents: [doc.id] },
'run_workflow',
{ workflow: 5, filter_documents: false }
)
expect(toastSpy).toHaveBeenCalled()
expect(modalCloseSpy).toHaveBeenCalled()
})
it('should pass remote OCR choice when reprocessing', () => { it('should pass remote OCR choice when reprocessing', () => {
initNormally() initNormally()
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments') const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
@@ -1895,6 +1930,203 @@ describe('DocumentDetailComponent', () => {
expect(toastSpy).toHaveBeenCalledWith('Document reloaded.') expect(toastSpy).toHaveBeenCalledWith('Document reloaded.')
}) })
it('should run a chosen zone OCR template on the selected version and reload', () => {
component.documentId.set(doc.id)
component.document.set({ ...doc, versions: docVersions })
component.selectedVersionId.set(10)
jest.spyOn(documentService, 'runZoneOcr').mockReturnValue(
of({
results: [
{ zone: 'Total', value: '102.10' },
{ zone: 'Due', value: '' },
],
} as any)
)
const loadSpy = jest
.spyOn(component as any, 'loadDocument')
.mockImplementation(() => {})
const notifySpy = jest.spyOn(component.docChangeNotifier, 'next')
const toastSpy = jest.spyOn(toastService, 'showInfo')
const modalSpy = jest.spyOn(modalService, 'open')
component.runZoneOcrWithOptions()
const dialog = modalSpy.mock.results[0].value.componentInstance
expect(dialog.message).toBe(
'Fields filled by the template will be overwritten.'
)
dialog.template = 3
dialog.confirmClicked.emit()
expect(documentService.runZoneOcr).toHaveBeenCalledWith(doc.id, 3, 10)
expect(toastSpy).toHaveBeenCalledWith(
'Filled 1 of 2 fields. No value found for: Due'
)
expect(notifySpy).toHaveBeenCalledWith(doc.id)
expect(loadSpy).toHaveBeenCalledWith(doc.id, true, expect.any(Function))
expect(component.zoneOcrRunning()).toBe(true)
loadSpy.mock.calls[0][2]()
expect(component.zoneOcrRunning()).toBe(false)
expect(component.networkActive()).toBe(false)
})
it('should summarize zone OCR results', () => {
component.documentId.set(doc.id)
component.document.set({ ...doc, versions: docVersions })
component.selectedVersionId.set(12)
jest.spyOn(component as any, 'loadDocument').mockImplementation(() => {})
const runSpy = jest.spyOn(documentService, 'runZoneOcr')
const toastSpy = jest.spyOn(toastService, 'showInfo')
const modalSpy = jest.spyOn(modalService, 'open')
runSpy.mockReturnValueOnce(of({ status: 'completed', results: [] }))
component.runZoneOcr()
expect(modalSpy).not.toHaveBeenCalled()
expect(runSpy).toHaveBeenCalledWith(doc.id, null, null)
expect(toastSpy).toHaveBeenLastCalledWith(
'Zone OCR found no fields to fill.'
)
component.networkActive.set(false)
runSpy.mockReturnValueOnce(
of({
status: 'completed',
results: [
{ template: 'T', zone: 'Total', custom_field: 'Amount', value: 0 },
],
})
)
component.runZoneOcr()
expect(toastSpy).toHaveBeenLastCalledWith('Filled 1 of 1 fields.')
})
it('should confirm before running zone OCR over unsaved changes', () => {
component.documentId.set(doc.id)
component.document.set({ ...doc, __changedFields: ['title'] })
component.documentForm.patchValue({ title: 'Unsaved title' })
const runSpy = jest.spyOn(documentService, 'runZoneOcr')
const modalSpy = jest.spyOn(modalService, 'open')
component.runZoneOcr()
const modal = modalSpy.mock.results[0].value
expect(modal.componentInstance.message).toBe(
'Running Zone OCR will discard them.'
)
expect(runSpy).not.toHaveBeenCalled()
modal.componentInstance.cancel()
expect(runSpy).not.toHaveBeenCalled()
expect(component.documentForm.get('title').value).toBe('Unsaved title')
expect(component.zoneOcrRunning()).toBe(false)
})
it('should warn about unsaved changes in the zone OCR options dialog', () => {
component.documentId.set(doc.id)
component.document.set({ ...doc, __changedFields: ['title'] })
const runSpy = jest.spyOn(documentService, 'runZoneOcr')
const modalSpy = jest.spyOn(modalService, 'open')
component.runZoneOcrWithOptions()
const dialog = modalSpy.mock.results[0].value.componentInstance
expect(dialog.message).toBe('Running Zone OCR will discard them.')
expect(dialog.btnClass).toBe('btn-warning')
dialog.cancel()
expect(runSpy).not.toHaveBeenCalled()
component.networkActive.set(true)
component.runZoneOcrWithOptions()
expect(modalSpy).toHaveBeenCalledTimes(1)
})
it('should not allow saving or a second zone OCR run while one is running', () => {
component.documentId.set(doc.id)
component.document.set({
...doc,
versions: docVersions,
__changedFields: ['title'],
})
component.selectedVersionId.set(doc.id)
const pending = new Subject<any>()
const runSpy = jest
.spyOn(documentService, 'runZoneOcr')
.mockReturnValue(pending)
const modalSpy = jest.spyOn(modalService, 'open')
component.runZoneOcr()
modalSpy.mock.results[0].value.componentInstance.confirmClicked.emit()
expect(runSpy).toHaveBeenCalledWith(doc.id, null, doc.id)
expect(component.zoneOcrRunning()).toBe(true)
expect(component.networkActive()).toBe(true)
const saveSpy = jest.spyOn(documentService, 'patch')
component.save()
expect(saveSpy).not.toHaveBeenCalled()
component.runZoneOcr()
expect(modalSpy).toHaveBeenCalledTimes(1)
expect(runSpy).toHaveBeenCalledTimes(1)
pending.error(new Error('failed'))
expect(component.zoneOcrRunning()).toBe(false)
expect(component.networkActive()).toBe(false)
expect(component.document().__changedFields).toEqual(['title'])
})
it('should not reload after zone OCR if another document is open', () => {
component.documentId.set(doc.id)
component.document.set({ ...doc, __changedFields: [] })
const pending = new Subject<any>()
jest.spyOn(documentService, 'runZoneOcr').mockReturnValue(pending)
const loadSpy = jest.spyOn(component as any, 'loadDocument')
component.runZoneOcr()
component.documentId.set(99)
pending.next({ results: [] })
expect(loadSpy).not.toHaveBeenCalled()
expect(component.zoneOcrRunning()).toBe(false)
pending.complete()
})
it('should keep unsaved changes when zone OCR is skipped', () => {
component.documentId.set(doc.id)
component.document.set({ ...doc, __changedFields: ['title'] })
component.documentForm.patchValue({ title: 'Unsaved title' })
const modalSpy = jest.spyOn(modalService, 'open')
jest
.spyOn(documentService, 'runZoneOcr')
.mockReturnValue(of({ status: 'skipped', results: [] }))
const loadSpy = jest.spyOn(component as any, 'loadDocument')
const infoSpy = jest.spyOn(toastService, 'showInfo')
component.runZoneOcr()
modalSpy.mock.results[0].value.componentInstance.confirm()
expect(loadSpy).not.toHaveBeenCalled()
expect(component.documentForm.get('title').value).toBe('Unsaved title')
expect(component.zoneOcrRunning()).toBe(false)
expect(component.networkActive()).toBe(false)
expect(infoSpy).toHaveBeenCalledWith(
'No OCR template applies to this document.'
)
})
it('should show an error when zone OCR fails', () => {
component.document.set(doc)
jest
.spyOn(documentService, 'runZoneOcr')
.mockReturnValue(throwError(() => new Error('nope')))
const loadSpy = jest.spyOn(component as any, 'loadDocument')
const errorSpy = jest.spyOn(toastService, 'showError')
component.runZoneOcr()
expect(errorSpy).toHaveBeenCalledWith(
'Error running zone OCR.',
expect.any(Error)
)
expect(loadSpy).not.toHaveBeenCalled()
expect(component.zoneOcrRunning()).toBe(false)
expect(component.networkActive()).toBe(false)
})
it('should change preview element by render type', () => { it('should change preview element by render type', () => {
initNormally() initNormally()
component.document.update((document) => ({ component.document.update((document) => ({
@@ -64,10 +64,13 @@ import {
FILTER_STORAGE_PATH, FILTER_STORAGE_PATH,
} from 'src/app/data/filter-rule-type' } from 'src/app/data/filter-rule-type'
import { ObjectWithId } from 'src/app/data/object-with-id' import { ObjectWithId } from 'src/app/data/object-with-id'
import { OcrZoneRunResult } from 'src/app/data/ocr-template'
import { StoragePath } from 'src/app/data/storage-path' import { StoragePath } from 'src/app/data/storage-path'
import { Tag } from 'src/app/data/tag' import { Tag } from 'src/app/data/tag'
import { SETTINGS_KEYS, SuggestionSource } from 'src/app/data/ui-settings' import { SETTINGS_KEYS, SuggestionSource } from 'src/app/data/ui-settings'
import { User } from 'src/app/data/user' import { User } from 'src/app/data/user'
import { Workflow } from 'src/app/data/workflow'
import { WorkflowTriggerType } from 'src/app/data/workflow-trigger'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe' import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
@@ -93,6 +96,7 @@ import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { TagService } from 'src/app/services/rest/tag.service' import { TagService } from 'src/app/services/rest/tag.service'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { WorkflowService } from 'src/app/services/rest/workflow.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { TrashService } from 'src/app/services/trash.service' import { TrashService } from 'src/app/services/trash.service'
@@ -104,6 +108,8 @@ import { DocumentDetailFieldID } from '../admin/settings/settings.component'
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component' import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
import { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component' import { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
import { RunWorkflowConfirmDialogComponent } from '../common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component'
import { ZoneOcrConfirmDialogComponent } from '../common/confirm-dialog/zone-ocr-confirm-dialog/zone-ocr-confirm-dialog.component'
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component' import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component' import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component' import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
@@ -233,6 +239,7 @@ export class DocumentDetailComponent
private componentRouterService = inject(ComponentRouterService) private componentRouterService = inject(ComponentRouterService)
private deviceDetectorService = inject(DeviceDetectorService) private deviceDetectorService = inject(DeviceDetectorService)
private savedViewService = inject(SavedViewService) private savedViewService = inject(SavedViewService)
private readonly workflowService = inject(WorkflowService)
private readonly websocketStatusService = inject(WebsocketStatusService) private readonly websocketStatusService = inject(WebsocketStatusService)
private readonly useNativePdfViewerSetting = this.settings.getSignal<boolean>( private readonly useNativePdfViewerSetting = this.settings.getSignal<boolean>(
SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER
@@ -266,6 +273,7 @@ export class DocumentDetailComponent
readonly error = signal<any>(undefined) readonly error = signal<any>(undefined)
readonly networkActive = signal(false) readonly networkActive = signal(false)
readonly zoneOcrRunning = signal(false)
readonly documentId = signal<number>(undefined) readonly documentId = signal<number>(undefined)
readonly document = signal<Document>(undefined) readonly document = signal<Document>(undefined)
@@ -295,6 +303,7 @@ export class DocumentDetailComponent
readonly correspondents = signal<Correspondent[]>(undefined) readonly correspondents = signal<Correspondent[]>(undefined)
readonly documentTypes = signal<DocumentType[]>(undefined) readonly documentTypes = signal<DocumentType[]>(undefined)
readonly storagePaths = signal<StoragePath[]>(undefined) readonly storagePaths = signal<StoragePath[]>(undefined)
readonly manualWorkflows = signal<Workflow[]>([])
documentForm: FormGroup = new FormGroup({ documentForm: FormGroup = new FormGroup({
title: new FormControl(''), title: new FormControl(''),
@@ -543,7 +552,11 @@ export class DocumentDetailComponent
) )
} }
private loadDocument(documentId: number, forceRemote: boolean = false): void { private loadDocument(
documentId: number,
forceRemote: boolean = false,
onComplete?: () => void
): void {
let redirectedToRoot = false let redirectedToRoot = false
this.closeIncomingUpdateModal() this.closeIncomingUpdateModal()
this.pendingIncomingUpdate = null this.pendingIncomingUpdate = null
@@ -600,7 +613,8 @@ export class DocumentDetailComponent
}), }),
first(), first(),
takeUntil(this.unsubscribeNotifier), takeUntil(this.unsubscribeNotifier),
takeUntil(this.docChangeNotifier) takeUntil(this.docChangeNotifier),
finalize(() => onComplete?.())
) )
.subscribe({ .subscribe({
next: (doc) => { next: (doc) => {
@@ -836,6 +850,20 @@ export class DocumentDetailComponent
.pipe(first(), takeUntil(this.unsubscribeNotifier)) .pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((result) => this.users.set(result.results)) .subscribe((result) => this.users.set(result.results))
} }
if (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Workflow
)
) {
this.workflowService
.list(1, 100000, null, null, {
trigger_type: WorkflowTriggerType.Manual,
enabled: true,
})
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((result) => this.manualWorkflows.set(result.results))
}
this.getCustomFields() this.getCustomFields()
@@ -1334,6 +1362,7 @@ export class DocumentDetailComponent
} }
save(close: boolean = false, savedCallback: () => void = null) { save(close: boolean = false, savedCallback: () => void = null) {
if (this.zoneOcrRunning()) return
this.networkActive.set(true) this.networkActive.set(true)
;(document.activeElement as HTMLElement)?.dispatchEvent(new Event('change')) ;(document.activeElement as HTMLElement)?.dispatchEvent(new Event('change'))
this.documentsService this.documentsService
@@ -1454,7 +1483,12 @@ export class DocumentDetailComponent
.subscribe((closed) => { .subscribe((closed) => {
if (!closed) return if (!closed) return
if (closedCallback) closedCallback() if (closedCallback) closedCallback()
if (this.documentListViewService.activeSavedViewId) { if (
this.componentRouterService.getComponentURLBefore() === 'dashboard'
) {
// e.g. opened from a dashboard widget
this.router.navigate(['dashboard'])
} else if (this.documentListViewService.activeSavedViewId) {
this.router.navigate([ this.router.navigate([
'view', 'view',
this.documentListViewService.activeSavedViewId, this.documentListViewService.activeSavedViewId,
@@ -1554,6 +1588,146 @@ export class DocumentDetailComponent
}) })
} }
runWorkflow() {
const modal = this.modalService.open(RunWorkflowConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Run workflow`
modal.componentInstance.message = $localize`Run a workflow on "${this.document().title}".`
modal.componentInstance.btnCaption = $localize`Run`
modal.componentInstance.workflows = this.manualWorkflows()
modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
this.documentsService
.bulkEdit({ documents: [this.document().id] }, 'run_workflow', {
workflow: modal.componentInstance.workflowId,
filter_documents: !modal.componentInstance.force,
})
.subscribe({
next: () => {
this.toastService.showInfo(
$localize`Workflow will run on "${this.document().title}" in the background.`
)
modal.close()
},
error: (error) => {
modal.componentInstance.buttonsEnabled.set(true)
this.toastService.showError(
$localize`Error executing operation`,
error
)
},
})
})
}
/** Runs every template that matches, confirming only over unsaved changes. */
runZoneOcr() {
if (this.networkActive()) return
const documentId = this.documentId()
const versionId = this.getSelectedNonLatestVersionId()
if (!this.hasLocalEdits(this.document())) {
this.executeZoneOcr(documentId, null, versionId)
return
}
const modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Run Zone OCR`
modal.componentInstance.messageBold = $localize`You have unsaved changes.`
modal.componentInstance.message = $localize`Running Zone OCR will discard them.`
modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Run Zone OCR`
modal.componentInstance.confirmClicked
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
modal.close()
if (this.documentId() === documentId && !this.networkActive()) {
this.executeZoneOcr(documentId, null, versionId)
}
})
}
/** Opens the dialog to pick which template to run. */
runZoneOcrWithOptions() {
if (this.networkActive()) return
const documentId = this.documentId()
const versionId = this.getSelectedNonLatestVersionId()
const modal = this.modalService.open(ZoneOcrConfirmDialogComponent, {
backdrop: 'static',
})
const dialog = modal.componentInstance as ZoneOcrConfirmDialogComponent
dialog.title = $localize`Run Zone OCR`
dialog.btnCaption = $localize`Run Zone OCR`
if (this.hasLocalEdits(this.document())) {
dialog.messageBold = $localize`You have unsaved changes.`
dialog.message = $localize`Running Zone OCR will discard them.`
dialog.btnClass = 'btn-warning'
} else {
dialog.message = $localize`Fields filled by the template will be overwritten.`
}
dialog.confirmClicked
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
modal.close()
if (this.documentId() === documentId && !this.networkActive()) {
this.executeZoneOcr(documentId, dialog.template, versionId)
}
})
}
private executeZoneOcr(
documentId: number,
templateId: number | null,
versionId: number | null
) {
this.zoneOcrRunning.set(true)
this.networkActive.set(true)
this.documentsService
.runZoneOcr(documentId, templateId, versionId)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (response) => {
if (
this.documentId() !== documentId ||
response.status === 'skipped'
) {
this.finishZoneOcr()
if (response.status === 'skipped') {
this.toastService.showInfo(
$localize`No OCR template applies to this document.`
)
}
return
}
this.toastService.showInfo(this.zoneOcrSummary(response.results))
this.docChangeNotifier.next(documentId)
this.loadDocument(documentId, true, () => this.finishZoneOcr())
},
error: (error) => {
this.finishZoneOcr()
this.toastService.showError($localize`Error running zone OCR.`, error)
},
})
}
private zoneOcrSummary(results: OcrZoneRunResult[]): string {
if (!results.length) return $localize`Zone OCR found no fields to fill.`
const missed = results
.filter((result) => !`${result.value ?? ''}`.trim())
.map((result) => result.zone)
const filled = results.length - missed.length
return missed.length
? $localize`Filled ${filled} of ${results.length} fields. No value found for: ${missed.join(', ')}`
: $localize`Filled ${filled} of ${results.length} fields.`
}
private finishZoneOcr() {
this.zoneOcrRunning.set(false)
this.networkActive.set(false)
this.flushPendingIncomingUpdate()
}
private getSelectedNonLatestVersionId(): number | null { private getSelectedNonLatestVersionId(): number | null {
const versions = this.document()?.versions ?? [] const versions = this.document()?.versions ?? []
if (!versions.length || !this.selectedVersionId()) { if (!versions.length || !this.selectedVersionId()) {
@@ -98,6 +98,16 @@
<button ngbDropdownItem (click)="mergeSelectedAsVersions()" [disabled]="!userOwnsAll || !userCanEditAll || !userCanDelete || list.allSelected || list.selectedCount < 2"> <button ngbDropdownItem (click)="mergeSelectedAsVersions()" [disabled]="!userOwnsAll || !userCanEditAll || !userCanDelete || list.allSelected || list.selectedCount < 2">
<pngx-bs-icon name="journal-bookmark-fill" class="me-2"></pngx-bs-icon><ng-container i18n>Merge as versions</ng-container> <pngx-bs-icon name="journal-bookmark-fill" class="me-2"></pngx-bs-icon><ng-container i18n>Merge as versions</ng-container>
</button> </button>
@if (manualWorkflows.length > 0) {
<button ngbDropdownItem (click)="runWorkflowSelected()" [disabled]="!canRunWorkflow">
<pngx-bs-icon name="play-circle" class="me-2"></pngx-bs-icon><ng-container i18n>Run workflow</ng-container>
</button>
}
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.OcrTemplate)) {
<button ngbDropdownItem (click)="runZoneOcrSelected()" [disabled]="!userCanEditAll">
<pngx-bs-icon name="bounding-box-circles" class="me-2"></pngx-bs-icon><ng-container i18n>Run Zone OCR</ng-container>
</button>
}
</div> </div>
</div> </div>
</div> </div>
@@ -17,6 +17,7 @@ import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
import { Results } from 'src/app/data/results' import { Results } from 'src/app/data/results'
import { StoragePath } from 'src/app/data/storage-path' import { StoragePath } from 'src/app/data/storage-path'
import { Tag } from 'src/app/data/tag' import { Tag } from 'src/app/data/tag'
import { WorkflowTriggerType } from 'src/app/data/workflow-trigger'
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 {
@@ -36,6 +37,7 @@ import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.
import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { TagService } from 'src/app/services/rest/tag.service' import { TagService } from 'src/app/services/rest/tag.service'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { WorkflowService } from 'src/app/services/rest/workflow.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { TrashService } from 'src/app/services/trash.service' import { TrashService } from 'src/app/services/trash.service'
@@ -83,6 +85,7 @@ describe('BulkEditorComponent', () => {
let customFieldsService: CustomFieldsService let customFieldsService: CustomFieldsService
let httpTestingController: HttpTestingController let httpTestingController: HttpTestingController
let shareLinkBundleService: ShareLinkBundleService let shareLinkBundleService: ShareLinkBundleService
let workflowService: WorkflowService
let router: Router let router: Router
beforeEach(async () => { beforeEach(async () => {
@@ -173,6 +176,22 @@ describe('BulkEditorComponent', () => {
delete: jest.fn(), delete: jest.fn(),
}, },
}, },
{
provide: WorkflowService,
useValue: {
list: jest.fn(() =>
of({
results: [
{
id: 5,
name: 'Manual workflow',
triggers: [{ type: WorkflowTriggerType.Manual }],
},
],
})
),
},
},
{ {
provide: Router, provide: Router,
useValue: { navigate: jest.fn().mockResolvedValue(true) }, useValue: { navigate: jest.fn().mockResolvedValue(true) },
@@ -195,6 +214,7 @@ describe('BulkEditorComponent', () => {
customFieldsService = TestBed.inject(CustomFieldsService) customFieldsService = TestBed.inject(CustomFieldsService)
httpTestingController = TestBed.inject(HttpTestingController) httpTestingController = TestBed.inject(HttpTestingController)
shareLinkBundleService = TestBed.inject(ShareLinkBundleService) shareLinkBundleService = TestBed.inject(ShareLinkBundleService)
workflowService = TestBed.inject(WorkflowService)
router = TestBed.inject(Router) router = TestBed.inject(Router)
fixture = TestBed.createComponent(BulkEditorComponent) fixture = TestBed.createComponent(BulkEditorComponent)
@@ -559,18 +579,19 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/` `${environment.apiBaseUrl}documents/bulk_edit/`
) )
req.flush(true) req.flush({ result: 'OK', task_id: 'queued-task' })
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
from_webui: true,
documents: [3, 4], documents: [3, 4],
method: 'modify_tags', method: 'modify_tags',
parameters: { add_tags: [101], remove_tags: [] }, parameters: { add_tags: [101], remove_tags: [] },
}) })
httpTestingController.match( httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload )
httpTestingController.match( httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds )
}) })
it('should execute modify tags bulk operation for all filtered documents', () => { it('should execute modify tags bulk operation for all filtered documents', () => {
@@ -607,17 +628,18 @@ describe('BulkEditorComponent', () => {
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/` `${environment.apiBaseUrl}documents/bulk_edit/`
) )
req.flush(true) req.flush({ result: 'OK', task_id: 'queued-task' })
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
from_webui: true,
all: true, all: true,
filters: { title_search: 'apple' }, filters: { title_search: 'apple' },
excluded_documents: [4], excluded_documents: [4],
method: 'modify_tags', method: 'modify_tags',
parameters: { add_tags: [101], remove_tags: [] }, parameters: { add_tags: [101], remove_tags: [] },
}) })
httpTestingController.match( httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload )
}) })
it('should execute modify tags bulk operation with confirmation dialog if enabled', () => { it('should execute modify tags bulk operation with confirmation dialog if enabled', () => {
@@ -644,6 +666,51 @@ describe('BulkEditorComponent', () => {
httpTestingController httpTestingController
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`) .expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true) .flush(true)
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
)
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
)
})
it('should load manual workflows on init', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
fixture.detectChanges()
expect(workflowService.list).toHaveBeenCalledWith(1, 100000, null, null, {
trigger_type: WorkflowTriggerType.Manual,
enabled: true,
})
expect(component.manualWorkflows).toHaveLength(1)
expect(component.manualWorkflows[0].id).toEqual(5)
})
it('should run a workflow on the selection via the run workflow dialog', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0]))
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
.spyOn(documentListViewService, 'documents', 'get')
.mockReturnValue([{ id: 3 }, { id: 4 }])
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 4]))
fixture.detectChanges()
component.runWorkflowSelected()
expect(modal).not.toBeUndefined()
expect(modal.componentInstance.workflows).toEqual(component.manualWorkflows)
modal.componentInstance.workflowId = 5
modal.componentInstance.force = true
modal.componentInstance.confirm()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
expect(req.request.body.method).toEqual('run_workflow')
expect(req.request.body.parameters).toEqual({
workflow: 5,
filter_documents: false,
})
req.flush(true)
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
@@ -729,18 +796,19 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/` `${environment.apiBaseUrl}documents/bulk_edit/`
) )
req.flush(true) req.flush({ result: 'OK', task_id: 'queued-task' })
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
from_webui: true,
documents: [3, 4], documents: [3, 4],
method: 'set_correspondent', method: 'set_correspondent',
parameters: { correspondent: 101 }, parameters: { correspondent: 101 },
}) })
httpTestingController.match( httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload )
httpTestingController.match( httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds )
}) })
it('should execute modify correspondent bulk operation with confirmation dialog if enabled', () => { it('should execute modify correspondent bulk operation with confirmation dialog if enabled', () => {
@@ -767,12 +835,12 @@ describe('BulkEditorComponent', () => {
httpTestingController httpTestingController
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`) .expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true) .flush(true)
httpTestingController.match( httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload )
httpTestingController.match( httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds )
}) })
it('should set modal dialog text accordingly for correspondent edit confirmation', () => { it('should set modal dialog text accordingly for correspondent edit confirmation', () => {
@@ -827,18 +895,19 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/` `${environment.apiBaseUrl}documents/bulk_edit/`
) )
req.flush(true) req.flush({ result: 'OK', task_id: 'queued-task' })
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
from_webui: true,
documents: [3, 4], documents: [3, 4],
method: 'set_document_type', method: 'set_document_type',
parameters: { document_type: 101 }, parameters: { document_type: 101 },
}) })
httpTestingController.match( httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload )
httpTestingController.match( httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds )
}) })
it('should execute modify document type bulk operation with confirmation dialog if enabled', () => { it('should execute modify document type bulk operation with confirmation dialog if enabled', () => {
@@ -865,12 +934,12 @@ describe('BulkEditorComponent', () => {
httpTestingController httpTestingController
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`) .expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true) .flush(true)
httpTestingController.match( httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload )
httpTestingController.match( httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds )
}) })
it('should set modal dialog text accordingly for document type edit confirmation', () => { it('should set modal dialog text accordingly for document type edit confirmation', () => {
@@ -925,18 +994,19 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/` `${environment.apiBaseUrl}documents/bulk_edit/`
) )
req.flush(true) req.flush({ result: 'OK', task_id: 'queued-task' })
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
from_webui: true,
documents: [3, 4], documents: [3, 4],
method: 'set_storage_path', method: 'set_storage_path',
parameters: { storage_path: 101 }, parameters: { storage_path: 101 },
}) })
httpTestingController.match( httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload )
httpTestingController.match( httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds )
}) })
it('should execute modify storage path bulk operation with confirmation dialog if enabled', () => { it('should execute modify storage path bulk operation with confirmation dialog if enabled', () => {
@@ -963,12 +1033,12 @@ describe('BulkEditorComponent', () => {
httpTestingController httpTestingController
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`) .expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true) .flush(true)
httpTestingController.match( httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload )
httpTestingController.match( httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds )
}) })
it('should set modal dialog text accordingly for storage path edit confirmation', () => { it('should set modal dialog text accordingly for storage path edit confirmation', () => {
@@ -1023,18 +1093,19 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/` `${environment.apiBaseUrl}documents/bulk_edit/`
) )
req.flush(true) req.flush({ result: 'OK', task_id: 'queued-task' })
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
from_webui: true,
documents: [3, 4], documents: [3, 4],
method: 'modify_custom_fields', method: 'modify_custom_fields',
parameters: { add_custom_fields: [101], remove_custom_fields: [102] }, parameters: { add_custom_fields: [101], remove_custom_fields: [102] },
}) })
httpTestingController.match( httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload )
httpTestingController.match( httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds )
}) })
it('should execute modify custom fields bulk operation with confirmation dialog if enabled', () => { it('should execute modify custom fields bulk operation with confirmation dialog if enabled', () => {
@@ -1061,12 +1132,12 @@ describe('BulkEditorComponent', () => {
httpTestingController httpTestingController
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`) .expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true) .flush(true)
httpTestingController.match( httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload )
httpTestingController.match( httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds )
// coverage for modal messages // coverage for modal messages
component.setCustomFields({ component.setCustomFields({
@@ -1285,6 +1356,53 @@ describe('BulkEditorComponent', () => {
) // listAllFilteredIds ) // listAllFilteredIds
}) })
it('should support run zone ocr', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0]))
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
.spyOn(documentListViewService, 'documents', 'get')
.mockReturnValue([{ id: 3 }, { id: 4 }])
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 4]))
jest
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
.mockReturnValue(true)
fixture.detectChanges()
component.runZoneOcrSelected()
expect(modal).not.toBeUndefined()
modal.componentInstance.confirm()
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/run_zone_ocr/`
)
req.flush(true)
expect(req.request.body).toEqual({
documents: [3, 4],
})
component.runZoneOcrSelected()
modal.componentInstance.template = 3
modal.componentInstance.confirm()
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/run_zone_ocr/`
)
req.flush(true)
expect(req.request.body).toEqual({
documents: [3, 4],
template: 3,
})
httpTestingController.match(
`${environment.apiBaseUrl}ocr_templates/?page=1&page_size=100000`
) // template list
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
})
it('should support rotate', () => { it('should support rotate', () => {
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0])) modalService.activeInstances.subscribe((m) => (modal = m[0]))
@@ -1540,6 +1658,47 @@ describe('BulkEditorComponent', () => {
) )
}) })
it.each([true, false])(
'shows queued feedback and gates the task link on permission (%s)',
(canViewTasks) => {
jest
.spyOn(permissionsService, 'currentUserCan')
.mockReturnValue(canViewTasks)
const queued = jest.spyOn(toastService, 'show')
let modal: NgbModalRef
modalService.activeInstances.subscribe((instances) => {
if (instances.length) modal = instances[0]
})
const reload = jest.spyOn(documentListViewService, 'reload')
component.setPermissions()
const close = jest.spyOn(modal, 'close')
modal.componentInstance.confirmClicked.emit({
permissions: {},
merge: true,
})
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush({ result: 'OK', task_id: 'queued-operation' })
expect(close).toHaveBeenCalledTimes(1)
const notification = queued.mock.calls.at(-1)[0]
expect(notification.content).toBe('Bulk operation queued.')
if (canViewTasks) {
expect(notification.actionName).toBe('View task')
notification.action()
expect(router.navigate).toHaveBeenCalledWith(['/tasks'], {
queryParams: { task_id: 'queued-operation' },
})
} else {
expect(notification.action).toBeUndefined()
expect(notification.actionName).toBeUndefined()
}
expect(reload).not.toHaveBeenCalled()
expect(documentListViewService.selected.size).toBe(0)
expect(documentListViewService.allSelected).toBe(false)
}
)
it('should support bulk permissions update', () => { it('should support bulk permissions update', () => {
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0])) modalService.activeInstances.subscribe((m) => (modal = m[0]))
@@ -1572,8 +1731,9 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/` `${environment.apiBaseUrl}documents/bulk_edit/`
) )
req.flush(true) req.flush({ result: 'OK', task_id: 'queued-task' })
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
from_webui: true,
documents: [3, 4], documents: [3, 4],
method: 'set_permissions', method: 'set_permissions',
parameters: { parameters: {
@@ -1581,12 +1741,12 @@ describe('BulkEditorComponent', () => {
merge: true, merge: true,
}, },
}) })
httpTestingController.match( httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload )
httpTestingController.match( httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds )
}) })
it('should not attempt to retrieve objects if user does not have permissions', () => { it('should not attempt to retrieve objects if user does not have permissions', () => {
@@ -1887,15 +2047,18 @@ describe('BulkEditorComponent', () => {
expect(toastServiceShowErrorSpy).toHaveBeenCalled() expect(toastServiceShowErrorSpy).toHaveBeenCalled()
expect(listReloadSpy).not.toHaveBeenCalled() expect(listReloadSpy).not.toHaveBeenCalled()
modal.componentInstance.succeeded.emit() modal.componentInstance.succeeded.emit({
expect(toastServiceShowInfoSpy).toHaveBeenCalled() result: 'OK',
expect(listReloadSpy).toHaveBeenCalled() task_id: 'custom-fields-task',
httpTestingController.match( })
expect(toastServiceShowInfoSpy).not.toHaveBeenCalled()
expect(listReloadSpy).not.toHaveBeenCalled()
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload )
httpTestingController.match( httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds )
}) })
it('should create share link bundle and enable manage callback', () => { it('should create share link bundle and enable manage callback', () => {
@@ -34,6 +34,7 @@ import { CustomField } from 'src/app/data/custom-field'
import { MatchingModel } from 'src/app/data/matching-model' import { MatchingModel } from 'src/app/data/matching-model'
import { SelectionDataItem } from 'src/app/data/results' import { SelectionDataItem } from 'src/app/data/results'
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 { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { OpenDocumentsService } from 'src/app/services/open-documents.service' import { OpenDocumentsService } from 'src/app/services/open-documents.service'
@@ -53,6 +54,7 @@ import {
} from 'src/app/services/rest/document.service' } from 'src/app/services/rest/document.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service' import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service' import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
import { WorkflowService } from 'src/app/services/rest/workflow.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { TagService } from 'src/app/services/rest/tag.service' import { TagService } from 'src/app/services/rest/tag.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
@@ -64,6 +66,8 @@ import { MergeAsVersionsConfirmDialogComponent } from '../../common/confirm-dial
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component' import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
import { ReprocessConfirmDialogComponent } from '../../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component' import { ReprocessConfirmDialogComponent } from '../../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component' import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
import { RunWorkflowConfirmDialogComponent } from '../../common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component'
import { ZoneOcrConfirmDialogComponent } from '../../common/confirm-dialog/zone-ocr-confirm-dialog/zone-ocr-confirm-dialog.component'
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component' import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component' import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component' import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
@@ -81,6 +85,7 @@ import { PermissionsDialogComponent } from '../../common/permissions-dialog/perm
import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component' import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component' import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
import { CustomFieldsBulkEditDialogComponent } from './custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component' import { CustomFieldsBulkEditDialogComponent } from './custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component'
import { WorkflowTriggerType } from 'src/app/data/workflow-trigger'
@Component({ @Component({
selector: 'pngx-bulk-editor', selector: 'pngx-bulk-editor',
@@ -104,18 +109,21 @@ export class BulkEditorComponent
private correspondentService = inject(CorrespondentService) private correspondentService = inject(CorrespondentService)
list = inject(DocumentListViewService) list = inject(DocumentListViewService)
private documentService = inject(DocumentService) private documentService = inject(DocumentService)
private trashService = inject(TrashService)
private modalService = inject(NgbModal) private modalService = inject(NgbModal)
private openDocumentService = inject(OpenDocumentsService) private openDocumentService = inject(OpenDocumentsService)
private settings = inject(SettingsService) private settings = inject(SettingsService)
private toastService = inject(ToastService) private toastService = inject(ToastService)
private trashService = inject(TrashService)
private storagePathService = inject(StoragePathService) private storagePathService = inject(StoragePathService)
private customFieldService = inject(CustomFieldsService) private customFieldService = inject(CustomFieldsService)
public readonly permissionService = inject(PermissionsService) public readonly permissionService = inject(PermissionsService)
private savedViewService = inject(SavedViewService) private savedViewService = inject(SavedViewService)
private readonly shareLinkBundleService = inject(ShareLinkBundleService) private readonly shareLinkBundleService = inject(ShareLinkBundleService)
private readonly workflowService = inject(WorkflowService)
private readonly router = inject(Router) private readonly router = inject(Router)
public manualWorkflows: Workflow[] = []
tagSelectionModel = new FilterableDropdownSelectionModel(true) tagSelectionModel = new FilterableDropdownSelectionModel(true)
correspondentSelectionModel = new FilterableDropdownSelectionModel() correspondentSelectionModel = new FilterableDropdownSelectionModel()
documentTypeSelectionModel = new FilterableDropdownSelectionModel() documentTypeSelectionModel = new FilterableDropdownSelectionModel()
@@ -258,6 +266,20 @@ export class BulkEditorComponent
(result) => (this.customFieldsSelectionModel.items = result.results) (result) => (this.customFieldsSelectionModel.items = result.results)
) )
} }
if (
this.permissionService.currentUserCan(
PermissionAction.View,
PermissionType.Workflow
)
) {
this.workflowService
.list(1, 100000, null, null, {
trigger_type: WorkflowTriggerType.Manual,
enabled: true,
})
.pipe(first())
.subscribe((result) => (this.manualWorkflows = result.results))
}
this.downloadForm this.downloadForm
.get('downloadFileTypeArchive') .get('downloadFileTypeArchive')
@@ -286,6 +308,28 @@ export class BulkEditorComponent
this.unsubscribeNotifier.complete() this.unsubscribeNotifier.complete()
} }
get canRunWorkflow(): boolean {
return this.userCanEditAll && this.manualWorkflows.length > 0
}
runWorkflowSelected() {
const modal = this.modalService.open(RunWorkflowConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Run workflow`
modal.componentInstance.message = $localize`Run a workflow on ${this.getSelectionSize()} selected document(s).`
modal.componentInstance.btnCaption = $localize`Run`
modal.componentInstance.workflows = this.manualWorkflows
modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
this.executeBulkEditMethod(modal, 'run_workflow', {
workflow: modal.componentInstance.workflowId,
filter_documents: !modal.componentInstance.force,
})
})
}
private executeBulkEditMethod( private executeBulkEditMethod(
modal: NgbModalRef, modal: NgbModalRef,
method: DocumentBulkEditMethod, method: DocumentBulkEditMethod,
@@ -299,11 +343,33 @@ export class BulkEditorComponent
.bulkEdit(overrideSelection ?? this.getSelectionQuery(), method, args) .bulkEdit(overrideSelection ?? this.getSelectionQuery(), method, args)
.pipe(first()) .pipe(first())
.subscribe({ .subscribe({
next: () => this.handleOperationSuccess(modal), next: ({ task_id }) => this.handleOperationQueued(modal, task_id),
error: (error) => this.handleOperationError(modal, error), error: (error) => this.handleOperationError(modal, error),
}) })
} }
private handleOperationQueued(modal: NgbModalRef, taskId: string) {
modal?.close()
this.list.selectNone()
const canViewTasks = this.permissionService.currentUserCan(
PermissionAction.View,
PermissionType.PaperlessTask
)
this.toastService.show({
content: $localize`Bulk operation queued.`,
delay: 5000,
...(canViewTasks
? {
actionName: $localize`View task`,
action: () =>
this.router.navigate(['/tasks'], {
queryParams: { task_id: taskId },
}),
}
: {}),
})
}
private executeDocumentAction( private executeDocumentAction(
modal: NgbModalRef, modal: NgbModalRef,
request: Observable<any>, request: Observable<any>,
@@ -966,6 +1032,30 @@ export class BulkEditorComponent
}) })
} }
runZoneOcrSelected() {
const modal = this.modalService.open(ZoneOcrConfirmDialogComponent, {
backdrop: 'static',
})
const dialog = modal.componentInstance as ZoneOcrConfirmDialogComponent
dialog.title = $localize`Run zone OCR confirm`
dialog.messageBold = $localize`This operation will run zone OCR on ${this.getSelectionSize()} selected document(s).`
dialog.message = $localize`Fields filled by the template will be overwritten.`
dialog.btnClass = 'btn-warning'
dialog.btnCaption = $localize`Proceed`
dialog.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
dialog.buttonsEnabled.set(false)
this.executeDocumentAction(
modal,
this.documentService.runZoneOcrDocuments(
this.getSelectionQuery(),
dialog.template
)
)
})
}
setPermissions() { setPermissions() {
let modal = this.modalService.open(PermissionsDialogComponent, { let modal = this.modalService.open(PermissionsDialogComponent, {
backdrop: 'static', backdrop: 'static',
@@ -1088,14 +1178,9 @@ export class BulkEditorComponent
dialog.selection = this.getSelectionQuery() dialog.selection = this.getSelectionQuery()
dialog.selectionCount = this.getSelectionSize() dialog.selectionCount = this.getSelectionSize()
dialog.succeeded.subscribe((result) => { dialog.succeeded.subscribe(({ task_id }) =>
this.toastService.showSuccess($localize`Custom fields updated.`) this.handleOperationQueued(null, task_id)
this.list.reload() )
this.list.reduceSelectionToFilter()
this.list.selected.forEach((id) => {
this.openDocumentService.refreshDocument(id)
})
})
dialog.failed.subscribe((error) => { dialog.failed.subscribe((error) => {
this.toastService.showError( this.toastService.showError(
$localize`Error updating custom fields.`, $localize`Error updating custom fields.`,
@@ -59,7 +59,7 @@ describe('CustomFieldsBulkEditDialogComponent', () => {
it('should emit succeeded event and close modal on successful save', () => { it('should emit succeeded event and close modal on successful save', () => {
const editSpy = jest const editSpy = jest
.spyOn(documentService, 'bulkEdit') .spyOn(documentService, 'bulkEdit')
.mockReturnValue(of('Success')) .mockReturnValue(of({ result: 'OK', task_id: 'custom-fields-task' }))
const successSpy = jest.spyOn(component.succeeded, 'emit') const successSpy = jest.spyOn(component.succeeded, 'emit')
component.selection = [1, 2] component.selection = [1, 2]
@@ -16,6 +16,7 @@ import { NumberComponent } from 'src/app/components/common/input/number/number.c
import { SelectComponent } from 'src/app/components/common/input/select/select.component' import { SelectComponent } from 'src/app/components/common/input/select/select.component'
import { TextComponent } from 'src/app/components/common/input/text/text.component' import { TextComponent } from 'src/app/components/common/input/text/text.component'
import { UrlComponent } from 'src/app/components/common/input/url/url.component' import { UrlComponent } from 'src/app/components/common/input/url/url.component'
import { BulkOperationResponse } from 'src/app/data/bulk-operation'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { import {
DocumentSelectionQuery, DocumentSelectionQuery,
@@ -49,7 +50,7 @@ export class CustomFieldsBulkEditDialogComponent {
CustomFieldDataType = CustomFieldDataType CustomFieldDataType = CustomFieldDataType
@Output() @Output()
succeeded = new EventEmitter() succeeded = new EventEmitter<BulkOperationResponse>()
@Output() @Output()
failed = new EventEmitter() failed = new EventEmitter()
@@ -103,6 +104,8 @@ export class CustomFieldsBulkEditDialogComponent {
} }
public save() { public save() {
if (this.networkActive) return
this.networkActive = true
this.documentService this.documentService
.bulkEdit(this.selection, 'modify_custom_fields', { .bulkEdit(this.selection, 'modify_custom_fields', {
add_custom_fields: this.form.value, add_custom_fields: this.form.value,
@@ -110,11 +113,12 @@ export class CustomFieldsBulkEditDialogComponent {
}) })
.pipe(first()) .pipe(first())
.subscribe({ .subscribe({
next: () => { next: (response) => {
this.activeModal.close() this.activeModal.close()
this.succeeded.emit() this.succeeded.emit(response)
}, },
error: (error) => { error: (error) => {
this.networkActive = false
this.failed.emit(error) this.failed.emit(error)
}, },
}) })
@@ -2,26 +2,47 @@
<div> <div>
<form [formGroup]="noteForm" class="needs-validation mt-3" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Note }" novalidate> <form [formGroup]="noteForm" class="needs-validation mt-3" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Note }" novalidate>
<div class="form-group"> <div class="form-group">
<textarea class="form-control form-control-sm" [class.is-invalid]="newNoteError" rows="3" formControlName="newNote" placeholder="Enter note" i18n-placeholder (keydown)="noteFormKeydown($event)" required></textarea> <textarea class="form-control form-control-sm" [class.is-invalid]="newNoteError" [class.d-none]="noteView() !== NoteViewMode.Edit" [attr.aria-invalid]="newNoteError" aria-label="Note" i18n-aria-label aria-describedby="noteFeedback" rows="3" formControlName="newNote" placeholder="Enter note (Markdown is supported)" i18n-placeholder (keydown)="noteFormKeydown($event)" required></textarea>
<div class="invalid-feedback" i18n> @if (noteView() === NoteViewMode.Preview) {
Please enter a note. <section class="form-control form-control-sm note-preview" aria-label="Note preview" i18n-aria-label>
</div> @if (noteForm.get('newNote').value) {
<div class="note-markdown text-break text-wrap" [innerHTML]="noteForm.get('newNote').value | markdown"></div>
} @else {
<span class="text-muted" i18n>Nothing to preview</span>
}
</section>
}
<div id="noteFeedback" class="invalid-feedback" i18n>
Please enter a note.
</div>
</div> </div>
<div class="form-group mt-2 d-flex justify-content-end align-items-center"> <div class="form-group mt-2 d-flex justify-content-between align-items-center">
@if (networkActive()) { @if (networkActive()) {
<div class="spinner-border spinner-border-sm fw-normal me-auto" role="status"></div> <div class="spinner-border spinner-border-sm fw-normal me-auto" role="status"></div>
} }
<fieldset class="btn-group btn-group-sm" aria-label="Note input mode" i18n-aria-label>
<input type="radio" class="btn-check" id="noteViewEdit" name="noteViewMode" [checked]="noteView() === NoteViewMode.Edit" (change)="noteView.set(NoteViewMode.Edit)">
<label class="btn btn-outline-secondary" for="noteViewEdit" i18n>Edit</label>
<input type="radio" class="btn-check" id="noteViewPreview" name="noteViewMode" [checked]="noteView() === NoteViewMode.Preview" (change)="noteView.set(NoteViewMode.Preview)">
<label class="btn btn-outline-secondary" for="noteViewPreview" i18n>Preview</label>
</fieldset>
<button type="button" class="btn btn-primary btn-sm" [disabled]="networkActive() || addDisabled()" (click)="addNote()" i18n>Add note</button> <button type="button" class="btn btn-primary btn-sm" [disabled]="networkActive() || addDisabled()" (click)="addNote()" i18n>Add note</button>
</div> </div>
</form> </form>
<hr> <hr>
@for (note of notes(); track note) { @for (note of notes(); track note.id) {
<div class="card border mb-3"> <div class="card border mb-3">
<div class="card-body text-dark"> <div class="card-body text-dark">
<p class="card-text">{{note.note}}</p> @if (note.format === NoteFormat.Markdown) {
<div class="card-text note-markdown text-break text-wrap" [innerHTML]="note.note | markdown"></div>
} @else {
<p class="card-text">{{note.note}}</p>
}
</div> </div>
<div class="d-flex card-footer small bg-light text-primary justify-content-between align-items-center"> <div class="d-flex card-footer small bg-light text-primary justify-content-between align-items-center">
<span>{{displayName(note)}} - {{ note.created | customDate}}</span> <span>
{{displayName(note)}} - {{ note.created | customDate}}
</span>
<button type="button" class="btn btn-link btn-sm p-0 fade" title="Delete note" i18n-title (click)="deleteNote(note.id)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Note }"> <button type="button" class="btn btn-link btn-sm p-0 fade" title="Delete note" i18n-title (click)="deleteNote(note.id)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Note }">
<pngx-bs-icon name="trash"></pngx-bs-icon> <pngx-bs-icon name="trash"></pngx-bs-icon>
<span class="visually-hidden" i18n>Delete note</span> <span class="visually-hidden" i18n>Delete note</span>
@@ -5,3 +5,42 @@
.card:hover .fade { .card:hover .fade {
opacity: 1; opacity: 1;
} }
.note-preview {
min-height: calc(1.5em * 3 + 0.5rem + var(--bs-border-width) * 2); // textfield height with 3 rows
}
.note-markdown ::ng-deep {
h1, h2, h3, h4, h5, h6 {
font-size: 1em;
font-weight: bold;
}
table {
margin-bottom: 1rem;
display: block;
overflow-x: auto;
white-space: nowrap;
}
th, td {
padding: 0.15rem 0.4rem;
border: 1px solid var(--bs-border-color);
}
pre {
padding: 0.5rem;
background-color: var(--bs-tertiary-bg);
border-radius: var(--bs-border-radius);
}
blockquote {
padding-left: 0.75rem;
border-left: 0.25rem solid var(--bs-border-color);
color: var(--bs-secondary-color);
}
> :last-child {
margin-bottom: 0;
}
}
@@ -5,7 +5,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { By } from '@angular/platform-browser' import { By } from '@angular/platform-browser'
import { of, throwError } from 'rxjs' import { of, throwError } from 'rxjs'
import { DocumentNote } from 'src/app/data/document-note' import { DocumentNote, NoteFormat } from 'src/app/data/document-note'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { PermissionsService } from 'src/app/services/permissions.service' import { PermissionsService } from 'src/app/services/permissions.service'
@@ -129,6 +129,66 @@ describe('DocumentNotesComponent', () => {
expect(fixture.debugElement.nativeElement.textContent).toContain('user3') expect(fixture.debugElement.nativeElement.textContent).toContain('user3')
}) })
it('should render notes according to their format', () => {
component.notes.set([
{ id: 31, note: '**x**', format: NoteFormat.Markdown },
{
id: 32,
note: '<script>alert(1)</script>',
format: NoteFormat.Markdown,
},
{ id: 33, note: '**x**', format: NoteFormat.Plain },
])
fixture.detectChanges()
const noteCards = fixture.debugElement.queryAll(By.css('.card-text'))
expect(noteCards[0].query(By.css('strong')).nativeElement.textContent).toBe(
'x'
)
expect(noteCards[1].query(By.css('script'))).toBeNull()
expect(noteCards[2].query(By.css('strong'))).toBeNull()
expect(noteCards[2].nativeElement.textContent).toBe('**x**')
})
it('should toggle between edit and preview', () => {
const radios = fixture.debugElement.queryAll(
By.css('.btn-group input[type="radio"]')
)
radios[1].triggerEventHandler('change')
fixture.detectChanges()
expect(
fixture.debugElement.query(By.css('.note-preview')).nativeElement
.textContent
).toContain('Nothing to preview')
component.noteForm.get('newNote').setValue('**bold**')
fixture.detectChanges()
expect(
fixture.debugElement.query(By.css('.note-preview strong'))
).not.toBeNull()
expect(
fixture.debugElement.query(By.css('textarea')).nativeElement.classList
).toContain('d-none')
radios[0].triggerEventHandler('change')
fixture.detectChanges()
expect(fixture.debugElement.query(By.css('.note-preview'))).toBeNull()
expect(
fixture.debugElement.query(By.css('textarea')).nativeElement.classList
).not.toContain('d-none')
})
it('should return to edit mode after saving', () => {
component.documentId.set(12)
component.noteForm.get('newNote').setValue('Preview note')
fixture.debugElement
.queryAll(By.css('.btn-group input[type="radio"]'))[1]
.triggerEventHandler('change')
jest.spyOn(notesService, 'addNote').mockReturnValueOnce(of(notes))
component.addNote()
expect(component.noteView()).toBe('edit')
})
it('should handle note user display in all situations', () => { it('should handle note user display in all situations', () => {
expect( expect(
component.displayName({ component.displayName({
@@ -161,7 +221,7 @@ describe('DocumentNotesComponent', () => {
const addSpy = jest.spyOn(notesService, 'addNote') const addSpy = jest.spyOn(notesService, 'addNote')
addSpy.mockReturnValueOnce(throwError(() => new Error('error saving note'))) addSpy.mockReturnValueOnce(throwError(() => new Error('error saving note')))
const toastsSpy = jest.spyOn(toastService, 'showError') const toastsSpy = jest.spyOn(toastService, 'showError')
const addButton = fixture.debugElement.query(By.css('button')) const addButton = fixture.debugElement.query(By.css('.btn-primary'))
addButton.triggerEventHandler('click') addButton.triggerEventHandler('click')
expect(addSpy).toHaveBeenCalledWith(12, note) expect(addSpy).toHaveBeenCalledWith(12, note)
expect(toastsSpy).toHaveBeenCalled() expect(toastsSpy).toHaveBeenCalled()
@@ -190,7 +250,9 @@ describe('DocumentNotesComponent', () => {
component.documentId.set(12) component.documentId.set(12)
component.notes.set(notes) component.notes.set(notes)
fixture.detectChanges() fixture.detectChanges()
const deleteButton = fixture.debugElement.queryAll(By.css('button'))[1] // 0 is add button const deleteButton = fixture.debugElement.query(
By.css('.card-footer button')
)
const deleteSpy = jest.spyOn(notesService, 'deleteNote') const deleteSpy = jest.spyOn(notesService, 'deleteNote')
const toastsSpy = jest.spyOn(toastService, 'showError') const toastsSpy = jest.spyOn(toastService, 'showError')
deleteSpy.mockReturnValueOnce( deleteSpy.mockReturnValueOnce(
@@ -14,15 +14,21 @@ import {
ReactiveFormsModule, ReactiveFormsModule,
} from '@angular/forms' } from '@angular/forms'
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component' import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
import { DocumentNote } from 'src/app/data/document-note' import { DocumentNote, NoteFormat } from 'src/app/data/document-note'
import { User } from 'src/app/data/user' import { User } from 'src/app/data/user'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'
import { DocumentNotesService } from 'src/app/services/rest/document-notes.service' import { DocumentNotesService } from 'src/app/services/rest/document-notes.service'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component' import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
enum NoteViewMode {
Edit = 'edit',
Preview = 'preview',
}
@Component({ @Component({
selector: 'pngx-document-notes', selector: 'pngx-document-notes',
templateUrl: './document-notes.component.html', templateUrl: './document-notes.component.html',
@@ -30,6 +36,7 @@ import { ComponentWithPermissions } from '../with-permissions/with-permissions.c
imports: [ imports: [
IfPermissionsDirective, IfPermissionsDirective,
CustomDatePipe, CustomDatePipe,
MarkdownPipe,
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
BootstrapIconComponent, BootstrapIconComponent,
@@ -43,6 +50,9 @@ export class DocumentNotesComponent extends ComponentWithPermissions {
readonly notes = model<DocumentNote[]>([]) readonly notes = model<DocumentNote[]>([])
readonly addDisabled = input(false) readonly addDisabled = input(false)
readonly networkActive = signal(false) readonly networkActive = signal(false)
protected readonly NoteViewMode = NoteViewMode
protected readonly NoteFormat = NoteFormat
protected readonly noteView = signal<NoteViewMode>(NoteViewMode.Edit)
noteForm: FormGroup = new FormGroup({ noteForm: FormGroup = new FormGroup({
newNote: new FormControl(''), newNote: new FormControl(''),
@@ -75,6 +85,7 @@ export class DocumentNotesComponent extends ComponentWithPermissions {
next: (result) => { next: (result) => {
this.notes.set(result) this.notes.set(result)
this.noteForm.get('newNote').reset() this.noteForm.get('newNote').reset()
this.noteView.set(NoteViewMode.Edit)
this.networkActive.set(false) this.networkActive.set(false)
this.updated.emit(this.notes()) this.updated.emit(this.notes())
}, },
@@ -0,0 +1,36 @@
@if (zones.length === 0) {
<p class="text-muted" i18n>No zones yet. Drag anywhere on the sample document to create one.</p>
} @else {
<div class="list-group">
@for (zone of zones; track $index; let i = $index) {
<div class="list-group-item list-group-item-action d-flex align-items-center gap-3 position-relative">
<span class="p-2 rounded flex-shrink-0" [style.background-color]="zoneColor(i)" aria-hidden="true"></span>
<div class="flex-grow-1 text-truncate">
<button type="button" class="btn btn-link p-0 fw-semibold text-reset text-decoration-none text-truncate mw-100 stretched-link" (click)="zoneSelected.emit(i)">{{ zone.name }}</button>
@if (!hasField(zone)) {
<div class="small text-warning text-truncate">
<pngx-bs-icon name="exclamation-triangle" class="me-1" aria-hidden="true"></pngx-bs-icon><span i18n>No field selected</span>
</div>
} @else {
<div class="small text-muted text-truncate">
{{ getZoneTargetName(zone) }}
@if (isCombined(zone)) {
<pngx-bs-icon name="braces" class="ms-1" title="Combined with other zones" i18n-title></pngx-bs-icon>
}
</div>
}
</div>
<small class="text-muted text-nowrap">
@if (zone.page === -1) {
<ng-container i18n>Last page</ng-container>
} @else {
<ng-container i18n>Page {{ zone.page ?? 1 }}</ng-container>
}
</small>
<button type="button" class="btn btn-sm btn-link text-danger position-relative z-2" (click)="zoneRemoved.emit(i)" title="Delete zone" i18n-title aria-label="Delete zone" i18n-aria-label>
<pngx-bs-icon name="trash"></pngx-bs-icon>
</button>
</div>
}
</div>
}
@@ -0,0 +1,115 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
import { CustomField } from 'src/app/data/custom-field'
import {
OcrTemplateZone,
OcrZoneTarget,
OcrZoneTransform,
} from 'src/app/data/ocr-template'
import { OcrTemplateEditorZoneListComponent } from './ocr-template-editor-zone-list.component'
function zone(overrides: Partial<OcrTemplateZone> = {}): OcrTemplateZone {
return {
name: 'Zone 1',
target: OcrZoneTarget.CustomField,
custom_field: 7,
x: 10,
y: 20,
width: 30,
height: 40,
page: 1,
ocr_language: 'eng',
transform: OcrZoneTransform.Strip,
validation_regex: '',
order: 0,
...overrides,
}
}
describe('OcrTemplateEditorZoneListComponent', () => {
let fixture: ComponentFixture<OcrTemplateEditorZoneListComponent>
let component: OcrTemplateEditorZoneListComponent
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [OcrTemplateEditorZoneListComponent, BootstrapIconComponent],
}).compileComponents()
fixture = TestBed.createComponent(OcrTemplateEditorZoneListComponent)
component = fixture.componentInstance
})
it('shows empty state when no zones are defined', () => {
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('No zones yet')
})
it('renders zone name, target, and page', () => {
component.zones = [zone(), zone({ name: 'Zone 2', page: -1 })]
component.customFields = [{ id: 7, name: 'Invoice Number' } as CustomField]
fixture.detectChanges()
const text = fixture.nativeElement.textContent
expect(text).toContain('Zone 1')
expect(text).toContain('Invoice Number')
expect(text).toContain('Page 1')
expect(text).toContain('Last page')
})
it('marks zones that share a field', () => {
component.zones = [
zone(),
zone({ name: 'Zone 2' }),
zone({ name: 'Zone 3', custom_field: 8 }),
]
fixture.detectChanges()
expect(component.isCombined(component.zones[0])).toBe(true)
expect(component.isCombined(component.zones[2])).toBe(false)
expect(
fixture.nativeElement.querySelectorAll(
'[title="Combined with other zones"]'
)
).toHaveLength(2)
})
it('warns only for zones without a field and clears the warning when assigned', () => {
component.zones = [
zone({ custom_field: null }),
zone({ target: OcrZoneTarget.Title, custom_field: null }),
zone(),
]
fixture.detectChanges()
const warnings = () =>
fixture.nativeElement.querySelectorAll('.text-warning')
expect(warnings()).toHaveLength(1)
expect(warnings()[0].textContent).toContain('No field selected')
component.zones[0].custom_field = 7
fixture.componentRef.setInput('zones', [...component.zones])
fixture.detectChanges()
expect(warnings()).toHaveLength(0)
})
it('emits select and remove events', () => {
component.zones = [zone()]
const selectSpy = jest.spyOn(component.zoneSelected, 'emit')
const removeSpy = jest.spyOn(component.zoneRemoved, 'emit')
fixture.detectChanges()
const buttons = fixture.nativeElement.querySelectorAll('button')
buttons[0].click()
buttons[1].click()
expect(selectSpy).toHaveBeenCalledWith(0)
expect(removeSpy).toHaveBeenCalledWith(0)
})
it('does not label an unknown built-in target', () => {
expect(
component.getZoneTargetName(zone({ target: 'unknown' as OcrZoneTarget }))
).toBe('')
})
})
@@ -0,0 +1,50 @@
import { Component, EventEmitter, Input, Output } from '@angular/core'
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
import { CustomField } from 'src/app/data/custom-field'
import {
getZoneFieldKey,
OCR_BUILTIN_TARGETS,
OcrTemplateZone,
OcrZoneTarget,
} from 'src/app/data/ocr-template'
import { getZoneColor } from '../zone-geometry'
@Component({
selector: 'pngx-ocr-template-zone-list',
imports: [BootstrapIconComponent],
templateUrl: './ocr-template-editor-zone-list.component.html',
})
export class OcrTemplateEditorZoneListComponent {
@Input() zones: OcrTemplateZone[] = []
@Input() customFields: CustomField[] = []
@Output() public readonly zoneSelected = new EventEmitter<number>()
@Output() public readonly zoneRemoved = new EventEmitter<number>()
public readonly zoneColor = getZoneColor
hasField(zone: OcrTemplateZone): boolean {
return getZoneFieldKey(zone) !== null
}
isCombined(zone: OcrTemplateZone): boolean {
const key = getZoneFieldKey(zone)
return (
key !== null &&
this.zones.filter((z) => getZoneFieldKey(z) === key).length > 1
)
}
getZoneTargetName(zone: OcrTemplateZone): string {
if (zone.target === OcrZoneTarget.CustomField) {
return (
this.customFields.find((field) => field.id === zone.custom_field)
?.name ?? ''
)
}
return (
OCR_BUILTIN_TARGETS.find((target) => target.id === zone.target)?.name ??
''
)
}
}
@@ -0,0 +1,313 @@
<div class="modal-header">
<h4 class="modal-title" id="ocr-template-editor-title">{{ dialogTitle }}</h4>
@if (template.id) {
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{ template.id }}</span>
}
<button type="button" class="btn-close" (click)="activeModal.dismiss()" [disabled]="saving()" aria-label="Close" i18n-aria-label></button>
</div>
<div class="modal-body d-flex flex-column">
<div class="row gx-3 editor-layout flex-grow-1 flex-lg-nowrap" [attr.inert]="saving() ? '' : null" [attr.aria-busy]="saving()">
<div class="col-lg-4 mb-3 mb-lg-0 overflow-auto">
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTab" class="nav-underline flex-nowrap flex-md-wrap overflow-auto">
<li ngbNavItem="template">
<a ngbNavLink i18n>Template</a>
<ng-template ngbNavContent>
<div class="row mb-3">
<div class="col-9">
<pngx-input-text [(ngModel)]="template.name" title="Template name" i18n-title></pngx-input-text>
</div>
<div class="col-3">
<pngx-input-switch [(ngModel)]="template.enabled" title="Enabled" i18n-title></pngx-input-switch>
</div>
</div>
<pngx-input-select [(ngModel)]="template.matching_algorithm" [items]="matchingAlgorithms" title="Matching algorithm" i18n-title></pngx-input-select>
@if (patternRequired) {
<pngx-input-text [(ngModel)]="template.match" title="Matching pattern" i18n-title></pngx-input-text>
<pngx-input-check [(ngModel)]="template.is_insensitive" title="Case insensitive" i18n-title></pngx-input-check>
}
<div *pngxIfOwner="object">
<hr />
<pngx-permissions-form [users]="users()" [accordion]="true" [(ngModel)]="permissionsForm"></pngx-permissions-form>
</div>
</ng-template>
</li>
<li ngbNavItem="zones">
<a ngbNavLink><ng-container i18n>Zones</ng-container> <span class="badge bg-primary ms-2">{{ template.zones.length }}</span></a>
<ng-template ngbNavContent>
@if (selectedZone; as zone) {
<div class="d-flex align-items-center gap-2 mb-3">
<button type="button" class="btn btn-sm btn-link px-0 me-auto" (click)="deselectZone()">
<pngx-bs-icon name="arrow-left" class="me-1"></pngx-bs-icon><ng-container i18n>All zones</ng-container>
</button>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="testZone()" [disabled]="!previewDocId || zoneTesting">
@if (zoneTesting) {
<span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span>
} @else {
<pngx-bs-icon name="play-fill" class="me-1"></pngx-bs-icon>
}
<ng-container i18n>Test</ng-container>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" (click)="removeZone(selectedZoneIndex)" title="Delete zone" i18n-title aria-label="Delete zone" i18n-aria-label>
<pngx-bs-icon name="trash"></pngx-bs-icon>
</button>
</div>
@if (zoneTestResult; as result) {
@if (result.error) {
<div class="alert alert-warning py-2 mb-3 small" role="alert">{{ result.error }}</div>
} @else {
<dl class="row small border rounded mx-0 py-2 mb-3">
<dt class="col-4" i18n>OCR text</dt>
<dd class="col-8">
@if (result.raw_text) {
<code>{{ result.raw_text }}</code>
} @else {
<span class="text-muted" i18n>Nothing detected</span>
}
</dd>
<dt class="col-4" i18n>Value</dt>
<dd class="col-8" [class.mb-0]="!result.regex">
@if (result.value) {
<code>{{ result.value }}</code>
} @else {
<span class="text-muted" i18n>Empty</span>
}
</dd>
@if (result.regex) {
<dt class="col-4" i18n>Validation</dt>
<dd class="col-8 mb-0">
@if (result.regex_match) {
<span class="badge bg-success" i18n>Regex matches</span>
} @else {
<span class="badge bg-danger" i18n>Regex does not match</span>
}
</dd>
}
</dl>
}
}
<pngx-input-text [ngModel]="zone.name" (ngModelChange)="renameZone(zone, $event)" title="Name" i18n-title></pngx-input-text>
<pngx-input-select
[items]="fieldItems()"
[ngModel]="zoneFieldValue(zone)"
(ngModelChange)="setZoneField(zone, $event)"
(createNew)="createField($event)"
title="Field"
i18n-title
placeholder="Choose a field"
i18n-placeholder
hint="Where the extracted value is written."
i18n-hint
></pngx-input-select>
@if (isFieldShared(zone)) {
<pngx-input-text
[ngModel]="getCombineFormat(zone)"
(ngModelChange)="setCombineFormat(zone, $event)"
[placeholder]="defaultCombineFormat(zone)"
title="Combined value"
i18n-title
hint="Several zones write to this field. Set the format or leave empty to join them with spaces."
i18n-hint
></pngx-input-text>
<div class="d-flex flex-wrap gap-1 mt-n2 mb-3">
@for (z of zonesForField(zone); track z) {
<button type="button" class="btn btn-sm btn-outline-secondary font-monospace" (click)="insertCombineToken(zone, z)">{{ '{' + z.name + '}' }}</button>
}
</div>
}
<div class="row align-items-end">
<div class="col">
@if (!isLastPageZone(zone)) {
<pngx-input-number [(ngModel)]="zone.page" [showAdd]="false" title="Page" i18n-title></pngx-input-number>
}
</div>
<div class="col-auto">
<pngx-input-switch [ngModel]="isLastPageZone(zone)" (ngModelChange)="setLastPageZone(zone, $event)" [horizontal]="true" title="Last page" i18n-title></pngx-input-switch>
</div>
</div>
<h6 class="border-top pt-3 mt-1 mb-3" i18n>Processing</h6>
<pngx-input-select
[items]="languageOptions"
[multiple]="true"
[ngModel]="ocrLanguageArray(zone)"
(ngModelChange)="setOcrLanguages(zone, $event)"
title="OCR language"
i18n-title
placeholder="Select languages"
i18n-placeholder
></pngx-input-select>
<pngx-input-select [items]="transformOptions" [(ngModel)]="zone.transform" title="Transform" i18n-title></pngx-input-select>
@if (zone.transform === OcrZoneTransform.Date) {
<pngx-input-text
[(ngModel)]="zone.date_format"
title="Date format"
i18n-title
placeholder="Auto-detect"
i18n-placeholder
hint="Python date codes, e.g. %d.%m.%Y. Leave empty to detect the format automatically."
i18n-hint
></pngx-input-text>
}
<pngx-input-text [(ngModel)]="zone.validation_regex" title="Validation regex" i18n-title placeholder="\d{2}\.\d{2}\.\d{4}" hint="The value must match this pattern to be applied." i18n-hint></pngx-input-text>
} @else {
<pngx-ocr-template-zone-list
[zones]="template.zones"
[customFields]="customFields()"
(zoneSelected)="selectZone($event)"
(zoneRemoved)="removeZone($event)"
></pngx-ocr-template-zone-list>
}
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav" class="mt-3"></div>
</div>
<div class="col-lg-8 d-flex flex-column">
<div class="d-flex flex-wrap gap-2 mb-2 flex-shrink-0">
<pngx-input-document-link
class="flex-grow-1"
[ngModel]="sampleDocumentIds"
(ngModelChange)="setSampleDocument($event)"
[minimal]="true"
[multiple]="false"
placeholder="Search for a sample document"
i18n-placeholder
></pngx-input-document-link>
@if (previewDocId) {
<div class="input-group w-auto flex-nowrap">
<input class="form-control flex-grow-0 preview-page-input" type="number" min="1" [max]="previewPageCount()" [(ngModel)]="previewPageDisplay" [disabled]="!pageLoaded()" aria-label="Page" i18n-aria-label />
<span class="input-group-text" i18n>of {{ previewPageCount() ?? '?' }}</span>
</div>
<fieldset class="input-group w-auto flex-nowrap" aria-label="Zoom" i18n-aria-label>
<button type="button" class="btn btn-outline-secondary" aria-label="Zoom out" i18n-aria-label [disabled]="!pageLoaded() || zoom <= 0.5" (click)="zoomOut()"><pngx-bs-icon name="dash"></pngx-bs-icon></button>
<button type="button" class="btn btn-outline-secondary preview-zoom" title="Reset zoom" i18n-title [disabled]="!pageLoaded()" (click)="resetZoom()">{{ zoom | percent: '1.0-0' }}</button>
<button type="button" class="btn btn-outline-secondary" aria-label="Zoom in" i18n-aria-label [disabled]="!pageLoaded() || zoom >= 4" (click)="zoomIn()"><pngx-bs-icon name="plus"></pngx-bs-icon></button>
</fieldset>
}
</div>
<div class="flex-grow-1 overflow-hidden">
<div class="h-100 overflow-auto border" [attr.aria-busy]="previewDocId && !pageLoaded()">
@if (pageFailed()) {
<div class="py-5 px-3 text-center" role="alert">
<pngx-bs-icon name="exclamation-triangle" width="32" height="32" class="text-warning"></pngx-bs-icon>
<p class="my-3" i18n>Could not load this page.</p>
<button type="button" class="btn btn-sm btn-outline-secondary" (click)="setSampleDocument(sampleDocumentIds)"><pngx-bs-icon name="arrow-clockwise" class="me-1"></pngx-bs-icon><span i18n>Retry</span></button>
</div>
} @else if (previewDocId) {
<div class="position-relative" [class.d-none]="!pageLoaded()" [style.width.%]="zoom * 100">
<img
#pageImage
[src]="pageImageUrl"
(load)="onImageLoad()"
(error)="onImageError()"
alt="Sample document page"
i18n-alt
class="d-block w-100"
crossorigin="use-credentials"
/>
@if (pageLoaded()) {
<svg
#zoneOverlay
class="zone-overlay position-absolute top-0 start-0 w-100 h-100"
[attr.viewBox]="overlayViewBox()"
preserveAspectRatio="none"
[style.cursor]="overlayCursor"
(pointerdown)="onOverlayPointerDown($event)"
(pointermove)="onOverlayPointerMove($event)"
(pointerup)="onOverlayPointerUp($event)"
(pointercancel)="onOverlayPointerCancel($event)"
(lostpointercapture)="onOverlayPointerCancel($event)"
aria-label="Zone overlay" i18n-aria-label
>
@for (zone of template.zones; track $index; let i = $index) {
@if (zoneDisplayRect(i); as rect) {
<g>
<rect
class="zone-rect"
[class.zone-rect-selected]="selectedZoneIndex === i"
[attr.x]="rect.x"
[attr.y]="rect.y"
[attr.width]="rect.w"
[attr.height]="rect.h"
[attr.stroke]="zoneColor(i)"
[attr.fill]="zoneColor(i)"
></rect>
<text
class="zone-label fw-semibold pe-none"
[attr.x]="rect.x + overlayUnitSize(6)"
[attr.y]="zoneLabelY(rect)"
[attr.font-size]="overlayFontSize()"
[attr.fill]="zoneColor(i)"
>{{ zoneLabel(zone, i) }}</text>
@if (selectedZoneIndex === i) {
@for (handle of resizeHandles(rect); track handle.handle) {
<rect
class="zone-resize-handle"
[attr.x]="handle.x - overlayHandleSize() / 2"
[attr.y]="handle.y - overlayHandleSize() / 2"
[attr.width]="overlayHandleSize()"
[attr.height]="overlayHandleSize()"
[attr.fill]="zoneColor(i)"
></rect>
}
}
</g>
}
}
@if (drawingRect(); as rect) {
<rect
class="zone-drawing-rect"
[attr.x]="rect.x"
[attr.y]="rect.y"
[attr.width]="rect.w"
[attr.height]="rect.h"
></rect>
}
</svg>
}
</div>
@if (!pageLoaded()) {
<div class="p-5 d-flex justify-content-center align-items-center">
<output class="spinner-border">
<span class="visually-hidden" i18n>Loading page...</span>
</output>
</div>
}
} @else {
<div class="py-5 px-3 text-center text-muted">
<pngx-bs-icon name="file-text" width="32" height="32"></pngx-bs-icon>
<p class="mt-3 mb-0" i18n>No sample document selected.</p>
</div>
}
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" (click)="activeModal.dismiss()" [disabled]="saving()" i18n>Cancel</button>
<button type="button" class="btn btn-primary" (click)="save()" [disabled]="saving() || !isDirty">
@if (saving()) {
<span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span>
}
<span i18n>Save</span>
</button>
</div>
@@ -0,0 +1,64 @@
:host {
display: flex;
flex-direction: column;
min-height: 0;
}
@media (min-width: 992px) {
.modal-body {
overflow: hidden;
}
.editor-layout {
min-height: 0;
}
}
.preview-page-input {
width: 3.5rem;
}
.preview-zoom {
min-width: 4rem;
}
.zone-overlay {
touch-action: none;
}
.zone-rect,
.zone-drawing-rect {
vector-effect: non-scaling-stroke;
}
.zone-rect {
fill-opacity: 0.2;
stroke-width: 2;
}
.zone-rect-selected {
stroke-width: 3;
}
.zone-label {
font-family: var(--bs-font-sans-serif);
paint-order: stroke;
stroke: #fff;
stroke-linejoin: round;
stroke-width: 4px;
vector-effect: non-scaling-stroke;
}
.zone-resize-handle {
stroke: #fff;
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.zone-drawing-rect {
fill: var(--bs-primary);
fill-opacity: 0.2;
stroke: var(--bs-primary);
stroke-dasharray: 5 5;
stroke-width: 2;
}
@@ -0,0 +1,875 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ElementRef } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
import { of, Subject, throwError } from 'rxjs'
import { PermissionsFormComponent } from 'src/app/components/common/input/permissions/permissions-form/permissions-form.component'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { Document } from 'src/app/data/document'
import {
MATCH_ANY,
MATCH_AUTO,
MATCH_LITERAL,
MATCH_NONE,
} from 'src/app/data/matching-model'
import {
OcrTemplate,
OcrTemplateZone,
OcrZoneTarget,
OcrZoneTestResult,
OcrZoneTransform,
} from 'src/app/data/ocr-template'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { User } from 'src/app/data/user'
import { PermissionsService } from 'src/app/services/permissions.service'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { OcrTemplateEditorComponent } from './ocr-template-editor.component'
const customFields: CustomField[] = [
{ id: 1, name: 'Invoice number', data_type: CustomFieldDataType.String },
{ id: 2, name: 'Status', data_type: CustomFieldDataType.Select },
]
function zone(overrides: Partial<OcrTemplateZone> = {}): OcrTemplateZone {
return {
name: 'Invoice number',
target: OcrZoneTarget.CustomField,
custom_field: 1,
x: 100,
y: 100,
width: 200,
height: 100,
page: 1,
ocr_language: 'eng',
transform: OcrZoneTransform.Strip,
validation_regex: '',
order: 0,
...overrides,
}
}
function pointer(x: number, y: number, pointerId = 1): PointerEvent {
return Object.assign(
new MouseEvent('pointermove', { clientX: x, clientY: y, button: 0 }),
{ pointerId }
) as PointerEvent
}
describe('OcrTemplateEditorComponent', () => {
let component: OcrTemplateEditorComponent
let fixture: ComponentFixture<OcrTemplateEditorComponent>
let documentService: DocumentService
let templateService: OcrTemplateService
let modalService: NgbModal
const activeModal = { close: jest.fn(), dismiss: jest.fn() }
beforeEach(() => {
TestBed.configureTestingModule({
imports: [BootstrapIconComponent, OcrTemplateEditorComponent],
providers: [
{ provide: NgbActiveModal, useValue: activeModal },
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
})
jest
.spyOn(TestBed.inject(CustomFieldsService), 'listAll')
.mockReturnValue(of({ count: 2, all: [1, 2], results: customFields }))
jest
.spyOn(TestBed.inject(UserService), 'listAll')
.mockReturnValue(of({ count: 0, all: [], results: [] }))
TestBed.inject(PermissionsService).initialize([], { id: 1 } as User)
documentService = TestBed.inject(DocumentService)
jest
.spyOn(documentService, 'get')
.mockReturnValue(of({ id: 5, page_count: 3 }))
templateService = TestBed.inject(OcrTemplateService)
modalService = TestBed.inject(NgbModal)
activeModal.close.mockReset()
fixture = TestBed.createComponent(OcrTemplateEditorComponent)
component = fixture.componentInstance
})
function prepareOverlay() {
const image = document.createElement('img')
Object.defineProperties(image, {
naturalWidth: { value: 1000 },
naturalHeight: { value: 1000 },
clientWidth: { value: 1000 },
})
const overlay = document.createElementNS(
'http://www.w3.org/2000/svg',
'svg'
)
Object.assign(overlay, {
getScreenCTM: () => ({ inverse: () => ({}) }),
createSVGPoint: () => ({
x: 0,
y: 0,
matrixTransform() {
return { x: this.x, y: this.y }
},
}),
setPointerCapture: jest.fn(),
hasPointerCapture: () => true,
releasePointerCapture: jest.fn(),
})
component.imageRef = new ElementRef(image)
component.overlayRef = new ElementRef(overlay)
component.pageLoaded.set(true)
}
function fieldDialog() {
const dialog = {
dialogMode: { set: jest.fn() },
object: null,
succeeded: new Subject<CustomField>(),
failed: new Subject(),
}
jest
.spyOn(modalService, 'open')
.mockReturnValue({ componentInstance: dialog } as any)
return dialog
}
it('updates screen-sized overlay units when the preview image resizes', () => {
let resize: ResizeObserverCallback
const observer = {
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
}
const resizeObserver = jest
.spyOn(globalThis, 'ResizeObserver')
.mockImplementation((callback) => {
resize = callback
return observer
})
const image = document.createElement('img')
const ref = new ElementRef(image)
component.pageImage = ref
expect(observer.observe).toHaveBeenCalledWith(image)
resize([], observer)
expect(component.overlayUnitSize(8)).toBe(8)
Object.defineProperties(image, {
naturalWidth: { value: 1000 },
clientWidth: { value: 500 },
})
resize([], observer)
expect(component.overlayUnitSize(8)).toBe(16)
expect(component.overlayFontSize()).toBe(24)
component.pageImage = undefined
expect(observer.disconnect).toHaveBeenCalledTimes(1)
fixture.destroy()
expect(observer.disconnect).toHaveBeenCalledTimes(2)
resizeObserver.mockRestore()
})
it('renders loaded image dimensions, zone labels, and selection handles', () => {
component.object = {
...component.template,
id: 3,
sample_document: 5,
zones: [zone()],
}
component.selectedZoneIndex = 0
fixture.detectChanges()
const image = fixture.nativeElement.querySelector('img')
Object.defineProperties(image, {
naturalWidth: { value: 1000 },
naturalHeight: { value: 1500 },
})
image.dispatchEvent(new Event('load'))
fixture.detectChanges()
expect(component.template).toMatchObject({
source_width: 1000,
source_height: 1500,
})
expect(
fixture.nativeElement
.querySelector('.zone-overlay')
.getAttribute('viewBox')
).toBe('0 0 1000 1500')
expect(
fixture.nativeElement.querySelector('.zone-label').textContent
).toContain('Invoice number')
expect(
fixture.nativeElement.querySelectorAll('.zone-resize-handle')
).toHaveLength(8)
expect(component.zoneLabel(zone({ name: '' }), 2)).toBe('Zone 3')
expect(component.zoneLabelY({ x: 0, y: 0, w: 100, h: 100 })).toBe(14)
})
it('offers the supported language and transform choices', () => {
expect(component.languageOptions.map((option) => option.id)).toContain(
'eng'
)
expect(component.transformOptions.map((option) => option.id)).toEqual(
Object.values(OcrZoneTransform)
)
})
it('clamps page navigation and supports one-based page input', () => {
component.setSampleDocument([5])
component.previewPageDisplay = 2
expect(component.previewPage).toBe(1)
expect(component.previewPageDisplay).toBe(2)
expect(component.pageImageUrl).toContain('/5/1/')
component.pageLoaded.set(true)
component.goToPage(1)
expect(component.pageLoaded()).toBe(true)
component.goToPage(Infinity)
component.goToPage(NaN)
expect(component.previewPage).toBe(1)
component.goToPage(99)
expect(component.previewPage).toBe(2)
expect(component.pageLoaded()).toBe(false)
component.goToPage(-5)
expect(component.previewPage).toBe(0)
component.previewPageCount.set(null)
component.goToPage(4.7)
expect(component.previewPage).toBe(4)
})
it('limits zoom and resets it to the original size', () => {
for (let i = 0; i < 20; i++) component.zoomIn()
expect(component.zoom).toBe(4)
for (let i = 0; i < 20; i++) component.zoomOut()
expect(component.zoom).toBe(0.5)
component.resetZoom()
expect(component.zoom).toBe(1)
})
it('moves last-page zones to the last page and can pin them to a page', () => {
component.setSampleDocument([5])
const selected = zone()
component.template.zones = [selected]
expect(component.isLastPageZone(selected)).toBe(false)
component.setLastPageZone(selected, true)
expect(selected.page).toBe(-1)
expect(component.isLastPageZone(selected)).toBe(true)
component.selectZone(0)
expect(component.previewPage).toBe(2)
component.setLastPageZone(selected, false)
expect(selected.page).toBe(3)
})
it('ignores page-count errors from a previous sample document', () => {
const pending = new Subject<Document>()
jest
.spyOn(documentService, 'get')
.mockReturnValueOnce(pending)
.mockReturnValueOnce(of({ id: 6 }))
component.setSampleDocument([5])
component.setSampleDocument([6])
pending.error(new Error('Old document failed'))
expect(component.pageFailed()).toBe(false)
expect(component.previewPageCount()).toBeNull()
})
it('ignores non-primary, unloaded, and saving pointer gestures', () => {
prepareOverlay()
component.onOverlayPointerDown(
new MouseEvent('pointerdown', { button: 2 }) as PointerEvent
)
expect(component.drawingRect()).toBeNull()
component.pageLoaded.set(false)
component.onOverlayPointerDown(pointer(100, 100))
expect(component.drawingRect()).toBeNull()
component.pageLoaded.set(true)
component.saving.set(true)
component.onOverlayPointerDown(pointer(100, 100))
expect(component.drawingRect()).toBeNull()
})
it('ignores pointer events when the SVG coordinate transform is unavailable', () => {
component.onOverlayPointerDown(pointer(100, 100))
expect(component.drawingRect()).toBeNull()
prepareOverlay()
jest
.spyOn(component.overlayRef.nativeElement, 'getScreenCTM')
.mockReturnValue(null)
component.onOverlayPointerDown(pointer(100, 100))
component.onOverlayPointerMove(pointer(200, 200))
expect(component.drawingRect()).toBeNull()
})
it('updates a drawing only for its captured pointer', () => {
prepareOverlay()
component.onOverlayPointerDown(pointer(100, 100))
component.onOverlayPointerDown(pointer(500, 500, 2))
component.onOverlayPointerMove(pointer(900, 900, 2))
expect(component.drawingRect()).toEqual({ x: 100, y: 100, w: 0, h: 0 })
component.onOverlayPointerMove(pointer(250, 200))
expect(component.drawingRect()).toEqual({ x: 100, y: 100, w: 150, h: 100 })
component.onOverlayPointerUp(pointer(250, 200))
expect(component.template.zones[0]).toMatchObject({
x: 100,
y: 100,
width: 150,
height: 100,
})
})
it('resizes a selected zone using its corner handle', () => {
prepareOverlay()
const selected = zone()
component.template.zones = [selected]
component.selectZone(0)
component.onOverlayPointerDown(pointer(100, 100))
component.onOverlayPointerMove(pointer(50, 75))
component.onOverlayPointerUp(pointer(60, 80))
expect(selected).toMatchObject({ x: 60, y: 80, width: 240, height: 120 })
expect(component.template.zones).toHaveLength(1)
})
it('shows the appropriate cursor over handles, zones, and empty space', () => {
prepareOverlay()
component.template.zones = [zone()]
component.onOverlayPointerMove(pointer(150, 150))
expect(component.overlayCursor).toBe('move')
component.selectZone(0)
component.onOverlayPointerMove(pointer(100, 100))
expect(component.overlayCursor).toBe('nw-resize')
component.onOverlayPointerMove(pointer(500, 500))
expect(component.overlayCursor).toBe('crosshair')
component.template.zones[0].page = 2
component.onOverlayPointerMove(pointer(100, 100))
expect(component.zoneDisplayRect(0)).toBeNull()
expect(component.overlayCursor).toBe('crosshair')
})
it('cancels only the captured pointer and tolerates an already-lost capture', () => {
prepareOverlay()
component.onOverlayPointerCancel(pointer(100, 100))
component.onOverlayPointerDown(pointer(100, 100))
component.onOverlayPointerCancel(pointer(150, 150, 2))
expect(component.drawingRect()).not.toBeNull()
jest
.spyOn(component.overlayRef.nativeElement, 'hasPointerCapture')
.mockReturnValue(false)
component.onOverlayPointerCancel(pointer(150, 150))
expect(component.drawingRect()).toBeNull()
expect(
component.overlayRef.nativeElement.releasePointerCapture
).not.toHaveBeenCalled()
expect(component.template.zones).toEqual([])
})
it('preserves the selected zone when an earlier or later zone is removed', () => {
component.template.zones = [zone(), zone(), zone(), zone()]
component.selectZone(1)
const selected = component.selectedZone
component.removeZone(3)
expect(component.selectedZone).toBe(selected)
component.removeZone(0)
expect(component.selectedZoneIndex).toBe(0)
expect(component.selectedZone).toBe(selected)
component.selectZone(99)
expect(component.selectedZone).toBeNull()
})
it('does not test zones without a selection or a sample document', () => {
const test = jest.spyOn(templateService, 'testZone')
component.testZone()
component.template.zones = [zone()]
component.selectZone(0)
component.testZone()
expect(test).not.toHaveBeenCalled()
})
it.each([
{
response: { error: { error: 'OCR unavailable' } },
message: 'OCR unavailable',
},
{ response: new Error('Network failure'), message: 'Test failed' },
])('shows zone-test errors: $message', ({ response, message }) => {
component.template.zones = [zone({ page: undefined })]
component.selectZone(0)
component.setSampleDocument([5])
jest
.spyOn(templateService, 'testZone')
.mockReturnValue(throwError(() => response))
component.testZone()
expect(component.zoneTestResult).toEqual({ error: message })
expect(component.zoneTesting).toBe(false)
expect(templateService.testZone).toHaveBeenCalledWith(
5,
expect.objectContaining({ page: 1 })
)
})
it('switches between built-in targets, custom fields, and an unassigned field', () => {
const selected = zone()
expect(component.zoneFieldValue(selected)).toBe(1)
component.setZoneField(selected, OcrZoneTarget.Title)
expect(component.zoneFieldValue(selected)).toBe(OcrZoneTarget.Title)
expect(selected.custom_field).toBeNull()
component.setZoneField(selected, 7)
expect(selected).toMatchObject({
target: OcrZoneTarget.CustomField,
custom_field: 7,
})
component.setZoneField(selected, null)
expect(component.zoneFieldValue(selected)).toBeNull()
expect(component.zonesForField(selected)).toEqual([])
component.setCombineFormat(selected, 'Ignored')
expect(component.template.combine_formats).toEqual({})
})
it('creates combine formats when absent and leaves implicit formats alone on rename', () => {
const selected = zone({ target: OcrZoneTarget.Title, custom_field: null })
component.template.combine_formats = undefined
component.renameZone(selected, 'Renamed')
expect(selected.name).toBe('Renamed')
expect(component.getCombineFormat(selected)).toBe('')
component.setCombineFormat(selected, '{Renamed} ')
component.insertCombineToken(selected, selected)
expect(component.template.combine_formats).toEqual({
title: '{Renamed} {Renamed}',
})
})
it('saves templates without combine formats', () => {
component.template.combine_formats = undefined
fixture.detectChanges()
expect(component.isDirty).toBe(false)
jest
.spyOn(templateService, 'create')
.mockReturnValue(of(component.template))
component.save()
expect(component.template.combine_formats).toBeUndefined()
})
it('does not assign unsupported custom field types and surfaces creation failures', () => {
component.template.zones = [zone({ custom_field: null })]
component.selectZone(0)
const dialog = fieldDialog()
const error = jest.spyOn(TestBed.inject(ToastService), 'showError')
component.createField('Status')
expect(dialog.object).toEqual({ name: 'Status' })
dialog.succeeded.next(customFields[1])
expect(component.selectedZone.custom_field).toBeNull()
const failure = new Error('Could not save')
dialog.failed.next(failure)
expect(error).toHaveBeenCalledWith('Error saving field.', failure)
})
it('refreshes field choices when a field is created without a selected zone', () => {
const dialog = fieldDialog()
component.createField('Total')
dialog.succeeded.next(customFields[0])
expect(component.customFields()).toEqual(customFields)
expect(component.selectedZone).toBeNull()
})
it('should edit a copy of the template and load its sample document', () => {
const original: OcrTemplate = {
...component.template,
id: 3,
name: 'Invoices',
match: 'Northwind',
matching_algorithm: MATCH_LITERAL,
sample_document: 5,
zones: [zone()],
combine_formats: { '1': '{Invoice number}' },
}
component.object = original
fixture.detectChanges()
expect(component.dialogTitle).toEqual('Edit OCR Template')
expect(component.previewDocId).toEqual(5)
expect(component.previewPageCount()).toEqual(3)
expect(component.pageImageUrl).toContain('document-page-image/5/0/')
expect(component.template.match).toEqual('Northwind')
expect(component.isDirty).toBeFalsy()
component.template.zones[0].x = 500
component.template.combine_formats['1'] = 'Edited'
expect(original.zones[0].x).toEqual(100)
expect(original.combine_formats['1']).toEqual('{Invoice number}')
})
it('should offer every matching algorithm except automatic', () => {
expect(component.matchingAlgorithms.map((a) => a.id)).not.toContain(
MATCH_AUTO
)
expect(component.template.matching_algorithm).toEqual(MATCH_ANY)
expect(component.patternRequired).toBeTruthy()
component.template.matching_algorithm = MATCH_NONE
expect(component.patternRequired).toBeFalsy()
})
it('should initialize new template permissions from saved defaults', () => {
const defaults = {
[SETTINGS_KEYS.DEFAULT_PERMS_OWNER]: 1,
[SETTINGS_KEYS.DEFAULT_PERMS_VIEW_USERS]: [2],
[SETTINGS_KEYS.DEFAULT_PERMS_VIEW_GROUPS]: [3],
[SETTINGS_KEYS.DEFAULT_PERMS_EDIT_USERS]: [],
[SETTINGS_KEYS.DEFAULT_PERMS_EDIT_GROUPS]: [4],
}
jest
.spyOn(TestBed.inject(SettingsService), 'get')
.mockImplementation((key) => defaults[key])
fixture.detectChanges()
expect(component.permissionsForm).toEqual({
owner: 1,
set_permissions: {
view: { users: [2], groups: [3] },
change: { users: [], groups: [4] },
},
})
expect(
fixture.debugElement.query(By.directive(PermissionsFormComponent))
).toBeTruthy()
expect(component.isDirty).toBe(false)
const create = jest
.spyOn(templateService, 'create')
.mockReturnValue(of({ ...component.template, id: 3 }))
component.save()
expect(create).toHaveBeenCalledWith(
expect.objectContaining(component.permissionsForm)
)
})
it('should copy existing permissions and track changes without mutating the list item', () => {
component.object = {
...component.template,
id: 3,
owner: 1,
permissions: {
view: { users: [2], groups: [] },
change: { users: [], groups: [3] },
},
}
fixture.detectChanges()
expect(component.permissionsForm.owner).toBe(1)
expect(component.isDirty).toBe(false)
component.permissionsForm.set_permissions.view.users.push(4)
expect(component.isDirty).toBe(true)
expect(component.object.permissions.view.users).toEqual([2])
const update = jest
.spyOn(templateService, 'update')
.mockReturnValue(of(component.template))
component.save()
expect(update).toHaveBeenCalledWith(
expect.objectContaining({
owner: 1,
set_permissions: {
view: { users: [2, 4], groups: [] },
change: { users: [], groups: [3] },
},
})
)
})
it('should hide sharing controls and omit permission changes for a shared editor', () => {
component.object = {
...component.template,
id: 3,
owner: 2,
user_can_change: true,
}
fixture.detectChanges()
expect(
fixture.debugElement.query(By.directive(PermissionsFormComponent))
).toBeNull()
component.permissionsForm = {
owner: 1,
set_permissions: { view: { users: [1] } },
}
const update = jest
.spyOn(templateService, 'update')
.mockReturnValue(of(component.template))
component.save()
expect(update.mock.calls[0][0].owner).toBe(2)
expect(update.mock.calls[0][0]).not.toHaveProperty('set_permissions')
})
it('should keep only the last picked sample document', () => {
component.setSampleDocument([5, 6])
expect(component.sampleDocumentIds).toEqual([6])
component.setSampleDocument([])
expect(component.previewDocId).toBeNull()
expect(component.pageImageUrl).toBeNull()
})
it('should ignore a stale sample document response', () => {
const pending = new Subject<Document>()
jest.spyOn(documentService, 'get').mockReturnValueOnce(pending)
component.setSampleDocument([5])
component.setSampleDocument([6])
pending.next({ id: 5, page_count: 99 })
expect(component.previewPageCount()).toEqual(3)
})
it('should show a failed page and retry it', () => {
jest
.spyOn(documentService, 'get')
.mockReturnValueOnce(throwError(() => new Error('offline')))
component.setSampleDocument([5])
expect(component.pageFailed()).toBeTruthy()
component.setSampleDocument(component.sampleDocumentIds)
expect(component.pageFailed()).toBeFalsy()
component.onImageError()
expect(component.pageFailed()).toBeTruthy()
})
it('should support create and update', () => {
const createSpy = jest
.spyOn(templateService, 'create')
.mockReturnValue(of({ ...component.template, id: 3 }))
const updateSpy = jest
.spyOn(templateService, 'update')
.mockReturnValue(of(component.template))
const succeededSpy = jest.spyOn(component.succeeded, 'emit')
component.save()
expect(createSpy).toHaveBeenCalled()
expect(succeededSpy).toHaveBeenCalledWith({ ...component.template, id: 3 })
expect(activeModal.close).toHaveBeenCalled()
component.saving.set(false)
component.template.id = 3
component.save()
expect(updateSpy).toHaveBeenCalledWith(component.template)
})
it('should emit failed and allow editing again after a failed save', () => {
prepareOverlay()
component.template.zones = [zone()]
const pending = new Subject<OcrTemplate>()
jest.spyOn(templateService, 'create').mockReturnValue(pending)
const failedSpy = jest.spyOn(component.failed, 'emit')
component.onOverlayPointerDown(pointer(150, 150))
component.save()
component.save()
expect(templateService.create).toHaveBeenCalledTimes(1)
component.onOverlayPointerMove(pointer(250, 250))
component.onOverlayPointerUp(pointer(250, 250))
expect(component.template.zones[0].x).toEqual(100)
pending.error(new Error('Save failed'))
expect(failedSpy).toHaveBeenCalled()
expect(component.saving()).toBeFalsy()
component.onOverlayPointerDown(pointer(150, 150))
component.onOverlayPointerUp(pointer(250, 250))
expect(component.template.zones[0].x).toEqual(200)
})
it('should track unsaved changes', () => {
fixture.detectChanges()
expect(component.isDirty).toBeFalsy()
component.template.combine_formats = { '1': '' }
expect(component.isDirty).toBeFalsy()
component.template.name = 'Invoices'
expect(component.isDirty).toBeTruthy()
component.template.name = ''
expect(component.isDirty).toBeFalsy()
component.setSampleDocument([5])
expect(component.isDirty).toBeTruthy()
})
it('should draw a zone with no field in empty space', () => {
prepareOverlay()
component.onOverlayPointerDown(pointer(100, 100))
component.onOverlayPointerUp(pointer(1200, 250))
expect(component.template.zones[0]).toMatchObject({
x: 100,
y: 100,
width: 900,
height: 150,
custom_field: null,
})
expect(component.selectedZoneIndex).toEqual(0)
})
it('should clear the selection on a click in empty space', () => {
prepareOverlay()
component.template.zones = [zone()]
component.selectZone(0)
component.onOverlayPointerDown(pointer(600, 600))
component.onOverlayPointerUp(pointer(602, 601))
expect(component.template.zones).toHaveLength(1)
expect(component.selectedZoneIndex).toBeNull()
})
it('should move a zone when dragging on it', () => {
prepareOverlay()
component.template.zones = [zone()]
component.onOverlayPointerDown(pointer(150, 150))
component.onOverlayPointerUp(pointer(250, 200))
expect(component.template.zones).toHaveLength(1)
expect(component.template.zones[0]).toMatchObject({ x: 200, y: 150 })
})
it('should cancel a drawing on escape and ignore other pointers', () => {
prepareOverlay()
component.onOverlayPointerDown(pointer(100, 100))
component.onOverlayPointerUp(pointer(300, 200, 2))
expect(component.drawingRect()).not.toBeNull()
component.cancelDrawing()
component.onOverlayPointerUp(pointer(300, 200))
expect(component.template.zones).toEqual([])
})
it('should open a zone in the zones tab and go back to the list', () => {
component.template.zones = [zone(), zone()]
component.selectZone(1)
expect(component.activeTab).toEqual('zones')
expect(component.selectedZone).toBe(component.template.zones[1])
component.deselectZone()
expect(component.selectedZone).toBeNull()
component.selectZone(0)
component.removeZone(0)
expect(component.template.zones).toHaveLength(1)
expect(component.selectedZone).toBeNull()
})
it('should only offer custom fields OCR can write to', () => {
fixture.detectChanges()
expect(component.fieldItems().map((item) => item.id)).toEqual([
OcrZoneTarget.Title,
OcrZoneTarget.Asn,
OcrZoneTarget.Created,
1,
])
})
it('should create a custom field and assign it to the zone', () => {
const succeeded = new Subject<CustomField>()
const dialog = {
dialogMode: { set: jest.fn() },
object: null,
succeeded,
failed: new Subject(),
}
jest
.spyOn(modalService, 'open')
.mockReturnValue({ componentInstance: dialog } as any)
component.template.zones = [zone({ custom_field: null })]
component.selectZone(0)
component.createField()
expect(dialog.object).toEqual({ name: 'Invoice number' })
succeeded.next(customFields[0])
expect(component.template.zones[0].custom_field).toEqual(1)
})
it('should build and prune combine formats', () => {
const first = zone({ name: 'First' })
const second = zone({ name: 'Second' })
const other = zone({ name: 'Other', custom_field: 2 })
component.template.zones = [first, second, other]
component.template.combine_formats = { '2': '{Other}' }
expect(component.isFieldShared(first)).toBeTruthy()
expect(component.isFieldShared(other)).toBeFalsy()
expect(component.defaultCombineFormat(first)).toEqual('{First} {Second}')
component.insertCombineToken(first, second)
component.insertCombineToken(first, first)
expect(component.getCombineFormat(second)).toEqual('{Second} {First}')
jest.spyOn(templateService, 'create').mockReturnValue(of(null))
component.save()
expect(component.template.combine_formats).toEqual({
'1': '{Second} {First}',
})
})
it('should update exact tokens when a zone is renamed', () => {
const first = zone({ name: 'First [value]' })
component.template.zones = [first, zone({ name: 'Second' })]
component.template.combine_formats = {
'1': 'First [value]: {First [value]} / { First [value] } / {Second} / {First [value] extra}',
'2': '{First [value]}',
}
component.renameZone(first, '')
component.renameZone(first, 'Invoice number')
expect(first.name).toEqual('Invoice number')
expect(component.template.combine_formats).toEqual({
'1': 'First [value]: {Invoice number} / {Invoice number} / {Second} / {First [value] extra}',
'2': '{First [value]}',
})
})
it('should keep the language array stable until the value changes', () => {
const selected = zone({ ocr_language: 'deu+eng' })
const languages = component.ocrLanguageArray(selected)
expect(languages).toEqual(['deu', 'eng'])
expect(component.ocrLanguageArray(selected)).toBe(languages)
component.setOcrLanguages(selected, ['fra'])
expect(selected.ocr_language).toEqual('fra')
expect(component.ocrLanguageArray(selected)).toEqual(['fra'])
component.setOcrLanguages(selected, null)
expect(component.ocrLanguageArray(selected)).toEqual([])
})
it('should hide a test result once the zone changes', () => {
const selected = zone()
component.template.zones = [selected]
component.selectZone(0)
component.setSampleDocument([5])
jest
.spyOn(templateService, 'testZone')
.mockReturnValue(of({ value: '123' }))
component.testZone()
expect(component.zoneTestResult.value).toEqual('123')
selected.transform = OcrZoneTransform.Numeric
expect(component.zoneTestResult).toBeNull()
})
it('should drop a pending test when the zone or sample document changes', () => {
const pending = new Subject<OcrZoneTestResult>()
component.template.zones = [zone(), zone()]
component.selectZone(0)
component.setSampleDocument([5])
jest.spyOn(templateService, 'testZone').mockReturnValue(pending)
component.testZone()
expect(component.zoneTesting).toBeTruthy()
component.selectZone(1)
pending.next({ value: 'Old zone' })
expect(component.zoneTestResult).toBeNull()
component.testZone()
component.setSampleDocument([])
pending.next({ value: 'Old document' })
expect(component.zoneTestResult).toBeNull()
})
})
@@ -0,0 +1,963 @@
import { CommonModule } from '@angular/common'
import {
Component,
computed,
ElementRef,
EventEmitter,
HostListener,
inject,
Input,
OnDestroy,
OnInit,
Output,
signal,
ViewChild,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import {
NgbActiveModal,
NgbModal,
NgbNavModule,
} from '@ng-bootstrap/ng-bootstrap'
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
import { Subject, takeUntil } from 'rxjs'
import { CustomFieldEditDialogComponent } from 'src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
import { EditDialogMode } from 'src/app/components/common/edit-dialog/edit-dialog.component'
import { DocumentLinkComponent } from 'src/app/components/common/input/document-link/document-link.component'
import { CheckComponent } from 'src/app/components/common/input/check/check.component'
import { NumberComponent } from 'src/app/components/common/input/number/number.component'
import {
PermissionsFormComponent,
PermissionsFormObject,
} from 'src/app/components/common/input/permissions/permissions-form/permissions-form.component'
import { SelectComponent } from 'src/app/components/common/input/select/select.component'
import { SwitchComponent } from 'src/app/components/common/input/switch/switch.component'
import { TextComponent } from 'src/app/components/common/input/text/text.component'
import { CustomField } from 'src/app/data/custom-field'
import {
MATCH_ANY,
MATCH_AUTO,
MATCH_NONE,
MATCHING_ALGORITHMS,
} from 'src/app/data/matching-model'
import {
DEFAULT_OCR_ZONE_LANGUAGE,
getZoneFieldKey,
OCR_BUILTIN_TARGETS,
OCR_SUPPORTED_FIELD_TYPES,
OcrTemplate,
OcrTemplateZone,
OcrZoneTarget,
OcrZoneTestResult,
OcrZoneTransform,
ZoneTestRequest,
} from 'src/app/data/ocr-template'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { User } from 'src/app/data/user'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { PermissionsService } from 'src/app/services/permissions.service'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { OcrTemplateEditorZoneListComponent } from './ocr-template-editor-zone-list/ocr-template-editor-zone-list.component'
import {
DisplayRect,
DrawingRect,
findHandleAt,
findZoneAt,
getResizeHandles,
getZoneColor,
getZoneDisplayRect,
getZonePage,
HANDLE_SIZE,
isZoneOnPage,
MIN_ZONE_SIZE,
MoveStart,
moveZone,
Point,
ResizeHandle,
resizeZone,
} from './zone-geometry'
type ActiveTab = 'template' | 'zones'
type ZoneFieldSelection = OcrZoneTarget | number | null
type OverlayInteraction =
| { kind: 'idle' }
| { kind: 'drawing'; pointerId: number; rect: DrawingRect }
| { kind: 'moving'; pointerId: number; zoneIndex: number; start: MoveStart }
| {
kind: 'resizing'
pointerId: number
zoneIndex: number
handle: ResizeHandle
}
interface ZoneTest {
zone: OcrTemplateZone
/** The sample document and zone settings the result was produced from. */
key: string
result: OcrZoneTestResult | null
}
export const ZONE_TRANSFORM_OPTIONS = [
{ id: OcrZoneTransform.None, name: $localize`None` },
{ id: OcrZoneTransform.Strip, name: $localize`Strip whitespace` },
{ id: OcrZoneTransform.Uppercase, name: $localize`Uppercase` },
{ id: OcrZoneTransform.Lowercase, name: $localize`Lowercase` },
{ id: OcrZoneTransform.Numeric, name: $localize`Numeric only` },
{
id: OcrZoneTransform.StripPunctuation,
name: $localize`Remove leading/trailing punctuation`,
},
{ id: OcrZoneTransform.Date, name: $localize`Parse date` },
{ id: OcrZoneTransform.QrCode, name: $localize`Read QR/barcode` },
]
export const ZONE_LANGUAGE_OPTIONS = [
{ id: 'eng', name: $localize`English` },
{ id: 'deu', name: $localize`German` },
{ id: 'fra', name: $localize`French` },
{ id: 'ita', name: $localize`Italian` },
{ id: 'spa', name: $localize`Spanish` },
{ id: 'por', name: $localize`Portuguese` },
{ id: 'nld', name: $localize`Dutch` },
]
const TEMPLATE_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
(algorithm) => algorithm.id !== MATCH_AUTO
)
const NO_OVERLAY_INTERACTION: OverlayInteraction = { kind: 'idle' }
const RESIZE_CURSOR: Record<ResizeHandle, string> = {
nw: 'nw-resize',
ne: 'ne-resize',
sw: 'sw-resize',
se: 'se-resize',
n: 'n-resize',
s: 's-resize',
w: 'w-resize',
e: 'e-resize',
}
@Component({
selector: 'pngx-ocr-template-editor',
imports: [
DocumentLinkComponent,
NumberComponent,
TextComponent,
CheckComponent,
SelectComponent,
SwitchComponent,
CommonModule,
FormsModule,
NgbNavModule,
BootstrapIconComponent,
OcrTemplateEditorZoneListComponent,
PermissionsFormComponent,
IfOwnerDirective,
],
templateUrl: './ocr-template-editor.component.html',
styleUrls: ['./ocr-template-editor.component.scss'],
})
export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
public readonly activeModal = inject(NgbActiveModal)
private readonly templateService = inject(OcrTemplateService)
private readonly customFieldsService = inject(CustomFieldsService)
private readonly documentService = inject(DocumentService)
private readonly toastService = inject(ToastService)
private readonly modalService = inject(NgbModal)
private readonly userService = inject(UserService)
private readonly settingsService = inject(SettingsService)
private readonly permissionsService = inject(PermissionsService)
private readonly destroy$ = new Subject<void>()
private readonly cancelZoneTest$ = new Subject<void>()
@Input() object: OcrTemplate
@Output() public readonly succeeded = new EventEmitter<OcrTemplate>()
@Output() public readonly failed = new EventEmitter()
@ViewChild('zoneOverlay') overlayRef: ElementRef<SVGSVGElement>
imageRef: ElementRef<HTMLImageElement>
private previewResizeObserver: ResizeObserver | null = null
private readonly overlayScale = signal(1)
@ViewChild('pageImage')
set pageImage(ref: ElementRef<HTMLImageElement> | undefined) {
this.previewResizeObserver?.disconnect()
this.imageRef = ref
if (!ref) return
this.previewResizeObserver = new ResizeObserver(() => {
const image = ref.nativeElement
if (image.naturalWidth && image.clientWidth) {
this.overlayScale.set(image.naturalWidth / image.clientWidth)
}
})
this.previewResizeObserver.observe(ref.nativeElement)
}
template: OcrTemplate = {
id: null,
name: '',
match: '',
matching_algorithm: MATCH_ANY,
is_insensitive: true,
sample_document: null,
source_width: 0,
source_height: 0,
enabled: true,
combine_formats: {},
zones: [],
}
public readonly customFields = signal<CustomField[]>([])
public readonly fieldItems = computed<
{ id: ZoneFieldSelection; name: string }[]
>(() => [
...OCR_BUILTIN_TARGETS,
...this.customFields()
.filter((field) => OCR_SUPPORTED_FIELD_TYPES.includes(field.data_type))
.map((field) => ({ id: field.id, name: field.name })),
])
public readonly users = signal<User[]>([])
permissionsForm: PermissionsFormObject = {}
public readonly OcrZoneTransform = OcrZoneTransform
public readonly saving = signal(false)
/** Bound to the document picker, which works with id arrays. */
sampleDocumentIds: number[] = []
previewPage = 0
public readonly previewPageCount = signal<number | null>(null)
public readonly pageLoaded = signal(false)
public readonly pageFailed = signal(false)
zoom = 1
get previewDocId(): number | null {
return this.sampleDocumentIds[0] ?? null
}
get pageImageUrl(): string | null {
return this.previewDocId
? this.templateService.getPageImageUrl(
this.previewDocId,
this.previewPage
)
: null
}
get transformOptions() {
return ZONE_TRANSFORM_OPTIONS
}
get languageOptions() {
return ZONE_LANGUAGE_OPTIONS
}
get matchingAlgorithms() {
return TEMPLATE_MATCHING_ALGORITHMS
}
get patternRequired(): boolean {
return (
this.template.matching_algorithm !== MATCH_AUTO &&
this.template.matching_algorithm !== MATCH_NONE
)
}
public get previewPageDisplay(): number {
return this.previewPage + 1
}
public set previewPageDisplay(value: number) {
this.goToPage(value - 1)
}
activeTab: ActiveTab = 'template'
selectedZoneIndex: number | null = null
private overlayInteraction: OverlayInteraction = NO_OVERLAY_INTERACTION
overlayCursor = 'crosshair'
private readonly zoneTest = signal<ZoneTest | null>(null)
get zoneTestResult(): OcrZoneTestResult | null {
return this.isCurrentZoneTest() ? this.zoneTest().result : null
}
get zoneTesting(): boolean {
return this.isCurrentZoneTest() && this.zoneTest().result === null
}
get selectedZone(): OcrTemplateZone | null {
return this.selectedZoneIndex !== null
? (this.template.zones[this.selectedZoneIndex] ?? null)
: null
}
private initialState = ''
get isDirty(): boolean {
return this.editorState() !== this.initialState
}
private editorState(): string {
const formats = Object.entries(this.template.combine_formats ?? {}).filter(
([, format]) => format
)
return JSON.stringify({
...this.template,
combine_formats: Object.fromEntries(formats),
sample_document: this.previewDocId ?? null,
permissions_form: this.permissionsForm,
})
}
get dialogTitle(): string {
return this.template.id
? $localize`Edit OCR Template`
: $localize`New OCR Template`
}
ngOnInit() {
this.userService
.listAll()
.pipe(takeUntil(this.destroy$))
.subscribe((r) => this.users.set(r.results))
this.customFieldsService
.listAll()
.pipe(takeUntil(this.destroy$))
.subscribe((r) => {
this.customFields.set(r.results)
})
if (this.object) {
this.template = {
...this.object,
zones: this.object.zones.map((zone) => ({ ...zone })),
combine_formats: { ...this.object.combine_formats },
}
if (this.template.sample_document) {
this.setSampleDocument([this.template.sample_document])
}
}
const permissions = this.object?.permissions
this.permissionsForm = this.object
? {
owner: this.object.owner,
set_permissions: {
view: {
users: [...(permissions?.view.users ?? [])],
groups: [...(permissions?.view.groups ?? [])],
},
change: {
users: [...(permissions?.change.users ?? [])],
groups: [...(permissions?.change.groups ?? [])],
},
},
}
: {
owner: this.settingsService.get(SETTINGS_KEYS.DEFAULT_PERMS_OWNER),
set_permissions: {
view: {
users: this.settingsService.get(
SETTINGS_KEYS.DEFAULT_PERMS_VIEW_USERS
),
groups: this.settingsService.get(
SETTINGS_KEYS.DEFAULT_PERMS_VIEW_GROUPS
),
},
change: {
users: this.settingsService.get(
SETTINGS_KEYS.DEFAULT_PERMS_EDIT_USERS
),
groups: this.settingsService.get(
SETTINGS_KEYS.DEFAULT_PERMS_EDIT_GROUPS
),
},
},
}
this.initialState = this.editorState()
}
setSampleDocument(ids: number[]) {
const id = ids.at(-1)
this.sampleDocumentIds = id ? [id] : []
this.previewPage = 0
this.previewPageCount.set(null)
this.resetPage()
if (!id) return
this.documentService
.get(id)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (doc) => {
if (this.previewDocId !== id) return
this.previewPageCount.set(doc.page_count ?? null)
},
error: () => {
if (this.previewDocId === id) this.pageFailed.set(true)
},
})
}
private resetPage() {
this.stopOverlayInteraction()
this.clearZoneTest()
this.pageLoaded.set(false)
this.pageFailed.set(false)
}
goToPage(page: number) {
if (!Number.isFinite(page)) return
const pageCount = this.previewPageCount()
const max = pageCount ? pageCount - 1 : page
const clamped = Math.max(0, Math.min(Math.trunc(page), max))
if (clamped === this.previewPage) return
this.previewPage = clamped
this.resetPage()
}
zoomIn() {
this.zoom = Math.min(4, Math.round((this.zoom + 0.25) * 100) / 100)
}
zoomOut() {
this.zoom = Math.max(0.5, Math.round((this.zoom - 0.25) * 100) / 100)
}
resetZoom() {
this.zoom = 1
}
zonePage(zone: OcrTemplateZone): number {
return getZonePage(zone, this.previewPage, this.previewPageCount())
}
isLastPageZone(zone: OcrTemplateZone): boolean {
return zone.page === -1
}
setLastPageZone(zone: OcrTemplateZone, lastPage: boolean) {
zone.page = lastPage ? -1 : this.previewPage + 1
}
private isOnCurrentPage(zone: OcrTemplateZone): boolean {
return isZoneOnPage(zone, this.previewPage, this.previewPageCount())
}
onImageLoad() {
this.pageLoaded.set(true)
const img = this.imageRef.nativeElement
this.template.source_width = img.naturalWidth
this.template.source_height = img.naturalHeight
}
onImageError() {
this.stopOverlayInteraction()
this.pageFailed.set(true)
}
@HostListener('document:keydown.escape')
cancelDrawing() {
this.stopOverlayInteraction()
}
onOverlayPointerDown(event: PointerEvent) {
if (this.saving()) return
if (event.button !== 0 || this.overlayInteraction.kind !== 'idle') return
const point = this.svgPointFromEvent(event)
if (!this.pageLoaded() || !point) return
event.preventDefault()
if (this.selectedZoneIndex !== null) {
const handle = this.handleAt(point, this.selectedZoneIndex)
if (handle) {
this.overlayInteraction = {
kind: 'resizing',
pointerId: event.pointerId,
zoneIndex: this.selectedZoneIndex,
handle,
}
this.overlayRef.nativeElement.setPointerCapture(event.pointerId)
return
}
}
const clickedIdx = this.zoneIndexAt(point)
if (clickedIdx !== null) {
this.selectZone(clickedIdx)
const zone = this.template.zones[clickedIdx]
this.overlayInteraction = {
kind: 'moving',
pointerId: event.pointerId,
zoneIndex: clickedIdx,
start: {
mouseX: point.x,
mouseY: point.y,
zoneX: zone.x,
zoneY: zone.y,
},
}
this.overlayRef.nativeElement.setPointerCapture(event.pointerId)
return
}
// Empty space: start drawing a new zone. A click without a drag just clears
// the selection (see onOverlayPointerUp).
this.clearZoneTest()
this.selectedZoneIndex = null
this.overlayInteraction = {
kind: 'drawing',
pointerId: event.pointerId,
rect: {
startX: point.x,
startY: point.y,
endX: point.x,
endY: point.y,
},
}
this.overlayRef.nativeElement.setPointerCapture(event.pointerId)
}
onOverlayPointerMove(event: PointerEvent) {
if (
this.overlayInteraction.kind !== 'idle' &&
this.overlayInteraction.pointerId !== event.pointerId
)
return
const point = this.svgPointFromEvent(event)
if (!point) return
if (this.overlayInteraction.kind === 'resizing') {
resizeZone(
this.template.zones[this.overlayInteraction.zoneIndex],
this.overlayInteraction.handle,
point,
this.imageNaturalSize()
)
return
}
if (this.overlayInteraction.kind === 'moving') {
moveZone(
this.template.zones[this.overlayInteraction.zoneIndex],
point,
this.overlayInteraction.start,
this.imageNaturalSize()
)
return
}
if (this.overlayInteraction.kind === 'drawing') {
this.overlayInteraction.rect.endX = point.x
this.overlayInteraction.rect.endY = point.y
return
}
this.updateOverlayCursor(point)
}
private updateOverlayCursor(point: Point) {
if (this.selectedZoneIndex !== null) {
const handle = this.handleAt(point, this.selectedZoneIndex)
if (handle) {
this.overlayCursor = RESIZE_CURSOR[handle]
return
}
}
this.overlayCursor = this.zoneIndexAt(point) !== null ? 'move' : 'crosshair'
}
onOverlayPointerUp(event: PointerEvent) {
if (this.overlayInteraction.kind === 'idle') return
if (this.overlayInteraction.pointerId !== event.pointerId) return
this.onOverlayPointerMove(event)
const interaction = this.overlayInteraction
this.stopOverlayInteraction()
if (interaction.kind !== 'drawing') return
const rect = this.sourceRectFromDrawing(interaction.rect)
// Ignore tiny accidental clicks.
if (rect.w < MIN_ZONE_SIZE || rect.h < MIN_ZONE_SIZE) {
return
}
this.template.zones.push(this.createZoneFromRect(rect))
this.selectZone(this.template.zones.length - 1)
}
private createZoneFromRect(rect: DisplayRect): OcrTemplateZone {
const imageSize = this.imageNaturalSize()
return {
name: `Zone ${this.template.zones.length + 1}`,
target: OcrZoneTarget.CustomField,
custom_field: null,
x: rect.x,
y: rect.y,
width: rect.w,
height: rect.h,
page: this.previewPageDisplay,
ocr_language: DEFAULT_OCR_ZONE_LANGUAGE,
transform: OcrZoneTransform.Strip,
date_format: '',
validation_regex: '',
order: this.template.zones.length,
zone_source_width: imageSize.width,
zone_source_height: imageSize.height,
}
}
onOverlayPointerCancel(event: PointerEvent) {
if (this.overlayInteraction.kind === 'idle') return
if (this.overlayInteraction.pointerId !== event.pointerId) return
this.stopOverlayInteraction()
}
private stopOverlayInteraction() {
const interaction = this.overlayInteraction
this.overlayInteraction = NO_OVERLAY_INTERACTION
this.overlayCursor = 'crosshair'
const overlay = this.overlayRef?.nativeElement
if (
interaction.kind !== 'idle' &&
overlay?.hasPointerCapture(interaction.pointerId)
) {
overlay.releasePointerCapture(interaction.pointerId)
}
}
drawingRect(): DisplayRect | null {
return this.overlayInteraction.kind === 'drawing'
? this.displayRectFromDrawing(this.overlayInteraction.rect)
: null
}
/** The zone's rectangle on the overlay, or null if it's on another page. */
zoneDisplayRect(zoneIndex: number): DisplayRect | null {
const zone = this.template.zones[zoneIndex]
if (!this.isOnCurrentPage(zone)) return null
return getZoneDisplayRect(zone, this.imageNaturalSize())
}
private handleAt(point: Point, zoneIndex: number): ResizeHandle | null {
const rect = this.zoneDisplayRect(zoneIndex)
return rect ? findHandleAt(point, rect, this.overlayHandleSize()) : null
}
private zoneIndexAt(point: Point): number | null {
return findZoneAt(
point,
this.template.zones,
this.previewPage,
this.previewPageCount(),
this.imageNaturalSize()
)
}
overlayViewBox(): string {
const imageSize = this.imageNaturalSize()
return `0 0 ${imageSize.width} ${imageSize.height}`
}
public readonly zoneColor = getZoneColor
zoneLabel(zone: OcrTemplateZone, index: number): string {
return zone.name || `Zone ${index + 1}`
}
zoneLabelY(rect: DisplayRect): number {
return Math.max(this.overlayUnitSize(14), rect.y - this.overlayUnitSize(4))
}
public readonly resizeHandles = getResizeHandles
overlayHandleSize(): number {
return this.overlayUnitSize(HANDLE_SIZE)
}
overlayFontSize(): number {
return this.overlayUnitSize(12)
}
overlayUnitSize(screenPixels: number): number {
return screenPixels * this.overlayScale()
}
private svgPointFromEvent(event: MouseEvent): Point | null {
const svg = this.overlayRef?.nativeElement
const matrix = svg?.getScreenCTM()
if (!svg || !matrix) return null
const point = svg.createSVGPoint()
point.x = event.clientX
point.y = event.clientY
const svgPoint = point.matrixTransform(matrix.inverse())
const size = this.imageNaturalSize()
return {
x: Math.max(0, Math.min(svgPoint.x, size.width)),
y: Math.max(0, Math.min(svgPoint.y, size.height)),
}
}
private displayRectFromDrawing(rect: DrawingRect): DisplayRect {
return {
x: Math.min(rect.startX, rect.endX),
y: Math.min(rect.startY, rect.endY),
w: Math.abs(rect.endX - rect.startX),
h: Math.abs(rect.endY - rect.startY),
}
}
private sourceRectFromDrawing(rect: DrawingRect): DisplayRect {
const displayRect = this.displayRectFromDrawing(rect)
return {
x: Math.round(displayRect.x),
y: Math.round(displayRect.y),
w: Math.round(displayRect.w),
h: Math.round(displayRect.h),
}
}
private imageNaturalSize() {
const img = this.imageRef.nativeElement
return { width: img.naturalWidth, height: img.naturalHeight }
}
removeZone(index: number) {
this.stopOverlayInteraction()
this.clearZoneTest()
this.template.zones.splice(index, 1)
if (this.selectedZoneIndex === index) {
this.selectedZoneIndex = null
} else if (this.selectedZoneIndex > index) {
this.selectedZoneIndex--
}
}
selectZone(index: number) {
this.selectedZoneIndex = index
this.activeTab = 'zones'
this.clearZoneTest()
const zone = this.template.zones[index]
if (zone) {
this.goToPage(this.zonePage(zone) - 1)
}
}
deselectZone() {
this.selectedZoneIndex = null
this.clearZoneTest()
}
testZone() {
const zone = this.selectedZone
if (!zone || !this.previewDocId) return
this.clearZoneTest()
const test: ZoneTest = { zone, key: this.zoneTestKey(zone), result: null }
this.zoneTest.set(test)
this.templateService
.testZone(this.previewDocId, this.zoneTestRequest(zone))
.pipe(takeUntil(this.destroy$), takeUntil(this.cancelZoneTest$))
.subscribe({
next: (res) => this.zoneTest.set({ ...test, result: res }),
error: (err) =>
this.zoneTest.set({
...test,
result: { error: err.error?.error || $localize`Test failed` },
}),
})
}
private clearZoneTest() {
this.cancelZoneTest$.next()
this.zoneTest.set(null)
}
/** A result is stale once its zone, settings or sample document change. */
private isCurrentZoneTest(): boolean {
const test = this.zoneTest()
return (
test?.zone === this.selectedZone &&
test.key === this.zoneTestKey(test.zone)
)
}
private zoneTestKey(zone: OcrTemplateZone): string {
return JSON.stringify([this.previewDocId, this.zoneTestRequest(zone)])
}
private zoneTestRequest(zone: OcrTemplateZone): ZoneTestRequest {
return {
name: zone.name,
x: zone.x,
y: zone.y,
width: zone.width,
height: zone.height,
page: zone.page ?? 1,
ocr_language: zone.ocr_language,
transform: zone.transform,
date_format: zone.date_format,
validation_regex: zone.validation_regex,
zone_source_width: zone.zone_source_width,
zone_source_height: zone.zone_source_height,
}
}
save() {
if (this.saving()) return
this.stopOverlayInteraction()
this.saving.set(true)
this.pruneCombineFormats()
this.template.sample_document = this.previewDocId
const payload = this.permissionsService.currentUserOwnsObject(this.object)
? { ...this.template, ...this.permissionsForm }
: this.template
const obs = this.template.id
? this.templateService.update(payload)
: this.templateService.create(payload)
obs.pipe(takeUntil(this.destroy$)).subscribe({
next: (saved) => {
this.activeModal.close()
this.succeeded.emit(saved)
},
error: (e) => {
this.saving.set(false)
this.failed.emit(e)
},
})
}
// ng-select needs a stable array between change detection runs, so the
// split language list is cached per zone until ocr_language changes.
private readonly ocrLanguageCache = new WeakMap<
OcrTemplateZone,
{ language: string; languages: string[] }
>()
ocrLanguageArray(zone: OcrTemplateZone): string[] {
const language = zone.ocr_language || ''
const cached = this.ocrLanguageCache.get(zone)
if (cached?.language === language) return cached.languages
const languages = language.split('+').filter(Boolean)
this.ocrLanguageCache.set(zone, { language, languages })
return languages
}
setOcrLanguages(zone: OcrTemplateZone, langs: string[]) {
zone.ocr_language = (langs ?? []).join('+')
}
/** The field select's value: a built-in target, or a custom field id. */
zoneFieldValue(zone: OcrTemplateZone): ZoneFieldSelection {
return zone.target === OcrZoneTarget.CustomField
? zone.custom_field
: zone.target
}
setZoneField(zone: OcrTemplateZone, value: ZoneFieldSelection) {
if (typeof value === 'string') {
zone.target = value
zone.custom_field = null
} else {
zone.target = OcrZoneTarget.CustomField
zone.custom_field = value
}
}
zonesForField(zone: OcrTemplateZone): OcrTemplateZone[] {
const key = getZoneFieldKey(zone)
if (!key) return []
return this.template.zones.filter((z) => getZoneFieldKey(z) === key)
}
isFieldShared(zone: OcrTemplateZone): boolean {
return this.zonesForField(zone).length > 1
}
renameZone(zone: OcrTemplateZone, name: string) {
const oldName = zone.name.trim()
const format = this.getCombineFormat(zone)
if (format) {
this.setCombineFormat(
zone,
format.replace(/\{([^{}]*)\}/g, (token, tokenName: string) =>
tokenName.trim() === oldName ? `{${name}}` : token
)
)
}
zone.name = name
}
getCombineFormat(zone: OcrTemplateZone): string {
return this.template.combine_formats?.[getZoneFieldKey(zone)] ?? ''
}
setCombineFormat(zone: OcrTemplateZone, value: string) {
const key = getZoneFieldKey(zone)
if (!key) return
this.template.combine_formats ??= {}
this.template.combine_formats[key] = value
}
defaultCombineFormat(zone: OcrTemplateZone): string {
return this.zonesForField(zone)
.map((z) => `{${z.name}}`)
.join(' ')
}
insertCombineToken(zone: OcrTemplateZone, tokenZone: OcrTemplateZone) {
const current = this.getCombineFormat(zone)
const sep = current && !current.endsWith(' ') ? ' ' : ''
this.setCombineFormat(zone, `${current}${sep}{${tokenZone.name}}`)
}
/** Drop formats that are empty or whose field no longer has several zones. */
private pruneCombineFormats() {
const formats = this.template.combine_formats
if (!formats) return
const keys = this.template.zones.map(getZoneFieldKey)
for (const [key, format] of Object.entries(formats)) {
if (!format || keys.filter((k) => k === key).length < 2) {
delete formats[key]
}
}
}
createField(name?: string) {
const zone = this.selectedZone
const modal = this.modalService.open(CustomFieldEditDialogComponent)
modal.componentInstance.dialogMode.set(EditDialogMode.CREATE)
modal.componentInstance.object = { name: name || zone?.name }
modal.componentInstance.succeeded
.pipe(takeUntil(this.destroy$))
.subscribe((field: CustomField) => {
this.toastService.showSuccess($localize`Saved field "${field.name}".`)
this.customFieldsService.clearCache()
this.customFieldsService
.listAll()
.pipe(takeUntil(this.destroy$))
.subscribe((r) => {
if (zone && OCR_SUPPORTED_FIELD_TYPES.includes(field.data_type)) {
this.setZoneField(zone, field.id)
}
this.customFields.set(r.results)
})
})
modal.componentInstance.failed
.pipe(takeUntil(this.destroy$))
.subscribe((e) => {
this.toastService.showError($localize`Error saving field.`, e)
})
}
ngOnDestroy() {
this.previewResizeObserver?.disconnect()
this.cancelZoneTest$.complete()
this.destroy$.next()
this.destroy$.complete()
}
}
@@ -0,0 +1,197 @@
import {
OcrTemplateZone,
OcrZoneTarget,
OcrZoneTransform,
} from 'src/app/data/ocr-template'
import {
findHandleAt,
findZoneAt,
getResizeHandles,
getZoneDisplayRect,
getZonePage,
isZoneOnPage,
moveZone,
resizeZone,
} from './zone-geometry'
function zone(overrides: Partial<OcrTemplateZone> = {}): OcrTemplateZone {
return {
name: 'Zone',
target: OcrZoneTarget.CustomField,
custom_field: 1,
x: 100,
y: 200,
width: 300,
height: 400,
page: 1,
ocr_language: 'eng',
transform: OcrZoneTransform.Strip,
validation_regex: '',
order: 0,
...overrides,
}
}
describe('OCR template editor geometry', () => {
it('normalizes zone pages', () => {
expect(getZonePage(zone({ page: 2 }), 0, 5)).toBe(2)
expect(getZonePage(zone({ page: -1 }), 0, 5)).toBe(5)
expect(getZonePage(zone({ page: -1 }), 2, null)).toBe(3)
expect(getZonePage(zone({ page: 0 }), 0, 5)).toBe(1)
expect(getZonePage(zone({ page: undefined }), 0, 5)).toBe(1)
})
it('checks whether a zone is on the current preview page', () => {
expect(isZoneOnPage(zone({ page: 2 }), 1, 5)).toBe(true)
expect(isZoneOnPage(zone({ page: 2 }), 0, 5)).toBe(false)
expect(isZoneOnPage(zone({ page: -1 }), 4, 5)).toBe(true)
})
it('uses natural image coordinates when source dimensions are unset', () => {
expect(
getZoneDisplayRect(zone({ x: 100, y: 200, width: 300, height: 400 }), {
width: 1000,
height: 2000,
})
).toEqual({ x: 100, y: 200, w: 300, h: 400 })
})
it('uses per-zone source dimensions when present', () => {
expect(
getZoneDisplayRect(
zone({
x: 100,
y: 100,
width: 100,
height: 100,
zone_source_width: 1000,
zone_source_height: 1000,
}),
{ width: 500, height: 750 }
)
).toEqual({ x: 50, y: 75, w: 50, h: 75 })
})
it('finds zones from topmost to bottommost on the current page', () => {
const zones = [
zone({ name: 'first', x: 0, y: 0, width: 100, height: 100, page: 1 }),
zone({ name: 'second', x: 0, y: 0, width: 50, height: 50, page: 1 }),
zone({ name: 'third', x: 0, y: 0, width: 50, height: 50, page: 2 }),
]
expect(
findZoneAt({ x: 25, y: 25 }, zones, 0, 2, { width: 100, height: 100 })
).toBe(1)
})
it('finds resize handles around a display rect', () => {
const rect = { x: 10, y: 20, w: 100, h: 200 }
expect(findHandleAt({ x: 10, y: 20 }, rect)).toBe('nw')
expect(findHandleAt({ x: 110, y: 220 }, rect)).toBe('se')
expect(findHandleAt({ x: 60, y: 20 }, rect)).toBe('n')
expect(findHandleAt({ x: 90, y: 160 }, rect)).toBeNull()
expect(findHandleAt({ x: 19, y: 20 }, rect, 8)).toBeNull()
expect(findHandleAt({ x: 19, y: 20 }, rect, 10)).toBe('nw')
})
it('returns no zone when the point is outside every rectangle', () => {
expect(
findZoneAt({ x: 900, y: 900 }, [zone()], 0, 1, {
width: 1000,
height: 1000,
})
).toBeNull()
})
it('resizes north-west edges while keeping the opposite corner fixed', () => {
const z = zone({ x: 100, y: 200, width: 300, height: 400 })
resizeZone(z, 'nw', { x: 50, y: 100 }, { width: 1000, height: 1000 })
expect(z).toMatchObject({ x: 50, y: 100, width: 350, height: 500 })
resizeZone(z, 'nw', { x: 900, y: 900 }, { width: 1000, height: 1000 })
expect(z).toMatchObject({ x: 390, y: 590, width: 10, height: 10 })
})
it('clamps resized north-west edges to the source image bounds', () => {
const z = zone({
x: 50,
y: 50,
width: 100,
height: 100,
zone_source_width: 200,
zone_source_height: 300,
})
resizeZone(z, 'nw', { x: -50, y: -50 }, { width: 400, height: 600 })
expect(z).toMatchObject({ x: 0, y: 0, width: 150, height: 150 })
})
it('uses the rendered handle positions for hit testing', () => {
const rect = { x: 10, y: 20, w: 100, h: 200 }
const handles = getResizeHandles(rect)
expect(handles).toEqual([
{ handle: 'nw', x: 10, y: 20 },
{ handle: 'n', x: 60, y: 20 },
{ handle: 'ne', x: 110, y: 20 },
{ handle: 'w', x: 10, y: 120 },
{ handle: 'e', x: 110, y: 120 },
{ handle: 'sw', x: 10, y: 220 },
{ handle: 's', x: 60, y: 220 },
{ handle: 'se', x: 110, y: 220 },
])
for (const marker of handles) {
expect(findHandleAt(marker, rect)).toBe(marker.handle)
}
})
it('moves zones without leaving source image bounds', () => {
const z = zone({ x: 50, y: 50, width: 100, height: 100 })
moveZone(
z,
{ x: 500, y: 500 },
{ mouseX: 50, mouseY: 50, zoneX: 50, zoneY: 50 },
{ width: 500, height: 500 }
)
expect(z.x).toBe(400)
expect(z.y).toBe(400)
})
it('resizes zones without leaving source image bounds', () => {
const z = zone({
x: 50,
y: 50,
width: 100,
height: 100,
zone_source_width: 200,
zone_source_height: 200,
})
resizeZone(z, 'se', { x: 500, y: 500 }, { width: 500, height: 500 })
expect(z.width).toBe(150)
expect(z.height).toBe(150)
})
it('moves zones in their original source coordinates on a different-size preview', () => {
const z = zone({
x: 50,
y: 50,
width: 100,
height: 100,
zone_source_width: 1000,
zone_source_height: 2000,
})
moveZone(
z,
{ x: 75, y: 75 },
{ mouseX: 25, mouseY: 25, zoneX: 50, zoneY: 50 },
{ width: 500, height: 500 }
)
expect(z.x).toBe(150)
expect(z.y).toBe(250)
})
})
@@ -0,0 +1,204 @@
import { OcrTemplateZone } from 'src/app/data/ocr-template'
export interface DrawingRect {
startX: number
startY: number
endX: number
endY: number
}
export interface Dimensions {
width: number
height: number
}
export interface Point {
x: number
y: number
}
export interface DisplayRect {
x: number
y: number
w: number
h: number
}
export interface MoveStart {
mouseX: number
mouseY: number
zoneX: number
zoneY: number
}
export type ResizeHandle = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
export interface ResizeHandleMarker extends Point {
handle: ResizeHandle
}
export const HANDLE_SIZE = 8
export const MIN_ZONE_SIZE = 10
const ZONE_COLORS = [
'#4f8ff7',
'#ff6b6b',
'#51cf66',
'#ffd43b',
'#cc5de8',
'#ff922b',
'#20c997',
'#e599f7',
]
/** Outline color for a zone, shared by the preview overlay and the zone list. */
export function getZoneColor(index: number): string {
return ZONE_COLORS[index % ZONE_COLORS.length]
}
export function getZonePage(
zone: OcrTemplateZone,
previewPage: number,
previewPageCount: number | null
): number {
const page = zone.page ?? 1
if (page === -1) return previewPageCount ?? previewPage + 1
return Math.max(page, 1)
}
export function isZoneOnPage(
zone: OcrTemplateZone,
previewPage: number,
previewPageCount: number | null
): boolean {
return getZonePage(zone, previewPage, previewPageCount) === previewPage + 1
}
function getZoneSourceSize(
zone: OcrTemplateZone,
imageSize: Dimensions
): Dimensions {
return {
width: zone.zone_source_width || imageSize.width,
height: zone.zone_source_height || imageSize.height,
}
}
export function getZoneDisplayRect(
zone: OcrTemplateZone,
imageSize: Dimensions
): DisplayRect {
const sourceSize = getZoneSourceSize(zone, imageSize)
// The SVG viewBox uses the preview image's natural dimensions.
const scaleX = imageSize.width / sourceSize.width
const scaleY = imageSize.height / sourceSize.height
return {
x: zone.x * scaleX,
y: zone.y * scaleY,
w: zone.width * scaleX,
h: zone.height * scaleY,
}
}
export function getResizeHandles(rect: DisplayRect): ResizeHandleMarker[] {
return [
{ handle: 'nw', x: rect.x, y: rect.y },
{ handle: 'n', x: rect.x + rect.w / 2, y: rect.y },
{ handle: 'ne', x: rect.x + rect.w, y: rect.y },
{ handle: 'w', x: rect.x, y: rect.y + rect.h / 2 },
{ handle: 'e', x: rect.x + rect.w, y: rect.y + rect.h / 2 },
{ handle: 'sw', x: rect.x, y: rect.y + rect.h },
{ handle: 's', x: rect.x + rect.w / 2, y: rect.y + rect.h },
{ handle: 'se', x: rect.x + rect.w, y: rect.y + rect.h },
]
}
export function findHandleAt(
point: Point,
rect: DisplayRect,
handleSize = HANDLE_SIZE
): ResizeHandle | null {
return (
getResizeHandles(rect).find(
({ x, y }) =>
Math.abs(point.x - x) <= handleSize &&
Math.abs(point.y - y) <= handleSize
)?.handle ?? null
)
}
export function findZoneAt(
point: Point,
zones: OcrTemplateZone[],
previewPage: number,
previewPageCount: number | null,
imageSize: Dimensions
): number | null {
for (let i = zones.length - 1; i >= 0; i--) {
const zone = zones[i]
if (!isZoneOnPage(zone, previewPage, previewPageCount)) continue
const rect = getZoneDisplayRect(zone, imageSize)
if (
point.x >= rect.x &&
point.x <= rect.x + rect.w &&
point.y >= rect.y &&
point.y <= rect.y + rect.h
) {
return i
}
}
return null
}
export function moveZone(
zone: OcrTemplateZone,
point: Point,
moveStart: MoveStart,
imageSize: Dimensions
) {
const sourceSize = getZoneSourceSize(zone, imageSize)
const scaleX = sourceSize.width / imageSize.width
const scaleY = sourceSize.height / imageSize.height
const dx = Math.round((point.x - moveStart.mouseX) * scaleX)
const dy = Math.round((point.y - moveStart.mouseY) * scaleY)
zone.x = clamp(moveStart.zoneX + dx, 0, sourceSize.width - zone.width)
zone.y = clamp(moveStart.zoneY + dy, 0, sourceSize.height - zone.height)
}
export function resizeZone(
zone: OcrTemplateZone,
handle: ResizeHandle,
point: Point,
imageSize: Dimensions
) {
const sourceSize = getZoneSourceSize(zone, imageSize)
const scaleX = sourceSize.width / imageSize.width
const scaleY = sourceSize.height / imageSize.height
const imageX = clamp(Math.round(point.x * scaleX), 0, sourceSize.width)
const imageY = clamp(Math.round(point.y * scaleY), 0, sourceSize.height)
if (handle.includes('w')) {
const right = Math.min(zone.x + zone.width, sourceSize.width)
zone.x = clamp(imageX, 0, right - MIN_ZONE_SIZE)
zone.width = right - zone.x
}
if (handle.includes('e')) {
zone.width = Math.max(MIN_ZONE_SIZE, imageX - zone.x)
}
if (handle.includes('n')) {
const bottom = Math.min(zone.y + zone.height, sourceSize.height)
zone.y = clamp(imageY, 0, bottom - MIN_ZONE_SIZE)
zone.height = bottom - zone.y
}
if (handle.includes('s')) {
zone.height = Math.max(MIN_ZONE_SIZE, imageY - zone.y)
}
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(value, max))
}
@@ -0,0 +1,75 @@
<pngx-page-header
title="OCR Templates"
i18n-title
info="Define extraction zones on document types to automatically populate custom fields via OCR."
i18n-info
>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="editTemplate()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.OcrTemplate }">
<pngx-bs-icon name="plus-circle" class="me-1"></pngx-bs-icon><ng-container i18n>Add Template</ng-container>
</button>
</pngx-page-header>
<ul class="list-group">
<li class="list-group-item">
<div class="row">
<div class="col" i18n>Name</div>
<div class="col d-none d-sm-flex" i18n>Matching</div>
<div class="col d-none d-sm-flex" i18n>Zones</div>
<div class="col" i18n>Status</div>
<div class="col" i18n>Actions</div>
</div>
</li>
@if (loading() && templates().length === 0) {
<li class="list-group-item">
<output class="spinner-border spinner-border-sm me-2"></output>
<ng-container i18n>Loading...</ng-container>
</li>
}
@for (template of templates(); track template.id) {
<li class="list-group-item">
<div class="row fade" [class.show]="show()">
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editTemplate(template)" [disabled]="!userCanEdit(template)">{{template.name}}</button></div>
<div class="col d-flex align-items-center d-none d-sm-flex">{{getMatching(template)}}</div>
<div class="col d-flex align-items-center d-none d-sm-flex"><code>{{template.zones.length}}</code></div>
<div class="col d-flex align-items-center">
<div class="form-check form-switch mb-0">
<input type="checkbox" class="form-check-input cursor-pointer" [id]="template.id+'_enable'" [(ngModel)]="template.enabled" (change)="toggleTemplateEnabled(template)" [disabled]="!userCanEdit(template)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.OcrTemplate }">
<label class="form-check-label cursor-pointer" [for]="template.id+'_enable'">
<code> @if(template.enabled) { <ng-container i18n>Enabled</ng-container> } @else { <span i18n class="text-muted">Disabled</span> }</code>
</label>
</div>
</div>
<div class="col">
<div class="btn-group d-block d-sm-none">
<div ngbDropdown container="body" class="d-inline-block">
<button type="button" class="btn btn-link" id="actionsMenuMobile{{template.id}}" (click)="$event.stopPropagation()" ngbDropdownToggle>
<pngx-bs-icon name="three-dots-vertical"></pngx-bs-icon>
</button>
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile{{template.id}}">
<button (click)="editTemplate(template)" [disabled]="!userCanEdit(template)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.OcrTemplate }" ngbDropdownItem i18n>Edit</button>
<button (click)="deleteTemplate(template)" [disabled]="!permissionsService.currentUserOwnsObject(template)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.OcrTemplate }" ngbDropdownItem i18n>Delete</button>
</div>
</div>
</div>
<div class="btn-toolbar d-none d-sm-flex gap-2" role="toolbar">
<div class="btn-group">
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.OcrTemplate }" [disabled]="!userCanEdit(template)" class="btn btn-sm btn-outline-secondary" type="button" (click)="editTemplate(template)">
<pngx-bs-icon width="1em" height="1em" name="pencil" class="me-1"></pngx-bs-icon><ng-container i18n>Edit</ng-container>
</button>
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.OcrTemplate }" [disabled]="!permissionsService.currentUserOwnsObject(template)" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteTemplate(template)">
<pngx-bs-icon width="1em" height="1em" name="trash" class="me-1"></pngx-bs-icon><ng-container i18n>Delete</ng-container>
</button>
</div>
</div>
</div>
</div>
</li>
}
@if (!loading() && templates().length === 0) {
<li class="list-group-item" [class.show]="show()" i18n>No OCR templates defined.</li>
}
</ul>
@@ -0,0 +1,300 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
import { of, throwError } from 'rxjs'
import { MATCH_LITERAL, MATCH_NONE } from 'src/app/data/matching-model'
import { OcrTemplate } from 'src/app/data/ocr-template'
import { User } from 'src/app/data/user'
import { DirtyFormGuard } from 'src/app/guards/dirty-form.guard'
import { PermissionsService } from 'src/app/services/permissions.service'
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
import { UserService } from 'src/app/services/rest/user.service'
import { ToastService } from 'src/app/services/toast.service'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { OcrTemplateEditorComponent } from './ocr-template-editor/ocr-template-editor.component'
import { OcrTemplatesComponent } from './ocr-templates.component'
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
const templates: OcrTemplate[] = [
{
id: 1,
name: 'Invoices',
match: 'Northwind',
matching_algorithm: MATCH_LITERAL,
sample_document: null,
source_width: 0,
source_height: 0,
enabled: true,
zones: [],
},
]
describe('OcrTemplatesComponent', () => {
let component: OcrTemplatesComponent
let fixture: ComponentFixture<OcrTemplatesComponent>
let ocrTemplateService: OcrTemplateService
let modalService: NgbModal
let toastService: ToastService
let modal: NgbModalRef
beforeEach(() => {
TestBed.configureTestingModule({
imports: [OcrTemplatesComponent, BootstrapIconComponent],
providers: [
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
})
TestBed.inject(PermissionsService).initialize(
[
'view_ocrtemplate',
'add_ocrtemplate',
'change_ocrtemplate',
'delete_ocrtemplate',
],
{ id: 1, groups: [], is_superuser: false } as User
)
jest
.spyOn(TestBed.inject(UserService), 'listAll')
.mockReturnValue(of({ count: 0, all: [], results: [] }))
ocrTemplateService = TestBed.inject(OcrTemplateService)
jest
.spyOn(ocrTemplateService, 'listAll')
.mockReturnValue(of({ count: 1, all: [1], results: templates }))
modalService = TestBed.inject(NgbModal)
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
toastService = TestBed.inject(ToastService)
fixture = TestBed.createComponent(OcrTemplatesComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
afterEach(() => modalService.dismissAll())
it('should show templates with their matching', () => {
const text = fixture.nativeElement.textContent
expect(text).toContain('Invoices')
expect(text).toContain('Exact match: Northwind')
expect(
component.getMatching({ ...templates[0], matching_algorithm: MATCH_NONE })
).toEqual('None')
expect(component.getMatching({ ...templates[0], match: '' })).toEqual('-')
})
it.each([
{
name: 'owned',
owner: 1,
user_can_change: true,
editable: true,
deletable: true,
},
{
name: 'unowned',
owner: null,
user_can_change: true,
editable: true,
deletable: true,
},
{
name: 'view-only shared',
owner: 2,
user_can_change: false,
editable: false,
deletable: false,
},
{
name: 'editable shared',
owner: 2,
user_can_change: true,
editable: true,
deletable: false,
},
])(
'should respect permissions for $name templates',
async ({ owner, user_can_change, editable, deletable }) => {
const template = { ...templates[0], owner, user_can_change }
component.templates.set([template])
fixture.detectChanges()
await fixture.whenStable()
expect(fixture.nativeElement.querySelector('.text-start').disabled).toBe(
!editable
)
expect(
fixture.nativeElement.querySelector('.form-switch input').disabled
).toBe(!editable)
const buttons = Array.from<HTMLButtonElement>(
fixture.nativeElement.querySelectorAll('button')
)
for (const button of buttons.filter(
(button) => button.textContent.trim() === 'Edit'
)) {
expect(button.disabled).toBe(!editable)
}
for (const button of buttons.filter(
(button) => button.textContent.trim() === 'Delete'
)) {
expect(button.disabled).toBe(!deletable)
}
}
)
it('should allow a superuser to manage another users template', () => {
TestBed.inject(PermissionsService).initialize([], {
id: 1,
is_superuser: true,
} as User)
component.templates.set([
{ ...templates[0], owner: 2, user_can_change: false },
])
fixture.detectChanges()
expect(fixture.nativeElement.querySelector('.text-start').disabled).toBe(
false
)
expect(
fixture.nativeElement.querySelector('.btn-outline-danger').disabled
).toBe(false)
})
it('should not open mutation dialogs or patch a view-only template', () => {
const template = { ...templates[0], owner: 2, user_can_change: false }
const open = jest.spyOn(modalService, 'open')
const patch = jest.spyOn(ocrTemplateService, 'patch')
component.editTemplate(template)
component.toggleTemplateEnabled(template)
component.deleteTemplate(template)
expect(open).not.toHaveBeenCalled()
expect(patch).not.toHaveBeenCalled()
})
it('should require global edit permission even for an owned template', () => {
TestBed.inject(PermissionsService).initialize(['view_ocrtemplate'], {
id: 1,
} as User)
fixture.destroy()
fixture = TestBed.createComponent(OcrTemplatesComponent)
component = fixture.componentInstance
fixture.detectChanges()
expect(fixture.nativeElement.querySelector('.text-start').disabled).toBe(
true
)
expect(fixture.nativeElement.querySelector('.form-switch input')).toBeNull()
})
it('should support create, show notification on error / success', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
const reloadSpy = jest.spyOn(component, 'reload')
fixture.debugElement
.queryAll(By.css('pngx-page-header button'))
.find((button) => button.nativeElement.textContent.includes('Add'))
.triggerEventHandler('click')
const editor = modal.componentInstance as OcrTemplateEditorComponent
expect(editor).toBeInstanceOf(OcrTemplateEditorComponent)
expect(editor.object).toBeUndefined()
editor.failed.emit({ error: 'error creating template' })
expect(toastErrorSpy).toHaveBeenCalled()
expect(reloadSpy).not.toHaveBeenCalled()
editor.succeeded.emit(templates[0])
expect(toastSuccessSpy).toHaveBeenCalled()
expect(reloadSpy).toHaveBeenCalled()
})
it('should pass the template to the editor', () => {
component.editTemplate(templates[0])
expect(modal.componentInstance.object).toBe(templates[0])
})
it('should confirm closing the editor with unsaved changes', async () => {
const confirmSpy = jest
.spyOn(TestBed.inject(DirtyFormGuard), 'confirmChanges')
.mockReturnValue(of(false))
component.editTemplate()
const editor = modal.componentInstance as OcrTemplateEditorComponent
editor.ngOnInit()
modal.dismiss()
await fixture.whenStable()
expect(confirmSpy).not.toHaveBeenCalled()
component.editTemplate()
const dirtyEditor = modal.componentInstance as OcrTemplateEditorComponent
dirtyEditor.ngOnInit()
dirtyEditor.template.name = 'Changed'
modal.dismiss()
await fixture.whenStable()
expect(confirmSpy).toHaveBeenCalled()
expect(modalService.hasOpenModals()).toBe(true)
dirtyEditor.saving.set(true)
confirmSpy.mockClear()
modal.dismiss()
await fixture.whenStable()
expect(confirmSpy).not.toHaveBeenCalled()
expect(modalService.hasOpenModals()).toBe(true)
})
it('should only send the enabled flag when toggling', () => {
const patchSpy = jest
.spyOn(ocrTemplateService, 'patch')
.mockReturnValueOnce(throwError(() => new Error('error toggling')))
.mockReturnValueOnce(of(templates[0]))
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
const reloadSpy = jest.spyOn(component, 'reload')
const toggle = fixture.debugElement.query(By.css('.form-switch input'))
toggle.nativeElement.click()
expect(patchSpy).toHaveBeenCalledWith({ id: 1, enabled: false })
expect(toastErrorSpy).toHaveBeenCalled()
expect(reloadSpy).toHaveBeenCalledTimes(1)
component.toggleTemplateEnabled(templates[0])
expect(toastSuccessSpy).toHaveBeenCalled()
expect(reloadSpy).toHaveBeenCalledTimes(2)
})
it('should support delete, show notification on error / success', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
const deleteSpy = jest.spyOn(ocrTemplateService, 'delete')
const reloadSpy = jest.spyOn(component, 'reload')
component.deleteTemplate(templates[0])
const dialog = modal.componentInstance as ConfirmDialogComponent
deleteSpy.mockReturnValueOnce(throwError(() => new Error('error deleting')))
dialog.confirmClicked.emit()
expect(toastErrorSpy).toHaveBeenCalled()
expect(dialog.buttonsEnabled()).toBe(true)
expect(reloadSpy).not.toHaveBeenCalled()
deleteSpy.mockReturnValueOnce(of(true))
dialog.confirmClicked.emit()
expect(toastSuccessSpy).toHaveBeenCalled()
expect(reloadSpy).toHaveBeenCalled()
})
it.each([true, false])(
'reports the enabled state %s after the update succeeds',
(enabled) => {
const template = { ...templates[0], enabled }
jest.spyOn(ocrTemplateService, 'patch').mockReturnValue(of(template))
const toast = jest.spyOn(toastService, 'showSuccess')
component.toggleTemplateEnabled(template)
expect(toast).toHaveBeenCalledWith(
`${enabled ? 'Enabled' : 'Disabled'} OCR template "${template.name}"`
)
}
)
})
@@ -0,0 +1,185 @@
import { Component, OnInit, inject, signal } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
import { firstValueFrom, takeUntil, tap } from 'rxjs'
import { MATCH_NONE, MATCHING_ALGORITHMS } from 'src/app/data/matching-model'
import { OcrTemplate } from 'src/app/data/ocr-template'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { DirtyFormGuard } from 'src/app/guards/dirty-form.guard'
import {
PermissionAction,
PermissionType,
PermissionsService,
} from 'src/app/services/permissions.service'
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
import { ToastService } from 'src/app/services/toast.service'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
import { OcrTemplateEditorComponent } from './ocr-template-editor/ocr-template-editor.component'
@Component({
selector: 'pngx-ocr-templates',
templateUrl: './ocr-templates.component.html',
imports: [
PageHeaderComponent,
IfPermissionsDirective,
FormsModule,
NgbDropdownModule,
BootstrapIconComponent,
],
})
export class OcrTemplatesComponent
extends LoadingComponentWithPermissions
implements OnInit
{
private readonly ocrTemplateService = inject(OcrTemplateService)
public readonly permissionsService = inject(PermissionsService)
private readonly modalService = inject(NgbModal)
private readonly dirtyFormGuard = inject(DirtyFormGuard)
private readonly toastService = inject(ToastService)
public readonly templates = signal<OcrTemplate[]>([])
ngOnInit() {
this.reload()
}
reload() {
this.loading.set(true)
this.ocrTemplateService
.listAll()
.pipe(
takeUntil(this.unsubscribeNotifier),
tap((r) => this.templates.set(r.results))
)
.subscribe(() => {
this.show.set(true)
this.loading.set(false)
})
}
getMatching(template: OcrTemplate): string {
if (template.matching_algorithm === MATCH_NONE) {
return $localize`None`
}
if (!template.match) {
return '-'
}
const algorithm = MATCHING_ALGORITHMS.find(
(a) => a.id === template.matching_algorithm
)
return `${algorithm?.shortName}: ${template.match}`
}
editTemplate(template?: OcrTemplate) {
if (template && !this.userCanEdit(template)) return
const modal = this.modalService.open(OcrTemplateEditorComponent, {
backdrop: 'static',
keyboard: false,
size: 'xxl',
fullscreen: 'lg',
scrollable: true,
ariaLabelledBy: 'ocr-template-editor-title',
beforeDismiss: () => {
const editor: OcrTemplateEditorComponent = modal.componentInstance
if (editor.saving()) return false
if (!editor.isDirty) return true
return firstValueFrom(
this.dirtyFormGuard.confirmChanges(undefined, $localize`Close`)
)
},
})
modal.componentInstance.object = template
modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((saved: OcrTemplate) => {
this.toastService.showSuccess(
$localize`Saved OCR template "${saved.name}".`
)
this.ocrTemplateService.clearCache()
this.reload()
})
modal.componentInstance.failed
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((e) => {
this.toastService.showError($localize`Error saving OCR template.`, e)
})
}
toggleTemplateEnabled(template: OcrTemplate) {
if (!this.userCanEdit(template)) return
this.ocrTemplateService
.patch({ id: template.id, enabled: template.enabled } as OcrTemplate)
.subscribe({
next: () => {
this.toastService.showSuccess(
template.enabled
? $localize`Enabled OCR template "${template.name}"`
: $localize`Disabled OCR template "${template.name}"`
)
this.reload()
},
error: (e) => {
this.toastService.showError(
$localize`Error toggling OCR template "${template.name}".`,
e
)
this.reload()
},
})
}
deleteTemplate(template: OcrTemplate) {
if (
!this.permissionsService.currentUserCan(
PermissionAction.Delete,
PermissionType.OcrTemplate
) ||
!this.permissionsService.currentUserOwnsObject(template)
)
return
const modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Confirm delete OCR template`
modal.componentInstance.messageBold = $localize`This operation will permanently delete this OCR template.`
modal.componentInstance.message = $localize`This operation cannot be undone.`
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
this.ocrTemplateService.delete(template).subscribe({
next: () => {
modal.close()
this.toastService.showSuccess(
$localize`Deleted OCR template "${template.name}".`
)
this.ocrTemplateService.clearCache()
this.reload()
},
error: (e) => {
modal.componentInstance.buttonsEnabled.set(true)
this.toastService.showError(
$localize`Error deleting OCR template "${template.name}".`,
e
)
},
})
})
}
userCanEdit(template: OcrTemplate): boolean {
return (
this.permissionsService.currentUserCan(
PermissionAction.Change,
PermissionType.OcrTemplate
) &&
this.permissionsService.currentUserHasObjectPermissions(
PermissionAction.Change,
template
)
)
}
}
+13
View File
@@ -0,0 +1,13 @@
import { PaperlessTaskStatus } from './paperless-task'
export interface BulkOperationResponse {
result: string
task_id: string
}
export interface BulkOperationStatus {
task_id: string
operation: string
status: PaperlessTaskStatus
error?: string
}
+6
View File
@@ -1,8 +1,14 @@
import { ObjectWithId } from './object-with-id' import { ObjectWithId } from './object-with-id'
import { User } from './user' import { User } from './user'
export enum NoteFormat {
Plain = 'plain',
Markdown = 'markdown',
}
export interface DocumentNote extends ObjectWithId { export interface DocumentNote extends ObjectWithId {
created?: Date created?: Date
note?: string note?: string
user?: User user?: User
format?: NoteFormat
} }
+115
View File
@@ -0,0 +1,115 @@
import { CustomFieldDataType } from './custom-field'
import { MatchingModel } from './matching-model'
import { ObjectWithPermissions } from './object-with-permissions'
export enum OcrZoneTarget {
CustomField = 'custom_field',
Title = 'title',
Asn = 'asn',
Created = 'created',
}
export enum OcrZoneTransform {
None = 'none',
Strip = 'strip',
Uppercase = 'uppercase',
Lowercase = 'lowercase',
Numeric = 'numeric',
StripPunctuation = 'strip_punctuation',
Date = 'date',
QrCode = 'qr_code',
}
export const DEFAULT_OCR_ZONE_LANGUAGE = 'eng'
/** The field a zone writes to, keyed by OcrTemplate.combine_formats. */
export function getZoneFieldKey(zone: OcrTemplateZone): string | null {
if (zone.target !== OcrZoneTarget.CustomField) return zone.target
return zone.custom_field === null ? null : String(zone.custom_field)
}
/** Custom field types zone OCR can write to. See models.py OCR_SUPPORTED_FIELD_TYPES. */
export const OCR_SUPPORTED_FIELD_TYPES: CustomFieldDataType[] = [
CustomFieldDataType.String,
CustomFieldDataType.Url,
CustomFieldDataType.Date,
CustomFieldDataType.Integer,
CustomFieldDataType.Float,
CustomFieldDataType.Monetary,
CustomFieldDataType.LongText,
CustomFieldDataType.Boolean,
]
export const OCR_BUILTIN_TARGETS = [
{ id: OcrZoneTarget.Title, name: $localize`Title` },
{ id: OcrZoneTarget.Asn, name: $localize`Archive serial number` },
{ id: OcrZoneTarget.Created, name: $localize`Date created` },
]
export interface OcrTemplateZone {
id?: number
name: string
target: OcrZoneTarget
custom_field: number | null
page?: number
x: number
y: number
width: number
height: number
ocr_language: string
transform: OcrZoneTransform
date_format?: string
validation_regex: string
order: number
zone_source_width?: number
zone_source_height?: number
}
export interface OcrTemplate extends MatchingModel, ObjectWithPermissions {
name: string
sample_document: number | null
source_width: number
source_height: number
enabled: boolean
combine_formats?: Record<string, string>
created?: string
updated?: string
zones: OcrTemplateZone[]
}
export interface ZoneTestRequest {
name: string
x: number
y: number
width: number
height: number
page: number
ocr_language: string
transform: OcrZoneTransform
date_format?: string
validation_regex: string
zone_source_width?: number
zone_source_height?: number
}
export interface OcrZoneTestResult {
raw_text?: string | null
value?: string | null
regex?: string
regex_match?: boolean | null
error?: string
}
export interface OcrZoneRunResult {
template: string
zone: string
custom_field: string
value: string | number | boolean | null
reason?: string | null
}
export interface OcrZoneRunResponse {
status: 'completed' | 'skipped'
reason?: 'no_template'
results: OcrZoneRunResult[]
}
+1
View File
@@ -35,6 +35,7 @@ export enum HideableSidebarItemID {
SavedViews = 'saved_views', SavedViews = 'saved_views',
ShareLinks = 'share_links', ShareLinks = 'share_links',
Workflows = 'workflows', Workflows = 'workflows',
OcrTemplates = 'ocr_templates',
Mail = 'mail', Mail = 'mail',
Documentation = 'documentation', Documentation = 'documentation',
} }
+1
View File
@@ -12,6 +12,7 @@ export enum WorkflowTriggerType {
DocumentAdded = 2, DocumentAdded = 2,
DocumentUpdated = 3, DocumentUpdated = 3,
Scheduled = 4, Scheduled = 4,
Manual = 5,
} }
export enum ScheduleDateField { export enum ScheduleDateField {
+6 -2
View File
@@ -3,12 +3,16 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { DirtyCheckGuard } from '@ngneat/dirty-check-forms' import { DirtyCheckGuard } from '@ngneat/dirty-check-forms'
import { Observable, Subject } from 'rxjs' import { Observable, Subject } from 'rxjs'
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
import { ActivatedRouteSnapshot } from '@angular/router'
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class DirtyFormGuard extends DirtyCheckGuard { export class DirtyFormGuard extends DirtyCheckGuard {
private modalService = inject(NgbModal) private modalService = inject(NgbModal)
confirmChanges(): Observable<boolean> { confirmChanges(
_currentRoute?: ActivatedRouteSnapshot,
leaveLabel: string = $localize`Leave page`
): Observable<boolean> {
let modal = this.modalService.open(ConfirmDialogComponent, { let modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static', backdrop: 'static',
}) })
@@ -16,7 +20,7 @@ export class DirtyFormGuard extends DirtyCheckGuard {
modal.componentInstance.messageBold = $localize`You have unsaved changes.` modal.componentInstance.messageBold = $localize`You have unsaved changes.`
modal.componentInstance.message = $localize`Are you sure you want to leave?` modal.componentInstance.message = $localize`Are you sure you want to leave?`
modal.componentInstance.btnClass = 'btn-warning' modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Leave page` modal.componentInstance.btnCaption = leaveLabel
modal.componentInstance.confirmClicked.subscribe(() => { modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled.set(false)
modal.close() modal.close()
@@ -124,6 +124,42 @@ describe('DocumentListViewService', () => {
}) })
}) })
it.each([false, true])(
'preserves the current route on a background refresh (saved view: %s)',
(savedView) => {
if (savedView) documentListViewService.activateSavedView(view)
const navigate = jest.mocked(router.navigate)
navigate.mockClear()
documentListViewService.reload(null, false)
httpTestingController
.expectOne((req) => req.url === `${environment.apiBaseUrl}documents/`)
.flush(full_results)
expect(documentListViewService.documents).toEqual(full_results.results)
expect(navigate).not.toHaveBeenCalled()
}
)
it('preserves the route when a background refresh falls back to the first page', () => {
jest
.spyOn(documentListViewService, 'saveDocumentListView')
.mockImplementation(() => {})
documentListViewService.currentPage = 2
httpTestingController
.expectOne((req) => req.url === `${environment.apiBaseUrl}documents/`)
.flush(full_results)
const navigate = jest.mocked(router.navigate)
navigate.mockClear()
documentListViewService.reload(null, false)
httpTestingController
.expectOne((req) => req.url === `${environment.apiBaseUrl}documents/`)
.flush('Not found', { status: 404, statusText: 'Not found' })
httpTestingController
.expectOne((req) => req.url === `${environment.apiBaseUrl}documents/`)
.flush(full_results)
expect(documentListViewService.currentPage).toBe(1)
expect(navigate).not.toHaveBeenCalled()
})
it('should reload the list', () => { it('should reload the list', () => {
expect(documentListViewService.currentPage).toEqual(1) expect(documentListViewService.currentPage).toEqual(1)
documentListViewService.reload() documentListViewService.reload()
@@ -386,6 +422,46 @@ describe('DocumentListViewService', () => {
documentListViewService.activateSavedView(null) documentListViewService.activateSavedView(null)
}) })
it('should support activating saved view with pre-fetched documents', () => {
const routerSpy = jest.spyOn(router, 'navigate')
routerSpy.mockClear()
documentListViewService.activateSavedViewWithDocuments(
view,
documents.slice(0, 2),
documents.length,
2
)
// no navigation, no reload
expect(routerSpy).not.toHaveBeenCalled()
httpTestingController.expectNone(
(req) => req.url === `${environment.apiBaseUrl}documents/`
)
expect(documentListViewService.activeSavedViewId).toEqual(view.id)
expect(documentListViewService.activeSavedViewTitle).toEqual(view.name)
expect(documentListViewService.documents).toEqual(documents.slice(0, 2))
expect(documentListViewService.collectionSize).toEqual(documents.length)
expect(documentListViewService.currentPage).toEqual(1)
expect(documentListViewService.pageSize).toEqual(2)
expect(documentListViewService.hasNext(documents[0].id)).toBeTruthy()
let nextDocId: number
documentListViewService
.getNext(documents[1].id)
.subscribe((docId) => (nextDocId = docId))
// crossing the page uses the view's sort + filters and the given page size
// (the currentPage setter reloads too, so the first request is cancelled)
const reqs = httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=2&page_size=2&ordering=-added&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
)
expect(reqs.length).toBeGreaterThan(0)
reqs[reqs.length - 1].flush({
count: documents.length,
results: documents.slice(2, 4),
})
expect(nextDocId).toEqual(documents[2].id)
documentListViewService.activateSavedView(null)
})
it('should support navigating next / previous', () => { it('should support navigating next / previous', () => {
documentListViewService.setFilterRules([]) documentListViewService.setFilterRules([])
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
@@ -270,11 +270,28 @@ export class DocumentListViewService {
this.currentPage = viewState.currentPage this.currentPage = viewState.currentPage
} }
loadSavedView(view: SavedView, closeCurrentView: boolean = false) { /**
if (closeCurrentView) { * Activate a saved view using documents that were already fetched, e.g. by a
this._activeSavedViewId = null * dashboard widget. Does not navigate or reload.
} */
activateSavedViewWithDocuments(
view: SavedView,
documents: Document[],
collectionSize: number,
pageSize: number
) {
this.cancelPending()
this.rangeSelectionAnchorIndex = this.lastRangeSelectionToIndex = null
this._activeSavedViewId = view.id
this.applySavedViewConfig(view)
this.activeListViewState.pageSize = pageSize // match the page size the documents were fetched with
this.activeListViewState.currentPage = 1
this.activeListViewState.documents = documents
this.activeListViewState.collectionSize = collectionSize
this.markChanged()
}
private applySavedViewConfig(view: SavedView) {
this.activeListViewState.filterRules = cloneFilterRules(view.filter_rules) this.activeListViewState.filterRules = cloneFilterRules(view.filter_rules)
this.activeListViewState.sortField = view.sort_field this.activeListViewState.sortField = view.sort_field
this.activeListViewState.sortReverse = view.sort_reverse this.activeListViewState.sortReverse = view.sort_reverse
@@ -284,6 +301,14 @@ export class DocumentListViewService {
this.activeListViewState.displayMode = view.display_mode this.activeListViewState.displayMode = view.display_mode
this.activeListViewState.pageSize = view.page_size this.activeListViewState.pageSize = view.page_size
this.activeListViewState.displayFields = view.display_fields this.activeListViewState.displayFields = view.display_fields
}
loadSavedView(view: SavedView, closeCurrentView: boolean = false) {
if (closeCurrentView) {
this._activeSavedViewId = null
}
this.applySavedViewConfig(view)
this.reduceSelectionToFilter() this.reduceSelectionToFilter()
this.markChanged() this.markChanged()
@@ -355,7 +380,7 @@ export class DocumentListViewService {
queryParams: paramsFromViewState(activeListViewState), queryParams: paramsFromViewState(activeListViewState),
replaceUrl: !this.router.routerState.snapshot.url.includes('?'), // in case navigating from params-less /documents replaceUrl: !this.router.routerState.snapshot.url.includes('?'), // in case navigating from params-less /documents
}) })
} else if (this._activeSavedViewId) { } else if (updateQueryParams && this._activeSavedViewId) {
this.router.navigate([], { this.router.navigate([], {
queryParams: paramsFromViewState(activeListViewState, true), queryParams: paramsFromViewState(activeListViewState, true),
queryParamsHandling: 'merge', queryParamsHandling: 'merge',
@@ -374,7 +399,7 @@ export class DocumentListViewService {
// this happens when applying a filter: the current page might not be available anymore due to the reduced result set. // this happens when applying a filter: the current page might not be available anymore due to the reduced result set.
activeListViewState.currentPage = 1 activeListViewState.currentPage = 1
this.markChanged() this.markChanged()
this.reload() this.reload(onFinish, updateQueryParams)
} else if ( } else if (
activeListViewState.sortField.indexOf('custom_field') === 0 && activeListViewState.sortField.indexOf('custom_field') === 0 &&
!this.settings !this.settings
@@ -283,6 +283,10 @@ describe('PermissionsService', () => {
'view_workflow', 'view_workflow',
'change_workflow', 'change_workflow',
'delete_workflow', 'delete_workflow',
'add_ocrtemplate',
'view_ocrtemplate',
'change_ocrtemplate',
'delete_ocrtemplate',
'add_customfield', 'add_customfield',
'view_customfield', 'view_customfield',
'change_customfield', 'change_customfield',
@@ -29,6 +29,7 @@ export enum PermissionType {
ShareLinkBundle = '%s_sharelinkbundle', ShareLinkBundle = '%s_sharelinkbundle',
CustomField = '%s_customfield', CustomField = '%s_customfield',
Workflow = '%s_workflow', Workflow = '%s_workflow',
OcrTemplate = '%s_ocrtemplate',
ProcessedMail = '%s_processedmail', ProcessedMail = '%s_processedmail',
GlobalStatistics = '%s_global_statistics', GlobalStatistics = '%s_global_statistics',
SystemMonitoring = '%s_system_monitoring', SystemMonitoring = '%s_system_monitoring',
@@ -1,6 +1,7 @@
import { HttpTestingController } from '@angular/common/http/testing' import { HttpTestingController } from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing' import { TestBed } from '@angular/core/testing'
import { Subscription } from 'rxjs' import { Subscription } from 'rxjs'
import { NoteFormat } from 'src/app/data/document-note'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec' import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
import { DocumentNotesService } from './document-notes.service' import { DocumentNotesService } from './document-notes.service'
@@ -50,6 +51,7 @@ describe(`Additional service tests for DocumentNotesService`, () => {
expect(req.request.method).toEqual('POST') expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
note: content, note: content,
format: NoteFormat.Markdown,
}) })
}) })
@@ -1,7 +1,7 @@
import { HttpParams } from '@angular/common/http' import { HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { Observable } from 'rxjs' import { Observable } from 'rxjs'
import { DocumentNote } from 'src/app/data/document-note' import { DocumentNote, NoteFormat } from 'src/app/data/document-note'
import { AbstractPaperlessService } from './abstract-paperless-service' import { AbstractPaperlessService } from './abstract-paperless-service'
@Injectable({ @Injectable({
@@ -22,6 +22,7 @@ export class DocumentNotesService extends AbstractPaperlessService<DocumentNote>
addNote(id: number, note: string): Observable<DocumentNote[]> { addNote(id: number, note: string): Observable<DocumentNote[]> {
return this.http.post<DocumentNote[]>(this.getResourceUrl(id, 'notes'), { return this.http.post<DocumentNote[]>(this.getResourceUrl(id, 'notes'), {
note: note, note: note,
format: NoteFormat.Markdown,
}) })
} }
@@ -248,6 +248,7 @@ describe(`DocumentService`, () => {
) )
expect(req.request.method).toEqual('POST') expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
from_webui: true,
documents: ids, documents: ids,
method, method,
parameters, parameters,
@@ -271,6 +272,7 @@ describe(`DocumentService`, () => {
) )
expect(req.request.method).toEqual('POST') expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
from_webui: true,
all: true, all: true,
filters: { title__icontains: 'apple' }, filters: { title__icontains: 'apple' },
excluded_documents: [2, 3], excluded_documents: [2, 3],
@@ -563,6 +565,20 @@ describe(`DocumentService`, () => {
req.flush('task-id') req.flush('task-id')
}) })
it('should call appropriate api endpoint for running zone OCR', () => {
const url = `${environment.apiBaseUrl}${endpoint}/${documents[0].id}/run-zone-ocr/`
subscription = service.runZoneOcr(documents[0].id).subscribe()
let req = httpTestingController.expectOne(url)
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({})
req.flush({ status: 'completed', results: [] })
subscription = service.runZoneOcr(documents[0].id, 3, 10).subscribe()
req = httpTestingController.expectOne(`${url}?version=10`)
expect(req.request.body).toEqual({ template: 3 })
req.flush({ status: 'completed', results: [] })
})
}) })
it('should construct sort fields respecting permissions', () => { it('should construct sort fields respecting permissions', () => {
@@ -12,8 +12,10 @@ import {
import { DocumentMetadata } from 'src/app/data/document-metadata' import { DocumentMetadata } from 'src/app/data/document-metadata'
import { DocumentSuggestions } from 'src/app/data/document-suggestions' import { DocumentSuggestions } from 'src/app/data/document-suggestions'
import { FilterRule } from 'src/app/data/filter-rule' import { FilterRule } from 'src/app/data/filter-rule'
import { OcrZoneRunResponse } from 'src/app/data/ocr-template'
import { Results, SelectionData } from 'src/app/data/results' import { Results, SelectionData } from 'src/app/data/results'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { BulkOperationResponse } from '../../data/bulk-operation'
import { queryParamsFromFilterRules } from '../../utils/query-params' import { queryParamsFromFilterRules } from '../../utils/query-params'
import { import {
PermissionAction, PermissionAction,
@@ -38,6 +40,7 @@ export type DocumentBulkEditMethod =
| 'modify_tags' | 'modify_tags'
| 'modify_custom_fields' | 'modify_custom_fields'
| 'set_permissions' | 'set_permissions'
| 'run_workflow'
export interface MergeDocumentsRequest { export interface MergeDocumentsRequest {
metadata_document_id?: number metadata_document_id?: number
@@ -337,11 +340,15 @@ export class DocumentService extends AbstractPaperlessService<Document> {
method: DocumentBulkEditMethod, method: DocumentBulkEditMethod,
args: any args: any
) { ) {
return this.http.post(this.getResourceUrl(null, 'bulk_edit'), { return this.http.post<BulkOperationResponse>(
...selection, this.getResourceUrl(null, 'bulk_edit'),
method: method, {
parameters: args, ...selection,
}) method: method,
from_webui: true,
parameters: args,
}
)
} }
deleteDocuments(selection: DocumentSelectionQuery) { deleteDocuments(selection: DocumentSelectionQuery) {
@@ -360,6 +367,28 @@ export class DocumentService extends AbstractPaperlessService<Document> {
}) })
} }
runZoneOcrDocuments(
selection: DocumentSelectionQuery,
templateId: number | null = null
) {
return this.http.post(this.getResourceUrl(null, 'run_zone_ocr'), {
...selection,
...(templateId ? { template: templateId } : {}),
})
}
runZoneOcr(
id: number,
templateId: number | null = null,
versionId: number | null = null
): Observable<OcrZoneRunResponse> {
return this.http.post<OcrZoneRunResponse>(
this.getResourceUrl(id, 'run-zone-ocr'),
templateId ? { template: templateId } : {},
{ params: versionId ? { version: versionId.toString() } : {} }
)
}
rotateDocuments( rotateDocuments(
selection: DocumentSelectionQuery, selection: DocumentSelectionQuery,
degrees: number, degrees: number,
@@ -0,0 +1,78 @@
import { provideHttpClient } from '@angular/common/http'
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing'
import { OcrZoneTransform, ZoneTestRequest } from 'src/app/data/ocr-template'
import { environment } from 'src/environments/environment'
import { OcrTemplateService } from './ocr-template.service'
describe('OcrTemplateService', () => {
let service: OcrTemplateService
let http: HttpTestingController
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
})
service = TestBed.inject(OcrTemplateService)
http = TestBed.inject(HttpTestingController)
})
afterEach(() => http.verify())
it('builds page image URLs with zero-based page numbers', () => {
expect(service.getPageImageUrl(12, 0)).toBe(
`${environment.apiBaseUrl}ocr_templates/document-page-image/12/0/`
)
expect(service.getPageImageUrl(12, 2)).toBe(
`${environment.apiBaseUrl}ocr_templates/document-page-image/12/2/`
)
})
it('posts a zone test and returns the extraction result', () => {
const zone: ZoneTestRequest = {
name: 'Total',
x: 10,
y: 20,
width: 100,
height: 50,
page: -1,
ocr_language: 'eng',
transform: OcrZoneTransform.Numeric,
validation_regex: '\\d+',
zone_source_width: 1000,
zone_source_height: 1500,
}
const result = {
raw_text: 'Total: 123',
value: '123',
regex: '\\d+',
regex_match: true,
}
const next = jest.fn()
service.testZone(12, zone).subscribe(next)
const request = http.expectOne(
`${environment.apiBaseUrl}ocr_templates/test-zone/`
)
expect(request.request.method).toBe('POST')
expect(request.request.body).toEqual({ document: 12, zone })
request.flush(result)
expect(next).toHaveBeenCalledWith(result)
})
it('passes zone-test failures to the caller', () => {
const error = jest.fn()
service.testZone(12, {} as ZoneTestRequest).subscribe({ error })
http
.expectOne(`${environment.apiBaseUrl}ocr_templates/test-zone/`)
.flush(
{ error: 'Invalid zone' },
{ status: 400, statusText: 'Bad Request' }
)
expect(error).toHaveBeenCalledWith(
expect.objectContaining({ status: 400, error: { error: 'Invalid zone' } })
)
})
})
@@ -0,0 +1,30 @@
import { Injectable } from '@angular/core'
import { Observable } from 'rxjs'
import {
OcrTemplate,
OcrZoneTestResult,
ZoneTestRequest,
} from '../../data/ocr-template'
import { AbstractPaperlessService } from './abstract-paperless-service'
@Injectable({ providedIn: 'root' })
export class OcrTemplateService extends AbstractPaperlessService<OcrTemplate> {
constructor() {
super()
this.resourceName = 'ocr_templates'
}
getPageImageUrl(docId: number, page: number): string {
return `${this.baseUrl}${this.resourceName}/document-page-image/${docId}/${page}/`
}
testZone(
docId: number,
zone: ZoneTestRequest
): Observable<OcrZoneTestResult> {
return this.http.post<OcrZoneTestResult>(
`${this.baseUrl}${this.resourceName}/test-zone/`,
{ document: docId, zone }
)
}
}
@@ -438,6 +438,20 @@ describe('ConsumerStatusService', () => {
expect(updated).toBeTruthy() expect(updated).toBeTruthy()
}) })
it('forwards bulk operation completion events', () => {
const received = jest.fn()
websocketStatusService.onBulkOperationStatus().subscribe(received)
const data = {
task_id: 'bulk-task',
operation: 'set_permissions',
status: 'success',
}
websocketStatusService.connect()
server.send({ type: WebsocketStatusType.BULK_OPERATION_STATUS, data })
websocketStatusService.disconnect()
expect(received).toHaveBeenCalledWith(data)
})
it('should ignore keep-alive heartbeat messages from the server', () => { it('should ignore keep-alive heartbeat messages from the server', () => {
let updated = false let updated = false
let deleted = false let deleted = false
@@ -1,6 +1,7 @@
import { Injectable, inject, signal } from '@angular/core' import { Injectable, inject, signal } from '@angular/core'
import { Subject } from 'rxjs' import { Subject } from 'rxjs'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { BulkOperationStatus } from '../data/bulk-operation'
import { User } from '../data/user' import { User } from '../data/user'
import { WebsocketDocumentUpdatedMessage } from '../data/websocket-document-updated-message' import { WebsocketDocumentUpdatedMessage } from '../data/websocket-document-updated-message'
import { WebsocketDocumentsDeletedMessage } from '../data/websocket-documents-deleted-message' import { WebsocketDocumentsDeletedMessage } from '../data/websocket-documents-deleted-message'
@@ -12,6 +13,7 @@ export enum WebsocketStatusType {
DOCUMENTS_DELETED = 'documents_deleted', DOCUMENTS_DELETED = 'documents_deleted',
DOCUMENT_UPDATED = 'document_updated', DOCUMENT_UPDATED = 'document_updated',
HEARTBEAT = 'heartbeat', HEARTBEAT = 'heartbeat',
BULK_OPERATION_STATUS = 'bulk_operation_status',
} }
// see ProgressStatusOptions in src/documents/plugins/helpers.py // see ProgressStatusOptions in src/documents/plugins/helpers.py
@@ -116,6 +118,13 @@ export class WebsocketStatusService {
private readonly documentDeletedSubject = new Subject<boolean>() private readonly documentDeletedSubject = new Subject<boolean>()
private readonly documentUpdatedSubject = private readonly documentUpdatedSubject =
new Subject<WebsocketDocumentUpdatedMessage>() new Subject<WebsocketDocumentUpdatedMessage>()
private readonly bulkOperationStatusSubject =
new Subject<BulkOperationStatus>()
onBulkOperationStatus() {
return this.bulkOperationStatusSubject.asObservable()
}
private readonly connectionStatusSubject = new Subject<boolean>() private readonly connectionStatusSubject = new Subject<boolean>()
private get(taskId: string, filename?: string) { private get(taskId: string, filename?: string) {
@@ -192,6 +201,7 @@ export class WebsocketStatusService {
| WebsocketProgressMessage | WebsocketProgressMessage
| WebsocketDocumentsDeletedMessage | WebsocketDocumentsDeletedMessage
| WebsocketDocumentUpdatedMessage | WebsocketDocumentUpdatedMessage
| BulkOperationStatus
} = JSON.parse(ev.data) } = JSON.parse(ev.data)
switch (type) { switch (type) {
@@ -209,6 +219,12 @@ export class WebsocketStatusService {
this.handleProgressUpdate(messageData as WebsocketProgressMessage) this.handleProgressUpdate(messageData as WebsocketProgressMessage)
break break
case WebsocketStatusType.BULK_OPERATION_STATUS:
this.bulkOperationStatusSubject.next(
messageData as BulkOperationStatus
)
break
case WebsocketStatusType.HEARTBEAT: case WebsocketStatusType.HEARTBEAT:
// keep-alive from the server, see paperless.consumers.StatusConsumer // keep-alive from the server, see paperless.consumers.StatusConsumer
break break
+6
View File
@@ -691,6 +691,12 @@ table.table {
} }
} }
@media (min-width: 992px) {
.modal-xxl {
--bs-modal-width: min(1440px, calc(100vw - 2 * var(--bs-modal-margin)));
}
}
.toast { .toast {
--bs-toast-max-width: var(--pngx-toast-max-width); --bs-toast-max-width: var(--pngx-toast-max-width);
} }
+1 -2
View File
@@ -1,7 +1,6 @@
from django.conf import settings from django.conf import settings
from django.contrib import admin from django.contrib import admin
from guardian.admin import GuardedModelAdmin from guardian.admin import GuardedModelAdmin
from treenode.admin import TreeNodeModelAdmin
from documents.models import Correspondent from documents.models import Correspondent
from documents.models import CustomField from documents.models import CustomField
@@ -29,7 +28,7 @@ class CorrespondentAdmin(GuardedModelAdmin):
list_editable = ("match", "matching_algorithm") list_editable = ("match", "matching_algorithm")
class TagAdmin(GuardedModelAdmin, TreeNodeModelAdmin): class TagAdmin(GuardedModelAdmin):
list_display = ("name", "color", "match", "matching_algorithm") list_display = ("name", "color", "match", "matching_algorithm")
list_filter = ("matching_algorithm",) list_filter = ("matching_algorithm",)
list_editable = ("color", "match", "matching_algorithm") list_editable = ("color", "match", "matching_algorithm")
+3
View File
@@ -15,6 +15,7 @@ class DocumentsConfig(AppConfig):
from documents.signals.handlers import add_to_index from documents.signals.handlers import add_to_index
from documents.signals.handlers import run_workflows_added from documents.signals.handlers import run_workflows_added
from documents.signals.handlers import run_workflows_updated from documents.signals.handlers import run_workflows_updated
from documents.signals.handlers import run_zone_ocr_extraction
from documents.signals.handlers import send_websocket_document_updated from documents.signals.handlers import send_websocket_document_updated
from documents.signals.handlers import set_correspondent from documents.signals.handlers import set_correspondent
from documents.signals.handlers import set_document_type from documents.signals.handlers import set_document_type
@@ -27,8 +28,10 @@ class DocumentsConfig(AppConfig):
document_consumption_finished.connect(set_tags) document_consumption_finished.connect(set_tags)
document_consumption_finished.connect(set_storage_path) document_consumption_finished.connect(set_storage_path)
document_consumption_finished.connect(run_workflows_added) document_consumption_finished.connect(run_workflows_added)
document_consumption_finished.connect(run_zone_ocr_extraction)
document_consumption_finished.connect(add_to_index) document_consumption_finished.connect(add_to_index)
document_consumption_finished.connect(add_or_update_document_in_llm_index) document_consumption_finished.connect(add_or_update_document_in_llm_index)
document_updated.connect(run_workflows_updated) document_updated.connect(run_workflows_updated)
document_updated.connect(send_websocket_document_updated) document_updated.connect(send_websocket_document_updated)
document_updated.connect(add_or_update_document_in_llm_index) document_updated.connect(add_or_update_document_in_llm_index)
+101 -49
View File
@@ -33,6 +33,7 @@ from documents.models import CustomField
from documents.models import CustomFieldInstance from documents.models import CustomFieldInstance
from documents.models import Document from documents.models import Document
from documents.models import DocumentType from documents.models import DocumentType
from documents.models import OcrTemplate
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
@@ -41,13 +42,16 @@ 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
from documents.tasks import extract_zones
from documents.tasks import remove_document_from_index from documents.tasks import remove_document_from_index
from documents.tasks import run_workflows_for_documents
from documents.tasks import update_document_content_maybe_archive_file from documents.tasks import update_document_content_maybe_archive_file
from documents.versioning import get_latest_version_for_root from documents.versioning import get_latest_version_for_root
from documents.versioning import get_root_document from documents.versioning import get_root_document
from documents.versioning import versions_newest_first from documents.versioning import versions_newest_first
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable
from collections.abc import Mapping from collections.abc import Mapping
from django.contrib.auth.models import User from django.contrib.auth.models import User
@@ -131,9 +135,24 @@ def _scratch_path(name: str) -> Path:
return Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR)) / name return Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR)) / name
def _finish_metadata_edit(
document_ids: list[int],
update_callback: Callable[[list[int]], None] | None,
) -> None:
if update_callback is not None:
update_callback(document_ids)
else:
bulk_update_documents.apply_async(
kwargs={"document_ids": document_ids},
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
)
def set_correspondent( def set_correspondent(
doc_ids: list[int], doc_ids: list[int],
correspondent: Correspondent, correspondent: Correspondent,
*,
update_callback: Callable[[list[int]], None] | None = None,
) -> Literal["OK"]: ) -> Literal["OK"]:
if correspondent: if correspondent:
correspondent = Correspondent.objects.only("pk").get(id=correspondent) correspondent = Correspondent.objects.only("pk").get(id=correspondent)
@@ -146,15 +165,17 @@ def set_correspondent(
affected_docs = list(qs.values_list("pk", flat=True)) affected_docs = list(qs.values_list("pk", flat=True))
qs.update(correspondent=correspondent) qs.update(correspondent=correspondent)
bulk_update_documents.apply_async( _finish_metadata_edit(affected_docs, update_callback)
kwargs={"document_ids": affected_docs},
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
)
return "OK" return "OK"
def set_storage_path(doc_ids: list[int], storage_path: StoragePath) -> Literal["OK"]: def set_storage_path(
doc_ids: list[int],
storage_path: StoragePath,
*,
update_callback: Callable[[list[int]], None] | None = None,
) -> Literal["OK"]:
if storage_path: if storage_path:
storage_path = StoragePath.objects.only("pk").get(id=storage_path) storage_path = StoragePath.objects.only("pk").get(id=storage_path)
@@ -168,15 +189,17 @@ def set_storage_path(doc_ids: list[int], storage_path: StoragePath) -> Literal["
affected_docs = list(qs.values_list("pk", flat=True)) affected_docs = list(qs.values_list("pk", flat=True))
qs.update(storage_path=storage_path) qs.update(storage_path=storage_path)
bulk_update_documents.apply_async( _finish_metadata_edit(affected_docs, update_callback)
kwargs={"document_ids": affected_docs},
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
)
return "OK" return "OK"
def set_document_type(doc_ids: list[int], document_type: DocumentType) -> Literal["OK"]: def set_document_type(
doc_ids: list[int],
document_type: DocumentType,
*,
update_callback: Callable[[list[int]], None] | None = None,
) -> Literal["OK"]:
if document_type: if document_type:
document_type = DocumentType.objects.only("pk").get(id=document_type) document_type = DocumentType.objects.only("pk").get(id=document_type)
@@ -188,15 +211,17 @@ def set_document_type(doc_ids: list[int], document_type: DocumentType) -> Litera
affected_docs = list(qs.values_list("pk", flat=True)) affected_docs = list(qs.values_list("pk", flat=True))
qs.update(document_type=document_type) qs.update(document_type=document_type)
bulk_update_documents.apply_async( _finish_metadata_edit(affected_docs, update_callback)
kwargs={"document_ids": affected_docs},
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
)
return "OK" return "OK"
def add_tag(doc_ids: list[int], tag: int) -> Literal["OK"]: def add_tag(
doc_ids: list[int],
tag: int,
*,
update_callback: Callable[[list[int]], None] | None = None,
) -> Literal["OK"]:
tag_obj = Tag.objects.get(pk=tag) tag_obj = Tag.objects.get(pk=tag)
tags_to_add = [tag_obj, *tag_obj.get_ancestors()] tags_to_add = [tag_obj, *tag_obj.get_ancestors()]
@@ -217,15 +242,17 @@ def add_tag(doc_ids: list[int], tag: int) -> Literal["OK"]:
DocumentTagRelationship.objects.bulk_create(to_create) DocumentTagRelationship.objects.bulk_create(to_create)
if affected_docs: if affected_docs:
bulk_update_documents.apply_async( _finish_metadata_edit(list(affected_docs), update_callback)
kwargs={"document_ids": list(affected_docs)},
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
)
return "OK" return "OK"
def remove_tag(doc_ids: list[int], tag: int) -> Literal["OK"]: def remove_tag(
doc_ids: list[int],
tag: int,
*,
update_callback: Callable[[list[int]], None] | None = None,
) -> Literal["OK"]:
tag_obj = Tag.objects.get(pk=tag) tag_obj = Tag.objects.get(pk=tag)
tag_ids = [tag_obj.id, *tag_obj.get_descendants_pks()] tag_ids = [tag_obj.id, *tag_obj.get_descendants_pks()]
@@ -238,10 +265,7 @@ def remove_tag(doc_ids: list[int], tag: int) -> Literal["OK"]:
qs.delete() qs.delete()
if affected_docs: if affected_docs:
bulk_update_documents.apply_async( _finish_metadata_edit(affected_docs, update_callback)
kwargs={"document_ids": affected_docs},
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
)
return "OK" return "OK"
@@ -250,24 +274,20 @@ def modify_tags(
doc_ids: list[int], doc_ids: list[int],
add_tags: list[int], add_tags: list[int],
remove_tags: list[int], remove_tags: list[int],
*,
update_callback: Callable[[list[int]], None] | None = None,
) -> Literal["OK"]: ) -> Literal["OK"]:
qs = Document.objects.filter(id__in=doc_ids).only("pk") qs = Document.objects.filter(id__in=doc_ids).only("pk")
affected_docs = list(qs.values_list("pk", flat=True)) affected_docs = list(qs.values_list("pk", flat=True))
DocumentTagRelationship = Document.tags.through DocumentTagRelationship = Document.tags.through
# add with all ancestors hierarchy = Tag.get_hierarchy()
expanded_add_tags: set[int] = set() expanded_add_tags = hierarchy.with_ancestors(
add_tag_objects = Tag.objects.filter(pk__in=add_tags) Tag.objects.filter(pk__in=add_tags).values_list("pk", flat=True),
for t in add_tag_objects: )
expanded_add_tags.add(int(t.id)) expanded_remove_tags = hierarchy.with_descendants(
expanded_add_tags.update(int(pk) for pk in t.get_ancestors_pks()) Tag.objects.filter(pk__in=remove_tags).values_list("pk", flat=True),
)
# remove with all descendants
expanded_remove_tags: set[int] = set()
remove_tag_objects = Tag.objects.filter(pk__in=remove_tags)
for t in remove_tag_objects:
expanded_remove_tags.add(int(t.id))
expanded_remove_tags.update(int(pk) for pk in t.get_descendants_pks())
with transaction.atomic(): with transaction.atomic():
if expanded_remove_tags: if expanded_remove_tags:
@@ -299,10 +319,7 @@ def modify_tags(
) )
if affected_docs: if affected_docs:
bulk_update_documents.apply_async( _finish_metadata_edit(affected_docs, update_callback)
kwargs={"document_ids": affected_docs},
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
)
return "OK" return "OK"
@@ -311,6 +328,8 @@ def modify_custom_fields(
doc_ids: list[int], doc_ids: list[int],
add_custom_fields: list[int] | dict, add_custom_fields: list[int] | dict,
remove_custom_fields: list[int], remove_custom_fields: list[int],
*,
update_callback: Callable[[list[int]], None] | None = None,
) -> Literal["OK"]: ) -> Literal["OK"]:
qs = Document.objects.filter(id__in=doc_ids).only("pk") qs = Document.objects.filter(id__in=doc_ids).only("pk")
affected_docs = list(qs.values_list("pk", flat=True)) affected_docs = list(qs.values_list("pk", flat=True))
@@ -373,10 +392,7 @@ def modify_custom_fields(
field_id__in=remove_custom_fields, field_id__in=remove_custom_fields,
).hard_delete() ).hard_delete()
bulk_update_documents.apply_async( _finish_metadata_edit(affected_docs, update_callback)
kwargs={"document_ids": affected_docs},
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
)
return "OK" return "OK"
@@ -451,12 +467,32 @@ def reprocess(doc_ids: list[int], *, remote_ocr: bool = False) -> Literal["OK"]:
return "OK" return "OK"
def run_workflow(
doc_ids: list[int],
workflow: int,
*,
filter_documents: bool = True,
) -> Literal["OK"]:
"""
Manually run a single workflow against the given documents.
Done asynchronously via a Celery task.
"""
run_workflows_for_documents.delay(
document_ids=doc_ids,
workflow_id=workflow,
filter_documents=filter_documents,
)
return "OK"
def set_permissions( def set_permissions(
doc_ids: list[int], doc_ids: list[int],
set_permissions: PermissionsSpec, set_permissions: PermissionsSpec,
*, *,
owner: User | None = None, owner: User | None = None,
merge: bool = False, merge: bool = False,
update_callback: Callable[[list[int]], None] | None = None,
) -> Literal["OK"]: ) -> Literal["OK"]:
qs = Document.objects.filter(id__in=doc_ids).select_related("owner") qs = Document.objects.filter(id__in=doc_ids).select_related("owner")
@@ -474,10 +510,7 @@ def set_permissions(
merge=merge, merge=merge,
) )
bulk_update_documents.apply_async( _finish_metadata_edit(affected_docs, update_callback)
kwargs={"document_ids": affected_docs},
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
)
return "OK" return "OK"
@@ -1197,3 +1230,22 @@ def remove_doclink(
target_doc_field_instance.value.remove(document.id) target_doc_field_instance.value.remove(document.id)
target_doc_field_instance.save() target_doc_field_instance.save()
Document.objects.filter(id=target_doc_id).update(modified=timezone.now()) Document.objects.filter(id=target_doc_id).update(modified=timezone.now())
def run_zone_ocr(
doc_ids: list[int],
*,
template: OcrTemplate | None = None,
) -> Literal["OK"]:
"""
Queue zone OCR for the given documents, with the given template or with the
templates that match each document's content when its task runs.
"""
template_id = template.pk if template is not None else None
for document_id in doc_ids:
extract_zones.apply_async(
kwargs={"document_id": document_id, "template_id": template_id},
headers={"trigger_source": PaperlessTask.TriggerSource.MANUAL},
)
return "OK"
Loaded 100 of 163 files, more files were not shown because too many files have changed in this diff. Show more