Compare commits

..
Author SHA1 Message Date
shamoon 87b2178dae Merge branch 'dev' into feature/ocr-templates 2026-07-10 00:47:08 -07:00
shamoon f24ed315d2 Use SVG, get rid of all the manual redraw stuff 2026-06-29 21:10:27 -07:00
shamoon 665a724221 Use a real canvas interaction model
[skip ci]
2026-06-28 12:55:13 -07:00
shamoon 7a4cddebbe Fix some permissions stuff, show error on toggle 2026-06-28 12:37:11 -07:00
shamoon 1b7c0af22e Get rid of absurd alert 2026-06-28 12:36:54 -07:00
shamoon 20a855444b Proper data types
[skip ci]
2026-06-28 12:33:18 -07:00
shamoon c3a3939387 Pull out the zone list into a sub-component 2026-06-27 22:45:13 -07:00
shamoon a1fad8309f Extract the geometry stuff a bit 2026-06-27 22:29:41 -07:00
shamoon 613b528b7e Remove afterviewinit, fix page change, custom date format per zone 2026-06-27 22:19:01 -07:00
Christoph Schlaepfershamoondependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>stumpylogGitHub Actions <41898282+github-actions[bot]@users.noreply.github.com>
bf73b5b1d1 Feature: OCR Templates (#13043)
[skip ci]

Signed-off-by: dependabot[bot] <support@github.com>
Co-Authored-By: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-Authored-By: stumpylog <797416+stumpylog@users.noreply.github.com>
Co-Authored-By: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-Authored-By: shamoon <4887959+shamoon@users.noreply.github.com>
2026-06-23 07:32:58 -07:00
297 changed files with 69233 additions and 106431 deletions
+1 -2
View File
@@ -26,7 +26,6 @@ jobs:
check-name: 'Merge and Push Manifest' check-name: 'Merge and Push Manifest'
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
wait-interval: 60 wait-interval: 60
checks-discovery-timeout: 1800
build-release: build-release:
name: Build Release name: Build Release
needs: wait-for-docker needs: wait-for-docker
@@ -86,7 +85,7 @@ jobs:
# ---- Prepare Release ---- # ---- Prepare Release ----
- name: Generate requirements file - name: Generate requirements file
run: | run: |
uv export --quiet --no-dev --all-extras --emit-index-url --format requirements-txt --output-file requirements.txt uv export --quiet --no-dev --all-extras --format requirements-txt --output-file requirements.txt
- name: Compile messages - name: Compile messages
env: env:
PAPERLESS_SECRET_KEY: "ci-release-not-a-real-secret" PAPERLESS_SECRET_KEY: "ci-release-not-a-real-secret"
+4 -4
View File
@@ -38,7 +38,7 @@ repos:
- json - json
# See https://github.com/prettier/prettier/issues/15742 for the fork reason # See https://github.com/prettier/prettier/issues/15742 for the fork reason
- repo: https://github.com/rbubley/mirrors-prettier - repo: https://github.com/rbubley/mirrors-prettier
rev: 'v3.9.4' rev: 'v3.8.4'
hooks: hooks:
- id: prettier - id: prettier
types_or: types_or:
@@ -46,16 +46,16 @@ repos:
- ts - ts
- markdown - markdown
additional_dependencies: additional_dependencies:
- prettier@3.9.4 - prettier@3.8.3
- 'prettier-plugin-organize-imports@4.3.0' - 'prettier-plugin-organize-imports@4.3.0'
# Python hooks # Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.20 rev: v0.15.17
hooks: hooks:
- id: ruff-check - id: ruff-check
- id: ruff-format - id: ruff-format
- repo: https://github.com/tox-dev/pyproject-fmt - repo: https://github.com/tox-dev/pyproject-fmt
rev: "v2.25.1" rev: "v2.24.1"
hooks: hooks:
- id: pyproject-fmt - id: pyproject-fmt
additional_dependencies: [tomli] additional_dependencies: [tomli]
+1 -5
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.11.28-python3.12-trixie-slim AS s6-overlay-base FROM ghcr.io/astral-sh/uv:0.11.19-python3.12-trixie-slim AS s6-overlay-base
WORKDIR /usr/src/s6 WORKDIR /usr/src/s6
@@ -238,10 +238,6 @@ RUN set -eux \
&& chown --from root:root --changes --recursive paperless:paperless /usr/src/paperless \ && chown --from root:root --changes --recursive paperless:paperless /usr/src/paperless \
&& echo "Making fontconfig cache writable for arbitrary container UIDs" \ && echo "Making fontconfig cache writable for arbitrary container UIDs" \
&& chmod 1777 /var/cache/fontconfig \ && chmod 1777 /var/cache/fontconfig \
&& echo "Making /run world-writable for rootless operation" \
&& chmod 1777 /run \
&& echo "Removing setuid from s6-overlay-suexec for rootless compat" \
&& chmod u-s /command/s6-overlay-suexec \
&& echo "Collecting static files" \ && echo "Collecting static files" \
&& PAPERLESS_SECRET_KEY=build-time-dummy s6-setuidgid paperless python3 manage.py collectstatic --clear --no-input --link \ && PAPERLESS_SECRET_KEY=build-time-dummy s6-setuidgid paperless python3 manage.py collectstatic --clear --no-input --link \
&& PAPERLESS_SECRET_KEY=build-time-dummy s6-setuidgid paperless python3 manage.py compilemessages \ && PAPERLESS_SECRET_KEY=build-time-dummy s6-setuidgid paperless python3 manage.py compilemessages \
+3 -3
View File
@@ -4,7 +4,7 @@
# correct networking for the tests # correct networking for the tests
services: services:
gotenberg: gotenberg:
image: docker.io/gotenberg/gotenberg:8.34 image: docker.io/gotenberg/gotenberg:8.33
hostname: gotenberg hostname: gotenberg
container_name: gotenberg container_name: gotenberg
network_mode: host network_mode: host
@@ -24,7 +24,7 @@ services:
network_mode: host network_mode: host
restart: unless-stopped restart: unless-stopped
greenmail: greenmail:
image: docker.io/greenmail/standalone:2.1.9 image: docker.io/greenmail/standalone:2.1.8
hostname: greenmail hostname: greenmail
container_name: greenmail container_name: greenmail
environment: environment:
@@ -35,7 +35,7 @@ services:
- "3143:3143" # IMAP - "3143:3143" # IMAP
restart: unless-stopped restart: unless-stopped
nginx: nginx:
image: docker.io/nginx:1.31.2-alpine image: docker.io/nginx:1.31.1-alpine
hostname: nginx hostname: nginx
container_name: nginx container_name: nginx
ports: ports:
@@ -72,7 +72,7 @@ services:
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000 PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998 PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg: gotenberg:
image: docker.io/gotenberg/gotenberg:8.34 image: docker.io/gotenberg/gotenberg:8.33
restart: unless-stopped restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not # The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript. # want to allow external content like tracking pixels or even javascript.
+1 -1
View File
@@ -56,7 +56,7 @@ services:
environment: environment:
PAPERLESS_REDIS: redis://broker:6379 PAPERLESS_REDIS: redis://broker:6379
PAPERLESS_DBHOST: db PAPERLESS_DBHOST: db
PAPERLESS_DBENGINE: postgresql PAPERLESS_DBENGINE: postgres
env_file: env_file:
- stack.env - stack.env
volumes: volumes:
@@ -67,7 +67,7 @@ services:
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000 PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998 PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg: gotenberg:
image: docker.io/gotenberg/gotenberg:8.34 image: docker.io/gotenberg/gotenberg:8.33
restart: unless-stopped restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not # The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript. # want to allow external content like tracking pixels or even javascript.
@@ -56,7 +56,7 @@ services:
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000 PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998 PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg: gotenberg:
image: docker.io/gotenberg/gotenberg:8.34 image: docker.io/gotenberg/gotenberg:8.33
restart: unless-stopped restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not # The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript. # want to allow external content like tracking pixels or even javascript.
-17
View File
@@ -151,23 +151,6 @@ the background.
### Bare Metal Route {#bare-metal-updating} ### Bare Metal Route {#bare-metal-updating}
!!! warning
Extracting a new release archive on top of an existing installation
(e.g. `tar -xf paperless-ngx-vX.Y.Z.tar.xz -C /opt/paperless`) only adds
and overwrites files -- it does not remove files that were deleted in
the new release. Leftover files from an old version, such as
superseded database migrations, can remain on disk and cause errors
like `NodeNotFoundError` during `manage.py migrate`.
Before unpacking a new release over an existing bare-metal
installation, remove the previous source tree first (everything
except your `media`, `data`, and `consume` directories and your
`paperless.conf`/`.env`), or unpack into a fresh directory and move
your persistent data and configuration over. Installations updated via
`git pull` on a clean checkout are not affected, since Git removes
files that no longer exist upstream.
After grabbing the new release and unpacking the contents, do the After grabbing the new release and unpacking the contents, do the
following: following:
+1 -985
View File
File diff suppressed because it is too large Load Diff
+17 -33
View File
@@ -522,36 +522,22 @@ do CORS calls. Set this to your public domain name.
fail2ban with log entries for failed authorization attempts. Value should be fail2ban with log entries for failed authorization attempts. Value should be
IP address(es). IP address(es).
By default, this setting also controls allauth's trusted proxy count, This setting also controls allauth's
which is set to the number of proxies listed here. Override that default [`ALLAUTH_TRUSTED_PROXY_COUNT`](https://docs.allauth.org/en/latest/account/configuration.html),
with [`PAPERLESS_ALLAUTH_TRUSTED_PROXY_COUNT`](#PAPERLESS_ALLAUTH_TRUSTED_PROXY_COUNT) which is set to the number of proxies listed here. Without this,
when the list length does not match the number of proxy hops. allauth cannot determine the client IP address for rate limiting when
running behind a reverse proxy, resulting in a `403 Forbidden` on login.
Defaults to empty string. Defaults to empty string.
#### [`PAPERLESS_ALLAUTH_TRUSTED_PROXY_COUNT=<integer>`](#PAPERLESS_ALLAUTH_TRUSTED_PROXY_COUNT) {#PAPERLESS_ALLAUTH_TRUSTED_PROXY_COUNT}
: Sets allauth's
[`ALLAUTH_TRUSTED_PROXY_COUNT`](https://docs.allauth.org/en/latest/common/rate_limits.html#configuration).
This is the number of trusted proxy **hops** represented in each
`X-Forwarded-For` header, not the number of IP addresses through which those
proxies may be reached. For example, a single dual-stack proxy is one hop even
when its IPv4 and IPv6 addresses are both listed in
[`PAPERLESS_TRUSTED_PROXIES`](#PAPERLESS_TRUSTED_PROXIES).
Only trust `X-Forwarded-For` when untrusted clients cannot connect directly
to Paperless-ngx.
Defaults to the number of entries in `PAPERLESS_TRUSTED_PROXIES`.
#### [`PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER=<header-name>`](#PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER) {#PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER} #### [`PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER=<header-name>`](#PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER) {#PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER}
: Sets allauth's : Sets allauth's
[`ALLAUTH_TRUSTED_CLIENT_IP_HEADER`](https://docs.allauth.org/en/latest/common/rate_limits.html#configuration). [`ALLAUTH_TRUSTED_CLIENT_IP_HEADER`](https://docs.allauth.org/en/latest/account/configuration.html).
Use this when your reverse proxy sets a dedicated header for the real Use this when your reverse proxy sets a dedicated header for the real
client IP instead of `X-Forwarded-For`, for example `X-Real-IP` (nginx) client IP instead of `X-Forwarded-For`, for example `X-Real-IP` (nginx)
or `CF-Connecting-IP` (Cloudflare). When set, this takes precedence over or `CF-Connecting-IP` (Cloudflare). When set, this takes precedence over
`PAPERLESS_ALLAUTH_TRUSTED_PROXY_COUNT`. [`PAPERLESS_TRUSTED_PROXIES`](#PAPERLESS_TRUSTED_PROXIES).
Defaults to none. Defaults to none.
@@ -1173,21 +1159,19 @@ still perform some basic text pre-processing before matching.
#### [`PAPERLESS_DATE_PARSER_LANGUAGES=<lang>`](#PAPERLESS_DATE_PARSER_LANGUAGES) {#PAPERLESS_DATE_PARSER_LANGUAGES} #### [`PAPERLESS_DATE_PARSER_LANGUAGES=<lang>`](#PAPERLESS_DATE_PARSER_LANGUAGES) {#PAPERLESS_DATE_PARSER_LANGUAGES}
: Specifies which language Paperless should use when parsing dates from documents. Specifies which language Paperless should use when parsing dates from documents.
: This should be a language code supported by the dateparser library, This should be a language code supported by the dateparser library,
for example: "en", or a combination such as "en+de". for example: "en", or a combination such as "en+de".
Locales are also supported (e.g., "en-AU"). Locales are also supported (e.g., "en-AU").
Multiple languages can be combined using "+", for example: "en+de" or "en-AU+de". Multiple languages can be combined using "+", for example: "en+de" or "en-AU+de".
For valid values, refer to the list of supported languages and locales in the [dateparser documentation](https://dateparser.readthedocs.io/en/latest/supported_locales.html). For valid values, refer to the list of supported languages and locales in the [dateparser documentation](https://dateparser.readthedocs.io/en/latest/supported_locales.html).
: Set this to match the languages in which most of your documents are written.
Set this to match the languages in which most of your documents are written.
If not set, Paperless will attempt to infer the language(s) from the OCR configuration (`PAPERLESS_OCR_LANGUAGE`). If not set, Paperless will attempt to infer the language(s) from the OCR configuration (`PAPERLESS_OCR_LANGUAGE`).
!!! note !!! note
This format differs from the `PAPERLESS_OCR_LANGUAGE` setting, which uses ISO 639-2 codes (3 letters, e.g., "eng+deu" for Tesseract OCR).
This format differs from the `PAPERLESS_OCR_LANGUAGE` setting, which uses ISO 639-2 codes (3 letters, e.g., "eng+deu" for Tesseract OCR).
#### [`PAPERLESS_EMAIL_TASK_CRON=<cron expression>`](#PAPERLESS_EMAIL_TASK_CRON) {#PAPERLESS_EMAIL_TASK_CRON} #### [`PAPERLESS_EMAIL_TASK_CRON=<cron expression>`](#PAPERLESS_EMAIL_TASK_CRON) {#PAPERLESS_EMAIL_TASK_CRON}
@@ -2135,7 +2119,7 @@ used with the OpenAI-compatible backend to target a custom provider or local gat
Defaults to None. Defaults to None.
#### [`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE=<str>`](#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE) {#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE} ### [`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE=<str>`](#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE) {#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE}
: The language to use for AI suggestions (results may vary by LLM model). If not supplied, defaults to the user's UI language setting or None. : The language to use for AI suggestions (results may vary by LLM model). If not supplied, defaults to the user's UI language setting or None.
+5 -74
View File
@@ -133,7 +133,7 @@ PAPERLESS_DB_OPTIONS="sslmode=require,sslrootcert=/certs/ca.pem,pool.max_size=10
## OCR and Archive File Generation Settings ## OCR and Archive File Generation Settings
The settings that control OCR behaviour and archive file generation have been redesigned. The old settings that coupled these two concerns together are **removed** - old values are not silently honoured; a startup warning is logged if any removed variable is still set in your environment. The settings that control OCR behaviour and archive file generation have been redesigned. The old settings that coupled these two concerns together are **removed** old values are not silently honoured; a startup warning is logged if any removed variable is still set in your environment.
### Removed settings ### Removed settings
@@ -166,7 +166,7 @@ Remove any `PAPERLESS_OCR_SKIP_ARCHIVE_FILE` variable from your environment. If
# v2: skip OCR when text present, always archive # v2: skip OCR when text present, always archive
PAPERLESS_OCR_MODE=skip PAPERLESS_OCR_MODE=skip
# v3: equivalent (auto is the new default) # v3: equivalent (auto is the new default)
# No change needed - auto is the default # No change needed auto is the default
# v2: skip OCR when text present, skip archive too # v2: skip OCR when text present, skip archive too
PAPERLESS_OCR_MODE=skip_noarchive PAPERLESS_OCR_MODE=skip_noarchive
@@ -189,10 +189,10 @@ PAPERLESS_ARCHIVE_FILE_GENERATION=auto
If you use the **remote OCR parser** (Azure AI), note that it always produces a If you use the **remote OCR parser** (Azure AI), note that it always produces a
searchable PDF and stores it as the archive copy. `ARCHIVE_FILE_GENERATION=never` searchable PDF and stores it as the archive copy. `ARCHIVE_FILE_GENERATION=never`
has no effect for documents handled by the remote parser - the archive is produced has no effect for documents handled by the remote parser the archive is produced
unconditionally by the remote engine. unconditionally by the remote engine.
## Search Index (Whoosh -> Tantivy) # Search Index (Whoosh -> Tantivy)
The full-text search backend has been replaced with [Tantivy](https://github.com/quickwit-oss/tantivy). The full-text search backend has been replaced with [Tantivy](https://github.com/quickwit-oss/tantivy).
The index format is incompatible with Whoosh, so **the search index is automatically rebuilt from The index format is incompatible with Whoosh, so **the search index is automatically rebuilt from
@@ -324,74 +324,5 @@ option since v1.8.0.
Allauth changed how it determines the client IP address for login rate limiting. Users running Allauth changed how it determines the client IP address for login rate limiting. Users running
behind a reverse proxy may need to set behind a reverse proxy may need to set
[`PAPERLESS_TRUSTED_PROXIES`](configuration.md#PAPERLESS_TRUSTED_PROXIES), [`PAPERLESS_TRUSTED_PROXIES`](configuration.md#PAPERLESS_TRUSTED_PROXIES),
[`PAPERLESS_ALLAUTH_TRUSTED_PROXY_COUNT`](configuration.md#PAPERLESS_ALLAUTH_TRUSTED_PROXY_COUNT),
[`PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER`](configuration.md#PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER), [`PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER`](configuration.md#PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER),
or a combination of these settings to avoid `403 Forbidden` errors on login. or both, to avoid `403 Forbidden` errors on login.
The proxy count is the number of proxy hops in `X-Forwarded-For`, which may
differ from the number of configured proxy IP addresses.
## Minimum CPU Requirements (NumPy Baseline)
Starting with NumPy 2.4.0, official `manylinux` x86_64 wheels are compiled with a minimum
CPU baseline of `x86-64-v2`, which requires SSE3, SSSE3, SSE4.1, SSE4.2, POPCNT, and
CMPXCHG16B. This is a [deliberate upstream change](https://github.com/numpy/numpy/issues/27851)
citing that these instructions have been present in over 99.7% of CPUs since 2008
([Intel](<https://en.wikipedia.org/wiki/Nehalem_(microarchitecture)>)) or 2011
([AMD](<https://en.wikipedia.org/wiki/Bulldozer_(microarchitecture)>)).
NumPy is a dependency of the document classifier (via scikit-learn), so any CPU that
predates SSE4.2 support will crash with `SIGILL` (illegal instruction) when the classifier
is loaded or trained, regardless of whether AI features are enabled.
This differs from NumPy's optional SIMD dispatch (e.g. AVX2, AVX512), which is detected and
selected safely at runtime - the `x86-64-v2` requirement above is a hard floor baked into the
wheel, with no runtime fallback.
### Affected hardware
CPUs older than roughly 2008 (Intel) or 2011 (AMD) that lack SSE4.2 support. This is more
likely to affect low-power or embedded hardware - e.g. early Atom, Celeron, or pre-Bulldozer
AMD chips - than typical desktop or server hardware from the last decade.
Check for SSE4.2 support with:
```bash
grep -o -m1 sse4_2 /proc/cpuinfo
```
If this prints nothing, your CPU is affected.
### Symptoms
The Celery worker (and potentially the web server) repeatedly crashes and restarts with a
`SIGILL` error, typically visible in `dmesg`/`journalctl` as a `trap invalid opcode` inside
`_multiarray_umath...so`. Because the classifier is trained on a periodic schedule
(hourly, by default), affected instances see intermittent, hard-to-reproduce document
consumption failures whenever that scheduled task runs and takes down the worker process
mid-task.
### Action Required (for affected hardware only)
There is no way to make the classifier itself work on such CPUs - it requires an unofficial
NumPy build with `cpu-baseline=none`, which is not something we can ship. The practical
path forward is to stop the classifier from ever loading or training, which avoids
importing NumPy at all:
```bash
PAPERLESS_TRAIN_TASK_CRON=disable
```
This disables the periodic classifier training task (see
[`PAPERLESS_TRAIN_TASK_CRON`](configuration.md#PAPERLESS_TRAIN_TASK_CRON)). Automatic
matching based on the classifier (suggested correspondents, document types, tags, and
storage paths from trained rules) will no longer be available, but rule-based matching is
unaffected, and document consumption itself will no longer be at risk of crashing the
worker.
## Database Migrations
Some integer fields have been changed to smaller types to reduce database size. If you have any `MailRule` records with a `maximum_age` greater than 32767, they will be clamped to 32767 during the migration to avoid errors during migration.
### Action Required
No user action is required. The migration will automatically clamp any `MailRule.maximum_age` values greater than 32767 to 32767 during the migration process.
+3 -11
View File
@@ -142,7 +142,7 @@ a [superuser](usage.md#superusers) account.
It is not possible to run the container rootless if additional languages are specified via `PAPERLESS_OCR_LANGUAGES`. It is not possible to run the container rootless if additional languages are specified via `PAPERLESS_OCR_LANGUAGES`.
If you want to run Paperless as a rootless container, set `user:` in `docker-compose.yml` to the UID and GID of your host user (use `id -u` and `id -g` to find these values). The container process starts directly as that user with no internal privilege remapping. Any UID and GID is supported: If you want to run Paperless as a rootless container, set `user:` in `docker-compose.yml` to the UID and GID of your host user (use `id -u` and `id -g` to find these values). The container process starts directly as that user with no internal privilege remapping:
```yaml ```yaml
webserver: webserver:
@@ -157,8 +157,8 @@ Do not combine this with `USERMAP_UID` or `USERMAP_GID`, which are intended for
Some file systems, such as NFS network shares, don't support file system Some file systems, such as NFS network shares, don't support file system
notifications with `inotify`. When the consumption directory is on such a notifications with `inotify`. When the consumption directory is on such a
file system, Paperless-ngx will not pick up new files with the default file system, Paperless-ngx will not pick up new files with the default
configuration. Set [`PAPERLESS_CONSUMER_POLLING_INTERVAL`](configuration.md#PAPERLESS_CONSUMER_POLLING_INTERVAL) configuration. Use [`PAPERLESS_CONSUMER_POLLING`](configuration.md#PAPERLESS_CONSUMER_POLLING)
to a positive number to enable polling and disable native filesystem notifications. to enable polling and disable inotify. See [here](configuration.md#polling).
## Bare Metal Install {#bare_metal} ## Bare Metal Install {#bare_metal}
@@ -262,14 +262,6 @@ to a positive number to enable polling and disable native filesystem notificatio
and copy the contents to the home directory of the user you created and copy the contents to the home directory of the user you created
earlier (`/opt/paperless`). earlier (`/opt/paperless`).
!!! note
If you are updating an existing bare-metal installation rather than
installing for the first time, see the
[bare metal update instructions](administration.md#bare-metal-updating)
instead -- extracting a new release on top of an old one can leave
behind stale files from the previous version.
Optional: If you cloned the Git repository, you will need to Optional: If you cloned the Git repository, you will need to
compile the frontend yourself. See [here](development.md#front-end-development) compile the frontend yourself. See [here](development.md#front-end-development)
and use the `build` step, not `serve`. and use the `build` step, not `serve`.
+11 -12
View File
@@ -458,8 +458,8 @@ For related metadata such as tags, correspondents, document types, and storage p
### Password reset ### Password reset
In order to enable the password reset feature you will need to setup an SMTP backend, see In order to enable the password reset feature you will need to setup an SMTP backend, see
[`PAPERLESS_EMAIL_HOST`](configuration.md#PAPERLESS_EMAIL_HOST). You should also set [`PAPERLESS_EMAIL_HOST`](configuration.md#PAPERLESS_EMAIL_HOST). If your installation does not have
[`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) and / or its corresponding configuration settings. [`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) set, the reset link included in emails will use the server host.
### Two-factor authentication ### Two-factor authentication
@@ -833,10 +833,10 @@ contract you signed 8 years ago).
When you search paperless for a document, it tries to match this query When you search paperless for a document, it tries to match this query
against your documents. Paperless will look for matching documents by against your documents. Paperless will look for matching documents by
inspecting their content, title, correspondent, type, and tags. Paperless inspecting their content, title, correspondent, type, tags, notes, and
returns a scored list of results, so that documents matching your query custom field values. Paperless returns a scored list of results, so that
better will appear further up in the search results. Notes and custom field documents matching your query better will appear further up in the search
values can be searched using the advanced search syntax described below. results.
By default, paperless returns only documents which contain all words By default, paperless returns only documents which contain all words
typed in the search bar. A few things to know about how matching works: typed in the search bar. A few things to know about how matching works:
@@ -891,9 +891,9 @@ Supported date keywords: `today`, `yesterday`, `previous week`,
#### Searching custom fields #### Searching custom fields
Custom field names and values are included in the full-text index, but they Custom field values are included in the full-text index, so a plain search
are not searched by a plain, unqualified query. Use the advanced search syntax already matches documents whose custom field values contain your search terms.
to search by field name or value: To narrow by field name or value specifically:
``` ```
custom_fields.value:policy custom_fields.value:policy
@@ -921,9 +921,8 @@ custom_fields.name:"Contract Number" custom_fields.value:1312
#### Searching notes #### Searching notes
Notes are included in the full-text index, but they are not searched by a Notes content is included in full-text search automatically. To search
plain, unqualified query. Use the advanced search syntax to search by note by note author or content specifically:
author or content:
``` ```
notes.user:alice notes.user:alice
View File
-123
View File
@@ -1,123 +0,0 @@
# profiling/harness.py
from __future__ import annotations
import json
import subprocess
import time
from dataclasses import dataclass
from datetime import UTC
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING
from typing import Any
from typing import Generic
from typing import TypeVar
import pytest
from django.db import connection
from django.test.utils import CaptureQueriesContext
if TYPE_CHECKING:
from collections.abc import Callable
from django.db.models import QuerySet
from profiling.seed import ScaleProfile
T = TypeVar("T")
def require_postgres() -> None:
if connection.vendor != "postgresql":
pytest.skip(
"Profiling requires PostgreSQL to reflect production query "
"planning; SQLite does not reproduce the varchar-cast planner "
"pathology this work fixes.",
)
@dataclass(frozen=True, slots=True)
class ProfileResult(Generic[T]):
best_seconds: float
all_seconds: tuple[float, ...]
query_count: int
result: T
def run_profile(fn: Callable[[], T], *, repeat: int = 5) -> ProfileResult[T]:
require_postgres()
all_seconds: list[float] = []
result: T | None = None
query_count = 0
for i in range(repeat):
with CaptureQueriesContext(connection) as ctx:
start = time.perf_counter()
result = fn()
all_seconds.append(time.perf_counter() - start)
if i == repeat - 1:
query_count = len(ctx.captured_queries)
assert result is not None # repeat >= 1 guarantees at least one assignment
return ProfileResult(
best_seconds=min(all_seconds),
all_seconds=tuple(all_seconds),
query_count=query_count,
result=result,
)
def capture_explain_analyze(queryset: QuerySet[Any]) -> str:
if connection.vendor != "postgresql":
raise RuntimeError("EXPLAIN ANALYZE capture is Postgres-only")
sql, params = queryset.query.sql_with_params()
with connection.cursor() as cur:
cur.execute(f"EXPLAIN ANALYZE {sql}", params)
return "\n".join(row[0] for row in cur.fetchall())
def _current_git_ref() -> str:
result = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
capture_output=True,
text=True,
check=False,
)
return result.stdout.strip() or "unknown"
def append_profiling_history(
*,
stage: str,
scenario: str,
best_seconds: float,
query_count: int,
scale: ScaleProfile,
code_ref: str | None = None,
) -> None:
"""
Append one line to profiling/results/history.jsonl -- an append-only,
cross-session record of every profiling run. Unlike the per-stage
before/after snapshot JSON files (which the next run of the same stage
overwrites), this never gets clobbered, so a profiling effort picked
back up days later has a full timeline to resume from instead of only
the single most recent comparison.
``code_ref`` should identify the *production* code under test (a short
SHA on the feature branch, not this `profiling` branch) -- pass it
explicitly when known. Left as ``None``, it falls back to this repo's
current ``git rev-parse --short HEAD``, which is only meaningful if
you're actually running from a checkout where that reflects the code
being measured.
"""
results_dir = Path(__file__).parent / "results"
results_dir.mkdir(exist_ok=True)
entry = {
"timestamp": datetime.now(UTC).isoformat(),
"code_ref": code_ref or _current_git_ref(),
"stage": stage,
"scenario": scenario,
"scale": scale,
"best_seconds": best_seconds,
"query_count": query_count,
}
with (results_dir / "history.jsonl").open("a") as f:
f.write(json.dumps(entry) + "\n")
-18
View File
@@ -1,18 +0,0 @@
[tool.pytest]
ini_options.pythonpath = [ "../src", "..", "." ]
ini_options.markers = [
"""\
profiling: Profiling scripts comparing old vs new query patterns; require a throwaway PostgreSQL container, not run \
as part of the normal test suite\
""",
]
ini_options.DJANGO_SETTINGS_MODULE = "paperless.settings"
[tool.pytest_env]
# Standalone config: this directory must be independently runnable without
# the rest of the dev stack (Redis, etc) present -- only Postgres is required,
# via run_with_postgres.sh.
PAPERLESS_SECRET_KEY = "profiling-only-insecure-key"
PAPERLESS_DISABLE_DBHANDLER = "true"
PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
PAPERLESS_CHANNELS_BACKEND = "channels.layers.InMemoryChannelLayer"
-8
View File
@@ -1,8 +0,0 @@
{"timestamp": "2026-07-27T22:47:46.677504+00:00", "code_ref": "test-run", "stage": "self-check", "scenario": "test_append_profiling_history_writes_a_valid_json_line", "scale": "medium", "best_seconds": 0.001, "query_count": 0}
{"timestamp": "2026-07-27T22:49:03.762638+00:00", "code_ref": "test-run", "stage": "self-check", "scenario": "test_append_profiling_history_writes_a_valid_json_line", "scale": "medium", "best_seconds": 0.001, "query_count": 0}
{"timestamp": "2026-07-27T23:01:49.777269+00:00", "code_ref": "0506dcad0", "stage": "stage1", "scenario": "get_objects_for_user_owner_aware_document_baseline", "scale": "medium", "best_seconds": 0.025123266968876123, "query_count": 1}
{"timestamp": "2026-07-28T01:38:32.706519+00:00", "code_ref": "e80ede4cb", "stage": "stage1", "scenario": "get_objects_for_user_owner_aware_document_baseline", "scale": "medium", "best_seconds": 31.46481539506931, "query_count": 1}
{"timestamp": "2026-07-28T03:53:46.770669+00:00", "code_ref": "d4fa85237", "stage": "stage1", "scenario": "get_objects_for_user_owner_aware_document_baseline", "scale": "medium", "best_seconds": 31.671881378046237, "query_count": 1}
{"timestamp": "2026-07-28T04:02:41.942168+00:00", "code_ref": "d4fa85237", "stage": "stage1", "scenario": "permitted_document_ids_after_stage1", "scale": "medium", "best_seconds": 0.07956207206007093, "query_count": 1}
{"timestamp": "2026-07-28T23:30:28.602501+00:00", "code_ref": "86846255f", "stage": "stage2", "scenario": "per_row_checker_vs_resolved_set_old", "scale": "medium", "best_seconds": 3.4628250940004364, "query_count": 692}
{"timestamp": "2026-07-28T23:30:28.606987+00:00", "code_ref": "86846255f", "stage": "stage2", "scenario": "per_row_checker_vs_resolved_set_new", "scale": "medium", "best_seconds": 0.10436739504802972, "query_count": 1}
-9
View File
@@ -1,9 +0,0 @@
{
"permitted_document_ids": {
"best_seconds": 0.07956207206007093,
"query_count": 1
},
"baseline_best_seconds": 31.671881378046237,
"baseline_query_count": 1,
"speedup_x": 398.07763370131113
}
@@ -1,58 +0,0 @@
Index Scan Backward using documents_document_created_bedd0818 on documents_document v0 (cost=91.31..99.33 rows=1 width=2900) (actual time=68.340..77.517 rows=12024.00 loops=1)
Filter: ((deleted_at IS NULL) AND ((owner_id = 12) OR (owner_id IS NULL) OR (ANY (id = (hashed SubPlan 1).col1))))
Rows Removed by Filter: 7976
Index Searches: 1
Buffers: shared hit=74978
SubPlan 1
-> Unique (cost=91.04..91.05 rows=2 width=4) (actual time=64.698..66.622 rows=9136.00 loops=1)
Buffers: shared hit=73487
-> Sort (cost=91.04..91.05 rows=2 width=4) (actual time=64.697..65.202 rows=9262.00 loops=1)
Sort Key: ((u0.object_pk)::integer)
Sort Method: quicksort Memory: 385kB
Buffers: shared hit=73487
-> Append (cost=0.27..91.03 rows=2 width=4) (actual time=0.026..63.201 rows=9262.00 loops=1)
Buffers: shared hit=73487
-> Nested Loop (cost=0.27..12.79 rows=1 width=4) (actual time=0.026..1.834 rows=237.00 loops=1)
Join Filter: (u0.permission_id = u1.id)
Buffers: shared hit=696
-> Index Only Scan using guardian_userobjectpermi_user_id_permission_id_ob_b0b3d2fc_uniq on guardian_userobjectpermission u0 (cost=0.27..8.29 rows=1 width=520) (actual time=0.012..0.131 rows=237.00 loops=1)
Index Cond: (user_id = 12)
Heap Fetches: 237
Index Searches: 1
Buffers: shared hit=222
-> Seq Scan on auth_permission u1 (cost=0.00..4.49 rows=1 width=4) (actual time=0.006..0.006 rows=1.00 loops=237)
Filter: (((codename)::text = 'view_document'::text) AND (content_type_id = 17))
Rows Removed by Filter: 67
Buffers: shared hit=474
-> Nested Loop (cost=4.73..78.23 rows=1 width=4) (actual time=1.119..60.528 rows=9025.00 loops=1)
Buffers: shared hit=72791
-> Nested Loop (cost=4.59..78.05 rows=1 width=524) (actual time=1.115..50.521 rows=9025.00 loops=1)
Buffers: shared hit=54741
-> Nested Loop (cost=4.44..73.62 rows=24 width=520) (actual time=1.109..13.226 rows=45387.00 loops=1)
Buffers: shared hit=329
-> Seq Scan on auth_permission u4 (cost=0.00..4.49 rows=1 width=4) (actual time=0.008..0.017 rows=1.00 loops=1)
Filter: (((codename)::text = 'view_document'::text) AND (content_type_id = 17))
Rows Removed by Filter: 165
Buffers: shared hit=2
-> Bitmap Heap Scan on guardian_groupobjectpermission u0_1 (cost=4.44..68.93 rows=20 width=524) (actual time=1.100..8.095 rows=45387.00 loops=1)
Recheck Cond: (permission_id = u4.id)
Heap Blocks: exact=290
Buffers: shared hit=327
-> Bitmap Index Scan on guardian_groupobjectpermission_permission_id_36572738 (cost=0.00..4.43 rows=20 width=0) (actual time=1.052..1.053 rows=45387.00 loops=1)
Index Cond: (permission_id = u4.id)
Index Searches: 1
Buffers: shared hit=37
-> Index Only Scan using auth_user_groups_user_id_group_id_94350c0c_uniq on auth_user_groups u2 (cost=0.15..0.18 rows=1 width=4) (actual time=0.001..0.001 rows=0.20 loops=45387)
Index Cond: ((user_id = 12) AND (group_id = u0_1.group_id))
Heap Fetches: 9025
Index Searches: 45387
Buffers: shared hit=54412
-> Index Only Scan using auth_group_pkey on auth_group u1_1 (cost=0.14..0.17 rows=1 width=4) (actual time=0.001..0.001 rows=1.00 loops=9025)
Index Cond: (id = u0_1.group_id)
Heap Fetches: 9025
Index Searches: 9025
Buffers: shared hit=18050
Planning:
Buffers: shared hit=2
Planning Time: 1.089 ms
Execution Time: 78.020 ms
-6
View File
@@ -1,6 +0,0 @@
{
"get_objects_for_user_owner_aware_document": {
"best_seconds": 31.671881378046237,
"query_count": 1
}
}
@@ -1,68 +0,0 @@
Sort (cost=3790.80..3790.82 rows=9 width=2900) (actual time=39120.166..39120.831 rows=12024.00 loops=1)
Sort Key: documents_document.created DESC
Sort Method: quicksort Memory: 3230kB
Buffers: shared hit=8598270
-> Seq Scan on documents_document (cost=2550.72..3790.65 rows=9 width=2900) (actual time=0.009..39114.720 rows=12024.00 loops=1)
Filter: ((deleted_at IS NULL) AND ((owner_id = 2) OR (owner_id IS NULL) OR (ANY (id = (hashed SubPlan 1).col1)) OR (ANY (id = (hashed SubPlan 2).col1))))
Rows Removed by Filter: 7976
Buffers: shared hit=8598270
SubPlan 1
-> Nested Loop Semi Join (cost=0.00..1245.63 rows=1 width=8) (actual time=4.254..969.033 rows=237.00 loops=1)
Join Filter: ((((v0.object_pk)::bigint)::character varying)::text = (((u0.id)::bigint)::character varying)::text)
Rows Removed by Join Filter: 2539276
Buffers: shared hit=214835
-> Nested Loop (cost=0.00..23.30 rows=1 width=516) (actual time=0.031..2.425 rows=237.00 loops=1)
Join Filter: (v0.permission_id = v2.id)
Buffers: shared hit=490
-> Seq Scan on guardian_userobjectpermission v0 (cost=0.00..18.80 rows=1 width=520) (actual time=0.021..0.424 rows=237.00 loops=1)
Filter: (user_id = 2)
Rows Removed by Filter: 2120
Buffers: shared hit=16
-> Seq Scan on auth_permission v2 (cost=0.00..4.49 rows=1 width=4) (actual time=0.006..0.006 rows=1.00 loops=237)
Filter: ((content_type_id = 17) AND ((codename)::text = 'view_document'::text))
Rows Removed by Filter: 67
Buffers: shared hit=474
-> Seq Scan on documents_document u0 (cost=0.00..1221.96 rows=12 width=4) (actual time=0.002..1.917 rows=10715.24 loops=237)
Filter: (deleted_at IS NULL)
Buffers: shared hit=214345
SubPlan 2
-> Nested Loop Semi Join (cost=4.73..1305.09 rows=1 width=8) (actual time=6.729..38127.912 rows=9025.00 loops=1)
Join Filter: ((((v0_1.object_pk)::bigint)::character varying)::text = (((u0_2.id)::bigint)::character varying)::text)
Rows Removed by Join Filter: 98955406
Buffers: shared hit=8382237
-> Nested Loop Semi Join (cost=4.73..82.77 rows=1 width=516) (actual time=1.109..137.661 rows=9025.00 loops=1)
Buffers: shared hit=145515
-> Nested Loop (cost=4.44..73.62 rows=24 width=520) (actual time=1.090..18.002 rows=45387.00 loops=1)
Buffers: shared hit=329
-> Seq Scan on auth_permission v2_1 (cost=0.00..4.49 rows=1 width=4) (actual time=0.011..0.023 rows=1.00 loops=1)
Filter: (((codename)::text = 'view_document'::text) AND (content_type_id = 17))
Rows Removed by Filter: 165
Buffers: shared hit=2
-> Bitmap Heap Scan on guardian_groupobjectpermission v0_1 (cost=4.44..68.93 rows=20 width=524) (actual time=1.077..10.777 rows=45387.00 loops=1)
Recheck Cond: (permission_id = v2_1.id)
Heap Blocks: exact=290
Buffers: shared hit=327
-> Bitmap Index Scan on guardian_groupobjectpermission_permission_id_36572738 (cost=0.00..4.43 rows=20 width=0) (actual time=1.026..1.026 rows=45387.00 loops=1)
Index Cond: (permission_id = v2_1.id)
Index Searches: 1
Buffers: shared hit=37
-> Nested Loop (cost=0.30..0.37 rows=1 width=8) (actual time=0.002..0.002 rows=0.20 loops=45387)
Join Filter: (u0_1.id = u1.group_id)
Buffers: shared hit=145186
-> Index Only Scan using auth_group_pkey on auth_group u0_1 (cost=0.14..0.17 rows=1 width=4) (actual time=0.001..0.001 rows=1.00 loops=45387)
Index Cond: (id = v0_1.group_id)
Heap Fetches: 45387
Index Searches: 45387
Buffers: shared hit=90774
-> Index Only Scan using auth_user_groups_user_id_group_id_94350c0c_uniq on auth_user_groups u1 (cost=0.15..0.18 rows=1 width=4) (actual time=0.001..0.001 rows=0.20 loops=45387)
Index Cond: ((user_id = 2) AND (group_id = v0_1.group_id))
Heap Fetches: 9025
Index Searches: 45387
Buffers: shared hit=54412
-> Seq Scan on documents_document u0_2 (cost=0.00..1221.96 rows=12 width=4) (actual time=0.002..1.956 rows=10965.59 loops=9025)
Filter: (deleted_at IS NULL)
Buffers: shared hit=8236722
Planning:
Buffers: shared hit=3
Planning Time: 1.119 ms
Execution Time: 39121.312 ms
-48
View File
@@ -1,48 +0,0 @@
#!/usr/bin/env bash
# profiling/run_with_postgres.sh
#
# Starts a PostgreSQL 18 container (matching
# docker/compose/docker-compose.postgres.yml's image) if one isn't already
# running, waits for it to accept connections, force-terminates any
# leftover connections from a previous run that crashed or was killed, then
# runs the given command against it with the right PAPERLESS_DB* env vars
# set. The container is intentionally left running afterward -- see
# stop_postgres.sh to tear it down explicitly once you're done profiling
# for the session.
set -euo pipefail
CONTAINER_NAME="pngx-profiling-pg"
HOST_PORT="55432"
if ! docker inspect "$CONTAINER_NAME" >/dev/null 2>&1; then
docker run --rm -d \
--name "$CONTAINER_NAME" \
-e POSTGRES_DB=paperless \
-e POSTGRES_USER=paperless \
-e POSTGRES_PASSWORD=paperless \
-p "${HOST_PORT}:5432" \
docker.io/library/postgres:18 >/dev/null
echo "Started profiling Postgres container '$CONTAINER_NAME' on port $HOST_PORT (left running -- see profiling/stop_postgres.sh)." >&2
fi
until docker exec "$CONTAINER_NAME" pg_isready -U paperless >/dev/null 2>&1; do
sleep 1
done
# Reap any connections left over from a previous run that crashed, was
# killed, or timed out mid-query -- without this, a persistent container
# can accumulate stuck backends that block later test-database
# create/drop operations indefinitely.
docker exec "$CONTAINER_NAME" psql -U paperless -d paperless -v ON_ERROR_STOP=0 -c "
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname IN ('paperless', 'test_paperless')
AND pid <> pg_backend_pid();
" >/dev/null 2>&1 || true
PAPERLESS_DBHOST=localhost \
PAPERLESS_DBNAME=paperless \
PAPERLESS_DBUSER=paperless \
PAPERLESS_DBPASS=paperless \
PAPERLESS_DBPORT="${HOST_PORT}" \
"$@"
-199
View File
@@ -1,199 +0,0 @@
# profiling/seed.py
from __future__ import annotations
import random
from dataclasses import dataclass
from typing import TYPE_CHECKING
from typing import Literal
from django.contrib.auth.models import Group
from django.contrib.auth.models import User
from guardian.shortcuts import assign_perm
from documents.models import Document
from documents.tests.factories import CorrespondentFactory
from documents.tests.factories import DocumentFactory
from documents.tests.factories import DocumentTypeFactory
from documents.tests.factories import StoragePathFactory
from documents.tests.factories import TagFactory
if TYPE_CHECKING:
from documents.models import Correspondent
from documents.models import DocumentType
from documents.models import StoragePath
from documents.models import Tag
ScaleProfile = Literal["medium", "large"]
# Fraction of seeded documents given a real owner; the remainder stays
# owner=None, mirroring a real install's mix of owned and legacy/imported
# unowned documents.
_OWNED_FRACTION = 0.9
class _ScaleCounts:
__slots__ = (
"correspondents",
"document_types",
"documents",
"groups",
"storage_paths",
"tags",
"users",
)
def __init__(
self,
*,
documents: int,
tags: int,
correspondents: int,
document_types: int,
storage_paths: int,
users: int,
groups: int,
) -> None:
self.documents = documents
self.tags = tags
self.correspondents = correspondents
self.document_types = document_types
self.storage_paths = storage_paths
self.users = users
self.groups = groups
_SCALE_PROFILES: dict[ScaleProfile, _ScaleCounts] = {
"medium": _ScaleCounts(
documents=20_000,
tags=100,
correspondents=300,
document_types=50,
storage_paths=20,
users=10,
groups=5,
),
"large": _ScaleCounts(
documents=360_000,
tags=1_000,
correspondents=5_000,
document_types=300,
storage_paths=50,
users=25,
groups=10,
),
}
# Ratios measured from a real install (discussion #13276): 1,414 user-perm
# rows / 27,232 group-perm rows over 12,000 documents.
_USER_PERM_ROWS_PER_DOC = 1_414 / 12_000
_GROUP_PERM_ROWS_PER_DOC = 27_232 / 12_000
def _sample_distinct_pairs(
rng: random.Random,
n_subjects: int,
n_objects: int,
count: int,
) -> set[tuple[int, int]]:
"""
Rejection-sample `count` distinct (subject_index, object_index) pairs.
`count` is always well under `n_subjects * n_objects` for every scale
profile, so this terminates quickly.
"""
if count > n_subjects * n_objects:
msg = (
f"cannot sample {count} distinct pairs from only "
f"{n_subjects * n_objects} possible (subject, object) slots"
)
raise ValueError(msg)
pairs: set[tuple[int, int]] = set()
while len(pairs) < count:
pairs.add((rng.randrange(n_subjects), rng.randrange(n_objects)))
return pairs
@dataclass(frozen=True, slots=True)
class SeededData:
users: tuple[User, ...]
groups: tuple[Group, ...]
documents: tuple[Document, ...]
tags: tuple[Tag, ...]
correspondents: tuple[Correspondent, ...]
document_types: tuple[DocumentType, ...]
storage_paths: tuple[StoragePath, ...]
def seed_permission_dataset(
scale: ScaleProfile = "medium",
*,
seed: int = 1337,
) -> SeededData:
"""
Build a dataset whose document count and guardian-permission-row ratios
mirror real bug reports, so profiling results are representative of
actual large installs rather than an arbitrary small fixture.
"""
counts = _SCALE_PROFILES[scale]
rng = random.Random(seed)
users = tuple(
User.objects.create_user(username=f"profile_user_{i}")
for i in range(counts.users)
)
groups = tuple(
Group.objects.create(name=f"profile_group_{i}") for i in range(counts.groups)
)
for user in users:
user.groups.add(rng.choice(groups))
documents = list(DocumentFactory.create_batch(counts.documents))
# Assign realistic ownership -- without this, every document stays
# owner=None and the "owned or unowned" visibility clause is trivially
# true for every row, so no guardian permission check ever needs to run
# (see the amendment note above this code block).
owned_documents = []
for document in documents:
if rng.random() < _OWNED_FRACTION:
document.owner = rng.choice(users)
owned_documents.append(document)
Document.objects.bulk_update(owned_documents, ["owner"], batch_size=2_000)
documents = tuple(documents)
tags = tuple(TagFactory.create_batch(counts.tags))
correspondents = tuple(CorrespondentFactory.create_batch(counts.correspondents))
document_types = tuple(DocumentTypeFactory.create_batch(counts.document_types))
storage_paths = tuple(StoragePathFactory.create_batch(counts.storage_paths))
n_user_perms = round(counts.documents * _USER_PERM_ROWS_PER_DOC)
n_group_perms = round(counts.documents * _GROUP_PERM_ROWS_PER_DOC)
# Grant permissions only on OWNED documents -- a grant on an already-
# unowned document is a no-op for visibility (unowned documents are
# visible to everyone regardless), so restricting the pool here is what
# makes each assign_perm() call actually matter for someone, and is
# what exercises the guardian-permission-join code path this profiling
# effort exists to measure.
for user_idx, owned_idx in _sample_distinct_pairs(
rng,
len(users),
len(owned_documents),
n_user_perms,
):
assign_perm("view_document", users[user_idx], owned_documents[owned_idx])
for group_idx, owned_idx in _sample_distinct_pairs(
rng,
len(groups),
len(owned_documents),
n_group_perms,
):
assign_perm("view_document", groups[group_idx], owned_documents[owned_idx])
return SeededData(
users=users,
groups=groups,
documents=documents,
tags=tags,
correspondents=correspondents,
document_types=document_types,
storage_paths=storage_paths,
)
-24
View File
@@ -1,24 +0,0 @@
#!/usr/bin/env bash
# profiling/stop_postgres.sh
#
# Explicit, manual teardown of the profiling Postgres container. Not called
# automatically by run_with_postgres.sh -- run this yourself once you're
# done with a profiling session, so a seeded `large`-scale dataset can be
# reused across several script runs first. Terminates any active backends
# before stopping, so a stuck query can't stall `docker stop` waiting for
# a graceful Postgres shutdown that never comes.
set -euo pipefail
CONTAINER_NAME="pngx-profiling-pg"
if docker inspect "$CONTAINER_NAME" >/dev/null 2>&1; then
docker exec "$CONTAINER_NAME" psql -U paperless -d paperless -v ON_ERROR_STOP=0 -c "
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE pid <> pg_backend_pid();
" >/dev/null 2>&1 || true
docker stop "$CONTAINER_NAME" >/dev/null
echo "Stopped and removed profiling Postgres container."
else
echo "No profiling Postgres container was running."
fi
-74
View File
@@ -1,74 +0,0 @@
# profiling/test_harness_self_check.py
from __future__ import annotations
import json
from pathlib import Path
import pytest
from profiling.harness import append_profiling_history
from profiling.harness import require_postgres
from profiling.harness import run_profile
from profiling.seed import seed_permission_dataset
@pytest.mark.profiling
@pytest.mark.django_db
def test_seed_medium_scale_produces_expected_counts() -> None:
require_postgres()
data = seed_permission_dataset(scale="medium")
assert len(data.documents) == 20_000
assert len(data.tags) == 100
assert len(data.correspondents) == 300
assert len(data.document_types) == 50
assert len(data.storage_paths) == 20
from guardian.models import GroupObjectPermission
from guardian.models import UserObjectPermission
user_perm_count = UserObjectPermission.objects.count()
group_perm_count = GroupObjectPermission.objects.count()
# within 5% of the ratio-derived target -- exact counts depend on random
# sampling without replacement, see seed.py
assert 2_242 <= user_perm_count <= 2_478
assert 43_111 <= group_perm_count <= 47_649
@pytest.mark.profiling
@pytest.mark.django_db
def test_run_profile_reports_query_count_and_timing() -> None:
require_postgres()
def trivial() -> list[int]:
from documents.models import Document
return list(Document.objects.values_list("id", flat=True)[:1])
result = run_profile(trivial, repeat=3)
assert result.best_seconds >= 0
assert len(result.all_seconds) == 3
assert result.query_count >= 1
def test_append_profiling_history_writes_a_valid_json_line() -> None:
history_path = Path(__file__).parent / "results" / "history.jsonl"
before_lines = (
history_path.read_text().splitlines() if history_path.exists() else []
)
append_profiling_history(
stage="self-check",
scenario="test_append_profiling_history_writes_a_valid_json_line",
best_seconds=0.001,
query_count=0,
scale="medium",
code_ref="test-run",
)
after_lines = history_path.read_text().splitlines()
assert len(after_lines) == len(before_lines) + 1
entry = json.loads(after_lines[-1])
assert entry["stage"] == "self-check"
assert entry["code_ref"] == "test-run"
assert entry["scale"] == "medium"
assert "timestamp" in entry
-125
View File
@@ -1,125 +0,0 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from documents.models import Document
from documents.permissions import get_objects_for_user_owner_aware
from documents.permissions import permitted_document_ids
from profiling.harness import append_profiling_history
from profiling.harness import capture_explain_analyze
from profiling.harness import require_postgres
from profiling.harness import run_profile
from profiling.seed import seed_permission_dataset
RESULTS_DIR = Path(__file__).parent / "results"
@pytest.mark.profiling
@pytest.mark.django_db
def test_profile_get_objects_for_user_owner_aware_document_baseline() -> None:
require_postgres()
data = seed_permission_dataset(scale="medium")
user = data.users[0]
def call() -> list[int]:
return list(
get_objects_for_user_owner_aware(
user,
"documents.view_document",
Document,
).values_list("id", flat=True),
)
profile = run_profile(call, repeat=3)
plan = capture_explain_analyze(
get_objects_for_user_owner_aware(user, "documents.view_document", Document),
)
RESULTS_DIR.mkdir(exist_ok=True)
(RESULTS_DIR / "stage1_baseline.json").write_text(
json.dumps(
{
"get_objects_for_user_owner_aware_document": {
"best_seconds": profile.best_seconds,
"query_count": profile.query_count,
},
},
indent=2,
),
)
(RESULTS_DIR / "stage1_baseline_explain.txt").write_text(plan)
append_profiling_history(
stage="stage1",
scenario="get_objects_for_user_owner_aware_document_baseline",
best_seconds=profile.best_seconds,
query_count=profile.query_count,
scale="medium",
)
print(f"\nBASELINE best={profile.best_seconds:.4f}s queries={profile.query_count}") # noqa: T201
print(plan) # noqa: T201
@pytest.mark.profiling
@pytest.mark.django_db
def test_profile_permitted_document_ids_after_stage1() -> None:
require_postgres()
data = seed_permission_dataset(scale="medium")
user = data.users[0]
def call():
return list(
Document.objects.filter(id__in=permitted_document_ids(user)).values_list(
"id",
flat=True,
),
)
profile = run_profile(call, repeat=3)
plan = capture_explain_analyze(
Document.objects.filter(id__in=permitted_document_ids(user)),
)
baseline = json.loads((RESULTS_DIR / "stage1_baseline.json").read_text())
baseline_seconds = baseline["get_objects_for_user_owner_aware_document"][
"best_seconds"
]
baseline_queries = baseline["get_objects_for_user_owner_aware_document"][
"query_count"
]
(RESULTS_DIR / "stage1_after.json").write_text(
json.dumps(
{
"permitted_document_ids": {
"best_seconds": profile.best_seconds,
"query_count": profile.query_count,
},
"baseline_best_seconds": baseline_seconds,
"baseline_query_count": baseline_queries,
"speedup_x": baseline_seconds / profile.best_seconds
if profile.best_seconds
else None,
},
indent=2,
),
)
(RESULTS_DIR / "stage1_after_explain.txt").write_text(plan)
append_profiling_history(
stage="stage1",
scenario="permitted_document_ids_after_stage1",
best_seconds=profile.best_seconds,
query_count=profile.query_count,
scale="medium",
)
print(f"\nBEFORE best={baseline_seconds:.4f}s queries={baseline_queries}") # noqa: T201
print(f"AFTER best={profile.best_seconds:.4f}s queries={profile.query_count}") # noqa: T201
# Hard gate: must not be a regression, and should be meaningfully faster
# at this scale (the whole point of this stage).
assert profile.best_seconds < baseline_seconds
assert profile.query_count <= baseline_queries
-64
View File
@@ -1,64 +0,0 @@
# profiling/test_stage2_document_loops.py
from __future__ import annotations
import pytest
from documents.permissions import has_perms_owner_aware
from documents.permissions import permitted_document_ids
from profiling.harness import append_profiling_history
from profiling.harness import require_postgres
from profiling.harness import run_profile
from profiling.seed import seed_permission_dataset
@pytest.mark.profiling
@pytest.mark.django_db
def test_profile_per_row_checker_vs_resolved_set_for_bulk_operation() -> None:
require_postgres()
data = seed_permission_dataset(scale="medium")
user = data.users[0]
# simulate a bulk operation over 500 documents the user can actually see
# (bulk-edit/share/trash all operate on a user's own selection) -- built
# from confirmed-visible documents so all() can't short-circuit unfairly
# in either old_style or new_style, see amendment note above
permitted_ids = set(permitted_document_ids(user))
batch = [doc for doc in data.documents if doc.pk in permitted_ids][:500]
assert len(batch) == 500, (
f"expected at least 500 visible documents to build a fair batch, "
f"got {len(batch)} -- scale profile or seed ratios may need adjusting"
)
def old_style():
return all(has_perms_owner_aware(user, "view_document", doc) for doc in batch)
def new_style():
permitted = set(permitted_document_ids(user))
return all(doc.pk in permitted for doc in batch)
old_profile = run_profile(old_style, repeat=3)
new_profile = run_profile(new_style, repeat=3)
append_profiling_history(
stage="stage2",
scenario="per_row_checker_vs_resolved_set_old",
best_seconds=old_profile.best_seconds,
query_count=old_profile.query_count,
scale="medium",
)
append_profiling_history(
stage="stage2",
scenario="per_row_checker_vs_resolved_set_new",
best_seconds=new_profile.best_seconds,
query_count=new_profile.query_count,
scale="medium",
)
print( # noqa: T201
f"\nOLD (per-row checker): best={old_profile.best_seconds:.4f}s queries={old_profile.query_count}",
)
print( # noqa: T201
f"NEW (resolved set): best={new_profile.best_seconds:.4f}s queries={new_profile.query_count}",
)
assert new_profile.query_count < old_profile.query_count
assert new_profile.best_seconds < old_profile.best_seconds
+6 -6
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "paperless-ngx" name = "paperless-ngx"
version = "3.0.2" version = "3.0.0"
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents" description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
@@ -16,7 +16,7 @@ classifiers = [
dependencies = [ dependencies = [
"azure-ai-documentintelligence>=1.0.2", "azure-ai-documentintelligence>=1.0.2",
"babel>=2.17", "babel>=2.17",
"bleach~=6.4.0", "bleach~=6.3.0",
"celery[redis]~=5.6.2", "celery[redis]~=5.6.2",
"channels~=4.2", "channels~=4.2",
"channels-redis~=4.2", "channels-redis~=4.2",
@@ -56,7 +56,7 @@ dependencies = [
"llama-index-embeddings-openai-like>=0.2.2", "llama-index-embeddings-openai-like>=0.2.2",
"llama-index-llms-ollama>=0.9.1", "llama-index-llms-ollama>=0.9.1",
"llama-index-llms-openai-like>=0.7.1", "llama-index-llms-openai-like>=0.7.1",
"nltk~=3.10.0", "nltk~=3.9.1",
"ocrmypdf~=17.4.2", "ocrmypdf~=17.4.2",
"openai>=2.32", "openai>=2.32",
"pathvalidate~=3.3.1", "pathvalidate~=3.3.1",
@@ -75,7 +75,7 @@ dependencies = [
"sqlite-vec==0.1.9", "sqlite-vec==0.1.9",
"tantivy~=0.26.0", "tantivy~=0.26.0",
"tika-client~=0.11.0", "tika-client~=0.11.0",
"torch~=2.13.0", "torch~=2.12.0",
"watchfiles>=1.1.1", "watchfiles>=1.1.1",
"whitenoise~=6.11", "whitenoise~=6.11",
"zxing-cpp~=3.0.0", "zxing-cpp~=3.0.0",
@@ -101,11 +101,11 @@ dev = [
{ include-group = "testing" }, { include-group = "testing" },
] ]
docs = [ docs = [
"zensical>=0.0.47", "zensical>=0.0.43",
] ]
lint = [ lint = [
"prek~=0.3.10", "prek~=0.3.10",
"ruff~=0.15.20", "ruff~=0.15.15",
] ]
testing = [ testing = [
"daphne", "daphne",
-10
View File
@@ -79,16 +79,6 @@
"glob": "{pdf.worker.min.mjs,pdf.min.mjs}", "glob": "{pdf.worker.min.mjs,pdf.min.mjs}",
"input": "node_modules/pdfjs-dist/legacy/build/", "input": "node_modules/pdfjs-dist/legacy/build/",
"output": "/assets/js/" "output": "/assets/js/"
},
{
"glob": "**/*",
"input": "node_modules/pdfjs-dist/wasm/",
"output": "/assets/wasm/"
},
{
"glob": "**/*",
"input": "node_modules/pdfjs-dist/iccs/",
"output": "/assets/iccs/"
} }
], ],
"styles": [ "styles": [
@@ -1,4 +1,4 @@
import { expect, test, type WebSocketRoute } from '@playwright/test' import { expect, test } from '@playwright/test'
import path from 'node:path' import path from 'node:path'
const REQUESTS_HAR = path.join(__dirname, 'requests/api-document-detail.har') const REQUESTS_HAR = path.join(__dirname, 'requests/api-document-detail.har')
@@ -95,60 +95,3 @@ test('should support quick filters', async ({ page }) => {
.click() .click()
await expect(page).toHaveURL(/tags__id__all=4&sort=created&reverse=1&page=1/) await expect(page).toHaveURL(/tags__id__all=4&sort=created&reverse=1&page=1/)
}) })
test('should finish reloading the preview after a remote document update', async ({
page,
}) => {
let resolveStatusSocket: (socket: WebSocketRoute) => void
const statusSocketReady = new Promise<WebSocketRoute>((resolve) => {
resolveStatusSocket = resolve
})
await page.routeWebSocket(/\/ws\/status\/$/, (socket) => {
resolveStatusSocket(socket)
})
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
let previewRequestCount = 0
page.on('request', (request) => {
if (request.url().includes('/api/documents/175/preview/')) {
previewRequestCount++
}
})
await page.goto('/documents/175/details')
await page.locator('pngx-document-detail').waitFor()
await expect(page.getByTitle('Storage path', { exact: true })).toHaveText(
/\w+/
)
const previewWasLoaded = await page.evaluate(() => {
const detail = document.querySelector('pngx-document-detail')
const component = (window as any).ng.getComponent(detail)
component.pdfPreviewLoaded({ numPages: 1 })
return component.previewLoaded()
})
expect(previewWasLoaded).toBe(true)
const previewRequestsBeforeReload = previewRequestCount
const statusSocket = await statusSocketReady
const documentReloaded = page.waitForResponse(
(response) =>
response.url().includes('/api/documents/175/?full_perms=true') &&
response.request().method() === 'GET'
)
statusSocket.send(
JSON.stringify({
type: 'document_updated',
data: {
document_id: 175,
modified: '2026-07-26T20:00:00Z',
},
})
)
await documentReloaded
await expect(
page.getByText('Document reloaded with latest changes.').first()
).toBeVisible()
await expect
.poll(() => previewRequestCount)
.toBeGreaterThan(previewRequestsBeforeReload + 1)
})
+383 -439
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "paperless-ngx-ui", "name": "paperless-ngx-ui",
"version": "3.0.2", "version": "3.0.0",
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm", "preinstall": "npx only-allow pnpm",
"ng": "ng", "ng": "ng",
@@ -18,6 +18,7 @@
"@angular/forms": "~22.0.5", "@angular/forms": "~22.0.5",
"@angular/localize": "~22.0.5", "@angular/localize": "~22.0.5",
"@angular/platform-browser": "~22.0.5", "@angular/platform-browser": "~22.0.5",
"@angular/platform-browser-dynamic": "~22.0.5",
"@angular/router": "~22.0.5", "@angular/router": "~22.0.5",
"@ng-bootstrap/ng-bootstrap": "^21.0.0", "@ng-bootstrap/ng-bootstrap": "^21.0.0",
"@ng-select/ng-select": "^23.2.0", "@ng-select/ng-select": "^23.2.0",
+1846 -1297
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -2,9 +2,6 @@ packages:
- "." - "."
minimumReleaseAge: 10080 minimumReleaseAge: 10080
trustPolicy: no-downgrade trustPolicy: no-downgrade
trustPolicyExclude:
- "chokidar@4.0.3"
- "semver@6.3.1 || 5.7.2"
allowBuilds: allowBuilds:
"@parcel/watcher": true "@parcel/watcher": true
canvas: true canvas: true
+38
View File
@@ -13,6 +13,8 @@ import { DocumentDetailComponent } from './components/document-detail/document-d
import { DocumentListComponent } from './components/document-list/document-list.component' import { DocumentListComponent } from './components/document-list/document-list.component'
import { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.component' import { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.component'
import { MailComponent } from './components/manage/mail/mail.component' import { MailComponent } from './components/manage/mail/mail.component'
import { OcrTemplateEditorComponent } from './components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component'
import { OcrTemplatesComponent } from './components/manage/ocr-templates/ocr-templates.component'
import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component' import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component'
import { WorkflowsComponent } from './components/manage/workflows/workflows.component' import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
import { NotFoundComponent } from './components/not-found/not-found.component' import { NotFoundComponent } from './components/not-found/not-found.component'
@@ -274,6 +276,42 @@ export const routes: Routes = [
componentName: 'WorkflowsComponent', componentName: 'WorkflowsComponent',
}, },
}, },
{
path: 'ocr-templates',
component: OcrTemplatesComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.OcrTemplate,
},
componentName: 'OcrTemplatesComponent',
},
},
{
path: 'ocr-templates/new',
component: OcrTemplateEditorComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.Add,
type: PermissionType.OcrTemplate,
},
componentName: 'OcrTemplateEditorComponent',
},
},
{
path: 'ocr-templates/:id',
component: OcrTemplateEditorComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.Change,
type: PermissionType.OcrTemplate,
},
componentName: 'OcrTemplateEditorComponent',
},
},
{ {
path: 'mail', path: 'mail',
component: MailComponent, component: MailComponent,
@@ -5,9 +5,8 @@
i18n-info> i18n-info>
<div class="input-group input-group-sm align-items-center"> <div class="input-group input-group-sm align-items-center">
<div class="input-group input-group-sm me-3"> <div class="input-group input-group-sm me-3">
<label class="input-group-text text-muted" for="logLineLimit" i18n>Show</label> <span class="input-group-text text-muted" i18n>Show</span>
<input <input
id="logLineLimit"
class="form-control" class="form-control"
type="number" type="number"
min="100" min="100"
@@ -18,7 +17,7 @@
<span class="input-group-text text-muted" i18n>lines</span> <span class="input-group-text text-muted" i18n>lines</span>
</div> </div>
<div class="form-check form-switch mt-1"> <div class="form-check form-switch mt-1">
<input id="autoRefreshSwitch" class="form-check-input" type="checkbox" role="switch" [ngModel]="autoRefreshEnabled()" (ngModelChange)="autoRefreshEnabled.set($event)"> <input class="form-check-input" type="checkbox" role="switch" [ngModel]="autoRefreshEnabled()" (ngModelChange)="autoRefreshEnabled.set($event)">
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label> <label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
</div> </div>
</div> </div>
@@ -100,7 +100,9 @@ export class LogsComponent
parsed.some((log, idx) => { parsed.some((log, idx) => {
const current = this.logs()[idx] const current = this.logs()[idx]
return ( return (
current?.message !== log.message || current?.level !== log.level !current ||
current.message !== log.message ||
current.level !== log.level
) )
}) })
if (hasChanges) { if (hasChanges) {
@@ -37,7 +37,7 @@
<form [formGroup]="settingsForm" (ngSubmit)="saveSettings()"> <form [formGroup]="settingsForm" (ngSubmit)="saveSettings()">
<ul ngbNav #nav="ngbNav" (navChange)="onNavChange($event)" [activeId]="activeNavID()" (activeIdChange)="activeNavID.set($event)" class="nav-tabs"> <ul ngbNav #nav="ngbNav" (navChange)="onNavChange($event)" [(activeId)]="activeNavID" class="nav-tabs">
<li [ngbNavItem]="SettingsNavIDs.General"> <li [ngbNavItem]="SettingsNavIDs.General">
<a ngbNavLink i18n>General</a> <a ngbNavLink i18n>General</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
@@ -150,10 +150,10 @@
<div class="row mb-3"> <div class="row mb-3">
<div class="col-md-3 col-form-label pt-0"> <div class="col-md-3 col-form-label pt-0">
<label for="searchLink" i18n>Full search links to</label> <span i18n>Full search links to</span>
</div> </div>
<div class="col mb-3"> <div class="col mb-3">
<select id="searchLink" class="form-select" formControlName="searchLink"> <select class="form-select" formControlName="searchLink">
<option [ngValue]="GlobalSearchType.TITLE_CONTENT" i18n>Title and content search</option> <option [ngValue]="GlobalSearchType.TITLE_CONTENT" i18n>Title and content search</option>
<option [ngValue]="GlobalSearchType.ADVANCED" i18n>Advanced search</option> <option [ngValue]="GlobalSearchType.ADVANCED" i18n>Advanced search</option>
</select> </select>
@@ -199,10 +199,10 @@
<h5 i18n>Documents</h5> <h5 i18n>Documents</h5>
<div class="row mb-3"> <div class="row mb-3">
<div class="col-md-3 col-form-label pt-0"> <div class="col-md-3 col-form-label pt-0">
<label for="documentListItemPerPage" i18n>Items per page</label> <span i18n>Items per page</span>
</div> </div>
<div class="col"> <div class="col">
<select id="documentListItemPerPage" class="form-select" formControlName="documentListItemPerPage"> <select class="form-select" formControlName="documentListItemPerPage">
<option [ngValue]="10">10</option> <option [ngValue]="10">10</option>
<option [ngValue]="25">25</option> <option [ngValue]="25">25</option>
<option [ngValue]="50">50</option> <option [ngValue]="50">50</option>
@@ -274,10 +274,10 @@
<h5 class="mt-3" i18n>PDF Editor</h5> <h5 class="mt-3" i18n>PDF Editor</h5>
<div class="row"> <div class="row">
<div class="col-md-3 col-form-label pt-0"> <div class="col-md-3 col-form-label pt-0">
<label for="pdfEditorDefaultEditMode" i18n>Default editing mode</label> <span i18n>Default editing mode</span>
</div> </div>
<div class="col"> <div class="col">
<select id="pdfEditorDefaultEditMode" class="form-select" formControlName="pdfEditorDefaultEditMode"> <select class="form-select" formControlName="pdfEditorDefaultEditMode">
<option [ngValue]="PdfEditorEditMode.Create" i18n>Create new document(s)</option> <option [ngValue]="PdfEditorEditMode.Create" i18n>Create new document(s)</option>
<option [ngValue]="PdfEditorEditMode.Update" i18n>Add document version</option> <option [ngValue]="PdfEditorEditMode.Update" i18n>Add document version</option>
</select> </select>
@@ -304,7 +304,7 @@
<div class="row mb-3"> <div class="row mb-3">
<div class="col"> <div class="col">
<p i18n> <p i18n>
Settings apply to this user account for objects (Tags, Mail Rules, etc.) created via the web UI. These settings do not apply to documents. Settings apply to this user account for objects (Tags, Mail Rules, etc. but not documents) created via the web UI.
</p> </p>
</div> </div>
</div> </div>
@@ -242,7 +242,7 @@ describe('SettingsComponent', () => {
activatedRoute.snapshot.fragment = '#notifications' activatedRoute.snapshot.fragment = '#notifications'
const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor') const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor')
component.ngOnInit() component.ngOnInit()
expect(component.activeNavID()).toEqual(4) // Notifications expect(component.activeNavID).toEqual(4) // Notifications
component.ngAfterViewInit() component.ngAfterViewInit()
expect(scrollSpy).toHaveBeenCalledWith('#notifications') expect(scrollSpy).toHaveBeenCalledWith('#notifications')
}) })
@@ -399,17 +399,17 @@ describe('SettingsComponent', () => {
completeSetup() completeSetup()
const field = 'storage_path' const field = 'storage_path'
expect( expect(
component.settingsForm.get('documentDetailsHiddenFields').value component.settingsForm.get('documentDetailsHiddenFields').value.length
).toHaveLength(0) ).toEqual(0)
component.toggleDocumentDetailField(field, false) component.toggleDocumentDetailField(field, false)
expect( expect(
component.settingsForm.get('documentDetailsHiddenFields').value component.settingsForm.get('documentDetailsHiddenFields').value.length
).toHaveLength(1) ).toEqual(1)
expect(component.isDocumentDetailFieldShown(field)).toBeFalsy() expect(component.isDocumentDetailFieldShown(field)).toBeFalsy()
component.toggleDocumentDetailField(field, true) component.toggleDocumentDetailField(field, true)
expect( expect(
component.settingsForm.get('documentDetailsHiddenFields').value component.settingsForm.get('documentDetailsHiddenFields').value.length
).toHaveLength(0) ).toEqual(0)
expect(component.isDocumentDetailFieldShown(field)).toBeTruthy() expect(component.isDocumentDetailFieldShown(field)).toBeTruthy()
}) })
}) })
@@ -142,7 +142,7 @@ export class SettingsComponent
private systemStatusService = inject(SystemStatusService) private systemStatusService = inject(SystemStatusService)
private savedViewsService = inject(SavedViewService) private savedViewsService = inject(SavedViewService)
readonly activeNavID = signal<number>(undefined) activeNavID: number
settingsForm = new FormGroup({ settingsForm = new FormGroup({
bulkEditConfirmationDialogs: new FormControl(null), bulkEditConfirmationDialogs: new FormControl(null),
@@ -283,7 +283,7 @@ export class SettingsComponent
(navID) => navID.toLowerCase() == section (navID) => navID.toLowerCase() == section
) )
if (navIDKey) { if (navIDKey) {
this.activeNavID.set(SettingsNavIDs[navIDKey]) this.activeNavID = SettingsNavIDs[navIDKey]
} }
} }
}) })
@@ -386,7 +386,7 @@ export class SettingsComponent
.navigate(['settings', foundNavIDkey.toLowerCase()]) .navigate(['settings', foundNavIDkey.toLowerCase()])
.then((navigated) => { .then((navigated) => {
if (!navigated && this.isDirty) { if (!navigated && this.isDirty) {
this.activeNavID.set(navChangeEvent.activeId) this.activeNavID = navChangeEvent.activeId
} else if (navigated && this.isDirty) { } else if (navigated && this.isDirty) {
this.initialize() this.initialize()
} }
@@ -15,7 +15,7 @@
<i-bs name="check2-all" class="me-1"></i-bs><ng-container i18n>Dismiss all</ng-container> <i-bs name="check2-all" class="me-1"></i-bs><ng-container i18n>Dismiss all</ng-container>
</button> </button>
<div class="form-check form-switch mb-0 ms-2"> <div class="form-check form-switch mb-0 ms-2">
<input id="autoRefreshSwitch" class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled"> <input class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled">
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label> <label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
</div> </div>
</div> </div>
@@ -93,8 +93,7 @@
<i-bs width="1em" height="1em" name="x"></i-bs> <i-bs width="1em" height="1em" name="x"></i-bs>
</button> </button>
} }
<label class="visually-hidden" for="taskSearch" i18n>Search tasks</label> <input #filterInput class="form-control form-control-sm" type="text"
<input #filterInput id="taskSearch" class="form-control form-control-sm" type="text"
(keyup)="filterInputKeyup($event)" (keyup)="filterInputKeyup($event)"
[(ngModel)]="filterText"> [(ngModel)]="filterText">
</div> </div>
@@ -212,7 +211,7 @@
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs> <i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
</button> </button>
</td> </td>
<td class="actions-col"> <td scope="row" class="actions-col">
<div class="btn-group" role="group"> <div class="btn-group" role="group">
<button class="btn btn-sm btn-outline-secondary" (click)="dismissTask(task); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }"> <button class="btn btn-sm btn-outline-secondary" (click)="dismissTask(task); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }">
<i-bs name="check" class="me-1"></i-bs><ng-container i18n>Dismiss</ng-container> <i-bs name="check" class="me-1"></i-bs><ng-container i18n>Dismiss</ng-container>
@@ -150,8 +150,7 @@
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
popoverClass="popover-slim"> popoverClass="popover-slim">
<i-bs class="me-2" name="file-text"></i-bs><span>{{d.title | documentTitle}}</span> <i-bs class="me-2" name="file-text"></i-bs><span>{{d.title | documentTitle}}</span>
<span class="close flex-column justify-content-center" <span class="close flex-column justify-content-center" (click)="closeDocument(d); $event.preventDefault()">
(click)="$event.preventDefault(); $event.stopPropagation(); closeDocument(d)">
<i-bs name="x"></i-bs> <i-bs name="x"></i-bs>
</span> </span>
</a> </a>
@@ -244,6 +243,14 @@
<i-bs class="me-2" name="boxes"></i-bs><span><ng-container i18n>Workflows</ng-container></span> <i-bs class="me-2" name="boxes"></i-bs><span><ng-container i18n>Workflows</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.OcrTemplate }">
<a class="nav-link" routerLink="ocr-templates" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="OCR Templates" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="file-earmark-break"></i-bs><span><ng-container i18n>OCR Templates</ng-container></span>
</a>
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }" <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
tourAnchor="tour.mail"> tourAnchor="tour.mail">
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail" <a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
@@ -280,52 +280,10 @@ main {
} }
} }
@media screen and (max-width: 575.98px) { @media screen and (min-width: 376px) and (max-width: 768px) {
.navbar {
display: grid;
grid-template-columns: 1fr auto 1fr;
grid-template-areas:
"toggler brand actions"
"search search search";
}
.navbar-toggler { .navbar-toggler {
grid-area: toggler; // compensate for 2 buttons on the right
text-align: left; margin-right: 45px;
}
.navbar-brand {
grid-area: brand;
min-width: 0;
max-width: 100%;
justify-self: center;
overflow: hidden;
> div,
.custom-title {
min-width: 0;
max-width: 100%;
overflow: hidden;
}
.title,
.byline {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.search-container {
grid-area: search;
width: 100%;
}
.navbar > ul {
grid-area: actions;
justify-self: end;
flex-wrap: nowrap;
} }
} }
@@ -335,6 +293,13 @@ main {
} }
} }
@media screen and (max-width: 345px) {
.custom-title {
max-width: 110px;
overflow: hidden;
}
}
:host ::ng-deep .dropdown.show .dropdown-toggle, :host ::ng-deep .dropdown.show .dropdown-toggle,
:host ::ng-deep .dropdown-toggle:hover { :host ::ng-deep .dropdown-toggle:hover {
opacity: 0.7; opacity: 0.7;
@@ -6,17 +6,17 @@
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown"> <div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
<div class="chat-container bg-light p-2"> <div class="chat-container bg-light p-2">
<div class="chat-messages font-monospace small"> <div class="chat-messages font-monospace small">
@for (message of messages(); track message) { @for (message of messages; track message) {
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'"> <div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'">
<div class="p-2 m-2" [class.bg-body]="message.role === 'user'"> <div class="p-2 m-2" [class.bg-body]="message.role === 'user'">
<span class="text-break"> <span>
{{ message.content }} {{ message.content }}
@if (message.isStreaming) { <span class="blinking-cursor">|</span> } @if (message.isStreaming) { <span class="blinking-cursor">|</span> }
</span> </span>
@if (message.role === 'assistant' && message.references?.length) { @if (message.role === 'assistant' && message.references?.length) {
<div class="chat-references list-group mt-3"> <div class="chat-references list-group mt-3">
@for (reference of message.references; track reference.id) { @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]"> <a class="list-group-item list-group-item-action text-primary" [routerLink]="['/documents', reference.id]">
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-1"></i-bs><span>{{ reference.title }}</span> <i-bs width="0.9em" height="0.9em" name="file-text" class="me-1"></i-bs><span>{{ reference.title }}</span>
</a> </a>
} }
@@ -29,19 +29,16 @@
</div> </div>
<form class="chat-input"> <form class="chat-input">
<label class="visually-hidden" for="chatInput" i18n>Message</label>
<div class="input-group"> <div class="input-group">
<input <input
#chatInput #chatInput
id="chatInput"
class="form-control form-control-sm" name="chatInput" type="text" class="form-control form-control-sm" name="chatInput" type="text"
[placeholder]="placeholder" [placeholder]="placeholder"
[disabled]="loading()" [disabled]="loading"
[ngModel]="input()" [(ngModel)]="input"
(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> <button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading">Send</button>
</div> </div>
</form> </form>
</div> </div>
@@ -5,7 +5,6 @@
.chat-messages { .chat-messages {
max-height: 350px; max-height: 350px;
overflow-y: auto; overflow-y: auto;
white-space: pre-wrap;
} }
.chat-references { .chat-references {
@@ -56,53 +56,44 @@ describe('ChatComponent', () => {
it('should update documentId on initialization', () => { it('should update documentId on initialization', () => {
jest.spyOn(router, 'url', 'get').mockReturnValue('/documents/123') jest.spyOn(router, 'url', 'get').mockReturnValue('/documents/123')
component.ngOnInit() component.ngOnInit()
expect(component.documentId()).toBe(123) expect(component.documentId).toBe(123)
}) })
it('should update documentId on navigation', () => { it('should update documentId on navigation', () => {
component.ngOnInit() component.ngOnInit()
routerEvents$.next(new NavigationEnd(1, '/documents/456', '/documents/456')) routerEvents$.next(new NavigationEnd(1, '/documents/456', '/documents/456'))
expect(component.documentId()).toBe(456) expect(component.documentId).toBe(456)
}) })
it('should return correct placeholder based on documentId', () => { it('should return correct placeholder based on documentId', () => {
component.documentId.set(123) component.documentId = 123
expect(component.placeholder).toBe('Ask a question about this document...') expect(component.placeholder).toBe('Ask a question about this document...')
component.documentId.set(undefined) component.documentId = undefined
expect(component.placeholder).toBe('Ask a question about a document...') expect(component.placeholder).toBe('Ask a question about a document...')
}) })
it('should send a message and render the streaming response', async () => { it('should send a message and handle streaming response', () => {
component.input.set('Hello') component.input = 'Hello'
component.sendMessage() component.sendMessage()
expect(component.messages()).toHaveLength(2) expect(component.messages.length).toBe(2)
expect(component.messages()[0].content).toBe('Hello') expect(component.messages[0].content).toBe('Hello')
expect(component.loading()).toBe(true) expect(component.loading).toBe(true)
mockStream$.next('Hi') mockStream$.next('Hi')
expect(component.messages()[1].content).toBe('H') expect(component.messages[1].content).toBe('H')
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() jest.advanceTimersByTime(1000)
await fixture.whenStable() expect(component.messages[1].content).toBe('Hi there')
expect(component.messages()[1].content).toBe('Hi there')
expect(
fixture.nativeElement.querySelector('.chat-messages').textContent
).toContain('Hi there')
mockStream$.complete() mockStream$.complete()
await jest.runAllTimersAsync() expect(component.loading).toBe(false)
await fixture.whenStable() expect(component.messages[1].isStreaming).toBe(false)
expect(component.loading()).toBe(false)
expect(component.messages()[1].isStreaming).toBe(false)
expect(fixture.nativeElement.querySelector('#chatInput').disabled).toBe(
false
)
}) })
it('should parse references from the metadata trailer without showing it', () => { it('should parse references from the metadata trailer without showing it', () => {
component.input.set('Hello') component.input = 'Hello'
component.sendMessage() component.sendMessage()
mockStream$.next( mockStream$.next(
@@ -110,14 +101,14 @@ describe('ChatComponent', () => {
) )
jest.advanceTimersByTime(1000) jest.advanceTimersByTime(1000)
expect(component.messages()[1].content).toBe('Hi there') expect(component.messages[1].content).toBe('Hi there')
expect(component.messages()[1].references).toEqual([ expect(component.messages[1].references).toEqual([
{ id: 42, title: 'Bread Recipe' }, { id: 42, title: 'Bread Recipe' },
]) ])
}) })
it('should render document reference links under assistant messages', () => { it('should render document reference links under assistant messages', () => {
component.input.set('Hello') component.input = 'Hello'
component.sendMessage() component.sendMessage()
mockStream$.next( mockStream$.next(
@@ -132,12 +123,12 @@ describe('ChatComponent', () => {
}) })
it('should remove delimiter fragments that were already streamed', () => { it('should remove delimiter fragments that were already streamed', () => {
component.input.set('Hello') component.input = 'Hello'
component.sendMessage() component.sendMessage()
mockStream$.next(`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`) mockStream$.next(`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`)
jest.advanceTimersByTime(1000) jest.advanceTimersByTime(1000)
expect(component.messages()[1].content).toBe( expect(component.messages[1].content).toBe(
`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}` `Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`
) )
@@ -146,28 +137,28 @@ describe('ChatComponent', () => {
) )
jest.advanceTimersByTime(1000) jest.advanceTimersByTime(1000)
expect(component.messages()[1].content).toBe('Hi there') expect(component.messages[1].content).toBe('Hi there')
expect(component.messages()[1].references).toEqual([ expect(component.messages[1].references).toEqual([
{ id: 42, title: 'Bread Recipe' }, { id: 42, title: 'Bread Recipe' },
]) ])
}) })
it('should handle errors during streaming', () => { it('should handle errors during streaming', () => {
component.input.set('Hello') component.input = 'Hello'
component.sendMessage() component.sendMessage()
mockStream$.error('Error') mockStream$.error('Error')
expect(component.messages()[1].content).toContain( expect(component.messages[1].content).toContain(
'⚠️ Error receiving response.' '⚠️ Error receiving response.'
) )
expect(component.loading()).toBe(false) expect(component.loading).toBe(false)
}) })
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
component.enqueueTypewriter('Hello', message as any) component.enqueueTypewriter('Hello', message as any)
expect(component['typewriterBuffer']).toHaveLength(4) expect(component['typewriterBuffer'].length).toBe(4)
}) })
it('should scroll to bottom after sending a message', () => { it('should scroll to bottom after sending a message', () => {
@@ -175,7 +166,7 @@ describe('ChatComponent', () => {
ChatComponent.prototype as any, ChatComponent.prototype as any,
'scrollToBottom' 'scrollToBottom'
) )
component.input.set('Test') component.input = 'Test'
component.sendMessage() component.sendMessage()
expect(scrollSpy).toHaveBeenCalled() expect(scrollSpy).toHaveBeenCalled()
}) })
@@ -1,11 +1,4 @@
import { import { Component, ElementRef, inject, OnInit, ViewChild } from '@angular/core'
Component,
ElementRef,
inject,
OnInit,
signal,
ViewChild,
} from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' 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'
@@ -30,10 +23,10 @@ import {
styleUrl: './chat.component.scss', styleUrl: './chat.component.scss',
}) })
export class ChatComponent implements OnInit { export class ChatComponent implements OnInit {
readonly messages = signal<ChatMessage[]>([]) public messages: ChatMessage[] = []
readonly loading = signal(false) public loading = false
readonly input = signal('') public input: string = ''
readonly documentId = signal<number>(undefined) public documentId!: number
private chatService: ChatService = inject(ChatService) private chatService: ChatService = inject(ChatService)
private router: Router = inject(Router) private router: Router = inject(Router)
@@ -45,7 +38,7 @@ export class ChatComponent implements OnInit {
private typewriterActive = false private typewriterActive = false
public get placeholder(): string { public get placeholder(): string {
return this.documentId() return this.documentId
? $localize`Ask a question about this document...` ? $localize`Ask a question about this document...`
: $localize`Ask a question about a document...` : $localize`Ask a question about a document...`
} }
@@ -64,14 +57,14 @@ export class ChatComponent implements OnInit {
private updateDocumentId(url: string): void { private updateDocumentId(url: string): void {
const docIdRe = url.match(/^\/documents\/(\d+)/) const docIdRe = url.match(/^\/documents\/(\d+)/)
this.documentId.set(docIdRe ? +docIdRe[1] : undefined) this.documentId = docIdRe ? +docIdRe[1] : undefined
} }
sendMessage(): void { sendMessage(): void {
if (!this.input().trim()) return if (!this.input.trim()) return
const userMessage: ChatMessage = { role: 'user', content: this.input() } const userMessage: ChatMessage = { role: 'user', content: this.input }
this.messages.update((messages) => [...messages, userMessage]) this.messages.push(userMessage)
this.scrollToBottom() this.scrollToBottom()
const assistantMessage: ChatMessage = { const assistantMessage: ChatMessage = {
@@ -79,12 +72,12 @@ export class ChatComponent implements OnInit {
content: '', content: '',
isStreaming: true, isStreaming: true,
} }
this.messages.update((messages) => [...messages, assistantMessage]) this.messages.push(assistantMessage)
this.loading.set(true) this.loading = true
let lastVisibleContent = '' let lastVisibleContent = ''
this.chatService.streamChat(this.documentId(), this.input()).subscribe({ this.chatService.streamChat(this.documentId, this.input).subscribe({
next: (chunk) => { next: (chunk) => {
const nextResponse = parseChatResponse(chunk) const nextResponse = parseChatResponse(chunk)
@@ -100,30 +93,26 @@ export class ChatComponent implements OnInit {
} }
assistantMessage.references = nextResponse.references assistantMessage.references = nextResponse.references
this.notifyMessagesChanged()
}, },
error: () => { error: () => {
assistantMessage.content += '\n\n⚠️ Error receiving response.' assistantMessage.content += '\n\n⚠️ Error receiving response.'
assistantMessage.isStreaming = false assistantMessage.isStreaming = false
this.notifyMessagesChanged() this.loading = false
this.loading.set(false)
}, },
complete: () => { complete: () => {
assistantMessage.isStreaming = false assistantMessage.isStreaming = false
this.notifyMessagesChanged() this.loading = false
this.loading.set(false)
this.scrollToBottom() this.scrollToBottom()
}, },
}) })
this.input.set('') this.input = ''
} }
private resetTypewriter(message: ChatMessage, content: string): void { private resetTypewriter(message: ChatMessage, content: string): void {
this.typewriterBuffer = [] this.typewriterBuffer = []
this.typewriterActive = false this.typewriterActive = false
message.content = content message.content = content
this.notifyMessagesChanged()
this.scrollToBottom() this.scrollToBottom()
} }
@@ -146,16 +135,11 @@ export class ChatComponent implements OnInit {
const nextChar = this.typewriterBuffer.shift() const nextChar = this.typewriterBuffer.shift()
message.content += nextChar message.content += nextChar
this.notifyMessagesChanged()
this.scrollToBottom() this.scrollToBottom()
setTimeout(() => this.playTypewriter(message), 10) // 10ms per character setTimeout(() => this.playTypewriter(message), 10) // 10ms per character
} }
private notifyMessagesChanged(): void {
this.messages.update((messages) => [...messages])
}
private scrollToBottom(): void { private scrollToBottom(): void {
setTimeout(() => { setTimeout(() => {
this.scrollAnchor?.nativeElement?.scrollIntoView({ behavior: 'smooth' }) this.scrollAnchor?.nativeElement?.scrollIntoView({ behavior: 'smooth' })
@@ -17,6 +17,6 @@
<ng-template #popoverContent> <ng-template #popoverContent>
<div> <div>
{{confirmMessage}}&nbsp;<button class="btn btn-link btn-sm text-danger p-0 m-0 lh-1" type="button" (click)="onConfirm($event)" i18n>Yes</button> {{confirmMessage}}&nbsp;<button class="btn btn-link btn-sm text-danger p-0 m-0 lh-1" type="button" (click)="onConfirm($event)">Yes</button>
</div> </div>
</ng-template> </ng-template>
@@ -1,12 +1,5 @@
import { CurrencyPipe, getLocaleCurrencyCode, SlicePipe } from '@angular/common' import { CurrencyPipe, getLocaleCurrencyCode, SlicePipe } from '@angular/common'
import { import { Component, inject, Input, LOCALE_ID, OnInit } from '@angular/core'
ChangeDetectorRef,
Component,
inject,
Input,
LOCALE_ID,
OnInit,
} from '@angular/core'
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap' import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
import { takeUntil } from 'rxjs' import { takeUntil } from 'rxjs'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
@@ -29,7 +22,6 @@ export class CustomFieldDisplayComponent
{ {
private customFieldService = inject(CustomFieldsService) private customFieldService = inject(CustomFieldsService)
private documentService = inject(DocumentService) private documentService = inject(DocumentService)
private changeDetector = inject(ChangeDetectorRef)
CustomFieldDataType = CustomFieldDataType CustomFieldDataType = CustomFieldDataType
@@ -82,7 +74,6 @@ export class CustomFieldDisplayComponent
this.customFieldService.listAll().subscribe((r) => { this.customFieldService.listAll().subscribe((r) => {
this.customFields = r.results this.customFields = r.results
this.init() this.init()
this.changeDetector.markForCheck()
}) })
} }
@@ -120,7 +111,6 @@ export class CustomFieldDisplayComponent
this.docLinkDocuments = this.value this.docLinkDocuments = this.value
.map((id) => result.results.find((d) => d.id === id)) .map((id) => result.results.find((d) => d.id === id))
.filter((d) => d) .filter((d) => d)
this.changeDetector.markForCheck()
}) })
} }
@@ -95,8 +95,8 @@ describe('CustomFieldsDropdownComponent', () => {
it('should support update unused fields', () => { it('should support update unused fields', () => {
component.existingFields = [{ field: fields[0].id } as any] component.existingFields = [{ field: fields[0].id } as any]
component['updateUnusedFields']() component['updateUnusedFields']()
expect(component['unusedFields']().length).toEqual(1) expect(component['unusedFields'].length).toEqual(1)
expect(component['unusedFields']()[0].name).toEqual('Field 2') expect(component['unusedFields'][0].name).toEqual('Field 2')
}) })
it('should support getting data type label', () => { it('should support getting data type label', () => {
@@ -8,7 +8,6 @@ import {
ViewChild, ViewChild,
ViewChildren, ViewChildren,
inject, inject,
signal,
} from '@angular/core' } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
@@ -66,11 +65,11 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
@ViewChildren('button') buttons: QueryList<ElementRef> @ViewChildren('button') buttons: QueryList<ElementRef>
private customFields: CustomField[] = [] private customFields: CustomField[] = []
private readonly unusedFields = signal<CustomField[]>([]) private unusedFields: CustomField[] = []
private keyboardIndex: number private keyboardIndex: number
public get filteredFields(): CustomField[] { public get filteredFields(): CustomField[] {
return this.unusedFields().filter( return this.unusedFields.filter(
(f) => !this.filterText || matchesSearchText(f.name, this.filterText) (f) => !this.filterText || matchesSearchText(f.name, this.filterText)
) )
} }
@@ -100,10 +99,8 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
} }
private updateUnusedFields() { private updateUnusedFields() {
this.unusedFields.set( this.unusedFields = this.customFields.filter(
this.customFields.filter( (f) => !this.existingFields?.find((e) => e.field === f.id)
(f) => !this.existingFields?.find((e) => e.field === f.id)
)
) )
} }
@@ -77,7 +77,7 @@
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<ng-select #fieldSelects <ng-select #fieldSelects
class="paperless-input-select" class="paperless-input-select"
[items]="customFields()" [items]="customFields"
[(ngModel)]="atom.field" [(ngModel)]="atom.field"
[disabled]="disabled" [disabled]="disabled"
bindLabel="name" bindLabel="name"
@@ -79,7 +79,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
}) })
it('should initialize custom fields on creation', () => { it('should initialize custom fields on creation', () => {
expect(component.customFields()).toEqual(customFields) expect(component.customFields).toEqual(customFields)
}) })
it('should add an expression when opened if queries are empty', () => { it('should add an expression when opened if queries are empty', () => {
@@ -101,7 +101,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
data_type: CustomFieldDataType.String, data_type: CustomFieldDataType.String,
extra_data: {}, extra_data: {},
} }
component.customFields.set([field]) component.customFields = [field]
const operators = component.getOperatorsForField(1) const operators = component.getOperatorsForField(1)
expect(operators.length).toEqual( expect(operators.length).toEqual(
[ [
@@ -138,7 +138,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
], ],
}, },
} }
component.customFields.set([field]) component.customFields = [field]
const options = component.getSelectOptionsForField(1) const options = component.getSelectOptionsForField(1)
expect(options).toEqual([ expect(options).toEqual([
{ label: 'Option 1', id: 'abc-123' }, { label: 'Option 1', id: 'abc-123' },
@@ -6,7 +6,6 @@ import {
Input, Input,
Output, Output,
QueryList, QueryList,
signal,
ViewChild, ViewChild,
ViewChildren, ViewChildren,
} from '@angular/core' } from '@angular/core'
@@ -279,7 +278,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
@Output() @Output()
selectionModelChange = new EventEmitter<CustomFieldQueriesModel>() selectionModelChange = new EventEmitter<CustomFieldQueriesModel>()
readonly customFields = signal<CustomField[]>([]) customFields: CustomField[] = []
public readonly today: string = new Date().toLocaleDateString('en-CA') public readonly today: string = new Date().toLocaleDateString('en-CA')
@@ -326,12 +325,12 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
.listAll() .listAll()
.pipe(first(), takeUntil(this.unsubscribeNotifier)) .pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((result) => { .subscribe((result) => {
this.customFields.set(result.results) this.customFields = result.results
}) })
} }
public getCustomFieldByID(id: number): CustomField { public getCustomFieldByID(id: number): CustomField {
return this.customFields().find((field) => field.id === id) return this.customFields.find((field) => field.id === id)
} }
public addAtom(expression: CustomFieldQueryExpression) { public addAtom(expression: CustomFieldQueryExpression) {
@@ -354,7 +353,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
getOperatorsForField( getOperatorsForField(
fieldID: number fieldID: number
): Array<{ value: string; label: string }> { ): Array<{ value: string; label: string }> {
const field = this.customFields().find((field) => field.id === fieldID) const field = this.customFields.find((field) => field.id === fieldID)
const groups: CustomFieldQueryOperatorGroups[] = field const groups: CustomFieldQueryOperatorGroups[] = field
? CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE[field.data_type] ? CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE[field.data_type]
: [CustomFieldQueryOperatorGroups.Basic] : [CustomFieldQueryOperatorGroups.Basic]
@@ -370,7 +369,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
getSelectOptionsForField( getSelectOptionsForField(
fieldID: number fieldID: number
): Array<{ label: string; id: string }> { ): Array<{ label: string; id: string }> {
const field = this.customFields().find((field) => field.id === fieldID) const field = this.customFields.find((field) => field.id === fieldID)
if (field) { if (field) {
return field.extra_data['select_options'] return field.extra_data['select_options']
} }
@@ -22,11 +22,11 @@
<ng-template> <ng-template>
<div class="card mb-2"> <div class="card mb-2">
<div class="card-body p-2"> <div class="card-body p-2">
@if (testLoading()) { @if (testLoading) {
<ng-container [ngTemplateOutlet]="loadingTemplate"></ng-container> <ng-container [ngTemplateOutlet]="loadingTemplate"></ng-container>
} @else if (testResult()) { } @else if (testResult) {
<code>{{testResult()}}</code> <code>{{testResult}}</code>
} @else if (testFailed()) { } @else if (testFailed) {
<div class="text-danger" i18n>Path test failed</div> <div class="text-danger" i18n>Path test failed</div>
} @else { } @else {
<div class="text-muted small" i18n>No document selected</div> <div class="text-muted small" i18n>No document selected</div>
@@ -42,7 +42,7 @@
[compareWith]="compareDocuments" [compareWith]="compareDocuments"
[trackByFn]="trackByFn" [trackByFn]="trackByFn"
[minTermLength]="2" [minTermLength]="2"
[loading]="loading()" [loading]="loading"
[typeahead]="documentsInput$" [typeahead]="documentsInput$"
(change)="testPath($event)"> (change)="testPath($event)">
<ng-template #loadingTemplate ng-loadingspinner-tmp> <ng-template #loadingTemplate ng-loadingspinner-tmp>
@@ -58,17 +58,17 @@ describe('StoragePathEditDialogComponent', () => {
fixture.detectChanges() fixture.detectChanges()
component.testPath({ id: 1 }) component.testPath({ id: 1 })
expect(testSpy).toHaveBeenCalledWith('test/{{title}}', 1) expect(testSpy).toHaveBeenCalledWith('test/{{title}}', 1)
expect(component.testResult()).toBe('test/abc123') expect(component.testResult).toBe('test/abc123')
expect(component.testFailed()).toBeFalsy() expect(component.testFailed).toBeFalsy()
// test failed // test failed
testSpy.mockReturnValueOnce(of('')) testSpy.mockReturnValueOnce(of(''))
component.testPath({ id: 1 }) component.testPath({ id: 1 })
expect(component.testResult()).toBeNull() expect(component.testResult).toBeNull()
expect(component.testFailed()).toBeTruthy() expect(component.testFailed).toBeTruthy()
component.testPath(null) component.testPath(null)
expect(component.testResult()).toBeNull() expect(component.testResult).toBeNull()
}) })
it('should compare two documents by id', () => { it('should compare two documents by id', () => {
@@ -1,5 +1,5 @@
import { AsyncPipe, NgTemplateOutlet } from '@angular/common' import { AsyncPipe, NgTemplateOutlet } from '@angular/common'
import { Component, OnDestroy, inject, signal } from '@angular/core' import { Component, OnDestroy, inject } from '@angular/core'
import { import {
FormControl, FormControl,
FormGroup, FormGroup,
@@ -65,9 +65,9 @@ export class StoragePathEditDialogComponent
public documentsInput$ = new Subject<string>() public documentsInput$ = new Subject<string>()
public foundDocuments$: Observable<Document[]> public foundDocuments$: Observable<Document[]>
private testDocument: Document private testDocument: Document
readonly testResult = signal<string>(undefined) public testResult: string
readonly testFailed = signal(false) public testFailed: boolean = false
readonly testLoading = signal(false) public testLoading = false
constructor() { constructor() {
super() super()
@@ -99,22 +99,22 @@ export class StoragePathEditDialogComponent
public testPath(document: Document) { public testPath(document: Document) {
if (!document) { if (!document) {
this.testResult.set(null) this.testResult = null
return return
} }
this.testDocument = document this.testDocument = document
this.testLoading.set(true) this.testLoading = true
;(this.service as StoragePathService) ;(this.service as StoragePathService)
.testPath(this.objectForm.get('path').value, document.id) .testPath(this.objectForm.get('path').value, document.id)
.subscribe((result) => { .subscribe((result) => {
if (result?.length) { if (result?.length) {
this.testResult.set(result) this.testResult = result
this.testFailed.set(false) this.testFailed = false
} else { } else {
this.testResult.set(null) this.testResult = null
this.testFailed.set(true) this.testFailed = true
} }
this.testLoading.set(false) this.testLoading = false
}) })
} }
@@ -12,7 +12,7 @@
<pngx-input-color i18n-title title="Color" formControlName="color" [error]="error?.color"></pngx-input-color> <pngx-input-color i18n-title title="Color" formControlName="color" [error]="error?.color"></pngx-input-color>
<pngx-input-select i18n-title title="Parent" formControlName="parent" [items]="tags()" [allowNull]="true" [error]="error?.parent"></pngx-input-select> <pngx-input-select i18n-title title="Parent" formControlName="parent" [items]="tags" [allowNull]="true" [error]="error?.parent"></pngx-input-select>
<pngx-input-check i18n-title title="Inbox tag" formControlName="is_inbox_tag" i18n-hint hint="Inbox tags are automatically assigned to all consumed documents."></pngx-input-check> <pngx-input-check i18n-title title="Inbox tag" formControlName="is_inbox_tag" i18n-hint hint="Inbox tags are automatically assigned to all consumed documents."></pngx-input-check>
<pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select> <pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
@@ -1,4 +1,4 @@
import { Component, inject, signal } from '@angular/core' import { Component, inject } from '@angular/core'
import { import {
FormControl, FormControl,
FormGroup, FormGroup,
@@ -35,7 +35,7 @@ import { TextComponent } from '../../input/text/text.component'
], ],
}) })
export class TagEditDialogComponent extends EditDialogComponent<Tag> { export class TagEditDialogComponent extends EditDialogComponent<Tag> {
readonly tags = signal<Tag[]>([]) tags: Tag[]
constructor() { constructor() {
super() super()
@@ -43,7 +43,7 @@ export class TagEditDialogComponent extends EditDialogComponent<Tag> {
this.userService = inject(UserService) this.userService = inject(UserService)
this.settingsService = inject(SettingsService) this.settingsService = inject(SettingsService)
this.service.listAll().subscribe((result) => { this.service.listAll().subscribe((result) => {
this.tags.set(result.results) this.tags = result.results
}) })
} }
@@ -180,7 +180,7 @@
<div class="col"> <div class="col">
<div class="trigger-filters mb-3"> <div class="trigger-filters mb-3">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<span class="form-label mb-0" i18n>Advanced Filters</span> <label class="form-label mb-0" i18n>Advanced Filters</label>
<button <button
type="button" type="button"
class="btn btn-sm btn-outline-primary ms-auto" class="btn btn-sm btn-outline-primary ms-auto"
@@ -401,7 +401,7 @@ describe('WorkflowEditDialogComponent', () => {
component.addFilter(triggerGroup as FormGroup) component.addFilter(triggerGroup as FormGroup)
const filters = component.getFiltersFormArray(triggerGroup as FormGroup) const filters = component.getFiltersFormArray(triggerGroup as FormGroup)
expect(filters).toHaveLength(3) expect(filters.length).toBe(3)
filters.at(0).get('values').setValue([1]) filters.at(0).get('values').setValue([1])
filters.at(1).get('values').setValue([2, 3]) filters.at(1).get('values').setValue([2, 3])
@@ -672,7 +672,7 @@ describe('WorkflowEditDialogComponent', () => {
} as any } as any
const filters = component['buildFiltersFormArray'](trigger) const filters = component['buildFiltersFormArray'](trigger)
expect(filters).toHaveLength(0) expect(filters.length).toBe(0)
component.filterDefinitions = originalDefinitions component.filterDefinitions = originalDefinitions
}) })
@@ -739,7 +739,7 @@ describe('WorkflowEditDialogComponent', () => {
component.ngOnInit() component.ngOnInit()
const triggerGroup = component.triggerFields.at(0) as FormGroup const triggerGroup = component.triggerFields.at(0) as FormGroup
const filters = component.getFiltersFormArray(triggerGroup) const filters = component.getFiltersFormArray(triggerGroup)
expect(filters).toHaveLength(13) expect(filters.length).toBe(13)
const customFieldFilter = filters.at(12) as FormGroup const customFieldFilter = filters.at(12) as FormGroup
expect(customFieldFilter.get('type').value).toBe( expect(customFieldFilter.get('type').value).toBe(
TriggerFilterType.CustomFieldQuery TriggerFilterType.CustomFieldQuery
@@ -969,12 +969,12 @@ describe('WorkflowEditDialogComponent', () => {
component.addFilter(triggerGroup) component.addFilter(triggerGroup)
component.removeFilter(triggerGroup, 0) component.removeFilter(triggerGroup, 0)
expect(component.getFiltersFormArray(triggerGroup)).toHaveLength(0) expect(component.getFiltersFormArray(triggerGroup).length).toBe(0)
component.addFilter(triggerGroup) component.addFilter(triggerGroup)
const filterArrayAfterAdd = component.getFiltersFormArray(triggerGroup) const filterArrayAfterAdd = component.getFiltersFormArray(triggerGroup)
filterArrayAfterAdd.at(0).get('type').setValue(TriggerFilterType.TagsAll) filterArrayAfterAdd.at(0).get('type').setValue(TriggerFilterType.TagsAll)
expect(component.getFiltersFormArray(triggerGroup)).toHaveLength(1) expect(component.getFiltersFormArray(triggerGroup).length).toBe(1)
}) })
it('should remove selected custom field from the form group', () => { it('should remove selected custom field from the form group', () => {
@@ -933,7 +933,7 @@ export class WorkflowEditDialogComponent
getFilterSelectItems(type: TriggerFilterType) { getFilterSelectItems(type: TriggerFilterType) {
const definition = this.getFilterDefinition(type) const definition = this.getFilterDefinition(type)
if (definition?.inputType !== 'select') { if (!definition || definition.inputType !== 'select') {
return [] return []
} }
@@ -34,30 +34,32 @@
</div> </div>
@if (selectionModel.items) { @if (selectionModel.items) {
<cdk-virtual-scroll-viewport class="items" [itemSize]="FILTERABLE_BUTTON_HEIGHT_PX" #buttonsViewport [style.height.px]="scrollViewportHeight"> <cdk-virtual-scroll-viewport class="items" [itemSize]="FILTERABLE_BUTTON_HEIGHT_PX" #buttonsViewport [style.height.px]="scrollViewportHeight">
<div *cdkVirtualFor="let item of filteredItems; trackBy: trackByItem; let i = index"> <div *cdkVirtualFor="let item of selectionModel.items | filter: filterText:'name'; trackBy: trackByItem; let i = index">
<pngx-toggleable-dropdown-button @if (allowSelectNone || item.id) {
[item]="item" <pngx-toggleable-dropdown-button
[hideCount]="hideCount(item)" [item]="item"
[opacifyCount]="!editing" [hideCount]="hideCount(item)"
[state]="selectionModel.get(item.id)" [opacifyCount]="!editing"
[count]="getUpdatedDocumentCount(item.id)" [state]="selectionModel.get(item.id)"
(toggled)="selectionModel.toggle(item.id)" [count]="getUpdatedDocumentCount(item.id)"
(exclude)="excludeClicked(item.id)" (toggled)="selectionModel.toggle(item.id)"
[disabled]="disabled"> (exclude)="excludeClicked(item.id)"
</pngx-toggleable-dropdown-button> [disabled]="disabled">
</pngx-toggleable-dropdown-button>
}
</div> </div>
</cdk-virtual-scroll-viewport> </cdk-virtual-scroll-viewport>
} }
@if (editing) { @if (editing) {
@if (filteredItems.length === 0 && createRef !== undefined) { @if ((selectionModel.items | filter: filterText:'name').length === 0 && createRef !== undefined) {
<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> <small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small>
<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 ((selectionModel.items | filter: filterText:'name').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> <small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty}" i18n>Apply</small>
<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>
} }
@@ -221,7 +221,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
.dispatchEvent(new MouseEvent('click')) // open .dispatchEvent(new MouseEvent('click')) // open
selectionModel.toggle(items[0].id) selectionModel.toggle(items[0].id)
fixture.detectChanges() fixture.detectChanges()
expect(component.modelIsDirty()).toBeTruthy() expect(component.modelIsDirty).toBeTruthy()
let applyResult: ChangedItems let applyResult: ChangedItems
const closeSpy = jest.spyOn(component.dropdown, 'close') const closeSpy = jest.spyOn(component.dropdown, 'close')
component.apply.subscribe((result) => (applyResult = result)) component.apply.subscribe((result) => (applyResult = result))
@@ -244,7 +244,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
.dispatchEvent(new MouseEvent('click')) // open .dispatchEvent(new MouseEvent('click')) // open
selectionModel.toggle(items[0].id) selectionModel.toggle(items[0].id)
fixture.detectChanges() fixture.detectChanges()
expect(component.modelIsDirty()).toBeTruthy() expect(component.modelIsDirty).toBeTruthy()
let applyResult: ChangedItems let applyResult: ChangedItems
component.apply.subscribe((result) => (applyResult = result)) component.apply.subscribe((result) => (applyResult = result))
component.dropdown.close() component.dropdown.close()
@@ -265,9 +265,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(document.activeElement).toEqual( expect(document.activeElement).toEqual(
component.listFilterTextInput.nativeElement component.listFilterTextInput.nativeElement
) )
expect(component.buttonsViewport.getRenderedRange().end).toEqual( expect(component.buttonsViewport.getRenderedRange().end).toEqual(3) // all items shown
items.length
) // all selectable items shown
component.filterText = 'Tag2' component.filterText = 'Tag2'
fixture.detectChanges() fixture.detectChanges()
@@ -280,29 +278,6 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(component.filterText).toHaveLength(0) expect(component.filterText).toHaveLength(0)
}) })
it('should omit disallowed null items from the virtual scroll viewport', async () => {
component.selectionModel.items = items
component.icon = 'tag-fill'
fixture.nativeElement
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
await wait(100)
fixture.detectChanges()
component.buttonsViewport?.checkViewportSize()
fixture.detectChanges()
expect(component.filteredItems).toEqual(items)
expect(component.scrollViewportHeight).toEqual(
items.length * component.FILTERABLE_BUTTON_HEIGHT_PX
)
expect(
component.buttonsViewport.elementRef.nativeElement.querySelectorAll(
'.cdk-virtual-scroll-content-wrapper > div'
)
).toHaveLength(items.length)
})
it('should toggle & close on enter inside filter field if 1 item remains', async () => { it('should toggle & close on enter inside filter field if 1 item remains', async () => {
component.selectionModel.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
@@ -12,7 +12,6 @@ import {
Output, Output,
ViewChild, ViewChild,
inject, inject,
signal,
} from '@angular/core' } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
@@ -661,6 +660,7 @@ export class FilterableDropdownSelectionModel {
imports: [ imports: [
ClearableBadgeComponent, ClearableBadgeComponent,
ToggleableDropdownButtonComponent, ToggleableDropdownButtonComponent,
FilterPipe,
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
@@ -796,27 +796,22 @@ export class FilterableDropdownComponent
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
} }
readonly modelIsDirty = signal(false) modelIsDirty: boolean = false
private keyboardIndex: number private keyboardIndex: number
public get filteredItems(): MatchingModel[] {
return this.filterPipe
.transform(this.items, this.filterText, 'name')
.filter((item) => this.allowSelectNone || Boolean(item.id))
}
public get scrollViewportHeight(): number { public get scrollViewportHeight(): number {
return Math.min( const filteredLength = this.filterPipe.transform(
this.filteredItems.length * this.FILTERABLE_BUTTON_HEIGHT_PX, this.items,
400 this.filterText
) ).length
return Math.min(filteredLength * this.FILTERABLE_BUTTON_HEIGHT_PX, 400)
} }
constructor() { constructor() {
super() super()
this.selectionModelChange.subscribe((updatedModel) => { this.selectionModelChange.subscribe((updatedModel) => {
this.modelIsDirty.set(updatedModel.isDirty()) this.modelIsDirty = updatedModel.isDirty()
}) })
} }
@@ -863,7 +858,7 @@ export class FilterableDropdownComponent
}, 0) }, 0)
if (this.editing) { if (this.editing) {
this.selectionModel.reset() this.selectionModel.reset()
this.modelIsDirty.set(false) this.modelIsDirty = false
} }
this.selectionModel.singleSelect = this.selectionModel.singleSelect =
this.editing && !this.selectionModel.manyToOne this.editing && !this.selectionModel.manyToOne
@@ -882,7 +877,7 @@ export class FilterableDropdownComponent
} }
listFilterEnter(): void { listFilterEnter(): void {
const filtered = this.filteredItems let filtered = this.filterPipe.transform(this.items, this.filterText)
if (filtered.length == 1) { if (filtered.length == 1) {
this.selectionModel.toggle(filtered[0].id) this.selectionModel.toggle(filtered[0].id)
setTimeout(() => { setTimeout(() => {
@@ -5,7 +5,6 @@ import {
inject, inject,
Input, Input,
Output, Output,
signal,
} from '@angular/core' } from '@angular/core'
import { import {
FormsModule, FormsModule,
@@ -64,11 +63,11 @@ export class CustomFieldsValuesComponent extends AbstractInputComponent<Object>
super() super()
customFieldsService.listAll().subscribe((items) => { customFieldsService.listAll().subscribe((items) => {
this.fields.set(items.results) this.fields = items.results
}) })
} }
private readonly fields = signal<CustomField[]>([]) private fields: CustomField[]
private _selectedFields: number[] private _selectedFields: number[]
@@ -91,6 +90,6 @@ export class CustomFieldsValuesComponent extends AbstractInputComponent<Object>
public removeSelectedField: EventEmitter<number> = new EventEmitter<number>() public removeSelectedField: EventEmitter<number> = new EventEmitter<number>()
public getCustomField(id: number): CustomField { public getCustomField(id: number): CustomField {
return this.fields().find((field) => field.id === id) return this.fields.find((field) => field.id === id)
} }
} }
@@ -33,7 +33,7 @@
[compareWith]="compareDocuments" [compareWith]="compareDocuments"
[trackByFn]="trackByFn" [trackByFn]="trackByFn"
[minTermLength]="2" [minTermLength]="2"
[loading]="loading()" [loading]="loading"
[typeahead]="documentsInput$" [typeahead]="documentsInput$"
(mousedown)="$event.stopImmediatePropagation()" (mousedown)="$event.stopImmediatePropagation()"
(change)="onChange(selectedDocumentIDs)"> (change)="onChange(selectedDocumentIDs)">
@@ -2,11 +2,7 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing' import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NG_VALUE_ACCESSOR } from '@angular/forms' import { NG_VALUE_ACCESSOR } from '@angular/forms'
import { By } from '@angular/platform-browser' import { of, throwError } from 'rxjs'
import { provideRouter } from '@angular/router'
import { NgSelectComponent } from '@ng-select/ng-select'
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { of, Subject, throwError } from 'rxjs'
import { FILTER_SIMPLE_TITLE } from 'src/app/data/filter-rule-type' import { FILTER_SIMPLE_TITLE } from 'src/app/data/filter-rule-type'
import { DocumentService } from 'src/app/services/rest/document.service' import { DocumentService } from 'src/app/services/rest/document.service'
import { DocumentLinkComponent } from './document-link.component' import { DocumentLinkComponent } from './document-link.component'
@@ -37,11 +33,10 @@ describe('DocumentLinkComponent', () => {
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [DocumentLinkComponent, NgxBootstrapIconsModule.pick(allIcons)], imports: [DocumentLinkComponent],
providers: [ providers: [
provideHttpClient(withInterceptorsFromDi()), provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(), provideHttpClientTesting(),
provideRouter([]),
], ],
}) })
documentService = TestBed.inject(DocumentService) documentService = TestBed.inject(DocumentService)
@@ -65,25 +60,6 @@ describe('DocumentLinkComponent', () => {
expect(getSpy).toHaveBeenCalled() expect(getSpy).toHaveBeenCalled()
}) })
it('should render loading and selected documents after async updates', async () => {
const result$ = new Subject<any>()
jest.spyOn(documentService, 'getFew').mockReturnValue(result$)
component.writeValue([1])
await fixture.whenStable()
const select = fixture.debugElement.query(By.directive(NgSelectComponent))
.componentInstance as NgSelectComponent
expect(select.loading()).toBe(true)
result$.next({ count: 1, all: [1], results: [documents[0]] })
result$.complete()
await fixture.whenStable()
expect(select.loading()).toBe(false)
expect(fixture.nativeElement.textContent).toContain(documents[0].title)
})
it('shoud maintain ordering of selected documents', () => { it('shoud maintain ordering of selected documents', () => {
const getSpy = jest.spyOn(documentService, 'getFew') const getSpy = jest.spyOn(documentService, 'getFew')
getSpy.mockImplementation((ids) => { getSpy.mockImplementation((ids) => {
@@ -6,7 +6,6 @@ import {
Input, Input,
OnDestroy, OnDestroy,
OnInit, OnInit,
signal,
} from '@angular/core' } from '@angular/core'
import { import {
FormsModule, FormsModule,
@@ -64,7 +63,7 @@ export class DocumentLinkComponent
documentsInput$ = new Subject<string>() documentsInput$ = new Subject<string>()
foundDocuments$: Observable<Document[]> foundDocuments$: Observable<Document[]>
readonly loading = signal(false) loading = false
selectedDocuments: Document[] = [] selectedDocuments: Document[] = []
private unsubscribeNotifier: Subject<any> = new Subject() private unsubscribeNotifier: Subject<any> = new Subject()
@@ -94,12 +93,12 @@ export class DocumentLinkComponent
this.selectedDocuments = [] this.selectedDocuments = []
super.writeValue([]) super.writeValue([])
} else { } else {
this.loading.set(true) this.loading = true
this.documentsService this.documentsService
.getFew(documentIDs, { fields: 'id,title' }) .getFew(documentIDs, { fields: 'id,title' })
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((documentResults) => { .subscribe((documentResults) => {
this.loading.set(false) this.loading = false
this.selectedDocuments = documentIDs.map( this.selectedDocuments = documentIDs.map(
(id) => documentResults.results.find((d) => d.id === id) ?? {} (id) => documentResults.results.find((d) => d.id === id) ?? {}
) )
@@ -114,7 +113,7 @@ export class DocumentLinkComponent
this.documentsInput$.pipe( this.documentsInput$.pipe(
distinctUntilChanged(), distinctUntilChanged(),
takeUntil(this.unsubscribeNotifier), takeUntil(this.unsubscribeNotifier),
tap(() => this.loading.set(true)), tap(() => (this.loading = true)),
switchMap((title) => switchMap((title) =>
this.documentsService this.documentsService
.listFiltered( .listFiltered(
@@ -134,7 +133,7 @@ export class DocumentLinkComponent
) )
), ),
catchError(() => of([])), // empty on error catchError(() => of([])), // empty on error
tap(() => this.loading.set(false)) tap(() => (this.loading = false))
) )
) )
) )
@@ -37,7 +37,6 @@ export class NumberComponent extends AbstractInputComponent<number> {
this.documentService.getNextAsn().subscribe((nextAsn) => { this.documentService.getNextAsn().subscribe((nextAsn) => {
this.value = nextAsn this.value = nextAsn
this.onChange(this.value) this.onChange(this.value)
this.changeDetector.markForCheck()
}) })
} }
@@ -157,8 +157,8 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
private removeChildren(tagIDs: number[], tag: Tag) { private removeChildren(tagIDs: number[], tag: Tag) {
if (tag.children?.length) { if (tag.children?.length) {
const childIDs = new Set(tag.children.map((child) => child.id)) const childIDs = tag.children.map((child) => child.id)
tagIDs = tagIDs.filter((id) => !childIDs.has(id)) tagIDs = tagIDs.filter((id) => !childIDs.includes(id))
for (const child of tag.children) { for (const child of tag.children) {
tagIDs = this.removeChildren(tagIDs, child) tagIDs = this.removeChildren(tagIDs, child)
} }
@@ -14,7 +14,7 @@ import { environment } from 'src/environments/environment'
}) })
export class PageHeaderComponent { export class PageHeaderComponent {
private titleService = inject(Title) private titleService = inject(Title)
private readonly clipboard = inject(Clipboard) private clipboard = inject(Clipboard)
readonly id = input<number>(undefined) readonly id = input<number>(undefined)
readonly subTitle = input('') readonly subTitle = input('')
@@ -49,7 +49,8 @@ describe('PngxPdfViewerComponent', () => {
new URL('assets/js/pdf.worker.min.mjs', document.baseURI).toString() new URL('assets/js/pdf.worker.min.mjs', document.baseURI).toString()
) )
const isVisible = (component as any).findController.onIsPageVisible as const isVisible = (component as any).findController.onIsPageVisible as
(() => boolean) | undefined | (() => boolean)
| undefined
expect(isVisible?.()).toBe(true) expect(isVisible?.()).toBe(true)
expect(loadSpy).toHaveBeenCalledWith( expect(loadSpy).toHaveBeenCalledWith(
expect.objectContaining({ numPages: 1 }) expect.objectContaining({ numPages: 1 })
@@ -60,7 +61,6 @@ describe('PngxPdfViewerComponent', () => {
it('resolves the worker source relative to the document base URI', async () => { it('resolves the worker source relative to the document base URI', async () => {
setBaseHref('/paperless/') setBaseHref('/paperless/')
const getDocumentSpy = jest.spyOn(pdfjs, 'getDocument')
await initComponent() await initComponent()
@@ -70,13 +70,6 @@ describe('PngxPdfViewerComponent', () => {
expect(pdfjs.GlobalWorkerOptions.workerSrc).toContain( expect(pdfjs.GlobalWorkerOptions.workerSrc).toContain(
'/paperless/assets/js/pdf.worker.min.mjs' '/paperless/assets/js/pdf.worker.min.mjs'
) )
expect(getDocumentSpy).toHaveBeenCalledWith({
url: 'test.pdf',
password: undefined,
withCredentials: true,
wasmUrl: expect.stringContaining('/paperless/assets/wasm/'),
iccUrl: expect.stringContaining('/paperless/assets/iccs/'),
})
}) })
it('initializes single-page viewer and disables text layer', async () => { it('initializes single-page viewer and disables text layer', async () => {
@@ -138,18 +131,6 @@ describe('PngxPdfViewerComponent', () => {
expect(applyScaleSpy).toHaveBeenCalled() expect(applyScaleSpy).toHaveBeenCalled()
}) })
it('does not reset the viewer when it is already on the requested page', async () => {
await initComponent()
const viewer = (component as any).pdfViewer as PDFViewer
const currentPageSpy = jest.spyOn(viewer, 'currentPageNumber', 'set')
component.page = viewer.currentPageNumber
;(component as any).applyViewerState()
expect(currentPageSpy).not.toHaveBeenCalled()
})
it('dispatches find when search query changes after render', async () => { it('dispatches find when search query changes after render', async () => {
await initComponent() await initComponent()
@@ -283,22 +264,6 @@ describe('PngxPdfViewerComponent', () => {
expect(mockViewer.currentPageNumber).toBe(1) expect(mockViewer.currentPageNumber).toBe(1)
}) })
it('reloads when the source revision changes', () => {
const resetSpy = jest.spyOn(component as any, 'resetViewerState')
const loadSpy = jest
.spyOn(component as any, 'loadDocument')
.mockImplementation(() => {})
component.src = 'test.pdf'
component.sourceRevision = 1
component.ngOnChanges({
sourceRevision: new SimpleChange(0, 1, false),
})
expect(resetSpy).toHaveBeenCalled()
expect(loadSpy).toHaveBeenCalled()
})
it('applies viewer state after view init when already loaded', () => { it('applies viewer state after view init when already loaded', () => {
const applySpy = jest.spyOn(component as any, 'applyViewerState') const applySpy = jest.spyOn(component as any, 'applyViewerState')
;(component as any).hasLoaded = true ;(component as any).hasLoaded = true
@@ -43,7 +43,6 @@ export class PngxPdfViewerComponent
private readonly document = inject<Document>(DOCUMENT) private readonly document = inject<Document>(DOCUMENT)
@Input() src!: string @Input() src!: string
@Input() sourceRevision = 0
@Input() password?: string @Input() password?: string
@Input() page?: number @Input() page?: number
@Output() pageChange = new EventEmitter<number>() @Output() pageChange = new EventEmitter<number>()
@@ -94,7 +93,7 @@ export class PngxPdfViewerComponent
} }
ngOnChanges(changes: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {
if (changes['src'] || changes['sourceRevision'] || changes['password']) { if (changes['src'] || changes['password']) {
this.resetViewerState() this.resetViewerState()
if (this.src) { if (this.src) {
this.loadDocument() this.loadDocument()
@@ -175,12 +174,10 @@ export class PngxPdfViewerComponent
'assets/js/pdf.worker.min.mjs', 'assets/js/pdf.worker.min.mjs',
this.document.baseURI this.document.baseURI
).toString() ).toString()
const initOptions = { let initOptions = {
url: this.src, url: this.src,
password: this.password, password: this.password,
withCredentials: true, withCredentials: true,
wasmUrl: new URL('assets/wasm/', this.document.baseURI).toString(),
iccUrl: new URL('assets/iccs/', this.document.baseURI).toString(),
} }
this.loadingTask = getDocument(initOptions) this.loadingTask = getDocument(initOptions)
try { try {
@@ -257,9 +254,7 @@ export class PngxPdfViewerComponent
Math.max(Math.trunc(this.page), 1), Math.max(Math.trunc(this.page), 1),
this.pdfViewer.pagesCount this.pdfViewer.pagesCount
) )
if (nextPage !== this.pdfViewer.currentPageNumber) { this.pdfViewer.currentPageNumber = nextPage
this.pdfViewer.currentPageNumber = nextPage
}
} }
if (this.page === this.lastViewerPage) { if (this.page === this.lastViewerPage) {
this.lastViewerPage = undefined this.lastViewerPage = undefined
@@ -68,7 +68,7 @@
[(ngModel)]="selectionModel.includeUsers" [(ngModel)]="selectionModel.includeUsers"
[disabled]="disabled" [disabled]="disabled"
[clearable]="false" [clearable]="false"
[items]="users()" [items]="users"
bindLabel="username" bindLabel="username"
multiple="true" multiple="true"
bindValue="id" bindValue="id"
@@ -1,12 +1,5 @@
import { NgClass } from '@angular/common' import { NgClass } from '@angular/common'
import { import { Component, EventEmitter, Input, Output, inject } from '@angular/core'
Component,
EventEmitter,
Input,
Output,
inject,
signal,
} from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectComponent } from '@ng-select/ng-select' import { NgSelectComponent } from '@ng-select/ng-select'
@@ -82,7 +75,7 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
@Output() @Output()
ownerFilterSet = new EventEmitter<PermissionsSelectionModel>() ownerFilterSet = new EventEmitter<PermissionsSelectionModel>()
readonly users = signal<User[]>([]) users: User[]
hideUnowned: boolean hideUnowned: boolean
@@ -109,7 +102,7 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
.listAll() .listAll()
.pipe(first()) .pipe(first())
.subscribe({ .subscribe({
next: (result) => this.users.set(result.results), next: (result) => (this.users = result.results),
}) })
} }
} }
@@ -57,15 +57,15 @@
</div> </div>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
@if (socialAccounts().length > 0) { @if (socialAccounts?.length > 0) {
<div class="mb-3"> <div class="mb-3">
<p i18n>Connected social accounts</p> <p i18n>Connected social accounts</p>
<ul class="list-group"> <ul class="list-group">
@for (account of socialAccounts(); track account.id) { @for (account of socialAccounts; track account.id) {
<li class="list-group-item" <li class="list-group-item"
ngbPopover="Set a password before disconnecting social account." ngbPopover="Set a password before disconnecting social account."
i18n-ngbPopover i18n-ngbPopover
[disablePopover]="hasUsablePassword()" [disablePopover]="hasUsablePassword"
triggers="mouseenter:mouseleave"> triggers="mouseenter:mouseleave">
{{account.name}} ({{account.provider}}) {{account.name}} ({{account.provider}})
<pngx-confirm-button <pngx-confirm-button
@@ -75,7 +75,7 @@
i18n-title i18n-title
buttonClasses="btn-outline-danger btn-sm ms-2 align-baseline" buttonClasses="btn-outline-danger btn-sm ms-2 align-baseline"
iconName="trash" iconName="trash"
[disabled]="!hasUsablePassword()" [disabled]="!hasUsablePassword"
(confirm)="disconnectSocialAccount(account.id)"> (confirm)="disconnectSocialAccount(account.id)">
</pngx-confirm-button> </pngx-confirm-button>
</li> </li>
@@ -84,11 +84,11 @@
<div class="form-text text-muted text-end fst-italic" i18n>Warning: disconnecting social accounts cannot be undone</div> <div class="form-text text-muted text-end fst-italic" i18n>Warning: disconnecting social accounts cannot be undone</div>
</div> </div>
} }
@if (socialAccountProviders().length > 0) { @if (socialAccountProviders?.length > 0) {
<div class="mb-3"> <div class="mb-3">
<p i18n>Connect new social account</p> <p i18n>Connect new social account</p>
<div class="list-group"> <div class="list-group">
@for (provider of socialAccountProviders(); track provider.name) { @for (provider of socialAccountProviders; track provider.name) {
<a class="list-group-item list-group-item-action text-primary d-flex align-items-center" href="{{ provider.login_url }}" rel="noopener noreferrer"> <a class="list-group-item list-group-item-action text-primary d-flex align-items-center" href="{{ provider.login_url }}" rel="noopener noreferrer">
{{provider.name}}<i-bs class="pb-1 ms-2" name="box-arrow-up-right"></i-bs> {{provider.name}}<i-bs class="pb-1 ms-2" name="box-arrow-up-right"></i-bs>
</a> </a>
@@ -96,7 +96,7 @@
</div> </div>
</div> </div>
} }
@if (!isTotpEnabled()) { @if (!isTotpEnabled) {
<div ngbAccordion> <div ngbAccordion>
<div ngbAccordionItem> <div ngbAccordionItem>
<h2 ngbAccordionHeader> <h2 ngbAccordionHeader>
@@ -105,10 +105,10 @@
<div ngbAccordionCollapse> <div ngbAccordionCollapse>
<div ngbAccordionBody> <div ngbAccordionBody>
<ng-template> <ng-template>
@if (totpSettingsLoading()) { @if (totpSettingsLoading) {
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div> <div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div> <div class="visually-hidden" i18n>Loading...</div>
} @else if (totpSettings()) { } @else if (totpSettings) {
<figure class="figure"> <figure class="figure">
@if (qrSvgDataUrl) { @if (qrSvgDataUrl) {
<img class="bg-white d-inline-block" [src]="qrSvgDataUrl" alt="Authenticator QR code"> <img class="bg-white d-inline-block" [src]="qrSvgDataUrl" alt="Authenticator QR code">
@@ -116,14 +116,14 @@
<figcaption class="figure-caption text-end mt-2" i18n>Scan the QR code with your authenticator app and then enter the code below</figcaption> <figcaption class="figure-caption text-end mt-2" i18n>Scan the QR code with your authenticator app and then enter the code below</figcaption>
</figure> </figure>
<p> <p>
<ng-container i18n>Authenticator secret</ng-container>: <code>{{totpSettings().secret}}</code>. <ng-container i18n>Authenticator secret</ng-container>: <code>{{totpSettings.secret}}</code>.
<ng-container i18n>You can store this secret and use it to reinstall your authenticator app at a later time.</ng-container> <ng-container i18n>You can store this secret and use it to reinstall your authenticator app at a later time.</ng-container>
</p> </p>
<div class="input-group mb-3"> <div class="input-group mb-3">
<input type="text" class="form-control" formControlName="totp_code" placeholder="Code" i18n-placeholder> <input type="text" class="form-control" formControlName="totp_code" placeholder="Code" i18n-placeholder>
<button type="button" class="btn btn-primary ml-auto" (click)="activateTotp()" [disabled]="totpLoading()"> <button type="button" class="btn btn-primary ml-auto" (click)="activateTotp()" [disabled]="totpLoading">
<ng-container i18n>Enable</ng-container> <ng-container i18n>Enable</ng-container>
@if (totpLoading()) { @if (totpLoading) {
<div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div> <div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div> <div class="visually-hidden" i18n>Loading...</div>
} }
@@ -137,18 +137,18 @@
</div> </div>
} @else { } @else {
<label class="d-block mb-2" i18n>Two-factor Authentication</label> <label class="d-block mb-2" i18n>Two-factor Authentication</label>
@if (recoveryCodes()) { @if (recoveryCodes) {
<div class="alert alert-warning" role="alert"> <div class="alert alert-warning" role="alert">
<i-bs name="exclamation-triangle" class="me-1"></i-bs><ng-container i18n>Recovery codes will not be shown again, make sure to save them.</ng-container> <i-bs name="exclamation-triangle" class="me-1"></i-bs><ng-container i18n>Recovery codes will not be shown again, make sure to save them.</ng-container>
</div> </div>
<div class="d-flex flex-row align-items-start mb-3"> <div class="d-flex flex-row align-items-start mb-3">
<ul class="list-group w-50"> <ul class="list-group w-50">
@for (code of recoveryCodes(); track code; let i = $index) { @for (code of recoveryCodes; track code; let i = $index) {
@if (i % 2 === 0) { @if (i % 2 === 0) {
<li class="list-group-item d-flex justify-content-around align-items-center"> <li class="list-group-item d-flex justify-content-around align-items-center">
<code>{{code}}</code> <code>{{code}}</code>
@if (recoveryCodes()[i + 1]) { @if (recoveryCodes[i + 1]) {
<code>{{recoveryCodes()[i + 1]}}</code> <code>{{recoveryCodes[i + 1]}}</code>
} }
</li> </li>
} }
@@ -171,7 +171,7 @@
i18n-title i18n-title
buttonClasses="btn-outline-danger btn-sm" buttonClasses="btn-outline-danger btn-sm"
iconName="trash" iconName="trash"
[disabled]="totpLoading()" [disabled]="totpLoading"
(confirm)="deactivateTotp()"> (confirm)="deactivateTotp()">
</pngx-confirm-button> </pngx-confirm-button>
} }
@@ -10,7 +10,7 @@ import {
NgbPopoverModule, NgbPopoverModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { NEVER, of, Subject, throwError } from 'rxjs' import { of, throwError } from 'rxjs'
import { ProfileService } from 'src/app/services/profile.service' import { ProfileService } from 'src/app/services/profile.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import * as navUtils from 'src/app/utils/navigation' import * as navUtils from 'src/app/utils/navigation'
@@ -60,10 +60,6 @@ describe('ProfileEditDialogComponent', () => {
providers: [NgbActiveModal, provideHttpClient(withInterceptorsFromDi())], providers: [NgbActiveModal, provideHttpClient(withInterceptorsFromDi())],
}) })
profileService = TestBed.inject(ProfileService) profileService = TestBed.inject(ProfileService)
jest.spyOn(profileService, 'get').mockReturnValue(NEVER)
jest
.spyOn(profileService, 'getSocialAccountProviders')
.mockReturnValue(of([]))
toastService = TestBed.inject(ToastService) toastService = TestBed.inject(ToastService)
clipboard = TestBed.inject(Clipboard) clipboard = TestBed.inject(Clipboard)
fixture = TestBed.createComponent(ProfileEditDialogComponent) fixture = TestBed.createComponent(ProfileEditDialogComponent)
@@ -159,7 +155,7 @@ describe('ProfileEditDialogComponent', () => {
'getSocialAccountProviders' 'getSocialAccountProviders'
) )
getProvidersSpy.mockReturnValue(of(socialAccountProviders)) getProvidersSpy.mockReturnValue(of(socialAccountProviders))
component.hasUsablePassword.set(true) component.hasUsablePassword = true
component.ngOnInit() component.ngOnInit()
component.form.get('password').patchValue('new*pass') component.form.get('password').patchValue('new*pass')
component.onPasswordKeyUp({ component.onPasswordKeyUp({
@@ -272,27 +268,6 @@ describe('ProfileEditDialogComponent', () => {
expect(getProvidersSpy).toHaveBeenCalled() expect(getProvidersSpy).toHaveBeenCalled()
}) })
it('should render social account providers after an async update', async () => {
const providers$ = new Subject<typeof socialAccountProviders>()
jest.spyOn(profileService, 'get').mockReturnValue(of(profile))
jest
.spyOn(profileService, 'getSocialAccountProviders')
.mockReturnValue(providers$)
component.ngOnInit()
await fixture.whenStable()
expect(
fixture.nativeElement.querySelector('a[href="https://example.com"]')
).toBeNull()
providers$.next(socialAccountProviders)
await fixture.whenStable()
expect(
fixture.nativeElement.querySelector('a[href="https://example.com"]')
).not.toBeNull()
})
it('should remove disconnected social account from component, show error if needed', () => { it('should remove disconnected social account from component, show error if needed', () => {
const disconnectSpy = jest.spyOn(profileService, 'disconnectSocialAccount') const disconnectSpy = jest.spyOn(profileService, 'disconnectSocialAccount')
const getSpy = jest.spyOn(profileService, 'get') const getSpy = jest.spyOn(profileService, 'get')
@@ -301,7 +276,7 @@ describe('ProfileEditDialogComponent', () => {
const errorSpy = jest.spyOn(toastService, 'showError') const errorSpy = jest.spyOn(toastService, 'showError')
expect(component.socialAccounts()).toContainEqual(socialAccount) expect(component.socialAccounts).toContainEqual(socialAccount)
// fail first // fail first
disconnectSpy.mockReturnValueOnce( disconnectSpy.mockReturnValueOnce(
@@ -314,7 +289,7 @@ describe('ProfileEditDialogComponent', () => {
disconnectSpy.mockReturnValue(of(socialAccount.id)) disconnectSpy.mockReturnValue(of(socialAccount.id))
component.disconnectSocialAccount(socialAccount.id) component.disconnectSocialAccount(socialAccount.id)
expect(disconnectSpy).toHaveBeenCalled() expect(disconnectSpy).toHaveBeenCalled()
expect(component.socialAccounts()).not.toContainEqual(socialAccount) expect(component.socialAccounts).not.toContainEqual(socialAccount)
}) })
it('should get totp settings', () => { it('should get totp settings', () => {
@@ -335,7 +310,7 @@ describe('ProfileEditDialogComponent', () => {
getSpy.mockReturnValue(of(settings)) getSpy.mockReturnValue(of(settings))
component.gettotpSettings() component.gettotpSettings()
expect(getSpy).toHaveBeenCalled() expect(getSpy).toHaveBeenCalled()
expect(component.totpSettings()).toEqual(settings) expect(component.totpSettings).toEqual(settings)
}) })
it('should activate totp', () => { it('should activate totp', () => {
@@ -344,15 +319,15 @@ describe('ProfileEditDialogComponent', () => {
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
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 = {
url: 'http://localhost/', url: 'http://localhost/',
qr_svg: 'svg', qr_svg: 'svg',
secret: 'secret', secret: 'secret',
}) }
component.form.get('totp_code').patchValue('123456') component.form.get('totp_code').patchValue('123456')
component.activateTotp() component.activateTotp()
expect(activateSpy).toHaveBeenCalledWith( expect(activateSpy).toHaveBeenCalledWith(
component.totpSettings().secret, component.totpSettings.secret,
component.form.get('totp_code').value component.form.get('totp_code').value
) )
expect(toastErrorSpy).toHaveBeenCalled() expect(toastErrorSpy).toHaveBeenCalled()
@@ -366,8 +341,8 @@ describe('ProfileEditDialogComponent', () => {
) )
component.activateTotp() component.activateTotp()
expect(toastInfoSpy).toHaveBeenCalled() expect(toastInfoSpy).toHaveBeenCalled()
expect(component.isTotpEnabled()).toBeTruthy() expect(component.isTotpEnabled).toBeTruthy()
expect(component.recoveryCodes()).toEqual(['1', '2', '3']) expect(component.recoveryCodes).toEqual(['1', '2', '3'])
}) })
it('should deactivate totp', () => { it('should deactivate totp', () => {
@@ -387,13 +362,13 @@ describe('ProfileEditDialogComponent', () => {
deactivateSpy.mockReturnValueOnce(of(true)) deactivateSpy.mockReturnValueOnce(of(true))
component.deactivateTotp() component.deactivateTotp()
expect(toastInfoSpy).toHaveBeenCalled() expect(toastInfoSpy).toHaveBeenCalled()
expect(component.isTotpEnabled()).toBeFalsy() expect(component.isTotpEnabled).toBeFalsy()
}) })
it('should copy recovery codes', () => { it('should copy recovery codes', () => {
jest.useFakeTimers() jest.useFakeTimers()
const copySpy = jest.spyOn(clipboard, 'copy') const copySpy = jest.spyOn(clipboard, 'copy')
component.recoveryCodes.set(['1', '2', '3']) component.recoveryCodes = ['1', '2', '3']
component.copyRecoveryCodes() component.copyRecoveryCodes()
expect(copySpy).toHaveBeenCalledWith('1\n2\n3') expect(copySpy).toHaveBeenCalledWith('1\n2\n3')
jest.advanceTimersByTime(3000) jest.advanceTimersByTime(3000)
@@ -56,14 +56,6 @@ export class ProfileEditDialogComponent
readonly showEmailConfirm = signal(false) readonly showEmailConfirm = signal(false)
readonly copied = signal(false) readonly copied = signal(false)
readonly codesCopied = signal(false) readonly codesCopied = signal(false)
readonly hasUsablePassword = signal(false)
readonly isTotpEnabled = signal(false)
readonly totpSettings = signal<TotpSettings>(undefined)
readonly totpSettingsLoading = signal(false)
readonly totpLoading = signal(false)
readonly recoveryCodes = signal<string[]>(undefined)
readonly socialAccounts = signal<SocialAccount[]>([])
readonly socialAccountProviders = signal<SocialAccountProvider[]>([])
public form = new FormGroup({ public form = new FormGroup({
email: new FormControl(''), email: new FormControl(''),
@@ -80,15 +72,25 @@ export class ProfileEditDialogComponent
private newPassword: string private newPassword: string
private passwordConfirm: string private passwordConfirm: string
public hasUsablePassword: boolean = false
private currentEmail: string private currentEmail: string
private newEmail: string private newEmail: string
private emailConfirm: string private emailConfirm: string
public isTotpEnabled: boolean = false
public totpSettings: TotpSettings
public totpSettingsLoading: boolean = false
public totpLoading: boolean = false
public recoveryCodes: string[]
public socialAccounts: SocialAccount[] = []
public socialAccountProviders: SocialAccountProvider[] = []
get qrSvgDataUrl(): string | null { get qrSvgDataUrl(): string | null {
if (!this.totpSettings()?.qr_svg) { if (!this.totpSettings?.qr_svg) {
return null return null
} }
return `data:image/svg+xml;utf8,${encodeURIComponent(this.totpSettings().qr_svg)}` return `data:image/svg+xml;utf8,${encodeURIComponent(this.totpSettings.qr_svg)}`
} }
ngOnInit(): void { ngOnInit(): void {
@@ -105,20 +107,20 @@ export class ProfileEditDialogComponent
this.onEmailChange() this.onEmailChange()
}) })
this.currentPassword = profile.password this.currentPassword = profile.password
this.hasUsablePassword.set(profile.has_usable_password) this.hasUsablePassword = profile.has_usable_password
this.form.get('password').valueChanges.subscribe((newPassword) => { this.form.get('password').valueChanges.subscribe((newPassword) => {
this.newPassword = newPassword this.newPassword = newPassword
this.onPasswordChange() this.onPasswordChange()
}) })
this.socialAccounts.set(profile.social_accounts ?? []) this.socialAccounts = profile.social_accounts
this.isTotpEnabled.set(profile.is_mfa_enabled) this.isTotpEnabled = profile.is_mfa_enabled
}) })
this.profileService this.profileService
.getSocialAccountProviders() .getSocialAccountProviders()
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((providers) => { .subscribe((providers) => {
this.socialAccountProviders.set(providers ?? []) this.socialAccountProviders = providers
}) })
} }
@@ -257,9 +259,7 @@ export class ProfileEditDialogComponent
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: (id: number) => { next: (id: number) => {
this.socialAccounts.update((accounts) => this.socialAccounts = this.socialAccounts.filter((a) => a.id != id)
accounts.filter((account) => account.id != id)
)
}, },
error: (error) => { error: (error) => {
this.toastService.showError( this.toastService.showError(
@@ -271,39 +271,36 @@ export class ProfileEditDialogComponent
} }
public gettotpSettings(): void { public gettotpSettings(): void {
this.totpSettingsLoading.set(true) this.totpSettingsLoading = true
this.profileService this.profileService
.getTotpSettings() .getTotpSettings()
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: (totpSettings) => { next: (totpSettings) => {
this.totpSettingsLoading.set(false) this.totpSettingsLoading = false
this.totpSettings.set(totpSettings) this.totpSettings = totpSettings
}, },
error: (error) => { error: (error) => {
this.toastService.showError( this.toastService.showError(
$localize`Error fetching TOTP settings`, $localize`Error fetching TOTP settings`,
error error
) )
this.totpSettingsLoading.set(false) this.totpSettingsLoading = false
}, },
}) })
} }
public activateTotp(): void { public activateTotp(): void {
this.totpLoading.set(true) this.totpLoading = true
this.form.get('totp_code').disable() this.form.get('totp_code').disable()
this.profileService this.profileService
.activateTotp( .activateTotp(this.totpSettings.secret, this.form.get('totp_code').value)
this.totpSettings().secret,
this.form.get('totp_code').value
)
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: (activationResponse) => { next: (activationResponse) => {
this.totpLoading.set(false) this.totpLoading = false
this.isTotpEnabled.set(activationResponse.success) this.isTotpEnabled = activationResponse.success
this.recoveryCodes.set(activationResponse.recovery_codes) this.recoveryCodes = 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.showInfo($localize`TOTP activated successfully`)
@@ -312,7 +309,7 @@ export class ProfileEditDialogComponent
} }
}, },
error: (error) => { error: (error) => {
this.totpLoading.set(false) this.totpLoading = false
this.form.get('totp_code').enable() this.form.get('totp_code').enable()
this.toastService.showError($localize`Error activating TOTP`, error) this.toastService.showError($localize`Error activating TOTP`, error)
}, },
@@ -320,15 +317,15 @@ export class ProfileEditDialogComponent
} }
public deactivateTotp(): void { public deactivateTotp(): void {
this.totpLoading.set(true) this.totpLoading = true
this.profileService this.profileService
.deactivateTotp() .deactivateTotp()
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: (success) => { next: (success) => {
this.totpLoading.set(false) this.totpLoading = false
this.isTotpEnabled.set(!success) this.isTotpEnabled = !success
this.recoveryCodes.set(null) this.recoveryCodes = null
if (success) { if (success) {
this.toastService.showInfo($localize`TOTP deactivated successfully`) this.toastService.showInfo($localize`TOTP deactivated successfully`)
} else { } else {
@@ -336,14 +333,14 @@ export class ProfileEditDialogComponent
} }
}, },
error: (error) => { error: (error) => {
this.totpLoading.set(false) this.totpLoading = false
this.toastService.showError($localize`Error deactivating TOTP`, error) this.toastService.showError($localize`Error deactivating TOTP`, error)
}, },
}) })
} }
public copyRecoveryCodes(): void { public copyRecoveryCodes(): void {
this.clipboard.copy(this.recoveryCodes().join('\n')) this.clipboard.copy(this.recoveryCodes.join('\n'))
this.codesCopied.set(true) this.codesCopied.set(true)
setTimeout(() => { setTimeout(() => {
this.codesCopied.set(false) this.codesCopied.set(false)
@@ -65,9 +65,8 @@
<dd class="col-sm-8"><code>{{ createdBundle.slug }}</code></dd> <dd class="col-sm-8"><code>{{ createdBundle.slug }}</code></dd>
<dt class="col-sm-4" i18n>Link</dt> <dt class="col-sm-4" i18n>Link</dt>
<dd class="col-sm-8"> <dd class="col-sm-8">
<label class="visually-hidden" for="shareBundleLink" i18n>Share link</label>
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<input id="shareBundleLink" class="form-control" type="text" [value]="getShareUrl(createdBundle)" readonly> <input class="form-control" type="text" [value]="getShareUrl(createdBundle)" readonly>
<button <button
class="btn btn-outline-primary" class="btn btn-outline-primary"
type="button" type="button"
@@ -14,9 +14,7 @@
} }
<div content class="wrapper fade" [class.show]="show()"> <div content class="wrapper fade" [class.show]="show()">
@if (error()) { @if (displayMode() === DisplayMode.TABLE) {
<div class="alert alert-danger mb-0" role="alert"><ng-container i18n>Error while loading documents</ng-container>: {{error()}}</div>
} @else if (displayMode() === DisplayMode.TABLE) {
<table class="table table-hover mb-0 mt-n2 align-middle"> <table class="table table-hover mb-0 mt-n2 align-middle">
<thead> <thead>
<tr> <tr>
@@ -1,10 +1,6 @@
import { DragDropModule } from '@angular/cdk/drag-drop' import { DragDropModule } from '@angular/cdk/drag-drop'
import { DatePipe } from '@angular/common' import { DatePipe } from '@angular/common'
import { import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
HttpErrorResponse,
provideHttpClient,
withInterceptorsFromDi,
} from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing' import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser' import { By } from '@angular/platform-browser'
@@ -12,7 +8,7 @@ import { Router } from '@angular/router'
import { RouterTestingModule } from '@angular/router/testing' import { RouterTestingModule } from '@angular/router/testing'
import { NgbModule } from '@ng-bootstrap/ng-bootstrap' import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { Subject, of, throwError } from 'rxjs' import { Subject, of } from 'rxjs'
import { routes } from 'src/app/app-routing.module' import { routes } from 'src/app/app-routing.module'
import { CustomFieldDisplayComponent } from 'src/app/components/common/custom-field-display/custom-field-display.component' import { CustomFieldDisplayComponent } from 'src/app/components/common/custom-field-display/custom-field-display.component'
import { PreviewPopupComponent } from 'src/app/components/common/preview-popup/preview-popup.component' import { PreviewPopupComponent } from 'src/app/components/common/preview-popup/preview-popup.component'
@@ -234,27 +230,6 @@ describe('SavedViewWidgetComponent', () => {
expect(component.documents()).toEqual(documentResults) expect(component.documents()).toEqual(documentResults)
}) })
it('should show an error if documents fail to load', () => {
jest.spyOn(documentService, 'listFiltered').mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
error: { added__date__lte: ['Enter a valid date.'] },
status: 400,
})
)
)
component.reload()
fixture.detectChanges()
expect(component.loading()).toBe(false)
expect(component.error()).toEqual('Added: Enter a valid date.')
expect(fixture.debugElement.nativeElement.textContent).toContain(
'Error while loading documents: Added: Enter a valid date.'
)
})
it('should reload on document consumption finished', () => { it('should reload on document consumption finished', () => {
const fileStatusSubject = new Subject<FileStatus>() const fileStatusSubject = new Subject<FileStatus>()
jest jest
@@ -125,8 +125,6 @@ export class SavedViewWidgetComponent
readonly count = signal<number>(null) readonly count = signal<number>(null)
readonly error = signal<string | null>(null)
placeholderRows: number[] = [] placeholderRows: number[] = []
ngOnInit(): void { ngOnInit(): void {
@@ -182,7 +180,6 @@ export class SavedViewWidgetComponent
reload() { reload() {
this.loading.set(this.documents().length == 0) this.loading.set(this.documents().length == 0)
this.error.set(null)
this.show.set(true) this.show.set(true)
this.documentService this.documentService
.listFiltered( .listFiltered(
@@ -194,40 +191,12 @@ export class SavedViewWidgetComponent
{ truncate_content: true } { truncate_content: true }
) )
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe((result) => {
next: (result) => { this.documents.set(result.results)
this.documents.set(result.results) this.count.set(result.count)
this.count.set(result.count) this.savedViewService.setDocumentCount(this.savedView, result.count)
this.savedViewService.setDocumentCount(this.savedView, result.count) this.loading.set(false)
this.loading.set(false) this.show.set(true)
this.show.set(true)
},
error: (error) => {
this.documents.set([])
this.count.set(null)
let errorMessage
if (
typeof error.error === 'object' &&
Object.keys(error.error).length > 0
) {
errorMessage = Object.keys(error.error)
.map((fieldName) => {
const fieldNameBase = fieldName.split('__')[0]
const fieldError: Array<string> = error.error[fieldName]
return `${
this.documentService.sortFields.find(
(f) => f.field?.split('__')[0] == fieldNameBase
)?.name ?? fieldNameBase
}: ${fieldError[0]}`
})
.join(', ')
} else {
errorMessage = error.error
}
this.error.set(errorMessage)
this.loading.set(false)
this.show.set(true)
},
}) })
} }
@@ -1 +1 @@
<p i18n>Searching document with asn {{asn()}}</p> <p i18n>Searching document with asn {{asn}}</p>
@@ -1,4 +1,4 @@
import { Component, OnInit, inject, signal } from '@angular/core' import { Component, OnInit, inject } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router' import { ActivatedRoute, Router } from '@angular/router'
import { FILTER_ASN } from '../../data/filter-rule-type' import { FILTER_ASN } from '../../data/filter-rule-type'
import { DocumentService } from '../../services/rest/document.service' import { DocumentService } from '../../services/rest/document.service'
@@ -13,13 +13,13 @@ export class DocumentAsnComponent implements OnInit {
private route = inject(ActivatedRoute) private route = inject(ActivatedRoute)
private router = inject(Router) private router = inject(Router)
readonly asn = signal<string>(undefined) asn: string
ngOnInit(): void { ngOnInit(): void {
this.route.paramMap.subscribe((paramMap) => { this.route.paramMap.subscribe((paramMap) => {
this.asn.set(paramMap.get('id')) this.asn = paramMap.get('id')
this.documentsService this.documentsService
.listAllFilteredIds([{ rule_type: FILTER_ASN, value: this.asn() }]) .listAllFilteredIds([{ rule_type: FILTER_ASN, value: this.asn }])
.subscribe((documentId) => { .subscribe((documentId) => {
if (documentId.length == 1) { if (documentId.length == 1) {
this.router.navigate(['documents', documentId[0]]) this.router.navigate(['documents', documentId[0]])
@@ -82,6 +82,23 @@
<i-bs name="pencil" class="me-1"></i-bs><ng-container i18n>PDF Editor</ng-container> <i-bs name="pencil" class="me-1"></i-bs><ng-container i18n>PDF Editor</ng-container>
</button> </button>
<button
ngbDropdownItem
(click)="runZoneOcr()"
[disabled]="!userCanEdit || !document?.document_type"
*pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.OcrTemplate }"
>
<i-bs width="1em" height="1em" name="file-earmark-ruled" class="me-1"></i-bs><span i18n>Run Zone OCR</span>
</button>
<button
ngbDropdownItem
(click)="createOcrTemplate()"
*pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.OcrTemplate }"
>
<i-bs width="1em" height="1em" name="file-earmark-medical" class="me-1"></i-bs><span i18n>Create OCR Template</span>
</button>
@if (userIsOwner && (requiresPassword || password)) { @if (userIsOwner && (requiresPassword || password)) {
<button ngbDropdownItem (click)="removePassword()" [disabled]="!password"> <button ngbDropdownItem (click)="removePassword()" [disabled]="!password">
<i-bs name="unlock" class="me-1"></i-bs><ng-container i18n>Remove Password</ng-container> <i-bs name="unlock" class="me-1"></i-bs><ng-container i18n>Remove Password</ng-container>
@@ -315,7 +332,7 @@
<td>{{metadata()?.original_filename}}</td> <td>{{metadata()?.original_filename}}</td>
</tr> </tr>
<tr> <tr>
<td i18n>Original SHA256 checksum</td> <td i18n>Original MD5 checksum</td>
<td>{{metadata()?.original_checksum}}</td> <td>{{metadata()?.original_checksum}}</td>
</tr> </tr>
<tr> <tr>
@@ -328,7 +345,7 @@
</tr> </tr>
@if (metadata()?.has_archive_version) { @if (metadata()?.has_archive_version) {
<tr> <tr>
<td i18n>Archive SHA256 checksum</td> <td i18n>Archive MD5 checksum</td>
<td>{{metadata()?.archive_checksum}}</td> <td>{{metadata()?.archive_checksum}}</td>
</tr> </tr>
} }
@@ -472,7 +489,6 @@
<div class="preview-sticky pdf-viewer-container"> <div class="preview-sticky pdf-viewer-container">
<pngx-pdf-viewer <pngx-pdf-viewer
[src]="pdfSource()" [src]="pdfSource()"
[sourceRevision]="previewRevision()"
[password]="pdfPassword()" [password]="pdfPassword()"
[renderMode]="PdfRenderMode.All" [renderMode]="PdfRenderMode.All"
[page]="previewCurrentPage()" (pageChange)="previewCurrentPage.set($event)" [page]="previewCurrentPage()" (pageChange)="previewCurrentPage.set($event)"
@@ -483,7 +499,9 @@
</pngx-pdf-viewer> </pngx-pdf-viewer>
</div> </div>
} @else { } @else {
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%"></object> <object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%">
<span>Preview is unavailable.</span>
</object>
} }
} }
@case (ContentRenderType.Text) { @case (ContentRenderType.Text) {
@@ -504,7 +522,9 @@
} }
} }
@case (ContentRenderType.Other) { @case (ContentRenderType.Other) {
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%"></object> <object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%">
<span>Preview is unavailable.</span>
</object>
} }
} }
@if (requiresPassword) { @if (requiresPassword) {
@@ -510,7 +510,7 @@ describe('DocumentDetailComponent', () => {
const deleteButtons = fixture.debugElement.queryAll( const deleteButtons = fixture.debugElement.queryAll(
By.css('pngx-confirm-button') By.css('pngx-confirm-button')
) )
expect(deleteButtons).toHaveLength(1) expect(deleteButtons.length).toEqual(1)
}) })
it('should fall back to details tab when duplicates tab is active but no duplicates', () => { it('should fall back to details tab when duplicates tab is active but no duplicates', () => {
@@ -1602,7 +1602,6 @@ describe('DocumentDetailComponent', () => {
expect(openDoc.__changedFields).toEqual([]) expect(openDoc.__changedFields).toEqual([])
expect(setDirtySpy).toHaveBeenCalledWith(openDoc, false) expect(setDirtySpy).toHaveBeenCalledWith(openDoc, false)
expect(saveSpy).toHaveBeenCalled() expect(saveSpy).toHaveBeenCalled()
expect(component.previewRevision()).toBe(1)
}) })
it('should ignore incoming update for a different document id', () => { it('should ignore incoming update for a different document id', () => {
@@ -249,7 +249,6 @@ export class DocumentDetailComponent
titleSubject: Subject<string> = new Subject() titleSubject: Subject<string> = new Subject()
readonly previewUrl = signal<string>(undefined) readonly previewUrl = signal<string>(undefined)
readonly pdfSource = signal<string>(undefined) readonly pdfSource = signal<string>(undefined)
readonly previewRevision = signal(0)
readonly pdfPassword = signal<string>(undefined) readonly pdfPassword = signal<string>(undefined)
readonly thumbUrl = signal<string>(undefined) readonly thumbUrl = signal<string>(undefined)
readonly previewText = signal<string>(undefined) readonly previewText = signal<string>(undefined)
@@ -610,9 +609,6 @@ export class DocumentDetailComponent
.subscribe() .subscribe()
} }
this.updateComponent(useDoc) this.updateComponent(useDoc)
if (forceRemote) {
this.previewRevision.update((revision) => revision + 1)
}
this.titleSubject this.titleSubject
.pipe( .pipe(
debounceTime(1000), debounceTime(1000),
@@ -1433,6 +1429,48 @@ export class DocumentDetailComponent
}) })
} }
runZoneOcr() {
this.documentsService.runZoneOcr(this.document.id).subscribe({
next: (res) => {
const results = res.results ?? []
if (results.length) {
const failed = results.filter(
(r) =>
r.value === null ||
r.value === undefined ||
`${r.value}`.trim() === ''
)
const filled = results.length - failed.length
let msg = $localize`Filled ${filled} of ${results.length} fields`
if (failed.length) {
const names = failed.map((r) => r.zone).join(', ')
msg = `${msg}. ${$localize`Failed to match zones: ${names}`}`
}
this.toastService.showInfo(msg)
} else {
this.toastService.showInfo(
$localize`Zone OCR ran but no results extracted.`
)
}
this.documentsService
.get(this.documentId)
.subscribe((doc) => this.updateComponent(doc))
},
error: (error) => {
this.toastService.showError($localize`Zone OCR failed`, error)
},
})
}
createOcrTemplate() {
this.router.navigate(['/ocr-templates', 'new'], {
queryParams: {
document_type: this.document.document_type,
sample_document: this.document.id,
},
})
}
private getSelectedNonLatestVersionId(): number | null { private getSelectedNonLatestVersionId(): number | null {
const versions = this.document()?.versions ?? [] const versions = this.document()?.versions ?? []
if (!versions.length || !this.selectedVersionId()) { if (!versions.length || !this.selectedVersionId()) {
@@ -1,6 +1,6 @@
@if (loading()) { @if (loading()) {
<div class="d-flex"> <div class="d-flex">
<output class="spinner-border spinner-border-sm fw-normal"></output> <output class="spinner-border spinner-border-sm fw-normal" role="status"></output>
</div> </div>
} @else { } @else {
<ul class="list-group"> <ul class="list-group">
@@ -26,11 +26,11 @@ import { UserService } from 'src/app/services/rest/user.service'
], ],
}) })
export class DocumentHistoryComponent implements OnInit { export class DocumentHistoryComponent implements OnInit {
private readonly documentService = inject(DocumentService) private documentService = inject(DocumentService)
private readonly correspondentService = inject(CorrespondentService) private correspondentService = inject(CorrespondentService)
private readonly storagePathService = inject(StoragePathService) private storagePathService = inject(StoragePathService)
private readonly documentTypeService = inject(DocumentTypeService) private documentTypeService = inject(DocumentTypeService)
private readonly userService = inject(UserService) private userService = inject(UserService)
public AuditLogAction = AuditLogAction public AuditLogAction = AuditLogAction
@@ -70,7 +70,7 @@ export class DocumentHistoryComponent implements OnInit {
return cached return cached
} }
const idInt = Number.parseInt(id, 10) const idInt = parseInt(id, 10)
const fallback$ = of(id) const fallback$ = of(id)
let result$: Observable<string> let result$: Observable<string>
@@ -5,11 +5,10 @@
</button> </button>
<div class="dropdown-menu shadow" ngbDropdownMenu> <div class="dropdown-menu shadow" ngbDropdownMenu>
<div class="px-3 py-2 mb-2"> <div class="px-3 py-2 mb-2">
@if (versionUploadState() === UploadState.Idle) { @if (versionUploadState === UploadState.Idle) {
<div class="input-group input-group-sm mb-2"> <div class="input-group input-group-sm mb-2">
<label class="input-group-text" for="newVersionLabel" i18n>Label</label> <span class="input-group-text" i18n>Label</span>
<input <input
id="newVersionLabel"
class="form-control" class="form-control"
type="text" type="text"
[(ngModel)]="newVersionLabel" [(ngModel)]="newVersionLabel"
@@ -32,7 +31,7 @@
<i-bs name="file-earmark-plus"></i-bs><span class="ps-1" i18n>Add new version</span> <i-bs name="file-earmark-plus"></i-bs><span class="ps-1" i18n>Add new version</span>
</button> </button>
} @else { } @else {
@switch (versionUploadState()) { @switch (versionUploadState) {
@case (UploadState.Uploading) { @case (UploadState.Uploading) {
<div class="small text-muted mt-1 d-flex align-items-center"> <div class="small text-muted mt-1 d-flex align-items-center">
<output class="spinner-border spinner-border-sm me-2" aria-hidden="true"></output> <output class="spinner-border spinner-border-sm me-2" aria-hidden="true"></output>
@@ -50,8 +49,8 @@
<span i18n>Version upload failed.</span> <span i18n>Version upload failed.</span>
<button type="button" class="btn btn-link btn-sm p-0 ms-2" (click)="clearVersionUploadStatus()" i18n>Dismiss</button> <button type="button" class="btn btn-link btn-sm p-0 ms-2" (click)="clearVersionUploadStatus()" i18n>Dismiss</button>
</div> </div>
@if (versionUploadError()) { @if (versionUploadError) {
<div class="small text-muted mt-1">{{ versionUploadError() }}</div> <div class="small text-muted mt-1">{{ versionUploadError }}</div>
} }
} }
} }
@@ -71,19 +70,15 @@
} }
</div> </div>
<div class="d-flex flex-column"> <div class="d-flex flex-column">
@if (isEditingVersion(version.id)) {
<label class="visually-hidden" for="versionLabel{{ version.id }}" i18n>Version label</label>
}
<div class="input-group input-group-sm mb-1"> <div class="input-group input-group-sm mb-1">
@if (isEditingVersion(version.id)) { @if (isEditingVersion(version.id)) {
<input <input
id="versionLabel{{ version.id }}"
class="form-control" class="form-control"
type="text" type="text"
[(ngModel)]="versionLabelDraft" [(ngModel)]="versionLabelDraft"
i18n-placeholder i18n-placeholder
placeholder="Version label" placeholder="Version label"
[disabled]="savingVersionLabelId() !== null" [disabled]="savingVersionLabelId !== null"
(keydown.enter)="submitEditedVersionLabel(version, $event)" (keydown.enter)="submitEditedVersionLabel(version, $event)"
(keydown.escape)="cancelEditingVersion($event)" (keydown.escape)="cancelEditingVersion($event)"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
@@ -101,7 +96,7 @@
<button <button
type="button" type="button"
class="btn btn-outline-secondary" class="btn btn-outline-secondary"
[disabled]="savingVersionLabelId() !== null" [disabled]="savingVersionLabelId !== null"
(click)="isEditingVersion(version.id) ? submitEditedVersionLabel(version, $event) : beginEditingVersion(version, $event)" (click)="isEditingVersion(version.id) ? submitEditedVersionLabel(version, $event) : beginEditingVersion(version, $event)"
> >
@if (isEditingVersion(version.id)) { @if (isEditingVersion(version.id)) {
@@ -205,7 +205,7 @@ describe('DocumentVersionDropdownComponent', () => {
{ id: 3, is_root: true, checksum: 'aaaa' }, { id: 3, is_root: true, checksum: 'aaaa' },
{ id: 10, is_root: false, checksum: 'bbbb', version_label: 'Updated' }, { id: 10, is_root: false, checksum: 'bbbb', version_label: 'Updated' },
]) ])
expect(component.savingVersionLabelId()).toBeNull() expect(component.savingVersionLabelId).toBeNull()
}) })
it('saveVersionLabel should show error toast on failure', () => { it('saveVersionLabel should show error toast on failure', () => {
@@ -218,7 +218,7 @@ describe('DocumentVersionDropdownComponent', () => {
'Error updating version label', 'Error updating version label',
error error
) )
expect(component.savingVersionLabelId()).toBeNull() expect(component.savingVersionLabelId).toBeNull()
}) })
it('onVersionFileSelected should upload and update versions after websocket success', () => { it('onVersionFileSelected should upload and update versions after websocket success', () => {
@@ -252,11 +252,11 @@ describe('DocumentVersionDropdownComponent', () => {
expect(versionsEmitSpy).toHaveBeenCalledWith(versions) expect(versionsEmitSpy).toHaveBeenCalledWith(versions)
expect(selectedEmitSpy).toHaveBeenCalledWith(20) expect(selectedEmitSpy).toHaveBeenCalledWith(20)
expect(component.newVersionLabel).toEqual('') expect(component.newVersionLabel).toEqual('')
expect(component.versionUploadState()).toEqual(UploadState.Idle) expect(component.versionUploadState).toEqual(UploadState.Idle)
expect(component.versionUploadError()).toBeNull() expect(component.versionUploadError).toBeNull()
}) })
it('onVersionFileSelected should render failed state after websocket failure', async () => { it('onVersionFileSelected should set failed state after websocket failure', () => {
const file = new File(['test'], 'new-version.pdf', { const file = new File(['test'], 'new-version.pdf', {
type: 'application/pdf', type: 'application/pdf',
}) })
@@ -266,11 +266,9 @@ describe('DocumentVersionDropdownComponent', () => {
component.onVersionFileSelected({ target: input } as Event) component.onVersionFileSelected({ target: input } as Event)
failed$.next({ taskId: 'task-1', message: 'processing failed' }) failed$.next({ taskId: 'task-1', message: 'processing failed' })
await fixture.whenStable()
expect(component.versionUploadState()).toEqual(UploadState.Failed) expect(component.versionUploadState).toEqual(UploadState.Failed)
expect(component.versionUploadError()).toEqual('processing failed') expect(component.versionUploadError).toEqual('processing failed')
expect(fixture.nativeElement.textContent).toContain('processing failed')
expect(documentService.getVersions).not.toHaveBeenCalled() expect(documentService.getVersions).not.toHaveBeenCalled()
}) })
@@ -284,8 +282,8 @@ describe('DocumentVersionDropdownComponent', () => {
component.onVersionFileSelected({ target: input } as Event) component.onVersionFileSelected({ target: input } as Event)
expect(component.versionUploadState()).toEqual(UploadState.Failed) expect(component.versionUploadState).toEqual(UploadState.Failed)
expect(component.versionUploadError()).toEqual('Missing task ID.') expect(component.versionUploadError).toEqual('Missing task ID.')
expect(documentService.getVersions).not.toHaveBeenCalled() expect(documentService.getVersions).not.toHaveBeenCalled()
}) })
@@ -300,8 +298,8 @@ describe('DocumentVersionDropdownComponent', () => {
component.onVersionFileSelected({ target: input } as Event) component.onVersionFileSelected({ target: input } as Event)
expect(component.versionUploadState()).toEqual(UploadState.Failed) expect(component.versionUploadState).toEqual(UploadState.Failed)
expect(component.versionUploadError()).toEqual('upload failed') expect(component.versionUploadError).toEqual('upload failed')
expect(toastService.showError).toHaveBeenCalledWith( expect(toastService.showError).toHaveBeenCalledWith(
'Error uploading new version', 'Error uploading new version',
error error
@@ -309,8 +307,8 @@ describe('DocumentVersionDropdownComponent', () => {
}) })
it('ngOnChanges should clear upload status on document switch', () => { it('ngOnChanges should clear upload status on document switch', () => {
component.versionUploadState.set(UploadState.Failed) component.versionUploadState = UploadState.Failed
component.versionUploadError.set('something failed') component.versionUploadError = 'something failed'
component.editingVersionId = 10 component.editingVersionId = 10
component.versionLabelDraft = 'draft' component.versionLabelDraft = 'draft'
@@ -318,8 +316,8 @@ describe('DocumentVersionDropdownComponent', () => {
documentId: new SimpleChange(3, 4, false), documentId: new SimpleChange(3, 4, false),
}) })
expect(component.versionUploadState()).toEqual(UploadState.Idle) expect(component.versionUploadState).toEqual(UploadState.Idle)
expect(component.versionUploadError()).toBeNull() expect(component.versionUploadError).toBeNull()
expect(component.editingVersionId).toBeNull() expect(component.editingVersionId).toBeNull()
expect(component.versionLabelDraft).toEqual('') expect(component.versionLabelDraft).toEqual('')
}) })
@@ -7,7 +7,6 @@ import {
OnChanges, OnChanges,
OnDestroy, OnDestroy,
Output, Output,
signal,
SimpleChanges, SimpleChanges,
} from '@angular/core' } from '@angular/core'
import { FormsModule } from '@angular/forms' import { FormsModule } from '@angular/forms'
@@ -60,9 +59,9 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
@Output() versionsUpdated = new EventEmitter<DocumentVersionInfo[]>() @Output() versionsUpdated = new EventEmitter<DocumentVersionInfo[]>()
newVersionLabel: string = '' newVersionLabel: string = ''
readonly versionUploadState = signal(UploadState.Idle) versionUploadState: UploadState = UploadState.Idle
readonly versionUploadError = signal<string | null>(null) versionUploadError: string | null = null
readonly savingVersionLabelId = signal<number | null>(null) savingVersionLabelId: number | null = null
editingVersionId: number | null = null editingVersionId: number | null = null
versionLabelDraft: string = '' versionLabelDraft: string = ''
@@ -102,7 +101,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
beginEditingVersion(version: DocumentVersionInfo, event?: Event): void { beginEditingVersion(version: DocumentVersionInfo, event?: Event): void {
event?.preventDefault() event?.preventDefault()
event?.stopPropagation() event?.stopPropagation()
if (!this.canEditLabels || this.savingVersionLabelId() !== null) return if (!this.canEditLabels || this.savingVersionLabelId !== null) return
this.editingVersionId = version.id this.editingVersionId = version.id
this.versionLabelDraft = version.version_label ?? '' this.versionLabelDraft = version.version_label ?? ''
} }
@@ -117,7 +116,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
submitEditedVersionLabel(version: DocumentVersionInfo, event?: Event): void { submitEditedVersionLabel(version: DocumentVersionInfo, event?: Event): void {
event?.preventDefault() event?.preventDefault()
event?.stopPropagation() event?.stopPropagation()
if (this.savingVersionLabelId() !== null) return if (this.savingVersionLabelId !== null) return
const nextLabel = this.versionLabelDraft?.trim() || null const nextLabel = this.versionLabelDraft?.trim() || null
const currentLabel = version.version_label?.trim() || null const currentLabel = version.version_label?.trim() || null
if (nextLabel === currentLabel) { if (nextLabel === currentLabel) {
@@ -159,15 +158,15 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
} }
saveVersionLabel(versionId: number, versionLabel: string | null): void { saveVersionLabel(versionId: number, versionLabel: string | null): void {
if (this.savingVersionLabelId() !== null) return if (this.savingVersionLabelId !== null) return
this.savingVersionLabelId.set(versionId) this.savingVersionLabelId = versionId
this.documentsService this.documentsService
.updateVersionLabel(this.documentId, versionId, versionLabel) .updateVersionLabel(this.documentId, versionId, versionLabel)
.pipe( .pipe(
first(), first(),
finalize(() => { finalize(() => {
if (this.savingVersionLabelId() === versionId) { if (this.savingVersionLabelId === versionId) {
this.savingVersionLabelId.set(null) this.savingVersionLabelId = null
} }
}), }),
takeUntil(this.destroy$) takeUntil(this.destroy$)
@@ -200,8 +199,8 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
const file = input.files[0] const file = input.files[0]
input.value = '' input.value = ''
const label = this.newVersionLabel?.trim() const label = this.newVersionLabel?.trim()
this.versionUploadState.set(UploadState.Uploading) this.versionUploadState = UploadState.Uploading
this.versionUploadError.set(null) this.versionUploadError = null
this.documentsService this.documentsService
.uploadVersion(uploadDocumentId, file, label) .uploadVersion(uploadDocumentId, file, label)
.pipe( .pipe(
@@ -211,7 +210,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
$localize`Uploading new version. Processing will happen in the background.` $localize`Uploading new version. Processing will happen in the background.`
) )
this.newVersionLabel = '' this.newVersionLabel = ''
this.versionUploadState.set(UploadState.Processing) this.versionUploadState = UploadState.Processing
}), }),
map((taskId) => map((taskId) =>
typeof taskId === 'string' typeof taskId === 'string'
@@ -220,8 +219,8 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
), ),
switchMap((taskId) => { switchMap((taskId) => {
if (!taskId) { if (!taskId) {
this.versionUploadState.set(UploadState.Failed) this.versionUploadState = UploadState.Failed
this.versionUploadError.set($localize`Missing task ID.`) this.versionUploadError = $localize`Missing task ID.`
return of(null) return of(null)
} }
return merge( return merge(
@@ -241,10 +240,9 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
switchMap((result) => { switchMap((result) => {
if (result?.state !== 'success') { if (result?.state !== 'success') {
if (result?.state === 'failed') { if (result?.state === 'failed') {
this.versionUploadState.set(UploadState.Failed) this.versionUploadState = UploadState.Failed
this.versionUploadError.set( this.versionUploadError =
result.message || $localize`Upload failed.` result.message || $localize`Upload failed.`
)
} }
return of(null) return of(null)
} }
@@ -266,10 +264,8 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
}, },
error: (error) => { error: (error) => {
if (uploadDocumentId !== this.documentId) return if (uploadDocumentId !== this.documentId) return
this.versionUploadState.set(UploadState.Failed) this.versionUploadState = UploadState.Failed
this.versionUploadError.set( this.versionUploadError = error?.message || $localize`Upload failed.`
error?.message || $localize`Upload failed.`
)
this.toastService.showError( this.toastService.showError(
$localize`Error uploading new version`, $localize`Error uploading new version`,
error error
@@ -279,7 +275,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
} }
clearVersionUploadStatus(): void { clearVersionUploadStatus(): void {
this.versionUploadState.set(UploadState.Idle) this.versionUploadState = UploadState.Idle
this.versionUploadError.set(null) this.versionUploadError = null
} }
} }
@@ -10,7 +10,7 @@
[createRef]="createTag.bind(this)" [createRef]="createTag.bind(this)"
(opened)="openTagsDropdown()" (opened)="openTagsDropdown()"
[(selectionModel)]="tagSelectionModel" [(selectionModel)]="tagSelectionModel"
[documentCounts]="tagDocumentCounts()" [documentCounts]="tagDocumentCounts"
(apply)="setTags($event)" (apply)="setTags($event)"
shortcutKey="t"> shortcutKey="t">
</pngx-filterable-dropdown> </pngx-filterable-dropdown>
@@ -24,7 +24,7 @@
[createRef]="createCorrespondent.bind(this)" [createRef]="createCorrespondent.bind(this)"
(opened)="openCorrespondentDropdown()" (opened)="openCorrespondentDropdown()"
[(selectionModel)]="correspondentSelectionModel" [(selectionModel)]="correspondentSelectionModel"
[documentCounts]="correspondentDocumentCounts()" [documentCounts]="correspondentDocumentCounts"
(apply)="setCorrespondents($event)" (apply)="setCorrespondents($event)"
shortcutKey="y"> shortcutKey="y">
</pngx-filterable-dropdown> </pngx-filterable-dropdown>
@@ -38,7 +38,7 @@
[createRef]="createDocumentType.bind(this)" [createRef]="createDocumentType.bind(this)"
(opened)="openDocumentTypeDropdown()" (opened)="openDocumentTypeDropdown()"
[(selectionModel)]="documentTypeSelectionModel" [(selectionModel)]="documentTypeSelectionModel"
[documentCounts]="documentTypeDocumentCounts()" [documentCounts]="documentTypeDocumentCounts"
(apply)="setDocumentTypes($event)" (apply)="setDocumentTypes($event)"
shortcutKey="u"> shortcutKey="u">
</pngx-filterable-dropdown> </pngx-filterable-dropdown>
@@ -52,7 +52,7 @@
[createRef]="createStoragePath.bind(this)" [createRef]="createStoragePath.bind(this)"
(opened)="openStoragePathDropdown()" (opened)="openStoragePathDropdown()"
[(selectionModel)]="storagePathsSelectionModel" [(selectionModel)]="storagePathsSelectionModel"
[documentCounts]="storagePathDocumentCounts()" [documentCounts]="storagePathDocumentCounts"
(apply)="setStoragePaths($event)" (apply)="setStoragePaths($event)"
shortcutKey="i"> shortcutKey="i">
</pngx-filterable-dropdown> </pngx-filterable-dropdown>
@@ -66,7 +66,7 @@
[createRef]="createCustomField.bind(this)" [createRef]="createCustomField.bind(this)"
(opened)="openCustomFieldsDropdown()" (opened)="openCustomFieldsDropdown()"
[(selectionModel)]="customFieldsSelectionModel" [(selectionModel)]="customFieldsSelectionModel"
[documentCounts]="customFieldDocumentCounts()" [documentCounts]="customFieldDocumentCounts"
extraButtonTitle="Set values" extraButtonTitle="Set values"
i18n-extraButtonTitle i18n-extraButtonTitle
(extraButton)="setCustomFieldValues($event)" (extraButton)="setCustomFieldValues($event)"
@@ -95,6 +95,9 @@
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.allSelected || list.selectedCount < 2"> <button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.allSelected || list.selectedCount < 2">
<i-bs name="journals" class="me-1"></i-bs><ng-container i18n>Merge</ng-container> <i-bs name="journals" class="me-1"></i-bs><ng-container i18n>Merge</ng-container>
</button> </button>
<button ngbDropdownItem (click)="runZoneOcrSelected()" [disabled]="!userCanEditAll || list.allSelected">
<i-bs name="file-earmark-ruled" class="me-1"></i-bs><ng-container i18n>Run Zone OCR</ng-container>
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -103,13 +106,13 @@
class="btn btn-sm btn-outline-primary" class="btn btn-sm btn-outline-primary"
id="dropdownSend" id="dropdownSend"
ngbDropdownToggle ngbDropdownToggle
[disabled]="disabled || !canSendSelection" [disabled]="disabled || !list.hasSelection || list.allSelected"
> >
<i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container> <i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container>
</div> </div>
</button> </button>
<div ngbDropdownMenu aria-labelledby="dropdownSend" class="shadow"> <div ngbDropdownMenu aria-labelledby="dropdownSend" class="shadow">
<button ngbDropdownItem (click)="createShareLinkBundle()" [disabled]="!canSendSelection"> <button ngbDropdownItem (click)="createShareLinkBundle()" [disabled]="list.allSelected">
<i-bs name="link" class="me-1"></i-bs><ng-container i18n>Create a share link bundle</ng-container> <i-bs name="link" class="me-1"></i-bs><ng-container i18n>Create a share link bundle</ng-container>
</button> </button>
<button ngbDropdownItem (click)="manageShareLinkBundles()"> <button ngbDropdownItem (click)="manageShareLinkBundles()">
@@ -117,18 +120,18 @@
</button> </button>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
@if (emailEnabled) { @if (emailEnabled) {
<button ngbDropdownItem (click)="emailSelected()" [disabled]="!canSendSelection"> <button ngbDropdownItem (click)="emailSelected()" [disabled]="list.allSelected">
<i-bs name="envelope" class="me-1"></i-bs><ng-container i18n>Email</ng-container> <i-bs name="envelope" class="me-1"></i-bs><ng-container i18n>Email</ng-container>
</button> </button>
} }
</div> </div>
</div> </div>
<div class="btn-group btn-group-sm"> <div class="btn-group btn-group-sm">
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload()" (click)="downloadSelected()"> <button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()">
@if (!awaitingDownload()) { @if (!awaitingDownload) {
<i-bs name="arrow-down"></i-bs> <i-bs name="arrow-down"></i-bs>
} }
@if (awaitingDownload()) { @if (awaitingDownload) {
<div class="spinner-border spinner-border-sm" role="status"> <div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Preparing download...</span> <span class="visually-hidden">Preparing download...</span>
</div> </div>
@@ -208,50 +208,6 @@ describe('BulkEditorComponent', () => {
expect(component.tagSelectionModel.selectionSize()).toEqual(1) expect(component.tagSelectionModel.selectionSize()).toEqual(1)
}) })
it('should allow sending an all-filtered selection that fits on the current page', () => {
jest
.spyOn(documentListViewService, 'hasSelection', 'get')
.mockReturnValue(true)
jest
.spyOn(documentListViewService, 'allSelected', 'get')
.mockReturnValue(true)
jest
.spyOn(documentListViewService, 'selectedCount', 'get')
.mockReturnValue(5)
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([1, 2, 3, 4, 5]))
fixture.detectChanges()
expect(component.canSendSelection).toBe(true)
expect(
fixture.debugElement.query(By.css('#dropdownSend')).nativeElement.disabled
).toBe(false)
})
it('should prevent sending an all-filtered selection spanning multiple pages', () => {
jest
.spyOn(documentListViewService, 'hasSelection', 'get')
.mockReturnValue(true)
jest
.spyOn(documentListViewService, 'allSelected', 'get')
.mockReturnValue(true)
jest
.spyOn(documentListViewService, 'selectedCount', 'get')
.mockReturnValue(6)
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([1, 2, 3, 4, 5]))
fixture.detectChanges()
expect(component.canSendSelection).toBe(false)
expect(
fixture.debugElement.query(By.css('#dropdownSend')).nativeElement.disabled
).toBe(true)
})
it('should apply selection data to correspondents menu', () => { it('should apply selection data to correspondents menu', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
fixture.detectChanges() fixture.detectChanges()
@@ -347,7 +303,7 @@ describe('BulkEditorComponent', () => {
component.openDocumentTypeDropdown() component.openDocumentTypeDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled() expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.documentTypeDocumentCounts()).toEqual( expect(component.documentTypeDocumentCounts).toEqual(
selectionData.selected_document_types selectionData.selected_document_types
) )
}) })
@@ -364,7 +320,7 @@ describe('BulkEditorComponent', () => {
component.openCorrespondentDropdown() component.openCorrespondentDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled() expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.correspondentDocumentCounts()).toEqual( expect(component.correspondentDocumentCounts).toEqual(
selectionData.selected_correspondents selectionData.selected_correspondents
) )
}) })
@@ -381,7 +337,7 @@ describe('BulkEditorComponent', () => {
component.openStoragePathDropdown() component.openStoragePathDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled() expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.storagePathDocumentCounts()).toEqual( expect(component.storagePathDocumentCounts).toEqual(
selectionData.selected_storage_paths selectionData.selected_storage_paths
) )
}) })
@@ -398,7 +354,7 @@ describe('BulkEditorComponent', () => {
component.openCustomFieldsDropdown() component.openCustomFieldsDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled() expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.customFieldDocumentCounts()).toEqual( expect(component.customFieldDocumentCounts).toEqual(
selectionData.selected_custom_fields selectionData.selected_custom_fields
) )
}) })
@@ -1641,7 +1597,6 @@ describe('BulkEditorComponent', () => {
expect(modal.componentInstance.customFields.length).toEqual(2) expect(modal.componentInstance.customFields.length).toEqual(2)
expect(modal.componentInstance.fieldsToAddIds).toEqual([1, 2]) expect(modal.componentInstance.fieldsToAddIds).toEqual([1, 2])
expect(modal.componentInstance.selection).toEqual({ documents: [3, 4] }) expect(modal.componentInstance.selection).toEqual({ documents: [3, 4] })
expect(modal.componentInstance.selectionCount).toEqual(2)
expect(modal.componentInstance.documents).toEqual([3, 4]) expect(modal.componentInstance.documents).toEqual([3, 4])
modal.componentInstance.failed.emit() modal.componentInstance.failed.emit()
@@ -1,11 +1,4 @@
import { import { Component, inject, Input, OnDestroy, OnInit } from '@angular/core'
Component,
inject,
Input,
OnDestroy,
OnInit,
signal,
} from '@angular/core'
import { import {
FormControl, FormControl,
FormGroup, FormGroup,
@@ -19,7 +12,15 @@ import {
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { saveAs } from 'file-saver' import { saveAs } from 'file-saver'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { first, map, Observable, Subject, switchMap, takeUntil } from 'rxjs' import {
first,
forkJoin,
map,
Observable,
Subject,
switchMap,
takeUntil,
} from 'rxjs'
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
import { CustomField } from 'src/app/data/custom-field' import { CustomField } from 'src/app/data/custom-field'
import { MatchingModel } from 'src/app/data/matching-model' import { MatchingModel } from 'src/app/data/matching-model'
@@ -108,12 +109,12 @@ export class BulkEditorComponent
documentTypeSelectionModel = new FilterableDropdownSelectionModel() documentTypeSelectionModel = new FilterableDropdownSelectionModel()
storagePathsSelectionModel = new FilterableDropdownSelectionModel() storagePathsSelectionModel = new FilterableDropdownSelectionModel()
customFieldsSelectionModel = new FilterableDropdownSelectionModel(true) customFieldsSelectionModel = new FilterableDropdownSelectionModel(true)
readonly tagDocumentCounts = signal<SelectionDataItem[]>(undefined) tagDocumentCounts: SelectionDataItem[]
readonly correspondentDocumentCounts = signal<SelectionDataItem[]>(undefined) correspondentDocumentCounts: SelectionDataItem[]
readonly documentTypeDocumentCounts = signal<SelectionDataItem[]>(undefined) documentTypeDocumentCounts: SelectionDataItem[]
readonly storagePathDocumentCounts = signal<SelectionDataItem[]>(undefined) storagePathDocumentCounts: SelectionDataItem[]
readonly customFieldDocumentCounts = signal<SelectionDataItem[]>(undefined) customFieldDocumentCounts: SelectionDataItem[]
readonly awaitingDownload = signal(false) awaitingDownload: boolean
unsubscribeNotifier: Subject<any> = new Subject() unsubscribeNotifier: Subject<any> = new Subject()
@@ -372,8 +373,8 @@ export class BulkEditorComponent
openTagsDropdown() { openTagsDropdown() {
if (this.list.allSelected) { if (this.list.allSelected) {
const selectionData = this.list.selectionData const selectionData = this.list.selectionData
this.tagDocumentCounts.set(selectionData?.selected_tags ?? []) this.tagDocumentCounts = selectionData?.selected_tags ?? []
this.applySelectionData(this.tagDocumentCounts(), this.tagSelectionModel) this.applySelectionData(this.tagDocumentCounts, this.tagSelectionModel)
return return
} }
@@ -381,7 +382,7 @@ export class BulkEditorComponent
.getSelectionData(Array.from(this.list.selected)) .getSelectionData(Array.from(this.list.selected))
.pipe(first()) .pipe(first())
.subscribe((s) => { .subscribe((s) => {
this.tagDocumentCounts.set(s.selected_tags) this.tagDocumentCounts = s.selected_tags
this.applySelectionData(s.selected_tags, this.tagSelectionModel) this.applySelectionData(s.selected_tags, this.tagSelectionModel)
}) })
} }
@@ -389,11 +390,10 @@ export class BulkEditorComponent
openDocumentTypeDropdown() { openDocumentTypeDropdown() {
if (this.list.allSelected) { if (this.list.allSelected) {
const selectionData = this.list.selectionData const selectionData = this.list.selectionData
this.documentTypeDocumentCounts.set( this.documentTypeDocumentCounts =
selectionData?.selected_document_types ?? [] selectionData?.selected_document_types ?? []
)
this.applySelectionData( this.applySelectionData(
this.documentTypeDocumentCounts(), this.documentTypeDocumentCounts,
this.documentTypeSelectionModel this.documentTypeSelectionModel
) )
return return
@@ -403,7 +403,7 @@ export class BulkEditorComponent
.getSelectionData(Array.from(this.list.selected)) .getSelectionData(Array.from(this.list.selected))
.pipe(first()) .pipe(first())
.subscribe((s) => { .subscribe((s) => {
this.documentTypeDocumentCounts.set(s.selected_document_types) this.documentTypeDocumentCounts = s.selected_document_types
this.applySelectionData( this.applySelectionData(
s.selected_document_types, s.selected_document_types,
this.documentTypeSelectionModel this.documentTypeSelectionModel
@@ -414,11 +414,10 @@ export class BulkEditorComponent
openCorrespondentDropdown() { openCorrespondentDropdown() {
if (this.list.allSelected) { if (this.list.allSelected) {
const selectionData = this.list.selectionData const selectionData = this.list.selectionData
this.correspondentDocumentCounts.set( this.correspondentDocumentCounts =
selectionData?.selected_correspondents ?? [] selectionData?.selected_correspondents ?? []
)
this.applySelectionData( this.applySelectionData(
this.correspondentDocumentCounts(), this.correspondentDocumentCounts,
this.correspondentSelectionModel this.correspondentSelectionModel
) )
return return
@@ -428,7 +427,7 @@ export class BulkEditorComponent
.getSelectionData(Array.from(this.list.selected)) .getSelectionData(Array.from(this.list.selected))
.pipe(first()) .pipe(first())
.subscribe((s) => { .subscribe((s) => {
this.correspondentDocumentCounts.set(s.selected_correspondents) this.correspondentDocumentCounts = s.selected_correspondents
this.applySelectionData( this.applySelectionData(
s.selected_correspondents, s.selected_correspondents,
this.correspondentSelectionModel this.correspondentSelectionModel
@@ -439,11 +438,10 @@ export class BulkEditorComponent
openStoragePathDropdown() { openStoragePathDropdown() {
if (this.list.allSelected) { if (this.list.allSelected) {
const selectionData = this.list.selectionData const selectionData = this.list.selectionData
this.storagePathDocumentCounts.set( this.storagePathDocumentCounts =
selectionData?.selected_storage_paths ?? [] selectionData?.selected_storage_paths ?? []
)
this.applySelectionData( this.applySelectionData(
this.storagePathDocumentCounts(), this.storagePathDocumentCounts,
this.storagePathsSelectionModel this.storagePathsSelectionModel
) )
return return
@@ -453,7 +451,7 @@ export class BulkEditorComponent
.getSelectionData(Array.from(this.list.selected)) .getSelectionData(Array.from(this.list.selected))
.pipe(first()) .pipe(first())
.subscribe((s) => { .subscribe((s) => {
this.storagePathDocumentCounts.set(s.selected_storage_paths) this.storagePathDocumentCounts = s.selected_storage_paths
this.applySelectionData( this.applySelectionData(
s.selected_storage_paths, s.selected_storage_paths,
this.storagePathsSelectionModel this.storagePathsSelectionModel
@@ -464,11 +462,10 @@ export class BulkEditorComponent
openCustomFieldsDropdown() { openCustomFieldsDropdown() {
if (this.list.allSelected) { if (this.list.allSelected) {
const selectionData = this.list.selectionData const selectionData = this.list.selectionData
this.customFieldDocumentCounts.set( this.customFieldDocumentCounts =
selectionData?.selected_custom_fields ?? [] selectionData?.selected_custom_fields ?? []
)
this.applySelectionData( this.applySelectionData(
this.customFieldDocumentCounts(), this.customFieldDocumentCounts,
this.customFieldsSelectionModel this.customFieldsSelectionModel
) )
return return
@@ -478,7 +475,7 @@ export class BulkEditorComponent
.getSelectionData(Array.from(this.list.selected)) .getSelectionData(Array.from(this.list.selected))
.pipe(first()) .pipe(first())
.subscribe((s) => { .subscribe((s) => {
this.customFieldDocumentCounts.set(s.selected_custom_fields) this.customFieldDocumentCounts = s.selected_custom_fields
this.applySelectionData( this.applySelectionData(
s.selected_custom_fields, s.selected_custom_fields,
this.customFieldsSelectionModel this.customFieldsSelectionModel
@@ -887,7 +884,7 @@ export class BulkEditorComponent
} }
downloadSelected() { downloadSelected() {
this.awaitingDownload.set(true) this.awaitingDownload = true
let downloadFileType: string = let downloadFileType: string =
this.downloadForm.get('downloadFileTypeArchive').value && this.downloadForm.get('downloadFileTypeArchive').value &&
this.downloadForm.get('downloadFileTypeOriginals').value this.downloadForm.get('downloadFileTypeOriginals').value
@@ -904,7 +901,7 @@ export class BulkEditorComponent
.pipe(first()) .pipe(first())
.subscribe((result: any) => { .subscribe((result: any) => {
saveAs(result, 'documents.zip') saveAs(result, 'documents.zip')
this.awaitingDownload.set(false) this.awaitingDownload = false
}) })
} }
@@ -928,6 +925,27 @@ export class BulkEditorComponent
}) })
} }
runZoneOcrSelected() {
const ids = Array.from(this.list.selected)
if (!ids.length) return
const modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Run Zone OCR`
modal.componentInstance.messageBold = $localize`Run zone OCR on ${this.getSelectionSize()} selected document(s)?`
modal.componentInstance.message = $localize`Each document's type template (if it has one) is applied, overwriting the mapped fields.`
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
modal.componentInstance.buttonsEnabled = false
this.executeDocumentAction(
modal,
forkJoin(ids.map((id) => this.documentService.runZoneOcr(id)))
)
})
}
setPermissions() { setPermissions() {
let modal = this.modalService.open(PermissionsDialogComponent, { let modal = this.modalService.open(PermissionsDialogComponent, {
backdrop: 'static', backdrop: 'static',
@@ -1020,7 +1038,6 @@ export class BulkEditorComponent
) )
dialog.selection = this.getSelectionQuery() dialog.selection = this.getSelectionQuery()
dialog.selectionCount = this.getSelectionSize()
dialog.succeeded.subscribe((result) => { dialog.succeeded.subscribe((result) => {
this.toastService.showInfo($localize`Custom fields updated.`) this.toastService.showInfo($localize`Custom fields updated.`)
this.list.reload() this.list.reload()
@@ -1041,14 +1058,6 @@ export class BulkEditorComponent
return this.settings.get(SETTINGS_KEYS.EMAIL_ENABLED) return this.settings.get(SETTINGS_KEYS.EMAIL_ENABLED)
} }
public get canSendSelection(): boolean {
return (
this.list.hasSelection &&
(!this.list.allSelected ||
this.list.selectedCount === this.list.selected.size)
)
}
createShareLinkBundle() { createShareLinkBundle() {
const modal = this.modalService.open(ShareLinkBundleDialogComponent, { const modal = this.modalService.open(ShareLinkBundleDialogComponent, {
backdrop: 'static', backdrop: 'static',
@@ -1,9 +1,9 @@
<form [formGroup]="form" (ngSubmit)="save()" autocomplete="off"> <form [formGroup]="form" (ngSubmit)="save()" autocomplete="off">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title" id="modal-basic-title" i8n>{ <h4 class="modal-title" id="modal-basic-title" i8n>{
documentCount, documents.length,
plural, plural,
=1 {Set custom fields for 1 document} other {Set custom fields for {{documentCount}} documents} =1 {Set custom fields for 1 document} other {Set custom fields for {{documents.length}} documents}
}</h4> }</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()"> <button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
</button> </button>
@@ -42,20 +42,6 @@ describe('CustomFieldsBulkEditDialogComponent', () => {
expect(component.form.contains('2')).toBeTruthy() expect(component.form.contains('2')).toBeTruthy()
}) })
it('should render the document count for a filtered selection', () => {
component.selection = {
all: true,
filters: { title__icontains: 'invoice' },
}
component.selectionCount = 42
fixture.detectChanges()
expect(component.documents).toEqual([])
expect(
fixture.nativeElement.querySelector('.modal-title').textContent
).toContain('Set custom fields for 42 documents')
})
it('should emit succeeded event and close modal on successful save', () => { it('should emit succeeded event and close modal on successful save', () => {
const editSpy = jest const editSpy = jest
.spyOn(documentService, 'bulkEdit') .spyOn(documentService, 'bulkEdit')
@@ -81,14 +81,8 @@ export class CustomFieldsBulkEditDialogComponent {
public selection: DocumentSelectionQuery = { documents: [] } public selection: DocumentSelectionQuery = { documents: [] }
public selectionCount: number
public get documents(): number[] { public get documents(): number[] {
return this.selection.documents ?? [] return this.selection.documents
}
public get documentCount(): number {
return this.selectionCount ?? this.documents.length
} }
initForm() { initForm() {

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