Compare commits

...
Author SHA1 Message Date
shamoonandCodex b68ce4b977 Lets just see this idea
Co-Authored-By: Codex <267193182+codex@users.noreply.github.com>
2026-10-22 16:03:07 -07:00
shamoon f71a9a6eda Merge branch 'main' into dev 2026-10-22 15:38:13 -07:00
github-actions[bot] f319b05e0f Documentation: Add v3.3.4 changelog (#14534) 2026-10-22 15:36:38 -07:00
shamoon 558cb01a1d Bump version to 3.3.4 2026-10-22 14:58:47 -07:00
shamoon 59e037fed2 Chore: bump pillow + aiohttp for security fixes 2026-10-22 14:56:06 -07:00
shamoon 56ae227de3 Chore: Update certifi for eMudhra CA incident (#14533) 2026-10-22 14:51:54 -07:00
Trenton H de61c20197 Chore: Update uv to 0.13 for the eMudhra CA fixes (#14532) 2026-10-22 14:47:05 -07:00
Trenton H c0dbe1f040 Chore: Update uv to 0.13 for the eMudhra CA fixes (#14532) 2026-10-22 14:46:08 -07:00
GitHub Actions 35ba0b36a8 Auto translate strings 2026-10-22 19:30:17 +00:00
Trenton H 7bdbcca051 Chore: Refactor bulk edit to remove PDF knowledge (#14445)
* Refactor: add pure pdf_ops module with real-PDF tests
* Drops tests we don't relly need + adds the typing annotation
* Now that I'm sure it works, re-work these tests to mock pdf_ops, rather than pikepdf
* Validates pages ahead of time in build_pdfs rather than during the building
* Adds the given/when/then and some additional cleanups of pytest style
* This is redundant now
2026-10-22 19:25:39 +00:00
Ben Petty 9b56dc2f61 Fix: folder export with --delete fails on a stale manifest.json.tmp (#14526) 2026-10-22 11:50:31 -07:00
dependabot[bot] d872e456ab Chore(deps): Bump the uv group across 1 directory with 2 updates (#14517)
Bumps the uv group with 2 updates in the / directory: [certifi](https://github.com/certifi/python-certifi) and [pillow](https://github.com/python-pillow/Pillow).


Updates `certifi` from 2026.1.4 to 2026.10.19
- [Commits](https://github.com/certifi/python-certifi/compare/2026.01.04...2026.10.19)

Updates `pillow` from 12.3.0 to 13.0.0
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst)
- [Commits](https://github.com/python-pillow/Pillow/compare/12.3.0...13.0.0)

---
updated-dependencies:
- dependency-name: certifi
  dependency-version: 2026.10.19
  dependency-type: indirect
- dependency-name: pillow
  dependency-version: 13.0.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-22 09:15:00 -07:00
GitHub Actions e2b884d353 Auto translate strings 2026-10-22 12:55:09 +00:00
shamoon 97c98ee44c Fix: fix list type for api schema document object filters (#14524) 2026-10-22 05:53:17 -07:00
dependabot[bot] 232f9c8dcc Chore(deps): Bump the uv group across 1 directory with 2 updates (#14493)
---
updated-dependencies:
- dependency-name: aiohttp
  dependency-version: 3.14.4
  dependency-type: indirect
- dependency-name: tinytag
  dependency-version: 2.3.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-21 19:47:49 -07:00
GitHub Actions f25fd5365f Auto translate strings 2026-10-21 15:47:44 +00:00
Trenton H a3b358bad7 Chore: drop get_objects_for_user_owner_aware (#14457)
Drops get_objects_for_user_owner_aware. Callers now use restrict_queryset_to_visible.

The same question was answered two ways, and the guardian path did not deny
deactivated users the way permitted_object_ids does.
2026-10-21 15:46:23 +00:00
GitHub Actions 376e865989 Auto translate strings 2026-10-21 14:39:11 +00:00
shamoon ca5e2df7f0 Chore: update Angular components to 22.2.2 (#14515) 2026-10-21 07:37:14 -07:00
Trenton H b904881472 Chore: share one base class for the method-mapped permission classes (#14458)
* Chore: share one base class for the method-mapped permission classes

ViewDocumentsPermissions, TrashPermissions, PaperlessNotePermissions and
AcknowledgeTasksPermissions each repeated the same has_permission body and
disagreed on HTTP methods missing from their perms_map.

GlobalPermissionsByMethod now holds that logic once. HEAD is checked
against the GET entry and a method missing from the map is denied. The
task acknowledge map gains OPTIONS, now requiring change_paperlesstask
like POST instead of being open to any authenticated user. The global
statistics and system status checks also share their active and
authenticated preamble.

* Chore: type GlobalPermissionsByMethod.perms_map keys as HTTP methods

perms_map accepted any string key, so a misspelled method or a HEAD entry
would never match. Keys are now a Literal of the mappable methods, without
HEAD, and the map is typed read-only.
2026-10-20 20:32:13 -07:00
shamoon 040ceb8503 Fix: standardize some dropdown sizing (#14510) 2026-10-20 16:16:05 -07:00
Trenton HandClaude Sonnet 5.5 c089bba32c Chore: Drop ImageMagik as a dependency (#14440)
* Mark the thumbnail constants Final

* Comment each pdftoppm option

* Feature/Chore: Replaces all usages of ImageMagik's convert binary with a simple Pillow based helper

This removes ImageMagik as a dependency entirly and all documentation is cleaned up from it.
We no longer need to install it, have a policy for it to allow PDFs and no longer shell
out to it either.

* Fix: Save WebP images losslessly when removing the alpha channel

Pillow's default WebP save is lossy, so a WebP with an alpha channel came
back from remove_alpha_channel with slightly shifted pixel values before
it reached OCR. Save WebP lossless; other formats are unchanged.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 11:22:43 -07:00
Trenton HandClaude Sonnet 5.5 a05afd654c Feature: Generate PDF thumbnails via poppler utils (#14420)
* Chore: Generate PDF thumbnails with pdftoppm and Pillow instead of ImageMagick

Replace the ImageMagick convert based PDF thumbnail path with pdftoppm and
Pillow. Keeps thumbnails at 500px wide for small and odd sized pages, covers
the qpdf repair and double failure paths, removes the unused run_convert
helper, hardens thumbnail encoding, deprecates PAPERLESS_CONVERT_MEMORY_LIMIT
and PAPERLESS_CONVERT_TMPDIR, and corrects the related docs and CI.

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

* Chore: Supersample the PDF thumbnail render 2x

Render the PDF page at twice the target size and downsample, with edge case
handling for small and odd sized pages, simplified helpers and tests, and
trimmed comments.

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

* Fix: use a fractional thumbnail DPI so oversized pages render a bounded raster

* Deprecate the unused PAPERLESS_GS_BINARY setting too

* Warn at startup when pdftoppm is missing

* Resample PDF thumbnails once instead of twice with LANCZOS, still never enlarging

* Read PDF page size from pdfinfo so it matches pdftoppm

* Mark the thumbnail constants Final

* Comment each pdftoppm option
2026-10-20 11:22:42 -07:00
GitHub Actions f730513e79 Auto translate strings 2026-10-20 05:27:06 +00:00
shamoon d4826f50c9 Fix: prevent doc permissions queries on schema view (#14503) 2026-10-19 22:25:52 -07:00
GitHub Actions 8bbca8a269 Auto translate strings 2026-10-20 03:34:03 +00:00
shamoon 3374debd6a Enhancement: add upload count to button, link to tray, reduce notifications (#14501) 2026-10-19 20:32:35 -07:00
GitHub Actions f8e93a535a Auto translate strings 2026-10-20 02:19:58 +00:00
shamoon 98fb7c3b63 Enhancement: notifications improvements (#14500) 2026-10-19 19:18:39 -07:00
GitHub Actions c657fceea5 Auto translate strings 2026-10-20 02:09:30 +00:00
shamoon d76943d57c Enhancement: doc details ux improvements (#14485) 2026-10-19 19:08:33 -07:00
shamoon a4003df7e8 Enhancement (QoL): allow independently toggling child + parent tags in WF tag fields (#14400) 2026-10-19 19:08:21 -07:00
shamoon f6f420a491 Tweak: drop the user dropdown caret (#14444) 2026-10-19 19:08:07 -07:00
shamoon c105486b6a Enhancement: navbar upload button (#14442) 2026-10-19 19:07:55 -07:00
GitHub Actions 8ea31cb3ce Auto translate strings 2026-10-20 02:05:59 +00:00
shamoon 4c124caee1 Enhancement: AI chat UX tweaks (#14415) 2026-10-19 19:04:50 -07:00
GitHub Actions 9e83864f83 Auto translate strings 2026-10-20 01:53:57 +00:00
shamoon 7b53a4b316 Enhancement: support markdown in AI chat (#14409) 2026-10-19 18:52:28 -07:00
shamoon 20b7a116fd Fix: dont defer tag assignment when barcode will not result in a split (#14497) 2026-10-19 15:37:04 -07:00
Nathanaël Houn a5c8b82da5 Documentation: add scratch_dir setting (#14498) 2026-10-19 15:00:28 -07:00
github-actions[bot] 8d3902454e Documentation: Add v3.3.3 changelog (#14492)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-19 10:28:42 -07:00
shamoon 31caf01ae5 Bump version to 3.3.3 2026-10-19 10:06:39 -07:00
shamoon e756f5985f Merge branch 'dev' 2026-10-19 10:05:59 -07:00
github-actions[bot]andCrowdin Bot ce1dee7549 New Crowdin translations by GitHub Action (#14474)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-10-19 16:58:46 +00:00
shamoon fd187cd09a Fix: fix global search input layout shifting (#14491) 2026-10-19 09:39:26 -07:00
GitHub Actions 814b208280 Auto translate strings 2026-10-19 15:41:29 +00:00
shamoon 11c32d352e Fix: dont include doc content on global search results (#14488) 2026-10-19 08:40:05 -07:00
GitHub Actions c21deaa452 Auto translate strings 2026-10-19 06:20:40 +00:00
shamoon c47766121d Fix: remove some redundant pagers (#14487) 2026-10-18 23:19:24 -07:00
GitHub Actions 979bcd3896 Auto translate strings 2026-10-19 06:05:02 +00:00
shamoon d450f03d5f Fix: prevent weird wrapping of dashboard widget column titles (#14486) 2026-10-18 23:03:43 -07:00
GitHub Actions 730c2d8688 Auto translate strings 2026-10-19 03:23:47 +00:00
shamoon b1738c85d7 Fix: dont paginate 'all' tasks view across sections (#14484) 2026-10-18 20:22:18 -07:00
Trenton H a8b835fcd0 Fix: use float parsing for importer manifest records with a passphrase (#14483) 2026-10-18 15:16:13 -07:00
GitHub Actions 41498113d3 Auto translate strings 2026-10-18 14:29:34 +00:00
309a4b004d Fix: respect subpath in logout, share link and auto-login redirects (#14476)
---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-10-18 14:28:15 +00:00
GitHub Actions 961f7fd672 Auto translate strings 2026-10-18 00:30:41 +00:00
shamoon de94657e17 Fix: dont tie suggestions button to ai enabled any more (#14478) 2026-10-17 17:29:29 -07:00
Trenton H 0cb2cdbfdd Fix: handle single-class classifier in threshold prediction (#14471) (#14473)
scikit-learn treats a single-class fit as binary, so predict_proba returns
[1 - p, p] for one class and argmax could index past classes_. Use only
column 0, the real class, so the confidence threshold still applies.
2026-10-16 10:47:58 -07:00
GitHub Actions 1f3d451a3a Auto translate strings 2026-10-16 17:12:50 +00:00
Trenton H 183cfd2da4 Fix: keep counter and extension when shortening long public filenames (#14472)
get_public_filename relied on sanitize_filename to truncate, which cuts
from the end and so dropped the counter and extension of long titles.
Multi-byte titles could also exceed the 255 byte filesystem limit.

Shorten the title by UTF-8 bytes before appending the counter and
extension. The exporter reserves room for its longer derived thumbnail
and archive suffixes.
2026-10-16 10:11:34 -07:00
github-actions[bot] 15e9a7a017 Changelog v3.3.2 - GHA (#14468)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-15 23:21:41 -07:00
shamoon 04a927c25f Bump version to 3.3.2 2026-10-15 22:52:43 -07:00
shamoon 30a24ee02e Chore: disable deprecated ts-jest isolation override 2026-10-15 22:51:55 -07:00
github-actions[bot]andCrowdin Bot 1943c99b27 New Crowdin translations by GitHub Action (#14449)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-10-15 22:51:00 -07:00
shamoon 8b26ecafb6 Fix: prevent ownerless docs leaking others in to LLM prompts 2026-10-15 22:42:34 -07:00
Trenton H d4a57204eb Fix: copy only direct user grants in DocumentMetadataOverrides.from_document (#14452)
get_users_with_perms includes group members by default, so PDF actions such
as merge and split gave them direct grants on the new documents, which
outlived their group membership.
2026-10-15 15:11:28 -07:00
dependabot[bot] c50c6f5517 Chore(deps): Bump filelock from 3.32.6 to 4.0.12 (#14336)
Bumps [filelock](https://github.com/tox-dev/py-filelock) from 3.32.6 to 4.0.12.
- [Release notes](https://github.com/tox-dev/py-filelock/releases)
- [Changelog](https://github.com/tox-dev/filelock/blob/main/docs/changelog.rst)
- [Commits](https://github.com/tox-dev/py-filelock/compare/3.32.6...4.0.12)

---
updated-dependencies:
- dependency-name: filelock
  dependency-version: 4.0.3
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-14 16:11:12 -07:00
GitHub Actions 34a6c718fa Auto translate strings 2026-10-14 15:18:54 +00:00
shamoon 14aaa42777 Fix: support combined text/query searches (#14443) 2026-10-14 08:17:22 -07:00
shamoon c86c822bc7 Fix ai auto-close message 2026-10-13 12:15:28 -07:00
dependabot[bot] b23b118a49 Chore(deps): Bump the uv group across 1 directory with 3 updates (#14418)
Bumps the uv group with 3 updates in the / directory: [banks](https://github.com/masci/banks), [fsspec](https://github.com/fsspec/filesystem_spec) and [multidict](https://github.com/aio-libs/multidict).


Updates `banks` from 2.4.5 to 2.5.1
- [Release notes](https://github.com/masci/banks/releases)
- [Commits](https://github.com/masci/banks/compare/v2.4.5...v2.5.1)

Updates `fsspec` from 2026.1.0 to 2026.6.0
- [Commits](https://github.com/fsspec/filesystem_spec/compare/2026.1.0...2026.6.0)

Updates `multidict` from 6.7.1 to 6.9.1
- [Release notes](https://github.com/aio-libs/multidict/releases)
- [Changelog](https://github.com/aio-libs/multidict/blob/master/CHANGES.rst)
- [Commits](https://github.com/aio-libs/multidict/compare/v6.7.1...v6.9.1)

---
updated-dependencies:
- dependency-name: banks
  dependency-version: 2.5.1
  dependency-type: indirect
- dependency-name: fsspec
  dependency-version: 2026.6.0
  dependency-type: indirect
- dependency-name: multidict
  dependency-version: 6.9.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-13 17:25:40 +00:00
Trenton H 6e717817c6 Fix: make exporter derived file names unique when originals differ only by extension (#14429) 2026-10-12 15:14:08 -07:00
shamoon 3e0fa73771 Chore: adjust issue auto-triage 2026-10-12 14:16:45 -07:00
github-actions[bot]andshamoon 2b00cf6a95 Documentation: Add v3.3.1 changelog (#14427)
* Changelog v3.3.1 - GHA

* Update changelog for paperless-ngx 3.3.1

Removed MARIADB_HOST documentation from MariaDB compose files.

---------

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-10-12 13:22:46 -07:00
shamoon c4d1b0c732 Bump version to 3.3.1 2026-10-12 13:01:03 -07:00
shamoon 333cbf0ae6 Merge branch 'dev' 2026-10-12 13:00:15 -07:00
github-actions[bot]andCrowdin Bot 49a298894e New Crowdin translations by GitHub Action (#14368)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-10-12 12:59:46 -07:00
GitHub Actions 551ebad6f1 Auto translate strings 2026-10-12 19:13:03 +00:00
Trenton H 4171ecf166 Chore: share the authorizing owner rule between the id sets and the trash filter (#14425)
The trash list needs to show a version to whoever owns its root document, but
it deliberately shows only owned and unowned documents and ignores explicit
shares, so it cannot use permitted_object_ids. #14384 gave it its own
filter_queryset with a hand-built condition through the root, which describes
the same rule as the annotations in permitted_object_ids a second time.

Move those annotations into annotate_authorizing_fields. PermittedObjectsFilter
gains a parent_field attribute that its owner-only and granted paths both use,
and the trash filter shrinks to setting include_granted and parent_field.
2026-10-12 12:11:42 -07:00
GitHub Actions 8289dd0bae Auto translate strings 2026-10-12 18:12:14 +00:00
Trenton H b195423f1d Fix: use a root document's effective content for the LLM index and pr… (#14423)
* Fix: use a root document's effective content for the LLM index and prompts

The search index and the document views use the newest version's content as a
root document's content, but everything the LLM side reads used the root's own
content: the text embedded in the LLM index, the content in the classification
prompt, the context blocks from similar documents, and the text that is
searched for similar documents. After a document was replaced by a new
version the LLM kept answering from the old text.

Read get_effective_content() in those four places. The index update annotates
the content in the query like the search index does, and the similar-document
lookup annotates it for the documents it fetches, so no query is made per
document. Entries already in the LLM index keep the old text until the
document next changes or the index is rebuilt.

* Chore: test the LLM query text and bulk annotation use effective content

* Removes the query count testing.  It's flakey from local to CI and workers, doesn't really add much imo
2026-10-12 11:10:50 -07:00
Trenton H 9f2f809131 Fix: index document versions as their root document (#14422)
* Fix: index document versions as their root document

The search index and the LLM index hold root documents only, a root being
indexed with its newest version's content. Every write path therefore had to
remember to hand them the root. Several did not.

WriteBatch.add_or_update, WriteBatch.add_or_update_ids and
llm_index_add_or_update_document now resolve a version to its root themselves.
The reindex command and update_llm_index only walk root documents, and an
incremental LLM update is scoped to the roots of the given ids through
versioning.root_document_ids, which add_or_update_ids shares. more_like_id
returns the root's id for a version. The reprocess task no longer picks the root
for the indexes and only still clears the caches of both documents.

* Fix: rebuild old search indexes and look versions up as their root for similar documents

An index built before versions were indexed as their root can still hold
version entries under their own id and metadata, and only a rebuild clears
them. Bump the schema version so the index is rebuilt on upgrade.

more_like_this_ids looked the given id up in the index as is, so asking for
documents like a version, as the AI suggestions endpoint can, silently found
nothing. It now looks the root up instead.
2026-10-12 11:10:50 -07:00
GitHub Actions 96535991a3 Auto translate strings 2026-10-12 17:18:18 +00:00
Trenton H c2deb69ac5 Fix: authorize document versions by their root in the single-object permission check (#14421)
* Fix: authorize document versions by their root in the single-object permission check

has_perms_owner_aware judged a document by its own owner and grants, so each
endpoint that fetches a document itself had to remember to map a version to
its root document first, and one that forgot, like the more-like-this search
filter, authorized by a stale version owner.

The check now maps a Document to its root before looking at the owner and the
guardian grants, matching what permitted_document_ids does for id sets. The
eight call sites that mapped the document themselves pass it straight through.
The DRF object permission class needs no change because the document viewset
only ever serves root documents.
2026-10-12 17:17:00 +00:00
GitHub Actions 070f614929 Auto translate strings 2026-10-12 17:06:18 +00:00
Trenton H 6f496f741b Chore: New helper for JSON parsing during settings, to provide a better error message to users (#14419) 2026-10-12 17:04:56 +00:00
Sven Lankes 1fb463b55c Chore: silence gotenberg startup warning (#14405) 2026-10-12 16:54:47 +00:00
shamoon 9a136ecb62 Chore: drop PR size labeling 2026-10-12 09:41:48 -07:00
shamoon 192ddbe45e Documentation: tweak demo hosting attribution 2026-10-12 09:18:19 -07:00
shamoon aac5e49034 Merge branch 'main' into dev 2026-10-12 09:13:04 -07:00
shamoon 716d310eb3 Documentation: credit Fyra Stack for hosting the demo (#14399) 2026-10-12 09:10:40 -07:00
dependabot[bot] 71d2edb055 Chore(deps): Bump the npm_and_yarn group across 1 directory with 2 updates (#14417)
Bumps the npm_and_yarn group with 2 updates in the /src/paperless_mail/templates directory: [source-map-js](https://github.com/7rulnik/source-map-js) and [postcss-selector-parser](https://github.com/postcss/postcss-selector-parser).


Updates `source-map-js` from 1.2.1 to 1.2.2
- [Release notes](https://github.com/7rulnik/source-map-js/releases)
- [Changelog](https://github.com/7rulnik/source-map-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/7rulnik/source-map-js/compare/v1.2.1...v1.2.2)

Removes `postcss-selector-parser`

---
updated-dependencies:
- dependency-name: source-map-js
  dependency-version: 1.2.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: postcss-selector-parser
  dependency-version:
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-12 06:52:11 -07:00
shamoon 973af89f61 Chore: update frontend TS libs to ES2022 2026-10-11 15:39:56 -07:00
GitHub Actions 937bf2fa7a Auto translate strings 2026-10-10 22:05:15 +00:00
Trenton HandClaude Sonnet 5.5 2f2381578c Fix: authorize document versions by their root and speed up permission id sets (#14396)
* Fix: authorize document versions by their root and speed up permission id sets

permitted_document_ids judged a version by its own owner and grants, so a
version whose owner had drifted from its root's was visible to the wrong
people and hidden from the right ones. Callers patched this individually by
mapping each document to its root first. The query itself was also slow on
MariaDB: the guardian grants were a UNION cast to integers and tested with
IN inside an OR with the owner checks, which MariaDB cannot materialize, so it
re-scans the user's grants for every document. At 20k documents that took
seconds for a user with a couple of hundred grants.

permitted_object_ids now casts the row key to a string, as guardian stores
object_pk, and tests it against a single uncorrelated UNION ALL of the user's
and groups' grants. No integer cast is needed and the user's groups are
matched with an IN subquery rather than a join through the membership table.
Postgres and SQLite build the grant set once, and MariaDB probes guardian's
unique indexes per row, which is cheap. A correlated EXISTS per grant also
fixed MariaDB but was up to 3x slower than before on Postgres and SQLite.

permitted_object_ids also takes an optional parent_field naming a
self-referencing foreign key whose target authorizes the row, and
permitted_document_ids passes root_document, so a version is visible exactly
when its root is. The helper that mapped documents to their roots at the call
sites is no longer needed, so the email, selection data, share link bundle,
trash, bulk download and bulk edit checks use the id set directly.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 15:03:52 -07:00
shamoon e606dbffcd Chore: update the db options warning (#14398) 2026-10-10 07:17:54 -07:00
shamoon 138160cbda Fix: bulk reprocess latest version for root documents (#14385) 2026-10-08 18:50:44 +00:00
GitHub Actions 6d960c11d5 Auto translate strings 2026-10-08 18:40:22 +00:00
shamoon a35bd6e238 Fix: consistently use root doc for version action permissions (#14384) 2026-10-08 18:39:05 +00:00
Trenton H 474c630aa4 Fix: Don't attempt to index created dates which are not representable inside the FTS index (#14390) 2026-10-08 08:36:35 -07:00
Trenton H d7a9894400 Fix: retry a search index rebuild that was interrupted (#14379)
An interrupted rebuild left an empty index stamped as current, so the next
start reported it as up to date. Mark the rebuild as in progress and only
clear the marker once it completes.
2026-10-07 08:54:36 -07:00
shamoon f8876c55e3 Documentation: remove MARIADB_HOST from MariaDB compose files (#14378) 2026-10-07 07:32:37 -07:00
Trenton H e5b215f9d3 Chore: Updates the troubleshooting with 2 common database collation things (#14370) 2026-10-06 11:02:42 -07:00
GitHub Actions ee34a6598e Auto translate strings 2026-10-06 15:13:08 +00:00
Trenton H 3a3b3ef66a Chore: Upgrade runners to Ubuntu 26.04 (#14347)
* Moves runners to 26.04 and a few jobs to -slim variant

* Probably fixing the imagemagik 7 problems and maybe the frontend playwright thing?

* Compare thumbnails, but allow a little difference in the perceptual hash
2026-10-06 08:11:44 -07:00
github-actions[bot]andshamoon 94f10b8622 Documentation: Add v3.3.0 changelog (#14362)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-10-05 21:14:19 -07:00
shamoon 6f7ed1d4a7 Bump version to 3.3.0 2026-10-05 20:34:24 -07:00
shamoon 0428bf6955 Merge branch 'dev' 2026-10-05 20:33:36 -07:00
github-actions[bot]andCrowdin Bot 662a5237a3 New Crowdin translations by GitHub Action (#14361)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-10-06 03:29:47 +00:00
GitHub Actions 562c2d0985 Auto translate strings 2026-10-06 03:19:38 +00:00
shamoon 6cdb93cd59 Fix: dont overcount same title or date suggestions 2026-10-05 20:18:09 -07:00
GitHub Actions 85fcf66691 Auto translate strings 2026-10-06 03:05:53 +00:00
shamoon 73b2321e87 Fix create after ML-only 2026-10-05 20:04:35 -07:00
GitHub Actions 5eecd6971e Auto translate strings 2026-10-06 02:34:22 +00:00
shamoon bc92e4ff98 Fix suggestions dropdown counts and empty state 2026-10-05 19:32:56 -07:00
github-actions[bot]andCrowdin Bot dacc213917 New Crowdin translations by GitHub Action (#14214)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-10-06 02:09:40 +00:00
GitHub Actions c780586c72 Auto translate strings 2026-10-05 16:26:46 +00:00
jurassicparkicecreamandshamoon 62cc31fbdb Feature: store barcode contents, list and search them (#14276)
* Feature: store barcode contents, list and search them

New setting PAPERLESS_CONSUMER_STORE_BARCODE_VALUES (off by default)
stores all barcodes found during consumption with the document: page,
type and content. They are listed on the metadata tab with a copy
button, returned by the documents API and searchable with barcodes:
in the advanced search. Versions keep their own barcodes, reprocessing
reads them again. Refs #9898

* Tests: cover the remaining barcode branches

Covers unchanged and failing barcode reads on reprocessing, unsupported files and DocumentBarcode.__str__, and uses toHaveLength in the barcode list spec as suggested by SonarCloud.

* Address review: keep barcodes when they can't be read, format choices

- Reprocessing and new versions keep the stored barcodes when the file
  can't be scanned or the scan fails, and replace them atomically.
- Format is a TextChoices of the zxing-cpp formats, with a test.
- Shared scan code, latest_version helper, TypedDict, serializer reuse.
- Barcodes in the split manifest, export/import tests, pytest-style tests.

* Barcode tests: TIFF reprocess, format check both ways, fixtures

- Reprocessing a TIFF with TIFF support off keeps the stored barcodes.
- The format test also fails when zxing-cpp drops a format.
- Fixtures in place of the sample dir mixin, plugin-level disabled test.
- Format labels aren't translated, OpenAPI enum named BarcodeFormatEnum.

* Review: module-level zxing reader, shorter barcode docs

- read_barcodes_zxing is a module-level function used by scan_pdf.
- Drop the trivial __str__ test, mark it no cover.
- Shorten the barcode docs and remove the duplicate in configuration.md.

* Fix header

* Use utility class

* Return barcodes from the metadata endpoint only

Drop the barcodes field and its prefetches from the document serializer,
as agreed in the review. Also remove the now empty component stylesheet.

---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-10-05 16:25:19 +00:00
Trenton H 312684aef3 Fix: Set the ProcessedMail owner based on the rule owner in all cases (#14356) 2026-10-05 05:57:41 -07:00
dependabot[bot]andTrenton H 1d42d372a2 Chore(deps): Bump django-filter from 25.2 to 26.1 (#14337)
* Chore(deps): Bump django-filter from 25.2 to 26.1

Bumps [django-filter](https://github.com/carltongibson/django-filter) from 25.2 to 26.1.
- [Release notes](https://github.com/carltongibson/django-filter/releases)
- [Changelog](https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst)
- [Commits](https://github.com/carltongibson/django-filter/compare/25.2...26.1)

---
updated-dependencies:
- dependency-name: django-filter
  dependency-version: '26.1'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

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

* Linting

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Trenton H <797416+stumpylog@users.noreply.github.com>
2026-10-04 22:24:13 +00:00
dependabot[bot] 08c4f3d3aa Chore(deps): Bump the utilities-patch group across 1 directory with 2 updates (#14338)
Bumps the utilities-patch group with 2 updates in the / directory: [psycopg-pool](https://github.com/psycopg/psycopg) and [ruff](https://github.com/astral-sh/ruff).


Updates `psycopg-pool` from 3.3.2 to 3.3.3
- [Changelog](https://github.com/psycopg/psycopg/blob/master/docs/news.rst)
- [Commits](https://github.com/psycopg/psycopg/compare/3.3.2...3.3.3)

Updates `ruff` from 0.16.8 to 0.16.9
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.16.8...0.16.9)

---
updated-dependencies:
- dependency-name: psycopg-pool
  dependency-version: 3.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: utilities-patch
- dependency-name: ruff
  dependency-version: 0.16.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: utilities-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-04 13:54:08 -07:00
shamoon c63afb47b2 Documentation: correct duplicates info (#14243) 2026-09-23 08:27:18 -07:00
354 changed files with 63053 additions and 49411 deletions

No files matched your search

-16
View File
@@ -39,7 +39,6 @@ ARG RUNTIME_PACKAGES="\
ghostscript \ ghostscript \
gnupg \ gnupg \
icc-profiles-free \ icc-profiles-free \
imagemagick \
# PostgreSQL # PostgreSQL
postgresql-client \ postgresql-client \
# MySQL / MariaDB # MySQL / MariaDB
@@ -93,21 +92,6 @@ RUN set -eux \
https://github.com/paperless-ngx/builder/releases/download/jbig2enc-trixie-v${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \ https://github.com/paperless-ngx/builder/releases/download/jbig2enc-trixie-v${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
&& dpkg --install ./jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb && dpkg --install ./jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb
# setup docker-specific things
# These change sometimes, but rarely
WORKDIR /usr/src/paperless/src/docker/
COPY [ \
"docker/rootfs/etc/ImageMagick-6/paperless-policy.xml", \
"./" \
]
RUN set -eux \
&& echo "Configuring ImageMagick" \
&& mkdir -p /etc/ImageMagick-6 \
&& mv paperless-policy.xml /etc/ImageMagick-6/policy.xml
# Packages needed only for building a few quick Python # Packages needed only for building a few quick Python
# dependencies # dependencies
ARG BUILD_PACKAGES="\ ARG BUILD_PACKAGES="\
@@ -66,7 +66,7 @@ services:
command: command:
- "gotenberg" - "gotenberg"
- "--chromium-disable-javascript=true" - "--chromium-disable-javascript=true"
- "--chromium-allow-list=file:///tmp/.*" - "--chromium-allow-list=^file:///tmp/.*"
tika: tika:
image: docker.io/apache/tika:3.3.1.0 image: docker.io/apache/tika:3.3.1.0
restart: unless-stopped restart: unless-stopped
+11 -1
View File
@@ -31,9 +31,19 @@ body:
value: | value: |
#### 🤖 Use of AI tools #### 🤖 Use of AI tools
If this report was generated, in whole or in part, by an AI tool or agent you **must** clearly say so in the description 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). If this report 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).
Reports written by an AI agent must describe the _observed behavior only_: what happened, how to reproduce it, logs etc. Do **not** include code analysis, speculation about the root cause, or suggest code fixes. Reports written by an AI agent must describe the _observed behavior only_: what happened, how to reproduce it, logs etc. Do **not** include code analysis, speculation about the root cause, or suggest code fixes.
- type: dropdown
id: ai-usage
attributes:
label: AI usage
description: Was any part of this report written by an AI tool or agent?
options:
- No AI tools were used
- Yes, AI tools helped write this report
validations:
required: true
- type: textarea - type: textarea
id: description id: description
attributes: attributes:
+43 -34
View File
@@ -1,53 +1,62 @@
name: Install apt packages name: Install apt packages
description: > description: >
Installs apt packages with a per-attempt timeout and retries. The hosted runner's Azure apt mirror intermittently serves archives at a crawl, which apt's own timeout does not catch. A stuck attempt is killed and retried, which usually lands on a healthy mirror node. Caches apt packages and their dependencies for an exact hosted runner image. Cache hits install offline; cache misses download from Ubuntu mirrors with bounded retries.
inputs: inputs:
packages: packages:
description: Space separated list of packages to install description: Space separated list of package names to install
required: true required: true
attempts: attempts:
description: Maximum number of attempts description: Maximum number of download attempts
default: "3" default: "3"
attempt-timeout: attempt-timeout:
description: Seconds before a single attempt is killed description: Seconds before a single download attempt is killed
default: "180" default: "180"
runs: runs:
using: composite using: composite
steps: steps:
- name: Identify apt cache
id: cache-key
shell: bash
env:
PACKAGES: ${{ inputs.packages }}
run: |
# The bundle contains only dependencies missing from this runner. Never
# restore it onto a different image or initial package installation.
# Without an image version (e.g. self-hosted), download normally.
if [ -n "${ImageVersion:-}" ]; then
fingerprint=$({
printf '%s\n' "$PACKAGES"
dpkg --print-architecture
dpkg-query -W -f='${binary:Package}=${Version} ${db:Status-Abbrev}\n'
sha256sum "$GITHUB_ACTION_PATH/action.yml" "$GITHUB_ACTION_PATH/install.sh" | cut -d ' ' -f 1
} | sha256sum | cut -d ' ' -f 1)
echo "key=apt-v1-${ImageOS}-${ImageVersion}-${fingerprint}" >> "$GITHUB_OUTPUT"
fi
- name: Restore apt packages
id: cache
if: steps.cache-key.outputs.key != ''
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
${{ runner.temp }}/apt-packages/*.deb
${{ runner.temp }}/apt-packages/complete
key: ${{ steps.cache-key.outputs.key }}
- name: Install ${{ inputs.packages }} - name: Install ${{ inputs.packages }}
shell: bash shell: bash
env: env:
PACKAGES: ${{ inputs.packages }} PACKAGES: ${{ inputs.packages }}
ATTEMPTS: ${{ inputs.attempts }} ATTEMPTS: ${{ inputs.attempts }}
ATTEMPT_TIMEOUT: ${{ inputs.attempt-timeout }} ATTEMPT_TIMEOUT: ${{ inputs.attempt-timeout }}
run: | CACHE_DIR: ${{ runner.temp }}/apt-packages
SUDO="" CACHE_HIT: ${{ steps.cache.outputs.cache-hit }}
if [ "$(id -u)" -ne 0 ]; then run: bash "$GITHUB_ACTION_PATH/install.sh"
SUDO="sudo" # Save immediately: a later test failure must not discard a good apt bundle.
fi - name: Save apt packages
if: steps.cache-key.outputs.key != '' && steps.cache.outputs.cache-hit != 'true'
attempt_install() { uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
$SUDO apt-get update -q with:
# Intentionally unquoted, this is a list of package names path: |
$SUDO apt-get install -q --yes --no-install-recommends $PACKAGES ${{ runner.temp }}/apt-packages/*.deb
} ${{ runner.temp }}/apt-packages/complete
export -f attempt_install key: ${{ steps.cache-key.outputs.key }}
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
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
set -euo pipefail
SUDO=()
if [ "$(id -u)" -ne 0 ]; then
SUDO=(sudo)
fi
# Inputs are package names, separated by whitespace (not shell expressions).
read -r -a requested_packages <<< "$PACKAGES"
mkdir -p "$CACHE_DIR"
install_local() {
local archives=("$CACHE_DIR"/*.deb)
if [ -e "${archives[0]}" ]; then
# Local .deb arguments pin the downloaded versions. No package lists or
# network access are needed, and apt still runs all package maintainer scripts.
"${SUDO[@]}" chmod a+r "${archives[@]}"
"${SUDO[@]}" apt-get install -q --yes --no-install-recommends \
--no-download -o "Dir::Cache::archives=$CACHE_DIR" "${archives[@]}"
fi
# An empty bundle is valid when the runner already includes every package.
local package
for package in "${requested_packages[@]}"; do
[ "$(dpkg-query -W -f='${Status}' "$package")" = "install ok installed" ]
done
}
if [ "$CACHE_HIT" = true ]; then
echo "Installing cached apt packages without contacting package repositories"
install_local
exit 0
fi
# The hosted runner gives Azure priority 1. A slow transfer never triggers its
# fallback mirrors, so retrying alone keeps selecting the same unhealthy mirror.
if [ -f /etc/apt/apt-mirrors.txt ]; then
"${SUDO[@]}" sed -i '\|azure\.archive\.ubuntu\.com|d' /etc/apt/apt-mirrors.txt
fi
# Download first so the bundle contains every missing transitive dependency.
# Keep downloads outside apt's default cache, which Docker cleanup hooks may wipe.
download_packages() {
"${SUDO[@]}" apt-get update -q --error-on=any
"${SUDO[@]}" apt-get install -q --yes --no-install-recommends \
--download-only -o "Dir::Cache::archives=$CACHE_DIR" "${requested_packages[@]}"
}
# Recreate the sudo and package arrays in the timed child process.
if [ "${1:-}" = download ]; then
download_packages
exit 0
fi
for attempt in $(seq 1 "$ATTEMPTS"); do
echo "::group::apt download attempt ${attempt}/${ATTEMPTS}"
if timeout --kill-after=10 "$ATTEMPT_TIMEOUT" bash "$0" download; then
echo "::endgroup::"
install_local
touch "$CACHE_DIR/complete"
exit 0
fi
echo "::endgroup::"
echo "::warning::apt download attempt ${attempt}/${ATTEMPTS} failed or exceeded ${ATTEMPT_TIMEOUT}s"
done
echo "::error::apt download failed after ${ATTEMPTS} attempts"
exit 1
+7 -9
View File
@@ -11,7 +11,7 @@ concurrency:
group: backend-${{ github.event.pull_request.number || github.ref }} group: backend-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true cancel-in-progress: true
env: env:
DEFAULT_UV_VERSION: "0.12.x" DEFAULT_UV_VERSION: "0.13.x"
# Match the Docker image: nltk refuses to read hardlinked data files, such as # Match the Docker image: nltk refuses to read hardlinked data files, such as
# the copy bundled with llama-index when uv links packages from its cache # the copy bundled with llama-index when uv links packages from its cache
UV_LINK_MODE: copy UV_LINK_MODE: copy
@@ -76,11 +76,12 @@ jobs:
- 'uv.lock' - 'uv.lock'
- 'docker/compose/docker-compose.ci-test.yml' - 'docker/compose/docker-compose.ci-test.yml'
- '.github/workflows/ci-backend.yml' - '.github/workflows/ci-backend.yml'
- '.github/actions/apt-install/**'
test: test:
needs: changes needs: changes
if: needs.changes.outputs.backend_changed == 'true' if: needs.changes.outputs.backend_changed == 'true'
name: "Python ${{ matrix.python-version }}" name: "Python ${{ matrix.python-version }}"
runs-on: ubuntu-24.04 runs-on: ubuntu-26.04
permissions: permissions:
contents: read contents: read
strategy: strategy:
@@ -102,7 +103,7 @@ jobs:
with: with:
python-version: "${{ matrix.python-version }}" python-version: "${{ matrix.python-version }}"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
@@ -111,10 +112,7 @@ jobs:
timeout-minutes: 12 timeout-minutes: 12
uses: $/.github/actions/apt-install uses: $/.github/actions/apt-install
with: with:
packages: unpaper tesseract-ocr imagemagick ghostscript poppler-utils packages: unpaper tesseract-ocr ghostscript poppler-utils qpdf
- name: Configure ImageMagick
run: |
sudo cp docker/rootfs/etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-6/policy.xml
- name: Install Python dependencies - name: Install Python dependencies
env: env:
PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }} PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }}
@@ -158,7 +156,7 @@ jobs:
needs: changes needs: changes
if: needs.changes.outputs.backend_changed == 'true' if: needs.changes.outputs.backend_changed == 'true'
name: Check project typing name: Check project typing
runs-on: ubuntu-24.04 runs-on: ubuntu-26.04
permissions: permissions:
contents: read contents: read
env: env:
@@ -175,7 +173,7 @@ jobs:
with: with:
python-version: "${{ env.DEFAULT_PYTHON }}" python-version: "${{ env.DEFAULT_PYTHON }}"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
+3 -3
View File
@@ -24,10 +24,10 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- runner: ubuntu-24.04 - runner: ubuntu-26.04
arch: amd64 arch: amd64
platform: linux/amd64 platform: linux/amd64
- runner: ubuntu-24.04-arm - runner: ubuntu-26.04-arm
arch: arm64 arch: arm64
platform: linux/arm64 platform: linux/arm64
runs-on: ${{ matrix.runner }} runs-on: ${{ matrix.runner }}
@@ -163,7 +163,7 @@ jobs:
archive: false archive: false
merge-and-push: merge-and-push:
name: Merge and Push Manifest name: Merge and Push Manifest
runs-on: ubuntu-24.04 runs-on: ubuntu-26.04
needs: build-arch needs: build-arch
if: needs.build-arch.outputs.should-push == 'true' if: needs.build-arch.outputs.should-push == 'true'
environment: image-publishing environment: image-publishing
+4 -4
View File
@@ -11,7 +11,7 @@ concurrency:
permissions: permissions:
contents: read contents: read
env: env:
DEFAULT_UV_VERSION: "0.12.x" DEFAULT_UV_VERSION: "0.13.x"
DEFAULT_PYTHON_VERSION: "3.12" DEFAULT_PYTHON_VERSION: "3.12"
jobs: jobs:
changes: changes:
@@ -65,7 +65,7 @@ jobs:
needs: changes needs: changes
if: needs.changes.outputs.docs_changed == 'true' if: needs.changes.outputs.docs_changed == 'true'
name: Build Documentation name: Build Documentation
runs-on: ubuntu-24.04 runs-on: ubuntu-26.04
steps: steps:
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- name: Checkout - name: Checkout
@@ -78,7 +78,7 @@ jobs:
with: with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }} python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
@@ -102,7 +102,7 @@ jobs:
name: Deploy Documentation name: Deploy Documentation
needs: [changes, build] needs: [changes, build]
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changes.outputs.docs_changed == 'true' if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changes.outputs.docs_changed == 'true'
runs-on: ubuntu-24.04 runs-on: ubuntu-26.04
permissions: permissions:
pages: write pages: write
id-token: write id-token: write
+9 -8
View File
@@ -68,11 +68,12 @@ jobs:
frontend: frontend:
- 'src-ui/**' - 'src-ui/**'
- '.github/workflows/ci-frontend.yml' - '.github/workflows/ci-frontend.yml'
- '.github/actions/apt-install/**'
install-dependencies: install-dependencies:
needs: changes needs: changes
if: needs.changes.outputs.frontend_changed == 'true' if: needs.changes.outputs.frontend_changed == 'true'
name: Install Dependencies name: Install Dependencies
runs-on: ubuntu-24.04 runs-on: ubuntu-26.04
permissions: permissions:
contents: read contents: read
steps: steps:
@@ -104,7 +105,7 @@ jobs:
name: Lint name: Lint
needs: [changes, install-dependencies] needs: [changes, install-dependencies]
if: needs.changes.outputs.frontend_changed == 'true' if: needs.changes.outputs.frontend_changed == 'true'
runs-on: ubuntu-24.04 runs-on: ubuntu-26.04
permissions: permissions:
contents: read contents: read
steps: steps:
@@ -137,7 +138,7 @@ jobs:
name: "Unit Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})" name: "Unit Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})"
needs: [changes, install-dependencies] needs: [changes, install-dependencies]
if: needs.changes.outputs.frontend_changed == 'true' if: needs.changes.outputs.frontend_changed == 'true'
runs-on: ubuntu-24.04 runs-on: ubuntu-26.04
permissions: permissions:
contents: read contents: read
strategy: strategy:
@@ -188,10 +189,10 @@ jobs:
name: E2E Tests name: E2E Tests
needs: [changes, install-dependencies] needs: [changes, install-dependencies]
if: needs.changes.outputs.frontend_changed == 'true' if: needs.changes.outputs.frontend_changed == 'true'
runs-on: ubuntu-24.04 runs-on: ubuntu-26.04
permissions: permissions:
contents: read contents: read
container: mcr.microsoft.com/playwright:v1.62.1-noble container: mcr.microsoft.com/playwright:v1.62.1-resolute
env: env:
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
@@ -216,9 +217,9 @@ jobs:
with: with:
python-version: '3.12' python-version: '3.12'
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
with: with:
version: '0.12.x' version: '0.13.x'
enable-cache: false enable-cache: false
python-version: ${{ steps.setup-python.outputs.python-version }} python-version: ${{ steps.setup-python.outputs.python-version }}
- name: Cache frontend dependencies - name: Cache frontend dependencies
@@ -246,7 +247,7 @@ jobs:
name: Frontend Build name: Frontend Build
needs: [changes, unit-tests, e2e-tests] needs: [changes, unit-tests, e2e-tests]
if: needs.changes.outputs.frontend_changed == 'true' if: needs.changes.outputs.frontend_changed == 'true'
runs-on: ubuntu-24.04 runs-on: ubuntu-26.04
permissions: permissions:
contents: read contents: read
steps: steps:
+8 -8
View File
@@ -8,13 +8,13 @@ concurrency:
group: release-${{ github.ref }} group: release-${{ github.ref }}
cancel-in-progress: false cancel-in-progress: false
env: env:
DEFAULT_UV_VERSION: "0.12.x" DEFAULT_UV_VERSION: "0.13.x"
DEFAULT_PYTHON_VERSION: "3.12" DEFAULT_PYTHON_VERSION: "3.12"
permissions: {} permissions: {}
jobs: jobs:
wait-for-docker: wait-for-docker:
name: Wait for Docker Build name: Wait for Docker Build
runs-on: ubuntu-24.04 runs-on: ubuntu-26.04
permissions: permissions:
checks: read checks: read
statuses: read statuses: read
@@ -30,7 +30,7 @@ jobs:
build-release: build-release:
name: Build Release name: Build Release
needs: wait-for-docker needs: wait-for-docker
runs-on: ubuntu-24.04 runs-on: ubuntu-26.04
permissions: permissions:
contents: read contents: read
steps: steps:
@@ -59,7 +59,7 @@ jobs:
with: with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }} python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: false enable-cache: false
@@ -73,7 +73,7 @@ jobs:
timeout-minutes: 12 timeout-minutes: 12
uses: $/.github/actions/apt-install uses: $/.github/actions/apt-install
with: with:
packages: gettext liblept5 packages: gettext libleptonica6
# ---- Build Documentation ---- # ---- Build Documentation ----
- name: Build documentation - name: Build documentation
env: env:
@@ -145,7 +145,7 @@ jobs:
publish-release: publish-release:
name: Publish Release name: Publish Release
needs: build-release needs: build-release
runs-on: ubuntu-24.04 runs-on: ubuntu-26.04
permissions: permissions:
contents: write contents: write
pull-requests: write pull-requests: write
@@ -197,7 +197,7 @@ jobs:
name: Append Changelog name: Append Changelog
needs: publish-release needs: publish-release
if: needs.publish-release.outputs.prerelease == 'false' if: needs.publish-release.outputs.prerelease == 'false'
runs-on: ubuntu-24.04 runs-on: ubuntu-26.04
permissions: permissions:
contents: write contents: write
pull-requests: write pull-requests: write
@@ -213,7 +213,7 @@ jobs:
with: with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }} python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: false enable-cache: false
+2 -2
View File
@@ -15,7 +15,7 @@ permissions:
jobs: jobs:
zizmor: zizmor:
name: Run zizmor name: Run zizmor
runs-on: ubuntu-24.04 runs-on: ubuntu-26.04
permissions: permissions:
contents: read contents: read
actions: read actions: read
@@ -29,7 +29,7 @@ jobs:
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
semgrep: semgrep:
name: Semgrep CE name: Semgrep CE
runs-on: ubuntu-24.04 runs-on: ubuntu-26.04
container: container:
image: semgrep/semgrep:1.155.0@sha256:cc869c685dcc0fe497c86258da9f205397d8108e56d21a86082ea4886e52784d image: semgrep/semgrep:1.155.0@sha256:cc869c685dcc0fe497c86258da9f205397d8108e56d21a86082ea4886e52784d
if: github.actor != 'dependabot[bot]' if: github.actor != 'dependabot[bot]'
+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
cleanup-images: cleanup-images:
name: Cleanup Image Tags for ${{ matrix.primary-name }} name: Cleanup Image Tags for ${{ matrix.primary-name }}
if: github.repository_owner == 'paperless-ngx' if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04 runs-on: ubuntu-26.04
environment: registry-maintenance environment: registry-maintenance
strategy: strategy:
fail-fast: false fail-fast: false
@@ -42,7 +42,7 @@ jobs:
cleanup-untagged-images: cleanup-untagged-images:
name: Cleanup Untagged Images Tags for ${{ matrix.primary-name }} name: Cleanup Untagged Images Tags for ${{ matrix.primary-name }}
if: github.repository_owner == 'paperless-ngx' if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04 runs-on: ubuntu-26.04
needs: needs:
- cleanup-images - cleanup-images
environment: registry-maintenance environment: registry-maintenance
+1 -1
View File
@@ -21,7 +21,7 @@ on:
jobs: jobs:
analyze: analyze:
name: Analyze name: Analyze
runs-on: ubuntu-24.04 runs-on: ubuntu-26.04
permissions: permissions:
actions: read actions: read
contents: read contents: read
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
synchronize-with-crowdin: synchronize-with-crowdin:
name: Crowdin Sync name: Crowdin Sync
if: github.repository_owner == 'paperless-ngx' if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04 runs-on: ubuntu-26.04
environment: translation-sync environment: translation-sync
steps: steps:
- name: Checkout - name: Checkout
+25 -8
View File
@@ -7,7 +7,7 @@ jobs:
# Note: peakoss/anti-slop does not support the `issues` event yet (all of its # Note: peakoss/anti-slop does not support the `issues` event yet (all of its
# issue inputs are still commented out upstream), so the checks that the PR Bot # issue inputs are still commented out upstream), so the checks that the PR Bot
# workflow gets from the action are implemented manually here. # workflow gets from the action are implemented manually here.
runs-on: ubuntu-latest runs-on: ubuntu-slim
permissions: permissions:
issues: write issues: write
steps: steps:
@@ -43,6 +43,12 @@ jobs:
const haystack = `${issue.title}\n${issue.body ?? ''}`; const haystack = `${issue.title}\n${issue.body ?? ''}`;
const honeypot = haystack.toUpperCase().includes('ASLOP-PR-VERIFY'); const honeypot = haystack.toUpperCase().includes('ASLOP-PR-VERIFY');
// The form's required "AI usage" dropdown renders as a `### AI usage` section. An agent
// that follows the template trips the honeypot even when it discloses, so only treat
// the honeypot as undisclosed AI use when the reporter did not answer "Yes" here.
const aiUsage = (issue.body ?? '').match(/^###\s+AI usage\s*\n+\s*(.+?)\s*$/m)?.[1] ?? '';
const disclosedAI = aiUsage.toLowerCase().startsWith('yes');
// Issues opened through the form always get the template's default labels. GitHub // Issues opened through the form always get the template's default labels. GitHub
// applies them a second or two *after* creation, so re-read them instead of trusting // applies them a second or two *after* creation, so re-read them instead of trusting
// the webhook payload, and retry before concluding that there are none. // the webhook payload, and retry before concluding that there are none.
@@ -55,19 +61,30 @@ jobs:
if (labels.some((label) => templateLabels.includes(label))) break; if (labels.some((label) => templateLabels.includes(label))) break;
} }
const bypassedTemplate = !labels.some((label) => templateLabels.includes(label)); const bypassedTemplate = !labels.some((label) => templateLabels.includes(label));
core.info(`Labels: [${labels.join(', ')}], honeypot: ${honeypot}, bypassed template: ${bypassedTemplate}`); core.info(
`Labels: [${labels.join(', ')}], honeypot: ${honeypot}, AI usage: "${aiUsage}", ` +
`bypassed template: ${bypassedTemplate}`
);
if (!honeypot && !bypassedTemplate) { if (!bypassedTemplate && (disclosedAI || !honeypot)) {
core.info('No slop signals found'); if (disclosedAI) {
core.info('AI use disclosed, labeling only');
await github.rest.issues.addLabels({ ...common, labels: ['ai'] });
} else {
core.info('No slop signals found');
}
return; return;
} }
// The honeypot is only ever tripped deliberately, so that message can name the cause.
// A missing template label only tells us the issue did not come from the form, which // A missing template label only tells us the issue did not come from the form, which
// is reason enough to close it, but not proof of how it was written. // is reason enough to close it, but not proof of how it was written. It takes priority:
const body = honeypot // a hand-built body can carry the honeypot *and* a disclosure, and the form message is
// the only one that is accurate for both. Reaching the honeypot message means the issue
// came from the form without disclosing AI usage, so that message can name the cause.
const body = honeypot && !bypassedTemplate
? 'This issue was automatically closed because it contains a marker that is only visible to ' + ? 'This issue was automatically closed because it contains a marker that is only visible to ' +
'automated tools, which indicates it was generated by an AI agent without being disclosed as such.\n\n' + 'automated tools, which indicates it was generated by an AI agent, but the "AI usage" field does not ' +
'disclose this.\n\n' +
`Please see our [contributing guidelines](${contributing}) and [Code of Conduct](${codeOfConduct}). ` + `Please see our [contributing guidelines](${contributing}) and [Code of Conduct](${codeOfConduct}). ` +
'You are welcome to open a new issue that describes the problem you observed in your own words.' 'You are welcome to open a new issue that describes the problem you observed in your own words.'
: 'This issue was automatically closed because it was not opened using our bug report form. ' + : 'This issue was automatically closed because it was not opened using our bug report form. ' +
+2 -12
View File
@@ -4,7 +4,7 @@ on:
types: [opened] types: [opened]
jobs: jobs:
Anti-slop: Anti-slop:
runs-on: ubuntu-latest runs-on: ubuntu-slim
permissions: permissions:
contents: read contents: read
issues: read issues: read
@@ -24,7 +24,7 @@ jobs:
ASLOP-PR-VERIFY ASLOP-PR-VERIFY
pr-bot: pr-bot:
name: Automated PR Bot name: Automated PR Bot
runs-on: ubuntu-latest runs-on: ubuntu-slim
# Runs after Anti-slop so the welcome comment can see whether the PR was closed # Runs after Anti-slop so the welcome comment can see whether the PR was closed
# instead of racing it. Still runs if that job fails, so labeling is not lost. # instead of racing it. Still runs if that job fails, so labeling is not lost.
needs: Anti-slop needs: Anti-slop
@@ -38,16 +38,6 @@ jobs:
uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0 uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Label by size
uses: Gascon1/pr-size-labeler@deff8ed00a76639a7c0f197525bafa3350ba4c36 # v1.3.0
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
xs_label: 'small-change'
xs_diff: '9'
s_label: 'non-trivial'
s_diff: '99999'
fail_if_xl: 'false'
excluded_files: /\.lock$/ /\.txt$/ ^src-ui/pnpm-lock\.yaml$ ^src-ui/messages\.xlf$ ^src/locale/en_US/LC_MESSAGES/django\.po$
- name: Label by PR title - name: Label by PR title
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with: with:
+1 -1
View File
@@ -12,7 +12,7 @@ permissions:
jobs: jobs:
pr_opened_or_reopened: pr_opened_or_reopened:
name: pr_opened_or_reopened name: pr_opened_or_reopened
runs-on: ubuntu-24.04 runs-on: ubuntu-slim
permissions: permissions:
# write permission is required for autolabeler # write permission is required for autolabeler
pull-requests: write pull-requests: write
+5 -5
View File
@@ -9,7 +9,7 @@ jobs:
stale: stale:
name: 'Stale' name: 'Stale'
if: github.repository_owner == 'paperless-ngx' if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04 runs-on: ubuntu-26.04
permissions: permissions:
issues: write issues: write
pull-requests: write pull-requests: write
@@ -34,7 +34,7 @@ jobs:
lock-threads: lock-threads:
name: 'Lock Old Threads' name: 'Lock Old Threads'
if: github.repository_owner == 'paperless-ngx' if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04 runs-on: ubuntu-26.04
permissions: permissions:
issues: write issues: write
pull-requests: write pull-requests: write
@@ -58,7 +58,7 @@ jobs:
close-answered-discussions: close-answered-discussions:
name: 'Close Answered Discussions' name: 'Close Answered Discussions'
if: github.repository_owner == 'paperless-ngx' if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04 runs-on: ubuntu-slim
permissions: permissions:
discussions: write discussions: write
steps: steps:
@@ -117,7 +117,7 @@ jobs:
close-outdated-discussions: close-outdated-discussions:
name: 'Close Outdated Discussions' name: 'Close Outdated Discussions'
if: github.repository_owner == 'paperless-ngx' if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04 runs-on: ubuntu-slim
permissions: permissions:
discussions: write discussions: write
steps: steps:
@@ -211,7 +211,7 @@ jobs:
close-unsupported-feature-requests: close-unsupported-feature-requests:
name: 'Close Unsupported Feature Requests' name: 'Close Unsupported Feature Requests'
if: github.repository_owner == 'paperless-ngx' if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04 runs-on: ubuntu-slim
permissions: permissions:
discussions: write discussions: write
steps: steps:
+3 -3
View File
@@ -4,11 +4,11 @@ on:
branches: branches:
- dev - dev
env: env:
DEFAULT_UV_VERSION: "0.12.x" DEFAULT_UV_VERSION: "0.13.x"
jobs: jobs:
generate-translate-strings: generate-translate-strings:
name: Generate Translation Strings name: Generate Translation Strings
runs-on: ubuntu-latest runs-on: ubuntu-26.04
environment: translation-sync environment: translation-sync
permissions: permissions:
contents: write contents: write
@@ -30,7 +30,7 @@ jobs:
with: with:
packages: gettext packages: gettext
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
-1
View File
@@ -38,7 +38,6 @@ src/documents/bulk_edit.py:0: error: Incompatible types in assignment (expressio
src/documents/bulk_edit.py:0: error: Invalid index type "str" for "dict[FieldDataType, str]"; expected type "FieldDataType" [index] src/documents/bulk_edit.py:0: error: Invalid index type "str" for "dict[FieldDataType, str]"; expected type "FieldDataType" [index]
src/documents/bulk_edit.py:0: error: List comprehension has incompatible type List[tuple[int, Any]]; expected List[int] [misc] src/documents/bulk_edit.py:0: error: List comprehension has incompatible type List[tuple[int, Any]]; expected List[int] [misc]
src/documents/bulk_edit.py:0: error: List comprehension has incompatible type List[tuple[int, None]]; expected List[int] [misc] src/documents/bulk_edit.py:0: error: List comprehension has incompatible type List[tuple[int, None]]; expected List[int] [misc]
src/documents/bulk_edit.py:0: error: Missing named argument "p" for "remove" of "PageList" [call-arg]
src/documents/bulk_edit.py:0: error: Missing type arguments for generic type "dict" [type-arg] src/documents/bulk_edit.py:0: error: Missing type arguments for generic type "dict" [type-arg]
src/documents/bulk_edit.py:0: error: Missing type arguments for generic type "dict" [type-arg] src/documents/bulk_edit.py:0: error: Missing type arguments for generic type "dict" [type-arg]
src/documents/bulk_edit.py:0: error: Need type annotation for "to_create" (hint: "to_create: list[<type>] = ...") [var-annotated] src/documents/bulk_edit.py:0: error: Need type annotation for "to_create" (hint: "to_create: list[<type>] = ...") [var-annotated]
-7
View File
@@ -91,13 +91,6 @@
"concise_description": "Argument `list[int]` is not assignable to parameter `args` with type `tuple[Any, ...] | None` in function `celery.app.task.Task.apply_async`", "concise_description": "Argument `list[int]` is not assignable to parameter `args` with type `tuple[Any, ...] | None` in function `celery.app.task.Task.apply_async`",
"severity": "error" "severity": "error"
}, },
{
"column": 33,
"path": "src/documents/bulk_edit.py",
"name": "missing-argument",
"concise_description": "Missing argument `p` in function `pikepdf._core.PageList.remove`",
"severity": "error"
},
{ {
"column": 25, "column": 25,
"path": "src/documents/caching.py", "path": "src/documents/caching.py",
+4 -13
View File
@@ -43,26 +43,17 @@ Once you have submitted a **P**ull **R**equest it will be reviewed, approved, an
Important: 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. Instead of opening a PR which does not meet this requirement, please open a feature request instead, to gather feedback from both users and the project maintainers. Important: 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. Instead of opening a PR which does not meet this requirement, please open a feature request instead, to gather feedback from both users and the project maintainers.
## Non-Trivial Requests ## PR Review Process
PRs deemed `non-trivial` will go through a stricter review process before being merged into `dev`. This is to ensure code quality and complete functionality (free of side effects). Our community review process for PRs is the following:
Examples of `non-trivial` PRs might include:
- Additional features
- Large changes to many distinct files
- Breaking or deprecation of existing features
Our community review process for `non-trivial` PRs is the following:
1. Must pass usual automated code tests and formatting checks. 1. Must pass usual automated code tests and formatting checks.
2. The PR will be assigned and pinged to the appropriately experienced team (i.e. @paperless-ngx/backend for backend changes). 2. The PR will be assigned and pinged to the appropriately experienced team (i.e. @paperless-ngx/backend for backend changes).
3. Development team will check and test code manually (possibly over several days). 3. Development team will check and test code manually (possibly over several days).
- You may be asked to make changes or rebase. - You may be asked to make changes or rebase.
- The team may ask for additional testing done by @paperless-ngx/test 4. One or more members of the team will approve and finally merge the request into `dev` 🎉.
4. **At least two** members of the team will approve and finally merge the request into `dev` 🎉.
This process might be slow as community members have different schedules and time to dedicate to the Paperless project. However it ensures community code reviews are as brilliantly thorough as they once were with @jonaswinkler. This process might be slow as community members have different schedules and time to dedicate to the Paperless project. However it ensures community code reviews are thorough and of high quality.
# Use of AI Tools # Use of AI Tools
+1 -4
View File
@@ -30,7 +30,7 @@ RUN set -eux \
# Purpose: Installs s6-overlay and rootfs # Purpose: Installs s6-overlay and rootfs
# Comments: # Comments:
# - Don't leave anything extra in here either # - Don't leave anything extra in here either
FROM ghcr.io/astral-sh/uv:0.12.23-python3.14-trixie-slim AS s6-overlay-base FROM ghcr.io/astral-sh/uv:0.13.5-python3.14-trixie-slim@sha256:d8e732839dfc4225a1ff698803b478b2223c05636c055d3add41ab9dbec0f315 AS s6-overlay-base
WORKDIR /usr/src/s6 WORKDIR /usr/src/s6
@@ -129,7 +129,6 @@ ARG RUNTIME_PACKAGES="\
ghostscript \ ghostscript \
gnupg \ gnupg \
icc-profiles-free \ icc-profiles-free \
imagemagick \
# PostgreSQL # PostgreSQL
postgresql-client \ postgresql-client \
# MySQL / MariaDB # MySQL / MariaDB
@@ -167,8 +166,6 @@ RUN set -eux \
https://github.com/paperless-ngx/builder/releases/download/jbig2enc-trixie-v${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \ https://github.com/paperless-ngx/builder/releases/download/jbig2enc-trixie-v${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
&& echo "Installing jbig2enc" \ && echo "Installing jbig2enc" \
&& dpkg --install ./jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \ && dpkg --install ./jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
&& echo "Configuring imagemagick" \
&& cp /etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-6/policy.xml \
&& echo "Cleaning up image layer" \ && echo "Cleaning up image layer" \
&& rm --force --verbose *.deb \ && rm --force --verbose *.deb \
&& rm --recursive --force --verbose /var/lib/apt/lists/* \ && rm --recursive --force --verbose /var/lib/apt/lists/* \
+4 -8
View File
@@ -21,7 +21,7 @@ Paperless-ngx is a document management system that transforms your physical docu
Paperless-ngx is the official successor to the original [Paperless](https://github.com/the-paperless-project/paperless) & [Paperless-ng](https://github.com/jonaswinkler/paperless-ng) projects and is designed to distribute the responsibility of advancing and supporting the project among a team of people. [Consider joining us!](#community-support) Paperless-ngx is the official successor to the original [Paperless](https://github.com/the-paperless-project/paperless) & [Paperless-ng](https://github.com/jonaswinkler/paperless-ng) projects and is designed to distribute the responsibility of advancing and supporting the project among a team of people. [Consider joining us!](#community-support)
Thanks to the generous folks at [DigitalOcean](https://m.do.co/c/8d70b916d462), a demo is available at [demo.paperless-ngx.com](https://demo.paperless-ngx.com) using login `demo` / `demo`. _Note: demo content is reset frequently and confidential information should not be uploaded._ Thanks to the generous folks at [Fyra Stack](https://fyrastack.com), a demo is available at [demo.paperless-ngx.com](https://demo.paperless-ngx.com) using login `demo` / `demo`. _Note: demo content is reset frequently and confidential information should not be uploaded._
- [Features](#features) - [Features](#features)
- [Getting started](#getting-started) - [Getting started](#getting-started)
@@ -33,13 +33,9 @@ Thanks to the generous folks at [DigitalOcean](https://m.do.co/c/8d70b916d462),
- [Related Projects](#related-projects) - [Related Projects](#related-projects)
- [Important Note](#important-note) - [Important Note](#important-note)
<p align="right">This project is supported by:<br/> <p align="right">Demo hosting provided by:<br/>
<a href="https://m.do.co/c/8d70b916d462" style="padding-top: 4px; display: block;"> <a href="https://fyrastack.com">
<picture> <img src="docs/assets/sponsors/fyra-stack.svg" alt="" width="28px" align="absmiddle"> Fyra Stack
<source media="(prefers-color-scheme: dark)" srcset="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_white.svg" width="140px">
<source media="(prefers-color-scheme: light)" srcset="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" width="140px">
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_black_.svg" width="140px">
</picture>
</a> </a>
</p> </p>
+1 -1
View File
@@ -14,7 +14,7 @@ services:
command: command:
- "gotenberg" - "gotenberg"
- "--chromium-disable-javascript=true" - "--chromium-disable-javascript=true"
- "--chromium-allow-list=file:///tmp/.*" - "--chromium-allow-list=^file:///tmp/.*"
- "--log-level=warn" - "--log-level=warn"
- "--log-format=text" - "--log-format=text"
tika: tika:
@@ -40,7 +40,6 @@ services:
volumes: volumes:
- dbdata:/var/lib/mysql - dbdata:/var/lib/mysql
environment: environment:
MARIADB_HOST: paperless
MARIADB_DATABASE: paperless MARIADB_DATABASE: paperless
MARIADB_USER: paperless MARIADB_USER: paperless
MARIADB_PASSWORD: paperless MARIADB_PASSWORD: paperless
@@ -79,7 +78,7 @@ services:
command: command:
- "gotenberg" - "gotenberg"
- "--chromium-disable-javascript=true" - "--chromium-disable-javascript=true"
- "--chromium-allow-list=file:///tmp/.*" - "--chromium-allow-list=^file:///tmp/.*"
tika: tika:
image: docker.io/apache/tika:3.3.1.0 image: docker.io/apache/tika:3.3.1.0
restart: unless-stopped restart: unless-stopped
@@ -36,7 +36,6 @@ services:
volumes: volumes:
- dbdata:/var/lib/mysql - dbdata:/var/lib/mysql
environment: environment:
MARIADB_HOST: paperless
MARIADB_DATABASE: paperless MARIADB_DATABASE: paperless
MARIADB_USER: paperless MARIADB_USER: paperless
MARIADB_PASSWORD: paperless MARIADB_PASSWORD: paperless
@@ -74,7 +74,7 @@ services:
command: command:
- "gotenberg" - "gotenberg"
- "--chromium-disable-javascript=true" - "--chromium-disable-javascript=true"
- "--chromium-allow-list=file:///tmp/.*" - "--chromium-allow-list=^file:///tmp/.*"
tika: tika:
image: docker.io/apache/tika:3.3.1.0 image: docker.io/apache/tika:3.3.1.0
restart: unless-stopped restart: unless-stopped
@@ -63,7 +63,7 @@ services:
command: command:
- "gotenberg" - "gotenberg"
- "--chromium-disable-javascript=true" - "--chromium-disable-javascript=true"
- "--chromium-allow-list=file:///tmp/.*" - "--chromium-allow-list=^file:///tmp/.*"
tika: tika:
image: docker.io/apache/tika:3.3.1.0 image: docker.io/apache/tika:3.3.1.0
restart: unless-stopped restart: unless-stopped
@@ -1,101 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policymap [
<!ELEMENT policymap (policy)+>
<!ATTLIST policymap xmlns CDATA #FIXED ''>
<!ELEMENT policy EMPTY>
<!ATTLIST policy xmlns CDATA #FIXED '' domain NMTOKEN #REQUIRED
name NMTOKEN #IMPLIED pattern CDATA #IMPLIED rights NMTOKEN #IMPLIED
stealth NMTOKEN #IMPLIED value CDATA #IMPLIED>
]>
<!--
Configure ImageMagick policies.
Domains include system, delegate, coder, filter, path, or resource.
Rights include none, read, write, execute and all. Use | to combine them,
for example: "read | write" to permit read from, or write to, a path.
Use a glob expression as a pattern.
Suppose we do not want users to process MPEG video images:
<policy domain="delegate" rights="none" pattern="mpeg:decode" />
Here we do not want users reading images from HTTP:
<policy domain="coder" rights="none" pattern="HTTP" />
The /repository file system is restricted to read only. We use a glob
expression to match all paths that start with /repository:
<policy domain="path" rights="read" pattern="/repository/*" />
Lets prevent users from executing any image filters:
<policy domain="filter" rights="none" pattern="*" />
Any large image is cached to disk rather than memory:
<policy domain="resource" name="area" value="1GP"/>
Define arguments for the memory, map, area, width, height and disk resources
with SI prefixes (.e.g 100MB). In addition, resource policies are maximums
for each instance of ImageMagick (e.g. policy memory limit 1GB, -limit 2GB
exceeds policy maximum so memory limit is 1GB).
Rules are processed in order. Here we want to restrict ImageMagick to only
read or write a small subset of proven web-safe image types:
<policy domain="delegate" rights="none" pattern="*" />
<policy domain="filter" rights="none" pattern="*" />
<policy domain="coder" rights="none" pattern="*" />
<policy domain="coder" rights="read|write" pattern="{GIF,JPEG,PNG,WEBP}" />
-->
<policymap>
<!-- <policy domain="system" name="shred" value="2"/> -->
<!-- <policy domain="system" name="precision" value="6"/> -->
<!-- <policy domain="system" name="memory-map" value="anonymous"/> -->
<!-- <policy domain="system" name="max-memory-request" value="256MiB"/> -->
<!-- <policy domain="resource" name="temporary-path" value="/tmp"/> -->
<policy domain="resource" name="memory" value="256MiB"/>
<policy domain="resource" name="map" value="512MiB"/>
<policy domain="resource" name="width" value="16KP"/>
<policy domain="resource" name="height" value="16KP"/>
<!-- <policy domain="resource" name="list-length" value="128"/> -->
<policy domain="resource" name="area" value="128MB"/>
<policy domain="resource" name="disk" value="1GiB"/>
<!-- <policy domain="resource" name="file" value="768"/> -->
<!-- <policy domain="resource" name="thread" value="4"/> -->
<!-- <policy domain="resource" name="throttle" value="0"/> -->
<!-- <policy domain="resource" name="time" value="3600"/> -->
<!-- Paperless does not process SVG or ImageMagick scripting formats. -->
<policy domain="coder" rights="none" pattern="SVG" />
<policy domain="coder" rights="none" pattern="SVGZ" />
<policy domain="coder" rights="none" pattern="MSVG" />
<policy domain="coder" rights="none" pattern="RSVG" />
<policy domain="coder" rights="none" pattern="MSL" />
<policy domain="coder" rights="none" pattern="MVG" />
<policy domain="coder" rights="none" pattern="EPHEMERAL" />
<!-- <policy domain="module" rights="none" pattern="{PS,PDF,XPS}" /> -->
<!-- <policy domain="delegate" rights="none" pattern="HTTPS" /> -->
<!-- <policy domain="path" rights="none" pattern="@*" /> -->
<!-- <policy domain="cache" name="memory-map" value="anonymous"/> -->
<!-- <policy domain="cache" name="synchronize" value="True"/> -->
<!-- <policy domain="cache" name="shared-secret" value="passphrase" stealth="true"/> -->
<!-- <policy domain="system" name="pixel-cache-memory" value="anonymous"/> -->
<!-- <policy domain="system" name="shred" value="2"/> -->
<!-- <policy domain="system" name="precision" value="6"/> -->
<!-- use curl -->
<policy domain="delegate" rights="none" pattern="URL" />
<policy domain="delegate" rights="none" pattern="HTTPS" />
<policy domain="delegate" rights="none" pattern="HTTP" />
<!-- in order to avoid to get image with password text -->
<policy domain="path" rights="none" pattern="@*"/>
<!-- disable ghostscript format types -->
<policy domain="coder" rights="none" pattern="PS" />
<policy domain="coder" rights="none" pattern="PS2" />
<policy domain="coder" rights="none" pattern="PS3" />
<policy domain="coder" rights="none" pattern="EPS" />
<policy domain="coder" rights="read|write" pattern="PDF" />
<policy domain="coder" rights="none" pattern="XPS" />
</policymap>
+13
View File
@@ -1010,6 +1010,19 @@ documents to both separate and categorize them in a single operation.
**Example:** A 6-page scan with TAG:invoice on page 3 and TAG:receipt on page 5 will create **Example:** A 6-page scan with TAG:invoice on page 3 and TAG:receipt on page 5 will create
three documents: pages 1-2 (no tags), pages 3-4 (tagged "invoice"), and pages 5-6 (tagged "receipt"). three documents: pages 1-2 (no tags), pages 3-4 (tagged "invoice"), and pages 5-6 (tagged "receipt").
### Barcode Contents {#barcode-contents}
By default, Paperless only uses barcodes for splitting, ASNs and tags. With
[`PAPERLESS_CONSUMER_STORE_BARCODE_VALUES`](configuration.md#PAPERLESS_CONSUMER_STORE_BARCODE_VALUES)
enabled, it stores the content of every barcode with the document, e.g. payment codes or QR codes.
- Barcodes are listed on the **Metadata** tab with page, type and content, and can be copied.
- The API returns them in the `barcodes` field of `/api/documents/{id}/metadata/`.
- They can be [searched](usage.md#searching-barcodes), e.g. `barcodes:DE89370400440532013000`.
- Only the first [`PAPERLESS_CONSUMER_BARCODE_MAX_PAGES`](configuration.md#PAPERLESS_CONSUMER_BARCODE_MAX_PAGES)
pages are scanned. Reprocessing reads the barcodes of existing documents.
- Each version keeps its own barcodes, and the newest version's are shown and searched.
## Automatic collation of double-sided documents {#collate} ## Automatic collation of double-sided documents {#collate}
!!! note !!! note
+29
View File
@@ -101,3 +101,32 @@ h4 code {
border-top: .05rem solid var(--md-default-fg-color--lightest); border-top: .05rem solid var(--md-default-fg-color--lightest);
} }
} }
.md-typeset .demo-sponsor {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: .35rem;
margin-top: -1rem;
margin-bottom: 2rem;
color: var(--md-default-fg-color--light);
font-size: .65rem;
}
.md-typeset .demo-sponsor a {
display: inline-flex;
align-items: center;
gap: .25rem;
color: inherit;
font-weight: 600;
}
.md-typeset .demo-sponsor a:hover {
color: var(--md-accent-fg-color);
}
.md-typeset .demo-sponsor img {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="666.667" height="666.667" viewBox="0 0 666.667 666.667"><g stroke-width="1.333"><path fill="#fef400" d="m332.66668 418.66668h-84.66667v-85.33334-85.33333h84.66667 84.66667v-85.33334-85.333334h85.33333 85.33334v85.333334 85.33334h-85.33334-85.33333v85.33333 85.33334z"/><path fill="#fe6565" d="m248.00001 589.33335h-170.666674v-170.66667-170.66667h85.333334 85.33334v-85.33334-85.333334h84.66667 84.66667v85.333334 85.33334h85.33333 85.33334v85.33333 85.33334h-84.66667-84.66667v85.33334 85.33333zm84.66667-170.66667h84.66667v-85.33334-85.33333h-84.66667-84.66667v85.33333 85.33334z"/></g></svg>

After

Width:  |  Height:  |  Size: 641 B

+240
View File
@@ -1,5 +1,245 @@
# Changelog # Changelog
## paperless-ngx 3.3.4
### All App Changes
- Chore: Update certifi for eMudhra CA incident [@shamoon](https://github.com/shamoon) ([#14533](https://github.com/paperless-ngx/paperless-ngx/pull/14533))
- Chore: Update uv to 0.13 for the eMudhra CA fixes [@stumpylog](https://github.com/stumpylog) ([#14532](https://github.com/paperless-ngx/paperless-ngx/pull/14532))
## paperless-ngx 3.3.3
### Bug Fixes
- Fix: fix global search input layout shifting [@shamoon](https://github.com/shamoon) ([#14491](https://github.com/paperless-ngx/paperless-ngx/pull/14491))
- Fix: dont include doc content on global search results [@shamoon](https://github.com/shamoon) ([#14488](https://github.com/paperless-ngx/paperless-ngx/pull/14488))
- Fix: remove some redundant pagers [@shamoon](https://github.com/shamoon) ([#14487](https://github.com/paperless-ngx/paperless-ngx/pull/14487))
- Fix: prevent weird wrapping of dashboard widget column titles [@shamoon](https://github.com/shamoon) ([#14486](https://github.com/paperless-ngx/paperless-ngx/pull/14486))
- Fix: dont paginate 'all' tasks view across sections [@shamoon](https://github.com/shamoon) ([#14484](https://github.com/paperless-ngx/paperless-ngx/pull/14484))
- Fix: use float parsing for importer manifest records with a passphrase [@stumpylog](https://github.com/stumpylog) ([#14483](https://github.com/paperless-ngx/paperless-ngx/pull/14483))
- Fix: respect subpath in logout, share link and auto-login redirects [@jaimevelaz](https://github.com/jaimevelaz) ([#14476](https://github.com/paperless-ngx/paperless-ngx/pull/14476))
- Fix: dont tie suggestions button to ai enabled any more [@shamoon](https://github.com/shamoon) ([#14478](https://github.com/paperless-ngx/paperless-ngx/pull/14478))
- Fix: handle single-class classifier in threshold prediction (#<!---->14471) [@stumpylog](https://github.com/stumpylog) ([#14473](https://github.com/paperless-ngx/paperless-ngx/pull/14473))
- Fix: keep counter and extension when shortening long public filenames [@stumpylog](https://github.com/stumpylog) ([#14472](https://github.com/paperless-ngx/paperless-ngx/pull/14472))
### All App Changes
<details>
<summary>10 changes</summary>
- Fix: fix global search input layout shifting [@shamoon](https://github.com/shamoon) ([#14491](https://github.com/paperless-ngx/paperless-ngx/pull/14491))
- Fix: dont include doc content on global search results [@shamoon](https://github.com/shamoon) ([#14488](https://github.com/paperless-ngx/paperless-ngx/pull/14488))
- Fix: remove some redundant pagers [@shamoon](https://github.com/shamoon) ([#14487](https://github.com/paperless-ngx/paperless-ngx/pull/14487))
- Fix: prevent weird wrapping of dashboard widget column titles [@shamoon](https://github.com/shamoon) ([#14486](https://github.com/paperless-ngx/paperless-ngx/pull/14486))
- Fix: dont paginate 'all' tasks view across sections [@shamoon](https://github.com/shamoon) ([#14484](https://github.com/paperless-ngx/paperless-ngx/pull/14484))
- Fix: use float parsing for importer manifest records with a passphrase [@stumpylog](https://github.com/stumpylog) ([#14483](https://github.com/paperless-ngx/paperless-ngx/pull/14483))
- Fix: respect subpath in logout, share link and auto-login redirects [@jaimevelaz](https://github.com/jaimevelaz) ([#14476](https://github.com/paperless-ngx/paperless-ngx/pull/14476))
- Fix: dont tie suggestions button to ai enabled any more [@shamoon](https://github.com/shamoon) ([#14478](https://github.com/paperless-ngx/paperless-ngx/pull/14478))
- Fix: handle single-class classifier in threshold prediction (#<!---->14471) [@stumpylog](https://github.com/stumpylog) ([#14473](https://github.com/paperless-ngx/paperless-ngx/pull/14473))
- Fix: keep counter and extension when shortening long public filenames [@stumpylog](https://github.com/stumpylog) ([#14472](https://github.com/paperless-ngx/paperless-ngx/pull/14472))
</details>
## paperless-ngx 3.3.2
### Bug Fixes
- Fix: copy only direct user grants in DocumentMetadataOverrides.from\_document [@stumpylog](https://github.com/stumpylog) ([#14452](https://github.com/paperless-ngx/paperless-ngx/pull/14452))
- Fix: support combined text/query searches [@shamoon](https://github.com/shamoon) ([#14443](https://github.com/paperless-ngx/paperless-ngx/pull/14443))
- Fix: make exporter derived file names unique when originals differ only by extension [@stumpylog](https://github.com/stumpylog) ([#14429](https://github.com/paperless-ngx/paperless-ngx/pull/14429))
### Dependencies
- Chore(deps): Bump filelock from 3.32.6 to 4.0.12 @[dependabot[bot]](https://github.com/apps/dependabot) ([#14336](https://github.com/paperless-ngx/paperless-ngx/pull/14336))
- Chore(deps): Bump the uv group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14418](https://github.com/paperless-ngx/paperless-ngx/pull/14418))
### All App Changes
<details>
<summary>5 changes</summary>
- Fix: copy only direct user grants in DocumentMetadataOverrides.from\_document [@stumpylog](https://github.com/stumpylog) ([#14452](https://github.com/paperless-ngx/paperless-ngx/pull/14452))
- Chore(deps): Bump filelock from 3.32.6 to 4.0.12 @[dependabot[bot]](https://github.com/apps/dependabot) ([#14336](https://github.com/paperless-ngx/paperless-ngx/pull/14336))
- Fix: support combined text/query searches [@shamoon](https://github.com/shamoon) ([#14443](https://github.com/paperless-ngx/paperless-ngx/pull/14443))
- Chore(deps): Bump the uv group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14418](https://github.com/paperless-ngx/paperless-ngx/pull/14418))
- Fix: make exporter derived file names unique when originals differ only by extension [@stumpylog](https://github.com/stumpylog) ([#14429](https://github.com/paperless-ngx/paperless-ngx/pull/14429))
</details>
## paperless-ngx 3.3.1
### Bug Fixes
- Fix: use a root document's effective content for the LLM index and pr… [@stumpylog](https://github.com/stumpylog) ([#14423](https://github.com/paperless-ngx/paperless-ngx/pull/14423))
- Fix: index document versions as their root document [@stumpylog](https://github.com/stumpylog) ([#14422](https://github.com/paperless-ngx/paperless-ngx/pull/14422))
- Fix: authorize document versions by their root in the single-object permission check [@stumpylog](https://github.com/stumpylog) ([#14421](https://github.com/paperless-ngx/paperless-ngx/pull/14421))
- Fix: Provide better error messages to users for invalid JSON [@stumpylog](https://github.com/stumpylog) ([#14419](https://github.com/paperless-ngx/paperless-ngx/pull/14419))
- Fix: authorize document versions by their root and speed up permission id sets [@stumpylog](https://github.com/stumpylog) ([#14396](https://github.com/paperless-ngx/paperless-ngx/pull/14396))
- Chore: update the db options warning [@shamoon](https://github.com/shamoon) ([#14398](https://github.com/paperless-ngx/paperless-ngx/pull/14398))
- Fix: bulk reprocess latest version for root documents [@shamoon](https://github.com/shamoon) ([#14385](https://github.com/paperless-ngx/paperless-ngx/pull/14385))
- Fix: consistently use root doc for version action permissions [@shamoon](https://github.com/shamoon) ([#14384](https://github.com/paperless-ngx/paperless-ngx/pull/14384))
- Fix: Don't attempt to index created dates which are not representable in the FTS index [@stumpylog](https://github.com/stumpylog) ([#14390](https://github.com/paperless-ngx/paperless-ngx/pull/14390))
- Fix: retry a search index rebuild that was interrupted [@stumpylog](https://github.com/stumpylog) ([#14379](https://github.com/paperless-ngx/paperless-ngx/pull/14379))
### Documentation
- Chore: silence gotenberg startup warning [@slankes](https://github.com/slankes) ([#14405](https://github.com/paperless-ngx/paperless-ngx/pull/14405))
- Documentation: remove MARIADB\_HOST from MariaDB compose files [@shamoon](https://github.com/shamoon) ([#14378](https://github.com/paperless-ngx/paperless-ngx/pull/14378))
- Documentation: Updates the troubleshooting with 2 common database collation issues [@stumpylog](https://github.com/stumpylog) ([#14370](https://github.com/paperless-ngx/paperless-ngx/pull/14370))
### Maintenance
- Chore: Upgrade runners to Ubuntu 26.04 [@stumpylog](https://github.com/stumpylog) ([#14347](https://github.com/paperless-ngx/paperless-ngx/pull/14347))
### Dependencies
- Chore(deps): Bump the npm\_and\_yarn group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14417](https://github.com/paperless-ngx/paperless-ngx/pull/14417))
### All App Changes
<details>
<summary>12 changes</summary>
- Chore: share the authorizing owner rule between the id sets and the trash filter [@stumpylog](https://github.com/stumpylog) ([#14425](https://github.com/paperless-ngx/paperless-ngx/pull/14425))
- Fix: use a root document's effective content for the LLM index and pr… [@stumpylog](https://github.com/stumpylog) ([#14423](https://github.com/paperless-ngx/paperless-ngx/pull/14423))
- Fix: index document versions as their root document [@stumpylog](https://github.com/stumpylog) ([#14422](https://github.com/paperless-ngx/paperless-ngx/pull/14422))
- Fix: authorize document versions by their root in the single-object permission check [@stumpylog](https://github.com/stumpylog) ([#14421](https://github.com/paperless-ngx/paperless-ngx/pull/14421))
- Fix: Provide better error messages to users for invalid JSON [@stumpylog](https://github.com/stumpylog) ([#14419](https://github.com/paperless-ngx/paperless-ngx/pull/14419))
- Chore(deps): Bump the npm\_and\_yarn group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14417](https://github.com/paperless-ngx/paperless-ngx/pull/14417))
- Fix: authorize document versions by their root and speed up permission id sets [@stumpylog](https://github.com/stumpylog) ([#14396](https://github.com/paperless-ngx/paperless-ngx/pull/14396))
- Chore: update the db options warning [@shamoon](https://github.com/shamoon) ([#14398](https://github.com/paperless-ngx/paperless-ngx/pull/14398))
- Fix: bulk reprocess latest version for root documents [@shamoon](https://github.com/shamoon) ([#14385](https://github.com/paperless-ngx/paperless-ngx/pull/14385))
- Fix: consistently use root doc for version action permissions [@shamoon](https://github.com/shamoon) ([#14384](https://github.com/paperless-ngx/paperless-ngx/pull/14384))
- Fix: Don't attempt to index created dates which are not representable in the FTS index [@stumpylog](https://github.com/stumpylog) ([#14390](https://github.com/paperless-ngx/paperless-ngx/pull/14390))
- Fix: retry a search index rebuild that was interrupted [@stumpylog](https://github.com/stumpylog) ([#14379](https://github.com/paperless-ngx/paperless-ngx/pull/14379))
</details>
## paperless-ngx 3.3.0
### Features / Enhancements
- Enhancement: more control over suggestion requests [@shamoon](https://github.com/shamoon) ([#14258](https://github.com/paperless-ngx/paperless-ngx/pull/14258))
- Chorehancement: set manifest CORS for credentials [@shamoon](https://github.com/shamoon) ([#14307](https://github.com/paperless-ngx/paperless-ngx/pull/14307))
- Enhancement: include Django admin with 2FA [@shamoon](https://github.com/shamoon) ([#14270](https://github.com/paperless-ngx/paperless-ngx/pull/14270))
- Feature: propagate resolved secrets to interactive container shells [@stumpylog](https://github.com/stumpylog) ([#14254](https://github.com/paperless-ngx/paperless-ngx/pull/14254))
- Enhancement: support separate embedding API key [@furkanural](https://github.com/furkanural) ([#14067](https://github.com/paperless-ngx/paperless-ngx/pull/14067))
- Enhancement: support passthrough extra params for LLMs [@shamoon](https://github.com/shamoon) ([#14202](https://github.com/paperless-ngx/paperless-ngx/pull/14202))
- Feature: store barcode contents, list and search them [@jurassicparkicecream](https://github.com/jurassicparkicecream) ([#14276](https://github.com/paperless-ngx/paperless-ngx/pull/14276))
### Bug Fixes
- Fix: Set the ProcessedMail owner based on the rule owner in all cases [@stumpylog](https://github.com/stumpylog) ([#14356](https://github.com/paperless-ngx/paperless-ngx/pull/14356))
- Fix: Ensure log rotation settings are converted to integers [@stumpylog](https://github.com/stumpylog) ([#14343](https://github.com/paperless-ngx/paperless-ngx/pull/14343))
- Fix: Wrap apt calls into a retry so we can ideally jump a slow mirror [@stumpylog](https://github.com/stumpylog) ([#14344](https://github.com/paperless-ngx/paperless-ngx/pull/14344))
- Fix: ship pdf.js CMaps so CJK documents render in the viewer [@MrOggy85](https://github.com/MrOggy85) ([#14318](https://github.com/paperless-ngx/paperless-ngx/pull/14318))
- Fix: use version page\_count for versioned document [@shamoon](https://github.com/shamoon) ([#14280](https://github.com/paperless-ngx/paperless-ngx/pull/14280))
- Fix: allow pointer events for pdf links in pngx viewer [@shamoon](https://github.com/shamoon) ([#14264](https://github.com/paperless-ngx/paperless-ngx/pull/14264))
- Fix: During a move to the trash directory, only attempt to copy metadata [@stumpylog](https://github.com/stumpylog) ([#14250](https://github.com/paperless-ngx/paperless-ngx/pull/14250))
- Fix: convert file mtime to the configured time zone directly [@stumpylog](https://github.com/stumpylog) ([#14249](https://github.com/paperless-ngx/paperless-ngx/pull/14249))
- Fix: ensure documentDeleted subscription is discarded [@shamoon](https://github.com/shamoon) ([#14247](https://github.com/paperless-ngx/paperless-ngx/pull/14247))
- Chore: Fix bugs in the test suite [@stumpylog](https://github.com/stumpylog) ([#14244](https://github.com/paperless-ngx/paperless-ngx/pull/14244))
- Fix: ensure bulk operations are checked against version root [@shamoon](https://github.com/shamoon) ([#14246](https://github.com/paperless-ngx/paperless-ngx/pull/14246))
- Fix: indexing after document-added workflows signal [@shamoon](https://github.com/shamoon) ([#14242](https://github.com/paperless-ngx/paperless-ngx/pull/14242))
- Fix: Record full tag and custom field lists in bulk edit audit log [@stumpylog](https://github.com/stumpylog) ([#14236](https://github.com/paperless-ngx/paperless-ngx/pull/14236))
- Chore: update pikepdf for ocrmypdf requirement [@shamoon](https://github.com/shamoon) ([#14235](https://github.com/paperless-ngx/paperless-ngx/pull/14235))
- Fix: handle legacy bulk edit split page range with missing page\_count [@shamoon](https://github.com/shamoon) ([#14212](https://github.com/paperless-ngx/paperless-ngx/pull/14212))
- Fix: ignore invalid EXIF orientation when generating image archives [@zhzy0077](https://github.com/zhzy0077) ([#14203](https://github.com/paperless-ngx/paperless-ngx/pull/14203))
### Documentation
- Documentation: correct duplicates info [@shamoon](https://github.com/shamoon) ([#14243](https://github.com/paperless-ngx/paperless-ngx/pull/14243))
### Maintenance
- Chore(deps): Bump the actions group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14332](https://github.com/paperless-ngx/paperless-ngx/pull/14332))
- Fix: Wrap apt calls into a retry so we can ideally jump a slow mirror [@stumpylog](https://github.com/stumpylog) ([#14344](https://github.com/paperless-ngx/paperless-ngx/pull/14344))
- Chore(deps): Bump the actions group across 1 directory with 10 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14301](https://github.com/paperless-ngx/paperless-ngx/pull/14301))
### Dependencies
<details>
<summary>27 changes</summary>
- Chore(deps): Bump django-filter from 25.2 to 26.1 @[dependabot[bot]](https://github.com/apps/dependabot) ([#14337](https://github.com/paperless-ngx/paperless-ngx/pull/14337))
- Chore(deps): Bump the utilities-patch group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14338](https://github.com/paperless-ngx/paperless-ngx/pull/14338))
- Chore(deps): Bump the pre-commit-dependencies group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14351](https://github.com/paperless-ngx/paperless-ngx/pull/14351))
- Chore(deps-dev): Bump types-channels from 4.3.0.20260408 to 4.3.0.20260518 @[dependabot[bot]](https://github.com/apps/dependabot) ([#14335](https://github.com/paperless-ngx/paperless-ngx/pull/14335))
- docker(deps): Bump astral-sh/uv from 0.12.20-python3.14-trixie-slim to 0.12.23-python3.14-trixie-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#14327](https://github.com/paperless-ngx/paperless-ngx/pull/14327))
- Chore(deps): Bump the actions group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14332](https://github.com/paperless-ngx/paperless-ngx/pull/14332))
- Chore(deps): Bump the uv group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14325](https://github.com/paperless-ngx/paperless-ngx/pull/14325))
- Chore(deps): Bump the frontend-angular-dependencies group across 1 directory with 13 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14329](https://github.com/paperless-ngx/paperless-ngx/pull/14329))
- Chore(deps-dev): Bump prettier from 3.9.8 to 3.9.9 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14331](https://github.com/paperless-ngx/paperless-ngx/pull/14331))
- Chore(deps-dev): Bump the frontend-eslint-dependencies group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14330](https://github.com/paperless-ngx/paperless-ngx/pull/14330))
- Chore(deps-dev): Bump zensical from 0.0.64 to 0.0.65 in the development group @[dependabot[bot]](https://github.com/apps/dependabot) ([#14326](https://github.com/paperless-ngx/paperless-ngx/pull/14326))
- Chore(deps): Bump the uv group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14314](https://github.com/paperless-ngx/paperless-ngx/pull/14314))
- Chore(deps): Bump the utilities-minor group across 1 directory with 7 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14305](https://github.com/paperless-ngx/paperless-ngx/pull/14305))
- docker-compose(deps): bump greenmail/standalone from 2.1.13 to 2.1.14 in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#14281](https://github.com/paperless-ngx/paperless-ngx/pull/14281))
- docker(deps): Bump astral-sh/uv from 0.12.16-python3.14-trixie-slim to 0.12.20-python3.14-trixie-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#14282](https://github.com/paperless-ngx/paperless-ngx/pull/14282))
- Chore(deps): Bump the pre-commit-dependencies group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14283](https://github.com/paperless-ngx/paperless-ngx/pull/14283))
- Chore(deps): Bump the utilities-patch group across 1 directory with 6 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14297](https://github.com/paperless-ngx/paperless-ngx/pull/14297))
- Chore(deps): Bump the actions group across 1 directory with 10 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14301](https://github.com/paperless-ngx/paperless-ngx/pull/14301))
- Chore(deps-dev): Bump the frontend-jest-dependencies group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14286](https://github.com/paperless-ngx/paperless-ngx/pull/14286))
- Chore(deps-dev): Bump eslint from 10.10.0 to 10.11.0 in /src-ui in the frontend-eslint-dependencies group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#14287](https://github.com/paperless-ngx/paperless-ngx/pull/14287))
- Chore(deps-dev): Bump @types/node from 26.5.0 to 26.6.2 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14288](https://github.com/paperless-ngx/paperless-ngx/pull/14288))
- Chore(deps-dev): Bump prettier from 3.9.6 to 3.9.8 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14289](https://github.com/paperless-ngx/paperless-ngx/pull/14289))
- Chore(deps): Bump the frontend-angular-dependencies group across 1 directory with 10 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14285](https://github.com/paperless-ngx/paperless-ngx/pull/14285))
- Chore: replace bleach with turbohtml [@gaborbernat](https://github.com/gaborbernat) ([#14269](https://github.com/paperless-ngx/paperless-ngx/pull/14269))
- Chore(deps): Bump autobahn from 25.12.2 to 26.7.1 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#14231](https://github.com/paperless-ngx/paperless-ngx/pull/14231))
- Chore: update pikepdf for ocrmypdf requirement [@shamoon](https://github.com/shamoon) ([#14235](https://github.com/paperless-ngx/paperless-ngx/pull/14235))
- Chore(deps): Bump the pre-commit-dependencies group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14133](https://github.com/paperless-ngx/paperless-ngx/pull/14133))
</details>
### All App Changes
<details>
<summary>41 changes</summary>
- Feature: store barcode contents, list and search them [@jurassicparkicecream](https://github.com/jurassicparkicecream) ([#14276](https://github.com/paperless-ngx/paperless-ngx/pull/14276))
- Fix: Set the ProcessedMail owner based on the rule owner in all cases [@stumpylog](https://github.com/stumpylog) ([#14356](https://github.com/paperless-ngx/paperless-ngx/pull/14356))
- Chore(deps): Bump django-filter from 25.2 to 26.1 @[dependabot[bot]](https://github.com/apps/dependabot) ([#14337](https://github.com/paperless-ngx/paperless-ngx/pull/14337))
- Chore(deps): Bump the utilities-patch group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14338](https://github.com/paperless-ngx/paperless-ngx/pull/14338))
- Chore(deps-dev): Bump types-channels from 4.3.0.20260408 to 4.3.0.20260518 @[dependabot[bot]](https://github.com/apps/dependabot) ([#14335](https://github.com/paperless-ngx/paperless-ngx/pull/14335))
- Chore(deps): Bump the uv group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14325](https://github.com/paperless-ngx/paperless-ngx/pull/14325))
- Fix: Ensure log rotation settings are converted to integers [@stumpylog](https://github.com/stumpylog) ([#14343](https://github.com/paperless-ngx/paperless-ngx/pull/14343))
- Chore(deps): Bump the frontend-angular-dependencies group across 1 directory with 13 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14329](https://github.com/paperless-ngx/paperless-ngx/pull/14329))
- Chore(deps-dev): Bump prettier from 3.9.8 to 3.9.9 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14331](https://github.com/paperless-ngx/paperless-ngx/pull/14331))
- Chore(deps-dev): Bump the frontend-eslint-dependencies group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14330](https://github.com/paperless-ngx/paperless-ngx/pull/14330))
- Fix: ship pdf.js CMaps so CJK documents render in the viewer [@MrOggy85](https://github.com/MrOggy85) ([#14318](https://github.com/paperless-ngx/paperless-ngx/pull/14318))
- Chore(deps-dev): Bump zensical from 0.0.64 to 0.0.65 in the development group @[dependabot[bot]](https://github.com/apps/dependabot) ([#14326](https://github.com/paperless-ngx/paperless-ngx/pull/14326))
- Enhancement: more control over suggestion requests [@shamoon](https://github.com/shamoon) ([#14258](https://github.com/paperless-ngx/paperless-ngx/pull/14258))
- Chore(deps): Bump the uv group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14314](https://github.com/paperless-ngx/paperless-ngx/pull/14314))
- Chore: anchor admin url pattern [@shamoon](https://github.com/shamoon) ([#14316](https://github.com/paperless-ngx/paperless-ngx/pull/14316))
- Chorehancement: set manifest CORS for credentials [@shamoon](https://github.com/shamoon) ([#14307](https://github.com/paperless-ngx/paperless-ngx/pull/14307))
- Chore(deps): Bump the utilities-minor group across 1 directory with 7 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14305](https://github.com/paperless-ngx/paperless-ngx/pull/14305))
- Chore(deps): Bump the utilities-patch group across 1 directory with 6 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14297](https://github.com/paperless-ngx/paperless-ngx/pull/14297))
- Chore(deps-dev): Bump the frontend-jest-dependencies group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14286](https://github.com/paperless-ngx/paperless-ngx/pull/14286))
- Chore(deps-dev): Bump eslint from 10.10.0 to 10.11.0 in /src-ui in the frontend-eslint-dependencies group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#14287](https://github.com/paperless-ngx/paperless-ngx/pull/14287))
- Chore(deps-dev): Bump @types/node from 26.5.0 to 26.6.2 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14288](https://github.com/paperless-ngx/paperless-ngx/pull/14288))
- Chore(deps-dev): Bump prettier from 3.9.6 to 3.9.8 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14289](https://github.com/paperless-ngx/paperless-ngx/pull/14289))
- Chore(deps): Bump the frontend-angular-dependencies group across 1 directory with 10 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14285](https://github.com/paperless-ngx/paperless-ngx/pull/14285))
- Fix: use version page\_count for versioned document [@shamoon](https://github.com/shamoon) ([#14280](https://github.com/paperless-ngx/paperless-ngx/pull/14280))
- Chore: replace bleach with turbohtml [@gaborbernat](https://github.com/gaborbernat) ([#14269](https://github.com/paperless-ngx/paperless-ngx/pull/14269))
- Enhancement: include Django admin with 2FA [@shamoon](https://github.com/shamoon) ([#14270](https://github.com/paperless-ngx/paperless-ngx/pull/14270))
- Fix: allow pointer events for pdf links in pngx viewer [@shamoon](https://github.com/shamoon) ([#14264](https://github.com/paperless-ngx/paperless-ngx/pull/14264))
- Feature: propagate resolved secrets to interactive container shells [@stumpylog](https://github.com/stumpylog) ([#14254](https://github.com/paperless-ngx/paperless-ngx/pull/14254))
- Fix: During a move to the trash directory, only attempt to copy metadata [@stumpylog](https://github.com/stumpylog) ([#14250](https://github.com/paperless-ngx/paperless-ngx/pull/14250))
- Fix: convert file mtime to the configured time zone directly [@stumpylog](https://github.com/stumpylog) ([#14249](https://github.com/paperless-ngx/paperless-ngx/pull/14249))
- Fix: ensure documentDeleted subscription is discarded [@shamoon](https://github.com/shamoon) ([#14247](https://github.com/paperless-ngx/paperless-ngx/pull/14247))
- Chore: Fix bugs in the test suite [@stumpylog](https://github.com/stumpylog) ([#14244](https://github.com/paperless-ngx/paperless-ngx/pull/14244))
- Fix: ensure bulk operations are checked against version root [@shamoon](https://github.com/shamoon) ([#14246](https://github.com/paperless-ngx/paperless-ngx/pull/14246))
- Enhancement: support separate embedding API key [@furkanural](https://github.com/furkanural) ([#14067](https://github.com/paperless-ngx/paperless-ngx/pull/14067))
- Enhancement: support passthrough extra params for LLMs [@shamoon](https://github.com/shamoon) ([#14202](https://github.com/paperless-ngx/paperless-ngx/pull/14202))
- Chore(deps): Bump autobahn from 25.12.2 to 26.7.1 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#14231](https://github.com/paperless-ngx/paperless-ngx/pull/14231))
- Fix: indexing after document-added workflows signal [@shamoon](https://github.com/shamoon) ([#14242](https://github.com/paperless-ngx/paperless-ngx/pull/14242))
- Fix: Record full tag and custom field lists in bulk edit audit log [@stumpylog](https://github.com/stumpylog) ([#14236](https://github.com/paperless-ngx/paperless-ngx/pull/14236))
- Chore: update pikepdf for ocrmypdf requirement [@shamoon](https://github.com/shamoon) ([#14235](https://github.com/paperless-ngx/paperless-ngx/pull/14235))
- Fix: handle legacy bulk edit split page range with missing page\_count [@shamoon](https://github.com/shamoon) ([#14212](https://github.com/paperless-ngx/paperless-ngx/pull/14212))
- Fix: ignore invalid EXIF orientation when generating image archives [@zhzy0077](https://github.com/zhzy0077) ([#14203](https://github.com/paperless-ngx/paperless-ngx/pull/14203))
</details>
## paperless-ngx 3.2.1 ## paperless-ngx 3.2.1
### Bug Fixes ### Bug Fixes
+30 -20
View File
@@ -370,6 +370,15 @@ instead of being removed from the filesystem. Only the original version is kept.
This setting was previously named PAPERLESS_TRASH_DIR. This setting was previously named PAPERLESS_TRASH_DIR.
#### [`PAPERLESS_SCRATCH_DIR=<path>`](#PAPERLESS_SCRATCH_DIR) {#PAPERLESS_SCRATCH_DIR}
: This folder is used as a temporary workspace for multiple paperless operations, including
consuming documents, editing, and the document exporter.
This must be writeable by the user running paperless.
Defaults to "/tmp/paperless".
#### [`PAPERLESS_MEDIA_ROOT=<path>`](#PAPERLESS_MEDIA_ROOT) {#PAPERLESS_MEDIA_ROOT} #### [`PAPERLESS_MEDIA_ROOT=<path>`](#PAPERLESS_MEDIA_ROOT) {#PAPERLESS_MEDIA_ROOT}
: This is where your documents and thumbnails are stored. : This is where your documents and thumbnails are stored.
@@ -1315,29 +1324,17 @@ valid crontab(5) expression describing when to run.
#### [`PAPERLESS_CONVERT_MEMORY_LIMIT=<num>`](#PAPERLESS_CONVERT_MEMORY_LIMIT) {#PAPERLESS_CONVERT_MEMORY_LIMIT} #### [`PAPERLESS_CONVERT_MEMORY_LIMIT=<num>`](#PAPERLESS_CONVERT_MEMORY_LIMIT) {#PAPERLESS_CONVERT_MEMORY_LIMIT}
: On smaller systems, or even in the case of Very Large Documents, the !!! warning
consumer may explode, complaining about how it's "unable to extend
pixel cache". In such cases, try setting this to a reasonably low
value, like 32. The default is to use whatever is necessary to do
everything without writing to disk, and units are in megabytes.
For more information on how to use this value, you should search the Deprecated and has no effect, since PDF thumbnails no longer use
web for "MAGICK_MEMORY_LIMIT". ImageMagick. It will be removed in a future release.
Defaults to 0, which disables the limit.
#### [`PAPERLESS_CONVERT_TMPDIR=<path>`](#PAPERLESS_CONVERT_TMPDIR) {#PAPERLESS_CONVERT_TMPDIR} #### [`PAPERLESS_CONVERT_TMPDIR=<path>`](#PAPERLESS_CONVERT_TMPDIR) {#PAPERLESS_CONVERT_TMPDIR}
: Similar to the memory limit, if you've got a small system and your !!! warning
OS mounts /tmp as tmpfs, you should set this to a path that's on a
physical disk, like /home/your_user/tmp or something. ImageMagick
will use this as scratch space when crunching through very large
documents.
For more information on how to use this value, you should search the Deprecated and has no effect, since PDF thumbnails no longer use
web for "MAGICK_TMPDIR". ImageMagick. It will be removed in a future release.
Default is none, which disables the temporary directory.
#### [`PAPERLESS_APPS=<string>`](#PAPERLESS_APPS) {#PAPERLESS_APPS} #### [`PAPERLESS_APPS=<string>`](#PAPERLESS_APPS) {#PAPERLESS_APPS}
@@ -1796,6 +1793,13 @@ assigns or creates tags if a properly formatted barcode is detected.
Defaults to false. Defaults to false.
#### [`PAPERLESS_CONSUMER_STORE_BARCODE_VALUES=<bool>`](#PAPERLESS_CONSUMER_STORE_BARCODE_VALUES) {#PAPERLESS_CONSUMER_STORE_BARCODE_VALUES}
: Stores the content of every barcode found during consumption, see
[Barcode Contents](advanced_usage.md#barcode-contents).
Defaults to false.
## Audit Trail ## Audit Trail
#### [`PAPERLESS_AUDIT_LOG_ENABLED=<bool>`](#PAPERLESS_AUDIT_LOG_ENABLED) {#PAPERLESS_AUDIT_LOG_ENABLED} #### [`PAPERLESS_AUDIT_LOG_ENABLED=<bool>`](#PAPERLESS_AUDIT_LOG_ENABLED) {#PAPERLESS_AUDIT_LOG_ENABLED}
@@ -1878,11 +1882,17 @@ the literal path for that program.
#### [`PAPERLESS_CONVERT_BINARY=<path>`](#PAPERLESS_CONVERT_BINARY) {#PAPERLESS_CONVERT_BINARY} #### [`PAPERLESS_CONVERT_BINARY=<path>`](#PAPERLESS_CONVERT_BINARY) {#PAPERLESS_CONVERT_BINARY}
: Defaults to "convert". !!! warning
Deprecated and has no effect, since Paperless-ngx no longer uses
ImageMagick. It will be removed in a future release.
#### [`PAPERLESS_GS_BINARY=<path>`](#PAPERLESS_GS_BINARY) {#PAPERLESS_GS_BINARY} #### [`PAPERLESS_GS_BINARY=<path>`](#PAPERLESS_GS_BINARY) {#PAPERLESS_GS_BINARY}
: Defaults to "gs". !!! warning
Deprecated and has no effect, since nothing in Paperless-ngx calls
Ghostscript by this path. It will be removed in a future release.
## Docker-specific options {#docker} ## Docker-specific options {#docker}
+3 -1
View File
@@ -76,7 +76,9 @@ is not supported by any of the available parsers.
**A:** Not by default. As of v3, a file whose contents match an existing document is still **A:** Not by default. As of v3, a file whose contents match an existing document is still
consumed, and the duplicate is flagged in the UI — open the document and check the consumed, and the duplicate is flagged in the UI — open the document and check the
**Duplicates** tab to review documents that share the same content. If you prefer the old **Duplicates** tab to review documents that share the same content, or filter the document
list by **Duplicates** to find all of them (see
[Duplicate documents](usage.md#duplicate-documents)). If you prefer the old
behavior of rejecting duplicates during consumption, set behavior of rejecting duplicates during consumption, set
[`PAPERLESS_CONSUMER_DELETE_DUPLICATES`](configuration.md#PAPERLESS_CONSUMER_DELETE_DUPLICATES) [`PAPERLESS_CONSUMER_DELETE_DUPLICATES`](configuration.md#PAPERLESS_CONSUMER_DELETE_DUPLICATES)
to `true`. to `true`.
+4 -4
View File
@@ -12,10 +12,10 @@ physical documents into a searchable online archive so you can keep, well, _less
[Get started](setup.md){ .md-button .md-button--primary .index-callout } [Get started](setup.md){ .md-button .md-button--primary .index-callout }
[Demo](https://demo.paperless-ngx.com){ .md-button .md-button--secondary target=\_blank } [Demo](https://demo.paperless-ngx.com){ .md-button .md-button--secondary target=\_blank }
<div style="display: flex; justify-content: end; margin-top: -1.5rem;"> <div class="demo-sponsor">
<a href="https://m.do.co/c/8d70b916d462" target="_blank"> <span>Demo hosting by</span>
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_white.svg#only-dark" class="no-lightbox" width="150px"> <a href="https://fyrastack.com" target="_blank" rel="noopener">
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_black.svg#only-light" class="no-lightbox" width="150px"> <img src="assets/sponsors/fyra-stack.svg" alt="" class="no-lightbox" width="20" height="20"> Fyra Stack
</a> </a>
</div> </div>
+2 -13
View File
@@ -177,17 +177,16 @@ to a positive number to enable polling and disable native filesystem notificatio
- `pkg-config` for mysqlclient (python dependency) - `pkg-config` for mysqlclient (python dependency)
- `fonts-liberation` for generating thumbnails for plain text - `fonts-liberation` for generating thumbnails for plain text
files files
- `imagemagick` >= 6 for PDF conversion
- `gnupg` for decrypting GPG-encrypted email - `gnupg` for decrypting GPG-encrypted email
- `libpq-dev` for PostgreSQL - `libpq-dev` for PostgreSQL
- `libmagic-dev` for mime type detection - `libmagic-dev` for mime type detection
- `mariadb-client` for MariaDB compile time - `mariadb-client` for MariaDB compile time
- `poppler-utils` for barcode detection - `poppler-utils` for thumbnail generation and barcode detection
Use this list for your preferred package management: Use this list for your preferred package management:
``` ```
python3 python3-pip python3-dev imagemagick fonts-liberation gnupg libpq-dev default-libmysqlclient-dev pkg-config libmagic-dev poppler-utils python3 python3-pip python3-dev fonts-liberation gnupg libpq-dev default-libmysqlclient-dev pkg-config libmagic-dev poppler-utils
``` ```
These dependencies are required for OCRmyPDF, which is used for text These dependencies are required for OCRmyPDF, which is used for text
@@ -416,16 +415,6 @@ to a positive number to enable polling and disable native filesystem notificatio
You may need to change the path in the files. Example: You may need to change the path in the files. Example:
`ExecStart=/opt/paperless/.local/bin/celery --app paperless worker --loglevel INFO` `ExecStart=/opt/paperless/.local/bin/celery --app paperless worker --loglevel INFO`
12. Configure ImageMagick to allow processing of PDF documents and disable
formats that Paperless-ngx does not use. Most distributions disable PDF
processing by default, since PDF documents can contain malware. If you
don't enable it, Paperless-ngx will fall back to Ghostscript for certain
steps such as thumbnail generation.
Configure the active ImageMagick policy file (commonly
`/etc/ImageMagick-6/policy.xml` or `/etc/ImageMagick-7/policy.xml`) and
adjust similar to [the docker policy file](https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/refs/heads/main/docker/rootfs/etc/ImageMagick-6/paperless-policy.xml). You should also include restrictions as noted there.
**Optional: Install the [jbig2enc](https://ocrmypdf.readthedocs.io/en/latest/jbig2.html) encoder.** **Optional: Install the [jbig2enc](https://ocrmypdf.readthedocs.io/en/latest/jbig2.html) encoder.**
This will reduce the size of generated PDF documents. You'll most likely need to compile this yourself, because this This will reduce the size of generated PDF documents. You'll most likely need to compile this yourself, because this
software has been patented until around 2017 and binary packages are not available for most distributions. software has been patented until around 2017 and binary packages are not available for most distributions.
+60 -1
View File
@@ -129,7 +129,7 @@ change in the `docker-compose.yml` file:
command: command:
- 'gotenberg' - 'gotenberg'
- '--chromium-disable-javascript=true' - '--chromium-disable-javascript=true'
- '--chromium-allow-list=file:///tmp/.*' - '--chromium-allow-list=^file:///tmp/.*'
- '--api-timeout=60s' - '--api-timeout=60s'
``` ```
@@ -272,6 +272,65 @@ This error can occur in installations which have upgraded from a version of Pape
$ python3 manage.py convert_mariadb_uuid $ python3 manage.py convert_mariadb_uuid
``` ```
## MariaDB/MySQL error "Illegal mix of collations"
Consumption or other operations fail with an error like:
```
(1267, "Illegal mix of collations (utf8mb4_general_ci,IMPLICIT) and
(utf8mb4_unicode_ci,IMPLICIT) for operation '='")
```
This happens when the tables in your database do not all use the same
collation. It is most often seen on databases that existed before a
MariaDB/MySQL upgrade: older tables keep their original collation, while
tables created afterwards use the new server default.
To work around it, set the collation your existing tables use (the one in the error
that is not `utf8mb4_unicode_ci`) with
[`PAPERLESS_DB_OPTIONS`](configuration.md#PAPERLESS_DB_OPTIONS):
```bash
PAPERLESS_DB_OPTIONS="collation=utf8mb4_general_ci"
```
To fix it permanently, back up your database, then convert the database and
each table to a single collation and remove the override:
```sql
ALTER DATABASE paperless CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE <table_name> CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
```
## PostgreSQL warns about a "collation version mismatch"
The PostgreSQL log shows a warning like:
```
WARNING: database "paperless" has a collation version mismatch
DETAIL: The database was created using collation version 2.36, but the operating system provides version 2.41.
HINT: Rebuild all objects in this database that use the default collation and run ALTER DATABASE paperless REFRESH COLLATION VERSION, or build PostgreSQL with the right library version.
```
This comes from PostgreSQL, not Paperless-ngx. The `glibc` version that PostgreSQL
runs against changed, and the existing database was created with an older one. With
Docker, `glibc` comes from the PostgreSQL image's Debian base, not the host, so this
commonly happens when a new image is pulled after its base Debian release changed.
If PostgreSQL is installed directly on a host, it uses the host's `glibc` instead.
The warning is not an error and Paperless-ngx keeps working, but the database's text
indexes may be built with outdated sorting rules. To resolve it, back up your
database, then connect to it (for example with `psql -U paperless -d paperless`
inside the database container) and run:
```sql
REINDEX DATABASE paperless;
ALTER DATABASE paperless REFRESH COLLATION VERSION;
```
To avoid this in the future, pin your PostgreSQL image to a specific Debian release,
for example `postgres:18-trixie`, rather than `postgres:18`.
## Platform-Specific Deployment Troubleshooting ## Platform-Specific Deployment Troubleshooting
A user-maintained wiki page is available to help troubleshoot issues that may arise when trying to deploy Paperless-ngx on specific platforms, for example SELinux. Please see [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Platform%E2%80%90Specific-Troubleshooting). A user-maintained wiki page is available to help troubleshoot issues that may arise when trying to deploy Paperless-ngx on specific platforms, for example SELinux. Please see [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Platform%E2%80%90Specific-Troubleshooting).
+21 -8
View File
@@ -299,19 +299,18 @@ for details.
### Duplicate documents ### Duplicate documents
By default, Paperless-ngx **does not reject duplicates**. If you consume a file whose By default, Paperless-ngx **does not reject duplicates**. If you consume a file whose
contents exactly match an existing document (same checksum), the new copy is still contents match an existing document (same original or archive checksum), the new copy is
consumed and a warning is logged. The task entry for the upload also flags that a still consumed and a warning is logged.
duplicate was detected and links to the existing document(s).
To review duplicates, open a document and switch to the **Duplicates** tab on the When a document has duplicates, a **Duplicates** tab appears on its detail page, listing
document detail page. It lists other documents that share the same content, including any the other documents you can view that share the same content (including any in the trash).
that are in the trash (shown with a badge), and links to each so you can decide which to To find all documents with duplicates, choose **Duplicates** in the document list's text
keep. filter dropdown, or use `has_duplicates=true` in the REST API.
If you would rather reject duplicates at consumption time (the pre-v3 behavior), set If you would rather reject duplicates at consumption time (the pre-v3 behavior), set
[`PAPERLESS_CONSUMER_DELETE_DUPLICATES`](configuration.md#PAPERLESS_CONSUMER_DELETE_DUPLICATES) [`PAPERLESS_CONSUMER_DELETE_DUPLICATES`](configuration.md#PAPERLESS_CONSUMER_DELETE_DUPLICATES)
to `true`. The duplicate file is then deleted instead of consumed, and the task fails with to `true`. The duplicate file is then deleted instead of consumed, and the task fails with
a "document already exists" message. a "Document already exists" message linking to the existing document.
## Document Suggestions ## Document Suggestions
@@ -1061,6 +1060,20 @@ notes.user:alice notes.note:insurance
The bare `notes:` prefix is shorthand for `notes.note:`. The bare `notes:` prefix is shorthand for `notes.note:`.
#### Searching barcodes
If [barcode contents are stored](advanced_usage.md#barcode-contents), they can be searched by
content or type, but only with a field name:
```
barcodes.value:DE89370400440532013000
barcodes.format:qrcode
barcodes:wifi barcodes:guest
```
`barcodes:` is shorthand for `barcodes.value:`. Separators are stripped, so each part of e.g.
`WIFI:S:Guest;P:secret;;` can be searched on its own.
All of these can be combined. Syntax not described here may not work as expected, and an unknown field name is searched as ordinary text. All of these can be combined. Syntax not described here may not work as expected, and an unknown field name is searched as ordinary text.
!!! note !!! note
+1 -7
View File
@@ -16,6 +16,7 @@
#PAPERLESS_CONSUMPTION_DIR=../consume #PAPERLESS_CONSUMPTION_DIR=../consume
#PAPERLESS_DATA_DIR=../data #PAPERLESS_DATA_DIR=../data
#PAPERLESS_EMPTY_TRASH_DIR= #PAPERLESS_EMPTY_TRASH_DIR=
#PAPERLESS_SCRATCH_DIR=/tmp/paperless
#PAPERLESS_MEDIA_ROOT=../media #PAPERLESS_MEDIA_ROOT=../media
#PAPERLESS_STATICDIR=../static #PAPERLESS_STATICDIR=../static
#PAPERLESS_FILENAME_FORMAT= #PAPERLESS_FILENAME_FORMAT=
@@ -50,8 +51,6 @@ PAPERLESS_SECRET_KEY=change-me
#PAPERLESS_OCR_ROTATE_PAGES=true #PAPERLESS_OCR_ROTATE_PAGES=true
#PAPERLESS_OCR_ROTATE_PAGES_THRESHOLD=12.0 #PAPERLESS_OCR_ROTATE_PAGES_THRESHOLD=12.0
#PAPERLESS_OCR_USER_ARGS={} #PAPERLESS_OCR_USER_ARGS={}
#PAPERLESS_CONVERT_MEMORY_LIMIT=0
#PAPERLESS_CONVERT_TMPDIR=/var/tmp/paperless
# Software tweaks # Software tweaks
@@ -87,8 +86,3 @@ PAPERLESS_SECRET_KEY=change-me
#PAPERLESS_TIKA_ENABLED=false #PAPERLESS_TIKA_ENABLED=false
#PAPERLESS_TIKA_ENDPOINT=http://localhost:9998 #PAPERLESS_TIKA_ENDPOINT=http://localhost:9998
#PAPERLESS_TIKA_GOTENBERG_ENDPOINT=http://localhost:3000 #PAPERLESS_TIKA_GOTENBERG_ENDPOINT=http://localhost:3000
# Binaries
#PAPERLESS_CONVERT_BINARY=/usr/bin/convert
#PAPERLESS_GS_BINARY=/usr/bin/gs
+4 -4
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "paperless-ngx" name = "paperless-ngx"
version = "3.2.1" version = "3.3.4"
description = """\ description = """\
A community-supported supercharged document management system: scan, index and archive all your physical documents\ A community-supported supercharged document management system: scan, index and archive all your physical documents\
""" """
@@ -33,7 +33,7 @@ dependencies = [
"django-compression-middleware~=0.5.0", "django-compression-middleware~=0.5.0",
"django-cors-headers~=4.9.0", "django-cors-headers~=4.9.0",
"django-extensions~=4.1", "django-extensions~=4.1",
"django-filter~=25.1", "django-filter>=25.1,<27",
"django-guardian>=3.3.3,<3.6", "django-guardian>=3.3.3,<3.6",
"django-multiselectfield~=1.0.1", "django-multiselectfield~=1.0.1",
"django-rich~=2.2.0", "django-rich~=2.2.0",
@@ -43,7 +43,7 @@ dependencies = [
"drf-spectacular~=0.30", "drf-spectacular~=0.30",
"drf-spectacular-sidecar>=2026.7.1,<2026.10", "drf-spectacular-sidecar>=2026.7.1,<2026.10",
"drf-writable-nested~=0.7.1", "drf-writable-nested~=0.7.1",
"filelock~=3.32.0", "filelock>=3.32,<4.1",
"flower>=2.0.1,<2.3", "flower>=2.0.1,<2.3",
"gotenberg-client[httpx]~=1.0", "gotenberg-client[httpx]~=1.0",
"httpx-oauth~=0.17", "httpx-oauth~=0.17",
@@ -90,7 +90,7 @@ postgres = [
"psycopg[c,pool]==3.3.4", "psycopg[c,pool]==3.3.4",
# Direct dependency for proper resolution of the pre-built wheels # Direct dependency for proper resolution of the pre-built wheels
"psycopg-c==3.3.4", "psycopg-c==3.3.4",
"psycopg-pool==3.3.2", "psycopg-pool==3.3.3",
] ]
webserver = [ webserver = [
"granian[uvloop]>=2.7,<2.9", "granian[uvloop]>=2.7,<2.9",
+1 -1
View File
@@ -2,5 +2,5 @@
docker run -p 5432:5432 -e POSTGRES_PASSWORD=password -v paperless_pgdata:/var/lib/postgresql/data -d postgres:15 docker run -p 5432:5432 -e POSTGRES_PASSWORD=password -v paperless_pgdata:/var/lib/postgresql/data -d postgres:15
docker run -d -p 6379:6379 redis:latest docker run -d -p 6379:6379 redis:latest
docker run -p 3000:3000 -d gotenberg/gotenberg:7.8 gotenberg --chromium-disable-javascript=true --chromium-allow-list="file:///tmp/.*" docker run -p 3000:3000 -d gotenberg/gotenberg:7.8 gotenberg --chromium-disable-javascript=true --chromium-allow-list="^file:///tmp/.*"
docker run -p 9998:9998 -d docker.io/apache/tika:latest docker run -p 9998:9998 -d docker.io/apache/tika:latest
+3 -1
View File
@@ -15,6 +15,8 @@ module.exports = {
tsconfig: '<rootDir>/tsconfig.spec.json', tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$', stringifyContentPathRegex: '\\.(html|svg)$',
useESM: true, useESM: true,
// Disable the builder's deprecated override; tsconfig.spec.json enables isolation.
isolatedModules: false,
}, },
], ],
}, },
@@ -26,7 +28,7 @@ module.exports = {
'abstract-paperless-service', 'abstract-paperless-service',
], ],
transformIgnorePatterns: [ transformIgnorePatterns: [
'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|normalize-diacritics|@angular/common/locales/.*\\.js$))', 'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|normalize-diacritics|marked|@angular/common/locales/.*\\.js$))',
], ],
moduleNameMapper: { moduleNameMapper: {
...esmPreset.moduleNameMapper, ...esmPreset.moduleNameMapper,
+911 -759
View File
File diff suppressed because it is too large. Load diff
+22 -21
View File
@@ -1,6 +1,6 @@
{ {
"name": "paperless-ngx-ui", "name": "paperless-ngx-ui",
"version": "3.2.1", "version": "3.3.4",
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm", "preinstall": "npx only-allow pnpm",
"ng": "ng", "ng": "ng",
@@ -15,21 +15,22 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/cdk": "^22.2.0", "@angular/cdk": "^22.2.2",
"@angular/common": "~22.2.0", "@angular/common": "~22.2.2",
"@angular/compiler": "~22.2.0", "@angular/compiler": "~22.2.2",
"@angular/core": "~22.2.0", "@angular/core": "~22.2.2",
"@angular/forms": "~22.2.0", "@angular/forms": "~22.2.2",
"@angular/localize": "~22.2.0", "@angular/localize": "~22.2.2",
"@angular/platform-browser": "~22.2.0", "@angular/platform-browser": "~22.2.2",
"@angular/router": "~22.2.0", "@angular/router": "~22.2.2",
"@ng-bootstrap/ng-bootstrap": "^21.0.0", "@ng-bootstrap/ng-bootstrap": "^21.0.0",
"@ng-select/ng-select": "~24.1.2", "@ng-select/ng-select": "~24.2.1",
"@ngneat/dirty-check-forms": "^3.0.3", "@ngneat/dirty-check-forms": "^3.0.3",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"lodash-es": "^4.18.1", "lodash-es": "^4.18.1",
"marked": "~18.0.14",
"mime-names": "^1.0.0", "mime-names": "^1.0.0",
"ngx-bootstrap-icons": "^1.9.3", "ngx-bootstrap-icons": "^1.9.3",
"ngx-color": "^10.1.0", "ngx-color": "^10.1.0",
@@ -45,16 +46,16 @@
}, },
"devDependencies": { "devDependencies": {
"@angular-builders/jest": "^22.0.1", "@angular-builders/jest": "^22.0.1",
"@angular-devkit/core": "^22.2.0", "@angular-devkit/core": "^22.2.2",
"@angular-devkit/schematics": "^22.2.0", "@angular-devkit/schematics": "^22.2.2",
"@angular-eslint/builder": "22.5.0", "@angular-eslint/builder": "22.6.0",
"@angular-eslint/eslint-plugin": "22.5.0", "@angular-eslint/eslint-plugin": "22.6.0",
"@angular-eslint/eslint-plugin-template": "22.5.0", "@angular-eslint/eslint-plugin-template": "22.6.0",
"@angular-eslint/schematics": "22.5.0", "@angular-eslint/schematics": "22.6.0",
"@angular-eslint/template-parser": "22.5.0", "@angular-eslint/template-parser": "22.6.0",
"@angular/build": "22.2.0", "@angular/build": "22.2.2",
"@angular/cli": "22.2.0", "@angular/cli": "22.2.2",
"@angular/compiler-cli": "~22.2.0", "@angular/compiler-cli": "~22.2.2",
"@playwright/test": "^1.62.1", "@playwright/test": "^1.62.1",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^26.6.2", "@types/node": "^26.6.2",
@@ -65,7 +66,7 @@
"jest": "30.5.2", "jest": "30.5.2",
"jest-environment-jsdom": "^30.5.2", "jest-environment-jsdom": "^30.5.2",
"jest-junit": "^17.0.0", "jest-junit": "^17.0.0",
"jest-preset-angular": "^17.0.0", "jest-preset-angular": "^17.0.3",
"jest-websocket-mock": "^2.5.0", "jest-websocket-mock": "^2.5.0",
"prettier": "^3.9.9", "prettier": "^3.9.9",
"prettier-plugin-organize-imports": "^4.3.0", "prettier-plugin-organize-imports": "^4.3.0",
+302 -334
View File
File diff suppressed because it is too large. Load diff
+15 -9
View File
@@ -19,6 +19,7 @@ import { PermissionsGuard } from './guards/permissions.guard'
import { HotKeyService } from './services/hot-key.service' import { HotKeyService } from './services/hot-key.service'
import { PermissionsService } from './services/permissions.service' import { PermissionsService } from './services/permissions.service'
import { SettingsService } from './services/settings.service' import { SettingsService } from './services/settings.service'
import { TasksService } from './services/tasks.service'
import { Toast, ToastService } from './services/toast.service' import { Toast, ToastService } from './services/toast.service'
import { import {
FileStatus, FileStatus,
@@ -34,6 +35,7 @@ describe('AppComponent', () => {
let toastService: ToastService let toastService: ToastService
let router: Router let router: Router
let settingsService: SettingsService let settingsService: SettingsService
let tasksService: TasksService
let hotKeyService: HotKeyService let hotKeyService: HotKeyService
beforeEach(async () => { beforeEach(async () => {
@@ -60,6 +62,7 @@ describe('AppComponent', () => {
websocketStatusService = TestBed.inject(WebsocketStatusService) websocketStatusService = TestBed.inject(WebsocketStatusService)
permissionsService = TestBed.inject(PermissionsService) permissionsService = TestBed.inject(PermissionsService)
settingsService = TestBed.inject(SettingsService) settingsService = TestBed.inject(SettingsService)
tasksService = TestBed.inject(TasksService)
toastService = TestBed.inject(ToastService) toastService = TestBed.inject(ToastService)
router = TestBed.inject(Router) router = TestBed.inject(Router)
hotKeyService = TestBed.inject(HotKeyService) hotKeyService = TestBed.inject(HotKeyService)
@@ -117,30 +120,33 @@ describe('AppComponent', () => {
expect(toast.action).toBeUndefined() expect(toast.action).toBeUndefined()
}) })
it('should display toast on document added', () => { it('should only reload tasks when a document is detected', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
const toastSpy = jest.spyOn(toastService, 'show') const toastSpy = jest.spyOn(toastService, 'show')
const reloadSpy = jest.spyOn(tasksService, 'reload')
const fileStatusSubject = new Subject<FileStatus>() const fileStatusSubject = new Subject<FileStatus>()
jest jest
.spyOn(websocketStatusService, 'onDocumentDetected') .spyOn(websocketStatusService, 'onDocumentDetected')
.mockReturnValue(fileStatusSubject) .mockReturnValue(fileStatusSubject)
component.ngOnInit() component.ngOnInit()
fileStatusSubject.next(new FileStatus()) fileStatusSubject.next(new FileStatus())
expect(toastSpy).toHaveBeenCalled() expect(reloadSpy).toHaveBeenCalled()
expect(toastSpy).not.toHaveBeenCalled()
}) })
it('should suppress dashboard notifications if set', () => { it('should add consumed documents to the bell without a popup', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest.spyOn(settingsService, 'get').mockReturnValue(true) const popupSpy = jest.fn()
jest.spyOn(router, 'url', 'get').mockReturnValue('/dashboard') toastService.showToast.subscribe(popupSpy)
const toastSpy = jest.spyOn(toastService, 'show') let toasts: Toast[] = []
toastService.getToasts().subscribe((t) => (toasts = [...t]))
const fileStatusSubject = new Subject<FileStatus>() const fileStatusSubject = new Subject<FileStatus>()
jest jest
.spyOn(websocketStatusService, 'onDocumentDetected') .spyOn(websocketStatusService, 'onDocumentConsumptionFinished')
.mockReturnValue(fileStatusSubject) .mockReturnValue(fileStatusSubject)
component.ngOnInit() component.ngOnInit()
fileStatusSubject.next(new FileStatus()) fileStatusSubject.next(new FileStatus())
expect(toastSpy).not.toHaveBeenCalled() expect(toasts).toHaveLength(1)
expect(popupSpy).not.toHaveBeenCalled()
}) })
it('should display toast on document failed', () => { it('should display toast on document failed', () => {
+19 -50
View File
@@ -14,7 +14,7 @@ import {
} from './services/permissions.service' } from './services/permissions.service'
import { SettingsService } from './services/settings.service' import { SettingsService } from './services/settings.service'
import { TasksService } from './services/tasks.service' import { TasksService } from './services/tasks.service'
import { ToastService } from './services/toast.service' import { ToastLevel, ToastService } from './services/toast.service'
import { WebsocketStatusService } from './services/websocket-status.service' import { WebsocketStatusService } from './services/websocket-status.service'
@Component({ @Component({
@@ -61,18 +61,6 @@ export class AppComponent implements OnInit, OnDestroy {
} }
} }
private showNotification(key) {
if (
this.router.url == '/dashboard' &&
this.settings.get(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD
)
) {
return false
}
return this.settings.get(key)
}
ngOnInit(): void { ngOnInit(): void {
this.websocketStatusService.connect() this.websocketStatusService.connect()
@@ -80,29 +68,22 @@ export class AppComponent implements OnInit, OnDestroy {
.onDocumentConsumptionFinished() .onDocumentConsumptionFinished()
.subscribe((status) => { .subscribe((status) => {
this.tasksService.reload() this.tasksService.reload()
if ( if (this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS)) {
this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS) const canView = this.permissionsService.currentUserCan(
) { PermissionAction.View,
if ( PermissionType.Document
this.permissionsService.currentUserCan( )
PermissionAction.View, // the upload status tray already shows this, so only keep a record
PermissionType.Document this.toastService.show({
) content: $localize`Document ${status.filename} was added to Paperless-ngx.`,
) { delay: 10000,
this.toastService.show({ level: ToastLevel.Success,
content: $localize`Document ${status.filename} was added to Paperless-ngx.`, silent: true,
delay: 10000, actionName: canView ? $localize`Open document` : undefined,
actionName: $localize`Open document`, action: canView
action: () => { ? () => this.router.navigate(['documents', status.documentId])
this.router.navigate(['documents', status.documentId]) : undefined,
}, })
})
} else {
this.toastService.show({
content: $localize`Document ${status.filename} was added to Paperless-ngx.`,
delay: 10000,
})
}
} }
}) })
@@ -110,9 +91,7 @@ export class AppComponent implements OnInit, OnDestroy {
.onDocumentConsumptionFailed() .onDocumentConsumptionFailed()
.subscribe((status) => { .subscribe((status) => {
this.tasksService.reload() this.tasksService.reload()
if ( if (this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED)) {
this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED)
) {
this.toastService.showError( this.toastService.showError(
$localize`Could not add ${status.filename}\: ${status.message}` $localize`Could not add ${status.filename}\: ${status.message}`
) )
@@ -121,18 +100,8 @@ export class AppComponent implements OnInit, OnDestroy {
this.newDocumentSubscription = this.websocketStatusService this.newDocumentSubscription = this.websocketStatusService
.onDocumentDetected() .onDocumentDetected()
.subscribe((status) => { .subscribe(() => {
this.tasksService.reload() this.tasksService.reload()
if (
this.showNotification(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT
)
) {
this.toastService.show({
content: $localize`Document ${status.filename} is being processed by Paperless-ngx.`,
delay: 5000,
})
}
}) })
this.hotKeyService this.hotKeyService
@@ -190,7 +190,7 @@ export class ConfigComponent
this.initialize(config) this.initialize(config)
this.store.next(config) this.store.next(config)
this.settingsService.initializeSettings().subscribe() this.settingsService.initializeSettings().subscribe()
this.toastService.showInfo($localize`Configuration updated`) this.toastService.showSuccess($localize`Configuration updated`)
}, },
error: (e) => { error: (e) => {
this.loading.set(false) this.loading.set(false)
@@ -217,7 +217,7 @@ export class ConfigComponent
this.initialize(config) this.initialize(config)
this.store.next(config) this.store.next(config)
this.settingsService.initializeSettings().subscribe() this.settingsService.initializeSettings().subscribe()
this.toastService.showInfo($localize`File successfully updated`) this.toastService.showSuccess($localize`File successfully updated`)
}, },
error: (e) => { error: (e) => {
this.loading.set(false) this.loading.set(false)
@@ -425,10 +425,8 @@
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<pngx-input-check i18n-title title="Show notifications when new documents are detected" formControlName="notificationsConsumerNewDocument"></pngx-input-check> <pngx-input-check i18n-title title="Add notifications when document processing completes successfully" formControlName="notificationsConsumerSuccess" i18n-hint hint="These are added to the notifications menu without a popup, processing progress is shown at the bottom of the screen."></pngx-input-check>
<pngx-input-check i18n-title title="Show notifications when document processing completes successfully" formControlName="notificationsConsumerSuccess"></pngx-input-check>
<pngx-input-check i18n-title title="Show notifications when document processing fails" formControlName="notificationsConsumerFailed"></pngx-input-check> <pngx-input-check i18n-title title="Show notifications when document processing fails" formControlName="notificationsConsumerFailed"></pngx-input-check>
<pngx-input-check i18n-title title="Suppress notifications on dashboard" formControlName="notificationsConsumerSuppressOnDashboard" i18n-hint hint="This will suppress all messages about document processing status on the dashboard."></pngx-input-check>
</div> </div>
</div> </div>
@@ -307,7 +307,7 @@ describe('SettingsComponent', () => {
expect(toastErrorSpy).toHaveBeenCalled() expect(toastErrorSpy).toHaveBeenCalled()
expect(storeSpy).toHaveBeenCalled() expect(storeSpy).toHaveBeenCalled()
expect(appearanceSettingsSpy).not.toHaveBeenCalled() expect(appearanceSettingsSpy).not.toHaveBeenCalled()
expect(setSpy).toHaveBeenCalledTimes(35) expect(setSpy).toHaveBeenCalledTimes(33)
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [ expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
HideableSidebarItemID.Workflows, HideableSidebarItemID.Workflows,
]) ])
@@ -63,7 +63,7 @@ import {
SettingsService, SettingsService,
} from 'src/app/services/settings.service' } from 'src/app/services/settings.service'
import { SystemStatusService } from 'src/app/services/system-status.service' import { SystemStatusService } from 'src/app/services/system-status.service'
import { Toast, ToastService } from 'src/app/services/toast.service' import { Toast, ToastLevel, ToastService } from 'src/app/services/toast.service'
import { locationReload } from 'src/app/utils/navigation' import { locationReload } from 'src/app/utils/navigation'
import { CheckComponent } from '../../common/input/check/check.component' import { CheckComponent } from '../../common/input/check/check.component'
import { ColorComponent } from '../../common/input/color/color.component' import { ColorComponent } from '../../common/input/color/color.component'
@@ -190,10 +190,8 @@ export class SettingsComponent
searchDbOnly: new FormControl(null), searchDbOnly: new FormControl(null),
searchLink: new FormControl(null), searchLink: new FormControl(null),
notificationsConsumerNewDocument: new FormControl(null),
notificationsConsumerSuccess: new FormControl(null), notificationsConsumerSuccess: new FormControl(null),
notificationsConsumerFailed: new FormControl(null), notificationsConsumerFailed: new FormControl(null),
notificationsConsumerSuppressOnDashboard: new FormControl(null),
savedViewsWarnOnUnsavedChange: new FormControl(null), savedViewsWarnOnUnsavedChange: new FormControl(null),
sidebarViewsShowCount: new FormControl(null), sidebarViewsShowCount: new FormControl(null),
@@ -371,18 +369,12 @@ export class SettingsComponent
updateCheckingEnabled: this.settings.get( updateCheckingEnabled: this.settings.get(
SETTINGS_KEYS.UPDATE_CHECKING_ENABLED SETTINGS_KEYS.UPDATE_CHECKING_ENABLED
), ),
notificationsConsumerNewDocument: this.settings.get(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT
),
notificationsConsumerSuccess: this.settings.get( notificationsConsumerSuccess: this.settings.get(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS
), ),
notificationsConsumerFailed: this.settings.get( notificationsConsumerFailed: this.settings.get(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED
), ),
notificationsConsumerSuppressOnDashboard: this.settings.get(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD
),
savedViewsWarnOnUnsavedChange: this.settings.get( savedViewsWarnOnUnsavedChange: this.settings.get(
SETTINGS_KEYS.SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE SETTINGS_KEYS.SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE
), ),
@@ -571,10 +563,6 @@ export class SettingsComponent
SETTINGS_KEYS.DATE_FORMAT, SETTINGS_KEYS.DATE_FORMAT,
this.settingsForm.value.dateFormat this.settingsForm.value.dateFormat
) )
this.settings.set(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT,
this.settingsForm.value.notificationsConsumerNewDocument
)
this.settings.set( this.settings.set(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS, SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS,
this.settingsForm.value.notificationsConsumerSuccess this.settingsForm.value.notificationsConsumerSuccess
@@ -583,10 +571,6 @@ export class SettingsComponent
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED, SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED,
this.settingsForm.value.notificationsConsumerFailed this.settingsForm.value.notificationsConsumerFailed
) )
this.settings.set(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD,
this.settingsForm.value.notificationsConsumerSuppressOnDashboard
)
this.settings.set( this.settings.set(
SETTINGS_KEYS.NOTES_ENABLED, SETTINGS_KEYS.NOTES_ENABLED,
this.settingsForm.value.notesEnabled this.settingsForm.value.notesEnabled
@@ -664,8 +648,11 @@ export class SettingsComponent
let savedToast: Toast = { let savedToast: Toast = {
content: $localize`Settings were saved successfully.`, content: $localize`Settings were saved successfully.`,
delay: 5000, delay: 5000,
level: ToastLevel.Success,
persistent: false,
} }
if (reloadRequired) { if (reloadRequired) {
savedToast.persistent = true
savedToast.content = $localize`Settings were saved successfully. Reload is required to apply some changes.` savedToast.content = $localize`Settings were saved successfully. Reload is required to apply some changes.`
savedToast.actionName = $localize`Reload now` savedToast.actionName = $localize`Reload now`
savedToast.action = () => { savedToast.action = () => {
@@ -102,25 +102,34 @@
</button> </button>
} }
<ngb-pagination @if (selectedSection !== TaskSection.All && totalTasks() > pageSize) {
[pageSize]="pageSize" <ngb-pagination
[collectionSize]="totalTasks()" [pageSize]="pageSize"
[page]="page" [collectionSize]="totalTasks()"
[maxSize]="5" [page]="page"
[rotate]="true" [maxSize]="5"
size="sm" [rotate]="true"
aria-label="Tasks pagination" size="sm"
(pageChange)="setPage($event)"> aria-label="Tasks pagination"
</ngb-pagination> (pageChange)="setPage($event)">
</ngb-pagination>
}
</div> </div>
<ng-template let-tasks="tasks" let-section="section" #tasksTemplate> <ng-template let-tasks="tasks" let-section="section" #tasksTemplate>
<div class="section-header d-flex align-items-center justify-content-between mb-2"> <div class="section-header mb-2">
<div> <h5 class="mb-0">{{ sectionLabel(section) }}</h5>
<h5 class="mb-0">{{ sectionLabel(section) }}</h5> @let count = sectionCount(section);
<div class="small text-muted"> @let hasMore = selectedSection === TaskSection.All && count > tasks.length;
<ng-container i18n>{tasks.length, plural, =1 {1 task} other {{{tasks.length}} tasks}}</ng-container> <div class="small text-muted">
</div> @if (hasMore) {
<ng-container i18n>{{tasks.length}} of {{count}} tasks</ng-container>
<button class="btn btn-link show-all p-0 ms-2 align-baseline" type="button" (click)="setSection(section)">
<ng-container i18n>Show all</ng-container><i-bs class="ms-1" width="1em" height="1em" name="arrow-right"></i-bs>
</button>
} @else {
<ng-container i18n>{count, plural, =1 {1 task} other {{{count}} tasks}}</ng-container>
}
</div> </div>
</div> </div>
@@ -169,10 +178,9 @@
<div>{{ taskDisplayName(task) }}</div> <div>{{ taskDisplayName(task) }}</div>
<div class="small text-muted"> <div class="small text-muted">
@if (taskShowsSeparateTypeLabel(task)) { @if (taskShowsSeparateTypeLabel(task)) {
<span>{{ task.task_type_display }}</span> <span class="me-2">{{ task.task_type_display }}</span>
<span class="mx-1">&bull;</span>
} }
<span>{{ task.trigger_source_display }}</span> <span class="text-nowrap"><i-bs class="me-1" width="1em" height="1em" name="box-arrow-in-right"></i-bs>{{ task.trigger_source_display }}</span>
</div> </div>
</td> </td>
<td class="d-none d-lg-table-cell created-col">{{ task.date_created | customDate:'short' }}</td> <td class="d-none d-lg-table-cell created-col">{{ task.date_created | customDate:'short' }}</td>
@@ -72,3 +72,7 @@ pre {
tbody tr:nth-last-child(2) td { tbody tr:nth-last-child(2) td {
border-bottom: none !important; border-bottom: none !important;
} }
.section-header .show-all {
font-size: inherit;
}
@@ -223,16 +223,6 @@ describe('TasksComponent', () => {
) )
.flush(paginatedTasks) .flush(paginatedTasks)
httpTestingController
.expectOne(
(req) =>
req.url === `${environment.apiBaseUrl}tasks/` &&
req.params.get('acknowledged') === 'false' &&
req.params.get('page_size') === '25' &&
req.params.get('page') === '1'
)
.flush(paginatedTasks)
httpTestingController httpTestingController
.expectOne( .expectOne(
(req) => (req) =>
@@ -241,8 +231,30 @@ describe('TasksComponent', () => {
!req.params.has('status') !req.params.has('status')
) )
.flush(sectionCountResponse) .flush(sectionCountResponse)
flushTaskLists(10)
}) })
// answers task list requests with the fixture tasks matching their status filter
function flushTaskLists(pageSize: number, count?: number) {
httpTestingController
.match(
(req) =>
req.url === `${environment.apiBaseUrl}tasks/` &&
req.params.get('page_size') === `${pageSize}`
)
.forEach((req) => {
const statuses = req.request.params.getAll('status')
const results = tasks.filter((task) => statuses.includes(task.status))
req.flush({ count: count ?? results.length, results })
})
}
function selectSection(section: TaskSection, count: number) {
component.setSection(section)
flushTaskLists(25, count)
}
it('should display task sections with counts', () => { it('should display task sections with counts', () => {
expect(component.selectedSection).toBe(TaskSection.All) expect(component.selectedSection).toBe(TaskSection.All)
expect(component.selectedTaskType).toBeNull() expect(component.selectedTaskType).toBeNull()
@@ -338,6 +350,7 @@ describe('TasksComponent', () => {
}) })
it('should render pagination controls next to the task filter', () => { it('should render pagination controls next to the task filter', () => {
selectSection(TaskSection.Completed, 30)
fixture.detectChanges() fixture.detectChanges()
const controls = fixture.debugElement.query(By.css('.task-controls')) const controls = fixture.debugElement.query(By.css('.task-controls'))
@@ -348,6 +361,51 @@ describe('TasksComponent', () => {
expect(pagination).not.toBeNull() expect(pagination).not.toBeNull()
}) })
it('should only paginate a section with more than one page of tasks', () => {
const pagination = () =>
fixture.debugElement.query(By.css('ngb-pagination'))
fixture.detectChanges()
expect(pagination()).toBeNull()
selectSection(TaskSection.Completed, 2)
fixture.detectChanges()
expect(pagination()).toBeNull()
})
it('should load the all tasks overview one section at a time', () => {
expect(component.tasksForSection(TaskSection.NeedsAttention)).toHaveLength(
2
)
expect(component.tasksForSection(TaskSection.InProgress)).toHaveLength(3)
expect(component.tasksForSection(TaskSection.Completed)).toHaveLength(2)
expect(component.totalTasks()).toBe(7)
})
it('should show section totals and link truncated sections to their full list', () => {
component.sectionCounts.update((counts) => ({
...counts,
[TaskSection.Completed]: 27,
}))
fixture.detectChanges()
const header = (label: string) =>
fixture.debugElement
.queryAll(By.css('.section-header'))
.find((h) => h.nativeElement.textContent.includes(label))
expect(header('Needs attention').nativeElement.textContent).toContain(
'2 tasks'
)
expect(header('Needs attention').query(By.css('button'))).toBeNull()
expect(header('Recently completed').nativeElement.textContent).toContain(
'2 of 27 tasks'
)
header('Recently completed').query(By.css('button')).nativeElement.click()
expect(component.selectedSection).toBe(TaskSection.Completed)
})
it('should apply the selected section to the server-side task query', () => { it('should apply the selected section to the server-side task query', () => {
component.setSection(TaskSection.NeedsAttention) component.setSection(TaskSection.NeedsAttention)
@@ -368,55 +426,56 @@ describe('TasksComponent', () => {
it('should apply task type and trigger source filters to the server-side task query', () => { it('should apply task type and trigger source filters to the server-side task query', () => {
component.setTaskType(PaperlessTaskType.SanityCheck) component.setTaskType(PaperlessTaskType.SanityCheck)
httpTestingController expect(
.expectOne( httpTestingController.match(
(request) => (request) =>
request.url === `${environment.apiBaseUrl}tasks/` && request.url === `${environment.apiBaseUrl}tasks/` &&
request.params.get('page_size') === '25' && request.params.get('page_size') === '10' &&
request.params.get('task_type') === PaperlessTaskType.SanityCheck request.params.get('task_type') === PaperlessTaskType.SanityCheck
) )
.flush({ count: 1, results: [tasks[6]] }) ).toHaveLength(3)
component.setTriggerSource(PaperlessTaskTriggerSource.System) component.setTriggerSource(PaperlessTaskTriggerSource.System)
httpTestingController expect(
.expectOne( httpTestingController.match(
(request) => (request) =>
request.url === `${environment.apiBaseUrl}tasks/` && request.url === `${environment.apiBaseUrl}tasks/` &&
request.params.get('page_size') === '25' && request.params.get('page_size') === '10' &&
request.params.get('task_type') === PaperlessTaskType.SanityCheck && request.params.get('task_type') === PaperlessTaskType.SanityCheck &&
request.params.get('trigger_source') === request.params.get('trigger_source') ===
PaperlessTaskTriggerSource.System PaperlessTaskTriggerSource.System
) )
.flush({ count: 1, results: [tasks[6]] }) ).toHaveLength(3)
}) })
it('should apply text filters to the server-side task query', () => { it('should apply text filters to the server-side task query', () => {
component.filterText = 'invoice' component.filterText = 'invoice'
jest.advanceTimersByTime(150) jest.advanceTimersByTime(150)
httpTestingController expect(
.expectOne( httpTestingController.match(
(request) => (request) =>
request.url === `${environment.apiBaseUrl}tasks/` && request.url === `${environment.apiBaseUrl}tasks/` &&
request.params.get('page_size') === '25' && request.params.get('page_size') === '10' &&
request.params.get('name') === 'invoice' request.params.get('name') === 'invoice'
) )
.flush({ count: 1, results: [tasks[0]] }) ).toHaveLength(3)
component.setFilterTarget(TaskFilterTargetID.Result) component.setFilterTarget(TaskFilterTargetID.Result)
httpTestingController expect(
.expectOne( httpTestingController.match(
(request) => (request) =>
request.url === `${environment.apiBaseUrl}tasks/` && request.url === `${environment.apiBaseUrl}tasks/` &&
request.params.get('page_size') === '25' && request.params.get('page_size') === '10' &&
request.params.get('result') === 'invoice' request.params.get('result') === 'invoice'
) )
.flush({ count: 0, results: [] }) ).toHaveLength(3)
}) })
it('should load a different task page when pagination changes', () => { it('should load a different task page when pagination changes', () => {
selectSection(TaskSection.Completed, 30)
component.setPage(2) component.setPage(2)
const pageTwoTasks = { const pageTwoTasks = {
@@ -440,6 +499,7 @@ describe('TasksComponent', () => {
}) })
it('should not replace section counts with current-page counts', () => { it('should not replace section counts with current-page counts', () => {
selectSection(TaskSection.Completed, 30)
component.setPage(2) component.setPage(2)
httpTestingController httpTestingController
@@ -455,11 +515,21 @@ describe('TasksComponent', () => {
results: [tasks[0]], results: [tasks[0]],
}) })
expect(component.sectionCount(TaskSection.All)).toBe(7)
expect(component.sectionCount(TaskSection.NeedsAttention)).toBe(2) expect(component.sectionCount(TaskSection.NeedsAttention)).toBe(2)
expect(component.sectionCount(TaskSection.InProgress)).toBe(3) expect(component.sectionCount(TaskSection.InProgress)).toBe(3)
expect(component.sectionCount(TaskSection.Completed)).toBe(2) expect(component.sectionCount(TaskSection.Completed)).toBe(2)
}) })
it('should stop loading when the overview fails to load', () => {
component.setSection(TaskSection.All)
httpTestingController
.match((req) => req.params.get('page_size') === '10')[0]
.flush('', { status: 500, statusText: 'Error' })
expect(component.loading()).toBe(false)
})
it('should expose stable task type options and disable empty ones', () => { it('should expose stable task type options and disable empty ones', () => {
expect(component.taskTypeOptions.map((option) => option.value)).toContain( expect(component.taskTypeOptions.map((option) => option.value)).toContain(
PaperlessTaskType.TrainClassifier PaperlessTaskType.TrainClassifier
@@ -15,6 +15,9 @@ import {
distinctUntilChanged, distinctUntilChanged,
filter, filter,
first, first,
forkJoin,
map,
Observable,
Subject, Subject,
takeUntil, takeUntil,
timer, timer,
@@ -25,6 +28,7 @@ import {
PaperlessTaskTriggerSource, PaperlessTaskTriggerSource,
PaperlessTaskType, PaperlessTaskType,
} from 'src/app/data/paperless-task' } from 'src/app/data/paperless-task'
import { Results } from 'src/app/data/results'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { TasksService } from 'src/app/services/tasks.service' import { TasksService } from 'src/app/services/tasks.service'
@@ -169,6 +173,7 @@ export class TasksComponent
public expandedTask: number public expandedTask: number
public autoRefreshEnabled: boolean = true public autoRefreshEnabled: boolean = true
public readonly pageSize = 25 public readonly pageSize = 25
public readonly overviewSize = 10
public page: number = 1 public page: number = 1
readonly totalTasks = signal(0) readonly totalTasks = signal(0)
readonly sectionCounts = signal<Record<TaskSection, number>>({ readonly sectionCounts = signal<Record<TaskSection, number>>({
@@ -484,13 +489,6 @@ export class TasksComponent
return this.sectionCounts()[section] return this.sectionCounts()[section]
} }
private setSectionCount(section: TaskSection, count: number) {
this.sectionCounts.update((counts) => ({
...counts,
[section]: count,
}))
}
sectionShowsResults(section: TaskSection): boolean { sectionShowsResults(section: TaskSection): boolean {
return section !== TaskSection.InProgress return section !== TaskSection.InProgress
} }
@@ -737,21 +735,12 @@ export class TasksComponent
this.reloadSectionCounts() this.reloadSectionCounts()
this.loading.set(true) this.loading.set(true)
this.tasksService this.listTasks()
.list(
this.page,
this.pageSize,
this.getParamsForSection(this.selectedSection)
)
.pipe(first(), takeUntil(this.unsubscribeNotifier)) .pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: (result) => { next: (result) => {
this.pagedTasks.set(result.results) this.pagedTasks.set(result.results)
this.totalTasks.set(result.count) this.totalTasks.set(result.count)
this.setSectionCount(TaskSection.All, result.count)
if (this.selectedSection !== TaskSection.All) {
this.setSectionCount(this.selectedSection, result.count)
}
this.loading.set(false) this.loading.set(false)
if ( if (
this.page > 1 && this.page > 1 &&
@@ -767,4 +756,30 @@ export class TasksComponent
}, },
}) })
} }
private listTasks(): Observable<Results<PaperlessTask>> {
if (this.selectedSection !== TaskSection.All) {
return this.tasksService.list(
this.page,
this.pageSize,
this.getParamsForSection(this.selectedSection)
)
}
// All is an overview of each section to avoid splitting sections across pages
return forkJoin(
this.sections.map((section) =>
this.tasksService.list(
1,
this.overviewSize,
this.getParamsForSection(section)
)
)
).pipe(
map((results) => ({
count: results.reduce((sum, result) => sum + result.count, 0),
results: results.flatMap((result) => result.results),
}))
)
}
} }
@@ -99,11 +99,16 @@ describe('TrashComponent', () => {
modal.componentInstance.confirmClicked.next() modal.componentInstance.confirmClicked.next()
expect(toastErrorSpy).toHaveBeenCalled() expect(toastErrorSpy).toHaveBeenCalled()
let toasts
toastService.getToasts().subscribe((allToasts) => {
toasts = [...allToasts]
})
trashSpy.mockReturnValue(of('OK')) trashSpy.mockReturnValue(of('OK'))
component.delete(documentsInTrash[0]) component.delete(documentsInTrash[0])
expect(modal).toBeDefined() expect(modal).toBeDefined()
modal.componentInstance.confirmClicked.next() modal.componentInstance.confirmClicked.next()
expect(trashSpy).toHaveBeenCalled() expect(trashSpy).toHaveBeenCalled()
expect(toasts[0].content).toContain('deleted')
}) })
it('should support empty trash, show error if needed', () => { it('should support empty trash, show error if needed', () => {
@@ -11,7 +11,7 @@ import { takeUntil, tap } from 'rxjs'
import { Document } from 'src/app/data/document' import { Document } from 'src/app/data/document'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastLevel, ToastService } from 'src/app/services/toast.service'
import { TrashService } from 'src/app/services/trash.service' import { TrashService } from 'src/app/services/trash.service'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component' import { PageHeaderComponent } from '../../common/page-header/page-header.component'
@@ -87,9 +87,11 @@ export class TrashComponent
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled.set(false)
this.trashService.emptyTrash([document.id]).subscribe({ this.trashService.emptyTrash([document.id]).subscribe({
next: () => { next: () => {
this.toastService.showInfo( this.toastService.show({
$localize`Document "${document.title}" deleted` content: $localize`Document "${document.title}" deleted`,
) delay: 5000,
level: ToastLevel.Success,
})
modal.close() modal.close()
this.reload() this.reload()
}, },
@@ -122,7 +124,11 @@ export class TrashComponent
.emptyTrash(documents ? Array.from(documents) : null) .emptyTrash(documents ? Array.from(documents) : null)
.subscribe({ .subscribe({
next: () => { next: () => {
this.toastService.showInfo($localize`Document(s) deleted`) this.toastService.show({
content: $localize`Document(s) deleted`,
delay: 5000,
level: ToastLevel.Success,
})
this.allToggled.set(false) this.allToggled.set(false)
modal.close() modal.close()
this.reload() this.reload()
@@ -144,6 +150,7 @@ export class TrashComponent
this.toastService.show({ this.toastService.show({
content: $localize`Document "${document.title}" restored`, content: $localize`Document "${document.title}" restored`,
delay: 5000, delay: 5000,
level: ToastLevel.Success,
actionName: $localize`Open document`, actionName: $localize`Open document`,
action: () => { action: () => {
this.router.navigate(['documents', document.id]) this.router.navigate(['documents', document.id])
@@ -165,7 +172,11 @@ export class TrashComponent
.restoreDocuments(documents ? Array.from(documents) : null) .restoreDocuments(documents ? Array.from(documents) : null)
.subscribe({ .subscribe({
next: () => { next: () => {
this.toastService.showInfo($localize`Document(s) restored`) this.toastService.show({
content: $localize`Document(s) restored`,
delay: 5000,
level: ToastLevel.Success,
})
this.allToggled.set(false) this.allToggled.set(false)
this.reload() this.reload()
}, },
@@ -101,7 +101,7 @@ describe('UsersAndGroupsComponent', () => {
component.editUser(users[0]) component.editUser(users[0])
const editDialog = modal.componentInstance as UserEditDialogComponent const editDialog = modal.componentInstance as UserEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
editDialog.failed.emit() editDialog.failed.emit()
expect(toastErrorSpy).toHaveBeenCalled() expect(toastErrorSpy).toHaveBeenCalled()
settingsService.currentUser.set(users[1]) // simulate logged in as different user settingsService.currentUser.set(users[1]) // simulate logged in as different user
@@ -120,7 +120,7 @@ describe('UsersAndGroupsComponent', () => {
const deleteDialog = modal.componentInstance as ConfirmDialogComponent const deleteDialog = modal.componentInstance as ConfirmDialogComponent
const deleteSpy = jest.spyOn(userService, 'delete') const deleteSpy = jest.spyOn(userService, 'delete')
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const listAllSpy = jest.spyOn(userService, 'listAll') const listAllSpy = jest.spyOn(userService, 'listAll')
deleteSpy.mockReturnValueOnce( deleteSpy.mockReturnValueOnce(
throwError(() => new Error('error deleting user')) throwError(() => new Error('error deleting user'))
@@ -154,6 +154,31 @@ describe('UsersAndGroupsComponent', () => {
jest.useRealTimers() jest.useRealTimers()
}) })
it('should logout current user under base href if password changed', () => {
jest.useFakeTimers()
const base = document.createElement('base')
base.href = '/paperless/'
document.head.appendChild(base)
completeSetup()
let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
component.editUser(users[0])
const navSpy = jest
.spyOn(navUtils, 'setLocationHref')
.mockImplementation(() => {})
const editDialog = modal.componentInstance as UserEditDialogComponent
editDialog.passwordIsSet = true
settingsService.currentUser.set(users[0]) // simulate logged in as same user
editDialog.succeeded.emit(users[0])
fixture.detectChanges()
jest.advanceTimersByTime(2600)
expect(navSpy).toHaveBeenCalledWith(
`${window.location.origin}/paperless/accounts/logout/?next=/paperless/accounts/login/?next=/paperless/`
)
base.remove()
jest.useRealTimers()
})
it('should support edit / create group, show error if needed', () => { it('should support edit / create group, show error if needed', () => {
completeSetup() completeSetup()
let modal: NgbModalRef let modal: NgbModalRef
@@ -161,7 +186,7 @@ describe('UsersAndGroupsComponent', () => {
component.editGroup(groups[0]) component.editGroup(groups[0])
const editDialog = modal.componentInstance as GroupEditDialogComponent const editDialog = modal.componentInstance as GroupEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
editDialog.failed.emit() editDialog.failed.emit()
expect(toastErrorSpy).toHaveBeenCalled() expect(toastErrorSpy).toHaveBeenCalled()
editDialog.succeeded.emit(groups[0]) editDialog.succeeded.emit(groups[0])
@@ -179,7 +204,7 @@ describe('UsersAndGroupsComponent', () => {
const deleteDialog = modal.componentInstance as ConfirmDialogComponent const deleteDialog = modal.componentInstance as ConfirmDialogComponent
const deleteSpy = jest.spyOn(groupService, 'delete') const deleteSpy = jest.spyOn(groupService, 'delete')
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const listAllSpy = jest.spyOn(groupService, 'listAll') const listAllSpy = jest.spyOn(groupService, 'listAll')
deleteSpy.mockReturnValueOnce( deleteSpy.mockReturnValueOnce(
throwError(() => new Error('error deleting group')) throwError(() => new Error('error deleting group'))
@@ -116,12 +116,13 @@ export class UsersAndGroupsComponent
$localize`Password has been changed, you will be logged out momentarily.` $localize`Password has been changed, you will be logged out momentarily.`
) )
setTimeout(() => { setTimeout(() => {
const basePath = new URL(document.baseURI).pathname
setLocationHref( setLocationHref(
`${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/` `${window.location.origin}${basePath}accounts/logout/?next=${basePath}accounts/login/?next=${basePath}`
) )
}, 2500) }, 2500)
} else { } else {
this.toastService.showInfo( this.toastService.showSuccess(
$localize`Saved user "${newUser.username}".` $localize`Saved user "${newUser.username}".`
) )
this.usersService.listAll().subscribe((r) => { this.usersService.listAll().subscribe((r) => {
@@ -150,7 +151,9 @@ export class UsersAndGroupsComponent
this.usersService.delete(user).subscribe({ this.usersService.delete(user).subscribe({
next: () => { next: () => {
modal.close() modal.close()
this.toastService.showInfo($localize`Deleted user "${user.username}"`) this.toastService.showSuccess(
$localize`Deleted user "${user.username}"`
)
this.usersService.listAll().subscribe((r) => { this.usersService.listAll().subscribe((r) => {
this.users.set(r.results) this.users.set(r.results)
}) })
@@ -177,7 +180,9 @@ export class UsersAndGroupsComponent
modal.componentInstance.succeeded modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newGroup) => { .subscribe((newGroup) => {
this.toastService.showInfo($localize`Saved group "${newGroup.name}".`) this.toastService.showSuccess(
$localize`Saved group "${newGroup.name}".`
)
this.groupsService.listAll().subscribe((r) => { this.groupsService.listAll().subscribe((r) => {
this.groups.set(r.results) this.groups.set(r.results)
}) })
@@ -203,7 +208,9 @@ export class UsersAndGroupsComponent
this.groupsService.delete(group).subscribe({ this.groupsService.delete(group).subscribe({
next: () => { next: () => {
modal.close() modal.close()
this.toastService.showInfo($localize`Deleted group "${group.name}"`) this.toastService.showSuccess(
$localize`Deleted group "${group.name}"`
)
this.groupsService.listAll().subscribe((r) => { this.groupsService.listAll().subscribe((r) => {
this.groups.set(r.results) this.groups.set(r.results)
}) })
@@ -29,8 +29,20 @@
</div> </div>
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 me-sm-auto order-3 order-sm-1" <div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 me-sm-auto order-3 order-sm-1"
[class.mobile-hidden]="mobileSearchHidden()"> [class.mobile-hidden]="mobileSearchHidden()">
<div class="col-12 header-search mx-auto"> <div class="col-12 header-search mx-auto d-flex align-items-stretch gap-3">
<pngx-global-search></pngx-global-search> <pngx-global-search class="flex-grow-1"></pngx-global-search>
<div *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Document }" class="btn-group header-upload-group flex-shrink-0">
<button class="btn btn-sm header-upload border-0 d-flex align-items-center justify-content-center py-0 px-2" type="button"
(click)="fileUpload.click()" aria-label="Upload documents" i18n-aria-label>
<i-bs class="d-flex" width="1.1em" height="1.1em" name="file-earmark-arrow-up"></i-bs><span class="ms-2 d-sm-none d-md-inline" i18n>Upload</span>
</button>
@if (uploadsInProgress > 0) {
<button class="btn btn-sm header-upload border-0 d-flex align-items-center py-0 px-2" type="button"
(click)="showUploadStatus()" aria-label="Show document processing status" i18n-aria-label>
<span class="spinner-border spinner-border-sm" aria-hidden="true"></span><span class="ms-2 small">{{ uploadsInProgress }}</span>
</button>
}
</div>
</div> </div>
</div> </div>
<ul ngbNav class="order-sm-3"> <ul ngbNav class="order-sm-3">
@@ -70,6 +82,9 @@
</ul> </ul>
</nav> </nav>
<input type="file" class="visually-hidden" id="navbar-file-upload" (change)="onFileSelected($event)" multiple #fileUpload tabindex="-1" aria-hidden="true">
<label for="navbar-file-upload" class="visually-hidden" i18n>Upload Files</label>
<div class="container-fluid"> <div class="container-fluid">
<div class="row"> <div class="row">
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse" <nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
@@ -419,5 +434,6 @@
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-sidebar-expanded'"> [ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-sidebar-expanded'">
<router-outlet></router-outlet> <router-outlet></router-outlet>
</main> </main>
<pngx-upload-status-tray></pngx-upload-status-tray>
</div> </div>
</div> </div>
@@ -66,6 +66,7 @@
} }
.search-container { .search-container {
min-width: 0;
padding-left: 1rem; padding-left: 1rem;
padding-right: 1rem; padding-right: 1rem;
} }
@@ -499,6 +500,11 @@ main {
// true center the search with equal flex widths // true center the search with equal flex widths
@media (min-width: 768px) { @media (min-width: 768px) {
.search-container {
// Prevent input padding and the conditional search button from resizing
flex-basis: 55rem;
}
.navbar-brand-container, .navbar-brand-container,
.navbar > ul { .navbar > ul {
display: flex; // so the brand link stays as wide as its contents display: flex; // so the brand link stays as wide as its contents
@@ -532,7 +538,36 @@ main {
.header-search { .header-search {
width: 100%; width: 100%;
max-width: 55rem; max-width: 50rem;
}
.header-search pngx-global-search {
min-width: 0;
}
// light w shadow to stand out against search
.header-upload {
--bs-btn-bg: rgba(255, 255, 255, .15);
--bs-btn-hover-bg: rgba(255, 255, 255, .22);
--bs-btn-active-bg: rgba(255, 255, 255, .3);
color: var(--pngx-primary-text-contrast) !important; // .navbar .dropdown > .btn in styles.scss
& + & {
border-left: 1px solid rgba(255, 255, 255, .2) !important;
}
&:focus {
box-shadow: none;
}
&:focus-visible {
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, .5);
}
}
.header-upload-group {
border-radius: var(--bs-border-radius-sm);
box-shadow: 0 1px 3px rgba(0, 0, 0, .35), 0 1px 1px rgba(0, 0, 0, .2);
} }
@media screen and (max-width: 575.98px) { @media screen and (max-width: 575.98px) {
@@ -624,8 +659,7 @@ main {
} }
.dropdown-toggle::after { .dropdown-toggle::after {
margin-left: 0.4em; display: none;
vertical-align: 0.155em;
} }
.navbar .dropdown-menu { .navbar .dropdown-menu {
@@ -662,3 +696,7 @@ main {
::ng-deep .navItemDrag .position-absolute i-bs { ::ng-deep .navItemDrag .position-absolute i-bs {
display: none; display: none;
} }
.smaller {
font-size: 0.75rem;
}
@@ -6,7 +6,7 @@ import {
} from '@angular/common/http/testing' } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { BrowserModule } from '@angular/platform-browser' import { BrowserModule, By } from '@angular/platform-browser'
import { ActivatedRoute, Router } from '@angular/router' import { ActivatedRoute, Router } from '@angular/router'
import { RouterTestingModule } from '@angular/router/testing' import { RouterTestingModule } from '@angular/router/testing'
import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap' import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap'
@@ -33,8 +33,11 @@ import { SearchService } from 'src/app/services/rest/search.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { TasksService } from 'src/app/services/tasks.service' import { TasksService } from 'src/app/services/tasks.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component' import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
import { UploadStatusTrayComponent } from '../common/upload-status-tray/upload-status-tray.component'
import { DocumentDetailComponent } from '../document-detail/document-detail.component' import { DocumentDetailComponent } from '../document-detail/document-detail.component'
import { AppFrameComponent } from './app-frame.component' import { AppFrameComponent } from './app-frame.component'
import { GlobalSearchComponent } from './global-search/global-search.component' import { GlobalSearchComponent } from './global-search/global-search.component'
@@ -462,6 +465,45 @@ describe('AppFrameComponent', () => {
expect(routerSpy).toHaveBeenCalled() expect(routerSpy).toHaveBeenCalled()
}) })
it('should upload files selected from the navbar upload button', () => {
const uploadDocumentsService = TestBed.inject(UploadDocumentsService)
const uploadSpy = jest
.spyOn(uploadDocumentsService, 'uploadFiles')
.mockImplementation(() => {})
const toastSpy = jest.spyOn(toastService, 'showInfo')
const file = new File(['testing'], 'file.pdf')
const input = window.document.createElement('input')
jest.spyOn(input, 'files', 'get').mockReturnValue([file] as any)
component.onFileSelected({ target: input } as unknown as Event)
expect(toastSpy).not.toHaveBeenCalled()
expect(uploadSpy).toHaveBeenCalledWith([file])
})
it('should show in-progress uploads and expand the status tray', () => {
const websocketStatusService = TestBed.inject(WebsocketStatusService)
expect(component.uploadsInProgress).toEqual(0)
websocketStatusService.newFileUpload('file.pdf')
expect(component.uploadsInProgress).toEqual(1)
fixture.detectChanges()
const tray = fixture.debugElement.query(
By.directive(UploadStatusTrayComponent)
).componentInstance as UploadStatusTrayComponent
tray.toggleCollapsed()
expect(tray.collapsed()).toBe(true)
component.showUploadStatus()
expect(tray.collapsed()).toBe(false)
})
it('should not upload when no files are selected from the navbar', () => {
const uploadDocumentsService = TestBed.inject(UploadDocumentsService)
const uploadSpy = jest.spyOn(uploadDocumentsService, 'uploadFiles')
const input = window.document.createElement('input')
component.onFileSelected({ target: input } as unknown as Event)
expect(uploadSpy).not.toHaveBeenCalled()
})
it('should close all documents on logout', () => { it('should close all documents on logout', () => {
const closeAllSpy = jest.spyOn(openDocumentsService, 'closeAll') const closeAllSpy = jest.spyOn(openDocumentsService, 'closeAll')
component.onLogout() component.onLogout()
@@ -483,7 +525,7 @@ describe('AppFrameComponent', () => {
it('should update saved view sorting on drag + drop, show info', () => { it('should update saved view sorting on drag + drop, show info', () => {
const settingsSpy = jest.spyOn(settingsService, 'updateSidebarViewsSort') const settingsSpy = jest.spyOn(settingsService, 'updateSidebarViewsSort')
const toastSpy = jest.spyOn(toastService, 'showInfo') const toastSpy = jest.spyOn(toastService, 'showSuccess')
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true)) jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
component.onDrop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop< component.onDrop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop<
SavedView[] SavedView[]
@@ -525,6 +567,7 @@ describe('AppFrameComponent', () => {
it('should show toasts for django messages', () => { it('should show toasts for django messages', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
jest.spyOn(messagesService, 'get').mockReturnValue([ jest.spyOn(messagesService, 'get').mockReturnValue([
{ level: DjangoMessageLevel.WARNING, message: 'Test warning' }, { level: DjangoMessageLevel.WARNING, message: 'Test warning' },
@@ -535,7 +578,8 @@ describe('AppFrameComponent', () => {
]) ])
component.ngOnInit() component.ngOnInit()
expect(toastErrorSpy).toHaveBeenCalledTimes(2) expect(toastErrorSpy).toHaveBeenCalledTimes(2)
expect(toastInfoSpy).toHaveBeenCalledTimes(3) expect(toastSuccessSpy).toHaveBeenCalledTimes(1)
expect(toastInfoSpy).toHaveBeenCalledTimes(2)
}) })
it('should call maybeRefreshDocumentCounts after saved views reload', () => { it('should call maybeRefreshDocumentCounts after saved views reload', () => {
@@ -6,7 +6,14 @@ import {
moveItemInArray, moveItemInArray,
} from '@angular/cdk/drag-drop' } from '@angular/cdk/drag-drop'
import { NgClass } from '@angular/common' import { NgClass } from '@angular/common'
import { Component, HostListener, inject, OnInit, signal } from '@angular/core' import {
Component,
HostListener,
inject,
OnInit,
signal,
viewChild,
} from '@angular/core'
import { FormsModule } from '@angular/forms' import { FormsModule } from '@angular/forms'
import { ActivatedRoute, Router, RouterModule } from '@angular/router' import { ActivatedRoute, Router, RouterModule } from '@angular/router'
import { import {
@@ -48,12 +55,15 @@ import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { TasksService } from 'src/app/services/tasks.service' import { TasksService } from 'src/app/services/tasks.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { ChatComponent } from '../chat/chat/chat.component' import { ChatComponent } from '../chat/chat/chat.component'
import { SwitchComponent } from '../common/input/switch/switch.component'
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component' import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
import { LogoComponent } from '../common/logo/logo.component' import { LogoComponent } from '../common/logo/logo.component'
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component' import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
import { SwitchComponent } from '../common/input/switch/switch.component' import { UploadStatusTrayComponent } from '../common/upload-status-tray/upload-status-tray.component'
import { DocumentDetailComponent } from '../document-detail/document-detail.component' import { DocumentDetailComponent } from '../document-detail/document-detail.component'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component' import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
import { GlobalSearchComponent } from './global-search/global-search.component' import { GlobalSearchComponent } from './global-search/global-search.component'
@@ -84,6 +94,7 @@ const SCROLL_THRESHOLD = 16
TourNgBootstrap, TourNgBootstrap,
FormsModule, FormsModule,
SwitchComponent, SwitchComponent,
UploadStatusTrayComponent,
], ],
}) })
export class AppFrameComponent export class AppFrameComponent
@@ -101,6 +112,10 @@ export class AppFrameComponent
private modalService = inject(NgbModal) private modalService = inject(NgbModal)
permissionsService = inject(PermissionsService) permissionsService = inject(PermissionsService)
private djangoMessagesService = inject(DjangoMessagesService) private djangoMessagesService = inject(DjangoMessagesService)
private uploadDocumentsService = inject(UploadDocumentsService)
private websocketStatusService = inject(WebsocketStatusService)
private readonly uploadStatusTray = viewChild(UploadStatusTrayComponent)
readonly appRemoteVersion = signal<AppRemoteVersion>(null) readonly appRemoteVersion = signal<AppRemoteVersion>(null)
readonly isMenuCollapsed = signal(true) readonly isMenuCollapsed = signal(true)
@@ -171,6 +186,8 @@ export class AppFrameComponent
this.toastService.showError(message.message) this.toastService.showError(message.message)
break break
case DjangoMessageLevel.SUCCESS: case DjangoMessageLevel.SUCCESS:
this.toastService.showSuccess(message.message)
break
case DjangoMessageLevel.INFO: case DjangoMessageLevel.INFO:
case DjangoMessageLevel.DEBUG: case DjangoMessageLevel.DEBUG:
this.toastService.showInfo(message.message) this.toastService.showInfo(message.message)
@@ -477,7 +494,7 @@ export class AppFrameComponent
this.settingsService.updateSidebarViewsSort(sidebarViews).subscribe({ this.settingsService.updateSidebarViewsSort(sidebarViews).subscribe({
next: () => { next: () => {
this.toastService.showInfo($localize`Sidebar views updated`) this.toastService.showSuccess($localize`Sidebar views updated`)
}, },
error: (e) => { error: (e) => {
this.toastService.showError($localize`Error updating sidebar views`, e) this.toastService.showError($localize`Error updating sidebar views`, e)
@@ -511,6 +528,22 @@ export class AppFrameComponent
} }
} }
onFileSelected(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files ?? [])
input.value = ''
if (!files.length) return
this.uploadDocumentsService.uploadFiles(files)
}
get uploadsInProgress(): number {
return this.websocketStatusService.getConsumerStatusNotCompleted().length
}
showUploadStatus() {
this.uploadStatusTray()?.expand()
}
onLogout() { onLogout() {
this.openDocumentsService.closeAll() this.openDocumentsService.closeAll()
} }
@@ -70,6 +70,17 @@ form {
max-height: 75vh; max-height: 75vh;
} }
.dropdown.show {
// Hide the search icon when the dropdown is shown
i-bs[name="search"] {
display: none !important;
}
.form-control {
padding-left: 0.5rem;
}
}
.dropdown-item { .dropdown-item {
&:has(button:focus) { &:has(button:focus) {
background-color: var(--pngx-bg-darker); background-color: var(--pngx-bg-darker);
@@ -409,7 +409,7 @@ describe('GlobalSearchComponent', () => {
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
// fail first // fail first
editDialog.failed.emit({ error: 'error creating item' }) editDialog.failed.emit({ error: 'error creating item' })
@@ -460,7 +460,7 @@ describe('GlobalSearchComponent', () => {
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
// fail first // fail first
editDialog.failed.emit({ error: 'error creating item' }) editDialog.failed.emit({ error: 'error creating item' })
@@ -216,7 +216,7 @@ export class GlobalSearchComponent implements OnInit {
modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT) modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT)
modalRef.componentInstance.object = object modalRef.componentInstance.object = object
modalRef.componentInstance.succeeded.subscribe(() => { modalRef.componentInstance.succeeded.subscribe(() => {
this.toastService.showInfo($localize`Successfully updated object.`) this.toastService.showSuccess($localize`Successfully updated object.`)
}) })
modalRef.componentInstance.failed.subscribe((e) => { modalRef.componentInstance.failed.subscribe((e) => {
this.toastService.showError($localize`Error occurred saving object.`, e) this.toastService.showError($localize`Error occurred saving object.`, e)
@@ -254,7 +254,7 @@ export class GlobalSearchComponent implements OnInit {
modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT) modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT)
modalRef.componentInstance.object = object modalRef.componentInstance.object = object
modalRef.componentInstance.succeeded.subscribe(() => { modalRef.componentInstance.succeeded.subscribe(() => {
this.toastService.showInfo($localize`Successfully updated object.`) this.toastService.showSuccess($localize`Successfully updated object.`)
}) })
modalRef.componentInstance.failed.subscribe((e) => { modalRef.componentInstance.failed.subscribe((e) => {
this.toastService.showError($localize`Error occurred saving object.`, e) this.toastService.showError($localize`Error occurred saving object.`, e)
@@ -1,28 +1,31 @@
<li ngbDropdown class="nav-item position-relative" (openChange)="onOpenChange($event)"> <li ngbDropdown class="nav-item position-relative" (openChange)="onOpenChange($event)">
@if (toasts().length) { @if (unread().length) {
<span class="notification-count badge rounded-pill z-3 pe-none bg-secondary position-absolute">{{ toasts().length }}</span> <span class="notification-count badge rounded-pill z-3 pe-none position-absolute" [class.bg-danger]="hasUnreadError()" [class.bg-secondary]="!hasUnreadError()">{{ unread().length }}</span>
} }
<button class="btn navbar-action border-0" id="notificationsDropdown" ngbDropdownToggle aria-label="Notifications" i18n-aria-label> <button class="btn navbar-action border-0" id="notificationsDropdown" ngbDropdownToggle aria-label="Notifications" i18n-aria-label>
<i-bs width="1.3em" height="1.3em" name="bell"></i-bs> <i-bs width="1.3em" height="1.3em" name="bell"></i-bs>
</button> </button>
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="notificationsDropdown"> <div ngbDropdownMenu class="dropdown-menu-end shadow p-2" aria-labelledby="notificationsDropdown">
<div class="btn-toolbar align-items-center" role="toolbar"> <div class="d-flex align-items-center px-1 mb-2">
<h6 i18n>Notifications</h6> <h6 class="mb-0" i18n>Notifications</h6>
<div class="btn-group ms-auto"> @if (toasts().length) {
<button class="btn btn-sm btn-outline-secondary mb-2 ms-auto" <button class="btn btn-link btn-sm p-0 ms-auto text-body-secondary text-decoration-none clear-all"
(click)="toastService.clearToasts()" (click)="toastService.clearToasts()">
[disabled]="toasts().length === 0" <i-bs width="1em" height="1em" name="check2-all" class="me-1"></i-bs><ng-container i18n>Clear All</ng-container>
i18n>Clear All</button> </button>
</div> }
</div> </div>
@if (toasts().length === 0) { @if (toasts().length === 0) {
<p class="text-center mb-0 small text-muted"><em i18n>No notifications</em></p> <div class="text-center text-body-secondary small py-4">
} <i-bs width="1.5em" height="1.5em" name="bell" class="d-block mx-auto mb-2 opacity-50"></i-bs>
<div class="scroll-list"> <ng-container i18n>No notifications</ng-container>
@for (toast of toasts(); track toast.id) {
<pngx-toast [autohide]="false" [toast]="toast" (hidden)="onHidden(toast)" (closed)="toastService.closeToast(toast)"></pngx-toast>
}
</div> </div>
}
<div class="scroll-list d-flex flex-column gap-2">
@for (toast of toasts(); track toast.id) {
<pngx-toast [autohide]="false" [toast]="toast" [now]="now()" [isNew]="newIds().has(toast.id)" (closed)="toastService.closeToast(toast)" (actioned)="onActioned(toast)"></pngx-toast>
}
</div>
</div> </div>
</li> </li>
@@ -30,3 +30,7 @@
right: -3rem; right: -3rem;
} }
} }
.clear-all:hover {
color: var(--bs-body-color) !important;
}
@@ -3,7 +3,7 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { Subject } from 'rxjs' import { Subject } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service' import { Toast, ToastLevel, ToastService } from 'src/app/services/toast.service'
import { ToastsDropdownComponent } from './toasts-dropdown.component' import { ToastsDropdownComponent } from './toasts-dropdown.component'
const toasts = [ const toasts = [
@@ -90,4 +90,73 @@ describe('ToastsDropdownComponent', () => {
component.onOpenChange(true) component.onOpenChange(true)
expect(spy).toHaveBeenCalledWith(true) expect(spy).toHaveBeenCalledWith(true)
}) })
it('should badge unread toasts, red when one is an error', () => {
toastsSubject.next([
{ id: 'a', content: 'Read', delay: 5000, read: true },
{ id: 'b', content: 'Unread', delay: 5000, read: false },
])
fixture.detectChanges()
let badge: HTMLElement = fixture.nativeElement.querySelector(
'.notification-count'
)
expect(badge.textContent.trim()).toEqual('1')
expect(badge.classList).toContain('bg-secondary')
toastsSubject.next([
{ id: 'c', content: 'Failed', delay: 5000, level: ToastLevel.Error },
])
fixture.detectChanges()
badge = fixture.nativeElement.querySelector('.notification-count')
expect(badge.classList).toContain('bg-danger')
toastsSubject.next([{ id: 'd', content: 'Seen', delay: 5000, read: true }])
fixture.detectChanges()
expect(
fixture.nativeElement.querySelector('.notification-count')
).toBeNull()
})
it('should mark all toasts read when opened', () => {
const markSpy = jest.spyOn(toastService, 'markAllRead')
component.onOpenChange(false)
expect(markSpy).not.toHaveBeenCalled()
component.onOpenChange(true)
expect(markSpy).toHaveBeenCalled()
})
it('should tick the clock only while open', () => {
jest.useFakeTimers()
component.onOpenChange(true)
const opened = component.now()
jest.advanceTimersByTime(30000)
const ticked = component.now()
expect(ticked).not.toBe(opened)
component.onOpenChange(false)
jest.advanceTimersByTime(60000)
expect(component.now()).toBe(ticked)
jest.useRealTimers()
})
it('should mark toasts that were unread when opened as new', () => {
toastsSubject.next([
{ id: 'a', content: 'Seen', delay: 5000, read: true },
{ id: 'b', content: 'Unseen', delay: 5000, read: false },
])
fixture.detectChanges()
component.onOpenChange(true)
expect([...component.newIds()]).toEqual(['b'])
component.onOpenChange(false)
expect(component.newIds().size).toEqual(0)
})
it('should clear the toast and close the menu when an action is used', () => {
const toast = { id: 'a', content: 'Moved', delay: 5000 }
const closeToastSpy = jest.spyOn(toastService, 'closeToast')
const closeSpy = jest.spyOn(component.dropdown, 'close')
component.onActioned(toast)
expect(closeToastSpy).toHaveBeenCalledWith(toast)
expect(closeSpy).toHaveBeenCalled()
})
}) })
@@ -1,12 +1,20 @@
import { Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { import {
Component,
DestroyRef,
ViewChild,
computed,
inject,
signal,
} from '@angular/core'
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'
import {
NgbDropdown,
NgbDropdownModule, NgbDropdownModule,
NgbProgressbarModule, NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { map } from 'rxjs' import { Subscription, interval, map } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service' import { Toast, ToastLevel, ToastService } from 'src/app/services/toast.service'
import { ToastComponent } from '../../common/toast/toast.component' import { ToastComponent } from '../../common/toast/toast.component'
@Component({ @Component({
@@ -22,13 +30,44 @@ import { ToastComponent } from '../../common/toast/toast.component'
}) })
export class ToastsDropdownComponent { export class ToastsDropdownComponent {
toastService = inject(ToastService) toastService = inject(ToastService)
private destroyRef = inject(DestroyRef)
private clock: Subscription
@ViewChild(NgbDropdown) dropdown: NgbDropdown
/** Drives the relative timestamps, ticks only while the dropdown is open */
readonly now = signal(new Date())
/** Notifications that were unread when the dropdown was opened */
readonly newIds = signal(new Set<string>())
readonly toasts = toSignal( readonly toasts = toSignal(
this.toastService.getToasts().pipe(map((toasts) => [...toasts])), this.toastService.getToasts().pipe(map((toasts) => [...toasts])),
{ initialValue: [] as Toast[] } { initialValue: [] as Toast[] }
) )
readonly unread = computed(() => this.toasts().filter((t) => !t.read))
readonly hasUnreadError = computed(() =>
this.unread().some((t) => t.level === ToastLevel.Error)
)
onOpenChange(open: boolean): void { onOpenChange(open: boolean): void {
this.toastService.suppressPopupToasts = open this.toastService.suppressPopupToasts = open
this.clock?.unsubscribe()
this.newIds.set(new Set(open ? this.unread().map((t) => t.id) : []))
if (open) {
this.toastService.markAllRead()
this.now.set(new Date())
this.clock = interval(30000)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.now.set(new Date()))
}
}
/** Using an action (Undo, Open document...) clears it and closes the menu */
onActioned(toast: Toast): void {
this.toastService.closeToast(toast)
this.dropdown?.close()
} }
} }
@@ -1,49 +1,85 @@
<li ngbDropdown class="nav-item" (openChange)="onOpenChange($event)"> <li ngbDropdown class="nav-item" display="dynamic" placement="bottom-end" [popperOptions]="popperOptions" (openChange)="onOpenChange($event)">
<button class="btn navbar-action border-0" id="chatDropdown" ngbDropdownToggle aria-label="Chat" i18n-aria-label> <button class="btn navbar-action border-0" id="chatDropdown" ngbDropdownToggle aria-label="Chat" i18n-aria-label>
<i-bs width="1.3em" height="1.3em" name="chatSquareDots"></i-bs> <i-bs width="1.3em" height="1.3em" name="chatSquareDots"></i-bs>
</button> </button>
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown"> <div ngbDropdownMenu class="dropdown-menu-end shadow p-0 overflow-hidden" aria-labelledby="chatDropdown">
<div class="chat-container bg-light p-2"> <div class="d-flex align-items-center border-bottom px-3 py-2">
<div class="chat-messages font-monospace small"> <i-bs width="1em" height="1em" name="stars" class="text-primary me-2"></i-bs>
@for (message of messages(); track message) { <span class="fw-semibold">
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'"> @if (documentId()) {
<div class="p-2 m-2" [class.bg-body]="message.role === 'user'"> <ng-container i18n>Ask about this document</ng-container>
<span class="text-break"> } @else {
{{ message.content }} <ng-container i18n>Ask about your documents</ng-container>
@if (message.isStreaming) { <span class="blinking-cursor">|</span> }
</span>
@if (message.role === 'assistant' && message.references?.length) {
<div class="chat-references list-group mt-3">
@for (reference of message.references; track reference.id) {
<a class="list-group-item list-group-item-action d-flex text-primary text-break" [routerLink]="['/documents', reference.id]">
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-1"></i-bs><span>{{ reference.title }}</span>
</a>
}
</div>
}
</div>
</div>
} }
<div #scrollAnchor></div> </span>
</div> @if (messages().length) {
<button class="btn btn-sm btn-link text-muted ms-auto p-0" type="button" (click)="clearChat()" title="Clear chat" i18n-title>
<i-bs width="1em" height="1em" name="arrow-counterclockwise"></i-bs>
</button>
}
</div>
<form class="chat-input"> <div class="chat-messages px-2">
<label class="visually-hidden" for="chatInput" i18n>Message</label> @if (!messages().length) {
<div class="input-group"> <p class="text-muted text-center py-4 px-3 mb-0" i18n>Answers come from your documents, with links to their sources.</p>
<input }
#chatInput @for (message of messages(); track message) {
id="chatInput" <div class="message d-flex flex-row" [class.justify-content-end]="message.role === 'user'" [class.mt-3]="message.role === 'user' && !$first">
class="form-control form-control-sm" name="chatInput" type="text" <div class="p-2 m-2" [class.bg-body-secondary]="message.role === 'user'" [class.rounded-3]="message.role === 'user'" [class.flex-grow-1]="message.role === 'assistant'">
@if (message.isStreaming && !message.content) {
<div class="d-flex align-items-center text-muted">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
@if (slowResponse()) {
<ng-container i18n>Still thinking...</ng-container>
} @else {
<ng-container i18n>Thinking...</ng-container>
}
</div>
} @else if (message.role === 'assistant') {
<div class="chat-markdown text-break text-wrap" [class.streaming]="message.isStreaming" [innerHTML]="message.content | markdown"></div>
} @else {
<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">
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-2 flex-shrink-0"></i-bs><span class="text-truncate">{{ reference.title }}</span>
</a>
}
</div>
}
</div>
</div>
}
<div #scrollAnchor></div>
</div>
<form class="chat-input border-top p-2">
<label class="visually-hidden" for="chatInput" i18n>Message</label>
<div class="input-group">
<input
#chatInput
id="chatInput"
class="form-control" name="chatInput" type="text"
[placeholder]="placeholder" [placeholder]="placeholder"
[disabled]="loading()" [disabled]="loading()"
[ngModel]="input()" [ngModel]="input()"
(ngModelChange)="input.set($event)" (ngModelChange)="input.set($event)"
(keydown)="searchInputKeyDown($event)" (keydown)="searchInputKeyDown($event)"
/> />
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading()">Send</button> @if (loading()) {
</div> <button class="btn btn-outline-secondary" type="button" (click)="stopStreaming()" title="Stop" i18n-title aria-label="Stop" i18n-aria-label>
</form> <i-bs width="1em" height="1em" name="x-lg"></i-bs>
</div> </button>
} @else {
<button class="btn btn-primary" type="button" (click)="sendMessage()" title="Send" i18n-title aria-label="Send" i18n-aria-label>
<i-bs width="1em" height="1em" name="arrow-return-left"></i-bs>
</button>
}
</div>
</form>
</div> </div>
</li> </li>
@@ -1,17 +1,13 @@
.dropdown-menu { .dropdown-menu {
width: var(--pngx-toast-max-width); width: min(36rem, calc(100vw - 20px));
} }
.chat-messages { .chat-messages {
max-height: 350px; max-height: min(60dvh, 36rem);
overflow-y: auto; overflow-y: auto;
white-space: pre-wrap; white-space: pre-wrap;
} }
.chat-references {
font-family: var(--bs-font-sans-serif);
}
.dropdown-toggle::after { .dropdown-toggle::after {
display: none; display: none;
} }
@@ -20,23 +16,44 @@
white-space: initial; white-space: initial;
} }
@media screen and (max-width: 400px) { @media screen and (max-width: 575.98px) {
:host ::ng-deep .dropdown-menu-end { .chat-messages {
right: -3rem; max-height: calc(100dvh - 12rem);
} }
} }
.blinking-cursor { .chat-markdown.streaming ::ng-deep {
font-weight: bold; > :last-child:not(ul, ol, table)::after,
font-size: 1.2em; > :is(ul, ol):last-child > li:last-child::after {
animation: blink 1s step-end infinite; content: '';
display: inline-block;
width: 2px;
height: 1.05em;
margin-left: 2px;
background-color: currentColor;
vertical-align: -0.15em;
animation: blink 1s step-end infinite;
}
} }
@keyframes blink { @keyframes blink {
from, to { 50% {
opacity: 0; opacity: 0;
} }
50% { }
opacity: 1;
.chat-markdown ::ng-deep {
h1, h2, h3, h4, h5, h6 {
font-size: 1em;
font-weight: bold;
}
th, td {
padding: 0.15rem 0.4rem;
border: 1px solid var(--bs-border-color);
}
> :last-child {
margin-bottom: 0;
} }
} }
@@ -80,8 +80,16 @@ describe('ChatComponent', () => {
expect(component.messages()[0].content).toBe('Hello') expect(component.messages()[0].content).toBe('Hello')
expect(component.loading()).toBe(true) expect(component.loading()).toBe(true)
fixture.detectChanges()
const messagesEl = fixture.nativeElement.querySelector('.chat-messages')
expect(messagesEl.querySelector('.spinner-border')).not.toBeNull()
expect(messagesEl.textContent).toContain('Thinking...')
mockStream$.next('Hi') mockStream$.next('Hi')
expect(component.messages()[1].content).toBe('H') expect(component.messages()[1].content).toBe('H')
fixture.detectChanges()
expect(messagesEl.querySelector('.spinner-border')).toBeNull()
expect(messagesEl.querySelector('.chat-markdown.streaming')).not.toBeNull()
mockStream$.next('Hi there') mockStream$.next('Hi there')
// advance time to process the typewriter effect // advance time to process the typewriter effect
await jest.runAllTimersAsync() await jest.runAllTimersAsync()
@@ -96,6 +104,8 @@ describe('ChatComponent', () => {
await fixture.whenStable() await fixture.whenStable()
expect(component.loading()).toBe(false) expect(component.loading()).toBe(false)
expect(component.messages()[1].isStreaming).toBe(false) expect(component.messages()[1].isStreaming).toBe(false)
fixture.detectChanges()
expect(messagesEl.querySelector('.chat-markdown.streaming')).toBeNull()
expect(fixture.nativeElement.querySelector('#chatInput').disabled).toBe( expect(fixture.nativeElement.querySelector('#chatInput').disabled).toBe(
false false
) )
@@ -123,6 +133,10 @@ describe('ChatComponent', () => {
mockStream$.next( mockStream$.next(
`Hi there${CHAT_METADATA_DELIMITER}{"references":[{"id":42,"title":"Bread Recipe"}]}` `Hi there${CHAT_METADATA_DELIMITER}{"references":[{"id":42,"title":"Bread Recipe"}]}`
) )
fixture.detectChanges()
expect(fixture.nativeElement.querySelector('.chat-references')).toBeNull()
mockStream$.complete()
jest.advanceTimersByTime(1000) jest.advanceTimersByTime(1000)
fixture.detectChanges() fixture.detectChanges()
@@ -156,13 +170,96 @@ describe('ChatComponent', () => {
component.input.set('Hello') component.input.set('Hello')
component.sendMessage() component.sendMessage()
mockStream$.next('Partial')
mockStream$.error('Error') mockStream$.error('Error')
expect(component.messages()[1].content).toContain( expect(component.messages()[1].content).toBe(
'⚠️ Error receiving response.' 'Partial\n\n⚠️ Error receiving response.'
) )
expect(component.loading()).toBe(false) expect(component.loading()).toBe(false)
}) })
it('should keep streaming until the typewriter catches up', () => {
component.input.set('Hello')
component.sendMessage()
mockStream$.next('Hi there')
mockStream$.complete()
expect(component.messages()[1].isStreaming).toBe(true)
expect(component.loading()).toBe(true)
jest.advanceTimersByTime(1000)
expect(component.messages()[1].content).toBe('Hi there')
expect(component.messages()[1].isStreaming).toBe(false)
expect(component.loading()).toBe(false)
})
it('should show a slow response message while waiting', () => {
component.input.set('Hello')
component.sendMessage()
fixture.detectChanges()
const messagesEl = fixture.nativeElement.querySelector('.chat-messages')
expect(messagesEl.textContent).not.toContain('Still thinking...')
jest.advanceTimersByTime(8000)
fixture.detectChanges()
expect(messagesEl.textContent).toContain('Still thinking...')
mockStream$.next('Hi')
expect(component.slowResponse()).toBe(false)
})
it('should stop streaming and keep the partial response', () => {
component.input.set('Hello')
component.sendMessage()
fixture.detectChanges()
const stopButton = fixture.nativeElement.querySelector('.chat-input button')
expect(stopButton.getAttribute('aria-label')).toBe('Stop')
mockStream$.next('Hi there')
jest.advanceTimersByTime(20)
stopButton.click()
expect(mockStream$.observed).toBe(false)
expect(component.messages()[1].content).toBe('Hi there')
expect(component.messages()[1].isStreaming).toBe(false)
expect(component.loading()).toBe(false)
jest.advanceTimersByTime(1000)
expect(component.messages()[1].content).toBe('Hi there')
})
it('should drop the empty response when stopped before any content', () => {
component.input.set('Hello')
component.sendMessage()
component.stopStreaming()
expect(component.messages()).toHaveLength(1)
expect(component.loading()).toBe(false)
component.stopStreaming() // no-op once stopped
expect(component.messages()).toHaveLength(1)
})
it('should clear the conversation', () => {
component.input.set('Hello')
component.sendMessage()
mockStream$.next('Hi there')
fixture.detectChanges()
fixture.nativeElement.querySelector('button[title="Clear chat"]').click()
expect(component.messages()).toHaveLength(0)
expect(component.loading()).toBe(false)
expect(mockStream$.observed).toBe(false)
fixture.detectChanges()
jest.advanceTimersByTime(10)
expect(document.activeElement).toBe(
fixture.nativeElement.querySelector('#chatInput')
)
expect(fixture.nativeElement.textContent).toContain(
'Answers come from your documents'
)
})
it('should enqueue typewriter chunks correctly', () => { it('should enqueue typewriter chunks correctly', () => {
const message = { content: '', role: 'assistant', isStreaming: true } const message = { content: '', role: 'assistant', isStreaming: true }
component.enqueueTypewriter(null, message as any) // coverage for null component.enqueueTypewriter(null, message as any) // coverage for null
@@ -207,4 +304,33 @@ describe('ChatComponent', () => {
component.searchInputKeyDown(event) component.searchInputKeyDown(event)
expect(component.sendMessage).not.toHaveBeenCalled() expect(component.sendMessage).not.toHaveBeenCalled()
}) })
it('should render markdown for assistant messages only', () => {
component.messages.set([
{ role: 'user', content: '**user**' },
{ role: 'assistant', content: '**assistant** <script>x</script>' },
])
fixture.detectChanges()
const el: HTMLElement = fixture.nativeElement
expect(el.querySelector('.chat-markdown strong')?.textContent).toBe(
'assistant'
)
expect(el.querySelector('.chat-markdown script')).toBeNull()
expect(el.textContent).toContain('**user**')
})
it('should keep the destination hostname visible after sanitizing chat links', () => {
component.messages.set([
{
role: 'assistant',
content:
'[View original document](https://otherdomain.example/?data=secret)',
},
])
fixture.detectChanges()
const el: HTMLElement = fixture.nativeElement
const link = el.querySelector('.chat-markdown a')
expect(link?.textContent).toBe('View original document')
expect(link?.nextElementSibling?.textContent).toBe('(otherdomain.example)')
})
}) })
@@ -10,12 +10,16 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NavigationEnd, Router, RouterModule } from '@angular/router' import { NavigationEnd, Router, RouterModule } from '@angular/router'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { filter, map } from 'rxjs' import { filter, map, Subscription } from 'rxjs'
import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'
import { import {
ChatMessage, ChatMessage,
ChatService, ChatService,
parseChatResponse, parseChatResponse,
} from 'src/app/services/chat.service' } from 'src/app/services/chat.service'
import { pngxPopperOptions } from 'src/app/utils/popper-options'
const SLOW_RESPONSE_DELAY_MS = 8000
@Component({ @Component({
selector: 'pngx-chat', selector: 'pngx-chat',
@@ -25,6 +29,7 @@ import {
RouterModule, RouterModule,
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
NgbDropdownModule, NgbDropdownModule,
MarkdownPipe,
], ],
templateUrl: './chat.component.html', templateUrl: './chat.component.html',
styleUrl: './chat.component.scss', styleUrl: './chat.component.scss',
@@ -34,6 +39,8 @@ export class ChatComponent implements OnInit {
readonly loading = signal(false) readonly loading = signal(false)
readonly input = signal('') readonly input = signal('')
readonly documentId = signal<number>(undefined) readonly documentId = signal<number>(undefined)
readonly slowResponse = signal(false)
public popperOptions = pngxPopperOptions
private chatService: ChatService = inject(ChatService) private chatService: ChatService = inject(ChatService)
private router: Router = inject(Router) private router: Router = inject(Router)
@@ -43,6 +50,11 @@ export class ChatComponent implements OnInit {
private typewriterBuffer: string[] = [] private typewriterBuffer: string[] = []
private typewriterActive = false private typewriterActive = false
private typewriterTimeout: ReturnType<typeof setTimeout>
private streamSubscription: Subscription
private streamingMessage: ChatMessage
private streamFinished = false
private slowResponseTimeout: ReturnType<typeof setTimeout>
public get placeholder(): string { public get placeholder(): string {
return this.documentId() return this.documentId()
@@ -81,47 +93,96 @@ export class ChatComponent implements OnInit {
} }
this.messages.update((messages) => [...messages, assistantMessage]) this.messages.update((messages) => [...messages, assistantMessage])
this.loading.set(true) this.loading.set(true)
this.streamingMessage = assistantMessage
this.streamFinished = false
this.slowResponseTimeout = setTimeout(
() => this.slowResponse.set(true),
SLOW_RESPONSE_DELAY_MS
)
let lastVisibleContent = '' let lastVisibleContent = ''
this.chatService.streamChat(this.documentId(), this.input()).subscribe({ this.streamSubscription = this.chatService
next: (chunk) => { .streamChat(this.documentId(), this.input())
const nextResponse = parseChatResponse(chunk) .subscribe({
next: (chunk) => {
this.clearSlowResponse()
const nextResponse = parseChatResponse(chunk)
if (nextResponse.content.length < lastVisibleContent.length) { if (nextResponse.content.length < lastVisibleContent.length) {
this.resetTypewriter(assistantMessage, nextResponse.content) this.resetTypewriter(assistantMessage, nextResponse.content)
lastVisibleContent = nextResponse.content lastVisibleContent = nextResponse.content
} else { } else {
const visibleDelta = nextResponse.content.substring( const visibleDelta = nextResponse.content.substring(
lastVisibleContent.length lastVisibleContent.length
) )
lastVisibleContent = nextResponse.content lastVisibleContent = nextResponse.content
this.enqueueTypewriter(visibleDelta, assistantMessage) this.enqueueTypewriter(visibleDelta, assistantMessage)
} }
assistantMessage.references = nextResponse.references assistantMessage.references = nextResponse.references
this.notifyMessagesChanged() this.notifyMessagesChanged()
}, },
error: () => { error: () => {
assistantMessage.content += '\n\n⚠️ Error receiving response.' this.flushTypewriter(assistantMessage)
assistantMessage.isStreaming = false assistantMessage.content += '\n\n⚠️ Error receiving response.'
this.notifyMessagesChanged() this.finishStreaming(assistantMessage)
this.loading.set(false) },
}, complete: () => {
complete: () => { // let the typewriter catch up before hiding the cursor
assistantMessage.isStreaming = false this.streamFinished = true
this.notifyMessagesChanged() if (!this.typewriterActive) this.finishStreaming(assistantMessage)
this.loading.set(false) },
this.scrollToBottom() })
},
})
this.input.set('') this.input.set('')
} }
private resetTypewriter(message: ChatMessage, content: string): void { stopStreaming(): void {
const message = this.streamingMessage
if (!message?.isStreaming) return
this.streamSubscription?.unsubscribe()
this.flushTypewriter(message)
if (!message.content) {
this.messages.update((messages) => messages.filter((m) => m !== message))
}
this.finishStreaming(message)
}
clearChat(): void {
this.stopStreaming()
this.messages.set([])
setTimeout(() => this.chatInput?.nativeElement.focus())
}
private finishStreaming(message: ChatMessage): void {
this.clearSlowResponse()
this.streamFinished = false
message.isStreaming = false
this.notifyMessagesChanged()
this.loading.set(false)
this.scrollToBottom()
}
private clearSlowResponse(): void {
clearTimeout(this.slowResponseTimeout)
this.slowResponse.set(false)
}
private flushTypewriter(message: ChatMessage): void {
message.content += this.typewriterBuffer.join('')
this.cancelTypewriter()
}
private cancelTypewriter(): void {
clearTimeout(this.typewriterTimeout)
this.typewriterBuffer = [] this.typewriterBuffer = []
this.typewriterActive = false this.typewriterActive = false
}
private resetTypewriter(message: ChatMessage, content: string): void {
this.cancelTypewriter()
message.content = content message.content = content
this.notifyMessagesChanged() this.notifyMessagesChanged()
this.scrollToBottom() this.scrollToBottom()
@@ -141,6 +202,7 @@ export class ChatComponent implements OnInit {
playTypewriter(message: ChatMessage): void { playTypewriter(message: ChatMessage): void {
if (this.typewriterBuffer.length === 0) { if (this.typewriterBuffer.length === 0) {
this.typewriterActive = false this.typewriterActive = false
if (this.streamFinished) this.finishStreaming(message)
return return
} }
@@ -149,7 +211,8 @@ export class ChatComponent implements OnInit {
this.notifyMessagesChanged() this.notifyMessagesChanged()
this.scrollToBottom() this.scrollToBottom()
setTimeout(() => this.playTypewriter(message), 10) // 10ms per character // 10ms per character
this.typewriterTimeout = setTimeout(() => this.playTypewriter(message), 10)
} }
private notifyMessagesChanged(): void { private notifyMessagesChanged(): void {
@@ -110,7 +110,7 @@ describe('CustomFieldsDropdownComponent', () => {
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1])) modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const getFieldsSpy = jest.spyOn( const getFieldsSpy = jest.spyOn(
CustomFieldsDropdownComponent.prototype as any, CustomFieldsDropdownComponent.prototype as any,
'getFields' 'getFields'
@@ -129,7 +129,9 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
modal.componentInstance.succeeded modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newField) => { .subscribe((newField) => {
this.toastService.showInfo($localize`Saved field "${newField.name}".`) this.toastService.showSuccess(
$localize`Saved field "${newField.name}".`
)
this.customFieldsService.clearCache() this.customFieldsService.clearCache()
this.getFields() this.getFields()
this.created.emit(newField) this.created.emit(newField)
@@ -15,7 +15,7 @@
</a> </a>
} }
</div> </div>
<div class="input-group input-group-sm small ps-1 pe-2"> <div class="input-group input-group-sm ps-1 pe-2">
<ng-select class="w-100" name="createdRelativeDate" <ng-select class="w-100" name="createdRelativeDate"
[items]="relativeDates" [(ngModel)]="createdRelativeDate" [items]="relativeDates" [(ngModel)]="createdRelativeDate"
bindValue="id" bindValue="id"
@@ -92,7 +92,7 @@
</a> </a>
} }
</div> </div>
<div class="input-group input-group-sm small ps-1 pe-2"> <div class="input-group input-group-sm ps-1 pe-2">
<ng-select class="w-100" name="addedRelativeDate" <ng-select class="w-100" name="addedRelativeDate"
[items]="relativeDates" [(ngModel)]="addedRelativeDate" [items]="relativeDates" [(ngModel)]="addedRelativeDate"
bindValue="id" bindValue="id"
@@ -151,7 +151,7 @@ describe('UserEditDialogComponent', () => {
'deactivateTotp' 'deactivateTotp'
) )
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
deactivateSpy.mockReturnValueOnce(throwError(() => new Error('error'))) deactivateSpy.mockReturnValueOnce(throwError(() => new Error('error')))
component.deactivateTotp() component.deactivateTotp()
expect(deactivateSpy).toHaveBeenCalled() expect(deactivateSpy).toHaveBeenCalled()
@@ -132,7 +132,7 @@ export class UserEditDialogComponent
next: (result) => { next: (result) => {
this.totpLoading.set(false) this.totpLoading.set(false)
if (result) { if (result) {
this.toastService.showInfo($localize`Totp deactivated`) this.toastService.showSuccess($localize`Totp deactivated`)
this.object.is_mfa_enabled = false this.object.is_mfa_enabled = false
} else { } else {
this.toastService.showError($localize`Totp deactivation failed`) this.toastService.showError($localize`Totp deactivation failed`)
@@ -209,6 +209,7 @@
@if (isTagsFilter(filter.get('type').value)) { @if (isTagsFilter(filter.get('type').value)) {
<pngx-input-tags <pngx-input-tags
[allowCreate]="false" [allowCreate]="false"
[preserveHierarchy]="false"
[title]="null" [title]="null"
formControlName="values" formControlName="values"
></pngx-input-tags> ></pngx-input-tags>
@@ -320,7 +321,7 @@
<h6 class="form-label" i18n>Remove tags</h6> <h6 class="form-label" i18n>Remove tags</h6>
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_tags"></pngx-input-switch> <pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_tags"></pngx-input-switch>
<div class="mt-n3"> <div class="mt-n3">
<pngx-input-tags [allowCreate]="false" title="" formControlName="remove_tags"></pngx-input-tags> <pngx-input-tags [allowCreate]="false" [preserveHierarchy]="false" title="" formControlName="remove_tags"></pngx-input-tags>
</div> </div>
<h6 class="form-label" i18n>Remove correspondents</h6> <h6 class="form-label" i18n>Remove correspondents</h6>
@@ -52,7 +52,7 @@ describe('EmailDocumentDialogComponent', () => {
it('should support sending single document via email, showing error if needed', () => { it('should support sending single document via email, showing error if needed', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo') const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
component.documentIds.set([1]) component.documentIds.set([1])
component.emailAddress = 'hello@paperless-ngx.com' component.emailAddress = 'hello@paperless-ngx.com'
component.emailSubject = 'Hello' component.emailSubject = 'Hello'
@@ -73,7 +73,7 @@ describe('EmailDocumentDialogComponent', () => {
it('should support sending multiple documents via email, showing appropriate messages', () => { it('should support sending multiple documents via email, showing appropriate messages', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo') const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
component.documentIds.set([1, 2, 3]) component.documentIds.set([1, 2, 3])
component.emailAddress = 'hello@paperless-ngx.com' component.emailAddress = 'hello@paperless-ngx.com'
component.emailSubject = 'Hello' component.emailSubject = 'Hello'
@@ -50,7 +50,7 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
this.emailSubject = '' this.emailSubject = ''
this.emailMessage = '' this.emailMessage = ''
this.close() this.close()
this.toastService.showInfo($localize`Email sent`) this.toastService.showSuccess($localize`Email sent`)
}, },
error: (e) => { error: (e) => {
this.loading.set(false) this.loading.set(false)
@@ -51,20 +51,20 @@
@if (editing) { @if (editing) {
@if (filteredItems.length === 0 && createRef !== undefined && filterText?.length > 0) { @if (filteredItems.length === 0 && createRef !== undefined && filterText?.length > 0) {
<button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled"> <button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled">
<small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small> <span class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</span>
<i-bs width="1.5em" height="1em" name="plus"></i-bs> <i-bs width="1.5em" height="1em" name="plus"></i-bs>
</button> </button>
} }
@if (filteredItems.length > 0) { @if (filteredItems.length > 0) {
<button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="applyClicked()" [disabled]="!modelIsDirty() || disabled"> <button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="applyClicked()" [disabled]="!modelIsDirty() || disabled">
<small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty()}" i18n>Apply</small> <span class="ms-2" [ngClass]="{'fw-bold': modelIsDirty()}" i18n>Apply</span>
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs> <i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
</button> </button>
} }
} }
@if (extraButtonTitle && (showExtraButtonIfEmpty || filteredItems?.length > 0)) { @if (extraButtonTitle && (showExtraButtonIfEmpty || filteredItems?.length > 0)) {
<button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="extraButtonClicked($event)" [disabled]="disabled"> <button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="extraButtonClicked($event)" [disabled]="disabled">
<small class="ms-2 fw-bold">{{extraButtonTitle}}</small> <span class="ms-2 fw-bold">{{extraButtonTitle}}</span>
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs> <i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
</button> </button>
} }
@@ -29,7 +29,7 @@
line-height: 1; line-height: 1;
small { small {
font-size: 65%; font-size: 0.65rem;
} }
} }
@@ -23,7 +23,7 @@
@if (isTag) { @if (isTag) {
<pngx-tag [tag]="item" [clickable]="false"></pngx-tag> <pngx-tag [tag]="item" [clickable]="false"></pngx-tag>
} @else { } @else {
<small>{{item.name}}</small> {{item.name}}
} }
</div> </div>
</div> </div>
@@ -23,4 +23,9 @@
.badge { .badge {
flex-shrink: 0; flex-shrink: 0;
font-size: 0.75rem;
}
pngx-tag {
font-size: 1rem;
} }
@@ -1,5 +1,7 @@
@if (!accordion) { @if (!accordion) {
<h5 i18n>Permissions</h5> @if (title?.length > 0) {
<h5>{{title}}</h5>
}
<ng-container [ngTemplateOutlet]="permissionsForm"></ng-container> <ng-container [ngTemplateOutlet]="permissionsForm"></ng-container>
} }
@if (accordion) { @if (accordion) {
@@ -59,6 +59,9 @@ export class PermissionsFormComponent
@Input() @Input()
accordion: boolean = false accordion: boolean = false
@Input()
title: string = $localize`Permissions`
form = new FormGroup({ form = new FormGroup({
owner: new FormControl(null), owner: new FormControl(null),
set_permissions: new FormGroup({ set_permissions: new FormGroup({
@@ -222,6 +222,22 @@ describe('TagsComponent', () => {
expect(propagated).toEqual([2, 1]) expect(propagated).toEqual([2, 1])
}) })
it('should not add parents or remove children when not preserving hierarchy', () => {
const parent: Tag = { id: 1, name: 'parent' }
const child: Tag = { id: 2, name: 'child', parent: 1 }
parent.children = [child]
component.tags = [parent, child]
component.preserveHierarchy = false
component.value = []
component.addTag(2)
expect(component.value).toEqual([2])
component.value = [1, 2]
component.removeTag(1)
expect(component.value).toEqual([2])
})
it('should not duplicate parents when adding sibling nested tags', () => { it('should not duplicate parents when adding sibling nested tags', () => {
const root: Tag = { id: 1, name: 'root' } const root: Tag = { id: 1, name: 'root' }
const parent: Tag = { id: 2, name: 'parent', parent: 1 } const parent: Tag = { id: 2, name: 'parent', parent: 1 }
@@ -109,6 +109,9 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
@Input() @Input()
multiple: boolean = true multiple: boolean = true
@Input()
preserveHierarchy: boolean = true
@Output() @Output()
filterDocuments = new EventEmitter<Tag[]>() filterDocuments = new EventEmitter<Tag[]>()
@@ -148,7 +151,9 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
oldValue.splice(index, 1) oldValue.splice(index, 1)
// remove children // remove children
oldValue = this.removeChildren(oldValue, tag) if (this.preserveHierarchy) {
oldValue = this.removeChildren(oldValue, tag)
}
this.value = [...oldValue] this.value = [...oldValue]
this.onChange(this.value) this.onChange(this.value)
@@ -167,7 +172,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
} }
public onAdd(tag: Tag) { public onAdd(tag: Tag) {
if (tag?.parent) { if (this.preserveHierarchy && tag?.parent) {
// add all parents recursively // add all parents recursively
const parent = this.getTag(tag.parent) const parent = this.getTag(tag.parent)
if (parent && !this.value.includes(parent.id)) { if (parent && !this.value.includes(parent.id)) {
@@ -3,13 +3,13 @@
<h3 class="d-flex align-items-center mb-2 mb-md-1" style="line-height: 1.4"> <h3 class="d-flex align-items-center mb-2 mb-md-1" style="line-height: 1.4">
<span class="text-truncate">{{title()}}</span> <span class="text-truncate">{{title()}}</span>
@if (id()) { @if (id()) {
<span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyID()"> <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()) { @if (copied()) {
<i-bs width="1em" height="1em" name="clipboard-check" class="me-1"></i-bs><ng-container i18n>Copied!</ng-container> <i-bs width="1em" height="1em" name="clipboard-check" class="me-1"></i-bs><ng-container i18n>Copied!</ng-container>
} @else { } @else {
ID: {{id()}} ID: {{id()}}
} }
</span> </button>
} }
@if (subTitle()) { @if (subTitle()) {
<span class="page-subtitle h6 mb-0 mt-1 fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle()}}</span> <span class="page-subtitle h6 mb-0 mt-1 fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle()}}</span>
@@ -43,6 +43,13 @@ describe('PageHeaderComponent', () => {
expect(titleSpy).toHaveBeenCalledWith(`Foo Bar - ${environment.appTitle}`) expect(titleSpy).toHaveBeenCalledWith(`Foo Bar - ${environment.appTitle}`)
}) })
it('should set html title to just the app title until a title is available', () => {
const titleSpy = jest.spyOn(titleService, 'setTitle')
fixture.componentRef.setInput('title', undefined)
fixture.detectChanges()
expect(titleSpy).toHaveBeenCalledWith(environment.appTitle)
})
it('should copy id to clipboard, reset after 3 seconds', () => { it('should copy id to clipboard, reset after 3 seconds', () => {
jest.useFakeTimers() jest.useFakeTimers()
fixture.componentRef.setInput('id', 42) fixture.componentRef.setInput('id', 42)
@@ -27,7 +27,10 @@ export class PageHeaderComponent {
constructor() { constructor() {
effect(() => { effect(() => {
this.titleService.setTitle(`${this.title()} - ${environment.appTitle}`) const title = this.title()
this.titleService.setTitle(
title ? `${title} - ${environment.appTitle}` : environment.appTitle
)
}) })
} }
@@ -12,7 +12,7 @@
} }
</div> </div>
<div class="me-1"> <div class="me-1">
<small i18n>All</small> <span i18n>All</span>
</div> </div>
</button> </button>
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SELF)" [disabled]="disabled"> <button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SELF)" [disabled]="disabled">
@@ -22,7 +22,7 @@
} }
</div> </div>
<div class="me-1"> <div class="me-1">
<small>{{ownerFilterLabel}}</small> {{ownerFilterLabel}}
</div> </div>
</button> </button>
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NOT_SELF)" [disabled]="disabled"> <button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NOT_SELF)" [disabled]="disabled">
@@ -32,7 +32,7 @@
} }
</div> </div>
<div class="me-1"> <div class="me-1">
<small>{{ownerExclusionFilterLabel}}</small> {{ownerExclusionFilterLabel}}
</div> </div>
</button> </button>
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SHARED_BY_ME)" [disabled]="disabled"> <button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SHARED_BY_ME)" [disabled]="disabled">
@@ -42,7 +42,7 @@
} }
</div> </div>
<div class="me-1"> <div class="me-1">
<small>{{sharedByFilterLabel}}</small> {{sharedByFilterLabel}}
</div> </div>
</button> </button>
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.UNOWNED)" [disabled]="disabled"> <button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.UNOWNED)" [disabled]="disabled">
@@ -52,7 +52,7 @@
} }
</div> </div>
<div class="me-1"> <div class="me-1">
<small i18n>Unowned</small> <span i18n>Unowned</span>
</div> </div>
</button> </button>
<button *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }" class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" [disabled]="disabled"> <button *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }" class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" [disabled]="disabled">
@@ -67,7 +67,7 @@
<div class="me-1 w-100"> <div class="me-1 w-100">
<ng-select <ng-select
name="user" name="user"
class="user-select small" class="user-select"
[ngModel]="selectionModel.includeUsers()" [ngModel]="selectionModel.includeUsers()"
(ngModelChange)="selectionModel.includeUsers.set($event)" (ngModelChange)="selectionModel.includeUsers.set($event)"
[disabled]="disabled" [disabled]="disabled"
@@ -87,7 +87,7 @@
<div class="list-group-item list-group-item-action d-flex align-items-center p-2 ps-3 border-bottom-0 border-start-0 border-end-0"> <div class="list-group-item list-group-item-action d-flex align-items-center p-2 ps-3 border-bottom-0 border-start-0 border-end-0">
<div class="form-check form-switch w-100"> <div class="form-check form-switch w-100">
<input type="checkbox" class="form-check-input" id="hideUnowned" [ngModel]="selectionModel.hideUnowned()" (ngModelChange)="selectionModel.hideUnowned.set($event)" (change)="onChange()" [disabled]="disabled"> <input type="checkbox" class="form-check-input" id="hideUnowned" [ngModel]="selectionModel.hideUnowned()" (ngModelChange)="selectionModel.hideUnowned.set($event)" (change)="onChange()" [disabled]="disabled">
<label class="form-check-label w-100" for="hideUnowned"><small i18n>Hide unowned</small></label> <label class="form-check-label w-100" for="hideUnowned"><ng-container i18n>Hide unowned</ng-container></label>
</div> </div>
</div> </div>
} }
@@ -100,7 +100,7 @@ describe('ProfileEditDialogComponent', () => {
expect(errorSpy).toHaveBeenCalled() expect(errorSpy).toHaveBeenCalled()
updateSpy.mockClear() updateSpy.mockClear()
const infoSpy = jest.spyOn(toastService, 'showInfo') const infoSpy = jest.spyOn(toastService, 'showSuccess')
component.form.patchValue(newProfile) component.form.patchValue(newProfile)
updateSpy.mockReturnValueOnce(of(newProfile)) updateSpy.mockReturnValueOnce(of(newProfile))
component.save() component.save()
@@ -218,6 +218,38 @@ describe('ProfileEditDialogComponent', () => {
jest.useRealTimers() jest.useRealTimers()
}) })
it('should logout under base href on save if password changed', () => {
jest.useFakeTimers()
const base = document.createElement('base')
base.href = '/paperless/'
document.head.appendChild(base)
const getSpy = jest.spyOn(profileService, 'get')
getSpy.mockReturnValue(of(profile))
const getProvidersSpy = jest.spyOn(
profileService,
'getSocialAccountProviders'
)
getProvidersSpy.mockReturnValue(of(socialAccountProviders))
component.ngOnInit()
component['newPassword'] = 'new*pass'
component.form.get('password').patchValue('new*pass')
component.form.get('password_confirm').patchValue('new*pass')
const updateSpy = jest.spyOn(profileService, 'update')
updateSpy.mockReturnValue(of(null))
const navSpy = jest
.spyOn(navUtils, 'setLocationHref')
.mockImplementation(() => {})
component.save()
expect(updateSpy).toHaveBeenCalled()
jest.advanceTimersByTime(2600)
expect(navSpy).toHaveBeenCalledWith(
`${window.location.origin}/paperless/accounts/logout/?next=/paperless/accounts/login/?next=/paperless/`
)
base.remove()
jest.useRealTimers()
})
it('should support auth token copy', () => { it('should support auth token copy', () => {
jest.useFakeTimers() jest.useFakeTimers()
const getSpy = jest.spyOn(profileService, 'get') const getSpy = jest.spyOn(profileService, 'get')
@@ -341,7 +373,7 @@ describe('ProfileEditDialogComponent', () => {
it('should activate totp', () => { it('should activate totp', () => {
const activateSpy = jest.spyOn(profileService, 'activateTotp') const activateSpy = jest.spyOn(profileService, 'activateTotp')
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const error = new Error('failed to activate totp') const error = new Error('failed to activate totp')
activateSpy.mockReturnValueOnce(throwError(() => error)) activateSpy.mockReturnValueOnce(throwError(() => error))
component.totpSettings.set({ component.totpSettings.set({
@@ -373,7 +405,7 @@ describe('ProfileEditDialogComponent', () => {
it('should deactivate totp', () => { it('should deactivate totp', () => {
const deactivateSpy = jest.spyOn(profileService, 'deactivateTotp') const deactivateSpy = jest.spyOn(profileService, 'deactivateTotp')
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const error = new Error('failed to deactivate totp') const error = new Error('failed to deactivate totp')
deactivateSpy.mockReturnValueOnce(throwError(() => error)) deactivateSpy.mockReturnValueOnce(throwError(() => error))
component.deactivateTotp() component.deactivateTotp()
@@ -204,14 +204,15 @@ export class ProfileEditDialogComponent
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: () => { next: () => {
this.toastService.showInfo($localize`Profile updated successfully`) this.toastService.showSuccess($localize`Profile updated successfully`)
if (passwordChanged) { if (passwordChanged) {
this.toastService.showInfo( this.toastService.showInfo(
$localize`Password has been changed, you will be logged out momentarily.` $localize`Password has been changed, you will be logged out momentarily.`
) )
setTimeout(() => { setTimeout(() => {
const basePath = new URL(document.baseURI).pathname
setLocationHref( setLocationHref(
`${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/` `${window.location.origin}${basePath}accounts/logout/?next=${basePath}accounts/login/?next=${basePath}`
) )
}, 2500) }, 2500)
} }
@@ -306,7 +307,9 @@ export class ProfileEditDialogComponent
this.recoveryCodes.set(activationResponse.recovery_codes) this.recoveryCodes.set(activationResponse.recovery_codes)
this.form.get('totp_code').enable() this.form.get('totp_code').enable()
if (activationResponse.success) { if (activationResponse.success) {
this.toastService.showInfo($localize`TOTP activated successfully`) this.toastService.showSuccess(
$localize`TOTP activated successfully`
)
} else { } else {
this.toastService.showError($localize`Error activating TOTP`) this.toastService.showError($localize`Error activating TOTP`)
} }
@@ -330,7 +333,9 @@ export class ProfileEditDialogComponent
this.isTotpEnabled.set(!success) this.isTotpEnabled.set(!success)
this.recoveryCodes.set(null) this.recoveryCodes.set(null)
if (success) { if (success) {
this.toastService.showInfo($localize`TOTP deactivated successfully`) this.toastService.showSuccess(
$localize`TOTP deactivated successfully`
)
} else { } else {
this.toastService.showError($localize`Error deactivating TOTP`) this.toastService.showError($localize`Error deactivating TOTP`)
} }
@@ -1,6 +1,6 @@
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="btn-group"> <div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading() || (suggestions() && !aiEnabled())" [aria-label]="noSuggestions ? 'No suggestions' : 'Suggest'" i18n-aria-label> <button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading()" [aria-label]="noSuggestions ? 'No suggestions' : 'Suggest'" i18n-aria-label>
@if (loading()) { @if (loading()) {
<div class="spinner-border spinner-border-sm" role="status"></div> <div class="spinner-border spinner-border-sm" role="status"></div>
} @else if (noSuggestions) { } @else if (noSuggestions) {
@@ -18,46 +18,44 @@
} }
</button> </button>
@if (aiEnabled()) { <div class="btn-group" ngbDropdown #dropdown="ngbDropdown" [popperOptions]="popperOptions">
<div class="btn-group" ngbDropdown #dropdown="ngbDropdown" [popperOptions]="popperOptions"> <button type="button" class="btn btn-sm btn-outline-primary" ngbDropdownToggle [disabled]="disabled() || loading() || !suggestions()" aria-expanded="false" aria-controls="suggestionsDropdown" aria-label="Suggestions dropdown">
<button type="button" class="btn btn-sm btn-outline-primary" ngbDropdownToggle [disabled]="disabled() || loading() || !suggestions()" aria-expanded="false" aria-controls="suggestionsDropdown" aria-label="Suggestions dropdown"> <span class="visually-hidden" i18n>Show suggestions</span>
<span class="visually-hidden" i18n>Show suggestions</span> </button>
</button>
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown"> <div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
<div class="list-group list-group-flush small pb-0"> <div class="list-group list-group-flush small pb-0">
@if (novelSuggestions === 0 && reusableSuggestions === 0) { @if (novelSuggestions === 0 && fieldSuggestions === 0) {
<div class="list-group-item text-muted fst-italic"> <div class="list-group-item text-muted fst-italic">
<small class="text-muted small fst-italic" i18n>No novel suggestions</small> <small class="text-muted small fst-italic" i18n>No novel suggestions</small>
</div> </div>
}
@if (suggestions()?.suggested_tags?.length > 0) {
<small class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="tags"></i-bs><ng-container i18n>Tags</ng-container></small>
@for (tag of suggestions().suggested_tags; track tag) {
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)">{{ tag }}</button>
} }
@if (suggestions()?.suggested_tags?.length > 0) { }
<small class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="tags"></i-bs><ng-container i18n>Tags</ng-container></small> @if (suggestions()?.suggested_document_types?.length > 0) {
@for (tag of suggestions().suggested_tags; track tag) { <div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="hash"></i-bs><ng-container i18n>Document Types</ng-container></div>
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)">{{ tag }}</button> @for (type of suggestions().suggested_document_types; track type) {
} <button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)">{{ type }}</button>
} }
@if (suggestions()?.suggested_document_types?.length > 0) { }
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="hash"></i-bs><ng-container i18n>Document Types</ng-container></div> @if (suggestions()?.suggested_correspondents?.length > 0) {
@for (type of suggestions().suggested_document_types; track type) { <div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="person"></i-bs><ng-container i18n>Correspondents</ng-container></div>
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)">{{ type }}</button> @for (correspondent of suggestions().suggested_correspondents; track correspondent) {
} <button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button>
} }
@if (suggestions()?.suggested_correspondents?.length > 0) { }
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="person"></i-bs><ng-container i18n>Correspondents</ng-container></div> @if (fieldSuggestions > 0) {
@for (correspondent of suggestions().suggested_correspondents; track correspondent) { <div class="list-group-item text-muted fst-italic">
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button> <small class="text-muted small fst-italic" i18n>{fieldSuggestions, plural, =1 {1 suggestion available below} other {{{fieldSuggestions}} suggestions available below}}</small>
} </div>
} }
@if (reusableSuggestions > 0) {
<div class="list-group-item text-muted fst-italic">
<small class="text-muted small fst-italic" i18n>{reusableSuggestions, plural, =1 {1 existing value suggested below} other {{{reusableSuggestions}} existing values suggested below}}</small>
</div>
}
</div>
</div> </div>
</div> </div>
} </div>
</div> </div>
@if (aiEnabled()) { @if (aiEnabled()) {
@@ -84,7 +84,7 @@ describe('SuggestionsDropdownComponent', () => {
fixture.detectChanges() fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain( expect(fixture.nativeElement.textContent).toContain(
'2 existing values suggested below' '2 suggestions available below'
) )
expect(fixture.nativeElement.textContent).not.toContain( expect(fixture.nativeElement.textContent).not.toContain(
'No novel suggestions' 'No novel suggestions'
@@ -108,7 +108,7 @@ describe('SuggestionsDropdownComponent', () => {
expect(component.totalSuggestions).toBe(4) expect(component.totalSuggestions).toBe(4)
expect(fixture.nativeElement.textContent).toContain('Arbitration') expect(fixture.nativeElement.textContent).toContain('Arbitration')
expect(fixture.nativeElement.textContent).toContain( expect(fixture.nativeElement.textContent).toContain(
'2 existing values suggested below' '2 suggestions available below'
) )
}) })
@@ -125,10 +125,46 @@ describe('SuggestionsDropdownComponent', () => {
}) })
expect(component.novelSuggestions).toBe(0) expect(component.novelSuggestions).toBe(0)
expect(component.totalSuggestions).toBe(5) expect(component.totalSuggestions).toBe(6)
fixture.componentRef.setInput('appliedStoragePath', 7) fixture.componentRef.setInput('appliedStoragePath', 7)
expect(component.totalSuggestions).toBe(4) expect(component.totalSuggestions).toBe(5)
})
it('should count title and dates as field suggestions without calling them existing values', () => {
fixture.componentRef.setInput('aiEnabled', true)
fixture.componentRef.setInput('fetchedSources', [SuggestionSource.ML])
fixture.componentRef.setInput('suggestions', {
title: 'Suggested title',
dates: ['2026-01-04', '2026-02-01', '2026-03-01'],
correspondents: [1, 2, 3, 4],
document_types: [1, 2, 3, 4],
tags: [1, 2, 3, 4, 5, 6, 7, 8],
})
fixture.detectChanges()
component.clickSuggest()
fixture.detectChanges()
expect(component.reusableSuggestions).toBe(16)
expect(component.fieldSuggestions).toBe(20)
expect(component.totalSuggestions).toBe(20)
expect(fixture.nativeElement.textContent).toContain(
'20 suggestions available below'
)
expect(fixture.nativeElement.textContent).not.toContain('existing value')
})
it('should not count title or date suggestions matching the current values', () => {
fixture.componentRef.setInput('suggestions', {
title: 'Current title',
dates: ['2026-01-04', '2026-02-01'],
})
expect(component.fieldSuggestions).toBe(3)
fixture.componentRef.setInput('appliedTitle', 'Current title')
fixture.componentRef.setInput('appliedCreated', '2026-01-04')
expect(component.fieldSuggestions).toBe(1)
expect(component.totalSuggestions).toBe(1)
}) })
it('should show when a completed request returned no suggestions', () => { it('should show when a completed request returned no suggestions', () => {
@@ -145,6 +181,28 @@ describe('SuggestionsDropdownComponent', () => {
expect(fixture.nativeElement.textContent).toContain('No suggestions') expect(fixture.nativeElement.textContent).toContain('No suggestions')
}) })
it('should wait for all pending responses before showing the empty state', () => {
fixture.componentRef.setInput('aiEnabled', true)
fixture.componentRef.setInput('source', SuggestionSource.Both)
fixture.componentRef.setInput('fetchedSources', [SuggestionSource.ML])
fixture.componentRef.setInput('suggestions', { tags: [] })
fixture.componentRef.setInput('loading', true)
fixture.detectChanges()
expect(component.noSuggestions).toBeFalsy()
expect(fixture.nativeElement.textContent).not.toContain('No suggestions')
expect(
fixture.nativeElement.querySelector('[role="status"]')
).not.toBeNull()
fixture.componentRef.setInput('loading', false)
fixture.detectChanges()
expect(component.noSuggestions).toBeTruthy()
expect(fixture.nativeElement.textContent).toContain('No suggestions')
expect(fixture.nativeElement.querySelector('[role="status"]')).toBeNull()
})
it('should not show the empty state before a request or with suggestions', () => { it('should not show the empty state before a request or with suggestions', () => {
expect(component.noSuggestions).toBeFalsy() expect(component.noSuggestions).toBeFalsy()
@@ -193,6 +251,37 @@ describe('SuggestionsDropdownComponent', () => {
expect(fixture.nativeElement.textContent).toContain('No novel suggestions') expect(fixture.nativeElement.textContent).toContain('No novel suggestions')
}) })
it('should open ML date suggestions with AI disabled', () => {
const getSuggestions = jest.spyOn(component.getSuggestions, 'emit')
fixture.componentRef.setInput('aiEnabled', false)
fixture.componentRef.setInput('fetchedSources', [SuggestionSource.ML])
fixture.componentRef.setInput('suggestions', {
dates: ['2026-01-04', '2026-02-01'],
})
fixture.componentRef.setInput('appliedCreated', '2026-01-04')
fixture.detectChanges()
const button = fixture.nativeElement.querySelector('button')
expect(button.disabled).toBeFalsy()
expect(button.querySelector('.badge').textContent.trim()).toBe('1')
button.click()
fixture.detectChanges()
expect(component.dropdown.isOpen()).toBeTruthy()
expect(fixture.nativeElement.textContent).toContain(
'1 suggestion available below'
)
expect(getSuggestions).not.toHaveBeenCalled()
expect(
fixture.nativeElement.querySelector('button[title="Suggestion options"]')
).toBeNull()
button.click()
fixture.detectChanges()
expect(component.dropdown.isOpen()).toBeFalsy()
})
it('should fetch unfetched sources and show existing suggestions', () => { it('should fetch unfetched sources and show existing suggestions', () => {
jest.spyOn(component.getSuggestions, 'emit') jest.spyOn(component.getSuggestions, 'emit')
fixture.componentRef.setInput('aiEnabled', true) fixture.componentRef.setInput('aiEnabled', true)
Loaded 100 of 354 files, more files were not shown because too many files have changed in this diff. Show more