Compare commits

...
Author SHA1 Message Date
shamoon 4c0df0506a Fix: more signal-backed conversions 2026-07-17 22:52:54 -07:00
shamoonandGitHub f8a641b402 Fix (beta): convert chat component to signal-backed (#13152) 2026-07-17 22:26:07 -07:00
stumpylog e85223929c Fix: increase checks-discovery-timeout for Wait for Docker Build
The Merge and Push Manifest check isn't materialized until the
build-arch matrix completes (its `if` gates on a job output), which
routinely takes longer than the action's 60s discovery window,
causing the release workflow to bail before Docker builds finish.
2026-07-17 15:27:55 -07:00
stumpylog f2e3304576 Merge remote-tracking branch 'origin/dev' into beta 2026-07-17 15:16:36 -07:00
6938fb0e01 New Crowdin translations by GitHub Action (#13134)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-17 15:16:21 -07:00
Trenton HandGitHub 255c214d0b Fix: avoid HAVING clause conflicts in custom field query filter on MariaDB (#13143) 2026-07-17 13:44:58 -07:00
Trenton HandGitHub a0ab05a9a6 Chore: Upgrade more vulerable packages (#13020)
* Few more before

* Bump django to 5.2.16

* Bump daphne to 4.2.2

* Bump transformers to 5.13.1

* Bump nltk to 3.10.0
2026-07-17 12:01:30 -07:00
df1ddb15cc Performance: Tantivy indexing optimization (#13053)
* Tantivy: get permissions by chunks

-40% indexing time compared to previous commit

* Make progress bar process one by one with chunk

-15% indexing time compared to previous commit

* Prefetch FK + iterate over chunk from SQL

Prefetch additional needed data (note user, custom field content)

-20% indexing time compared to previous commit

* Reindex: increase Tantivy heap size from 128 to 512MB

Gains probably vary depending on the machine,
but it seems a sweet spot compatible with low-end hardware.

* Reindex: optimization on permission fetching and autocomplete word set

-10% indexing time compared to previous commit

* Autocomplete analyzer python->rust

Splits words with underscore compared to the python analyzer.
E.g.: "blue_print" -> ["blue", "print"]
It can still be found with the "blue_print" keyword,
as the search string is also split in two words.

-50% indexing time compared to previous commit (indexing is twice faster!)

* Index bigram for CJK content only

Inedxing time slightly longer (~3%),
but since the non-CJK content is not indexed,
bigram searchs will be slightly optimized.

* Fix group-based view_document permissions missing from bulk rebuild

_bulk_get_viewer_ids only queried UserObjectPermission, dropping the
group-permission expansion that get_users_with_perms(with_group_users=True)
performs for the non-batched per-document indexing path. A user who could
only see a document via group membership would lose search access to it
after any full reindex.

Also query GroupObjectPermission and expand group membership to user ids,
matching the existing single-document behavior.

* Yield (document, viewer_ids) pairs from _DocumentViewerStream

Previously _DocumentViewerStream.__iter__ yielded plain Document objects
while the matching viewer ids were exposed through a separate mutable
attribute (viewer_ids_by_pk), overwritten each time the generator crossed
a chunk boundary. rebuild() read that attribute out-of-band per document.

This only worked because the current iter_wrapper (a plain progress-bar
passthrough) happens to consume the stream in strict lock-step with no
lookahead. Any wrapper that buffers, batches, or reorders would silently
pair a document with the wrong chunk's viewer ids. Yield the pair directly
so the association travels with the document regardless of how iter_wrapper
consumes the stream, and drop the now-unneeded viewer_ids_by_pk attribute.

* Add --heap-size-mb CLI arg to document_index reindex

writer_heap_bytes was hardcoded at 512MB with no way to tune it. Expose it
as a manual-rebuild-only CLI arg rather than a settings/env var, per review
feedback, so lower-memory hosts can reduce it without a wider config
surface. Defaults to unset so TantivyBackend.rebuild's own default stays
the single source of truth.

---------

Co-authored-by: stumpylog <797416+stumpylog@users.noreply.github.com>
2026-07-17 11:33:09 -07:00
GitHub Actions 71557d7c64 Auto translate strings 2026-07-16 17:43:31 +00:00
shamoon 66f5111dc1 Chore: some frontend sonar fixes 2026-07-16 10:42:00 -07:00
dependabot[bot]andGitHub 0addb448f5 Chore(deps): Bump the pre-commit-dependencies group across 1 directory with 4 updates (#13137) 2026-07-15 23:52:20 +00:00
dependabot[bot]andGitHub 5428ea5caa Chore(deps-dev): Bump the development group with 2 updates (#13068)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-15 21:04:28 +00:00
GitHub Actions 2de2dfdb81 Auto translate strings 2026-07-15 20:45:19 +00:00
Hugo Beauzée-LuyssenandGitHub f0d934259c Fix: scope ProcessedMail UID deduplication by IMAP UIDVALIDITY (#12839) 2026-07-15 13:43:16 -07:00
Albert MikaelyanandGitHub 735c3b073a Fix: allow setting any UID and GID when running rootless (#13090) 2026-07-15 10:19:22 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e2cf3633fe docker-compose(deps): bump greenmail/standalone in /docker/compose (#13067)
Bumps greenmail/standalone from 2.1.8 to 2.1.9.

---
updated-dependencies:
- dependency-name: greenmail/standalone
  dependency-version: 2.1.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-15 09:23:10 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c0589a9ce9 docker-compose(deps): bump gotenberg/gotenberg in /docker/compose (#13066)
Bumps gotenberg/gotenberg from 8.33 to 8.34.

---
updated-dependencies:
- dependency-name: gotenberg/gotenberg
  dependency-version: '8.34'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-15 09:09:30 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2135572adb docker-compose(deps): bump nginx in /docker/compose (#13065)
Bumps nginx from 1.31.1-alpine to 1.31.2-alpine.

---
updated-dependencies:
- dependency-name: nginx
  dependency-version: 1.31.2-alpine
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-15 15:55:33 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
697e262b5d docker(deps): Bump astral-sh/uv (#13070)
Bumps [astral-sh/uv](https://github.com/astral-sh/uv) from 0.11.19-python3.12-trixie-slim to 0.11.28-python3.12-trixie-slim.
- [Release notes](https://github.com/astral-sh/uv/releases)
- [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/uv/compare/0.11.19...0.11.28)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-15 15:37:35 +00:00
GitHub Actions 5a65c4448d Auto translate strings 2026-07-15 15:14:34 +00:00
shamoonandGitHub 370a0a29ba Fix (beta): fix sidebar document close button (#13136) 2026-07-15 08:12:56 -07:00
536007f42b New Crowdin translations by GitHub Action (#12722)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-14 09:29:42 -07:00
shamoonandGitHub 71e2dd4b08 Fix: include wasm assets in pdf.js-based viewer (#13122) 2026-07-11 22:14:38 -07:00
GitHub Actions dabd7826d0 Auto translate strings 2026-07-11 23:14:15 +00:00
shamoonandGitHub 054b234739 Fix: fix ui labels from md5 to sha256 (#13119) 2026-07-11 16:12:47 -07:00
GitHub Actions 77dd4d1b0c Auto translate strings 2026-07-11 23:05:28 +00:00
shamoon 56dc3e426e Merge branch 'dev' into beta 2026-07-10 11:34:15 -07:00
GitHub Actions be8890cfaf Auto translate strings 2026-07-10 18:24:51 +00:00
shamoonandGitHub e03b09e2ea Chore: pnpm lockfile cleanup (#13116) 2026-07-10 11:23:19 -07:00
shamoon 19960cc4fa Merge branch 'dev' into beta 2026-07-10 00:44:47 -07:00
shamoonandGitHub e145fe8cc7 Fix: clamp mailrule max age before migration (#13093) 2026-07-07 10:56:45 -07:00
shamoonandGitHub fce55a1609 Fix: use latest document version for bulk download (#13103) 2026-07-07 10:55:40 -07:00
shamoonandGitHub 1dabd2601d Fix: handle relative date with comma query (#13087) 2026-07-03 12:44:33 -07:00
shamoon 88f69841b3 Merge branch 'dev' into beta 2026-07-02 15:06:19 -07:00
shamoon 5b8fbdcec7 Merge branch 'dev' into beta 2026-06-27 15:56:20 -07:00
shamoon beb048b94a Merge branch 'dev' into beta 2026-06-27 06:53:09 -07:00
shamoonandGitHub b33d11778a Fix: change tantivy token limit to title field max length (#13046) 2026-06-23 08:02:26 -07:00
192 changed files with 86958 additions and 49442 deletions
+1
View File
@@ -26,6 +26,7 @@ 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
+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.8.4' rev: 'v3.9.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.8.3 - prettier@3.9.4
- '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.17 rev: v0.15.20
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.24.1" rev: "v2.25.1"
hooks: hooks:
- id: pyproject-fmt - id: pyproject-fmt
additional_dependencies: [tomli] additional_dependencies: [tomli]
+5 -1
View File
@@ -30,7 +30,7 @@ RUN set -eux \
# Purpose: Installs s6-overlay and rootfs # Purpose: Installs s6-overlay and rootfs
# Comments: # Comments:
# - Don't leave anything extra in here either # - Don't leave anything extra in here either
FROM ghcr.io/astral-sh/uv:0.11.19-python3.12-trixie-slim AS s6-overlay-base FROM ghcr.io/astral-sh/uv:0.11.28-python3.12-trixie-slim AS s6-overlay-base
WORKDIR /usr/src/s6 WORKDIR /usr/src/s6
@@ -238,6 +238,10 @@ 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.33 image: docker.io/gotenberg/gotenberg:8.34
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.8 image: docker.io/greenmail/standalone:2.1.9
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.1-alpine image: docker.io/nginx:1.31.2-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.33 image: docker.io/gotenberg/gotenberg:8.34
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.
@@ -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.33 image: docker.io/gotenberg/gotenberg:8.34
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.33 image: docker.io/gotenberg/gotenberg:8.34
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.
+143
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -326,3 +326,11 @@ 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_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 both, to avoid `403 Forbidden` errors on login. or both, to avoid `403 Forbidden` errors on login.
## 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.
+1 -1
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: 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:
```yaml ```yaml
webserver: webserver:
+4 -4
View File
@@ -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.3.0", "bleach~=6.4.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.9.1", "nltk~=3.10.0",
"ocrmypdf~=17.4.2", "ocrmypdf~=17.4.2",
"openai>=2.32", "openai>=2.32",
"pathvalidate~=3.3.1", "pathvalidate~=3.3.1",
@@ -101,11 +101,11 @@ dev = [
{ include-group = "testing" }, { include-group = "testing" },
] ]
docs = [ docs = [
"zensical>=0.0.43", "zensical>=0.0.47",
] ]
lint = [ lint = [
"prek~=0.3.10", "prek~=0.3.10",
"ruff~=0.15.15", "ruff~=0.15.20",
] ]
testing = [ testing = [
"daphne", "daphne",
+10
View File
@@ -79,6 +79,16 @@
"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": [
+151 -122
View File
@@ -5,14 +5,14 @@
<trans-unit id="ngb.alert.close" datatype="html"> <trans-unit id="ngb.alert.close" datatype="html">
<source>Close</source> <source>Close</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_d3d748c9cb3098b2696b844e4082b488/node_modules/src/alert/alert.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_88eda8c8846cc5cfe00e25de83b3f075/node_modules/src/alert/alert.ts</context>
<context context-type="linenumber">39</context> <context context-type="linenumber">39</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.carousel.slide-number" datatype="html"> <trans-unit id="ngb.carousel.slide-number" datatype="html">
<source> Slide <x id="INTERPOLATION" equiv-text="ueryList&lt;NgbSli"/> of <x id="INTERPOLATION_1" equiv-text="EventSource = N"/> </source> <source> Slide <x id="INTERPOLATION" equiv-text="ueryList&lt;NgbSli"/> of <x id="INTERPOLATION_1" equiv-text="EventSource = N"/> </source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_d3d748c9cb3098b2696b844e4082b488/node_modules/src/carousel/carousel.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_88eda8c8846cc5cfe00e25de83b3f075/node_modules/src/carousel/carousel.ts</context>
<context context-type="linenumber">129,133</context> <context context-type="linenumber">129,133</context>
</context-group> </context-group>
<note priority="1" from="description">Currently selected slide number read by screen reader</note> <note priority="1" from="description">Currently selected slide number read by screen reader</note>
@@ -20,114 +20,114 @@
<trans-unit id="ngb.carousel.previous" datatype="html"> <trans-unit id="ngb.carousel.previous" datatype="html">
<source>Previous</source> <source>Previous</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_d3d748c9cb3098b2696b844e4082b488/node_modules/src/carousel/carousel.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_88eda8c8846cc5cfe00e25de83b3f075/node_modules/src/carousel/carousel.ts</context>
<context context-type="linenumber">160,161</context> <context context-type="linenumber">160,161</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.carousel.next" datatype="html"> <trans-unit id="ngb.carousel.next" datatype="html">
<source>Next</source> <source>Next</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_d3d748c9cb3098b2696b844e4082b488/node_modules/src/carousel/carousel.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_88eda8c8846cc5cfe00e25de83b3f075/node_modules/src/carousel/carousel.ts</context>
<context context-type="linenumber">205,206</context> <context context-type="linenumber">205,206</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.datepicker.previous-month" datatype="html"> <trans-unit id="ngb.datepicker.previous-month" datatype="html">
<source>Previous month</source> <source>Previous month</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_d3d748c9cb3098b2696b844e4082b488/node_modules/src/datepicker/datepicker-navigation.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_88eda8c8846cc5cfe00e25de83b3f075/node_modules/src/datepicker/datepicker-navigation.ts</context>
<context context-type="linenumber">74,76</context> <context context-type="linenumber">74,76</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_d3d748c9cb3098b2696b844e4082b488/node_modules/src/datepicker/datepicker-navigation.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_88eda8c8846cc5cfe00e25de83b3f075/node_modules/src/datepicker/datepicker-navigation.ts</context>
<context context-type="linenumber">103</context> <context context-type="linenumber">103</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.datepicker.next-month" datatype="html"> <trans-unit id="ngb.datepicker.next-month" datatype="html">
<source>Next month</source> <source>Next month</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_d3d748c9cb3098b2696b844e4082b488/node_modules/src/datepicker/datepicker-navigation.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_88eda8c8846cc5cfe00e25de83b3f075/node_modules/src/datepicker/datepicker-navigation.ts</context>
<context context-type="linenumber">103</context> <context context-type="linenumber">103</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_d3d748c9cb3098b2696b844e4082b488/node_modules/src/datepicker/datepicker-navigation.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_88eda8c8846cc5cfe00e25de83b3f075/node_modules/src/datepicker/datepicker-navigation.ts</context>
<context context-type="linenumber">103</context> <context context-type="linenumber">103</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.datepicker.select-month" datatype="html"> <trans-unit id="ngb.datepicker.select-month" datatype="html">
<source>Select month</source> <source>Select month</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_d3d748c9cb3098b2696b844e4082b488/node_modules/src/datepicker/ngb-calendar.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_88eda8c8846cc5cfe00e25de83b3f075/node_modules/src/datepicker/ngb-calendar.ts</context>
<context context-type="linenumber">63,65</context> <context context-type="linenumber">63,65</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_d3d748c9cb3098b2696b844e4082b488/node_modules/src/datepicker/ngb-calendar.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_88eda8c8846cc5cfe00e25de83b3f075/node_modules/src/datepicker/ngb-calendar.ts</context>
<context context-type="linenumber">106,107</context> <context context-type="linenumber">106,107</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.datepicker.select-year" datatype="html"> <trans-unit id="ngb.datepicker.select-year" datatype="html">
<source>Select year</source> <source>Select year</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_d3d748c9cb3098b2696b844e4082b488/node_modules/src/datepicker/ngb-calendar.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_88eda8c8846cc5cfe00e25de83b3f075/node_modules/src/datepicker/ngb-calendar.ts</context>
<context context-type="linenumber">145,146</context> <context context-type="linenumber">145,146</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_d3d748c9cb3098b2696b844e4082b488/node_modules/src/datepicker/ngb-calendar.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_88eda8c8846cc5cfe00e25de83b3f075/node_modules/src/datepicker/ngb-calendar.ts</context>
<context context-type="linenumber">179,183</context> <context context-type="linenumber">179,183</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.pagination.first" datatype="html"> <trans-unit id="ngb.pagination.first" datatype="html">
<source>««</source> <source>««</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_d3d748c9cb3098b2696b844e4082b488/node_modules/src/pagination/pagination.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_88eda8c8846cc5cfe00e25de83b3f075/node_modules/src/pagination/pagination.ts</context>
<context context-type="linenumber">302</context> <context context-type="linenumber">302</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.pagination.previous" datatype="html"> <trans-unit id="ngb.pagination.previous" datatype="html">
<source>«</source> <source>«</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_d3d748c9cb3098b2696b844e4082b488/node_modules/src/pagination/pagination.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_88eda8c8846cc5cfe00e25de83b3f075/node_modules/src/pagination/pagination.ts</context>
<context context-type="linenumber">333,335</context> <context context-type="linenumber">333,335</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.pagination.next" datatype="html"> <trans-unit id="ngb.pagination.next" datatype="html">
<source>»</source> <source>»</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_d3d748c9cb3098b2696b844e4082b488/node_modules/src/pagination/pagination.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_88eda8c8846cc5cfe00e25de83b3f075/node_modules/src/pagination/pagination.ts</context>
<context context-type="linenumber">375</context> <context context-type="linenumber">375</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.pagination.last" datatype="html"> <trans-unit id="ngb.pagination.last" datatype="html">
<source>»»</source> <source>»»</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_d3d748c9cb3098b2696b844e4082b488/node_modules/src/pagination/pagination.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_88eda8c8846cc5cfe00e25de83b3f075/node_modules/src/pagination/pagination.ts</context>
<context context-type="linenumber">408</context> <context context-type="linenumber">408</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.pagination.first-aria" datatype="html"> <trans-unit id="ngb.pagination.first-aria" datatype="html">
<source>First</source> <source>First</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_d3d748c9cb3098b2696b844e4082b488/node_modules/src/pagination/pagination.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_88eda8c8846cc5cfe00e25de83b3f075/node_modules/src/pagination/pagination.ts</context>
<context context-type="linenumber">437</context> <context context-type="linenumber">437</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.pagination.previous-aria" datatype="html"> <trans-unit id="ngb.pagination.previous-aria" datatype="html">
<source>Previous</source> <source>Previous</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_d3d748c9cb3098b2696b844e4082b488/node_modules/src/pagination/pagination.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_88eda8c8846cc5cfe00e25de83b3f075/node_modules/src/pagination/pagination.ts</context>
<context context-type="linenumber">472,474</context> <context context-type="linenumber">472,474</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.pagination.next-aria" datatype="html"> <trans-unit id="ngb.pagination.next-aria" datatype="html">
<source>Next</source> <source>Next</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_d3d748c9cb3098b2696b844e4082b488/node_modules/src/pagination/pagination.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_88eda8c8846cc5cfe00e25de83b3f075/node_modules/src/pagination/pagination.ts</context>
<context context-type="linenumber">506</context> <context context-type="linenumber">506</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.pagination.last-aria" datatype="html"> <trans-unit id="ngb.pagination.last-aria" datatype="html">
<source>Last</source> <source>Last</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_d3d748c9cb3098b2696b844e4082b488/node_modules/src/pagination/pagination.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_88eda8c8846cc5cfe00e25de83b3f075/node_modules/src/pagination/pagination.ts</context>
<context context-type="linenumber">506</context> <context context-type="linenumber">506</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
@@ -135,105 +135,105 @@
<source><x id="INTERPOLATION" equiv-text="barConfig); <source><x id="INTERPOLATION" equiv-text="barConfig);
pu"/></source> pu"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_d3d748c9cb3098b2696b844e4082b488/node_modules/src/progressbar/progressbar.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_88eda8c8846cc5cfe00e25de83b3f075/node_modules/src/progressbar/progressbar.ts</context>
<context context-type="linenumber">40,41</context> <context context-type="linenumber">40,41</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.HH" datatype="html"> <trans-unit id="ngb.timepicker.HH" datatype="html">
<source>HH</source> <source>HH</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_d3d748c9cb3098b2696b844e4082b488/node_modules/src/timepicker/timepicker.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_88eda8c8846cc5cfe00e25de83b3f075/node_modules/src/timepicker/timepicker.ts</context>
<context context-type="linenumber">263,264</context> <context context-type="linenumber">263,264</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.hours" datatype="html"> <trans-unit id="ngb.timepicker.hours" datatype="html">
<source>Hours</source> <source>Hours</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_d3d748c9cb3098b2696b844e4082b488/node_modules/src/timepicker/timepicker.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_88eda8c8846cc5cfe00e25de83b3f075/node_modules/src/timepicker/timepicker.ts</context>
<context context-type="linenumber">301,302</context> <context context-type="linenumber">301,302</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.MM" datatype="html"> <trans-unit id="ngb.timepicker.MM" datatype="html">
<source>MM</source> <source>MM</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_d3d748c9cb3098b2696b844e4082b488/node_modules/src/timepicker/timepicker.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_88eda8c8846cc5cfe00e25de83b3f075/node_modules/src/timepicker/timepicker.ts</context>
<context context-type="linenumber">341,342</context> <context context-type="linenumber">341,342</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.minutes" datatype="html"> <trans-unit id="ngb.timepicker.minutes" datatype="html">
<source>Minutes</source> <source>Minutes</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_d3d748c9cb3098b2696b844e4082b488/node_modules/src/timepicker/timepicker.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_88eda8c8846cc5cfe00e25de83b3f075/node_modules/src/timepicker/timepicker.ts</context>
<context context-type="linenumber">381,384</context> <context context-type="linenumber">381,384</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.increment-hours" datatype="html"> <trans-unit id="ngb.timepicker.increment-hours" datatype="html">
<source>Increment hours</source> <source>Increment hours</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_d3d748c9cb3098b2696b844e4082b488/node_modules/src/timepicker/timepicker.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_88eda8c8846cc5cfe00e25de83b3f075/node_modules/src/timepicker/timepicker.ts</context>
<context context-type="linenumber">429,430</context> <context context-type="linenumber">429,430</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.decrement-hours" datatype="html"> <trans-unit id="ngb.timepicker.decrement-hours" datatype="html">
<source>Decrement hours</source> <source>Decrement hours</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_d3d748c9cb3098b2696b844e4082b488/node_modules/src/timepicker/timepicker.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_88eda8c8846cc5cfe00e25de83b3f075/node_modules/src/timepicker/timepicker.ts</context>
<context context-type="linenumber">442</context> <context context-type="linenumber">442</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.increment-minutes" datatype="html"> <trans-unit id="ngb.timepicker.increment-minutes" datatype="html">
<source>Increment minutes</source> <source>Increment minutes</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_d3d748c9cb3098b2696b844e4082b488/node_modules/src/timepicker/timepicker.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_88eda8c8846cc5cfe00e25de83b3f075/node_modules/src/timepicker/timepicker.ts</context>
<context context-type="linenumber">442</context> <context context-type="linenumber">442</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.decrement-minutes" datatype="html"> <trans-unit id="ngb.timepicker.decrement-minutes" datatype="html">
<source>Decrement minutes</source> <source>Decrement minutes</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_d3d748c9cb3098b2696b844e4082b488/node_modules/src/timepicker/timepicker.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_88eda8c8846cc5cfe00e25de83b3f075/node_modules/src/timepicker/timepicker.ts</context>
<context context-type="linenumber">442</context> <context context-type="linenumber">442</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.SS" datatype="html"> <trans-unit id="ngb.timepicker.SS" datatype="html">
<source>SS</source> <source>SS</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_d3d748c9cb3098b2696b844e4082b488/node_modules/src/timepicker/timepicker.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_88eda8c8846cc5cfe00e25de83b3f075/node_modules/src/timepicker/timepicker.ts</context>
<context context-type="linenumber">442</context> <context context-type="linenumber">442</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.seconds" datatype="html"> <trans-unit id="ngb.timepicker.seconds" datatype="html">
<source>Seconds</source> <source>Seconds</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_d3d748c9cb3098b2696b844e4082b488/node_modules/src/timepicker/timepicker.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_88eda8c8846cc5cfe00e25de83b3f075/node_modules/src/timepicker/timepicker.ts</context>
<context context-type="linenumber">442</context> <context context-type="linenumber">442</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.increment-seconds" datatype="html"> <trans-unit id="ngb.timepicker.increment-seconds" datatype="html">
<source>Increment seconds</source> <source>Increment seconds</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_d3d748c9cb3098b2696b844e4082b488/node_modules/src/timepicker/timepicker.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_88eda8c8846cc5cfe00e25de83b3f075/node_modules/src/timepicker/timepicker.ts</context>
<context context-type="linenumber">442</context> <context context-type="linenumber">442</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.decrement-seconds" datatype="html"> <trans-unit id="ngb.timepicker.decrement-seconds" datatype="html">
<source>Decrement seconds</source> <source>Decrement seconds</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_d3d748c9cb3098b2696b844e4082b488/node_modules/src/timepicker/timepicker.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_88eda8c8846cc5cfe00e25de83b3f075/node_modules/src/timepicker/timepicker.ts</context>
<context context-type="linenumber">442</context> <context context-type="linenumber">442</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.PM" datatype="html"> <trans-unit id="ngb.timepicker.PM" datatype="html">
<source><x id="INTERPOLATION"/></source> <source><x id="INTERPOLATION"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_d3d748c9cb3098b2696b844e4082b488/node_modules/src/timepicker/timepicker.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_88eda8c8846cc5cfe00e25de83b3f075/node_modules/src/timepicker/timepicker.ts</context>
<context context-type="linenumber">442</context> <context context-type="linenumber">442</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.toast.close-aria" datatype="html"> <trans-unit id="ngb.toast.close-aria" datatype="html">
<source>Close</source> <source>Close</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_d3d748c9cb3098b2696b844e4082b488/node_modules/src/toast/toast.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.0.5_@angular+core@22.0.5_@angular+_88eda8c8846cc5cfe00e25de83b3f075/node_modules/src/toast/toast.ts</context>
<context context-type="linenumber">116</context> <context context-type="linenumber">116</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
@@ -332,7 +332,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html</context>
<context context-type="linenumber">85</context> <context context-type="linenumber">86</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html</context>
@@ -379,11 +379,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">272</context> <context context-type="linenumber">273</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">274</context> <context context-type="linenumber">275</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5890330709052835856" datatype="html"> <trans-unit id="5890330709052835856" datatype="html">
@@ -724,11 +724,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">307</context> <context context-type="linenumber">308</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">310</context> <context context-type="linenumber">311</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2272120016352772836" datatype="html"> <trans-unit id="2272120016352772836" datatype="html">
@@ -757,14 +757,14 @@
<source>lines</source> <source>lines</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/logs/logs.component.html</context> <context context-type="sourcefile">src/app/components/admin/logs/logs.component.html</context>
<context context-type="linenumber">17</context> <context context-type="linenumber">18</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8838884664569764142" datatype="html"> <trans-unit id="8838884664569764142" datatype="html">
<source>Auto refresh</source> <source>Auto refresh</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/logs/logs.component.html</context> <context context-type="sourcefile">src/app/components/admin/logs/logs.component.html</context>
<context context-type="linenumber">21</context> <context context-type="linenumber">22</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
@@ -775,11 +775,11 @@
<source>Loading...</source> <source>Loading...</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/logs/logs.component.html</context> <context context-type="sourcefile">src/app/components/admin/logs/logs.component.html</context>
<context context-type="linenumber">38</context> <context context-type="linenumber">39</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/logs/logs.component.html</context> <context context-type="sourcefile">src/app/components/admin/logs/logs.component.html</context>
<context context-type="linenumber">48</context> <context context-type="linenumber">49</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
@@ -886,7 +886,7 @@
<source>Jump to bottom</source> <source>Jump to bottom</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/logs/logs.component.html</context> <context context-type="sourcefile">src/app/components/admin/logs/logs.component.html</context>
<context context-type="linenumber">62</context> <context context-type="linenumber">63</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1255048712725285892" datatype="html"> <trans-unit id="1255048712725285892" datatype="html">
@@ -1135,11 +1135,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">232</context> <context context-type="linenumber">233</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">234</context> <context context-type="linenumber">235</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.html</context> <context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.html</context>
@@ -1696,7 +1696,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">203</context> <context context-type="linenumber">204</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/tags/tags.component.ts</context> <context context-type="sourcefile">src/app/components/common/input/tags/tags.component.ts</context>
@@ -1801,11 +1801,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">295</context> <context context-type="linenumber">296</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">297</context> <context context-type="linenumber">298</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8492095365580052820" datatype="html"> <trans-unit id="8492095365580052820" datatype="html">
@@ -1905,11 +1905,18 @@
<context context-type="linenumber">227</context> <context context-type="linenumber">227</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3570013039254960445" datatype="html">
<source>Search tasks</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">96</context>
</context-group>
</trans-unit>
<trans-unit id="6849725902312323996" datatype="html"> <trans-unit id="6849725902312323996" datatype="html">
<source>Reset filters</source> <source>Reset filters</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">104</context> <context context-type="linenumber">105</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
@@ -1924,14 +1931,14 @@
<source>{VAR_PLURAL, plural, =1 {1 task} other {<x id="INTERPOLATION"/> tasks}}</source> <source>{VAR_PLURAL, plural, =1 {1 task} other {<x id="INTERPOLATION"/> tasks}}</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">125</context> <context context-type="linenumber">126</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8953033926734869941" datatype="html"> <trans-unit id="8953033926734869941" datatype="html">
<source>Name</source> <source>Name</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">147</context> <context context-type="linenumber">148</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
@@ -2042,7 +2049,7 @@
<source>Created</source> <source>Created</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">148</context> <context context-type="linenumber">149</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context>
@@ -2073,21 +2080,21 @@
<source>Results</source> <source>Results</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">150</context> <context context-type="linenumber">151</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="314315645942131479" datatype="html"> <trans-unit id="314315645942131479" datatype="html">
<source>Info</source> <source>Info</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">152</context> <context context-type="linenumber">153</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3193976279273491157" datatype="html"> <trans-unit id="3193976279273491157" datatype="html">
<source>Actions</source> <source>Actions</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">153</context> <context context-type="linenumber">154</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.html</context> <context context-type="sourcefile">src/app/components/admin/trash/trash.component.html</context>
@@ -2158,14 +2165,14 @@
<source>click for full output</source> <source>click for full output</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">204</context> <context context-type="linenumber">205</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1536087519743707362" datatype="html"> <trans-unit id="1536087519743707362" datatype="html">
<source>Dismiss</source> <source>Dismiss</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">217</context> <context context-type="linenumber">218</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
@@ -2181,35 +2188,35 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
<context context-type="linenumber">50</context> <context context-type="linenumber">51</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2134950584701094962" datatype="html"> <trans-unit id="2134950584701094962" datatype="html">
<source>Open Document</source> <source>Open Document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">222</context> <context context-type="linenumber">223</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5404759957685833020" datatype="html"> <trans-unit id="5404759957685833020" datatype="html">
<source>Result message</source> <source>Result message</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">235</context> <context context-type="linenumber">236</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6621329748219109148" datatype="html"> <trans-unit id="6621329748219109148" datatype="html">
<source>Duplicate</source> <source>Duplicate</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">242</context> <context context-type="linenumber">243</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7593555694782789615" datatype="html"> <trans-unit id="7593555694782789615" datatype="html">
<source>Open</source> <source>Open</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">250</context> <context context-type="linenumber">251</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
@@ -2240,21 +2247,21 @@
<source>Input data</source> <source>Input data</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">260</context> <context context-type="linenumber">261</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1585185618099050920" datatype="html"> <trans-unit id="1585185618099050920" datatype="html">
<source>Result data</source> <source>Result data</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">266</context> <context context-type="linenumber">267</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7976920528153858271" datatype="html"> <trans-unit id="7976920528153858271" datatype="html">
<source>No tasks match the current filters.</source> <source>No tasks match the current filters.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">288</context> <context context-type="linenumber">289</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2525230676386818985" datatype="html"> <trans-unit id="2525230676386818985" datatype="html">
@@ -2513,11 +2520,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">255</context> <context context-type="linenumber">256</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">258</context> <context context-type="linenumber">259</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3818027200170621545" datatype="html"> <trans-unit id="3818027200170621545" datatype="html">
@@ -2870,11 +2877,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">286</context> <context context-type="linenumber">287</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">288</context> <context context-type="linenumber">289</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4569276013106377105" datatype="html"> <trans-unit id="4569276013106377105" datatype="html">
@@ -3211,11 +3218,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">316</context> <context context-type="linenumber">317</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">319</context> <context context-type="linenumber">320</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="472206565520537964" datatype="html"> <trans-unit id="472206565520537964" datatype="html">
@@ -3240,36 +3247,36 @@
<source>Close all</source> <source>Close all</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">162</context> <context context-type="linenumber">163</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">164</context> <context context-type="linenumber">165</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3897348120591552265" datatype="html"> <trans-unit id="3897348120591552265" datatype="html">
<source>Manage</source> <source>Manage</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">173</context> <context context-type="linenumber">174</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8008131619909556709" datatype="html"> <trans-unit id="8008131619909556709" datatype="html">
<source>Attributes</source> <source>Attributes</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">180</context> <context context-type="linenumber">181</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">182</context> <context context-type="linenumber">183</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7437910965833684826" datatype="html"> <trans-unit id="7437910965833684826" datatype="html">
<source>Correspondents</source> <source>Correspondents</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">208</context> <context context-type="linenumber">209</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
@@ -3288,7 +3295,7 @@
<source>Document types</source> <source>Document types</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">213</context> <context context-type="linenumber">214</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.ts</context> <context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.ts</context>
@@ -3299,7 +3306,7 @@
<source>Storage paths</source> <source>Storage paths</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">218</context> <context context-type="linenumber">219</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.ts</context> <context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.ts</context>
@@ -3310,7 +3317,7 @@
<source>Custom fields</source> <source>Custom fields</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">223</context> <context context-type="linenumber">224</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
@@ -3329,11 +3336,11 @@
<source>Workflows</source> <source>Workflows</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">241</context> <context context-type="linenumber">242</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">243</context> <context context-type="linenumber">244</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context> <context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
@@ -3344,71 +3351,71 @@
<source>Mail</source> <source>Mail</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">248</context> <context context-type="linenumber">249</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">251</context> <context context-type="linenumber">252</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7844706011418789951" datatype="html"> <trans-unit id="7844706011418789951" datatype="html">
<source>Administration</source> <source>Administration</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">266</context> <context context-type="linenumber">267</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3008420115644088420" datatype="html"> <trans-unit id="3008420115644088420" datatype="html">
<source>Configuration</source> <source>Configuration</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">279</context> <context context-type="linenumber">280</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">281</context> <context context-type="linenumber">282</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1534029177398918729" datatype="html"> <trans-unit id="1534029177398918729" datatype="html">
<source>GitHub</source> <source>GitHub</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">326</context> <context context-type="linenumber">327</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4112664765954374539" datatype="html"> <trans-unit id="4112664765954374539" datatype="html">
<source>is available.</source> <source>is available.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">335,336</context> <context context-type="linenumber">336,337</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1175891574282637937" datatype="html"> <trans-unit id="1175891574282637937" datatype="html">
<source>Click to view.</source> <source>Click to view.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">336</context> <context context-type="linenumber">337</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="9811291095862612" datatype="html"> <trans-unit id="9811291095862612" datatype="html">
<source>Paperless-ngx can automatically check for updates</source> <source>Paperless-ngx can automatically check for updates</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">340</context> <context context-type="linenumber">341</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="894819944961861800" datatype="html"> <trans-unit id="894819944961861800" datatype="html">
<source> How does this work? </source> <source> How does this work? </source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">347,349</context> <context context-type="linenumber">348,350</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="509090351011426949" datatype="html"> <trans-unit id="509090351011426949" datatype="html">
<source>Update available</source> <source>Update available</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">360</context> <context context-type="linenumber">361</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1542489069631984294" datatype="html"> <trans-unit id="1542489069631984294" datatype="html">
@@ -3604,6 +3611,17 @@
<context context-type="linenumber">20</context> <context context-type="linenumber">20</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8066608938393600549" datatype="html">
<source>Message</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.html</context>
<context context-type="linenumber">32</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/email-document-dialog/email-document-dialog.component.html</context>
<context context-type="linenumber">19</context>
</context-group>
</trans-unit>
<trans-unit id="3167745892412525370" datatype="html"> <trans-unit id="3167745892412525370" datatype="html">
<source>Ask a question about this document...</source> <source>Ask a question about this document...</source>
<context-group purpose="location"> <context-group purpose="location">
@@ -5828,13 +5846,6 @@
<context context-type="linenumber">32</context> <context context-type="linenumber">32</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8066608938393600549" datatype="html">
<source>Message</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/email-document-dialog/email-document-dialog.component.html</context>
<context context-type="linenumber">19</context>
</context-group>
</trans-unit>
<trans-unit id="5867799091834207531" datatype="html"> <trans-unit id="5867799091834207531" datatype="html">
<source>Use archive version</source> <source>Use archive version</source>
<context-group purpose="location"> <context-group purpose="location">
@@ -6694,7 +6705,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html</context>
<context context-type="linenumber">87</context> <context context-type="linenumber">88</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html</context>
@@ -6771,18 +6782,25 @@
<context context-type="linenumber">66</context> <context context-type="linenumber">66</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4306169234658044987" datatype="html">
<source>Share link</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html</context>
<context context-type="linenumber">68</context>
</context-group>
</trans-unit>
<trans-unit id="2618101617221155143" datatype="html"> <trans-unit id="2618101617221155143" datatype="html">
<source>Copy link</source> <source>Copy link</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html</context>
<context context-type="linenumber">81</context> <context context-type="linenumber">82</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8372007266188249803" datatype="html"> <trans-unit id="8372007266188249803" datatype="html">
<source>Never</source> <source>Never</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html</context>
<context context-type="linenumber">93</context> <context context-type="linenumber">94</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html</context>
@@ -6797,7 +6815,7 @@
<source>File version</source> <source>File version</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html</context>
<context context-type="linenumber">96</context> <context context-type="linenumber">97</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html</context>
@@ -6808,7 +6826,7 @@
<source>Size</source> <source>Size</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html</context>
<context context-type="linenumber">99</context> <context context-type="linenumber">100</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html</context>
@@ -6819,14 +6837,14 @@
<source>A zip file containing the selected documents will be created for this share link bundle. This process happens in the background and may take some time, especially for large bundles.</source> <source>A zip file containing the selected documents will be created for this share link bundle. This process happens in the background and may take some time, especially for large bundles.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html</context>
<context context-type="linenumber">109</context> <context context-type="linenumber">110</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7274497464693086242" datatype="html"> <trans-unit id="7274497464693086242" datatype="html">
<source>Manage share link bundles</source> <source>Manage share link bundles</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html</context>
<context context-type="linenumber">113</context> <context context-type="linenumber">114</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
@@ -7817,8 +7835,8 @@
<context context-type="linenumber">314</context> <context context-type="linenumber">314</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7985558498848210210" datatype="html"> <trans-unit id="2659735245739197634" datatype="html">
<source>Original MD5 checksum</source> <source>Original SHA256 checksum</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">318</context> <context context-type="linenumber">318</context>
@@ -7838,8 +7856,8 @@
<context context-type="linenumber">326</context> <context context-type="linenumber">326</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="342875990758166588" datatype="html"> <trans-unit id="6714358112223607756" datatype="html">
<source>Archive MD5 checksum</source> <source>Archive SHA256 checksum</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">331</context> <context context-type="linenumber">331</context>
@@ -8237,63 +8255,67 @@
<source>Optional</source> <source>Optional</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
<context context-type="linenumber">16</context> <context context-type="linenumber">17</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="787787001194154763" datatype="html"> <trans-unit id="787787001194154763" datatype="html">
<source>Add new version</source> <source>Add new version</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
<context context-type="linenumber">31</context> <context context-type="linenumber">32</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3119565892291077820" datatype="html"> <trans-unit id="3119565892291077820" datatype="html">
<source>Uploading version...</source> <source>Uploading version...</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
<context context-type="linenumber">38</context> <context context-type="linenumber">39</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8538204518127762311" datatype="html"> <trans-unit id="8538204518127762311" datatype="html">
<source>Processing version...</source> <source>Processing version...</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
<context context-type="linenumber">44</context> <context context-type="linenumber">45</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6587183611950398104" datatype="html"> <trans-unit id="6587183611950398104" datatype="html">
<source>Version upload failed.</source> <source>Version upload failed.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
<context context-type="linenumber">49</context> <context context-type="linenumber">50</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6328986243040954523" datatype="html"> <trans-unit id="6328986243040954523" datatype="html">
<source>Version label</source> <source>Version label</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
<context context-type="linenumber">80</context> <context context-type="linenumber">75</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
<context context-type="linenumber">85</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2724055831234181057" datatype="html"> <trans-unit id="2724055831234181057" datatype="html">
<source>Version</source> <source>Version</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
<context context-type="linenumber">91</context> <context context-type="linenumber">96</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2491751767883178298" datatype="html"> <trans-unit id="2491751767883178298" datatype="html">
<source>Delete this version?</source> <source>Delete this version?</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
<context context-type="linenumber">122</context> <context context-type="linenumber">127</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="894108684749537200" datatype="html"> <trans-unit id="894108684749537200" datatype="html">
<source>Delete version</source> <source>Delete version</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
<context context-type="linenumber">127</context> <context context-type="linenumber">132</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="9111284310237278846" datatype="html"> <trans-unit id="9111284310237278846" datatype="html">
@@ -10196,6 +10218,13 @@
<context context-type="linenumber">20</context> <context context-type="linenumber">20</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8669280833944376006" datatype="html">
<source>Select all processed email messages</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context>
<context context-type="linenumber">29</context>
</context-group>
</trans-unit>
<trans-unit id="8691920320483720007" datatype="html"> <trans-unit id="8691920320483720007" datatype="html">
<source>Received</source> <source>Received</source>
<context-group purpose="location"> <context-group purpose="location">
-1
View File
@@ -18,7 +18,6 @@
"@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",
+1295 -1844
View File
File diff suppressed because it is too large Load Diff
@@ -5,8 +5,9 @@
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">
<span class="input-group-text text-muted" i18n>Show</span> <label class="input-group-text text-muted" for="logLineLimit" i18n>Show</label>
<input <input
id="logLineLimit"
class="form-control" class="form-control"
type="number" type="number"
min="100" min="100"
@@ -17,7 +18,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 class="form-check-input" type="checkbox" role="switch" [ngModel]="autoRefreshEnabled()" (ngModelChange)="autoRefreshEnabled.set($event)"> <input id="autoRefreshSwitch" 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,9 +100,7 @@ export class LogsComponent
parsed.some((log, idx) => { parsed.some((log, idx) => {
const current = this.logs()[idx] const current = this.logs()[idx]
return ( return (
!current || current?.message !== log.message || current?.level !== log.level
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" class="nav-tabs"> <ul ngbNav #nav="ngbNav" (navChange)="onNavChange($event)" [activeId]="activeNavID()" (activeIdChange)="activeNavID.set($event)" 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">
<span i18n>Full search links to</span> <label for="searchLink" i18n>Full search links to</label>
</div> </div>
<div class="col mb-3"> <div class="col mb-3">
<select class="form-select" formControlName="searchLink"> <select id="searchLink" 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">
<span i18n>Items per page</span> <label for="documentListItemPerPage" i18n>Items per page</label>
</div> </div>
<div class="col"> <div class="col">
<select class="form-select" formControlName="documentListItemPerPage"> <select id="documentListItemPerPage" 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">
<span i18n>Default editing mode</span> <label for="pdfEditorDefaultEditMode" i18n>Default editing mode</label>
</div> </div>
<div class="col"> <div class="col">
<select class="form-select" formControlName="pdfEditorDefaultEditMode"> <select id="pdfEditorDefaultEditMode" 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>
@@ -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.length component.settingsForm.get('documentDetailsHiddenFields').value
).toEqual(0) ).toHaveLength(0)
component.toggleDocumentDetailField(field, false) component.toggleDocumentDetailField(field, false)
expect( expect(
component.settingsForm.get('documentDetailsHiddenFields').value.length component.settingsForm.get('documentDetailsHiddenFields').value
).toEqual(1) ).toHaveLength(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.length component.settingsForm.get('documentDetailsHiddenFields').value
).toEqual(0) ).toHaveLength(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)
activeNavID: number readonly activeNavID = signal<number>(undefined)
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 = SettingsNavIDs[navIDKey] this.activeNavID.set(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 = navChangeEvent.activeId this.activeNavID.set(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 class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled"> <input id="autoRefreshSwitch" 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,7 +93,8 @@
<i-bs width="1em" height="1em" name="x"></i-bs> <i-bs width="1em" height="1em" name="x"></i-bs>
</button> </button>
} }
<input #filterInput class="form-control form-control-sm" type="text" <label class="visually-hidden" for="taskSearch" i18n>Search tasks</label>
<input #filterInput id="taskSearch" class="form-control form-control-sm" type="text"
(keyup)="filterInputKeyup($event)" (keyup)="filterInputKeyup($event)"
[(ngModel)]="filterText"> [(ngModel)]="filterText">
</div> </div>
@@ -211,7 +212,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 scope="row" class="actions-col"> <td 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,7 +150,8 @@
[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" (click)="closeDocument(d); $event.preventDefault()"> <span class="close flex-column justify-content-center"
(click)="$event.preventDefault(); $event.stopPropagation(); closeDocument(d)">
<i-bs name="x"></i-bs> <i-bs name="x"></i-bs>
</span> </span>
</a> </a>
@@ -6,7 +6,7 @@
<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> <span>
@@ -29,16 +29,19 @@
</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>
@@ -56,44 +56,53 @@ 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 = 123 component.documentId.set(123)
expect(component.placeholder).toBe('Ask a question about this document...') expect(component.placeholder).toBe('Ask a question about this document...')
component.documentId = undefined component.documentId.set(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 handle streaming response', () => { it('should send a message and render the streaming response', async () => {
component.input = 'Hello' component.input.set('Hello')
component.sendMessage() component.sendMessage()
expect(component.messages.length).toBe(2) expect(component.messages()).toHaveLength(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
jest.advanceTimersByTime(1000) await jest.runAllTimersAsync()
expect(component.messages[1].content).toBe('Hi there') await fixture.whenStable()
expect(component.messages()[1].content).toBe('Hi there')
expect(
fixture.nativeElement.querySelector('.chat-messages').textContent
).toContain('Hi there')
mockStream$.complete() mockStream$.complete()
expect(component.loading).toBe(false) await jest.runAllTimersAsync()
expect(component.messages[1].isStreaming).toBe(false) await fixture.whenStable()
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 = 'Hello' component.input.set('Hello')
component.sendMessage() component.sendMessage()
mockStream$.next( mockStream$.next(
@@ -101,14 +110,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 = 'Hello' component.input.set('Hello')
component.sendMessage() component.sendMessage()
mockStream$.next( mockStream$.next(
@@ -123,12 +132,12 @@ describe('ChatComponent', () => {
}) })
it('should remove delimiter fragments that were already streamed', () => { it('should remove delimiter fragments that were already streamed', () => {
component.input = 'Hello' component.input.set('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)}`
) )
@@ -137,28 +146,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 = 'Hello' component.input.set('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'].length).toBe(4) expect(component['typewriterBuffer']).toHaveLength(4)
}) })
it('should scroll to bottom after sending a message', () => { it('should scroll to bottom after sending a message', () => {
@@ -166,7 +175,7 @@ describe('ChatComponent', () => {
ChatComponent.prototype as any, ChatComponent.prototype as any,
'scrollToBottom' 'scrollToBottom'
) )
component.input = 'Test' component.input.set('Test')
component.sendMessage() component.sendMessage()
expect(scrollSpy).toHaveBeenCalled() expect(scrollSpy).toHaveBeenCalled()
}) })
@@ -1,4 +1,11 @@
import { Component, ElementRef, inject, OnInit, ViewChild } from '@angular/core' import {
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'
@@ -23,10 +30,10 @@ import {
styleUrl: './chat.component.scss', styleUrl: './chat.component.scss',
}) })
export class ChatComponent implements OnInit { export class ChatComponent implements OnInit {
public messages: ChatMessage[] = [] readonly messages = signal<ChatMessage[]>([])
public loading = false readonly loading = signal(false)
public input: string = '' readonly input = signal('')
public documentId!: number readonly documentId = signal<number>(undefined)
private chatService: ChatService = inject(ChatService) private chatService: ChatService = inject(ChatService)
private router: Router = inject(Router) private router: Router = inject(Router)
@@ -38,7 +45,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...`
} }
@@ -57,14 +64,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 = docIdRe ? +docIdRe[1] : undefined this.documentId.set(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.push(userMessage) this.messages.update((messages) => [...messages, userMessage])
this.scrollToBottom() this.scrollToBottom()
const assistantMessage: ChatMessage = { const assistantMessage: ChatMessage = {
@@ -72,12 +79,12 @@ export class ChatComponent implements OnInit {
content: '', content: '',
isStreaming: true, isStreaming: true,
} }
this.messages.push(assistantMessage) this.messages.update((messages) => [...messages, assistantMessage])
this.loading = true this.loading.set(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)
@@ -93,26 +100,30 @@ 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.loading = false this.notifyMessagesChanged()
this.loading.set(false)
}, },
complete: () => { complete: () => {
assistantMessage.isStreaming = false assistantMessage.isStreaming = false
this.loading = false this.notifyMessagesChanged()
this.loading.set(false)
this.scrollToBottom() this.scrollToBottom()
}, },
}) })
this.input = '' this.input.set('')
} }
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()
} }
@@ -135,11 +146,16 @@ 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' })
@@ -1,5 +1,12 @@
import { CurrencyPipe, getLocaleCurrencyCode, SlicePipe } from '@angular/common' import { CurrencyPipe, getLocaleCurrencyCode, SlicePipe } from '@angular/common'
import { Component, inject, Input, LOCALE_ID, OnInit } from '@angular/core' import {
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'
@@ -22,6 +29,7 @@ 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
@@ -74,6 +82,7 @@ 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()
}) })
} }
@@ -111,6 +120,7 @@ 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,6 +8,7 @@ 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'
@@ -65,11 +66,11 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
@ViewChildren('button') buttons: QueryList<ElementRef> @ViewChildren('button') buttons: QueryList<ElementRef>
private customFields: CustomField[] = [] private customFields: CustomField[] = []
private unusedFields: CustomField[] = [] private readonly unusedFields = signal<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)
) )
} }
@@ -99,8 +100,10 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
} }
private updateUnusedFields() { private updateUnusedFields() {
this.unusedFields = this.customFields.filter( this.unusedFields.set(
(f) => !this.existingFields?.find((e) => e.field === f.id) this.customFields.filter(
(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 = [field] component.customFields.set([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 = [field] component.customFields.set([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,6 +6,7 @@ import {
Input, Input,
Output, Output,
QueryList, QueryList,
signal,
ViewChild, ViewChild,
ViewChildren, ViewChildren,
} from '@angular/core' } from '@angular/core'
@@ -278,7 +279,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
@Output() @Output()
selectionModelChange = new EventEmitter<CustomFieldQueriesModel>() selectionModelChange = new EventEmitter<CustomFieldQueriesModel>()
customFields: CustomField[] = [] readonly customFields = signal<CustomField[]>([])
public readonly today: string = new Date().toLocaleDateString('en-CA') public readonly today: string = new Date().toLocaleDateString('en-CA')
@@ -325,12 +326,12 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
.listAll() .listAll()
.pipe(first(), takeUntil(this.unsubscribeNotifier)) .pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((result) => { .subscribe((result) => {
this.customFields = result.results this.customFields.set(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) {
@@ -353,7 +354,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]
@@ -369,7 +370,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 } from '@angular/core' import { Component, OnDestroy, inject, signal } 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
public testResult: string readonly testResult = signal<string>(undefined)
public testFailed: boolean = false readonly testFailed = signal(false)
public testLoading = false readonly testLoading = signal(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 = null this.testResult.set(null)
return return
} }
this.testDocument = document this.testDocument = document
this.testLoading = true this.testLoading.set(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 = result this.testResult.set(result)
this.testFailed = false this.testFailed.set(false)
} else { } else {
this.testResult = null this.testResult.set(null)
this.testFailed = true this.testFailed.set(true)
} }
this.testLoading = false this.testLoading.set(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 } from '@angular/core' import { Component, inject, signal } 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> {
tags: Tag[] readonly tags = signal<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 = result.results this.tags.set(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">
<label class="form-label mb-0" i18n>Advanced Filters</label> <span class="form-label mb-0" i18n>Advanced Filters</span>
<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.length).toBe(3) expect(filters).toHaveLength(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.length).toBe(0) expect(filters).toHaveLength(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.length).toBe(13) expect(filters).toHaveLength(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).length).toBe(0) expect(component.getFiltersFormArray(triggerGroup)).toHaveLength(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).length).toBe(1) expect(component.getFiltersFormArray(triggerGroup)).toHaveLength(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 || definition.inputType !== 'select') { if (definition?.inputType !== 'select') {
return [] return []
} }
@@ -58,8 +58,8 @@
</button> </button>
} }
@if ((selectionModel.items | filter: filterText:'name').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()
@@ -12,6 +12,7 @@ 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'
@@ -796,7 +797,7 @@ export class FilterableDropdownComponent
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
} }
modelIsDirty: boolean = false readonly modelIsDirty = signal(false)
private keyboardIndex: number private keyboardIndex: number
@@ -811,7 +812,7 @@ export class FilterableDropdownComponent
constructor() { constructor() {
super() super()
this.selectionModelChange.subscribe((updatedModel) => { this.selectionModelChange.subscribe((updatedModel) => {
this.modelIsDirty = updatedModel.isDirty() this.modelIsDirty.set(updatedModel.isDirty())
}) })
} }
@@ -858,7 +859,7 @@ export class FilterableDropdownComponent
}, 0) }, 0)
if (this.editing) { if (this.editing) {
this.selectionModel.reset() this.selectionModel.reset()
this.modelIsDirty = false this.modelIsDirty.set(false)
} }
this.selectionModel.singleSelect = this.selectionModel.singleSelect =
this.editing && !this.selectionModel.manyToOne this.editing && !this.selectionModel.manyToOne
@@ -5,6 +5,7 @@ import {
inject, inject,
Input, Input,
Output, Output,
signal,
} from '@angular/core' } from '@angular/core'
import { import {
FormsModule, FormsModule,
@@ -63,11 +64,11 @@ export class CustomFieldsValuesComponent extends AbstractInputComponent<Object>
super() super()
customFieldsService.listAll().subscribe((items) => { customFieldsService.listAll().subscribe((items) => {
this.fields = items.results this.fields.set(items.results)
}) })
} }
private fields: CustomField[] private readonly fields = signal<CustomField[]>([])
private _selectedFields: number[] private _selectedFields: number[]
@@ -90,6 +91,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,7 +2,11 @@ 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 { of, throwError } from 'rxjs' import { By } from '@angular/platform-browser'
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'
@@ -33,10 +37,11 @@ describe('DocumentLinkComponent', () => {
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [DocumentLinkComponent], imports: [DocumentLinkComponent, NgxBootstrapIconsModule.pick(allIcons)],
providers: [ providers: [
provideHttpClient(withInterceptorsFromDi()), provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(), provideHttpClientTesting(),
provideRouter([]),
], ],
}) })
documentService = TestBed.inject(DocumentService) documentService = TestBed.inject(DocumentService)
@@ -60,6 +65,25 @@ 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,6 +6,7 @@ import {
Input, Input,
OnDestroy, OnDestroy,
OnInit, OnInit,
signal,
} from '@angular/core' } from '@angular/core'
import { import {
FormsModule, FormsModule,
@@ -63,7 +64,7 @@ export class DocumentLinkComponent
documentsInput$ = new Subject<string>() documentsInput$ = new Subject<string>()
foundDocuments$: Observable<Document[]> foundDocuments$: Observable<Document[]>
loading = false readonly loading = signal(false)
selectedDocuments: Document[] = [] selectedDocuments: Document[] = []
private unsubscribeNotifier: Subject<any> = new Subject() private unsubscribeNotifier: Subject<any> = new Subject()
@@ -93,12 +94,12 @@ export class DocumentLinkComponent
this.selectedDocuments = [] this.selectedDocuments = []
super.writeValue([]) super.writeValue([])
} else { } else {
this.loading = true this.loading.set(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 = false this.loading.set(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) ?? {}
) )
@@ -113,7 +114,7 @@ export class DocumentLinkComponent
this.documentsInput$.pipe( this.documentsInput$.pipe(
distinctUntilChanged(), distinctUntilChanged(),
takeUntil(this.unsubscribeNotifier), takeUntil(this.unsubscribeNotifier),
tap(() => (this.loading = true)), tap(() => this.loading.set(true)),
switchMap((title) => switchMap((title) =>
this.documentsService this.documentsService
.listFiltered( .listFiltered(
@@ -133,7 +134,7 @@ export class DocumentLinkComponent
) )
), ),
catchError(() => of([])), // empty on error catchError(() => of([])), // empty on error
tap(() => (this.loading = false)) tap(() => this.loading.set(false))
) )
) )
) )
@@ -37,6 +37,7 @@ 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 = tag.children.map((child) => child.id) const childIDs = new Set(tag.children.map((child) => child.id))
tagIDs = tagIDs.filter((id) => !childIDs.includes(id)) tagIDs = tagIDs.filter((id) => !childIDs.has(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 clipboard = inject(Clipboard) private readonly clipboard = inject(Clipboard)
readonly id = input<number>(undefined) readonly id = input<number>(undefined)
readonly subTitle = input('') readonly subTitle = input('')
@@ -49,8 +49,7 @@ 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) (() => boolean) | undefined
| undefined
expect(isVisible?.()).toBe(true) expect(isVisible?.()).toBe(true)
expect(loadSpy).toHaveBeenCalledWith( expect(loadSpy).toHaveBeenCalledWith(
expect.objectContaining({ numPages: 1 }) expect.objectContaining({ numPages: 1 })
@@ -61,6 +60,7 @@ 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,6 +70,13 @@ 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 () => {
@@ -174,10 +174,12 @@ export class PngxPdfViewerComponent
'assets/js/pdf.worker.min.mjs', 'assets/js/pdf.worker.min.mjs',
this.document.baseURI this.document.baseURI
).toString() ).toString()
let initOptions = { const 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 {
@@ -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,5 +1,12 @@
import { NgClass } from '@angular/common' import { NgClass } from '@angular/common'
import { Component, EventEmitter, Input, Output, inject } from '@angular/core' import {
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'
@@ -75,7 +82,7 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
@Output() @Output()
ownerFilterSet = new EventEmitter<PermissionsSelectionModel>() ownerFilterSet = new EventEmitter<PermissionsSelectionModel>()
users: User[] readonly users = signal<User[]>([])
hideUnowned: boolean hideUnowned: boolean
@@ -102,7 +109,7 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
.listAll() .listAll()
.pipe(first()) .pipe(first())
.subscribe({ .subscribe({
next: (result) => (this.users = result.results), next: (result) => this.users.set(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 { of, throwError } from 'rxjs' import { NEVER, of, Subject, 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,6 +60,10 @@ 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)
@@ -155,7 +159,7 @@ describe('ProfileEditDialogComponent', () => {
'getSocialAccountProviders' 'getSocialAccountProviders'
) )
getProvidersSpy.mockReturnValue(of(socialAccountProviders)) getProvidersSpy.mockReturnValue(of(socialAccountProviders))
component.hasUsablePassword = true component.hasUsablePassword.set(true)
component.ngOnInit() component.ngOnInit()
component.form.get('password').patchValue('new*pass') component.form.get('password').patchValue('new*pass')
component.onPasswordKeyUp({ component.onPasswordKeyUp({
@@ -268,6 +272,27 @@ 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')
@@ -276,7 +301,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(
@@ -289,7 +314,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', () => {
@@ -310,7 +335,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', () => {
@@ -319,15 +344,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 = { component.totpSettings.set({
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()
@@ -341,8 +366,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', () => {
@@ -362,13 +387,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 = ['1', '2', '3'] component.recoveryCodes.set(['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,6 +56,14 @@ 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(''),
@@ -72,25 +80,15 @@ 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 {
@@ -107,20 +105,20 @@ export class ProfileEditDialogComponent
this.onEmailChange() this.onEmailChange()
}) })
this.currentPassword = profile.password this.currentPassword = profile.password
this.hasUsablePassword = profile.has_usable_password this.hasUsablePassword.set(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 = profile.social_accounts this.socialAccounts.set(profile.social_accounts ?? [])
this.isTotpEnabled = profile.is_mfa_enabled this.isTotpEnabled.set(profile.is_mfa_enabled)
}) })
this.profileService this.profileService
.getSocialAccountProviders() .getSocialAccountProviders()
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((providers) => { .subscribe((providers) => {
this.socialAccountProviders = providers this.socialAccountProviders.set(providers ?? [])
}) })
} }
@@ -259,7 +257,9 @@ export class ProfileEditDialogComponent
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: (id: number) => { next: (id: number) => {
this.socialAccounts = this.socialAccounts.filter((a) => a.id != id) this.socialAccounts.update((accounts) =>
accounts.filter((account) => account.id != id)
)
}, },
error: (error) => { error: (error) => {
this.toastService.showError( this.toastService.showError(
@@ -271,36 +271,39 @@ export class ProfileEditDialogComponent
} }
public gettotpSettings(): void { public gettotpSettings(): void {
this.totpSettingsLoading = true this.totpSettingsLoading.set(true)
this.profileService this.profileService
.getTotpSettings() .getTotpSettings()
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: (totpSettings) => { next: (totpSettings) => {
this.totpSettingsLoading = false this.totpSettingsLoading.set(false)
this.totpSettings = totpSettings this.totpSettings.set(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 = false this.totpSettingsLoading.set(false)
}, },
}) })
} }
public activateTotp(): void { public activateTotp(): void {
this.totpLoading = true this.totpLoading.set(true)
this.form.get('totp_code').disable() this.form.get('totp_code').disable()
this.profileService this.profileService
.activateTotp(this.totpSettings.secret, this.form.get('totp_code').value) .activateTotp(
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 = false this.totpLoading.set(false)
this.isTotpEnabled = activationResponse.success this.isTotpEnabled.set(activationResponse.success)
this.recoveryCodes = activationResponse.recovery_codes this.recoveryCodes.set(activationResponse.recovery_codes)
this.form.get('totp_code').enable() this.form.get('totp_code').enable()
if (activationResponse.success) { if (activationResponse.success) {
this.toastService.showInfo($localize`TOTP activated successfully`) this.toastService.showInfo($localize`TOTP activated successfully`)
@@ -309,7 +312,7 @@ export class ProfileEditDialogComponent
} }
}, },
error: (error) => { error: (error) => {
this.totpLoading = false this.totpLoading.set(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)
}, },
@@ -317,15 +320,15 @@ export class ProfileEditDialogComponent
} }
public deactivateTotp(): void { public deactivateTotp(): void {
this.totpLoading = true this.totpLoading.set(true)
this.profileService this.profileService
.deactivateTotp() .deactivateTotp()
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: (success) => { next: (success) => {
this.totpLoading = false this.totpLoading.set(false)
this.isTotpEnabled = !success this.isTotpEnabled.set(!success)
this.recoveryCodes = null this.recoveryCodes.set(null)
if (success) { if (success) {
this.toastService.showInfo($localize`TOTP deactivated successfully`) this.toastService.showInfo($localize`TOTP deactivated successfully`)
} else { } else {
@@ -333,14 +336,14 @@ export class ProfileEditDialogComponent
} }
}, },
error: (error) => { error: (error) => {
this.totpLoading = false this.totpLoading.set(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,8 +65,9 @@
<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 class="form-control" type="text" [value]="getShareUrl(createdBundle)" readonly> <input id="shareBundleLink" 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"
@@ -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 } from '@angular/core' import { Component, OnInit, inject, signal } 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)
asn: string readonly asn = signal<string>(undefined)
ngOnInit(): void { ngOnInit(): void {
this.route.paramMap.subscribe((paramMap) => { this.route.paramMap.subscribe((paramMap) => {
this.asn = paramMap.get('id') this.asn.set(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]])
@@ -315,7 +315,7 @@
<td>{{metadata()?.original_filename}}</td> <td>{{metadata()?.original_filename}}</td>
</tr> </tr>
<tr> <tr>
<td i18n>Original MD5 checksum</td> <td i18n>Original SHA256 checksum</td>
<td>{{metadata()?.original_checksum}}</td> <td>{{metadata()?.original_checksum}}</td>
</tr> </tr>
<tr> <tr>
@@ -328,7 +328,7 @@
</tr> </tr>
@if (metadata()?.has_archive_version) { @if (metadata()?.has_archive_version) {
<tr> <tr>
<td i18n>Archive MD5 checksum</td> <td i18n>Archive SHA256 checksum</td>
<td>{{metadata()?.archive_checksum}}</td> <td>{{metadata()?.archive_checksum}}</td>
</tr> </tr>
} }
@@ -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.length).toEqual(1) expect(deleteButtons).toHaveLength(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', () => {
@@ -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" role="status"></output> <output class="spinner-border spinner-border-sm fw-normal"></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 documentService = inject(DocumentService) private readonly documentService = inject(DocumentService)
private correspondentService = inject(CorrespondentService) private readonly correspondentService = inject(CorrespondentService)
private storagePathService = inject(StoragePathService) private readonly storagePathService = inject(StoragePathService)
private documentTypeService = inject(DocumentTypeService) private readonly documentTypeService = inject(DocumentTypeService)
private userService = inject(UserService) private readonly userService = inject(UserService)
public AuditLogAction = AuditLogAction public AuditLogAction = AuditLogAction
@@ -70,7 +70,7 @@ export class DocumentHistoryComponent implements OnInit {
return cached return cached
} }
const idInt = parseInt(id, 10) const idInt = Number.parseInt(id, 10)
const fallback$ = of(id) const fallback$ = of(id)
let result$: Observable<string> let result$: Observable<string>
@@ -5,10 +5,11 @@
</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">
<span class="input-group-text" i18n>Label</span> <label class="input-group-text" for="newVersionLabel" i18n>Label</label>
<input <input
id="newVersionLabel"
class="form-control" class="form-control"
type="text" type="text"
[(ngModel)]="newVersionLabel" [(ngModel)]="newVersionLabel"
@@ -31,7 +32,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>
@@ -49,8 +50,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>
} }
} }
} }
@@ -70,15 +71,19 @@
} }
</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()"
@@ -96,7 +101,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 set failed state after websocket failure', () => { it('onVersionFileSelected should render failed state after websocket failure', async () => {
const file = new File(['test'], 'new-version.pdf', { const file = new File(['test'], 'new-version.pdf', {
type: 'application/pdf', type: 'application/pdf',
}) })
@@ -266,9 +266,11 @@ 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()
}) })
@@ -282,8 +284,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()
}) })
@@ -298,8 +300,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
@@ -307,8 +309,8 @@ describe('DocumentVersionDropdownComponent', () => {
}) })
it('ngOnChanges should clear upload status on document switch', () => { it('ngOnChanges should clear upload status on document switch', () => {
component.versionUploadState = UploadState.Failed component.versionUploadState.set(UploadState.Failed)
component.versionUploadError = 'something failed' component.versionUploadError.set('something failed')
component.editingVersionId = 10 component.editingVersionId = 10
component.versionLabelDraft = 'draft' component.versionLabelDraft = 'draft'
@@ -316,8 +318,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,6 +7,7 @@ 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'
@@ -59,9 +60,9 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
@Output() versionsUpdated = new EventEmitter<DocumentVersionInfo[]>() @Output() versionsUpdated = new EventEmitter<DocumentVersionInfo[]>()
newVersionLabel: string = '' newVersionLabel: string = ''
versionUploadState: UploadState = UploadState.Idle readonly versionUploadState = signal(UploadState.Idle)
versionUploadError: string | null = null readonly versionUploadError = signal<string | null>(null)
savingVersionLabelId: number | null = null readonly savingVersionLabelId = signal<number | null>(null)
editingVersionId: number | null = null editingVersionId: number | null = null
versionLabelDraft: string = '' versionLabelDraft: string = ''
@@ -101,7 +102,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 ?? ''
} }
@@ -116,7 +117,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) {
@@ -158,15 +159,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 = versionId this.savingVersionLabelId.set(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 = null this.savingVersionLabelId.set(null)
} }
}), }),
takeUntil(this.destroy$) takeUntil(this.destroy$)
@@ -199,8 +200,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 = UploadState.Uploading this.versionUploadState.set(UploadState.Uploading)
this.versionUploadError = null this.versionUploadError.set(null)
this.documentsService this.documentsService
.uploadVersion(uploadDocumentId, file, label) .uploadVersion(uploadDocumentId, file, label)
.pipe( .pipe(
@@ -210,7 +211,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 = UploadState.Processing this.versionUploadState.set(UploadState.Processing)
}), }),
map((taskId) => map((taskId) =>
typeof taskId === 'string' typeof taskId === 'string'
@@ -219,8 +220,8 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
), ),
switchMap((taskId) => { switchMap((taskId) => {
if (!taskId) { if (!taskId) {
this.versionUploadState = UploadState.Failed this.versionUploadState.set(UploadState.Failed)
this.versionUploadError = $localize`Missing task ID.` this.versionUploadError.set($localize`Missing task ID.`)
return of(null) return of(null)
} }
return merge( return merge(
@@ -240,9 +241,10 @@ 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 = UploadState.Failed this.versionUploadState.set(UploadState.Failed)
this.versionUploadError = this.versionUploadError.set(
result.message || $localize`Upload failed.` result.message || $localize`Upload failed.`
)
} }
return of(null) return of(null)
} }
@@ -264,8 +266,10 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
}, },
error: (error) => { error: (error) => {
if (uploadDocumentId !== this.documentId) return if (uploadDocumentId !== this.documentId) return
this.versionUploadState = UploadState.Failed this.versionUploadState.set(UploadState.Failed)
this.versionUploadError = error?.message || $localize`Upload failed.` this.versionUploadError.set(
error?.message || $localize`Upload failed.`
)
this.toastService.showError( this.toastService.showError(
$localize`Error uploading new version`, $localize`Error uploading new version`,
error error
@@ -275,7 +279,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
} }
clearVersionUploadStatus(): void { clearVersionUploadStatus(): void {
this.versionUploadState = UploadState.Idle this.versionUploadState.set(UploadState.Idle)
this.versionUploadError = null this.versionUploadError.set(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)"
@@ -124,11 +124,11 @@
</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>
@@ -303,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
) )
}) })
@@ -320,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
) )
}) })
@@ -337,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
) )
}) })
@@ -354,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
) )
}) })
@@ -1,4 +1,11 @@
import { Component, inject, Input, OnDestroy, OnInit } from '@angular/core' import {
Component,
inject,
Input,
OnDestroy,
OnInit,
signal,
} from '@angular/core'
import { import {
FormControl, FormControl,
FormGroup, FormGroup,
@@ -101,12 +108,12 @@ export class BulkEditorComponent
documentTypeSelectionModel = new FilterableDropdownSelectionModel() documentTypeSelectionModel = new FilterableDropdownSelectionModel()
storagePathsSelectionModel = new FilterableDropdownSelectionModel() storagePathsSelectionModel = new FilterableDropdownSelectionModel()
customFieldsSelectionModel = new FilterableDropdownSelectionModel(true) customFieldsSelectionModel = new FilterableDropdownSelectionModel(true)
tagDocumentCounts: SelectionDataItem[] readonly tagDocumentCounts = signal<SelectionDataItem[]>(undefined)
correspondentDocumentCounts: SelectionDataItem[] readonly correspondentDocumentCounts = signal<SelectionDataItem[]>(undefined)
documentTypeDocumentCounts: SelectionDataItem[] readonly documentTypeDocumentCounts = signal<SelectionDataItem[]>(undefined)
storagePathDocumentCounts: SelectionDataItem[] readonly storagePathDocumentCounts = signal<SelectionDataItem[]>(undefined)
customFieldDocumentCounts: SelectionDataItem[] readonly customFieldDocumentCounts = signal<SelectionDataItem[]>(undefined)
awaitingDownload: boolean readonly awaitingDownload = signal(false)
unsubscribeNotifier: Subject<any> = new Subject() unsubscribeNotifier: Subject<any> = new Subject()
@@ -365,8 +372,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 = selectionData?.selected_tags ?? [] this.tagDocumentCounts.set(selectionData?.selected_tags ?? [])
this.applySelectionData(this.tagDocumentCounts, this.tagSelectionModel) this.applySelectionData(this.tagDocumentCounts(), this.tagSelectionModel)
return return
} }
@@ -374,7 +381,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 = s.selected_tags this.tagDocumentCounts.set(s.selected_tags)
this.applySelectionData(s.selected_tags, this.tagSelectionModel) this.applySelectionData(s.selected_tags, this.tagSelectionModel)
}) })
} }
@@ -382,10 +389,11 @@ 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 = this.documentTypeDocumentCounts.set(
selectionData?.selected_document_types ?? [] selectionData?.selected_document_types ?? []
)
this.applySelectionData( this.applySelectionData(
this.documentTypeDocumentCounts, this.documentTypeDocumentCounts(),
this.documentTypeSelectionModel this.documentTypeSelectionModel
) )
return return
@@ -395,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 = s.selected_document_types this.documentTypeDocumentCounts.set(s.selected_document_types)
this.applySelectionData( this.applySelectionData(
s.selected_document_types, s.selected_document_types,
this.documentTypeSelectionModel this.documentTypeSelectionModel
@@ -406,10 +414,11 @@ 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 = this.correspondentDocumentCounts.set(
selectionData?.selected_correspondents ?? [] selectionData?.selected_correspondents ?? []
)
this.applySelectionData( this.applySelectionData(
this.correspondentDocumentCounts, this.correspondentDocumentCounts(),
this.correspondentSelectionModel this.correspondentSelectionModel
) )
return return
@@ -419,7 +428,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 = s.selected_correspondents this.correspondentDocumentCounts.set(s.selected_correspondents)
this.applySelectionData( this.applySelectionData(
s.selected_correspondents, s.selected_correspondents,
this.correspondentSelectionModel this.correspondentSelectionModel
@@ -430,10 +439,11 @@ 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 = this.storagePathDocumentCounts.set(
selectionData?.selected_storage_paths ?? [] selectionData?.selected_storage_paths ?? []
)
this.applySelectionData( this.applySelectionData(
this.storagePathDocumentCounts, this.storagePathDocumentCounts(),
this.storagePathsSelectionModel this.storagePathsSelectionModel
) )
return return
@@ -443,7 +453,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 = s.selected_storage_paths this.storagePathDocumentCounts.set(s.selected_storage_paths)
this.applySelectionData( this.applySelectionData(
s.selected_storage_paths, s.selected_storage_paths,
this.storagePathsSelectionModel this.storagePathsSelectionModel
@@ -454,10 +464,11 @@ 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 = this.customFieldDocumentCounts.set(
selectionData?.selected_custom_fields ?? [] selectionData?.selected_custom_fields ?? []
)
this.applySelectionData( this.applySelectionData(
this.customFieldDocumentCounts, this.customFieldDocumentCounts(),
this.customFieldsSelectionModel this.customFieldsSelectionModel
) )
return return
@@ -467,7 +478,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 = s.selected_custom_fields this.customFieldDocumentCounts.set(s.selected_custom_fields)
this.applySelectionData( this.applySelectionData(
s.selected_custom_fields, s.selected_custom_fields,
this.customFieldsSelectionModel this.customFieldsSelectionModel
@@ -876,7 +887,7 @@ export class BulkEditorComponent
} }
downloadSelected() { downloadSelected() {
this.awaitingDownload = true this.awaitingDownload.set(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
@@ -893,7 +904,7 @@ export class BulkEditorComponent
.pipe(first()) .pipe(first())
.subscribe((result: any) => { .subscribe((result: any) => {
saveAs(result, 'documents.zip') saveAs(result, 'documents.zip')
this.awaitingDownload = false this.awaitingDownload.set(false)
}) })
} }
@@ -115,7 +115,7 @@
</pngx-page-header> </pngx-page-header>
<div class="row sticky-top py-3 mt-n2 mt-md-n3 bg-body"> <div class="row sticky-top py-3 mt-n2 mt-md-n3 bg-body">
<pngx-filter-editor [hidden]="isBulkEditing" [disabled]="isBulkEditing" [filterRules]="list.filterRules" (filterRulesChange)="onFilterRulesChange($event)" (resetFilterRules)="onFilterRulesReset($event)" [unmodifiedFilterRules]="unmodifiedFilterRules" [selectionData]="list.selectionData" #filterEditor></pngx-filter-editor> <pngx-filter-editor [hidden]="isBulkEditing" [disabled]="isBulkEditing" [filterRules]="list.filterRules" (filterRulesChange)="onFilterRulesChange($event)" (resetFilterRules)="onFilterRulesReset($event)" [unmodifiedFilterRules]="unmodifiedFilterRules()" [selectionData]="list.selectionData" #filterEditor></pngx-filter-editor>
<pngx-bulk-editor [hidden]="!isBulkEditing" [disabled]="!isBulkEditing"></pngx-bulk-editor> <pngx-bulk-editor [hidden]="!isBulkEditing" [disabled]="!isBulkEditing"></pngx-bulk-editor>
</div> </div>
@@ -5,6 +5,7 @@ import {
OnDestroy, OnDestroy,
OnInit, OnInit,
QueryList, QueryList,
signal,
ViewChild, ViewChild,
ViewChildren, ViewChildren,
} from '@angular/core' } from '@angular/core'
@@ -149,7 +150,7 @@ export class DocumentListComponent
) )
} }
unmodifiedFilterRules: FilterRule[] = [] readonly unmodifiedFilterRules = signal<FilterRule[]>([])
private unmodifiedSavedView: SavedView private unmodifiedSavedView: SavedView
private activeSavedView: SavedView | null = null private activeSavedView: SavedView | null = null
@@ -299,7 +300,7 @@ export class DocumentListComponent
this.savedViewService.setDocumentCount(view, this.list.collectionSize) this.savedViewService.setDocumentCount(view, this.list.collectionSize)
}) })
this.updateDisplayCustomFields() this.updateDisplayCustomFields()
this.unmodifiedFilterRules = view.filter_rules this.unmodifiedFilterRules.set(view.filter_rules)
}) })
this.route.queryParamMap this.route.queryParamMap
@@ -316,7 +317,7 @@ export class DocumentListComponent
this.activeSavedView = null this.activeSavedView = null
this.list.activateSavedView(null) this.list.activateSavedView(null)
this.list.loadFromQueryParams(queryParams) this.list.loadFromQueryParams(queryParams)
this.unmodifiedFilterRules = [] this.unmodifiedFilterRules.set([])
} }
}) })
@@ -415,7 +416,7 @@ export class DocumentListComponent
this.toastService.showInfo( this.toastService.showInfo(
$localize`View "${this.list.activeSavedViewTitle}" saved successfully.` $localize`View "${this.list.activeSavedViewTitle}" saved successfully.`
) )
this.unmodifiedFilterRules = this.list.filterRules this.unmodifiedFilterRules.set(this.list.filterRules)
}, },
error: (err) => { error: (err) => {
this.toastService.showError( this.toastService.showError(
@@ -80,7 +80,7 @@
shortcutKey="i"></pngx-filterable-dropdown> shortcutKey="i"></pngx-filterable-dropdown>
} }
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.CustomField) && customFields.length > 0) { @if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.CustomField) && customFields().length > 0) {
<pngx-custom-fields-query-dropdown class="flex-fill fade" [class.show]="show()" title="Custom fields" icon="ui-radios" i18n-title <pngx-custom-fields-query-dropdown class="flex-fill fade" [class.show]="show()" title="Custom fields" icon="ui-radios" i18n-title
[(selectionModel)]="customFieldQueriesModel" [(selectionModel)]="customFieldQueriesModel"
(selectionModelChange)="updateRules()" (selectionModelChange)="updateRules()"
@@ -328,7 +328,7 @@ describe('FilterEditorComponent', () => {
}) })
it('should ingest legacy text filter rules for doc title + content', () => { it('should ingest legacy text filter rules for doc title + content', () => {
expect(component.textFilter).toEqual(null) expect(component.textFilter).toBeNull()
component.filterRules = [ component.filterRules = [
{ {
rule_type: FILTER_TITLE_CONTENT, rule_type: FILTER_TITLE_CONTENT,
@@ -1,5 +1,6 @@
import { import {
AfterViewInit, AfterViewInit,
ChangeDetectorRef,
Component, Component,
ElementRef, ElementRef,
EventEmitter, EventEmitter,
@@ -9,6 +10,7 @@ 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 { import {
@@ -349,7 +351,7 @@ export class FilterEditorComponent
@ViewChild('textFilterInput') @ViewChild('textFilterInput')
textFilterInput: ElementRef textFilterInput: ElementRef
customFields: CustomField[] = [] readonly customFields = signal<CustomField[]>([])
tagDocumentCounts: SelectionDataItem[] tagDocumentCounts: SelectionDataItem[]
correspondentDocumentCounts: SelectionDataItem[] correspondentDocumentCounts: SelectionDataItem[]
@@ -514,6 +516,7 @@ export class FilterEditorComponent
this.documentService.get(this._moreLikeId).subscribe((result) => { this.documentService.get(this._moreLikeId).subscribe((result) => {
this._moreLikeDoc = result this._moreLikeDoc = result
this._textFilter = result.title this._textFilter = result.title
this.changeDetector.markForCheck()
}) })
break break
case FILTER_CREATED_AFTER: case FILTER_CREATED_AFTER:
@@ -1162,6 +1165,7 @@ export class FilterEditorComponent
private loadingCountTotal: number = 0 private loadingCountTotal: number = 0
private loadingCount: number = 0 private loadingCount: number = 0
private readonly changeDetector = inject(ChangeDetectorRef)
private maybeCompleteLoading() { private maybeCompleteLoading() {
this.loadingCount++ this.loadingCount++
@@ -1229,7 +1233,7 @@ export class FilterEditorComponent
) { ) {
this.loadingCountTotal++ this.loadingCountTotal++
this.customFieldService.listAll().subscribe((result) => { this.customFieldService.listAll().subscribe((result) => {
this.customFields = result.results this.customFields.set(result.results)
this.maybeCompleteLoading() this.maybeCompleteLoading()
}) })
} }
@@ -1,18 +1,18 @@
<div class="row mb-3"> <div class="row mb-3">
<div class="col mb-2 mb-xl-0"> <div class="col mb-2 mb-xl-0">
<div class="form-inline d-flex align-items-center"> <div class="form-inline d-flex align-items-center">
<label class="text-muted me-2 mb-0" i18n>Filter by:</label> <label class="text-muted me-2 mb-0" for="managementNameFilter" i18n>Filter by:</label>
<input class="form-control form-control-sm flex-fill w-auto" type="text" autofocus [(ngModel)]="nameFilter" (keyup)="onNameFilterKeyUp($event)" placeholder="Name" i18n-placeholder> <input id="managementNameFilter" class="form-control form-control-sm flex-fill w-auto" type="text" autofocus [(ngModel)]="nameFilter" (keyup)="onNameFilterKeyUp($event)" placeholder="Name" i18n-placeholder>
</div> </div>
</div> </div>
<div class="col-auto mb-2 mb-xl-0"> <div class="col-auto mb-2 mb-xl-0">
<div class="form-inline d-flex align-items-center"> <div class="form-inline d-flex align-items-center">
<div class="input-group input-group-sm w-auto d-none d-md-flex"> <div class="input-group input-group-sm w-auto d-none d-md-flex">
<span class="input-group-text border-0" i18n>Show:</span> <label class="input-group-text border-0" for="managementPageSize" i18n>Show:</label>
</div> </div>
<div class="input-group input-group-sm w-auto me-3"> <div class="input-group input-group-sm w-auto me-3">
<select class="form-select form-select-sm small" [(ngModel)]="pageSize"> <select id="managementPageSize" class="form-select form-select-sm small" [(ngModel)]="pageSize">
<option [ngValue]="25">25</option> <option [ngValue]="25">25</option>
<option [ngValue]="50">50</option> <option [ngValue]="50">50</option>
<option [ngValue]="100">100</option> <option [ngValue]="100">100</option>
@@ -83,12 +83,12 @@ describe('TagListComponent', () => {
] ]
component['_nameFilter'] = null // Simulate empty name filter component['_nameFilter'] = null // Simulate empty name filter
const filtered = component.filterData(tags as any) const filtered = component.filterData(tags as any)
expect(filtered.length).toBe(2) expect(filtered).toHaveLength(2)
expect(filtered.find((t) => t.id === 2)).toBeUndefined() expect(filtered.find((t) => t.id === 2)).toBeUndefined()
component['_nameFilter'] = 'Tag2' // Simulate non-empty name filter component['_nameFilter'] = 'Tag2' // Simulate non-empty name filter
const filteredWithName = component.filterData(tags as any) const filteredWithName = component.filterData(tags as any)
expect(filteredWithName.length).toBe(2) expect(filteredWithName).toHaveLength(2)
expect(filteredWithName.find((t) => t.id === 2)).toBeUndefined() expect(filteredWithName.find((t) => t.id === 2)).toBeUndefined()
expect( expect(
filteredWithName filteredWithName
@@ -26,7 +26,7 @@
<th scope="col" style="width: 40px;"> <th scope="col" style="width: 40px;">
<div class="form-check m-0 ms-2 me-n2"> <div class="form-check m-0 ms-2 me-n2">
<input type="checkbox" class="form-check-input" id="all-objects" [ngModel]="toggleAllEnabled()" (ngModelChange)="toggleAllEnabled.set($event)" [disabled]="processedMails().length === 0" (click)="toggleAll($event); $event.stopPropagation();"> <input type="checkbox" class="form-check-input" id="all-objects" [ngModel]="toggleAllEnabled()" (ngModelChange)="toggleAllEnabled.set($event)" [disabled]="processedMails().length === 0" (click)="toggleAll($event); $event.stopPropagation();">
<label class="form-check-label" for="all-objects"></label> <label class="form-check-label" for="all-objects"><span class="visually-hidden" i18n>Select all processed email messages</span></label>
</div> </div>
</th> </th>
<th scope="col" i18n>Subject</th> <th scope="col" i18n>Subject</th>
@@ -5,6 +5,6 @@
pre { pre {
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; overflow-wrap: break-word;
} }
} }
+1 -6
View File
@@ -1,12 +1,7 @@
import { normalizeSync } from 'normalize-diacritics' import { normalizeSync } from 'normalize-diacritics'
export type SearchTextValue = export type SearchTextValue =
| string string | number | boolean | bigint | null | undefined
| number
| boolean
| bigint
| null
| undefined
export function normalizeSearchText(value: SearchTextValue): string { export function normalizeSearchText(value: SearchTextValue): string {
return normalizeSync(String(value ?? '')).toLocaleLowerCase() return normalizeSync(String(value ?? '')).toLocaleLowerCase()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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