Compare commits

...
50 Commits
Author SHA1 Message Date
shamoon aad70575b7 Tweak/fix: auto-close CF dropdown 2026-11-13 10:10:06 -08:00
shamoon 694619c33e Fix: add missing css from notes markdown PR 2026-11-13 09:57:16 -08:00
github-actions[bot]andCrowdin Bot 1f2faaec86 New Crowdin translations by GitHub Action (#14683)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-11-13 09:36:19 -08:00
GitHub Actions 6b15d625ed Auto translate strings 2026-11-13 17:10:14 +00: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
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
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
223 changed files with 52274 additions and 39126 deletions

No files matched your search

@@ -1,5 +1,23 @@
title: "[Feature Request] "
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
id: description
attributes:
+16
View File
@@ -1,5 +1,21 @@
title: "[Support] "
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
id: description
attributes:
+34 -34
View File
@@ -1,53 +1,53 @@
name: Install apt packages
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:
packages:
description: Space separated list of packages to install
description: Space separated list of package names to install
required: true
attempts:
description: Maximum number of attempts
description: Maximum number of download attempts
default: "3"
attempt-timeout:
description: Seconds before a single attempt is killed
description: Seconds before a single download attempt is killed
default: "180"
runs:
using: composite
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 }}
shell: bash
env:
PACKAGES: ${{ inputs.packages }}
ATTEMPTS: ${{ inputs.attempts }}
ATTEMPT_TIMEOUT: ${{ inputs.attempt-timeout }}
run: |
SUDO=""
if [ "$(id -u)" -ne 0 ]; then
SUDO="sudo"
fi
attempt_install() {
$SUDO apt-get update -q
# Intentionally unquoted, this is a list of package names
$SUDO apt-get install -q --yes --no-install-recommends $PACKAGES
}
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
CACHE_DIR: ${{ runner.temp }}/apt-packages
run: bash "$GITHUB_ACTION_PATH/install.sh"
# Save immediately: a later test failure must not discard downloaded packages.
- name: Save apt packages
if: steps.cache.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
${{ runner.temp }}/apt-packages/*.deb
${{ runner.temp }}/apt-packages/.keep
key: ${{ steps.cache-key.outputs.key }}
+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'
- 'docker/compose/docker-compose.ci-test.yml'
- '.github/workflows/ci-backend.yml'
- '.github/actions/apt-install/**'
test:
needs: changes
if: needs.changes.outputs.backend_changed == 'true'
@@ -99,11 +100,11 @@ jobs:
docker compose --file docker/compose/docker-compose.ci-test.yml up --detach
- name: Set up Python
id: setup-python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: "${{ matrix.python-version }}"
- name: Install uv
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
@@ -169,11 +170,11 @@ jobs:
persist-credentials: false
- name: Set up Python
id: setup-python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: "${{ env.DEFAULT_PYTHON }}"
- name: Install uv
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
+6 -6
View File
@@ -109,7 +109,7 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -155,7 +155,7 @@ jobs:
echo "${digest}" > "/tmp/digests/digest-${{ matrix.arch }}.txt"
- name: Upload digest
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:
name: digests-${{ matrix.arch }}
path: /tmp/digests/digest-${{ matrix.arch }}.txt
@@ -173,7 +173,7 @@ jobs:
packages: write
steps:
- name: Download digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
path: /tmp/digests
pattern: digest-*.txt
@@ -185,20 +185,20 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
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:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to Quay.io
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:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
+2 -2
View File
@@ -75,11 +75,11 @@ jobs:
persist-credentials: false
- name: Set up Python
id: setup-python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
+8 -7
View File
@@ -69,6 +69,7 @@ jobs:
frontend:
- 'src-ui/**'
- '.github/workflows/ci-frontend.yml'
- '.github/actions/apt-install/**'
install-dependencies:
needs: changes
if: needs.changes.outputs.frontend_changed == 'true'
@@ -86,7 +87,7 @@ jobs:
with:
package_json_file: src-ui/package.json
- name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24.x
cache: 'pnpm'
@@ -118,7 +119,7 @@ jobs:
with:
package_json_file: src-ui/package.json
- name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24.x
cache: 'pnpm'
@@ -157,7 +158,7 @@ jobs:
with:
package_json_file: src-ui/package.json
- name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24.x
cache: 'pnpm'
@@ -206,18 +207,18 @@ jobs:
with:
package_json_file: src-ui/package.json
- name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24.x
cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Set up Python
id: setup-python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: '3.12'
- name: Install uv
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
with:
version: '0.13.x'
enable-cache: false
@@ -261,7 +262,7 @@ jobs:
with:
package_json_file: src-ui/package.json
- name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24.x
cache: 'pnpm'
+2 -2
View File
@@ -22,8 +22,8 @@ jobs:
with:
persist-credentials: false
- name: Install Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: "3.14"
- 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:
package_json_file: src-ui/package.json
- name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24.x
package-manager-cache: false
@@ -55,11 +55,11 @@ jobs:
# ---- Backend Setup ----
- name: Set up Python
id: setup-python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: false
@@ -137,7 +137,7 @@ jobs:
sudo chown -R 1000:1000 paperless-ngx/
tar -cJf paperless-ngx.tar.xz paperless-ngx/
- name: Upload release artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release
path: dist/paperless-ngx.tar.xz
@@ -155,7 +155,7 @@ jobs:
version: ${{ steps.get-version.outputs.version }}
steps:
- name: Download release artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release
path: ./
@@ -172,7 +172,7 @@ jobs:
fi
- name: Create release and changelog
id: create-release
uses: release-drafter/release-drafter@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7.7.0
uses: release-drafter/release-drafter@978bb0b30c5ee8cdd332ced253758157150d43f0 # v7.10.0
with:
name: Paperless-ngx ${{ steps.get-version.outputs.version }}
tag: ${{ steps.get-version.outputs.version }}
@@ -183,7 +183,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- 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:
github_token: ${{ secrets.GITHUB_TOKEN }}
upload_url: ${{ steps.create-release.outputs.upload_url }}
@@ -209,11 +209,11 @@ jobs:
persist-credentials: true # for pushing changelog branch
- name: Set up Python
id: setup-python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: false
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
with:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
uses: zizmorcore/zizmor-action@53290badfa369cb0bf7ad16342cb62c2fa47ac7b # v0.8.0
semgrep:
name: Semgrep CE
runs-on: ubuntu-26.04
@@ -45,7 +45,7 @@ jobs:
- name: Run Semgrep
run: semgrep scan --config auto --sarif-output results.sarif
- 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()
with:
sarif_file: results.sarif
+2 -2
View File
@@ -29,7 +29,7 @@ jobs:
steps:
- name: Clean temporary images
if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/ephemeral@21f875bab2376314e0525c614e923c669434f0e4 # v0.13.0
uses: stumpylog/image-cleaner-action/ephemeral@9796867b2a7994f0a3b6b1f2e38a6579f502c977 # v0.14.0
with:
token: "${{ env.TOKEN }}"
owner: "${{ github.repository_owner }}"
@@ -56,7 +56,7 @@ jobs:
steps:
- name: Clean untagged images
if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/untagged@21f875bab2376314e0525c614e923c669434f0e4 # v0.13.0
uses: stumpylog/image-cleaner-action/untagged@9796867b2a7994f0a3b6b1f2e38a6579f502c977 # v0.14.0
with:
token: "${{ env.TOKEN }}"
owner: "${{ github.repository_owner }}"
+2 -2
View File
@@ -39,7 +39,7 @@ jobs:
persist-credentials: false
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
languages: ${{ matrix.language }}
# 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.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
- 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 }}
persist-credentials: false
- name: crowdin action
uses: crowdin/github-action@9c23991700c0ec5256fd41089b9d9d7d540e424e # v3.3.0
uses: crowdin/github-action@959dc74ac104447731cba99a016f85d0ae719788 # v3.5.0
with:
upload_translations: false
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'
steps:
- 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:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+4 -4
View File
@@ -23,14 +23,14 @@ jobs:
persist-credentials: true # for pushing translation branch
- name: Set up Python
id: setup-python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
- name: Install system dependencies
timeout-minutes: 12
uses: $/.github/actions/apt-install
with:
packages: gettext
- name: Install uv
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
@@ -48,7 +48,7 @@ jobs:
with:
package_json_file: src-ui/package.json
- name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24.x
cache: 'pnpm'
@@ -68,7 +68,7 @@ jobs:
cd src-ui
pnpm run ng extract-i18n
- name: Commit changes
uses: stefanzweifel/git-auto-commit-action@4a55954c782fc1ea30b9056cd3e7a2b40ca8887d # v7.2.0
uses: stefanzweifel/git-auto-commit-action@d0b01107b5db882071f96de5dd4b54961ea93f4c # v7.2.1
with:
file_pattern: 'src-ui/messages.xlf src/locale/en_US/LC_MESSAGES/django.po'
commit_message: "Auto translate strings"
+4 -4
View File
@@ -38,7 +38,7 @@ repos:
- json
# See https://github.com/prettier/prettier/issues/15742 for the fork reason
- repo: https://github.com/rbubley/mirrors-prettier
rev: 'v3.9.9'
rev: 'v3.9.10'
hooks:
- id: prettier
types_or:
@@ -46,16 +46,16 @@ repos:
- ts
- markdown
additional_dependencies:
- prettier@3.9.9
- prettier@3.9.10
- 'prettier-plugin-organize-imports@4.3.0'
# Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.9
rev: v0.17.2
hooks:
- id: ruff-check
- id: ruff-format
- repo: https://github.com/tox-dev/pyproject-fmt
rev: "v2.29.4"
rev: "v2.30.3"
hooks:
- id: pyproject-fmt
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.
## 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:
- 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.
2. AI-generated code must not violate copyright protections.
3. We will not accept PRs that are entirely or mostly AI-derived.
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.
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.
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. We will not accept PRs that are entirely or mostly AI-derived.
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
+1 -1
View File
@@ -30,7 +30,7 @@ RUN set -eux \
# Purpose: Installs s6-overlay and rootfs
# Comments:
# - 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
+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:
- 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
- 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
@@ -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
- missing limits or hardening settings presented without concrete impact
- generic AI or static-analysis output that is not confirmed against the current codebase and a real deployment scenario
- metadata names visible in a document's custom storage path, even when the user cannot access the underlying metadata object; this is expected behavior
- 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
## Transparency
@@ -39,7 +39,6 @@ declare -a scope=(
"${site_packages}/allauth"
"${site_packages}/drf_spectacular"
"${site_packages}/drf_spectacular_sidecar"
"${site_packages}/treenode"
"${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
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
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}
: If you're planning on putting Paperless on the open internet, then
you really should set this value to the domain name you're using.
Failing to do so leaves you open to HTTP host header attacks.
: If you expose Paperless to the internet, set `PAPERLESS_URL` to its public URL
or list its public hostname in `PAPERLESS_ALLOWED_HOSTS` to help protect against
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)
Just remember that this is a comma-separated list, so
"example.com" is fine, as is "example.com,www.example.com", but
NOT " example.com" or "example.com,"
Separate multiple hostnames with commas, as in `example.com,www.example.com`.
Can also be set using PAPERLESS_URL (see above).
"localhost" is always allowed for docker healthcheck
Defaults to "\*", which is all hosts.
- With neither setting configured, Paperless accepts any host.
- With only `PAPERLESS_URL` set, Paperless accepts the hostname from that URL
and `localhost` (used by the Docker health check).
- `PAPERLESS_ALLOWED_HOSTS` adds hosts to `localhost` and the hostname from
`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}
@@ -873,6 +873,14 @@ If both the [PAPERLESS_ACCOUNT_DEFAULT_GROUPS](#PAPERLESS_ACCOUNT_DEFAULT_GROUPS
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}
: 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,
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
booleans and double quoted parameter names and strings. Examples:
+30 -5
View File
@@ -493,7 +493,7 @@ fields and permissions, which will be merged.
#### 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
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
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).
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
flowchart TD
@@ -526,6 +528,10 @@ flowchart TD
matching
trigger(s)"}
manual{"Matching
'Manual'
trigger(s)"}
A[New Document] --> consumption
consumption --> |Yes| C[Workflow Actions Run]
consumption --> |No| D
@@ -543,6 +549,10 @@ flowchart TD
scheduled --> |Yes| N[Workflow Actions Run]
scheduled --> |No| O[Document Saved]
N --> O
P[User Runs Workflow] --> manual
manual --> |Yes| Q[Workflow Actions Run]
manual --> |No| R[Document Saved]
Q --> R
```
#### 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
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.
- 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.
- 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.
- 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
#### 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
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
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.
+5 -11
View File
@@ -27,7 +27,7 @@ dependencies = [
# WARNING: django does not use semver.
# Only patch versions are guaranteed to not introduce breaking changes.
"django~=5.2.18",
"django-allauth[mfa,socialaccount]~=65.20.0",
"django-allauth[mfa,socialaccount]~=65.21.1",
"django-auditlog~=3.4.1",
"django-cachalot~=2.9.0",
"django-compression-middleware~=0.5.0",
@@ -38,7 +38,6 @@ dependencies = [
"django-multiselectfield~=1.0.1",
"django-rich~=2.2.0",
"django-soft-delete~=1.0.18",
"django-treenode>=0.24",
"djangorestframework~=3.16",
"drf-spectacular~=0.30",
"drf-spectacular-sidecar>=2026.10.1,<2026.11",
@@ -112,14 +111,14 @@ lint = [
testing = [
"daphne",
"factory-boy~=3.3.1",
"faker>=40.36,<40.40",
"faker>=40.36,<40.44",
"imagehash",
"pytest~=9.1.1",
"pytest-cov~=7.1.0",
"pytest-django>=4.12,<4.15",
"pytest-env~=1.7.0",
"pytest-env~=1.8.1",
"pytest-httpx",
"pytest-mock~=3.15.1",
"pytest-mock~=3.17.0",
"pytest-randomly~=5.0.0",
"pytest-rerunfailures~=16.4",
"pytest-sugar",
@@ -213,12 +212,7 @@ extend-select = [
"UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up
"W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
]
ignore = [
"DJ001",
"PLC0415",
"RUF012",
"SIM105",
]
ignore = [ "DJ001", "ICN001", "PLC0415", "RUF012", "SIM105" ]
# Migrations
per-file-ignores."*/migrations/*.py" = [
"E501",
+469 -304
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -37,7 +37,7 @@ export const routes: Routes = [
path: 'dashboard',
component: DashboardComponent,
data: {
componentName: 'AppFrameComponent',
componentName: 'DashboardComponent',
},
},
{
@@ -7,10 +7,15 @@ import {
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule } from '@angular/forms'
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 { 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 {
PaperlessTask,
@@ -159,6 +164,7 @@ const sectionCountResponse = {
}
describe('TasksComponent', () => {
let queryParams: BehaviorSubject<ParamMap>
let component: TasksComponent
let fixture: ComponentFixture<TasksComponent>
let tasksService: TasksService
@@ -169,6 +175,7 @@ describe('TasksComponent', () => {
let toastService: ToastService
beforeEach(async () => {
queryParams = new BehaviorSubject(convertToParamMap({}))
TestBed.configureTestingModule({
imports: [
NgbModule,
@@ -181,6 +188,17 @@ describe('TasksComponent', () => {
ConfirmDialogComponent,
],
providers: [
{
provide: ActivatedRoute,
useValue: {
queryParamMap: queryParams.asObservable(),
snapshot: {
get queryParamMap() {
return queryParams.value
},
},
},
},
{
provide: PermissionsService,
useValue: {
@@ -219,7 +237,11 @@ describe('TasksComponent', () => {
})
// 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
.match(
(req) =>
@@ -228,7 +250,9 @@ describe('TasksComponent', () => {
)
.forEach((req) => {
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 })
})
}
@@ -238,6 +262,71 @@ describe('TasksComponent', () => {
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', () => {
expect(component.selectedSection).toBe(TaskSection.All)
expect(component.selectedTaskType).toBeNull()
@@ -771,6 +860,7 @@ describe('TasksComponent', () => {
input.nativeElement.value = '191092'
input.nativeElement.dispatchEvent(new Event('input'))
jest.advanceTimersByTime(150)
flushTaskLists(10, undefined, [tasks[1]])
fixture.detectChanges()
expect(component.filterText).toEqual('191092')
expect(component.tasksForSection(TaskSection.NeedsAttention)).toHaveLength(
@@ -782,6 +872,7 @@ describe('TasksComponent', () => {
component.setSection(TaskSection.InProgress)
component.filterText = 'system'
jest.advanceTimersByTime(150)
flushTaskLists(25, undefined, [tasks[6]])
expect(component.tasksForSection(TaskSection.InProgress)).toHaveLength(1)
expect(component.tasksForSection(TaskSection.InProgress)[0].task_type).toBe(
@@ -815,6 +906,7 @@ describe('TasksComponent', () => {
input.nativeElement.value = 'duplicate'
input.nativeElement.dispatchEvent(new Event('input'))
jest.advanceTimersByTime(150)
flushTaskLists(25, undefined, [tasks[0], tasks[1]])
fixture.detectChanges()
expect(component.filterText).toEqual('duplicate')
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', () => {
expect(
component.taskResultMessage({
@@ -1,7 +1,7 @@
import { JsonPipe, NgTemplateOutlet } from '@angular/common'
import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { Router, RouterLink } from '@angular/router'
import { ActivatedRoute, Router, RouterLink } from '@angular/router'
import {
NgbCollapseModule,
NgbDropdownModule,
@@ -47,11 +47,13 @@ export enum TaskSection {
export enum TaskFilterTargetID {
Name,
Result,
TaskId,
}
const FILTER_TARGETS = [
{ id: TaskFilterTargetID.Name, name: $localize`Name` },
{ id: TaskFilterTargetID.Result, name: $localize`Result` },
{ id: TaskFilterTargetID.TaskId, name: $localize`Task ID` },
]
const SECTION_LABELS = {
@@ -161,6 +163,7 @@ export class TasksComponent
tasksService = inject(TasksService)
private modalService = inject(NgbModal)
private readonly router = inject(Router)
private readonly route = inject(ActivatedRoute)
private readonly toastService = inject(ToastService)
readonly TaskSection = TaskSection
@@ -275,6 +278,17 @@ export class TasksComponent
}
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.reloadPage()
timer(5000, 5000)
@@ -636,17 +650,12 @@ export class TasksComponent
const query = this._filterText.toLowerCase()
if (this.filterTargetID == TaskFilterTargetID.Name) {
return [
this.taskDisplayName(task),
task.task_type_display,
task.trigger_source_display,
]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(query))
if (this.filterTargetID === TaskFilterTargetID.TaskId) {
return task.task_id.toLowerCase() === query
}
return this.taskResultMessage(task)?.toLowerCase().includes(query) ?? false
// Text filtering now handled API-side
return true
}
private tasksForOptionCounts({
@@ -706,9 +715,15 @@ export class TasksComponent
}
if (this._filterText.length) {
params[
this.filterTargetID === TaskFilterTargetID.Name ? 'name' : 'result'
] = this._filterText
const parameter = {
[TaskFilterTargetID.Name]: 'name',
[TaskFilterTargetID.Result]: 'result',
[TaskFilterTargetID.TaskId]: 'task_id',
}[this.filterTargetID]
params[parameter] = this._filterText
if (this.filterTargetID === TaskFilterTargetID.TaskId) {
delete params.acknowledged
}
}
return params
@@ -11,8 +11,10 @@ import { ActivatedRoute, Router } from '@angular/router'
import { RouterTestingModule } from '@angular/router/testing'
import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/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 { BulkOperationStatus } from 'src/app/data/bulk-operation'
import { PaperlessTaskStatus } from 'src/app/data/paperless-task'
import { SavedView } from 'src/app/data/saved-view'
import { HideableSidebarItemID, SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
@@ -21,10 +23,11 @@ import {
DjangoMessageLevel,
DjangoMessagesService,
} 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 {
PermissionType,
PermissionsService,
PermissionType,
} from 'src/app/services/permissions.service'
import { RemoteVersionService } from 'src/app/services/rest/remote-version.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
@@ -95,6 +98,7 @@ describe('AppFrameComponent', () => {
let savedViewSpy
let modalService: NgbModal
let maybeRefreshSpy
let bulkEvents: Subject<BulkOperationStatus>
let tasksService: TasksService
beforeEach(async () => {
@@ -182,6 +186,10 @@ describe('AppFrameComponent', () => {
savedViewSpy = jest.spyOn(savedViewService, 'reload')
maybeRefreshSpy = jest.spyOn(savedViewService, 'maybeRefreshDocumentCounts')
bulkEvents = new Subject<BulkOperationStatus>()
jest
.spyOn(TestBed.inject(WebsocketStatusService), 'onBulkOperationStatus')
.mockReturnValue(bulkEvents.asObservable())
fixture = TestBed.createComponent(AppFrameComponent)
component = fixture.componentInstance
@@ -190,6 +198,78 @@ describe('AppFrameComponent', () => {
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', () => {
expect(savedViewSpy).toHaveBeenCalled()
})
@@ -8,12 +8,14 @@ import {
import { NgClass } from '@angular/common'
import {
Component,
DestroyRef,
HostListener,
inject,
OnInit,
signal,
viewChild,
} from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { FormsModule } from '@angular/forms'
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
import {
@@ -24,10 +26,11 @@ import {
NgbPopoverModule,
} from '@ng-bootstrap/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 { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
import { Document } from 'src/app/data/document'
import { PaperlessTaskStatus } from 'src/app/data/paperless-task'
import { SavedView } from 'src/app/data/saved-view'
import {
CollapsibleSection,
@@ -41,6 +44,7 @@ import {
DjangoMessageLevel,
DjangoMessagesService,
} 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 {
PermissionAction,
@@ -101,6 +105,8 @@ export class AppFrameComponent
extends ComponentWithPermissions
implements OnInit, ComponentCanDeactivate
{
private destroyRef = inject(DestroyRef)
private documentList = inject(DocumentListViewService)
router = inject(Router)
private activatedRoute = inject(ActivatedRoute)
private openDocumentsService = inject(OpenDocumentsService)
@@ -164,6 +170,46 @@ export class AppFrameComponent
}
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.detectClassicScrollbars()
@@ -42,11 +42,16 @@
<span class="text-break">{{ message.content }}</span>
}
@if (message.role === 'assistant' && !message.isStreaming && message.references?.length) {
<div class="chat-references list-group mt-3">
<div class="list-group-item text-muted small py-1" i18n>Sources</div>
@for (reference of message.references; track reference.id) {
<a class="list-group-item list-group-item-action d-flex align-items-center text-primary" [routerLink]="['/documents', reference.id]" [title]="reference.title">
<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>
<div class="chat-references mt-2 pt-2 border-top">
<div class="d-flex align-items-center text-muted mb-1">
<span i18n>Sources</span>
@if (hiddenReferenceCount(message) > 0 && !expandedReferences().has(message)) {
<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>
}
</div>
@@ -12,6 +12,10 @@
display: none;
}
.message .flex-grow-1 {
min-width: 0;
}
.dropdown-item {
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 {
ChatMessage,
ChatReference,
ChatService,
parseChatResponse,
} from 'src/app/services/chat.service'
@@ -40,8 +41,29 @@ export class ChatComponent implements OnInit {
readonly input = signal('')
readonly documentId = signal<number>(undefined)
readonly slowResponse = signal(false)
readonly expandedReferences = signal<Set<ChatMessage>>(new Set())
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 router: Router = inject(Router)
@@ -118,6 +118,7 @@ import personFill from 'bootstrap-icons/icons/person-fill.svg'
import personLock from 'bootstrap-icons/icons/person-lock.svg'
import personSquare from 'bootstrap-icons/icons/person-square.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 plusCircle from 'bootstrap-icons/icons/plus-circle.svg'
import plus from 'bootstrap-icons/icons/plus.svg'
@@ -279,6 +280,7 @@ export const icons: Record<string, string> = {
personFillLock,
personLock,
personSquare,
playCircle,
playFill,
plus,
plusCircle,
@@ -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
}
}
@@ -4,6 +4,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { By } from '@angular/platform-browser'
import {
NgbDropdown,
NgbDropdownModule,
NgbModal,
NgbModalModule,
@@ -250,4 +251,14 @@ describe('CustomFieldsDropdownComponent', () => {
expect(createFieldSpy).not.toHaveBeenCalled()
expect(addFieldSpy).not.toHaveBeenCalled()
})
it('should close the dropdown after selecting a filtered field', () => {
const dropdown = fixture.debugElement
.query(By.directive(NgbDropdown))
.injector.get(NgbDropdown)
dropdown.open()
component.filterText = 'Field 1'
component.listFilterEnter()
expect(dropdown.isOpen()).toBe(false)
})
})
@@ -11,7 +11,11 @@ import {
signal,
} from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
import {
NgbDropdown,
NgbDropdownModule,
NgbModal,
} from '@ng-bootstrap/ng-bootstrap'
import { first, takeUntil } from 'rxjs'
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
import { CustomField, DATA_TYPE_LABELS } from 'src/app/data/custom-field'
@@ -63,6 +67,7 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
created: EventEmitter<CustomField> = new EventEmitter()
@ViewChild('listFilterTextInput') listFilterTextInput: ElementRef
@ViewChild('fieldDropdown') fieldDropdown: NgbDropdown
@ViewChildren('button') buttons: QueryList<ElementRef>
private customFields: CustomField[] = []
@@ -120,6 +125,7 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
addField(field: CustomField) {
this.added.emit(field)
this.fieldDropdown.close()
this.updateUnusedFields()
}
@@ -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-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>
@if (matchingPatternRequired(formGroup)) {
<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>
@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="col">
<div class="trigger-filters mb-3">
@@ -123,6 +123,10 @@ export const WORKFLOW_TYPE_OPTIONS = [
id: WorkflowTriggerType.Scheduled,
name: $localize`Scheduled`,
},
{
id: WorkflowTriggerType.Manual,
name: $localize`Manual`,
},
]
export const WORKFLOW_ACTION_OPTIONS = [
@@ -36,7 +36,7 @@
[appendTo]="appendTo"
[loading]="loading()"
[typeahead]="documentsInput$"
(mousedown)="$event.stopImmediatePropagation()"
(mousedown)="appendTo && $event.stopImmediatePropagation()"
(change)="onChange(selectedDocumentIDs)">
<ng-template ng-label-tmp let-document="item">
<div class="d-flex align-items-center">
@@ -97,6 +97,7 @@ describe('SelectComponent', () => {
let createNewVal
component.createNew.subscribe((v) => (createNewVal = v))
expect(component.allowCreateNew).toBeTruthy()
const closeSpy = jest.spyOn(component.ngSelectComponent, 'close')
component.onSearch({ term: 'foo' })
component.addItem(undefined)
expect(createNewVal).toEqual('foo')
@@ -105,6 +106,8 @@ describe('SelectComponent', () => {
component.onSearch({ term: 'baz' })
component.clickNew()
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', () => {
@@ -4,6 +4,7 @@ import {
forwardRef,
Input,
Output,
ViewChild,
} from '@angular/core'
import {
FormsModule,
@@ -11,7 +12,7 @@ import {
ReactiveFormsModule,
} from '@angular/forms'
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 { matchesSearchText } from 'src/app/utils/text-search'
import { AbstractInputComponent } from '../abstract-input'
@@ -124,6 +125,9 @@ export class SelectComponent extends AbstractInputComponent<number | string> {
@Output()
filterDocuments = new EventEmitter<any[]>()
@ViewChild(NgSelectComponent)
ngSelectComponent: NgSelectComponent
public addItemRef: (name) => void
private _lastSearchTerm: string
@@ -150,11 +154,13 @@ export class SelectComponent extends AbstractInputComponent<number | string> {
if (name) this.createNew.next(name)
else this.createNew.next(this._lastSearchTerm)
this.clearLastSearchTerm()
this.ngSelectComponent?.close()
}
clickNew() {
this.createNew.next(this._lastSearchTerm)
this.clearLastSearchTerm()
this.ngSelectComponent?.close()
}
clearLastSearchTerm() {
@@ -1,16 +1,18 @@
<div class="row pt-3 pb-2 align-items-center">
<div class="col-md text-truncate">
<h3 class="d-flex align-items-center mb-2 mb-md-1" style="line-height: 1.4">
<span class="text-truncate">{{title()}}</span>
@if (id()) {
<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 (copied()) {
<pngx-bs-icon width="1em" height="1em" name="clipboard-check" class="me-1"></pngx-bs-icon><ng-container i18n>Copied!</ng-container>
} @else {
ID: {{id()}}
}
</button>
}
<span class="d-flex align-items-center mw-100">
<span class="text-truncate">{{title()}}</span>
@if (id()) {
<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>
@if (copied()) {
<pngx-bs-icon width="1em" height="1em" name="clipboard-check" class="me-1"></pngx-bs-icon><ng-container i18n>Copied!</ng-container>
} @else {
ID: {{id()}}
}
</button>
}
</span>
@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>
}
@@ -62,13 +62,13 @@
} @else {
@switch (field) {
@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) {
<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) {
<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) {
@if (doc.correspondent) {
@@ -106,7 +106,7 @@
}
@case (DisplayField.NOTES) {
@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">
<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>
@@ -150,6 +150,7 @@
<pngx-document-card-small
class="p-0"
[ngStyle]="{ opacity: show() ? 1 : 1 - (i * 1/documents().length) }"
(click)="activateViewForNavigation()"
(dblClickDocument)="openDocumentDetail(d)"
[document]="d"
[displayFields]="displayFields()"
@@ -173,6 +174,7 @@
}
@for (d of documents(); track d.id; let i = $index) {
<pngx-document-card-large
(click)="activateViewForNavigation()"
(dblClickDocument)="openDocumentDetail(d)"
[document]="d"
[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 { PreviewPopupComponent } from 'src/app/components/common/preview-popup/preview-popup.component'
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 {
FILTER_CORRESPONDENT,
FILTER_DOCUMENT_TYPE,
@@ -277,10 +281,40 @@ describe('SavedViewWidgetComponent', () => {
it('should navigate to document', () => {
const routerSpy = jest.spyOn(router, 'navigate')
const activateSpy = jest.spyOn(
documentListViewService,
'activateSavedViewWithDocuments'
)
component.openDocumentDetail(documentResults[0])
expect(activateSpy).toHaveBeenCalled()
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', () => {
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
component.clickTag(11, new MouseEvent('click'))
@@ -132,7 +132,7 @@ export class SavedViewWidgetComponent
ngOnInit(): void {
this.placeholderRows = Array.from(
{
length: this.savedView?.page_size ?? DEFAULT_DASHBOARD_VIEW_PAGE_SIZE,
length: this.pageSize,
},
(_, index) => index
)
@@ -187,7 +187,7 @@ export class SavedViewWidgetComponent
this.documentService
.listFiltered(
1,
this.savedView?.page_size ?? DEFAULT_DASHBOARD_VIEW_PAGE_SIZE,
this.pageSize,
this.savedView.sort_field,
this.savedView.sort_reverse,
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) {
this.activateViewForNavigation()
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>
</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">
<pngx-bs-icon width="1em" height="1em" name="printer" class="me-2"></pngx-bs-icon><span i18n>Print</span>
</button>
@@ -940,6 +940,20 @@ describe('DocumentDetailComponent', () => {
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', () => {
initNormally()
jest.spyOn(openDocumentsService, 'closeDocument').mockReturnValue(of(false))
@@ -1016,6 +1030,27 @@ describe('DocumentDetailComponent', () => {
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', () => {
initNormally()
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
@@ -68,6 +68,8 @@ import { StoragePath } from 'src/app/data/storage-path'
import { Tag } from 'src/app/data/tag'
import { SETTINGS_KEYS, SuggestionSource } from 'src/app/data/ui-settings'
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 { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
@@ -93,6 +95,7 @@ import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { TagService } from 'src/app/services/rest/tag.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 { ToastService } from 'src/app/services/toast.service'
import { TrashService } from 'src/app/services/trash.service'
@@ -104,6 +107,7 @@ import { DocumentDetailFieldID } from '../admin/settings/settings.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 { 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 { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.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'
@@ -233,6 +237,7 @@ export class DocumentDetailComponent
private componentRouterService = inject(ComponentRouterService)
private deviceDetectorService = inject(DeviceDetectorService)
private savedViewService = inject(SavedViewService)
private readonly workflowService = inject(WorkflowService)
private readonly websocketStatusService = inject(WebsocketStatusService)
private readonly useNativePdfViewerSetting = this.settings.getSignal<boolean>(
SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER
@@ -295,6 +300,7 @@ export class DocumentDetailComponent
readonly correspondents = signal<Correspondent[]>(undefined)
readonly documentTypes = signal<DocumentType[]>(undefined)
readonly storagePaths = signal<StoragePath[]>(undefined)
readonly manualWorkflows = signal<Workflow[]>([])
documentForm: FormGroup = new FormGroup({
title: new FormControl(''),
@@ -836,6 +842,20 @@ export class DocumentDetailComponent
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.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()
@@ -1454,7 +1474,12 @@ export class DocumentDetailComponent
.subscribe((closed) => {
if (!closed) return
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([
'view',
this.documentListViewService.activeSavedViewId,
@@ -1554,6 +1579,39 @@ 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
)
},
})
})
}
private getSelectedNonLatestVersionId(): number | null {
const versions = this.document()?.versions ?? []
if (!versions.length || !this.selectedVersionId()) {
@@ -98,6 +98,11 @@
<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>
</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>
}
</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 { StoragePath } from 'src/app/data/storage-path'
import { Tag } from 'src/app/data/tag'
import { WorkflowTriggerType } from 'src/app/data/workflow-trigger'
import { FilterPipe } from 'src/app/pipes/filter.pipe'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
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 { TagService } from 'src/app/services/rest/tag.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 { ToastService } from 'src/app/services/toast.service'
import { TrashService } from 'src/app/services/trash.service'
@@ -83,6 +85,7 @@ describe('BulkEditorComponent', () => {
let customFieldsService: CustomFieldsService
let httpTestingController: HttpTestingController
let shareLinkBundleService: ShareLinkBundleService
let workflowService: WorkflowService
let router: Router
beforeEach(async () => {
@@ -173,6 +176,22 @@ describe('BulkEditorComponent', () => {
delete: jest.fn(),
},
},
{
provide: WorkflowService,
useValue: {
list: jest.fn(() =>
of({
results: [
{
id: 5,
name: 'Manual workflow',
triggers: [{ type: WorkflowTriggerType.Manual }],
},
],
})
),
},
},
{
provide: Router,
useValue: { navigate: jest.fn().mockResolvedValue(true) },
@@ -195,6 +214,7 @@ describe('BulkEditorComponent', () => {
customFieldsService = TestBed.inject(CustomFieldsService)
httpTestingController = TestBed.inject(HttpTestingController)
shareLinkBundleService = TestBed.inject(ShareLinkBundleService)
workflowService = TestBed.inject(WorkflowService)
router = TestBed.inject(Router)
fixture = TestBed.createComponent(BulkEditorComponent)
@@ -559,18 +579,19 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
req.flush({ result: 'OK', task_id: 'queued-task' })
expect(req.request.body).toEqual({
from_webui: true,
documents: [3, 4],
method: 'modify_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`
) // list reload
httpTestingController.match(
)
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
)
})
it('should execute modify tags bulk operation for all filtered documents', () => {
@@ -607,17 +628,18 @@ describe('BulkEditorComponent', () => {
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
req.flush({ result: 'OK', task_id: 'queued-task' })
expect(req.request.body).toEqual({
from_webui: true,
all: true,
filters: { title_search: 'apple' },
excluded_documents: [4],
method: 'modify_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`
) // list reload
)
})
it('should execute modify tags bulk operation with confirmation dialog if enabled', () => {
@@ -644,6 +666,51 @@ describe('BulkEditorComponent', () => {
httpTestingController
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.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(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
@@ -729,18 +796,19 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
req.flush({ result: 'OK', task_id: 'queued-task' })
expect(req.request.body).toEqual({
from_webui: true,
documents: [3, 4],
method: 'set_correspondent',
parameters: { correspondent: 101 },
})
httpTestingController.match(
httpTestingController.expectNone(
`${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`
) // listAllFilteredIds
)
})
it('should execute modify correspondent bulk operation with confirmation dialog if enabled', () => {
@@ -767,12 +835,12 @@ describe('BulkEditorComponent', () => {
httpTestingController
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true)
httpTestingController.match(
httpTestingController.expectNone(
`${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`
) // listAllFilteredIds
)
})
it('should set modal dialog text accordingly for correspondent edit confirmation', () => {
@@ -827,18 +895,19 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
req.flush({ result: 'OK', task_id: 'queued-task' })
expect(req.request.body).toEqual({
from_webui: true,
documents: [3, 4],
method: 'set_document_type',
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`
) // list reload
httpTestingController.match(
)
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
)
})
it('should execute modify document type bulk operation with confirmation dialog if enabled', () => {
@@ -865,12 +934,12 @@ describe('BulkEditorComponent', () => {
httpTestingController
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true)
httpTestingController.match(
httpTestingController.expectNone(
`${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`
) // listAllFilteredIds
)
})
it('should set modal dialog text accordingly for document type edit confirmation', () => {
@@ -925,18 +994,19 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
req.flush({ result: 'OK', task_id: 'queued-task' })
expect(req.request.body).toEqual({
from_webui: true,
documents: [3, 4],
method: 'set_storage_path',
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`
) // list reload
httpTestingController.match(
)
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
)
})
it('should execute modify storage path bulk operation with confirmation dialog if enabled', () => {
@@ -963,12 +1033,12 @@ describe('BulkEditorComponent', () => {
httpTestingController
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true)
httpTestingController.match(
httpTestingController.expectNone(
`${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`
) // listAllFilteredIds
)
})
it('should set modal dialog text accordingly for storage path edit confirmation', () => {
@@ -1023,18 +1093,19 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
req.flush({ result: 'OK', task_id: 'queued-task' })
expect(req.request.body).toEqual({
from_webui: true,
documents: [3, 4],
method: 'modify_custom_fields',
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`
) // list reload
httpTestingController.match(
)
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
)
})
it('should execute modify custom fields bulk operation with confirmation dialog if enabled', () => {
@@ -1061,12 +1132,12 @@ describe('BulkEditorComponent', () => {
httpTestingController
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true)
httpTestingController.match(
httpTestingController.expectNone(
`${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`
) // listAllFilteredIds
)
// coverage for modal messages
component.setCustomFields({
@@ -1540,6 +1611,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', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0]))
@@ -1572,8 +1684,9 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
req.flush({ result: 'OK', task_id: 'queued-task' })
expect(req.request.body).toEqual({
from_webui: true,
documents: [3, 4],
method: 'set_permissions',
parameters: {
@@ -1581,12 +1694,12 @@ describe('BulkEditorComponent', () => {
merge: true,
},
})
httpTestingController.match(
httpTestingController.expectNone(
`${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`
) // listAllFilteredIds
)
})
it('should not attempt to retrieve objects if user does not have permissions', () => {
@@ -1887,15 +2000,18 @@ describe('BulkEditorComponent', () => {
expect(toastServiceShowErrorSpy).toHaveBeenCalled()
expect(listReloadSpy).not.toHaveBeenCalled()
modal.componentInstance.succeeded.emit()
expect(toastServiceShowInfoSpy).toHaveBeenCalled()
expect(listReloadSpy).toHaveBeenCalled()
httpTestingController.match(
modal.componentInstance.succeeded.emit({
result: 'OK',
task_id: 'custom-fields-task',
})
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`
) // list reload
httpTestingController.match(
)
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
)
})
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 { SelectionDataItem } from 'src/app/data/results'
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 { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
@@ -53,6 +54,7 @@ import {
} from 'src/app/services/rest/document.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.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 { TagService } from 'src/app/services/rest/tag.service'
import { SettingsService } from 'src/app/services/settings.service'
@@ -64,6 +66,7 @@ import { MergeAsVersionsConfirmDialogComponent } from '../../common/confirm-dial
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
import { ReprocessConfirmDialogComponent } from '../../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
import { RunWorkflowConfirmDialogComponent } from '../../common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-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 { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
@@ -81,6 +84,7 @@ import { PermissionsDialogComponent } from '../../common/permissions-dialog/perm
import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
import { CustomFieldsBulkEditDialogComponent } from './custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component'
import { WorkflowTriggerType } from 'src/app/data/workflow-trigger'
@Component({
selector: 'pngx-bulk-editor',
@@ -104,18 +108,21 @@ export class BulkEditorComponent
private correspondentService = inject(CorrespondentService)
list = inject(DocumentListViewService)
private documentService = inject(DocumentService)
private trashService = inject(TrashService)
private modalService = inject(NgbModal)
private openDocumentService = inject(OpenDocumentsService)
private settings = inject(SettingsService)
private toastService = inject(ToastService)
private trashService = inject(TrashService)
private storagePathService = inject(StoragePathService)
private customFieldService = inject(CustomFieldsService)
public readonly permissionService = inject(PermissionsService)
private savedViewService = inject(SavedViewService)
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
private readonly workflowService = inject(WorkflowService)
private readonly router = inject(Router)
public manualWorkflows: Workflow[] = []
tagSelectionModel = new FilterableDropdownSelectionModel(true)
correspondentSelectionModel = new FilterableDropdownSelectionModel()
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
@@ -258,6 +265,20 @@ export class BulkEditorComponent
(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
.get('downloadFileTypeArchive')
@@ -286,6 +307,28 @@ export class BulkEditorComponent
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(
modal: NgbModalRef,
method: DocumentBulkEditMethod,
@@ -299,11 +342,33 @@ export class BulkEditorComponent
.bulkEdit(overrideSelection ?? this.getSelectionQuery(), method, args)
.pipe(first())
.subscribe({
next: () => this.handleOperationSuccess(modal),
next: ({ task_id }) => this.handleOperationQueued(modal, task_id),
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(
modal: NgbModalRef,
request: Observable<any>,
@@ -1088,14 +1153,9 @@ export class BulkEditorComponent
dialog.selection = this.getSelectionQuery()
dialog.selectionCount = this.getSelectionSize()
dialog.succeeded.subscribe((result) => {
this.toastService.showSuccess($localize`Custom fields updated.`)
this.list.reload()
this.list.reduceSelectionToFilter()
this.list.selected.forEach((id) => {
this.openDocumentService.refreshDocument(id)
})
})
dialog.succeeded.subscribe(({ task_id }) =>
this.handleOperationQueued(null, task_id)
)
dialog.failed.subscribe((error) => {
this.toastService.showError(
$localize`Error updating custom fields.`,
@@ -59,7 +59,7 @@ describe('CustomFieldsBulkEditDialogComponent', () => {
it('should emit succeeded event and close modal on successful save', () => {
const editSpy = jest
.spyOn(documentService, 'bulkEdit')
.mockReturnValue(of('Success'))
.mockReturnValue(of({ result: 'OK', task_id: 'custom-fields-task' }))
const successSpy = jest.spyOn(component.succeeded, 'emit')
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 { TextComponent } from 'src/app/components/common/input/text/text.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 {
DocumentSelectionQuery,
@@ -49,7 +50,7 @@ export class CustomFieldsBulkEditDialogComponent {
CustomFieldDataType = CustomFieldDataType
@Output()
succeeded = new EventEmitter()
succeeded = new EventEmitter<BulkOperationResponse>()
@Output()
failed = new EventEmitter()
@@ -103,6 +104,8 @@ export class CustomFieldsBulkEditDialogComponent {
}
public save() {
if (this.networkActive) return
this.networkActive = true
this.documentService
.bulkEdit(this.selection, 'modify_custom_fields', {
add_custom_fields: this.form.value,
@@ -110,11 +113,12 @@ export class CustomFieldsBulkEditDialogComponent {
})
.pipe(first())
.subscribe({
next: () => {
next: (response) => {
this.activeModal.close()
this.succeeded.emit()
this.succeeded.emit(response)
},
error: (error) => {
this.networkActive = false
this.failed.emit(error)
},
})
@@ -2,26 +2,47 @@
<div>
<form [formGroup]="noteForm" class="needs-validation mt-3" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Note }" novalidate>
<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>
<div class="invalid-feedback" i18n>
Please enter a note.
</div>
<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>
@if (noteView() === NoteViewMode.Preview) {
<section class="form-control form-control-sm note-preview" aria-label="Note preview" i18n-aria-label>
@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 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()) {
<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>
</div>
</form>
<hr>
@for (note of notes(); track note) {
@for (note of notes(); track note.id) {
<div class="card border mb-3">
<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 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 }">
<pngx-bs-icon name="trash"></pngx-bs-icon>
<span class="visually-hidden" i18n>Delete note</span>
@@ -5,3 +5,45 @@
.card:hover .fade {
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;
}
h1 { font-size: 1.25em; }
h2 { font-size: 1.15em; }
h3 { font-size: 1.05em; }
table {
margin-bottom: 1rem;
display: block;
overflow-x: auto;
}
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 { By } from '@angular/platform-browser'
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 { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { PermissionsService } from 'src/app/services/permissions.service'
@@ -129,6 +129,66 @@ describe('DocumentNotesComponent', () => {
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', () => {
expect(
component.displayName({
@@ -161,7 +221,7 @@ describe('DocumentNotesComponent', () => {
const addSpy = jest.spyOn(notesService, 'addNote')
addSpy.mockReturnValueOnce(throwError(() => new Error('error saving note')))
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')
expect(addSpy).toHaveBeenCalledWith(12, note)
expect(toastsSpy).toHaveBeenCalled()
@@ -190,7 +250,9 @@ describe('DocumentNotesComponent', () => {
component.documentId.set(12)
component.notes.set(notes)
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 toastsSpy = jest.spyOn(toastService, 'showError')
deleteSpy.mockReturnValueOnce(
@@ -14,15 +14,21 @@ import {
ReactiveFormsModule,
} from '@angular/forms'
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 { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
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 { UserService } from 'src/app/services/rest/user.service'
import { ToastService } from 'src/app/services/toast.service'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
enum NoteViewMode {
Edit = 'edit',
Preview = 'preview',
}
@Component({
selector: 'pngx-document-notes',
templateUrl: './document-notes.component.html',
@@ -30,6 +36,7 @@ import { ComponentWithPermissions } from '../with-permissions/with-permissions.c
imports: [
IfPermissionsDirective,
CustomDatePipe,
MarkdownPipe,
FormsModule,
ReactiveFormsModule,
BootstrapIconComponent,
@@ -43,6 +50,9 @@ export class DocumentNotesComponent extends ComponentWithPermissions {
readonly notes = model<DocumentNote[]>([])
readonly addDisabled = input(false)
readonly networkActive = signal(false)
protected readonly NoteViewMode = NoteViewMode
protected readonly NoteFormat = NoteFormat
protected readonly noteView = signal<NoteViewMode>(NoteViewMode.Edit)
noteForm: FormGroup = new FormGroup({
newNote: new FormControl(''),
@@ -75,6 +85,7 @@ export class DocumentNotesComponent extends ComponentWithPermissions {
next: (result) => {
this.notes.set(result)
this.noteForm.get('newNote').reset()
this.noteView.set(NoteViewMode.Edit)
this.networkActive.set(false)
this.updated.emit(this.notes())
},
+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 { User } from './user'
export enum NoteFormat {
Plain = 'plain',
Markdown = 'markdown',
}
export interface DocumentNote extends ObjectWithId {
created?: Date
note?: string
user?: User
format?: NoteFormat
}
+1
View File
@@ -12,6 +12,7 @@ export enum WorkflowTriggerType {
DocumentAdded = 2,
DocumentUpdated = 3,
Scheduled = 4,
Manual = 5,
}
export enum ScheduleDateField {
@@ -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', () => {
expect(documentListViewService.currentPage).toEqual(1)
documentListViewService.reload()
@@ -386,6 +422,46 @@ describe('DocumentListViewService', () => {
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', () => {
documentListViewService.setFilterRules([])
let req = httpTestingController.expectOne(
@@ -270,11 +270,28 @@ export class DocumentListViewService {
this.currentPage = viewState.currentPage
}
loadSavedView(view: SavedView, closeCurrentView: boolean = false) {
if (closeCurrentView) {
this._activeSavedViewId = null
}
/**
* Activate a saved view using documents that were already fetched, e.g. by a
* 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.sortField = view.sort_field
this.activeListViewState.sortReverse = view.sort_reverse
@@ -284,6 +301,14 @@ export class DocumentListViewService {
this.activeListViewState.displayMode = view.display_mode
this.activeListViewState.pageSize = view.page_size
this.activeListViewState.displayFields = view.display_fields
}
loadSavedView(view: SavedView, closeCurrentView: boolean = false) {
if (closeCurrentView) {
this._activeSavedViewId = null
}
this.applySavedViewConfig(view)
this.reduceSelectionToFilter()
this.markChanged()
@@ -355,7 +380,7 @@ export class DocumentListViewService {
queryParams: paramsFromViewState(activeListViewState),
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([], {
queryParams: paramsFromViewState(activeListViewState, true),
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.
activeListViewState.currentPage = 1
this.markChanged()
this.reload()
this.reload(onFinish, updateQueryParams)
} else if (
activeListViewState.sortField.indexOf('custom_field') === 0 &&
!this.settings
@@ -1,6 +1,7 @@
import { HttpTestingController } from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing'
import { Subscription } from 'rxjs'
import { NoteFormat } from 'src/app/data/document-note'
import { environment } from 'src/environments/environment'
import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
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.body).toEqual({
note: content,
format: NoteFormat.Markdown,
})
})
@@ -1,7 +1,7 @@
import { HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core'
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'
@Injectable({
@@ -22,6 +22,7 @@ export class DocumentNotesService extends AbstractPaperlessService<DocumentNote>
addNote(id: number, note: string): Observable<DocumentNote[]> {
return this.http.post<DocumentNote[]>(this.getResourceUrl(id, 'notes'), {
note: note,
format: NoteFormat.Markdown,
})
}
@@ -248,6 +248,7 @@ describe(`DocumentService`, () => {
)
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({
from_webui: true,
documents: ids,
method,
parameters,
@@ -271,6 +272,7 @@ describe(`DocumentService`, () => {
)
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({
from_webui: true,
all: true,
filters: { title__icontains: 'apple' },
excluded_documents: [2, 3],
@@ -14,6 +14,7 @@ import { DocumentSuggestions } from 'src/app/data/document-suggestions'
import { FilterRule } from 'src/app/data/filter-rule'
import { Results, SelectionData } from 'src/app/data/results'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { BulkOperationResponse } from '../../data/bulk-operation'
import { queryParamsFromFilterRules } from '../../utils/query-params'
import {
PermissionAction,
@@ -38,6 +39,7 @@ export type DocumentBulkEditMethod =
| 'modify_tags'
| 'modify_custom_fields'
| 'set_permissions'
| 'run_workflow'
export interface MergeDocumentsRequest {
metadata_document_id?: number
@@ -337,11 +339,15 @@ export class DocumentService extends AbstractPaperlessService<Document> {
method: DocumentBulkEditMethod,
args: any
) {
return this.http.post(this.getResourceUrl(null, 'bulk_edit'), {
...selection,
method: method,
parameters: args,
})
return this.http.post<BulkOperationResponse>(
this.getResourceUrl(null, 'bulk_edit'),
{
...selection,
method: method,
from_webui: true,
parameters: args,
}
)
}
deleteDocuments(selection: DocumentSelectionQuery) {
@@ -438,6 +438,20 @@ describe('ConsumerStatusService', () => {
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', () => {
let updated = false
let deleted = false
@@ -1,6 +1,7 @@
import { Injectable, inject, signal } from '@angular/core'
import { Subject } from 'rxjs'
import { environment } from 'src/environments/environment'
import { BulkOperationStatus } from '../data/bulk-operation'
import { User } from '../data/user'
import { WebsocketDocumentUpdatedMessage } from '../data/websocket-document-updated-message'
import { WebsocketDocumentsDeletedMessage } from '../data/websocket-documents-deleted-message'
@@ -12,6 +13,7 @@ export enum WebsocketStatusType {
DOCUMENTS_DELETED = 'documents_deleted',
DOCUMENT_UPDATED = 'document_updated',
HEARTBEAT = 'heartbeat',
BULK_OPERATION_STATUS = 'bulk_operation_status',
}
// see ProgressStatusOptions in src/documents/plugins/helpers.py
@@ -116,6 +118,13 @@ export class WebsocketStatusService {
private readonly documentDeletedSubject = new Subject<boolean>()
private readonly documentUpdatedSubject =
new Subject<WebsocketDocumentUpdatedMessage>()
private readonly bulkOperationStatusSubject =
new Subject<BulkOperationStatus>()
onBulkOperationStatus() {
return this.bulkOperationStatusSubject.asObservable()
}
private readonly connectionStatusSubject = new Subject<boolean>()
private get(taskId: string, filename?: string) {
@@ -192,6 +201,7 @@ export class WebsocketStatusService {
| WebsocketProgressMessage
| WebsocketDocumentsDeletedMessage
| WebsocketDocumentUpdatedMessage
| BulkOperationStatus
} = JSON.parse(ev.data)
switch (type) {
@@ -209,6 +219,12 @@ export class WebsocketStatusService {
this.handleProgressUpdate(messageData as WebsocketProgressMessage)
break
case WebsocketStatusType.BULK_OPERATION_STATUS:
this.bulkOperationStatusSubject.next(
messageData as BulkOperationStatus
)
break
case WebsocketStatusType.HEARTBEAT:
// keep-alive from the server, see paperless.consumers.StatusConsumer
break
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
Loaded 100 of 223 files, more files were not shown because too many files have changed in this diff. Show more