Compare commits

...

58 Commits

Author SHA1 Message Date
shamoon
6758bba0c7 Bit more coverage 2026-02-08 23:55:40 -08:00
shamoon
e3d3feca23 Sonar 2026-02-08 23:55:40 -08:00
shamoon
1f64f6caff Use an enum 2026-02-08 23:55:40 -08:00
shamoon
8d694388cd Old css 2026-02-08 23:55:40 -08:00
shamoon
e29a393743 Fix ExpressionChangedAfterItHasBeenCheckedError 2026-02-08 23:55:40 -08:00
shamoon
9b0e4756b3 Actually just use title 2026-02-08 23:55:40 -08:00
shamoon
8ebbb6eea8 nav-underline 2026-02-08 23:55:40 -08:00
shamoon
f829a89770 Testing for new stuff 2026-02-08 23:55:40 -08:00
shamoon
4d7a4e3e62 Fix e2e test 2026-02-08 23:55:40 -08:00
shamoon
3672ab3b13 Store collapsed setting 2026-02-08 23:55:40 -08:00
shamoon
87872a377c Attributes submenu 2026-02-08 23:55:40 -08:00
shamoon
bc26380025 document attributes page title 2026-02-08 23:55:40 -08:00
shamoon
2727bbc716 Oops broke the buttons 2026-02-08 23:55:40 -08:00
shamoon
be8b027f53 info links 2026-02-08 23:55:40 -08:00
shamoon
64cfae1fcd Fix test imports 2026-02-08 23:55:40 -08:00
shamoon
83b02cd40a DocumentAttributesTab --> Section, move header stuff in there too 2026-02-08 23:55:40 -08:00
shamoon
2caf2ec5be More reorganizing 2026-02-08 23:55:40 -08:00
shamoon
c634bb4a02 DRY FTW 2026-02-08 23:55:40 -08:00
shamoon
75c8d53293 Move lists under management list 2026-02-08 23:55:40 -08:00
shamoon
79b44e1850 Remove unused imports 2026-02-08 23:55:40 -08:00
shamoon
4e0ec9ca0b Finish rename 2026-02-08 23:55:40 -08:00
shamoon
6dfb919421 Icons, of course 2026-02-08 23:55:40 -08:00
shamoon
145c11394b Ok lets just merge it all together 2026-02-08 23:55:40 -08:00
shamoon
9d7231d2dc Tweak: improve 2-digit year parsing (#12044) 2026-02-08 23:00:00 -08:00
GitHub Actions
4208d9255a Auto translate strings 2026-02-09 05:26:28 +00:00
shamoon
9e9e55758f Enhancement: pngx pdf viewer (#12043) 2026-02-08 21:24:43 -08:00
Trenton H
6a87c3f4dd Fixes handling the case where there is no status reported from celery (due to external termination of the worker) (#12040) 2026-02-08 17:26:35 -08:00
shamoon
d7c64760ed Update playwright docker image version in CI too 2026-02-07 21:12:12 -08:00
dependabot[bot]
750c77736b Chore(deps-dev): Bump @playwright/test from 1.58.1 to 1.58.2 in /src-ui (#12032)
Bumps [@playwright/test](https://github.com/microsoft/playwright) from 1.58.1 to 1.58.2.
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.58.1...v1.58.2)

---
updated-dependencies:
- dependency-name: "@playwright/test"
  dependency-version: 1.58.2
  dependency-type: direct:development
  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-02-08 03:47:48 +00:00
shamoon
30b1d3c6d7 Fix missing content_length migration 2026-02-07 11:07:16 -08:00
shamoon
d3ff856202 Merge origin/main into dev 2026-02-07 11:04:55 -08:00
shamoon
3bc4631a0f CI: build docs with Zensical in release workflow 2026-02-07 10:59:06 -08:00
shamoon
ab328e0212 Chore: move to Zensical for docs (#12011)
(cherry picked from commit 3c51b3f9cd)
2026-02-07 10:58:55 -08:00
Trenton H
5c3d02e6d4 Chore: Configure pyrefly as an alternative typing tool (#12003) 2026-02-07 10:33:00 -08:00
dependabot[bot]
1d89ec402b Chore(deps): Bump the utilities-minor group across 1 directory with 2 updates (#12020)
Bumps the utilities-minor group with 2 updates in the / directory: [openai](https://github.com/openai/openai-python) and [types-dateparser](https://github.com/typeshed-internal/stub_uploader).


Updates `openai` from 2.16.0 to 2.17.0
- [Release notes](https://github.com/openai/openai-python/releases)
- [Changelog](https://github.com/openai/openai-python/blob/main/CHANGELOG.md)
- [Commits](https://github.com/openai/openai-python/compare/v2.16.0...v2.17.0)

Updates `types-dateparser` from 1.2.2.20250809 to 1.3.0.20260206
- [Commits](https://github.com/typeshed-internal/stub_uploader/commits)

---
updated-dependencies:
- dependency-name: openai
  dependency-version: 2.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: types-dateparser
  dependency-version: 1.3.0.20260206
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-07 10:17:42 -08:00
shamoon
6192915be7 Fixhancement: improve ASN handling with PDF operations (#11689) 2026-02-06 21:14:02 +00:00
dependabot[bot]
b9b90ec9f7 docker-compose(deps): Bump nginx in /docker/compose (#12018)
Bumps nginx from 1.29-alpine to 1.29.5-alpine.

---
updated-dependencies:
- dependency-name: nginx
  dependency-version: 1.29.5-alpine
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-06 12:26:29 -08:00
shamoon
0dc58cf686 Update GitHub Pages artifact naming in CI workflow 2026-02-06 11:54:43 -08:00
shamoon
505ff31748 Update CI workflow with additional permissions
Add permissions for contents, pages, and id-token.
2026-02-06 10:04:51 -08:00
shamoon
3c51b3f9cd Chore: move to Zensical for docs (#12011) 2026-02-06 08:34:15 -08:00
dependabot[bot]
dfbac35f9c Upgrade: Bump @types/node from 25.2.0 to 25.2.1 in /src-ui (#12008)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.2.0 to 25.2.1.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.2.1
  dependency-type: direct:development
  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-02-06 03:52:09 +00:00
dependabot[bot]
8f917555b1 Upgrade: Bump webpack from 5.103.0 to 5.105.0 in /src-ui (#12007)
Bumps [webpack](https://github.com/webpack/webpack) from 5.103.0 to 5.105.0.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Changelog](https://github.com/webpack/webpack/blob/main/CHANGELOG.md)
- [Commits](https://github.com/webpack/webpack/compare/v5.103.0...v5.105.0)

---
updated-dependencies:
- dependency-name: webpack
  dependency-version: 5.105.0
  dependency-type: direct:development
  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-02-06 03:43:29 +00:00
GitHub Actions
734b5b9a45 Auto translate strings 2026-02-06 03:40:03 +00:00
shamoon
0f1cae03ec Chore: bump Angular to 21.1.3, ngx-ui-tour-ng-bootstrap to v18 (#12015) 2026-02-05 19:37:59 -08:00
Trenton H
71663fdbe2 Chore: Switches all locations to use prek in place of pre-commit (#12002) 2026-02-05 10:51:23 -08:00
Jason Lingohr
1188a89369 Documentation: update FAQ about file extension handling (#12000)
---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-02-05 02:05:47 +00:00
dependabot[bot]
b8e3b6590e docker(deps): Bump astral-sh/uv (#11980)
Bumps [astral-sh/uv](https://github.com/astral-sh/uv) from 0.9.26-python3.12-trixie-slim to 0.9.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.9.26...0.9.28)

---
updated-dependencies:
- dependency-name: astral-sh/uv
  dependency-version: 0.9.28-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-02-04 13:42:07 -08:00
dependabot[bot]
4a5116adf8 docker-compose(deps): Bump gotenberg/gotenberg in /docker/compose (#11979)
Bumps gotenberg/gotenberg from 8.25 to 8.26.

---
updated-dependencies:
- dependency-name: gotenberg/gotenberg
  dependency-version: '8.26'
  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-02-04 13:24:19 -08:00
dependabot[bot]
bbf2e63f10 Chore(deps): Bump the utilities-patch group with 3 updates (#11981)
Bumps the utilities-patch group with 3 updates: llama-index-llms-openai, [tqdm](https://github.com/tqdm/tqdm) and [types-tqdm](https://github.com/typeshed-internal/stub_uploader).


Updates `llama-index-llms-openai` from 0.6.15 to 0.6.16

Updates `tqdm` from 4.67.1 to 4.67.2
- [Release notes](https://github.com/tqdm/tqdm/releases)
- [Commits](https://github.com/tqdm/tqdm/compare/v4.67.1...v4.67.2)

Updates `types-tqdm` from 4.67.0.20250809 to 4.67.2.20260202
- [Commits](https://github.com/typeshed-internal/stub_uploader/commits)

---
updated-dependencies:
- dependency-name: llama-index-llms-openai
  dependency-version: 0.6.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: utilities-patch
- dependency-name: tqdm
  dependency-version: 4.67.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: utilities-patch
- dependency-name: types-tqdm
  dependency-version: 4.67.2.20260202
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: utilities-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-04 12:49:01 -08:00
dependabot[bot]
33cbe2ad54 Chore(deps): Bump the utilities-minor group across 1 directory with 6 updates (#11993)
* Chore(deps): Bump the utilities-minor group across 1 directory with 6 updates

Bumps the utilities-minor group with 6 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [babel](https://github.com/python-babel/babel) | `2.17.0` | `2.18.0` |
| [dateparser](https://github.com/scrapinghub/dateparser) | `1.2.2` | `1.3.0` |
| [django-cachalot](https://github.com/noripyt/django-cachalot) | `2.8.0` | `2.9.0` |
| [openai](https://github.com/openai/openai-python) | `2.15.0` | `2.16.0` |
| [torch](https://github.com/pytorch/pytorch) | `2.9.1` | `2.10.0` |
| [ruff](https://github.com/astral-sh/ruff) | `0.14.14` | `0.15.0` |



Updates `babel` from 2.17.0 to 2.18.0
- [Release notes](https://github.com/python-babel/babel/releases)
- [Changelog](https://github.com/python-babel/babel/blob/master/CHANGES.rst)
- [Commits](https://github.com/python-babel/babel/compare/v2.17.0...v2.18.0)

Updates `dateparser` from 1.2.2 to 1.3.0
- [Release notes](https://github.com/scrapinghub/dateparser/releases)
- [Changelog](https://github.com/scrapinghub/dateparser/blob/master/HISTORY.rst)
- [Commits](https://github.com/scrapinghub/dateparser/compare/v1.2.2...v1.3.0)

Updates `django-cachalot` from 2.8.0 to 2.9.0
- [Release notes](https://github.com/noripyt/django-cachalot/releases)
- [Changelog](https://github.com/noripyt/django-cachalot/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/noripyt/django-cachalot/compare/v2.8.0...v2.9.0)

Updates `openai` from 2.15.0 to 2.16.0
- [Release notes](https://github.com/openai/openai-python/releases)
- [Changelog](https://github.com/openai/openai-python/blob/main/CHANGELOG.md)
- [Commits](https://github.com/openai/openai-python/compare/v2.15.0...v2.16.0)

Updates `torch` from 2.9.1 to 2.10.0
- [Release notes](https://github.com/pytorch/pytorch/releases)
- [Changelog](https://github.com/pytorch/pytorch/blob/main/RELEASE.md)
- [Commits](https://github.com/pytorch/pytorch/compare/v2.9.1...v2.10.0)

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

---
updated-dependencies:
- dependency-name: babel
  dependency-version: 2.18.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: dateparser
  dependency-version: 1.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: django-cachalot
  dependency-version: 2.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: openai
  dependency-version: 2.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: torch
  dependency-version: 2.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: ruff
  dependency-version: 0.15.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
...

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

* Updates to ruff 0.15.0

* Ignores all notes in the baseline.  They seem to be problematic??

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Trenton H <797416+stumpylog@users.noreply.github.com>
2026-02-04 11:50:31 -08:00
dependabot[bot]
261e10ebeb Chore(deps): Bump drf-spectacular-sidecar from 2025.10.1 to 2026.1.1 (#11985)
Bumps [drf-spectacular-sidecar](https://github.com/tfranzel/drf-spectacular-sidecar) from 2025.10.1 to 2026.1.1.
- [Commits](https://github.com/tfranzel/drf-spectacular-sidecar/compare/2025.10.1...2026.1.1)

---
updated-dependencies:
- dependency-name: drf-spectacular-sidecar
  dependency-version: 2026.1.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-04 08:32:41 -08:00
dependabot[bot]
585c28b460 Chore(deps): Update django-allauth[mfa,socialaccount] requirement (#11984)
Updates the requirements on [django-allauth[mfa,socialaccount]](https://github.com/sponsors/pennersr) to permit the latest version.
- [Commits](https://github.com/sponsors/pennersr/commits)

---
updated-dependencies:
- dependency-name: django-allauth[mfa,socialaccount]
  dependency-version: 65.14.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-04 08:15:55 -08:00
dependabot[bot]
e77ab3357c Chore(deps): Update granian[uvloop] requirement from ~=2.6.0 to ~=2.7.0 (#11983)
Updates the requirements on [granian[uvloop]](https://github.com/emmett-framework/granian) to permit the latest version.
- [Release notes](https://github.com/emmett-framework/granian/releases)
- [Commits](https://github.com/emmett-framework/granian/compare/v2.6.0...v2.7.0)

---
updated-dependencies:
- dependency-name: granian[uvloop]
  dependency-version: 2.7.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-04 07:46:24 -08:00
dependabot[bot]
05ab091ea4 Chore(deps): Bump django from 5.2.10 to 5.2.11 (#11988)
* Chore(deps): Bump django from 5.2.10 to 5.2.11

Bumps [django](https://github.com/django/django) from 5.2.10 to 5.2.11.
- [Commits](https://github.com/django/django/compare/5.2.10...5.2.11)

---
updated-dependencies:
- dependency-name: django
  dependency-version: 5.2.11
  dependency-type: direct:production
...

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

* Reruns the baseline sync

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Trenton Holmes <797416+stumpylog@users.noreply.github.com>
2026-02-04 07:21:13 -08:00
Trenton H
fb7abf7a6e Chore: Enable mypy checking in CI (#11991) 2026-02-03 16:02:33 -08:00
GitHub Actions
6ad2fc0356 Auto translate strings 2026-02-03 20:11:13 +00:00
Trenton H
2ec8ec96c8 Feature: Enable users to customize date parsing via plugins (#11931) 2026-02-03 20:09:13 +00:00
Trenton H
276dc13e3f Chore: Fixes the TO filter chaining so it doesn't reset the messages list + deterministic UIDs (#11987) 2026-02-03 11:31:19 -08:00
132 changed files with 25199 additions and 2551 deletions

View File

@@ -91,12 +91,12 @@ Additional tasks are available for common maintenance operations:
## Committing from the Host Machine ## Committing from the Host Machine
The DevContainer automatically installs pre-commit hooks during setup. However, these hooks are configured for use inside the container. The DevContainer automatically installs Git pre-commit hooks during setup. However, these hooks are configured for use inside the container.
If you want to commit changes from your host machine (outside the DevContainer), you need to set up pre-commit on your host. This installs it as a standalone tool. If you want to commit changes from your host machine (outside the DevContainer), you need to set up prek on your host. This installs it as a standalone tool.
```bash ```bash
uv tool install pre-commit && pre-commit install uv tool install prek && prek install
``` ```
After this, you can commit either from inside the DevContainer or from your host machine. After this, you can commit either from inside the DevContainer or from your host machine.

View File

@@ -7,7 +7,7 @@
"containerEnv": { "containerEnv": {
"UV_CACHE_DIR": "/usr/src/paperless/paperless-ngx/.uv-cache" "UV_CACHE_DIR": "/usr/src/paperless/paperless-ngx/.uv-cache"
}, },
"postCreateCommand": "/bin/bash -c 'rm -rf .venv/.* && uv sync --group dev && uv run pre-commit install'", "postCreateCommand": "/bin/bash -c 'rm -rf .venv/.* && uv sync --group dev && uv run prek install'",
"customizations": { "customizations": {
"vscode": { "vscode": {
"extensions": [ "extensions": [

View File

@@ -116,9 +116,9 @@
}, },
{ {
"label": "Maintenance: Build Documentation", "label": "Maintenance: Build Documentation",
"description": "Build the documentation with MkDocs", "description": "Build the documentation with Zensical",
"type": "shell", "type": "shell",
"command": "uv run mkdocs build --config-file mkdocs.yml && uv run mkdocs serve", "command": "uv run zensical build && uv run zensical serve",
"group": "none", "group": "none",
"presentation": { "presentation": {
"echo": true, "echo": true,

View File

@@ -28,3 +28,4 @@
./resources ./resources
# Other stuff # Other stuff
**/*.drawio.png **/*.drawio.png
.mypy_baseline

View File

@@ -37,6 +37,6 @@ NOTE: PRs that do not address the following will not be merged, please do not sk
- [ ] If applicable, I have included testing coverage for new code in this PR, for [backend](https://docs.paperless-ngx.com/development/#testing) and / or [front-end](https://docs.paperless-ngx.com/development/#testing-and-code-style) changes. - [ ] If applicable, I have included testing coverage for new code in this PR, for [backend](https://docs.paperless-ngx.com/development/#testing) and / or [front-end](https://docs.paperless-ngx.com/development/#testing-and-code-style) changes.
- [ ] If applicable, I have tested my code for breaking changes & regressions on both mobile & desktop devices, using the latest version of major browsers. - [ ] If applicable, I have tested my code for breaking changes & regressions on both mobile & desktop devices, using the latest version of major browsers.
- [ ] If applicable, I have checked that all tests pass, see [documentation](https://docs.paperless-ngx.com/development/#back-end-development). - [ ] If applicable, I have checked that all tests pass, see [documentation](https://docs.paperless-ngx.com/development/#back-end-development).
- [ ] I have run all `pre-commit` hooks, see [documentation](https://docs.paperless-ngx.com/development/#code-formatting-with-pre-commit-hooks). - [ ] I have run all Git `pre-commit` hooks, see [documentation](https://docs.paperless-ngx.com/development/#code-formatting-with-pre-commit-hooks).
- [ ] I have made corresponding changes to the documentation as needed. - [ ] I have made corresponding changes to the documentation as needed.
- [ ] In the description of the PR above I have disclosed the use of AI tools in the coding of this PR. - [ ] In the description of the PR above I have disclosed the use of AI tools in the coding of this PR.

View File

@@ -46,8 +46,8 @@ updates:
patterns: patterns:
- "*pytest*" - "*pytest*"
- "ruff" - "ruff"
- "mkdocs-material" - "zensical"
- "pre-commit*" - "prek*"
# Django & DRF Ecosystem # Django & DRF Ecosystem
django-ecosystem: django-ecosystem:
patterns: patterns:

View File

@@ -99,3 +99,52 @@ jobs:
run: | run: |
docker compose --file docker/compose/docker-compose.ci-test.yml logs docker compose --file docker/compose/docker-compose.ci-test.yml logs
docker compose --file docker/compose/docker-compose.ci-test.yml down docker compose --file docker/compose/docker-compose.ci-test.yml down
typing:
name: Check project typing
runs-on: ubuntu-24.04
env:
DEFAULT_PYTHON: "3.12"
steps:
- name: Checkout
uses: actions/checkout@v6.0.1
- name: Set up Python
id: setup-python
uses: actions/setup-python@v6.2.0
with:
python-version: "${{ env.DEFAULT_PYTHON }}"
- name: Install uv
uses: astral-sh/setup-uv@v7.2.1
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
python-version: ${{ steps.setup-python.outputs.python-version }}
- name: Install Python dependencies
run: |
uv sync \
--python ${{ steps.setup-python.outputs.python-version }} \
--group testing \
--group typing \
--frozen
- name: List installed Python dependencies
run: |
uv pip list
- name: Check typing (pyrefly)
run: |
uv run pyrefly \
check \
src/
- name: Cache Mypy
uses: actions/cache@v5.0.3
with:
path: .mypy_cache
# Keyed by OS, Python version, and dependency hashes
key: ${{ runner.os }}-mypy-py${{ env.DEFAULT_PYTHON }}-${{ hashFiles('pyproject.toml', 'uv.lock') }}
restore-keys: |
${{ runner.os }}-mypy-py${{ env.DEFAULT_PYTHON }}-
${{ runner.os }}-mypy-
- name: Check typing (mypy)
run: |
uv run mypy \
--show-error-codes \
--warn-unused-configs \
src/ | uv run mypy-baseline filter

View File

@@ -6,17 +6,25 @@ on:
- dev - dev
paths: paths:
- 'docs/**' - 'docs/**'
- 'mkdocs.yml' - 'zensical.toml'
- 'pyproject.toml'
- 'uv.lock'
- '.github/workflows/ci-docs.yml' - '.github/workflows/ci-docs.yml'
pull_request: pull_request:
paths: paths:
- 'docs/**' - 'docs/**'
- 'mkdocs.yml' - 'zensical.toml'
- 'pyproject.toml'
- 'uv.lock'
- '.github/workflows/ci-docs.yml' - '.github/workflows/ci-docs.yml'
workflow_dispatch: workflow_dispatch:
concurrency: concurrency:
group: docs-${{ github.event.pull_request.number || github.ref }} group: docs-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true cancel-in-progress: true
permissions:
contents: read
pages: write
id-token: write
env: env:
DEFAULT_UV_VERSION: "0.9.x" DEFAULT_UV_VERSION: "0.9.x"
DEFAULT_PYTHON_VERSION: "3.11" DEFAULT_PYTHON_VERSION: "3.11"
@@ -25,6 +33,7 @@ jobs:
name: Build Documentation name: Build Documentation
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/configure-pages@v5
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Set up Python - name: Set up Python
@@ -47,42 +56,23 @@ jobs:
--python ${{ steps.setup-python.outputs.python-version }} \ --python ${{ steps.setup-python.outputs.python-version }} \
--dev \ --dev \
--frozen \ --frozen \
mkdocs build --config-file ./mkdocs.yml zensical build --clean
- name: Upload artifact - name: Upload GitHub Pages artifact
uses: actions/upload-artifact@v6 uses: actions/upload-pages-artifact@v4
with: with:
name: documentation path: site
path: site/ name: github-pages-${{ github.run_id }}-${{ github.run_attempt }}
retention-days: 7
deploy: deploy:
name: Deploy Documentation name: Deploy Documentation
needs: build needs: build
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps: steps:
- name: Checkout - name: Deploy GitHub Pages
uses: actions/checkout@v6 uses: actions/deploy-pages@v4
- name: Set up Python id: deployment
id: setup-python
uses: actions/setup-python@v6
with: with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }} artifact_name: github-pages-${{ github.run_id }}-${{ github.run_attempt }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install Python dependencies
run: |
uv sync --python ${{ steps.setup-python.outputs.python-version }} --dev --frozen
- name: Deploy documentation
run: |
echo "docs.paperless-ngx.com" > "${{ github.workspace }}/docs/CNAME"
git config --global user.name "${{ github.actor }}"
git config --global user.email "${{ github.actor }}@users.noreply.github.com"
uv run \
--python ${{ steps.setup-python.outputs.python-version }} \
--dev \
--frozen \
mkdocs gh-deploy --force --no-history

View File

@@ -121,7 +121,7 @@ jobs:
name: "E2E Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})" name: "E2E Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})"
needs: install-dependencies needs: install-dependencies
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
container: mcr.microsoft.com/playwright:v1.58.1-noble container: mcr.microsoft.com/playwright:v1.58.2-noble
env: env:
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1

View File

@@ -10,15 +10,15 @@ concurrency:
group: lint-${{ github.event.pull_request.number || github.ref }} group: lint-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
pre-commit: lint:
name: Pre-commit Checks name: Linting via prek
runs-on: ubuntu-24.04 runs-on: ubuntu-slim
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6.0.2
- name: Install Python - name: Install Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6.2.0
with: with:
python-version: "3.11" python-version: "3.14"
- name: Run pre-commit - name: Run prek
uses: pre-commit/action@v3.0.1 uses: j178/prek-action@v1.1.0

View File

@@ -70,7 +70,7 @@ jobs:
--python ${{ steps.setup-python.outputs.python-version }} \ --python ${{ steps.setup-python.outputs.python-version }} \
--dev \ --dev \
--frozen \ --frozen \
mkdocs build --config-file ./mkdocs.yml zensical build --clean
# ---- Prepare Release ---- # ---- Prepare Release ----
- name: Generate requirements file - name: Generate requirements file
run: | run: |
@@ -211,7 +211,7 @@ jobs:
uv run \ uv run \
--python ${{ steps.setup-python.outputs.python-version }} \ --python ${{ steps.setup-python.outputs.python-version }} \
--dev \ --dev \
pre-commit run --files changelog.md || true prek run --files changelog.md || true
git config --global user.name "github-actions" git config --global user.name "github-actions"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"

2
.gitignore vendored
View File

@@ -54,7 +54,7 @@ junit.xml
# Django stuff: # Django stuff:
*.log *.log
# MkDocs documentation # Zensical documentation
site/ site/
# PyBuilder # PyBuilder

2470
.mypy-baseline.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
# This file configures pre-commit hooks. # This file configures pre-commit hooks.
# See https://pre-commit.com/ for general information # See https://pre-commit.com/ for general information
# See https://pre-commit.com/hooks.html for a listing of possible hooks # See https://pre-commit.com/hooks.html for a listing of possible hooks
# We actually run via https://github.com/j178/prek which is compatible
repos: repos:
# General hooks # General hooks
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
@@ -49,12 +50,12 @@ repos:
- 'prettier-plugin-organize-imports@4.1.0' - 'prettier-plugin-organize-imports@4.1.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.14.14 rev: v0.15.0
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.11.1" rev: "v2.12.1"
hooks: hooks:
- id: pyproject-fmt - id: pyproject-fmt
# Dockerfile hooks # Dockerfile hooks

17368
.pyrefly-baseline.json Normal file

File diff suppressed because one or more lines are too long

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.9.26-python3.12-trixie-slim AS s6-overlay-base FROM ghcr.io/astral-sh/uv:0.9.29-python3.12-trixie-slim AS s6-overlay-base
WORKDIR /usr/src/s6 WORKDIR /usr/src/s6

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.25 image: docker.io/gotenberg/gotenberg:8.26
hostname: gotenberg hostname: gotenberg
container_name: gotenberg container_name: gotenberg
network_mode: host network_mode: host
@@ -35,7 +35,7 @@ services:
- "3143:3143" # IMAP - "3143:3143" # IMAP
restart: unless-stopped restart: unless-stopped
nginx: nginx:
image: docker.io/nginx:1.29-alpine image: docker.io/nginx:1.29.5-alpine
hostname: nginx hostname: nginx
container_name: nginx container_name: nginx
ports: ports:

View File

@@ -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.25 image: docker.io/gotenberg/gotenberg:8.26
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.

View File

@@ -66,7 +66,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.25 image: docker.io/gotenberg/gotenberg:8.26
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.

View File

@@ -55,7 +55,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.25 image: docker.io/gotenberg/gotenberg:8.26
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.

View File

@@ -1,4 +1,4 @@
# The REST API # REST API
Paperless-ngx now ships with a fully-documented REST API and a browsable Paperless-ngx now ships with a fully-documented REST API and a browsable
web interface to explore it. The API browsable interface is available at web interface to explore it. The API browsable interface is available at

View File

@@ -1,13 +1,31 @@
:root > * { :root>* {
--md-primary-fg-color: #17541f; --paperless-green: #17541f;
--md-primary-fg-color--dark: #17541f; --paperless-green-accent: #2b8a38;
--md-primary-fg-color--light: #17541f; --md-primary-fg-color: var(--paperless-green);
--md-accent-fg-color: #2b8a38; --md-primary-fg-color--dark: var(--paperless-green);
--md-primary-fg-color--light: var(--paperless-green-accent);
--md-accent-fg-color: var(--paperless-green-accent);
--md-typeset-a-color: #21652a; --md-typeset-a-color: #21652a;
} }
.md-header,
.md-tabs {
background-color: var(--paperless-green);
color: #fff;
}
.md-tabs__link {
color: rgba(255, 255, 255, 0.82);
}
.md-tabs__link:hover,
.md-tabs__link--active {
color: #fff;
}
[data-md-color-scheme="slate"] { [data-md-color-scheme="slate"] {
--md-hue: 222; --md-hue: 222;
--md-default-bg-color: hsla(var(--md-hue), 15%, 10%, 1);
} }
@media (min-width: 768px) { @media (min-width: 768px) {
@@ -69,8 +87,8 @@ h4 code {
} }
/* Hide config vars from sidebar, toc and move the border on mobile case their hidden */ /* Hide config vars from sidebar, toc and move the border on mobile case their hidden */
.md-nav.md-nav--secondary .md-nav__item .md-nav__link[href*="PAPERLESS_"], .md-nav.md-nav--secondary .md-nav__item:has(> .md-nav__link[href*="PAPERLESS_"]),
.md-nav.md-nav--secondary .md-nav__item .md-nav__link[href*="USERMAP_"] { .md-nav.md-nav--secondary .md-nav__item:has(> .md-nav__link[href*="USERMAP_"]) {
display: none; display: none;
} }
@@ -83,18 +101,3 @@ h4 code {
border-top: .05rem solid var(--md-default-fg-color--lightest); border-top: .05rem solid var(--md-default-fg-color--lightest);
} }
} }
/* Show search shortcut key */
[data-md-toggle="search"]:not(:checked) ~ .md-header .md-search__form::after {
position: absolute;
top: .3rem;
right: .3rem;
display: block;
padding: .1rem .4rem;
color: var(--md-default-fg-color--lighter);
font-weight: bold;
font-size: .8rem;
border: .05rem solid var(--md-default-fg-color--lighter);
border-radius: .1rem;
content: "/";
}

View File

@@ -81,7 +81,7 @@ first-time setup.
5. Install pre-commit hooks: 5. Install pre-commit hooks:
```bash ```bash
$ uv run pre-commit install $ uv run prek install
``` ```
6. Apply migrations and create a superuser (also can be done via the web UI) for your development instance: 6. Apply migrations and create a superuser (also can be done via the web UI) for your development instance:
@@ -217,7 +217,7 @@ commit. See [above](#code-formatting-with-pre-commit-hooks) for installation ins
command such as command such as
```bash ```bash
$ git ls-files -- '*.ts' | xargs pre-commit run prettier --files $ git ls-files -- '*.ts' | xargs prek run prettier --files
``` ```
Front end testing uses Jest and Playwright. Unit tests and e2e tests, Front end testing uses Jest and Playwright. Unit tests and e2e tests,
@@ -338,13 +338,13 @@ LANGUAGES = [
## Building the documentation ## Building the documentation
The documentation is built using material-mkdocs, see their [documentation](https://squidfunk.github.io/mkdocs-material/reference/). The documentation is built using Zensical, see their [documentation](https://zensical.org/docs/).
If you want to build the documentation locally, this is how you do it: If you want to build the documentation locally, this is how you do it:
1. Build the documentation 1. Build the documentation
```bash ```bash
$ uv run mkdocs build --config-file mkdocs.yml $ uv run zensical build
``` ```
_alternatively..._ _alternatively..._
@@ -355,7 +355,7 @@ If you want to build the documentation locally, this is how you do it:
something. something.
```bash ```bash
$ uv run mkdocs serve $ uv run zensical serve
``` ```
## Building the Docker image ## Building the Docker image
@@ -481,3 +481,147 @@ To get started:
5. The project is ready for debugging, start either run the fullstack debug or individual debug 5. The project is ready for debugging, start either run the fullstack debug or individual debug
processes. Yo spin up the project without debugging run the task **Project Start: Run all Services** processes. Yo spin up the project without debugging run the task **Project Start: Run all Services**
## Developing Date Parser Plugins
Paperless-ngx uses a plugin system for date parsing, allowing you to extend or replace the default date parsing behavior. Plugins are discovered using [Python entry points](https://setuptools.pypa.io/en/latest/userguide/entry_point.html).
### Creating a Date Parser Plugin
To create a custom date parser plugin, you need to:
1. Create a class that inherits from `DateParserPluginBase`
2. Implement the required abstract method
3. Register your plugin via an entry point
#### 1. Implementing the Parser Class
Your parser must extend `documents.plugins.date_parsing.DateParserPluginBase` and implement the `parse` method:
```python
from collections.abc import Iterator
import datetime
from documents.plugins.date_parsing import DateParserPluginBase
class MyDateParserPlugin(DateParserPluginBase):
"""
Custom date parser implementation.
"""
def parse(self, filename: str, content: str) -> Iterator[datetime.datetime]:
"""
Parse dates from the document's filename and content.
Args:
filename: The original filename of the document
content: The extracted text content of the document
Yields:
datetime.datetime: Valid datetime objects found in the document
"""
# Your parsing logic here
# Use self.config to access configuration settings
# Example: parse dates from filename first
if self.config.filename_date_order:
# Your filename parsing logic
yield some_datetime
# Then parse dates from content
# Your content parsing logic
yield another_datetime
```
#### 2. Configuration and Helper Methods
Your parser instance is initialized with a `DateParserConfig` object accessible via `self.config`. This provides:
- `languages: list[str]` - List of language codes for date parsing
- `timezone_str: str` - Timezone string for date localization
- `ignore_dates: set[datetime.date]` - Dates that should be filtered out
- `reference_time: datetime.datetime` - Current time for filtering future dates
- `filename_date_order: str | None` - Date order preference for filenames (e.g., "DMY", "MDY")
- `content_date_order: str` - Date order preference for content
The base class provides two helper methods you can use:
```python
def _parse_string(
self,
date_string: str,
date_order: str,
) -> datetime.datetime | None:
"""
Parse a single date string using dateparser with configured settings.
"""
def _filter_date(
self,
date: datetime.datetime | None,
) -> datetime.datetime | None:
"""
Validate a parsed datetime against configured rules.
Filters out dates before 1900, future dates, and ignored dates.
"""
```
#### 3. Resource Management (Optional)
If your plugin needs to acquire or release resources (database connections, API clients, etc.), override the context manager methods. Paperless-ngx will always use plugins as context managers, ensuring resources can be released even in the event of errors.
#### 4. Registering Your Plugin
Register your plugin using a setuptools entry point in your package's `pyproject.toml`:
```toml
[project.entry-points."paperless_ngx.date_parsers"]
my_parser = "my_package.parsers:MyDateParserPlugin"
```
The entry point name (e.g., `"my_parser"`) is used for sorting when multiple plugins are found. Paperless-ngx will use the first plugin alphabetically by name if multiple plugins are discovered.
### Plugin Discovery
Paperless-ngx automatically discovers and loads date parser plugins at runtime. The discovery process:
1. Queries the `paperless_ngx.date_parsers` entry point group
2. Validates that each plugin is a subclass of `DateParserPluginBase`
3. Sorts valid plugins alphabetically by entry point name
4. Uses the first valid plugin, or falls back to the default `RegexDateParserPlugin` if none are found
If multiple plugins are installed, a warning is logged indicating which plugin was selected.
### Example: Simple Date Parser
Here's a minimal example that only looks for ISO 8601 dates:
```python
import datetime
import re
from collections.abc import Iterator
from documents.plugins.date_parsing.base import DateParserPluginBase
class ISODateParserPlugin(DateParserPluginBase):
"""
Parser that only matches ISO 8601 formatted dates (YYYY-MM-DD).
"""
ISO_REGEX = re.compile(r"\b(\d{4}-\d{2}-\d{2})\b")
def parse(self, filename: str, content: str) -> Iterator[datetime.datetime]:
# Combine filename and content for searching
text = f"{filename} {content}"
for match in self.ISO_REGEX.finditer(text):
date_string = match.group(1)
# Use helper method to parse with configured timezone
date = self._parse_string(date_string, "YMD")
# Use helper method to validate the date
filtered_date = self._filter_date(date)
if filtered_date is not None:
yield filtered_date
```

View File

@@ -1,3 +1,7 @@
---
title: FAQs
---
# Frequently Asked Questions # Frequently Asked Questions
## _What's the general plan for Paperless-ngx?_ ## _What's the general plan for Paperless-ngx?_
@@ -63,8 +67,10 @@ elsewhere. Here are a couple notes about that.
Paperless also supports various Office documents (.docx, .doc, odt, Paperless also supports various Office documents (.docx, .doc, odt,
.ppt, .pptx, .odp, .xls, .xlsx, .ods). .ppt, .pptx, .odp, .xls, .xlsx, .ods).
Paperless-ngx determines the type of a file by inspecting its content. Paperless-ngx determines the type of a file by inspecting its content
The file extensions do not matter. rather than its file extensions. However, files processed via the
consumption directory will be rejected if they have a file extension that
not supported by any of the available parsers.
## _Will paperless-ngx run on Raspberry Pi?_ ## _Will paperless-ngx run on Raspberry Pi?_

View File

@@ -1,3 +1,7 @@
---
title: Home
---
<div class="grid-left" markdown> <div class="grid-left" markdown>
![image](assets/logo_full_black.svg#only-light){.index-logo} ![image](assets/logo_full_black.svg#only-light){.index-logo}
![image](assets/logo_full_white.svg#only-dark){.index-logo} ![image](assets/logo_full_white.svg#only-dark){.index-logo}

View File

@@ -1,4 +1,8 @@
## Installation ---
title: Setup
---
# Installation
You can go multiple routes to setup and run Paperless: You can go multiple routes to setup and run Paperless:

View File

@@ -1,4 +1,8 @@
# Usage Overview ---
title: Basic Usage
---
# Usage
Paperless-ngx is an application that manages your personal documents. With Paperless-ngx is an application that manages your personal documents. With
the (optional) help of a document scanner (see [the scanners wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Scanner-&-Software-Recommendations)), Paperless-ngx transforms your unwieldy the (optional) help of a document scanner (see [the scanners wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Scanner-&-Software-Recommendations)), Paperless-ngx transforms your unwieldy

View File

@@ -1,87 +0,0 @@
site_name: Paperless-ngx
theme:
name: material
logo: assets/logo.svg
font:
text: Roboto
code: Roboto Mono
palette:
# Palette toggle for automatic mode
- media: "(prefers-color-scheme)"
toggle:
icon: material/brightness-auto
name: Switch to light mode
# Palette toggle for light mode
- media: "(prefers-color-scheme: light)"
scheme: default
toggle:
icon: material/brightness-7
name: Switch to dark mode
# Palette toggle for dark mode
- media: "(prefers-color-scheme: dark)"
scheme: slate
toggle:
icon: material/brightness-4
name: Switch to system preference
features:
- navigation.tabs
- navigation.top
- toc.integrate
- content.code.annotate
icon:
repo: fontawesome/brands/github
favicon: assets/favicon.png
repo_url: https://github.com/paperless-ngx/paperless-ngx
repo_name: paperless-ngx/paperless-ngx
edit_uri: blob/main/docs/
extra_css:
- assets/extra.css
markdown_extensions:
- attr_list
- md_in_html
- def_list
- admonition
- tables
- pymdownx.highlight:
anchor_linenums: true
- pymdownx.superfences
- pymdownx.inlinehilite
- pymdownx.snippets
- pymdownx.tilde
- footnotes
- pymdownx.superfences:
custom_fences:
- name: mermaid
class: mermaid
format: !!python/name:pymdownx.superfences.fence_code_format
- pymdownx.emoji:
emoji_index: !!python/name:material.extensions.emoji.twemoji
emoji_generator: !!python/name:material.extensions.emoji.to_svg
strict: true
nav:
- index.md
- setup.md
- 'Basic Usage': usage.md
- configuration.md
- administration.md
- advanced_usage.md
- 'REST API': api.md
- development.md
- 'FAQs': faq.md
- troubleshooting.md
- 'Migration to v3': migration.md
- changelog.md
copyright: Copyright &copy; 2016 - 2026 Daniel Quinn, Jonas Winkler, and the Paperless-ngx team
extra:
social:
- icon: fontawesome/brands/github
link: https://github.com/paperless-ngx/paperless-ngx
- icon: fontawesome/brands/docker
link: https://hub.docker.com/r/paperlessngx/paperless-ngx
- icon: material/chat
link: https://matrix.to/#/#paperless:matrix.org
plugins:
- search
- glightbox:
skip_classes:
- no-lightbox

View File

@@ -27,9 +27,9 @@ dependencies = [
# WARNING: django does not use semver. # WARNING: django does not use semver.
# Only patch versions are guaranteed to not introduce breaking changes. # Only patch versions are guaranteed to not introduce breaking changes.
"django~=5.2.10", "django~=5.2.10",
"django-allauth[mfa,socialaccount]~=65.13.1", "django-allauth[mfa,socialaccount]~=65.14.0",
"django-auditlog~=3.4.1", "django-auditlog~=3.4.1",
"django-cachalot~=2.8.0", "django-cachalot~=2.9.0",
"django-celery-results~=2.6.0", "django-celery-results~=2.6.0",
"django-compression-middleware~=0.5.0", "django-compression-middleware~=0.5.0",
"django-cors-headers~=4.9.0", "django-cors-headers~=4.9.0",
@@ -42,7 +42,7 @@ dependencies = [
"djangorestframework~=3.16", "djangorestframework~=3.16",
"djangorestframework-guardian~=0.4.0", "djangorestframework-guardian~=0.4.0",
"drf-spectacular~=0.28", "drf-spectacular~=0.28",
"drf-spectacular-sidecar~=2025.10.1", "drf-spectacular-sidecar~=2026.1.1",
"drf-writable-nested~=0.7.1", "drf-writable-nested~=0.7.1",
"faiss-cpu>=1.10", "faiss-cpu>=1.10",
"filelock~=3.20.0", "filelock~=3.20.0",
@@ -76,7 +76,7 @@ dependencies = [
"sentence-transformers>=4.1", "sentence-transformers>=4.1",
"setproctitle~=1.3.4", "setproctitle~=1.3.4",
"tika-client~=0.10.0", "tika-client~=0.10.0",
"torch~=2.9.1", "torch~=2.10.0",
"tqdm~=4.67.1", "tqdm~=4.67.1",
"watchfiles>=1.1.1", "watchfiles>=1.1.1",
"whitenoise~=6.11", "whitenoise~=6.11",
@@ -94,7 +94,7 @@ optional-dependencies.postgres = [
"psycopg-pool==3.3", "psycopg-pool==3.3",
] ]
optional-dependencies.webserver = [ optional-dependencies.webserver = [
"granian[uvloop]~=2.6.0", "granian[uvloop]~=2.7.0",
] ]
[dependency-groups] [dependency-groups]
@@ -106,8 +106,7 @@ dev = [
] ]
docs = [ docs = [
"mkdocs-glightbox~=0.5.1", "zensical>=0.0.21",
"mkdocs-material~=9.7.0",
] ]
testing = [ testing = [
@@ -127,9 +126,8 @@ testing = [
] ]
lint = [ lint = [
"pre-commit~=4.5.1", "prek~=0.3.0",
"pre-commit-uv~=4.2.0", "ruff~=0.15.0",
"ruff~=0.14.0",
] ]
typing = [ typing = [
@@ -138,8 +136,12 @@ typing = [
"django-stubs[compatible-mypy]", "django-stubs[compatible-mypy]",
"djangorestframework-stubs[compatible-mypy]", "djangorestframework-stubs[compatible-mypy]",
"lxml-stubs", "lxml-stubs",
"microsoft-python-type-stubs @ git+https://github.com/microsoft/python-type-stubs.git",
"mypy", "mypy",
"mypy-baseline",
"pyrefly",
"types-bleach", "types-bleach",
"types-channels",
"types-colorama", "types-colorama",
"types-dateparser", "types-dateparser",
"types-markdown", "types-markdown",
@@ -159,6 +161,11 @@ environments = [
"sys_platform == 'linux'", "sys_platform == 'linux'",
] ]
[[tool.uv.index]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
explicit = true
[tool.uv.sources] [tool.uv.sources]
# Markers are chosen to select these almost exclusively when building the Docker image # Markers are chosen to select these almost exclusively when building the Docker image
psycopg-c = [ psycopg-c = [
@@ -174,11 +181,6 @@ torch = [
{ index = "pytorch-cpu" }, { index = "pytorch-cpu" },
] ]
[[tool.uv.index]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
explicit = true
[tool.ruff] [tool.ruff]
target-version = "py310" target-version = "py310"
line-length = 88 line-length = 88
@@ -306,12 +308,20 @@ markers = [
"gotenberg: Tests requiring Gotenberg service", "gotenberg: Tests requiring Gotenberg service",
"tika: Tests requiring Tika service", "tika: Tests requiring Tika service",
"greenmail: Tests requiring Greenmail service", "greenmail: Tests requiring Greenmail service",
"date_parsing: Tests which cover date parsing from content or filename",
] ]
[tool.pytest_env] [tool.pytest_env]
PAPERLESS_DISABLE_DBHANDLER = "true" PAPERLESS_DISABLE_DBHANDLER = "true"
PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache" PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
[tool.coverage.report]
exclude_also = [
"if settings.AUDIT_LOG_ENABLED:",
"if AUDIT_LOG_ENABLED:",
"if TYPE_CHECKING:",
]
[tool.coverage.run] [tool.coverage.run]
source = [ source = [
"src/", "src/",
@@ -323,13 +333,6 @@ omit = [
"paperless/auth.py", "paperless/auth.py",
] ]
[tool.coverage.report]
exclude_also = [
"if settings.AUDIT_LOG_ENABLED:",
"if AUDIT_LOG_ENABLED:",
"if TYPE_CHECKING:",
]
[tool.mypy] [tool.mypy]
mypy_path = "src" mypy_path = "src"
plugins = [ plugins = [
@@ -343,5 +346,15 @@ disallow_untyped_defs = true
warn_redundant_casts = true warn_redundant_casts = true
warn_unused_ignores = true warn_unused_ignores = true
[tool.pyrefly]
search-path = [ "src" ]
baseline = ".pyrefly-baseline.json"
python-platform = "linux"
[tool.django-stubs] [tool.django-stubs]
django_settings_module = "paperless.settings" django_settings_module = "paperless.settings"
[tool.mypy-baseline]
baseline_path = ".mypy-baseline.txt"
sort_baseline = true
ignore_categories = [ "note" ]

View File

@@ -86,7 +86,6 @@
], ],
"scripts": [], "scripts": [],
"allowedCommonJsDependencies": [ "allowedCommonJsDependencies": [
"ng2-pdf-viewer",
"file-saver", "file-saver",
"utif" "utif"
], ],

View File

@@ -52,11 +52,11 @@ test('dashboard saved view document links', async ({ page }) => {
test('test slim sidebar', async ({ page }) => { test('test slim sidebar', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR1, { notFound: 'fallback' }) await page.routeFromHAR(REQUESTS_HAR1, { notFound: 'fallback' })
await page.goto('/dashboard') await page.goto('/dashboard')
await page.locator('#sidebarMenu').getByRole('button').click() await page.locator('.sidebar-slim-toggler').click()
await expect( await expect(
page.getByRole('link', { name: 'Dashboard' }).getByText('Dashboard') page.getByRole('link', { name: 'Dashboard' }).getByText('Dashboard')
).toBeHidden() ).toBeHidden()
await page.locator('#sidebarMenu').getByRole('button').click() await page.locator('.sidebar-slim-toggler').click()
await expect( await expect(
page.getByRole('link', { name: 'Dashboard' }).getByText('Dashboard') page.getByRole('link', { name: 'Dashboard' }).getByText('Dashboard')
).toBeVisible() ).toBeVisible()

View File

@@ -72,7 +72,7 @@ test('should show a mobile preview', async ({ page }) => {
await page.setViewportSize({ width: 400, height: 1000 }) await page.setViewportSize({ width: 400, height: 1000 })
await expect(page.getByRole('tab', { name: 'Preview' })).toBeVisible() await expect(page.getByRole('tab', { name: 'Preview' })).toBeVisible()
await page.getByRole('tab', { name: 'Preview' }).click() await page.getByRole('tab', { name: 'Preview' }).click()
await page.waitForSelector('pdf-viewer') await page.waitForSelector('pngx-pdf-viewer')
}) })
test('should show a list of notes', async ({ page }) => { test('should show a list of notes', async ({ page }) => {

View File

@@ -33,9 +33,9 @@ test('should not allow user to view correspondents', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' }) await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
await page.goto('/dashboard') await page.goto('/dashboard')
await expect( await expect(
page.getByRole('link', { name: 'Correspondents' }) page.getByRole('link', { name: 'Attributes' })
).not.toBeAttached() ).not.toBeAttached()
await page.goto('/correspondents') await page.goto('/attributes/correspondents')
await expect(page.locator('body')).toHaveText( await expect(page.locator('body')).toHaveText(
/You don't have permissions to do that/i /You don't have permissions to do that/i
) )
@@ -44,8 +44,10 @@ test('should not allow user to view correspondents', async ({ page }) => {
test('should not allow user to view tags', async ({ page }) => { test('should not allow user to view tags', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' }) await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
await page.goto('/dashboard') await page.goto('/dashboard')
await expect(page.getByRole('link', { name: 'Tags' })).not.toBeAttached() await expect(
await page.goto('/tags') page.getByRole('link', { name: 'Attributes' })
).not.toBeAttached()
await page.goto('/attributes/tags')
await expect(page.locator('body')).toHaveText( await expect(page.locator('body')).toHaveText(
/You don't have permissions to do that/i /You don't have permissions to do that/i
) )
@@ -55,9 +57,9 @@ test('should not allow user to view document types', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' }) await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
await page.goto('/dashboard') await page.goto('/dashboard')
await expect( await expect(
page.getByRole('link', { name: 'Document Types' }) page.getByRole('link', { name: 'Attributes' })
).not.toBeAttached() ).not.toBeAttached()
await page.goto('/documenttypes') await page.goto('/attributes/documenttypes')
await expect(page.locator('body')).toHaveText( await expect(page.locator('body')).toHaveText(
/You don't have permissions to do that/i /You don't have permissions to do that/i
) )
@@ -67,9 +69,9 @@ test('should not allow user to view storage paths', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' }) await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
await page.goto('/dashboard') await page.goto('/dashboard')
await expect( await expect(
page.getByRole('link', { name: 'Storage Paths' }) page.getByRole('link', { name: 'Attributes' })
).not.toBeAttached() ).not.toBeAttached()
await page.goto('/storagepaths') await page.goto('/attributes/storagepaths')
await expect(page.locator('body')).toHaveText( await expect(page.locator('body')).toHaveText(
/You don't have permissions to do that/i /You don't have permissions to do that/i
) )

View File

@@ -31,6 +31,10 @@ module.exports = {
moduleNameMapper: { moduleNameMapper: {
...esmPreset.moduleNameMapper, ...esmPreset.moduleNameMapper,
'^src/(.*)': '<rootDir>/src/$1', '^src/(.*)': '<rootDir>/src/$1',
'^pdfjs-dist/legacy/build/pdf\\.mjs$':
'<rootDir>/src/test/mocks/pdfjs-legacy-build-pdf.ts',
'^pdfjs-dist/web/pdf_viewer\\.mjs$':
'<rootDir>/src/test/mocks/pdfjs-web-pdf_viewer.ts',
}, },
workerIdleMemoryLimit: '512MB', workerIdleMemoryLimit: '512MB',
reporters: [ reporters: [

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@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/alert/alert.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/alert/alert.ts</context>
<context context-type="linenumber">50</context> <context context-type="linenumber">50</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@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/carousel/carousel.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/carousel/carousel.ts</context>
<context context-type="linenumber">131,135</context> <context context-type="linenumber">131,135</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@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/carousel/carousel.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/carousel/carousel.ts</context>
<context context-type="linenumber">159,162</context> <context context-type="linenumber">159,162</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@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/carousel/carousel.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/carousel/carousel.ts</context>
<context context-type="linenumber">202,203</context> <context context-type="linenumber">202,203</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@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/datepicker/datepicker-navigation-select.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
<context context-type="linenumber">91</context> <context context-type="linenumber">91</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/datepicker/datepicker-navigation-select.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
<context context-type="linenumber">91</context> <context context-type="linenumber">91</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@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/datepicker/datepicker-navigation-select.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
<context context-type="linenumber">91</context> <context context-type="linenumber">91</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/datepicker/datepicker-navigation-select.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
<context context-type="linenumber">91</context> <context context-type="linenumber">91</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@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/datepicker/datepicker-navigation.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/datepicker/datepicker-navigation.ts</context>
<context context-type="linenumber">83,85</context> <context context-type="linenumber">83,85</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/datepicker/datepicker-navigation.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/datepicker/datepicker-navigation.ts</context>
<context context-type="linenumber">112</context> <context context-type="linenumber">112</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@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/datepicker/datepicker-navigation.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/datepicker/datepicker-navigation.ts</context>
<context context-type="linenumber">112</context> <context context-type="linenumber">112</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/datepicker/datepicker-navigation.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/datepicker/datepicker-navigation.ts</context>
<context context-type="linenumber">112</context> <context context-type="linenumber">112</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@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/pagination/pagination-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/pagination/pagination-config.ts</context>
<context context-type="linenumber">20</context> <context context-type="linenumber">20</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@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/pagination/pagination-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/pagination/pagination-config.ts</context>
<context context-type="linenumber">20</context> <context context-type="linenumber">20</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@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/pagination/pagination-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/pagination/pagination-config.ts</context>
<context context-type="linenumber">20</context> <context context-type="linenumber">20</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@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/pagination/pagination-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/pagination/pagination-config.ts</context>
<context context-type="linenumber">20</context> <context context-type="linenumber">20</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@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/pagination/pagination-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/pagination/pagination-config.ts</context>
<context context-type="linenumber">20</context> <context context-type="linenumber">20</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@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/pagination/pagination-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/pagination/pagination-config.ts</context>
<context context-type="linenumber">20</context> <context context-type="linenumber">20</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@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/pagination/pagination-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/pagination/pagination-config.ts</context>
<context context-type="linenumber">20</context> <context context-type="linenumber">20</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@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/pagination/pagination-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/pagination/pagination-config.ts</context>
<context context-type="linenumber">20</context> <context context-type="linenumber">20</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@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/progressbar/progressbar.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/progressbar/progressbar.ts</context>
<context context-type="linenumber">41,42</context> <context context-type="linenumber">41,42</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@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/timepicker/timepicker-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/timepicker/timepicker-config.ts</context>
<context context-type="linenumber">21</context> <context context-type="linenumber">21</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@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/timepicker/timepicker-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/timepicker/timepicker-config.ts</context>
<context context-type="linenumber">21</context> <context context-type="linenumber">21</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@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/timepicker/timepicker-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/timepicker/timepicker-config.ts</context>
<context context-type="linenumber">21</context> <context context-type="linenumber">21</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@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/timepicker/timepicker-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/timepicker/timepicker-config.ts</context>
<context context-type="linenumber">21</context> <context context-type="linenumber">21</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@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/timepicker/timepicker-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/timepicker/timepicker-config.ts</context>
<context context-type="linenumber">21</context> <context context-type="linenumber">21</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@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/timepicker/timepicker-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/timepicker/timepicker-config.ts</context>
<context context-type="linenumber">21</context> <context context-type="linenumber">21</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@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/timepicker/timepicker-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/timepicker/timepicker-config.ts</context>
<context context-type="linenumber">21</context> <context context-type="linenumber">21</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@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/timepicker/timepicker-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/timepicker/timepicker-config.ts</context>
<context context-type="linenumber">21</context> <context context-type="linenumber">21</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@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/timepicker/timepicker-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/timepicker/timepicker-config.ts</context>
<context context-type="linenumber">21</context> <context context-type="linenumber">21</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@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/timepicker/timepicker-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/timepicker/timepicker-config.ts</context>
<context context-type="linenumber">21</context> <context context-type="linenumber">21</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@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/timepicker/timepicker-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/timepicker/timepicker-config.ts</context>
<context context-type="linenumber">21</context> <context context-type="linenumber">21</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@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/timepicker/timepicker-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/timepicker/timepicker-config.ts</context>
<context context-type="linenumber">21</context> <context context-type="linenumber">21</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@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/timepicker/timepicker-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/timepicker/timepicker-config.ts</context>
<context context-type="linenumber">21</context> <context context-type="linenumber">21</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@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/toast/toast-config.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/toast/toast-config.ts</context>
<context context-type="linenumber">54</context> <context context-type="linenumber">54</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
@@ -241,18 +241,18 @@
<source>Document <x id="PH" equiv-text="status.filename"/> was added to Paperless-ngx.</source> <source>Document <x id="PH" equiv-text="status.filename"/> was added to Paperless-ngx.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context> <context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">95</context> <context context-type="linenumber">90</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context> <context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">104</context> <context context-type="linenumber">99</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1931214133925051574" datatype="html"> <trans-unit id="1931214133925051574" datatype="html">
<source>Open document</source> <source>Open document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context> <context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">97</context> <context context-type="linenumber">92</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context> <context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
@@ -279,21 +279,21 @@
<source>Could not add <x id="PH" equiv-text="status.filename"/>: <x id="PH_1" equiv-text="status.message"/></source> <source>Could not add <x id="PH" equiv-text="status.filename"/>: <x id="PH_1" equiv-text="status.message"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context> <context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">119</context> <context context-type="linenumber">114</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1218124467712564468" datatype="html"> <trans-unit id="1218124467712564468" datatype="html">
<source>Document <x id="PH" equiv-text="status.filename"/> is being processed by Paperless-ngx.</source> <source>Document <x id="PH" equiv-text="status.filename"/> is being processed by Paperless-ngx.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context> <context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">134</context> <context context-type="linenumber">129</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6570363013146073520" datatype="html"> <trans-unit id="6570363013146073520" datatype="html">
<source>Dashboard</source> <source>Dashboard</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context> <context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">141</context> <context context-type="linenumber">136</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>
@@ -312,7 +312,7 @@
<source>Documents</source> <source>Documents</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context> <context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">152</context> <context context-type="linenumber">147</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
@@ -367,7 +367,7 @@
<source>Settings</source> <source>Settings</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context> <context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">164</context> <context context-type="linenumber">159</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
@@ -386,78 +386,53 @@
<context context-type="linenumber">260</context> <context context-type="linenumber">260</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2501522447884928778" datatype="html">
<source>Prev</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">170</context>
</context-group>
</trans-unit>
<trans-unit id="3885497195825665706" datatype="html">
<source>Next</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">171</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">109</context>
</context-group>
</trans-unit>
<trans-unit id="1241348629231510663" datatype="html">
<source>End</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">172</context>
</context-group>
</trans-unit>
<trans-unit id="5890330709052835856" datatype="html"> <trans-unit id="5890330709052835856" datatype="html">
<source>The dashboard can be used to show saved views, such as an &apos;Inbox&apos;. Views are found under Manage &gt; Saved Views once you have created some.</source> <source>The dashboard can be used to show saved views, such as an &apos;Inbox&apos;. Views are found under Manage &gt; Saved Views once you have created some.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context> <context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">178</context> <context context-type="linenumber">168</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="9075755296812854717" datatype="html"> <trans-unit id="9075755296812854717" datatype="html">
<source>Drag-and-drop documents here to start uploading or place them in the consume folder. You can also drag-and-drop documents anywhere on all other pages of the web app. Once you do, Paperless-ngx will start training its machine learning algorithms.</source> <source>Drag-and-drop documents here to start uploading or place them in the consume folder. You can also drag-and-drop documents anywhere on all other pages of the web app. Once you do, Paperless-ngx will start training its machine learning algorithms.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context> <context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">185</context> <context context-type="linenumber">175</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7495498057594070122" datatype="html"> <trans-unit id="7495498057594070122" datatype="html">
<source>The documents list shows all of your documents and allows for filtering as well as bulk-editing. There are three different view styles: list, small cards and large cards. A list of documents currently opened for editing is shown in the sidebar.</source> <source>The documents list shows all of your documents and allows for filtering as well as bulk-editing. There are three different view styles: list, small cards and large cards. A list of documents currently opened for editing is shown in the sidebar.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context> <context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">190</context> <context context-type="linenumber">180</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1334220418719920556" datatype="html"> <trans-unit id="1334220418719920556" datatype="html">
<source>The filtering tools allow you to quickly find documents using various searches, dates, tags, etc.</source> <source>The filtering tools allow you to quickly find documents using various searches, dates, tags, etc.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context> <context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">197</context> <context context-type="linenumber">187</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5427326625898532358" datatype="html"> <trans-unit id="5427326625898532358" datatype="html">
<source>Any combination of filters can be saved as a &apos;view&apos; which can then be displayed on the dashboard and / or sidebar.</source> <source>Any combination of filters can be saved as a &apos;view&apos; which can then be displayed on the dashboard and / or sidebar.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context> <context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">203</context> <context context-type="linenumber">193</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2804886236408698479" datatype="html"> <trans-unit id="2804886236408698479" datatype="html">
<source>Tags, correspondents, document types and storage paths can all be managed using these pages. They can also be created from the document edit view.</source> <source>Tags, correspondents, document types and storage paths can all be managed using these pages. They can also be created from the document edit view.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context> <context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">208</context> <context context-type="linenumber">198</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7851939076947092983" datatype="html"> <trans-unit id="7851939076947092983" datatype="html">
<source>Manage e-mail accounts and rules for automatically importing documents.</source> <source>Manage e-mail accounts and rules for automatically importing documents.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context> <context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">216</context> <context context-type="linenumber">206</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
@@ -468,14 +443,14 @@
<source>Workflows give you more control over the document pipeline.</source> <source>Workflows give you more control over the document pipeline.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context> <context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">224</context> <context context-type="linenumber">214</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4680387114119209483" datatype="html"> <trans-unit id="4680387114119209483" datatype="html">
<source>File Tasks shows you documents that have been consumed, are waiting to be, or may have failed during the process.</source> <source>File Tasks shows you documents that have been consumed, are waiting to be, or may have failed during the process.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context> <context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">232</context> <context context-type="linenumber">222</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>
@@ -486,28 +461,28 @@
<source>Check out the settings for various tweaks to the web app.</source> <source>Check out the settings for various tweaks to the web app.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context> <context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">240</context> <context context-type="linenumber">230</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7172877665285340082" datatype="html"> <trans-unit id="7172877665285340082" datatype="html">
<source>Thank you! 🙏</source> <source>Thank you! 🙏</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context> <context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">248</context> <context context-type="linenumber">238</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7354947513482088740" datatype="html"> <trans-unit id="7354947513482088740" datatype="html">
<source>There are &lt;em&gt;tons&lt;/em&gt; more features and info we didn&apos;t cover here, but this should get you started. Check out the documentation or visit the project on GitHub to learn more or to report issues.</source> <source>There are &lt;em&gt;tons&lt;/em&gt; more features and info we didn&apos;t cover here, but this should get you started. Check out the documentation or visit the project on GitHub to learn more or to report issues.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context> <context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">250</context> <context context-type="linenumber">240</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4270528545616947218" datatype="html"> <trans-unit id="4270528545616947218" datatype="html">
<source>Lastly, on behalf of every contributor to this community-supported project, thank you for using Paperless-ngx!</source> <source>Lastly, on behalf of every contributor to this community-supported project, thank you for using Paperless-ngx!</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context> <context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">252</context> <context context-type="linenumber">242</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="9063918187161876141" datatype="html"> <trans-unit id="9063918187161876141" datatype="html">
@@ -1234,7 +1209,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1472</context> <context context-type="linenumber">1481</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1577733187050997705" datatype="html"> <trans-unit id="1577733187050997705" datatype="html">
@@ -2823,11 +2798,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1108</context> <context context-type="linenumber">1116</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1473</context> <context context-type="linenumber">1482</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.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -3418,7 +3393,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1061</context> <context context-type="linenumber">1069</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.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -3523,7 +3498,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1524</context> <context context-type="linenumber">1533</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6661109599266152398" datatype="html"> <trans-unit id="6661109599266152398" datatype="html">
@@ -3534,7 +3509,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1525</context> <context context-type="linenumber">1534</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5162686434580248853" datatype="html"> <trans-unit id="5162686434580248853" datatype="html">
@@ -3545,7 +3520,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1526</context> <context context-type="linenumber">1535</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8157388568390631653" datatype="html"> <trans-unit id="8157388568390631653" datatype="html">
@@ -6215,7 +6190,7 @@
<source>Open preview</source> <source>Open preview</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/preview-popup/preview-popup.component.ts</context> <context context-type="sourcefile">src/app/components/common/preview-popup/preview-popup.component.ts</context>
<context context-type="linenumber">52</context> <context context-type="linenumber">54</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2984628903434675339" datatype="html"> <trans-unit id="2984628903434675339" datatype="html">
@@ -7460,6 +7435,17 @@
<context context-type="linenumber">106</context> <context context-type="linenumber">106</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3885497195825665706" datatype="html">
<source>Next</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">109</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/main.ts</context>
<context context-type="linenumber">403</context>
</context-group>
</trans-unit>
<trans-unit id="5028777105388019087" datatype="html"> <trans-unit id="5028777105388019087" datatype="html">
<source>Details</source> <source>Details</source>
<context-group purpose="location"> <context-group purpose="location">
@@ -7663,63 +7649,63 @@
<source>Enter Password</source> <source>Enter Password</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">499</context> <context context-type="linenumber">497</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2218903673684131427" datatype="html"> <trans-unit id="2218903673684131427" datatype="html">
<source>An error occurred loading content: <x id="PH" equiv-text="err.message ?? err.toString()"/></source> <source>An error occurred loading content: <x id="PH" equiv-text="err.message ?? err.toString()"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">428,430</context> <context context-type="linenumber">434,436</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3200733026060976258" datatype="html"> <trans-unit id="3200733026060976258" datatype="html">
<source>Document changes detected</source> <source>Document changes detected</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">467</context> <context context-type="linenumber">473</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2887155916749964" datatype="html"> <trans-unit id="2887155916749964" datatype="html">
<source>The version of this document in your browser session appears older than the existing version.</source> <source>The version of this document in your browser session appears older than the existing version.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">468</context> <context context-type="linenumber">474</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="237142428785956348" datatype="html"> <trans-unit id="237142428785956348" datatype="html">
<source>Saving the document here may overwrite other changes that were made. To restore the existing version, discard your changes or close the document.</source> <source>Saving the document here may overwrite other changes that were made. To restore the existing version, discard your changes or close the document.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">469</context> <context context-type="linenumber">475</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8720977247725652816" datatype="html"> <trans-unit id="8720977247725652816" datatype="html">
<source>Ok</source> <source>Ok</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">471</context> <context context-type="linenumber">477</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6142395741265832184" datatype="html"> <trans-unit id="6142395741265832184" datatype="html">
<source>Next document</source> <source>Next document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">597</context> <context context-type="linenumber">605</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="651985345816518480" datatype="html"> <trans-unit id="651985345816518480" datatype="html">
<source>Previous document</source> <source>Previous document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">607</context> <context context-type="linenumber">615</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2885986061416655600" datatype="html"> <trans-unit id="2885986061416655600" datatype="html">
<source>Close document</source> <source>Close document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">615</context> <context context-type="linenumber">623</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context> <context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
@@ -7730,67 +7716,67 @@
<source>Save document</source> <source>Save document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">622</context> <context context-type="linenumber">630</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1784543155727940353" datatype="html"> <trans-unit id="1784543155727940353" datatype="html">
<source>Save and close / next</source> <source>Save and close / next</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">631</context> <context context-type="linenumber">639</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5758784066858623886" datatype="html"> <trans-unit id="5758784066858623886" datatype="html">
<source>Error retrieving metadata</source> <source>Error retrieving metadata</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">686</context> <context context-type="linenumber">694</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3456881259945295697" datatype="html"> <trans-unit id="3456881259945295697" datatype="html">
<source>Error retrieving suggestions.</source> <source>Error retrieving suggestions.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">741</context> <context context-type="linenumber">749</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2194092841814123758" datatype="html"> <trans-unit id="2194092841814123758" datatype="html">
<source>Document &quot;<x id="PH" equiv-text="newValues.title"/>&quot; saved successfully.</source> <source>Document &quot;<x id="PH" equiv-text="newValues.title"/>&quot; saved successfully.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">950</context> <context context-type="linenumber">958</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">974</context> <context context-type="linenumber">982</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6626387786259219838" datatype="html"> <trans-unit id="6626387786259219838" datatype="html">
<source>Error saving document &quot;<x id="PH" equiv-text="this.document.title"/>&quot;</source> <source>Error saving document &quot;<x id="PH" equiv-text="this.document.title"/>&quot;</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">980</context> <context context-type="linenumber">988</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="448882439049417053" datatype="html"> <trans-unit id="448882439049417053" datatype="html">
<source>Error saving document</source> <source>Error saving document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1030</context> <context context-type="linenumber">1038</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8410796510716511826" datatype="html"> <trans-unit id="8410796510716511826" datatype="html">
<source>Do you really want to move the document &quot;<x id="PH" equiv-text="this.document.title"/>&quot; to the trash?</source> <source>Do you really want to move the document &quot;<x id="PH" equiv-text="this.document.title"/>&quot; to the trash?</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1062</context> <context context-type="linenumber">1070</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="282586936710748252" datatype="html"> <trans-unit id="282586936710748252" datatype="html">
<source>Documents can be restored prior to permanent deletion.</source> <source>Documents can be restored prior to permanent deletion.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1063</context> <context context-type="linenumber">1071</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.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -7801,7 +7787,7 @@
<source>Move to trash</source> <source>Move to trash</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1065</context> <context context-type="linenumber">1073</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.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -7812,14 +7798,14 @@
<source>Error deleting document</source> <source>Error deleting document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1084</context> <context context-type="linenumber">1092</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="619486176823357521" datatype="html"> <trans-unit id="619486176823357521" datatype="html">
<source>Reprocess confirm</source> <source>Reprocess confirm</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1104</context> <context context-type="linenumber">1112</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.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -7830,102 +7816,102 @@
<source>This operation will permanently recreate the archive file for this document.</source> <source>This operation will permanently recreate the archive file for this document.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1105</context> <context context-type="linenumber">1113</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="302054111564709516" datatype="html"> <trans-unit id="302054111564709516" datatype="html">
<source>The archive file will be re-generated with the current settings.</source> <source>The archive file will be re-generated with the current settings.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1106</context> <context context-type="linenumber">1114</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8251197608401006898" datatype="html"> <trans-unit id="8251197608401006898" datatype="html">
<source>Reprocess operation for &quot;<x id="PH" equiv-text="this.document.title"/>&quot; will begin in the background. Close and re-open or reload this document after the operation has completed to see new content.</source> <source>Reprocess operation for &quot;<x id="PH" equiv-text="this.document.title"/>&quot; will begin in the background. Close and re-open or reload this document after the operation has completed to see new content.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1116</context> <context context-type="linenumber">1124</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4409560272830824468" datatype="html"> <trans-unit id="4409560272830824468" datatype="html">
<source>Error executing operation</source> <source>Error executing operation</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1127</context> <context context-type="linenumber">1135</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6030453331794586802" datatype="html"> <trans-unit id="6030453331794586802" datatype="html">
<source>Error downloading document</source> <source>Error downloading document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1176</context> <context context-type="linenumber">1184</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4458954481601077369" datatype="html"> <trans-unit id="4458954481601077369" datatype="html">
<source>Page Fit</source> <source>Page Fit</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1253</context> <context context-type="linenumber">1263</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4663705961777238777" datatype="html"> <trans-unit id="4663705961777238777" datatype="html">
<source>PDF edit operation for &quot;<x id="PH" equiv-text="this.document.title"/>&quot; will begin in the background.</source> <source>PDF edit operation for &quot;<x id="PH" equiv-text="this.document.title"/>&quot; will begin in the background.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1491</context> <context context-type="linenumber">1500</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="9043972994040261999" datatype="html"> <trans-unit id="9043972994040261999" datatype="html">
<source>Error executing PDF edit operation</source> <source>Error executing PDF edit operation</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1503</context> <context context-type="linenumber">1512</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6172690334763056188" datatype="html"> <trans-unit id="6172690334763056188" datatype="html">
<source>Please enter the current password before attempting to remove it.</source> <source>Please enter the current password before attempting to remove it.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1514</context> <context context-type="linenumber">1523</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="968660764814228922" datatype="html"> <trans-unit id="968660764814228922" datatype="html">
<source>Password removal operation for &quot;<x id="PH" equiv-text="this.document.title"/>&quot; will begin in the background.</source> <source>Password removal operation for &quot;<x id="PH" equiv-text="this.document.title"/>&quot; will begin in the background.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1546</context> <context context-type="linenumber">1555</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2282118435712883014" datatype="html"> <trans-unit id="2282118435712883014" datatype="html">
<source>Error executing password removal operation</source> <source>Error executing password removal operation</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1560</context> <context context-type="linenumber">1569</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3740891324955700797" datatype="html"> <trans-unit id="3740891324955700797" datatype="html">
<source>Print failed.</source> <source>Print failed.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1597</context> <context context-type="linenumber">1606</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6457245677384603573" datatype="html"> <trans-unit id="6457245677384603573" datatype="html">
<source>Error loading document for printing.</source> <source>Error loading document for printing.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1609</context> <context context-type="linenumber">1618</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6085793215710522488" datatype="html"> <trans-unit id="6085793215710522488" datatype="html">
<source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source> <source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1674</context> <context context-type="linenumber">1683</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1678</context> <context context-type="linenumber">1687</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4958946940233632319" datatype="html"> <trans-unit id="4958946940233632319" datatype="html">
@@ -11244,6 +11230,20 @@
<context context-type="linenumber">39</context> <context context-type="linenumber">39</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2501522447884928778" datatype="html">
<source>Prev</source>
<context-group purpose="location">
<context context-type="sourcefile">src/main.ts</context>
<context context-type="linenumber">402</context>
</context-group>
</trans-unit>
<trans-unit id="1241348629231510663" datatype="html">
<source>End</source>
<context-group purpose="location">
<context context-type="sourcefile">src/main.ts</context>
<context context-type="linenumber">404</context>
</context-group>
</trans-unit>
</body> </body>
</file> </file>
</xliff> </xliff>

View File

@@ -11,15 +11,15 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/cdk": "^21.1.2", "@angular/cdk": "^21.1.3",
"@angular/common": "~21.1.2", "@angular/common": "~21.1.3",
"@angular/compiler": "~21.1.2", "@angular/compiler": "~21.1.3",
"@angular/core": "~21.1.2", "@angular/core": "~21.1.3",
"@angular/forms": "~21.1.2", "@angular/forms": "~21.1.3",
"@angular/localize": "~21.1.2", "@angular/localize": "~21.1.3",
"@angular/platform-browser": "~21.1.2", "@angular/platform-browser": "~21.1.3",
"@angular/platform-browser-dynamic": "~21.1.2", "@angular/platform-browser-dynamic": "~21.1.3",
"@angular/router": "~21.1.2", "@angular/router": "~21.1.3",
"@ng-bootstrap/ng-bootstrap": "^20.0.0", "@ng-bootstrap/ng-bootstrap": "^20.0.0",
"@ng-select/ng-select": "^21.2.0", "@ng-select/ng-select": "^21.2.0",
"@ngneat/dirty-check-forms": "^3.0.3", "@ngneat/dirty-check-forms": "^3.0.3",
@@ -27,12 +27,12 @@
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"mime-names": "^1.0.0", "mime-names": "^1.0.0",
"ng2-pdf-viewer": "^10.4.0",
"ngx-bootstrap-icons": "^1.9.3", "ngx-bootstrap-icons": "^1.9.3",
"ngx-color": "^10.1.0", "ngx-color": "^10.1.0",
"ngx-cookie-service": "^21.1.0", "ngx-cookie-service": "^21.1.0",
"ngx-device-detector": "^11.0.0", "ngx-device-detector": "^11.0.0",
"ngx-ui-tour-ng-bootstrap": "^17.0.1", "ngx-ui-tour-ng-bootstrap": "^18.0.0",
"pdfjs-dist": "^5.4.624",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"utif": "^3.1.0", "utif": "^3.1.0",
@@ -42,20 +42,20 @@
"devDependencies": { "devDependencies": {
"@angular-builders/custom-webpack": "^21.0.3", "@angular-builders/custom-webpack": "^21.0.3",
"@angular-builders/jest": "^21.0.3", "@angular-builders/jest": "^21.0.3",
"@angular-devkit/core": "^21.1.2", "@angular-devkit/core": "^21.1.3",
"@angular-devkit/schematics": "^21.1.2", "@angular-devkit/schematics": "^21.1.3",
"@angular-eslint/builder": "21.2.0", "@angular-eslint/builder": "21.2.0",
"@angular-eslint/eslint-plugin": "21.2.0", "@angular-eslint/eslint-plugin": "21.2.0",
"@angular-eslint/eslint-plugin-template": "21.2.0", "@angular-eslint/eslint-plugin-template": "21.2.0",
"@angular-eslint/schematics": "21.2.0", "@angular-eslint/schematics": "21.2.0",
"@angular-eslint/template-parser": "21.2.0", "@angular-eslint/template-parser": "21.2.0",
"@angular/build": "^21.1.2", "@angular/build": "^21.1.3",
"@angular/cli": "~21.1.2", "@angular/cli": "~21.1.3",
"@angular/compiler-cli": "~21.1.2", "@angular/compiler-cli": "~21.1.3",
"@codecov/webpack-plugin": "^1.9.1", "@codecov/webpack-plugin": "^1.9.1",
"@playwright/test": "^1.58.1", "@playwright/test": "^1.58.2",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^25.2.0", "@types/node": "^25.2.1",
"@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/eslint-plugin": "^8.54.0",
"@typescript-eslint/parser": "^8.54.0", "@typescript-eslint/parser": "^8.54.0",
"@typescript-eslint/utils": "^8.54.0", "@typescript-eslint/utils": "^8.54.0",
@@ -68,7 +68,7 @@
"prettier-plugin-organize-imports": "^4.3.0", "prettier-plugin-organize-imports": "^4.3.0",
"ts-node": "~10.9.1", "ts-node": "~10.9.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"webpack": "^5.103.0" "webpack": "^5.105.0"
}, },
"packageManager": "pnpm@10.17.1", "packageManager": "pnpm@10.17.1",
"pnpm": { "pnpm": {

1332
src-ui/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -100,10 +100,10 @@ const mock = () => {
} }
} }
Object.defineProperty(window, 'open', { value: jest.fn() }) Object.defineProperty(globalThis, 'open', { value: jest.fn() })
Object.defineProperty(window, 'localStorage', { value: mock() }) Object.defineProperty(globalThis, 'localStorage', { value: mock() })
Object.defineProperty(window, 'sessionStorage', { value: mock() }) Object.defineProperty(globalThis, 'sessionStorage', { value: mock() })
Object.defineProperty(window, 'getComputedStyle', { Object.defineProperty(globalThis, 'getComputedStyle', {
value: () => ['-webkit-appearance'], value: () => ['-webkit-appearance'],
}) })
Object.defineProperty(navigator, 'clipboard', { Object.defineProperty(navigator, 'clipboard', {
@@ -115,13 +115,33 @@ Object.defineProperty(navigator, 'canShare', { value: () => true })
if (!navigator.share) { if (!navigator.share) {
Object.defineProperty(navigator, 'share', { value: jest.fn() }) Object.defineProperty(navigator, 'share', { value: jest.fn() })
} }
if (!URL.createObjectURL) { if (!globalThis.URL.createObjectURL) {
Object.defineProperty(window.URL, 'createObjectURL', { value: jest.fn() }) Object.defineProperty(globalThis.URL, 'createObjectURL', { value: jest.fn() })
} }
if (!URL.revokeObjectURL) { if (!globalThis.URL.revokeObjectURL) {
Object.defineProperty(window.URL, 'revokeObjectURL', { value: jest.fn() }) Object.defineProperty(globalThis.URL, 'revokeObjectURL', { value: jest.fn() })
} }
Object.defineProperty(window, 'ResizeObserver', { value: mock() }) class MockResizeObserver {
private readonly callback: ResizeObserverCallback
constructor(callback: ResizeObserverCallback) {
this.callback = callback
}
observe = jest.fn()
unobserve = jest.fn()
disconnect = jest.fn()
trigger = (entries: ResizeObserverEntry[] = []) => {
this.callback(entries, this)
}
}
Object.defineProperty(globalThis, 'ResizeObserver', {
writable: true,
configurable: true,
value: MockResizeObserver,
})
if (typeof IntersectionObserver === 'undefined') { if (typeof IntersectionObserver === 'undefined') {
class MockIntersectionObserver { class MockIntersectionObserver {
@@ -136,7 +156,7 @@ if (typeof IntersectionObserver === 'undefined') {
takeRecords = jest.fn() takeRecords = jest.fn()
} }
Object.defineProperty(window, 'IntersectionObserver', { Object.defineProperty(globalThis, 'IntersectionObserver', {
writable: true, writable: true,
configurable: true, configurable: true,
value: MockIntersectionObserver, value: MockIntersectionObserver,

View File

@@ -11,13 +11,9 @@ import { DashboardComponent } from './components/dashboard/dashboard.component'
import { DocumentAsnComponent } from './components/document-asn/document-asn.component' import { DocumentAsnComponent } from './components/document-asn/document-asn.component'
import { DocumentDetailComponent } from './components/document-detail/document-detail.component' import { DocumentDetailComponent } from './components/document-detail/document-detail.component'
import { DocumentListComponent } from './components/document-list/document-list.component' import { DocumentListComponent } from './components/document-list/document-list.component'
import { CorrespondentListComponent } from './components/manage/correspondent-list/correspondent-list.component' import { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.component'
import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
import { DocumentTypeListComponent } from './components/manage/document-type-list/document-type-list.component'
import { MailComponent } from './components/manage/mail/mail.component' import { MailComponent } from './components/manage/mail/mail.component'
import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component' import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component'
import { StoragePathListComponent } from './components/manage/storage-path-list/storage-path-list.component'
import { TagListComponent } from './components/manage/tag-list/tag-list.component'
import { WorkflowsComponent } from './components/manage/workflows/workflows.component' import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
import { NotFoundComponent } from './components/not-found/not-found.component' import { NotFoundComponent } from './components/not-found/not-found.component'
import { DirtyDocGuard } from './guards/dirty-doc.guard' import { DirtyDocGuard } from './guards/dirty-doc.guard'
@@ -106,52 +102,76 @@ export const routes: Routes = [
}, },
}, },
{ {
path: 'tags', path: 'attributes',
component: TagListComponent, component: DocumentAttributesComponent,
canActivate: [PermissionsGuard], canActivate: [PermissionsGuard],
data: { data: {
requiredPermission: { requiredPermissionAny: [
action: PermissionAction.View, { action: PermissionAction.View, type: PermissionType.Tag },
type: PermissionType.Tag,
},
componentName: 'TagListComponent',
},
},
{ {
path: 'documenttypes',
component: DocumentTypeListComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.DocumentType,
},
componentName: 'DocumentTypeListComponent',
},
},
{
path: 'correspondents',
component: CorrespondentListComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View, action: PermissionAction.View,
type: PermissionType.Correspondent, type: PermissionType.Correspondent,
}, },
componentName: 'CorrespondentListComponent', {
action: PermissionAction.View,
type: PermissionType.DocumentType,
},
{ action: PermissionAction.View, type: PermissionType.StoragePath },
{ action: PermissionAction.View, type: PermissionType.CustomField },
],
componentName: 'DocumentAttributesComponent',
}, },
}, },
{ {
path: 'storagepaths', path: 'attributes/:section',
component: StoragePathListComponent, component: DocumentAttributesComponent,
canActivate: [PermissionsGuard], canActivate: [PermissionsGuard],
data: { data: {
requiredPermission: { requiredPermissionAny: [
{ action: PermissionAction.View, type: PermissionType.Tag },
{
action: PermissionAction.View, action: PermissionAction.View,
type: PermissionType.StoragePath, type: PermissionType.Correspondent,
}, },
componentName: 'StoragePathListComponent', {
action: PermissionAction.View,
type: PermissionType.DocumentType,
}, },
{ action: PermissionAction.View, type: PermissionType.StoragePath },
{ action: PermissionAction.View, type: PermissionType.CustomField },
],
componentName: 'DocumentAttributesComponent',
},
},
{
path: 'documentproperties',
redirectTo: '/attributes',
pathMatch: 'full',
},
{
path: 'documentproperties/:section',
redirectTo: '/attributes/:section',
pathMatch: 'full',
},
{
path: 'tags',
redirectTo: '/attributes/tags',
pathMatch: 'full',
},
{
path: 'correspondents',
redirectTo: '/attributes/correspondents',
pathMatch: 'full',
},
{
path: 'documenttypes',
redirectTo: '/attributes/documenttypes',
pathMatch: 'full',
},
{
path: 'storagepaths',
redirectTo: '/attributes/storagepaths',
pathMatch: 'full',
}, },
{ {
path: 'logs', path: 'logs',
@@ -239,15 +259,8 @@ export const routes: Routes = [
}, },
{ {
path: 'customfields', path: 'customfields',
component: CustomFieldsComponent, redirectTo: '/attributes/customfields',
canActivate: [PermissionsGuard], pathMatch: 'full',
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.CustomField,
},
componentName: 'CustomFieldsComponent',
},
}, },
{ {
path: 'workflows', path: 'workflows',

View File

@@ -9,7 +9,11 @@ import {
import { Router, RouterModule } from '@angular/router' import { Router, RouterModule } from '@angular/router'
import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap' import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap' import {
provideUiTour,
TourNgBootstrap,
TourService,
} from 'ngx-ui-tour-ng-bootstrap'
import { Subject } from 'rxjs' import { Subject } from 'rxjs'
import { routes } from './app-routing.module' import { routes } from './app-routing.module'
import { AppComponent } from './app.component' import { AppComponent } from './app.component'
@@ -40,12 +44,12 @@ describe('AppComponent', () => {
beforeEach(async () => { beforeEach(async () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [ imports: [
TourNgBootstrapModule,
RouterModule.forRoot(routes), RouterModule.forRoot(routes),
NgbModalModule, NgbModalModule,
AppComponent, AppComponent,
ToastsComponent, ToastsComponent,
FileDropComponent, FileDropComponent,
TourNgBootstrap,
NgxBootstrapIconsModule.pick(allIcons), NgxBootstrapIconsModule.pick(allIcons),
], ],
providers: [ providers: [
@@ -53,6 +57,7 @@ describe('AppComponent', () => {
DirtySavedViewGuard, DirtySavedViewGuard,
provideHttpClient(withInterceptorsFromDi()), provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(), provideHttpClientTesting(),
provideUiTour(),
], ],
}).compileComponents() }).compileComponents()

View File

@@ -1,6 +1,6 @@
import { Component, inject, OnDestroy, OnInit, Renderer2 } from '@angular/core' import { Component, inject, OnDestroy, OnInit, Renderer2 } from '@angular/core'
import { Router, RouterOutlet } from '@angular/router' import { Router, RouterOutlet } from '@angular/router'
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap' import { TourNgBootstrap, TourService } from 'ngx-ui-tour-ng-bootstrap'
import { first, Subscription } from 'rxjs' import { first, Subscription } from 'rxjs'
import { ToastsComponent } from './components/common/toasts/toasts.component' import { ToastsComponent } from './components/common/toasts/toasts.component'
import { FileDropComponent } from './components/file-drop/file-drop.component' import { FileDropComponent } from './components/file-drop/file-drop.component'
@@ -21,12 +21,7 @@ import { WebsocketStatusService } from './services/websocket-status.service'
selector: 'pngx-root', selector: 'pngx-root',
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'], styleUrls: ['./app.component.scss'],
imports: [ imports: [FileDropComponent, ToastsComponent, TourNgBootstrap, RouterOutlet],
FileDropComponent,
ToastsComponent,
TourNgBootstrapModule,
RouterOutlet,
],
}) })
export class AppComponent implements OnInit, OnDestroy { export class AppComponent implements OnInit, OnDestroy {
private settings = inject(SettingsService) private settings = inject(SettingsService)
@@ -167,12 +162,7 @@ export class AppComponent implements OnInit, OnDestroy {
}) })
} }
const prevBtnTitle = $localize`Prev` this.tourService.initialize([
const nextBtnTitle = $localize`Next`
const endBtnTitle = $localize`End`
this.tourService.initialize(
[
{ {
anchorId: 'tour.dashboard', anchorId: 'tour.dashboard',
content: $localize`The dashboard can be used to show saved views, such as an 'Inbox'. Views are found under Manage > Saved Views once you have created some.`, content: $localize`The dashboard can be used to show saved views, such as an 'Inbox'. Views are found under Manage > Saved Views once you have created some.`,
@@ -205,8 +195,8 @@ export class AppComponent implements OnInit, OnDestroy {
}, },
{ {
anchorId: 'tour.tags', anchorId: 'tour.tags',
content: $localize`Tags, correspondents, document types and storage paths can all be managed using these pages. They can also be created from the document edit view.`, content: $localize`Attributes like tags, correspondents, document types, storage paths and custom fields can all be managed here. They can also be created from the document edit view.`,
route: '/tags', route: '/attributes/tags',
backdropConfig: { backdropConfig: {
offset: 0, offset: 0,
}, },
@@ -256,19 +246,7 @@ export class AppComponent implements OnInit, OnDestroy {
offset: 0, offset: 0,
}, },
}, },
], ])
{
enableBackdrop: true,
backdropConfig: {
offset: 10,
},
prevBtnTitle,
nextBtnTitle,
endBtnTitle,
isOptional: true,
useLegacyTitle: true,
}
)
this.tourService.start$.subscribe(() => { this.tourService.start$.subscribe(() => {
this.renderer.addClass(document.body, 'tour-active') this.renderer.addClass(document.body, 'tour-active')

View File

@@ -222,8 +222,8 @@
</div> </div>
<div class="col"> <div class="col">
<select class="form-select" formControlName="pdfViewerDefaultZoom"> <select class="form-select" formControlName="pdfViewerDefaultZoom">
<option [ngValue]="ZoomSetting.PageWidth" i18n>Fit width</option> <option [ngValue]="PdfZoomScale.PageWidth" i18n>Fit width</option>
<option [ngValue]="ZoomSetting.PageFit" i18n>Fit page</option> <option [ngValue]="PdfZoomScale.PageFit" i18n>Fit page</option>
</select> </select>
<p class="small text-muted mt-1" i18n>Only applies to the Paperless-ngx PDF viewer.</p> <p class="small text-muted mt-1" i18n>Only applies to the Paperless-ngx PDF viewer.</p>
</div> </div>

View File

@@ -16,6 +16,7 @@ import {
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select' import { NgSelectModule } from '@ng-select/ng-select'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
import { of, throwError } from 'rxjs' import { of, throwError } from 'rxjs'
import { routes } from 'src/app/app-routing.module' import { routes } from 'src/app/app-routing.module'
import { import {
@@ -147,6 +148,7 @@ describe('SettingsComponent', () => {
PermissionsGuard, PermissionsGuard,
provideHttpClient(withInterceptorsFromDi()), provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(), provideHttpClientTesting(),
provideUiTour(),
], ],
}).compileComponents() }).compileComponents()

View File

@@ -65,8 +65,8 @@ import { PermissionsUserComponent } from '../../common/input/permissions/permiss
import { SelectComponent } from '../../common/input/select/select.component' import { SelectComponent } from '../../common/input/select/select.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component' import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { PdfEditorEditMode } from '../../common/pdf-editor/pdf-editor-edit-mode' import { PdfEditorEditMode } from '../../common/pdf-editor/pdf-editor-edit-mode'
import { PdfZoomScale } from '../../common/pdf-viewer/pdf-viewer.types'
import { SystemStatusDialogComponent } from '../../common/system-status-dialog/system-status-dialog.component' import { SystemStatusDialogComponent } from '../../common/system-status-dialog/system-status-dialog.component'
import { ZoomSetting } from '../../document-detail/zoom-setting'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component' import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
enum SettingsNavIDs { enum SettingsNavIDs {
@@ -196,7 +196,7 @@ export class SettingsComponent
public readonly GlobalSearchType = GlobalSearchType public readonly GlobalSearchType = GlobalSearchType
public readonly ZoomSetting = ZoomSetting public readonly PdfZoomScale = PdfZoomScale
public readonly PdfEditorEditMode = PdfEditorEditMode public readonly PdfEditorEditMode = PdfEditorEditMode

View File

@@ -175,44 +175,60 @@
<span i18n>Manage</span> <span i18n>Manage</span>
</h6> </h6>
<ul class="nav flex-column mb-2"> <ul class="nav flex-column mb-2">
<li class="nav-item app-link" @if (canManageAttributes) {
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }"> <li class="nav-item app-link" tourAnchor="tour.tags">
<a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()" <div class="d-flex align-items-center attributes-row">
ngbPopover="Correspondents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" <a class="nav-link flex-fill" routerLink="attributes" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Attributes" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="person"></i-bs><span>&nbsp;<ng-container i18n>Correspondents</ng-container></span> <i-bs class="me-1" name="stack"></i-bs><span>&nbsp;<ng-container i18n>Attributes</ng-container></span>
</a> </a>
</li> @if (!slimSidebarEnabled) {
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }" <button
tourAnchor="tour.tags"> type="button"
<a class="nav-link" routerLink="tags" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Tags" class="btn btn-link btn-sm text-muted p-0 me-3 attributes-expand-btn"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" (click)="toggleAttributesSections($event)"
triggers="mouseenter:mouseleave" popoverClass="popover-slim"> [attr.aria-label]="attributesSectionsCollapsed ? 'Expand attributes sections' : 'Collapse attributes sections'"
i18n-aria-label
>
<i-bs [name]="attributesSectionsCollapsed ? 'plus-circle' : 'dash-circle'"></i-bs>
</button>
}
</div>
<div
class="attributes-submenu ms-2"
[ngbCollapse]="slimSidebarEnabled || attributesSectionsCollapsed"
>
<ul class="nav flex-column">
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }">
<a class="nav-link py-1" routerLink="attributes/tags" routerLinkActive="active" (click)="closeMenu()">
<i-bs class="me-1" name="tags"></i-bs><span>&nbsp;<ng-container i18n>Tags</ng-container></span> <i-bs class="me-1" name="tags"></i-bs><span>&nbsp;<ng-container i18n>Tags</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }"> <a class="nav-link py-1" routerLink="attributes/correspondents" routerLinkActive="active" (click)="closeMenu()">
<a class="nav-link" routerLink="documenttypes" routerLinkActive="active" (click)="closeMenu()" <i-bs class="me-1" name="person"></i-bs><span>&nbsp;<ng-container i18n>Correspondents</ng-container></span>
ngbPopover="Document Types" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" </a>
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> </li>
<i-bs class="me-1" name="hash"></i-bs><span>&nbsp;<ng-container i18n>Document Types</ng-container></span> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
<a class="nav-link py-1" routerLink="attributes/documenttypes" routerLinkActive="active" (click)="closeMenu()">
<i-bs class="me-1" name="hash"></i-bs><span>&nbsp;<ng-container i18n>Document types</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
<a class="nav-link" routerLink="storagepaths" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link py-1" routerLink="attributes/storagepaths" routerLinkActive="active" (click)="closeMenu()">
ngbPopover="Storage Paths" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" <i-bs class="me-1" name="folder"></i-bs><span>&nbsp;<ng-container i18n>Storage paths</ng-container></span>
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="folder"></i-bs><span>&nbsp;<ng-container i18n>Storage Paths</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
<a class="nav-link" routerLink="customfields" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link py-1" routerLink="attributes/customfields" routerLinkActive="active" (click)="closeMenu()">
ngbPopover="Custom Fields" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" <i-bs class="me-1" name="ui-radios"></i-bs><span>&nbsp;<ng-container i18n>Custom fields</ng-container></span>
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="ui-radios"></i-bs><span>&nbsp;<ng-container i18n>Custom Fields</ng-container></span>
</a> </a>
</li> </li>
</ul>
</div>
</li>
}
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
<a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"

View File

@@ -177,6 +177,15 @@ main {
} }
} }
.attributes-row .attributes-expand-btn {
opacity: 0.2;
transition: opacity 0.15s ease-in-out;
}
.attributes-row:hover .attributes-expand-btn {
opacity: 1;
}
.sidebar-heading { .sidebar-heading {
font-size: 0.75rem; font-size: 0.75rem;
text-transform: uppercase; text-transform: uppercase;

View File

@@ -16,6 +16,7 @@ import { ActivatedRoute, Router } from '@angular/router'
import { RouterTestingModule } from '@angular/router/testing' import { RouterTestingModule } from '@angular/router/testing'
import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap' import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
import { of, throwError } from 'rxjs' import { of, throwError } from 'rxjs'
import { routes } from 'src/app/app-routing.module' import { routes } from 'src/app/app-routing.module'
import { SavedView } from 'src/app/data/saved-view' import { SavedView } from 'src/app/data/saved-view'
@@ -27,7 +28,10 @@ import {
DjangoMessagesService, DjangoMessagesService,
} from 'src/app/services/django-messages.service' } from 'src/app/services/django-messages.service'
import { OpenDocumentsService } from 'src/app/services/open-documents.service' import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import { PermissionsService } from 'src/app/services/permissions.service' import {
PermissionType,
PermissionsService,
} from 'src/app/services/permissions.service'
import { RemoteVersionService } from 'src/app/services/rest/remote-version.service' import { RemoteVersionService } from 'src/app/services/rest/remote-version.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service' import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SearchService } from 'src/app/services/rest/search.service' import { SearchService } from 'src/app/services/rest/search.service'
@@ -157,6 +161,7 @@ describe('AppFrameComponent', () => {
PermissionsGuard, PermissionsGuard,
provideHttpClient(withInterceptorsFromDi()), provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(), provideHttpClientTesting(),
provideUiTour(),
], ],
}).compileComponents() }).compileComponents()
@@ -256,7 +261,7 @@ describe('AppFrameComponent', () => {
const toastSpy = jest.spyOn(toastService, 'showError') const toastSpy = jest.spyOn(toastService, 'showError')
component.toggleSlimSidebar() component.toggleSlimSidebar()
httpTestingController httpTestingController
.expectOne(`${environment.apiBaseUrl}ui_settings/`) .match(`${environment.apiBaseUrl}ui_settings/`)[0]
.flush('error', { .flush('error', {
status: 500, status: 500,
statusText: 'error', statusText: 'error',
@@ -371,4 +376,103 @@ describe('AppFrameComponent', () => {
it('should call maybeRefreshDocumentCounts after saved views reload', () => { it('should call maybeRefreshDocumentCounts after saved views reload', () => {
expect(maybeRefreshSpy).toHaveBeenCalled() expect(maybeRefreshSpy).toHaveBeenCalled()
}) })
it('should indicate attributes management availability when any permission is granted', () => {
jest
.spyOn(permissionsService, 'currentUserCan')
.mockImplementation((action, type) => {
return type === PermissionType.Tag
})
expect(component.canManageAttributes).toBe(true)
})
it('should indicate attributes management availability for other permission types', () => {
const canSpy = jest
.spyOn(permissionsService, 'currentUserCan')
.mockImplementation((action, type) => {
return type === PermissionType.Correspondent
})
expect(component.canManageAttributes).toBe(true)
canSpy.mockImplementation((action, type) => {
return type === PermissionType.DocumentType
})
expect(component.canManageAttributes).toBe(true)
canSpy.mockImplementation((action, type) => {
return type === PermissionType.StoragePath
})
expect(component.canManageAttributes).toBe(true)
canSpy.mockImplementation((action, type) => {
return type === PermissionType.CustomField
})
expect(component.canManageAttributes).toBe(true)
})
it('should toggle attributes sections and stop event bubbling', () => {
const preventDefault = jest.fn()
const stopPropagation = jest.fn()
const setSpy = jest.spyOn(settingsService, 'set')
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
component.toggleAttributesSections({
preventDefault,
stopPropagation,
} as any)
expect(preventDefault).toHaveBeenCalled()
expect(stopPropagation).toHaveBeenCalled()
expect(setSpy).toHaveBeenCalledWith(
SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
['attributes']
)
})
it('should show error when saving slim sidebar setting fails', () => {
const toastSpy = jest.spyOn(toastService, 'showError')
jest.spyOn(console, 'warn').mockImplementation(() => {})
jest
.spyOn(settingsService, 'storeSettings')
.mockReturnValue(throwError(() => new Error('boom')))
component.slimSidebarEnabled = true
expect(toastSpy).toHaveBeenCalled()
})
it('should show error when saving attributes collapsed setting fails', () => {
const toastSpy = jest.spyOn(toastService, 'showError')
jest.spyOn(console, 'warn').mockImplementation(() => {})
jest
.spyOn(settingsService, 'storeSettings')
.mockReturnValue(throwError(() => new Error('boom')))
component.attributesSectionsCollapsed = true
expect(toastSpy).toHaveBeenCalled()
})
it('should persist attributes section collapse state', () => {
const setSpy = jest.spyOn(settingsService, 'set')
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
component.attributesSectionsCollapsed = true
expect(setSpy).toHaveBeenCalledWith(
SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
['attributes']
)
})
it('should collapse attributes sections when enabling slim sidebar', () => {
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
settingsService.set(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED, [])
settingsService.set(SETTINGS_KEYS.SLIM_SIDEBAR, false)
component.toggleSlimSidebar()
expect(component.attributesSectionsCollapsed).toBe(true)
})
}) })

View File

@@ -16,12 +16,12 @@ import {
NgbPopoverModule, NgbPopoverModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap' import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
import { Observable } from 'rxjs' import { Observable } from 'rxjs'
import { first } from 'rxjs/operators' import { first } from 'rxjs/operators'
import { Document } from 'src/app/data/document' import { Document } from 'src/app/data/document'
import { SavedView } from 'src/app/data/saved-view' import { SavedView } from 'src/app/data/saved-view'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { CollapsibleSection, SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { ComponentCanDeactivate } from 'src/app/guards/dirty-doc.guard' import { ComponentCanDeactivate } from 'src/app/guards/dirty-doc.guard'
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe' import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
@@ -69,7 +69,7 @@ import { ToastsDropdownComponent } from './toasts-dropdown/toasts-dropdown.compo
NgbNavModule, NgbNavModule,
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
DragDropModule, DragDropModule,
TourNgBootstrapModule, TourNgBootstrap,
], ],
}) })
export class AppFrameComponent export class AppFrameComponent
@@ -141,11 +141,20 @@ export class AppFrameComponent
toggleSlimSidebar(): void { toggleSlimSidebar(): void {
this.slimSidebarAnimating = true this.slimSidebarAnimating = true
this.slimSidebarEnabled = !this.slimSidebarEnabled this.slimSidebarEnabled = !this.slimSidebarEnabled
if (this.slimSidebarEnabled) {
this.attributesSectionsCollapsed = true
}
setTimeout(() => { setTimeout(() => {
this.slimSidebarAnimating = false this.slimSidebarAnimating = false
}, 200) // slightly longer than css animation for slim sidebar }, 200) // slightly longer than css animation for slim sidebar
} }
toggleAttributesSections(event?: Event): void {
event?.preventDefault()
event?.stopPropagation()
this.attributesSectionsCollapsed = !this.attributesSectionsCollapsed
}
get versionString(): string { get versionString(): string {
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}` return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
} }
@@ -167,6 +176,31 @@ export class AppFrameComponent
) )
} }
get canManageAttributes(): boolean {
return (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Tag
) ||
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Correspondent
) ||
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.DocumentType
) ||
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.StoragePath
) ||
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.CustomField
)
)
}
get slimSidebarEnabled(): boolean { get slimSidebarEnabled(): boolean {
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR) return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
} }
@@ -186,6 +220,31 @@ export class AppFrameComponent
}) })
} }
get attributesSectionsCollapsed(): boolean {
return this.settingsService
.get(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED)
?.includes(CollapsibleSection.ATTRIBUTES)
}
set attributesSectionsCollapsed(collapsed: boolean) {
// TODO: refactor to be able to toggle individual sections, if implemented
this.settingsService.set(
SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
collapsed ? [CollapsibleSection.ATTRIBUTES] : []
)
this.settingsService
.storeSettings()
.pipe(first())
.subscribe({
error: (error) => {
this.toastService.showError(
$localize`An error occurred while saving settings.`
)
console.warn(error)
},
})
}
get aiEnabled(): boolean { get aiEnabled(): boolean {
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED) return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
} }

View File

@@ -3,14 +3,14 @@ import { Component, Input, inject } from '@angular/core'
import { Title } from '@angular/platform-browser' import { Title } from '@angular/platform-browser'
import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap' import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap' import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
@Component({ @Component({
selector: 'pngx-page-header', selector: 'pngx-page-header',
templateUrl: './page-header.component.html', templateUrl: './page-header.component.html',
styleUrls: ['./page-header.component.scss'], styleUrls: ['./page-header.component.scss'],
imports: [NgbPopoverModule, NgxBootstrapIconsModule, TourNgBootstrapModule], imports: [NgbPopoverModule, NgxBootstrapIconsModule, TourNgBootstrap],
}) })
export class PageHeaderComponent { export class PageHeaderComponent {
private titleService = inject(Title) private titleService = inject(Title)

View File

@@ -1,4 +1,4 @@
<pdf-viewer [src]="pdfSrc" [render-text]="false" zoom="0.4" (after-load-complete)="pdfLoaded($event)"></pdf-viewer> <pngx-pdf-viewer class="visually-hidden" [src]="pdfSrc" [renderMode]="PdfRenderMode.Single" [page]="1" [selectable]="false" (afterLoadComplete)="pdfLoaded($event)"></pngx-pdf-viewer>
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title">{{ title }}</h4> <h4 class="modal-title">{{ title }}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button> <button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button>
@@ -59,7 +59,7 @@
<span class="placeholder w-100 h-100"></span> <span class="placeholder w-100 h-100"></span>
</div> </div>
} }
<pdf-viewer class="fade" [class.show]="p.loaded" [src]="pdfSrc" [page]="p.page" [rotation]="p.rotate" [original-size]="false" [show-all]="false" [render-text]="false" (page-rendered)="p.loaded = true"></pdf-viewer> <pngx-pdf-viewer class="fade" [class.show]="p.loaded" [src]="pdfSrc" [page]="p.page" [rotation]="p.rotate" [renderMode]="PdfRenderMode.Single" (rendered)="p.loaded = true"></pngx-pdf-viewer>
} @placeholder { } @placeholder {
<div class="placeholder-glow w-100 h-100 z-10"> <div class="placeholder-glow w-100 h-100 z-10">
<span class="placeholder w-100 h-100"></span> <span class="placeholder w-100 h-100"></span>

View File

@@ -15,13 +15,13 @@
background-color: gray; background-color: gray;
height: 240px; height: 240px;
pdf-viewer { pngx-pdf-viewer {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
} }
::ng-deep .ng2-pdf-viewer-container { ::ng-deep .pngx-pdf-viewer-container {
overflow: hidden; overflow: hidden;
} }

View File

@@ -6,12 +6,16 @@ import {
import { Component, inject } from '@angular/core' import { Component, inject } from '@angular/core'
import { FormsModule } from '@angular/forms' import { FormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { DocumentService } from 'src/app/services/rest/document.service' import { DocumentService } from 'src/app/services/rest/document.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component'
import { PngxPdfViewerComponent } from '../pdf-viewer/pdf-viewer.component'
import {
PdfRenderMode,
PngxPdfDocumentProxy,
} from '../pdf-viewer/pdf-viewer.types'
import { PdfEditorEditMode } from './pdf-editor-edit-mode' import { PdfEditorEditMode } from './pdf-editor-edit-mode'
interface PageOperation { interface PageOperation {
@@ -29,11 +33,12 @@ interface PageOperation {
imports: [ imports: [
DragDropModule, DragDropModule,
FormsModule, FormsModule,
PdfViewerModule,
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
PngxPdfViewerComponent,
], ],
}) })
export class PDFEditorComponent extends ConfirmDialogComponent { export class PDFEditorComponent extends ConfirmDialogComponent {
PdfRenderMode = PdfRenderMode
public PdfEditorEditMode = PdfEditorEditMode public PdfEditorEditMode = PdfEditorEditMode
private documentService = inject(DocumentService) private documentService = inject(DocumentService)
@@ -53,7 +58,7 @@ export class PDFEditorComponent extends ConfirmDialogComponent {
return this.documentService.getPreviewUrl(this.documentID) return this.documentService.getPreviewUrl(this.documentID)
} }
pdfLoaded(pdf: PDFDocumentProxy) { pdfLoaded(pdf: PngxPdfDocumentProxy) {
this.totalPages = pdf.numPages this.totalPages = pdf.numPages
this.pages = Array.from({ length: this.totalPages }, (_, i) => ({ this.pages = Array.from({ length: this.totalPages }, (_, i) => ({
page: i + 1, page: i + 1,

View File

@@ -0,0 +1,3 @@
<div #container class="pngx-pdf-viewer-container">
<div #viewer class="pdfViewer"></div>
</div>

View File

@@ -0,0 +1,153 @@
:host {
display: block;
width: 100%;
height: 100%;
position: relative;
}
:host ::ng-deep .pngx-pdf-viewer-container {
position: absolute;
inset: 0;
overflow: auto;
}
:host ::ng-deep .pdfViewer {
--scale-factor: 1;
--page-bg-color: unset;
padding-bottom: 0;
}
:host ::ng-deep .pdfViewer .page {
--user-unit: 1;
--total-scale-factor: calc(var(--scale-factor) * var(--user-unit));
--scale-round-x: 1px;
--scale-round-y: 1px;
direction: ltr;
margin: 0 auto 10px;
border: 0;
position: relative;
overflow: visible;
background-clip: content-box;
background-color: var(--page-bg-color, rgb(255 255 255));
}
:host ::ng-deep .pdfViewer > .page:last-of-type {
margin-bottom: 0;
}
:host ::ng-deep .pdfViewer.singlePageView {
display: inline-block;
}
:host ::ng-deep .pdfViewer.singlePageView .page {
margin: 0;
border: none;
}
:host ::ng-deep .pdfViewer .canvasWrapper {
overflow: hidden;
width: 100%;
height: 100%;
}
:host ::ng-deep .pdfViewer .canvasWrapper canvas {
position: absolute;
top: 0;
left: 0;
margin: 0;
display: block;
width: 100%;
height: 100%;
contain: content;
}
:host ::ng-deep .textLayer {
position: absolute;
text-align: initial;
inset: 0;
overflow: clip;
opacity: 1;
line-height: 1;
text-size-adjust: none;
transform-origin: 0 0;
caret-color: CanvasText;
z-index: 0;
user-select: text;
--min-font-size: 1;
--text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size));
--min-font-size-inv: calc(1 / var(--min-font-size));
}
:host ::ng-deep .textLayer.highlighting {
touch-action: none;
}
:host ::ng-deep .textLayer :is(span, br) {
position: absolute;
white-space: pre;
color: transparent;
cursor: text;
transform-origin: 0% 0%;
}
:host ::ng-deep .textLayer > :not(.markedContent),
:host ::ng-deep .textLayer .markedContent span:not(.markedContent) {
z-index: 1;
--font-height: 0;
font-size: calc(var(--text-scale-factor) * var(--font-height));
--scale-x: 1;
--rotate: 0deg;
transform: rotate(var(--rotate)) scaleX(var(--scale-x))
scale(var(--min-font-size-inv));
}
:host ::ng-deep .textLayer .markedContent {
display: contents;
}
:host ::ng-deep .textLayer span[role='img'] {
user-select: none;
cursor: default;
}
:host ::ng-deep .textLayer .highlight {
--highlight-bg-color: rgb(180 0 170 / 0.25);
--highlight-selected-bg-color: rgb(0 100 0 / 0.25);
--highlight-backdrop-filter: none;
--highlight-selected-backdrop-filter: none;
margin: -1px;
padding: 1px;
background-color: var(--highlight-bg-color);
backdrop-filter: var(--highlight-backdrop-filter);
border-radius: 4px;
}
:host ::ng-deep .appended:is(.textLayer .highlight) {
position: initial;
}
:host ::ng-deep .begin:is(.textLayer .highlight) {
border-radius: 4px 0 0 4px;
}
:host ::ng-deep .end:is(.textLayer .highlight) {
border-radius: 0 4px 4px 0;
}
:host ::ng-deep .middle:is(.textLayer .highlight) {
border-radius: 0;
}
:host ::ng-deep .selected:is(.textLayer .highlight) {
background-color: var(--highlight-selected-bg-color);
}
:host ::ng-deep .textLayer ::selection {
background: rgba(30, 100, 255, 0.35);
}
:host ::ng-deep .annotationLayer {
position: absolute;
inset: 0;
pointer-events: none;
}

View File

@@ -0,0 +1,299 @@
import { SimpleChange } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import * as pdfjs from 'pdfjs-dist/legacy/build/pdf.mjs'
import { PDFSinglePageViewer, PDFViewer } from 'pdfjs-dist/web/pdf_viewer.mjs'
import { PngxPdfViewerComponent } from './pdf-viewer.component'
import { PdfRenderMode, PdfZoomLevel, PdfZoomScale } from './pdf-viewer.types'
describe('PngxPdfViewerComponent', () => {
let fixture: ComponentFixture<PngxPdfViewerComponent>
let component: PngxPdfViewerComponent
const initComponent = async (src = 'test.pdf') => {
component.src = src
fixture.detectChanges()
await fixture.whenStable()
}
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PngxPdfViewerComponent],
}).compileComponents()
fixture = TestBed.createComponent(PngxPdfViewerComponent)
component = fixture.componentInstance
})
it('loads a document and emits events', async () => {
const loadSpy = jest.fn()
const renderedSpy = jest.fn()
component.afterLoadComplete.subscribe(loadSpy)
component.rendered.subscribe(renderedSpy)
await initComponent()
expect(pdfjs.GlobalWorkerOptions.workerSrc).toBe(
'/assets/js/pdf.worker.min.mjs'
)
const isVisible = (component as any).findController.onIsPageVisible as
| (() => boolean)
| undefined
expect(isVisible?.()).toBe(true)
expect(loadSpy).toHaveBeenCalledWith(
expect.objectContaining({ numPages: 1 })
)
expect(renderedSpy).toHaveBeenCalled()
expect((component as any).pdfViewer).toBeInstanceOf(PDFViewer)
})
it('initializes single-page viewer and disables text layer', async () => {
component.renderMode = PdfRenderMode.Single
component.selectable = false
await initComponent()
const viewer = (component as any).pdfViewer as PDFSinglePageViewer & {
options: Record<string, unknown>
}
expect(viewer).toBeInstanceOf(PDFSinglePageViewer)
expect(viewer.options.textLayerMode).toBe(0)
})
it('applies zoom, rotation, and page changes', async () => {
await initComponent()
const pageSpy = jest.fn()
component.pageChange.subscribe(pageSpy)
component.zoomScale = PdfZoomScale.PageFit
component.zoom = PdfZoomLevel.Two
component.rotation = 90
component.page = 2
component.ngOnChanges({
zoomScale: new SimpleChange(
PdfZoomScale.PageWidth,
PdfZoomScale.PageFit,
false
),
zoom: new SimpleChange(PdfZoomLevel.One, PdfZoomLevel.Two, false),
rotation: new SimpleChange(undefined, 90, false),
page: new SimpleChange(undefined, 2, false),
})
const viewer = (component as any).pdfViewer as PDFViewer
expect(viewer.pagesRotation).toBe(90)
expect(viewer.currentPageNumber).toBe(2)
expect(pageSpy).toHaveBeenCalledWith(2)
viewer.currentScale = 1
;(component as any).applyScale()
expect(viewer.currentScaleValue).toBe(PdfZoomScale.PageFit)
expect(viewer.currentScale).toBe(2)
const applyScaleSpy = jest.spyOn(component as any, 'applyScale')
component.page = 2
;(component as any).lastViewerPage = 2
;(component as any).applyViewerState()
expect((component as any).lastViewerPage).toBeUndefined()
expect(applyScaleSpy).toHaveBeenCalled()
})
it('dispatches find when search query changes after render', async () => {
await initComponent()
const eventBus = (component as any).eventBus as { dispatch: jest.Mock }
const dispatchSpy = jest.spyOn(eventBus, 'dispatch')
;(component as any).hasRenderedPage = true
component.searchQuery = 'needle'
component.ngOnChanges({
searchQuery: new SimpleChange('', 'needle', false),
})
expect(dispatchSpy).toHaveBeenCalledWith('find', {
query: 'needle',
caseSensitive: false,
highlightAll: true,
phraseSearch: true,
})
component.ngOnChanges({
searchQuery: new SimpleChange('needle', 'needle', false),
})
expect(dispatchSpy).toHaveBeenCalledTimes(1)
})
it('emits error when document load fails', async () => {
const errorSpy = jest.fn()
component.loadError.subscribe(errorSpy)
jest.spyOn(pdfjs, 'getDocument').mockImplementationOnce(() => {
return {
promise: Promise.reject(new Error('boom')),
destroy: jest.fn(),
} as any
})
await initComponent('bad.pdf')
expect(errorSpy).toHaveBeenCalled()
})
it('cleans up resources on destroy', async () => {
await initComponent()
const viewer = (component as any).pdfViewer as { cleanup: jest.Mock }
const loadingTask = (component as any).loadingTask as unknown as {
destroy: jest.Mock
}
const resizeObserver = (component as any).resizeObserver as unknown as {
disconnect: jest.Mock
}
const eventBus = (component as any).eventBus as { off: jest.Mock }
jest.spyOn(viewer, 'cleanup')
jest.spyOn(loadingTask, 'destroy')
jest.spyOn(resizeObserver, 'disconnect')
jest.spyOn(eventBus, 'off')
component.ngOnDestroy()
expect(eventBus.off).toHaveBeenCalledWith(
'pagerendered',
expect.any(Function)
)
expect(eventBus.off).toHaveBeenCalledWith('pagesinit', expect.any(Function))
expect(eventBus.off).toHaveBeenCalledWith(
'pagechanging',
expect.any(Function)
)
expect(resizeObserver.disconnect).toHaveBeenCalled()
expect(loadingTask.destroy).toHaveBeenCalled()
expect(viewer.cleanup).toHaveBeenCalled()
expect((component as any).pdfViewer).toBeUndefined()
})
it('skips work when viewer is missing or has no pages', () => {
const eventBus = (component as any).eventBus as { dispatch: jest.Mock }
const dispatchSpy = jest.spyOn(eventBus, 'dispatch')
;(component as any).dispatchFindIfReady()
expect(dispatchSpy).not.toHaveBeenCalled()
;(component as any).applyViewerState()
;(component as any).applyScale()
const viewer = new PDFViewer({ eventBus: undefined })
viewer.pagesCount = 0
;(component as any).pdfViewer = viewer
viewer.currentScale = 5
;(component as any).applyScale()
expect(viewer.currentScale).toBe(5)
})
it('returns early on src change in ngOnChanges', () => {
const loadSpy = jest.spyOn(component as any, 'loadDocument')
const initSpy = jest.spyOn(component as any, 'initViewer')
const scaleSpy = jest.spyOn(component as any, 'applyViewerState')
const resizeSpy = jest.spyOn(component as any, 'setupResizeObserver')
component.ngOnChanges({
src: new SimpleChange(undefined, 'test.pdf', true),
zoomScale: new SimpleChange(
PdfZoomScale.PageWidth,
PdfZoomScale.PageFit,
false
),
})
expect(loadSpy).toHaveBeenCalled()
expect(resizeSpy).not.toHaveBeenCalled()
expect(initSpy).not.toHaveBeenCalled()
expect(scaleSpy).not.toHaveBeenCalled()
})
it('applies viewer state after view init when already loaded', () => {
const applySpy = jest.spyOn(component as any, 'applyViewerState')
;(component as any).hasLoaded = true
;(component as any).pdf = { numPages: 1 }
fixture.detectChanges()
expect(applySpy).toHaveBeenCalled()
})
it('skips viewer state after view init when no pdf is available', () => {
const applySpy = jest.spyOn(component as any, 'applyViewerState')
;(component as any).hasLoaded = true
fixture.detectChanges()
expect(applySpy).not.toHaveBeenCalled()
})
it('does not reload when already loaded', async () => {
await initComponent()
const getDocumentSpy = jest.spyOn(pdfjs, 'getDocument')
const callCount = getDocumentSpy.mock.calls.length
await (component as any).loadDocument()
expect(getDocumentSpy).toHaveBeenCalledTimes(callCount)
})
it('runs applyScale on resize observer notifications', async () => {
await initComponent()
const applySpy = jest.spyOn(component as any, 'applyScale')
const resizeObserver = (component as any).resizeObserver as {
trigger: () => void
}
resizeObserver.trigger()
expect(applySpy).toHaveBeenCalled()
})
it('skips page work when no pages are available', async () => {
await initComponent()
const viewer = (component as any).pdfViewer as PDFViewer
viewer.pagesCount = 0
const applyScaleSpy = jest.spyOn(component as any, 'applyScale')
component.page = undefined
;(component as any).lastViewerPage = 1
;(component as any).applyViewerState()
expect(applyScaleSpy).not.toHaveBeenCalled()
expect((component as any).lastViewerPage).toBe(1)
})
it('falls back to a default zoom when input is invalid', async () => {
await initComponent()
const viewer = (component as any).pdfViewer as PDFViewer
viewer.currentScale = 3
component.zoom = 'not-a-number' as PdfZoomLevel
;(component as any).applyScale()
expect(viewer.currentScale).toBe(3)
})
it('re-initializes viewer on selectable or render mode changes', async () => {
await initComponent()
const initSpy = jest.spyOn(component as any, 'initViewer')
component.selectable = false
component.renderMode = PdfRenderMode.Single
component.ngOnChanges({
selectable: new SimpleChange(true, false, false),
renderMode: new SimpleChange(
PdfRenderMode.All,
PdfRenderMode.Single,
false
),
})
expect(initSpy).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,266 @@
import {
AfterViewInit,
Component,
ElementRef,
EventEmitter,
Input,
OnChanges,
OnDestroy,
Output,
SimpleChanges,
ViewChild,
} from '@angular/core'
import {
getDocument,
GlobalWorkerOptions,
PDFDocumentLoadingTask,
PDFDocumentProxy,
} from 'pdfjs-dist/legacy/build/pdf.mjs'
import {
EventBus,
PDFFindController,
PDFLinkService,
PDFSinglePageViewer,
PDFViewer,
} from 'pdfjs-dist/web/pdf_viewer.mjs'
import {
PdfRenderMode,
PdfSource,
PdfZoomLevel,
PdfZoomScale,
PngxPdfDocumentProxy,
} from './pdf-viewer.types'
@Component({
selector: 'pngx-pdf-viewer',
templateUrl: './pdf-viewer.component.html',
styleUrl: './pdf-viewer.component.scss',
})
export class PngxPdfViewerComponent
implements AfterViewInit, OnChanges, OnDestroy
{
@Input() src!: PdfSource
@Input() page?: number
@Output() pageChange = new EventEmitter<number>()
@Input() rotation?: number
@Input() renderMode: PdfRenderMode = PdfRenderMode.All
@Input() selectable = true
@Input() searchQuery = ''
@Input() zoom: PdfZoomLevel = PdfZoomLevel.One
@Input() zoomScale: PdfZoomScale = PdfZoomScale.PageWidth
@Output() afterLoadComplete = new EventEmitter<PngxPdfDocumentProxy>()
@Output() rendered = new EventEmitter<void>()
@Output() loadError = new EventEmitter<unknown>()
@ViewChild('container', { static: true })
private readonly container!: ElementRef<HTMLDivElement>
@ViewChild('viewer', { static: true })
private readonly viewer!: ElementRef<HTMLDivElement>
private hasLoaded = false
private loadingTask?: PDFDocumentLoadingTask
private resizeObserver?: ResizeObserver
private pdf?: PDFDocumentProxy
private pdfViewer?: PDFViewer | PDFSinglePageViewer
private hasRenderedPage = false
private lastFindQuery = ''
private lastViewerPage?: number
private readonly eventBus = new EventBus()
private readonly linkService = new PDFLinkService({ eventBus: this.eventBus })
private readonly findController = new PDFFindController({
eventBus: this.eventBus,
linkService: this.linkService,
updateMatchesCountOnProgress: false,
})
private readonly onPageRendered = () => {
this.hasRenderedPage = true
this.dispatchFindIfReady()
this.rendered.emit()
}
private readonly onPagesInit = () => this.applyScale()
private readonly onPageChanging = (evt: { pageNumber: number }) => {
// Avoid [(page)] two-way binding re-triggers navigation
this.lastViewerPage = evt.pageNumber
this.pageChange.emit(evt.pageNumber)
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['src']) {
this.hasLoaded = false
this.loadDocument()
return
}
if (changes['zoomScale']) {
this.setupResizeObserver()
}
if (changes['selectable'] || changes['renderMode']) {
this.initViewer()
}
if (
changes['page'] ||
changes['zoom'] ||
changes['zoomScale'] ||
changes['rotation']
) {
this.applyViewerState()
}
if (changes['searchQuery']) {
this.dispatchFindIfReady()
}
}
ngAfterViewInit(): void {
this.setupResizeObserver()
this.initViewer()
if (!this.hasLoaded) {
this.loadDocument()
return
}
if (this.pdf) {
this.applyViewerState()
}
}
ngOnDestroy(): void {
this.eventBus.off('pagerendered', this.onPageRendered)
this.eventBus.off('pagesinit', this.onPagesInit)
this.eventBus.off('pagechanging', this.onPageChanging)
this.resizeObserver?.disconnect()
this.loadingTask?.destroy()
this.pdfViewer?.cleanup()
this.pdfViewer = undefined
}
private async loadDocument(): Promise<void> {
if (this.hasLoaded) {
return
}
this.hasLoaded = true
this.hasRenderedPage = false
this.lastFindQuery = ''
this.loadingTask?.destroy()
GlobalWorkerOptions.workerSrc = '/assets/js/pdf.worker.min.mjs'
this.loadingTask = getDocument(this.src)
try {
const pdf = await this.loadingTask.promise
this.pdf = pdf
this.linkService.setDocument(pdf)
this.findController.onIsPageVisible = () => true
this.pdfViewer?.setDocument(pdf)
this.applyViewerState()
this.afterLoadComplete.emit(pdf)
} catch (err) {
this.loadError.emit(err)
}
}
private setupResizeObserver(): void {
this.resizeObserver?.disconnect()
this.resizeObserver = new ResizeObserver(() => {
this.applyScale()
})
this.resizeObserver.observe(this.container.nativeElement)
}
private initViewer(): void {
this.viewer.nativeElement.innerHTML = ''
this.pdfViewer?.cleanup()
this.hasRenderedPage = false
this.lastFindQuery = ''
const textLayerMode = this.selectable === false ? 0 : 1
const options = {
container: this.container.nativeElement,
viewer: this.viewer.nativeElement,
eventBus: this.eventBus,
linkService: this.linkService,
findController: this.findController,
textLayerMode,
removePageBorders: true,
}
this.pdfViewer =
this.renderMode === PdfRenderMode.Single
? new PDFSinglePageViewer(options)
: new PDFViewer(options)
this.linkService.setViewer(this.pdfViewer)
this.eventBus.off('pagerendered', this.onPageRendered)
this.eventBus.off('pagesinit', this.onPagesInit)
this.eventBus.off('pagechanging', this.onPageChanging)
this.eventBus.on('pagerendered', this.onPageRendered)
this.eventBus.on('pagesinit', this.onPagesInit)
this.eventBus.on('pagechanging', this.onPageChanging)
if (this.pdf) {
this.pdfViewer.setDocument(this.pdf)
this.applyViewerState()
}
}
private applyViewerState(): void {
if (!this.pdfViewer) {
return
}
const hasPages = this.pdfViewer.pagesCount > 0
if (typeof this.rotation === 'number' && hasPages) {
this.pdfViewer.pagesRotation = this.rotation
}
if (
typeof this.page === 'number' &&
hasPages &&
this.page !== this.lastViewerPage
) {
this.pdfViewer.currentPageNumber = this.page
}
if (this.page === this.lastViewerPage) {
this.lastViewerPage = undefined
}
if (hasPages) {
this.applyScale()
}
this.dispatchFindIfReady()
}
private applyScale(): void {
if (!this.pdfViewer) {
return
}
if (this.pdfViewer.pagesCount === 0) {
return
}
const zoomFactor = Number(this.zoom) || 1
this.pdfViewer.currentScaleValue = this.zoomScale
if (zoomFactor !== 1) {
this.pdfViewer.currentScale = this.pdfViewer.currentScale * zoomFactor
}
}
private dispatchFindIfReady(): void {
if (!this.hasRenderedPage) {
return
}
const query = this.searchQuery.trim()
if (query === this.lastFindQuery) {
return
}
this.lastFindQuery = query
this.eventBus.dispatch('find', {
query,
caseSensitive: false,
highlightAll: query.length > 0,
phraseSearch: true,
})
}
}

View File

@@ -0,0 +1,25 @@
export type PngxPdfDocumentProxy = {
numPages: number
}
export type PdfSource = string | { url: string; password?: string }
export enum PdfRenderMode {
Single = 'single',
All = 'all',
}
export enum PdfZoomScale {
PageFit = 'page-fit',
PageWidth = 'page-width',
}
export enum PdfZoomLevel {
Quarter = '.25',
Half = '.5',
ThreeQuarters = '.75',
One = '1',
OneAndHalf = '1.5',
Two = '2',
Three = '3',
}

View File

@@ -23,14 +23,12 @@
</div> </div>
} }
@if (!requiresPassword) { @if (!requiresPassword) {
<pdf-viewer <pngx-pdf-viewer
[src]="previewUrl" [src]="previewUrl"
[original-size]="false" [renderMode]="PdfRenderMode.All"
[show-borders]="false" [searchQuery]="documentService.searchQuery"
[show-all]="true" (loadError)="onError($event)">
(text-layer-rendered)="onPageRendered()" </pngx-pdf-viewer>
(error)="onError($event)" #pdfViewer>
</pdf-viewer>
} }
} }
} }

View File

@@ -12,6 +12,7 @@ import { of, throwError } from 'rxjs'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { DocumentService } from 'src/app/services/rest/document.service' import { DocumentService } from 'src/app/services/rest/document.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { PngxPdfViewerComponent } from '../pdf-viewer/pdf-viewer.component'
import { PreviewPopupComponent } from './preview-popup.component' import { PreviewPopupComponent } from './preview-popup.component'
const doc = { const doc = {
@@ -78,7 +79,7 @@ describe('PreviewPopupComponent', () => {
component.popover.open() component.popover.open()
fixture.detectChanges() fixture.detectChanges()
expect(fixture.debugElement.query(By.css('object'))).toBeNull() expect(fixture.debugElement.query(By.css('object'))).toBeNull()
expect(fixture.debugElement.query(By.css('pdf-viewer'))).not.toBeNull() expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull()
}) })
it('should show lock icon on password error', () => { it('should show lock icon on password error', () => {
@@ -159,23 +160,15 @@ describe('PreviewPopupComponent', () => {
expect(component.popover.isOpen()).toBeFalsy() expect(component.popover.isOpen()).toBeFalsy()
}) })
it('should dispatch find event on viewer loaded if searchQuery set', () => { it('should pass searchQuery to viewer', () => {
documentService.searchQuery = 'test' documentService.searchQuery = 'test'
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false) settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
component.popover.open() component.popover.open()
jest.advanceTimersByTime(1000)
fixture.detectChanges() fixture.detectChanges()
// normally setup by pdf-viewer const viewer = fixture.debugElement.query(
jest.replaceProperty(component.pdfViewer, 'eventBus', { By.directive(PngxPdfViewerComponent)
dispatch: jest.fn(), )
} as any) expect(viewer).not.toBeNull()
const dispatchSpy = jest.spyOn(component.pdfViewer.eventBus, 'dispatch') expect(viewer.componentInstance.searchQuery).toBe('test')
component.onPageRendered()
expect(dispatchSpy).toHaveBeenCalledWith('find', {
query: 'test',
caseSensitive: false,
highlightAll: true,
phraseSearch: true,
})
}) })
}) })

View File

@@ -1,7 +1,6 @@
import { HttpClient } from '@angular/common/http' import { HttpClient } from '@angular/common/http'
import { Component, inject, Input, OnDestroy, ViewChild } from '@angular/core' import { Component, inject, Input, OnDestroy, ViewChild } from '@angular/core'
import { NgbPopover, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap' import { NgbPopover, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
import { PdfViewerComponent, PdfViewerModule } from 'ng2-pdf-viewer'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { first, Subject, takeUntil } from 'rxjs' import { first, Subject, takeUntil } from 'rxjs'
import { Document } from 'src/app/data/document' import { Document } from 'src/app/data/document'
@@ -10,6 +9,8 @@ import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
import { SafeUrlPipe } from 'src/app/pipes/safeurl.pipe' import { SafeUrlPipe } from 'src/app/pipes/safeurl.pipe'
import { DocumentService } from 'src/app/services/rest/document.service' import { DocumentService } from 'src/app/services/rest/document.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { PngxPdfViewerComponent } from '../pdf-viewer/pdf-viewer.component'
import { PdfRenderMode } from '../pdf-viewer/pdf-viewer.types'
@Component({ @Component({
selector: 'pngx-preview-popup', selector: 'pngx-preview-popup',
@@ -18,14 +19,15 @@ import { SettingsService } from 'src/app/services/settings.service'
imports: [ imports: [
NgbPopoverModule, NgbPopoverModule,
DocumentTitlePipe, DocumentTitlePipe,
PdfViewerModule, PngxPdfViewerComponent,
SafeUrlPipe, SafeUrlPipe,
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
], ],
}) })
export class PreviewPopupComponent implements OnDestroy { export class PreviewPopupComponent implements OnDestroy {
PdfRenderMode = PdfRenderMode
private settingsService = inject(SettingsService) private settingsService = inject(SettingsService)
private documentService = inject(DocumentService) public readonly documentService = inject(DocumentService)
private http = inject(HttpClient) private http = inject(HttpClient)
private _document: Document private _document: Document
@@ -61,8 +63,6 @@ export class PreviewPopupComponent implements OnDestroy {
@ViewChild('popover') popover: NgbPopover @ViewChild('popover') popover: NgbPopover
@ViewChild('pdfViewer') pdfViewer: PdfViewerComponent
mouseOnPreview: boolean = false mouseOnPreview: boolean = false
popoverClass: string = 'shadow popover-preview' popoverClass: string = 'shadow popover-preview'
@@ -114,18 +114,6 @@ export class PreviewPopupComponent implements OnDestroy {
} }
} }
onPageRendered() {
// Only triggered by the pngx pdf viewer
if (this.documentService.searchQuery) {
this.pdfViewer.eventBus.dispatch('find', {
query: this.documentService.searchQuery,
caseSensitive: false,
highlightAll: true,
phraseSearch: true,
})
}
}
mouseEnterPreview() { mouseEnterPreview() {
this.mouseOnPreview = true this.mouseOnPreview = true
if (!this.popover.isOpen()) { if (!this.popover.isOpen()) {

View File

@@ -5,8 +5,12 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser' import { By } from '@angular/platform-browser'
import { RouterTestingModule } from '@angular/router/testing' import { RouterTestingModule } from '@angular/router/testing'
import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap' import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap' import {
provideUiTour,
TourNgBootstrap,
TourService,
} from 'ngx-ui-tour-ng-bootstrap'
import { of, throwError } from 'rxjs' import { of, throwError } from 'rxjs'
import { SavedView } from 'src/app/data/saved-view' import { SavedView } from 'src/app/data/saved-view'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
@@ -75,7 +79,7 @@ describe('DashboardComponent', () => {
imports: [ imports: [
NgbAlertModule, NgbAlertModule,
RouterTestingModule, RouterTestingModule,
TourNgBootstrapModule, TourNgBootstrap,
DragDropModule, DragDropModule,
NgxBootstrapIconsModule.pick(allIcons), NgxBootstrapIconsModule.pick(allIcons),
DashboardComponent, DashboardComponent,
@@ -111,6 +115,7 @@ describe('DashboardComponent', () => {
}, },
provideHttpClient(withInterceptorsFromDi()), provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(), provideHttpClientTesting(),
provideUiTour(),
], ],
}).compileComponents() }).compileComponents()

View File

@@ -8,7 +8,7 @@ import {
import { Component, inject } from '@angular/core' import { Component, inject } from '@angular/core'
import { RouterModule } from '@angular/router' import { RouterModule } from '@angular/router'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap' import { TourNgBootstrap, TourService } from 'ngx-ui-tour-ng-bootstrap'
import { SavedView } from 'src/app/data/saved-view' import { SavedView } from 'src/app/data/saved-view'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SavedViewService } from 'src/app/services/rest/saved-view.service' import { SavedViewService } from 'src/app/services/rest/saved-view.service'
@@ -36,7 +36,7 @@ import { WelcomeWidgetComponent } from './widgets/welcome-widget/welcome-widget.
WelcomeWidgetComponent, WelcomeWidgetComponent,
IfPermissionsDirective, IfPermissionsDirective,
DragDropModule, DragDropModule,
TourNgBootstrapModule, TourNgBootstrap,
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
RouterModule, RouterModule,
], ],

View File

@@ -9,6 +9,7 @@ import {
import { By } from '@angular/platform-browser' import { By } from '@angular/platform-browser'
import { RouterTestingModule } from '@angular/router/testing' import { RouterTestingModule } from '@angular/router/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
import { routes } from 'src/app/app-routing.module' import { routes } from 'src/app/app-routing.module'
import { PermissionsGuard } from 'src/app/guards/permissions.guard' import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { PermissionsService } from 'src/app/services/permissions.service' import { PermissionsService } from 'src/app/services/permissions.service'
@@ -61,6 +62,7 @@ describe('UploadFileWidgetComponent', () => {
}, },
provideHttpClient(withInterceptorsFromDi()), provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(), provideHttpClientTesting(),
provideUiTour(),
], ],
}).compileComponents() }).compileComponents()

View File

@@ -7,7 +7,7 @@ import {
NgbProgressbarModule, NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap' import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component' import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
@@ -33,7 +33,7 @@ import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
NgbAlertModule, NgbAlertModule,
NgbProgressbarModule, NgbProgressbarModule,
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
TourNgBootstrapModule, TourNgBootstrap,
], ],
}) })
export class UploadFileWidgetComponent extends ComponentWithPermissions { export class UploadFileWidgetComponent extends ComponentWithPermissions {

View File

@@ -1,6 +1,7 @@
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser' import { By } from '@angular/platform-browser'
import { NgbAlert, NgbAlertModule } from '@ng-bootstrap/ng-bootstrap' import { NgbAlert, NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
import { PermissionsGuard } from 'src/app/guards/permissions.guard' import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component' import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
import { WelcomeWidgetComponent } from './welcome-widget.component' import { WelcomeWidgetComponent } from './welcome-widget.component'
@@ -11,7 +12,7 @@ describe('WelcomeWidgetComponent', () => {
beforeEach(async () => { beforeEach(async () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [PermissionsGuard], providers: [PermissionsGuard, provideUiTour()],
imports: [NgbAlertModule, WelcomeWidgetComponent, WidgetFrameComponent], imports: [NgbAlertModule, WelcomeWidgetComponent, WidgetFrameComponent],
}).compileComponents() }).compileComponents()

View File

@@ -456,17 +456,15 @@
@case (ContentRenderType.PDF) { @case (ContentRenderType.PDF) {
@if (!useNativePdfViewer) { @if (!useNativePdfViewer) {
<div class="preview-sticky pdf-viewer-container"> <div class="preview-sticky pdf-viewer-container">
<pdf-viewer <pngx-pdf-viewer
[src]="{ url: previewUrl, password: password }" [src]="{ url: previewUrl, password: password }"
[original-size]="false" [renderMode]="PdfRenderMode.All"
[show-borders]="true"
[show-all]="true"
[(page)]="previewCurrentPage" [(page)]="previewCurrentPage"
[zoom-scale]="previewZoomScale" [zoomScale]="previewZoomScale"
[zoom]="previewZoomSetting" [zoom]="previewZoomSetting"
(error)="onError($event)" (loadError)="onError($event)"
(after-load-complete)="pdfPreviewLoaded($event)"> (afterLoadComplete)="pdfPreviewLoaded($event)">
</pdf-viewer> </pngx-pdf-viewer>
</div> </div>
} @else { } @else {
<object [data]="previewUrl | safeUrl" class="preview-sticky" width="100%"></object> <object [data]="previewUrl | safeUrl" class="preview-sticky" width="100%"></object>

View File

@@ -5,20 +5,15 @@
} }
.pdf-viewer-container { .pdf-viewer-container {
padding-top: 10px; padding: 8px;
background-color: gray; background-color: gray;
pdf-viewer { pngx-pdf-viewer {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
} }
::ng-deep .ng2-pdf-viewer-container .page {
--page-margin: 0 auto 10px;
--page-border: 0;
}
.btn-group .dropdown-toggle-split { .btn-group .dropdown-toggle-split {
border-top-right-radius: inherit; border-top-right-radius: inherit;
border-bottom-right-radius: inherit; border-bottom-right-radius: inherit;

View File

@@ -69,8 +69,11 @@ import { environment } from 'src/environments/environment'
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component' import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component' import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
import {
PdfZoomLevel,
PdfZoomScale,
} from '../common/pdf-viewer/pdf-viewer.types'
import { DocumentDetailComponent } from './document-detail.component' import { DocumentDetailComponent } from './document-detail.component'
import { ZoomSetting } from './zoom-setting'
const doc: Document = { const doc: Document = {
id: 3, id: 3,
@@ -860,7 +863,7 @@ describe('DocumentDetailComponent', () => {
it('should support zoom controls', () => { it('should support zoom controls', () => {
initNormally() initNormally()
component.setZoom(ZoomSetting.One) // from select component.setZoom(PdfZoomLevel.One) // from select
expect(component.previewZoomSetting).toEqual('1') expect(component.previewZoomSetting).toEqual('1')
component.increaseZoom() component.increaseZoom()
expect(component.previewZoomSetting).toEqual('1.5') expect(component.previewZoomSetting).toEqual('1.5')
@@ -868,18 +871,18 @@ describe('DocumentDetailComponent', () => {
expect(component.previewZoomSetting).toEqual('2') expect(component.previewZoomSetting).toEqual('2')
component.decreaseZoom() component.decreaseZoom()
expect(component.previewZoomSetting).toEqual('1.5') expect(component.previewZoomSetting).toEqual('1.5')
component.setZoom(ZoomSetting.One) // from select component.setZoom(PdfZoomLevel.One) // from select
component.decreaseZoom() component.decreaseZoom()
expect(component.previewZoomSetting).toEqual('.75') expect(component.previewZoomSetting).toEqual('.75')
component.setZoom(ZoomSetting.PageFit) // from select component.setZoom(PdfZoomScale.PageFit) // from select
expect(component.previewZoomScale).toEqual('page-fit') expect(component.previewZoomScale).toEqual('page-fit')
expect(component.previewZoomSetting).toEqual('1') expect(component.previewZoomSetting).toEqual('1')
component.increaseZoom() component.increaseZoom()
expect(component.previewZoomSetting).toEqual('1.5') expect(component.previewZoomSetting).toEqual('1.5')
expect(component.previewZoomScale).toEqual('page-width') expect(component.previewZoomScale).toEqual('page-width')
component.setZoom(ZoomSetting.PageFit) // from select component.setZoom(PdfZoomScale.PageFit) // from select
expect(component.previewZoomScale).toEqual('page-fit') expect(component.previewZoomScale).toEqual('page-fit')
expect(component.previewZoomSetting).toEqual('1') expect(component.previewZoomSetting).toEqual('1')
component.decreaseZoom() component.decreaseZoom()
@@ -889,10 +892,10 @@ describe('DocumentDetailComponent', () => {
it('should select correct zoom setting in dropdown', () => { it('should select correct zoom setting in dropdown', () => {
initNormally() initNormally()
component.setZoom(ZoomSetting.PageFit) component.setZoom(PdfZoomScale.PageFit)
expect(component.currentZoom).toEqual(ZoomSetting.PageFit) expect(component.currentZoom).toEqual(PdfZoomScale.PageFit)
component.setZoom(ZoomSetting.Quarter) component.setZoom(PdfZoomLevel.Quarter)
expect(component.currentZoom).toEqual(ZoomSetting.Quarter) expect(component.currentZoom).toEqual(PdfZoomLevel.Quarter)
}) })
it('should support updating notes dynamically', () => { it('should support updating notes dynamically', () => {
@@ -1017,7 +1020,7 @@ describe('DocumentDetailComponent', () => {
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false) settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
expect(component.useNativePdfViewer).toBeFalsy() expect(component.useNativePdfViewer).toBeFalsy()
fixture.detectChanges() fixture.detectChanges()
expect(fixture.debugElement.query(By.css('pdf-viewer'))).not.toBeNull() expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull()
}) })
it('should display native pdf viewer if enabled', () => { it('should display native pdf viewer if enabled', () => {

View File

@@ -18,7 +18,6 @@ import {
NgbNavModule, NgbNavModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms' import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms'
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { DeviceDetectorService } from 'ngx-device-detector' import { DeviceDetectorService } from 'ngx-device-detector'
import { BehaviorSubject, Observable, of, Subject, timer } from 'rxjs' import { BehaviorSubject, Observable, of, Subject, timer } from 'rxjs'
@@ -108,13 +107,19 @@ import { UrlComponent } from '../common/input/url/url.component'
import { PageHeaderComponent } from '../common/page-header/page-header.component' import { PageHeaderComponent } from '../common/page-header/page-header.component'
import { PdfEditorEditMode } from '../common/pdf-editor/pdf-editor-edit-mode' import { PdfEditorEditMode } from '../common/pdf-editor/pdf-editor-edit-mode'
import { PDFEditorComponent } from '../common/pdf-editor/pdf-editor.component' import { PDFEditorComponent } from '../common/pdf-editor/pdf-editor.component'
import { PngxPdfViewerComponent } from '../common/pdf-viewer/pdf-viewer.component'
import {
PdfRenderMode,
PdfZoomLevel,
PdfZoomScale,
PngxPdfDocumentProxy,
} from '../common/pdf-viewer/pdf-viewer.types'
import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-links-dialog.component' import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-links-dialog.component'
import { SuggestionsDropdownComponent } from '../common/suggestions-dropdown/suggestions-dropdown.component' import { SuggestionsDropdownComponent } from '../common/suggestions-dropdown/suggestions-dropdown.component'
import { DocumentHistoryComponent } from '../document-history/document-history.component' import { DocumentHistoryComponent } from '../document-history/document-history.component'
import { DocumentNotesComponent } from '../document-notes/document-notes.component' import { DocumentNotesComponent } from '../document-notes/document-notes.component'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component' import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
import { MetadataCollapseComponent } from './metadata-collapse/metadata-collapse.component' import { MetadataCollapseComponent } from './metadata-collapse/metadata-collapse.component'
import { ZoomSetting } from './zoom-setting'
enum DocumentDetailNavIDs { enum DocumentDetailNavIDs {
Details = 1, Details = 1,
@@ -168,16 +173,17 @@ enum ContentRenderType {
NgbNavModule, NgbNavModule,
NgbDropdownModule, NgbDropdownModule,
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
PdfViewerModule,
TextAreaComponent, TextAreaComponent,
RouterModule, RouterModule,
PngxPdfViewerComponent,
], ],
}) })
export class DocumentDetailComponent export class DocumentDetailComponent
extends ComponentWithPermissions extends ComponentWithPermissions
implements OnInit, OnDestroy, DirtyComponent implements OnInit, OnDestroy, DirtyComponent
{ {
private documentsService = inject(DocumentService) PdfRenderMode = PdfRenderMode
documentsService = inject(DocumentService)
private route = inject(ActivatedRoute) private route = inject(ActivatedRoute)
private tagService = inject(TagService) private tagService = inject(TagService)
private correspondentService = inject(CorrespondentService) private correspondentService = inject(CorrespondentService)
@@ -246,8 +252,8 @@ export class DocumentDetailComponent
previewCurrentPage: number = 1 previewCurrentPage: number = 1
previewNumPages: number previewNumPages: number
previewZoomSetting: ZoomSetting = ZoomSetting.One previewZoomSetting: PdfZoomLevel = PdfZoomLevel.One
previewZoomScale: ZoomSetting = ZoomSetting.PageWidth previewZoomScale: PdfZoomScale = PdfZoomScale.PageWidth
store: BehaviorSubject<any> store: BehaviorSubject<any>
isDirty$: Observable<boolean> isDirty$: Observable<boolean>
@@ -503,7 +509,9 @@ export class DocumentDetailComponent
} }
ngOnInit(): void { ngOnInit(): void {
this.setZoom(this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING)) this.setZoom(
this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING) as PdfZoomScale
)
this.documentForm.valueChanges this.documentForm.valueChanges
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((values) => { .subscribe((values) => {
@@ -1204,7 +1212,7 @@ export class DocumentDetailComponent
}) })
} }
pdfPreviewLoaded(pdf: PDFDocumentProxy) { pdfPreviewLoaded(pdf: PngxPdfDocumentProxy) {
this.previewNumPages = pdf.numPages this.previewNumPages = pdf.numPages
if (this.password) this.requiresPassword = false if (this.password) this.requiresPassword = false
setTimeout(() => { setTimeout(() => {
@@ -1225,31 +1233,33 @@ export class DocumentDetailComponent
} }
} }
setZoom(setting: ZoomSetting) { setZoom(setting: PdfZoomScale | PdfZoomLevel) {
if (ZoomSetting.PageFit === setting || ZoomSetting.PageWidth === setting) { if (
setting === PdfZoomScale.PageFit ||
setting === PdfZoomScale.PageWidth
) {
this.previewZoomScale = setting this.previewZoomScale = setting
this.previewZoomSetting = ZoomSetting.One this.previewZoomSetting = PdfZoomLevel.One
} else { return
this.previewZoomSetting = setting
this.previewZoomScale = ZoomSetting.PageWidth
} }
this.previewZoomSetting = setting
this.previewZoomScale = PdfZoomScale.PageWidth
} }
get zoomSettings() { get zoomSettings() {
return Object.values(ZoomSetting).filter( return [PdfZoomScale.PageFit, ...Object.values(PdfZoomLevel)]
(setting) => setting !== ZoomSetting.PageWidth
)
} }
get currentZoom() { get currentZoom() {
if (this.previewZoomScale === ZoomSetting.PageFit) { if (this.previewZoomScale === PdfZoomScale.PageFit) {
return ZoomSetting.PageFit return PdfZoomScale.PageFit
} else return this.previewZoomSetting }
return this.previewZoomSetting
} }
getZoomSettingTitle(setting: ZoomSetting): string { getZoomSettingTitle(setting: PdfZoomScale | PdfZoomLevel): string {
switch (setting) { switch (setting) {
case ZoomSetting.PageFit: case PdfZoomScale.PageFit:
return $localize`Page Fit` return $localize`Page Fit`
default: default:
return `${parseFloat(setting) * 100}%` return `${parseFloat(setting) * 100}%`
@@ -1257,25 +1267,24 @@ export class DocumentDetailComponent
} }
increaseZoom(): void { increaseZoom(): void {
let currentIndex = Object.values(ZoomSetting).indexOf( const zoomLevels = Object.values(PdfZoomLevel)
this.previewZoomSetting let currentIndex = zoomLevels.indexOf(this.previewZoomSetting)
) if (this.previewZoomScale === PdfZoomScale.PageFit) {
if (this.previewZoomScale === ZoomSetting.PageFit) currentIndex = 5 currentIndex = zoomLevels.indexOf(PdfZoomLevel.One)
this.previewZoomScale = ZoomSetting.PageWidth }
this.previewZoomScale = PdfZoomScale.PageWidth
this.previewZoomSetting = this.previewZoomSetting =
Object.values(ZoomSetting)[ zoomLevels[Math.min(zoomLevels.length - 1, currentIndex + 1)]
Math.min(Object.values(ZoomSetting).length - 1, currentIndex + 1)
]
} }
decreaseZoom(): void { decreaseZoom(): void {
let currentIndex = Object.values(ZoomSetting).indexOf( const zoomLevels = Object.values(PdfZoomLevel)
this.previewZoomSetting let currentIndex = zoomLevels.indexOf(this.previewZoomSetting)
) if (this.previewZoomScale === PdfZoomScale.PageFit) {
if (this.previewZoomScale === ZoomSetting.PageFit) currentIndex = 4 currentIndex = zoomLevels.indexOf(PdfZoomLevel.ThreeQuarters)
this.previewZoomScale = ZoomSetting.PageWidth }
this.previewZoomSetting = this.previewZoomScale = PdfZoomScale.PageWidth
Object.values(ZoomSetting)[Math.max(2, currentIndex - 1)] this.previewZoomSetting = zoomLevels[Math.max(0, currentIndex - 1)]
} }
get showPermissions(): boolean { get showPermissions(): boolean {

View File

@@ -1,11 +0,0 @@
export enum ZoomSetting {
PageFit = 'page-fit',
PageWidth = 'page-width',
Quarter = '.25',
Half = '.5',
ThreeQuarters = '.75',
One = '1',
OneAndHalf = '1.5',
Two = '2',
Three = '3',
}

View File

@@ -16,6 +16,7 @@ import {
NgbModalRef, NgbModalRef,
} 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 { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
import { Subject, of, throwError } from 'rxjs' import { Subject, of, throwError } from 'rxjs'
import { routes } from 'src/app/app-routing.module' import { routes } from 'src/app/app-routing.module'
import { import {
@@ -105,6 +106,7 @@ describe('DocumentListComponent', () => {
PermissionsGuard, PermissionsGuard,
provideHttpClient(withInterceptorsFromDi()), provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(), provideHttpClientTesting(),
provideUiTour(),
], ],
}).compileComponents() }).compileComponents()

View File

@@ -21,7 +21,7 @@ import {
NgbPaginationModule, NgbPaginationModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap' import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
import { filter, first, map, Subject, switchMap, takeUntil } from 'rxjs' import { filter, first, map, Subject, switchMap, takeUntil } from 'rxjs'
import { import {
DEFAULT_DISPLAY_FIELDS, DEFAULT_DISPLAY_FIELDS,
@@ -99,7 +99,7 @@ import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-vi
NgbPaginationModule, NgbPaginationModule,
NgClass, NgClass,
RouterModule, RouterModule,
TourNgBootstrapModule, TourNgBootstrap,
], ],
}) })
export class DocumentListComponent export class DocumentListComponent

View File

@@ -21,6 +21,7 @@ import {
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select' import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
import { of, throwError } from 'rxjs' import { of, throwError } from 'rxjs'
import { Correspondent } from 'src/app/data/correspondent' import { Correspondent } from 'src/app/data/correspondent'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
@@ -251,6 +252,7 @@ describe('FilterEditorComponent', () => {
SettingsService, SettingsService,
provideHttpClient(withInterceptorsFromDi()), provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(), provideHttpClientTesting(),
provideUiTour(),
], ],
}).compileComponents() }).compileComponents()

View File

@@ -16,7 +16,7 @@ import {
NgbTypeaheadModule, NgbTypeaheadModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap' import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
import { Observable, Subject, from } from 'rxjs' import { Observable, Subject, from } from 'rxjs'
import { import {
catchError, catchError,
@@ -251,7 +251,7 @@ const DEFAULT_TEXT_FILTER_MODIFIER_OPTIONS = [
NgbTypeaheadModule, NgbTypeaheadModule,
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
TourNgBootstrapModule, TourNgBootstrap,
], ],
}) })
export class FilterEditorComponent export class FilterEditorComponent

View File

@@ -1,15 +1,3 @@
<pngx-page-header
title="Custom Fields"
i18n-title
info="Customize the data fields that can be attached to documents."
i18n-info
infoLink="usage/#custom-fields"
>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="editField()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.CustomField }">
<i-bs name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Add Field</ng-container>
</button>
</pngx-page-header>
<ul class="list-group"> <ul class="list-group">
<li class="list-group-item"> <li class="list-group-item">

View File

@@ -26,9 +26,9 @@ import { PermissionsService } from 'src/app/services/permissions.service'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from '../../../common/confirm-dialog/confirm-dialog.component'
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component' import { CustomFieldEditDialogComponent } from '../../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component' import { PageHeaderComponent } from '../../../common/page-header/page-header.component'
import { CustomFieldsComponent } from './custom-fields.component' import { CustomFieldsComponent } from './custom-fields.component'
const fields: CustomField[] = [ const fields: CustomField[] = [
@@ -110,10 +110,7 @@ describe('CustomFieldsComponent', () => {
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const reloadSpy = jest.spyOn(component, 'reload') const reloadSpy = jest.spyOn(component, 'reload')
const createButton = fixture.debugElement component.editField()
.queryAll(By.css('button'))
.find((btn) => btn.nativeElement.textContent.trim().includes('Add Field'))
createButton.triggerEventHandler('click')
expect(modal).not.toBeUndefined() expect(modal).not.toBeUndefined()
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent const editDialog = modal.componentInstance as CustomFieldEditDialogComponent

View File

@@ -7,6 +7,10 @@ import {
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { delay, takeUntil, tap } from 'rxjs' import { delay, takeUntil, tap } from 'rxjs'
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
import { CustomFieldEditDialogComponent } from 'src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
import { EditDialogMode } from 'src/app/components/common/edit-dialog/edit-dialog.component'
import { LoadingComponentWithPermissions } from 'src/app/components/loading-component/loading.component'
import { CustomField, DATA_TYPE_LABELS } from 'src/app/data/custom-field' import { CustomField, DATA_TYPE_LABELS } from 'src/app/data/custom-field'
import { import {
CustomFieldQueryLogicalOperator, CustomFieldQueryLogicalOperator,
@@ -21,18 +25,12 @@ import { DocumentService } from 'src/app/services/rest/document.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service' import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
@Component({ @Component({
selector: 'pngx-custom-fields', selector: 'pngx-custom-fields',
templateUrl: './custom-fields.component.html', templateUrl: './custom-fields.component.html',
styleUrls: ['./custom-fields.component.scss'], styleUrls: ['./custom-fields.component.scss'],
imports: [ imports: [
PageHeaderComponent,
IfPermissionsDirective, IfPermissionsDirective,
NgbDropdownModule, NgbDropdownModule,
NgbPaginationModule, NgbPaginationModule,
@@ -44,14 +42,14 @@ export class CustomFieldsComponent
extends LoadingComponentWithPermissions extends LoadingComponentWithPermissions
implements OnInit implements OnInit
{ {
private customFieldsService = inject(CustomFieldsService) private readonly customFieldsService = inject(CustomFieldsService)
permissionsService = inject(PermissionsService) public readonly permissionsService = inject(PermissionsService)
private modalService = inject(NgbModal) private readonly modalService = inject(NgbModal)
private toastService = inject(ToastService) private readonly toastService = inject(ToastService)
private documentListViewService = inject(DocumentListViewService) private readonly documentListViewService = inject(DocumentListViewService)
private settingsService = inject(SettingsService) private readonly settingsService = inject(SettingsService)
private documentService = inject(DocumentService) private readonly documentService = inject(DocumentService)
private savedViewService = inject(SavedViewService) private readonly savedViewService = inject(SavedViewService)
public fields: CustomField[] = [] public fields: CustomField[] = []

View File

@@ -0,0 +1,78 @@
<pngx-page-header
[title]="activeTabLabel"
info="Manage tags, correspondents, document types, storage paths, and custom fields."
i18n-info
[infoLink]="activeInfoLink"
[loading]="activeHeaderLoading"
>
@if (activeAttributeList) {
<div ngbDropdown class="btn-group flex-fill d-sm-none">
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
<i-bs name="text-indent-left"></i-bs>
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Select</ng-container></div>
@if (activeAttributeList.selectedObjects.size > 0) {
<pngx-clearable-badge [selected]="activeAttributeList.selectedObjects.size > 0" [number]="activeAttributeList.selectedObjects.size" (cleared)="activeAttributeList.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
}
</button>
<div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow">
<button ngbDropdownItem (click)="activeAttributeList.selectNone()" i18n>Select none</button>
<button ngbDropdownItem (click)="activeAttributeList.selectPage(true)" i18n>Select page</button>
<button ngbDropdownItem (click)="activeAttributeList.selectAll()" i18n>Select all</button>
</div>
</div>
<div class="d-none d-sm-flex flex-fill me-3">
<div class="input-group input-group-sm">
<span class="input-group-text border-0" i18n>Select:</span>
</div>
<div class="btn-group btn-group-sm flex-nowrap">
@if (activeAttributeList.selectedObjects.size > 0) {
<button class="btn btn-sm btn-outline-secondary" (click)="activeAttributeList.selectNone()">
<i-bs name="slash-circle"></i-bs>&nbsp;<ng-container i18n>None</ng-container>
</button>
}
<button class="btn btn-sm btn-outline-primary" (click)="activeAttributeList.selectPage(true)">
<i-bs name="file-earmark-check"></i-bs>&nbsp;<ng-container i18n>Page</ng-container>
</button>
<button class="btn btn-sm btn-outline-primary" (click)="activeAttributeList.selectAll()">
<i-bs name="check-all"></i-bs>&nbsp;<ng-container i18n>All</ng-container>
</button>
</div>
</div>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="activeAttributeList.setPermissions()"
[disabled]="!activeAttributeList.userCanBulkEdit(PermissionAction.Change) || activeAttributeList.selectedObjects.size === 0">
<i-bs name="person-fill-lock"></i-bs>&nbsp;<ng-container i18n>Permissions</ng-container>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" (click)="activeAttributeList.delete()"
[disabled]="!activeAttributeList.userCanBulkEdit(PermissionAction.Delete) || activeAttributeList.selectedObjects.size === 0">
<i-bs name="trash"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
</button>
<button type="button" class="btn btn-sm btn-outline-primary ms-md-5" (click)="activeAttributeList.openCreateDialog()"
*pngxIfPermissions="{ action: PermissionAction.Add, type: activeAttributeList.permissionType }">
<i-bs name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Create</ng-container>
</button>
} @else if (activeCustomFields) {
<button type="button" class="btn btn-sm btn-outline-primary" (click)="activeCustomFields.editField()"
*pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.CustomField }">
<i-bs name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Add Field</ng-container>
</button>
}
</pngx-page-header>
<ul ngbNav #nav="ngbNav" (navChange)="onNavChange($event)" [(activeId)]="activeNavID" class="nav-underline">
@for (section of visibleSections; track section.id) {
<li [ngbNavItem]="section.id">
<a ngbNavLink >
<i-bs class="me-2" [name]="section.icon"></i-bs>{{ section.label }}
</a>
</li>
}
</ul>
<div class="my-3 shadow-sm">
<ng-container
[ngComponentOutlet]="activeSection?.component"
#activeOutlet="ngComponentOutlet"
></ng-container>
</div>

View File

@@ -0,0 +1,164 @@
import { Component } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import {
ActivatedRoute,
convertToParamMap,
ParamMap,
Router,
} from '@angular/router'
import { RouterTestingModule } from '@angular/router/testing'
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subject } from 'rxjs'
import {
PermissionAction,
PermissionsService,
PermissionType,
} from 'src/app/services/permissions.service'
import { DocumentAttributesComponent } from './document-attributes.component'
@Component({
selector: 'pngx-dummy-section',
template: '',
standalone: true,
})
class DummySectionComponent {}
describe('DocumentAttributesComponent', () => {
let component: DocumentAttributesComponent
let fixture: ComponentFixture<DocumentAttributesComponent>
let router: Router
let paramMapSubject: Subject<ParamMap>
let permissionsService: PermissionsService
beforeEach(async () => {
paramMapSubject = new Subject<ParamMap>()
TestBed.configureTestingModule({
imports: [
RouterTestingModule,
NgxBootstrapIconsModule.pick(allIcons),
DocumentAttributesComponent,
DummySectionComponent,
],
providers: [
{
provide: ActivatedRoute,
useValue: {
paramMap: paramMapSubject.asObservable(),
},
},
{
provide: PermissionsService,
useValue: {
currentUserCan: jest.fn(),
},
},
],
}).compileComponents()
fixture = TestBed.createComponent(DocumentAttributesComponent)
component = fixture.componentInstance
router = TestBed.inject(Router)
permissionsService = TestBed.inject(PermissionsService)
jest.spyOn(router, 'navigate').mockResolvedValue(true)
;(component as any).sections = [
{
id: 1,
path: 'tags',
label: 'Tags',
icon: 'tags',
permissionType: PermissionType.Tag,
kind: 'attributeList',
component: DummySectionComponent,
},
{
id: 2,
path: 'customfields',
label: 'Custom fields',
icon: 'ui-radios',
permissionType: PermissionType.CustomField,
kind: 'customFields',
component: DummySectionComponent,
},
]
})
it('should navigate to default section when no section is provided', () => {
;(permissionsService.currentUserCan as jest.Mock).mockImplementation(
(action, type) => {
return action === PermissionAction.View && type === PermissionType.Tag
}
)
fixture.detectChanges()
paramMapSubject.next(convertToParamMap({}))
expect(router.navigate).toHaveBeenCalledWith(['attributes', 'tags'], {
replaceUrl: true,
})
expect(component.activeNavID).toBe(1)
})
it('should set active section from route param when valid', () => {
;(permissionsService.currentUserCan as jest.Mock).mockImplementation(
(action, type) => {
return (
action === PermissionAction.View &&
type === PermissionType.CustomField
)
}
)
fixture.detectChanges()
paramMapSubject.next(convertToParamMap({ section: 'customfields' }))
expect(component.activeNavID).toBe(2)
expect(router.navigate).not.toHaveBeenCalled()
})
it('should update active nav id when route section changes', () => {
;(permissionsService.currentUserCan as jest.Mock).mockReturnValue(true)
fixture.detectChanges()
component.activeNavID = 1
paramMapSubject.next(convertToParamMap({ section: 'customfields' }))
expect(component.activeNavID).toBe(2)
})
it('should redirect to dashboard when no sections are visible', () => {
;(permissionsService.currentUserCan as jest.Mock).mockReturnValue(false)
fixture.detectChanges()
paramMapSubject.next(convertToParamMap({}))
expect(router.navigate).toHaveBeenCalledWith(['/dashboard'], {
replaceUrl: true,
})
})
it('should navigate when a nav change occurs', () => {
;(permissionsService.currentUserCan as jest.Mock).mockImplementation(
() => true
)
fixture.detectChanges()
paramMapSubject.next(convertToParamMap({ section: 'tags' }))
component.onNavChange({ nextId: 2 } as any)
expect(router.navigate).toHaveBeenCalledWith(['attributes', 'customfields'])
})
it('should ignore nav changes for unknown sections', () => {
;(permissionsService.currentUserCan as jest.Mock).mockReturnValue(true)
fixture.detectChanges()
paramMapSubject.next(convertToParamMap({ section: 'tags' }))
component.onNavChange({ nextId: 999 } as any)
expect(router.navigate).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,256 @@
import { NgComponentOutlet } from '@angular/common'
import {
AfterViewChecked,
ChangeDetectorRef,
Component,
inject,
OnDestroy,
OnInit,
Type,
ViewChild,
} from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import {
NgbDropdownModule,
NgbNavChangeEvent,
NgbNavModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subject, takeUntil } from 'rxjs'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import {
PermissionAction,
PermissionsService,
PermissionType,
} from 'src/app/services/permissions.service'
import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { CustomFieldsComponent } from './custom-fields/custom-fields.component'
import { CorrespondentListComponent } from './management-list/correspondent-list/correspondent-list.component'
import { DocumentTypeListComponent } from './management-list/document-type-list/document-type-list.component'
import { ManagementListComponent } from './management-list/management-list.component'
import { StoragePathListComponent } from './management-list/storage-path-list/storage-path-list.component'
import { TagListComponent } from './management-list/tag-list/tag-list.component'
enum DocumentAttributesNavIDs {
Tags = 1,
Correspondents = 2,
DocumentTypes = 3,
StoragePaths = 4,
CustomFields = 5,
}
export enum DocumentAttributesSectionKind {
ManagementList = 'managementList',
CustomFields = 'customFields',
}
interface DocumentAttributesSection {
id: DocumentAttributesNavIDs
path: string
label: string
icon: string
infoLink?: string
permissionType: PermissionType
kind: DocumentAttributesSectionKind
component: Type<any>
}
@Component({
selector: 'pngx-document-attributes',
templateUrl: './document-attributes.component.html',
styleUrls: ['./document-attributes.component.scss'],
imports: [
PageHeaderComponent,
NgbNavModule,
NgbDropdownModule,
NgComponentOutlet,
NgxBootstrapIconsModule,
IfPermissionsDirective,
ClearableBadgeComponent,
],
})
export class DocumentAttributesComponent
implements OnInit, OnDestroy, AfterViewChecked
{
private readonly permissionsService = inject(PermissionsService)
private readonly activatedRoute = inject(ActivatedRoute)
private readonly router = inject(Router)
private readonly cdr = inject(ChangeDetectorRef)
private readonly unsubscribeNotifier = new Subject<void>()
protected readonly PermissionAction = PermissionAction
protected readonly PermissionType = PermissionType
readonly sections: DocumentAttributesSection[] = [
{
id: DocumentAttributesNavIDs.Tags,
path: 'tags',
label: $localize`Tags`,
icon: 'tags',
infoLink: 'usage/#terms-and-definitions',
permissionType: PermissionType.Tag,
kind: DocumentAttributesSectionKind.ManagementList,
component: TagListComponent,
},
{
id: DocumentAttributesNavIDs.Correspondents,
path: 'correspondents',
label: $localize`Correspondents`,
icon: 'person',
infoLink: 'usage/#terms-and-definitions',
permissionType: PermissionType.Correspondent,
kind: DocumentAttributesSectionKind.ManagementList,
component: CorrespondentListComponent,
},
{
id: DocumentAttributesNavIDs.DocumentTypes,
path: 'documenttypes',
label: $localize`Document types`,
icon: 'hash',
infoLink: 'usage/#terms-and-definitions',
permissionType: PermissionType.DocumentType,
kind: DocumentAttributesSectionKind.ManagementList,
component: DocumentTypeListComponent,
},
{
id: DocumentAttributesNavIDs.StoragePaths,
path: 'storagepaths',
label: $localize`Storage paths`,
icon: 'folder',
infoLink: 'usage/#terms-and-definitions',
permissionType: PermissionType.StoragePath,
kind: DocumentAttributesSectionKind.ManagementList,
component: StoragePathListComponent,
},
{
id: DocumentAttributesNavIDs.CustomFields,
path: 'customfields',
label: $localize`Custom fields`,
icon: 'ui-radios',
infoLink: 'usage/#custom-fields',
permissionType: PermissionType.CustomField,
kind: DocumentAttributesSectionKind.CustomFields,
component: CustomFieldsComponent,
},
]
@ViewChild('activeOutlet', { read: NgComponentOutlet })
private readonly activeOutlet?: NgComponentOutlet
private lastHeaderLoading: boolean
activeNavID: number = null
get visibleSections(): DocumentAttributesSection[] {
return this.sections.filter((section) =>
this.permissionsService.currentUserCan(
PermissionAction.View,
section.permissionType
)
)
}
get activeSection(): DocumentAttributesSection | null {
return (
this.visibleSections.find((section) => section.id === this.activeNavID) ??
null
)
}
get activeAttributeList(): ManagementListComponent<any> | null {
if (
this.activeSection?.kind !== DocumentAttributesSectionKind.ManagementList
)
return null
const instance = this.activeOutlet?.componentInstance
return instance instanceof ManagementListComponent ? instance : null
}
get activeCustomFields(): CustomFieldsComponent | null {
if (this.activeSection?.kind !== DocumentAttributesSectionKind.CustomFields)
return null
const instance = this.activeOutlet?.componentInstance
return instance instanceof CustomFieldsComponent ? instance : null
}
get activeTabLabel(): string {
return this.activeSection?.label ?? ''
}
get activeInfoLink(): string {
return this.activeSection?.infoLink ?? null
}
get activeHeaderLoading(): boolean {
return (
this.activeAttributeList?.loading ??
this.activeCustomFields?.loading ??
false
)
}
ngOnInit(): void {
this.activatedRoute.paramMap
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((paramMap) => {
const section = paramMap.get('section')
const navIDFromSection =
this.getNavIDForSection(section) ?? this.getDefaultNavID()
if (navIDFromSection == null) {
this.router.navigate(['/dashboard'], { replaceUrl: true })
return
}
if (this.activeNavID !== navIDFromSection) {
this.activeNavID = navIDFromSection
}
if (!section || this.getNavIDForSection(section) == null) {
this.router.navigate(
['attributes', this.getSectionForNavID(this.activeNavID)],
{ replaceUrl: true }
)
}
})
}
ngOnDestroy(): void {
this.unsubscribeNotifier.next()
this.unsubscribeNotifier.complete()
}
ngAfterViewChecked(): void {
const current = this.activeHeaderLoading
if (this.lastHeaderLoading !== current) {
this.lastHeaderLoading = current
this.cdr.detectChanges()
}
}
onNavChange(navChangeEvent: NgbNavChangeEvent): void {
const nextSection = this.getSectionForNavID(navChangeEvent.nextId)
if (!nextSection) {
return
}
this.router.navigate(['attributes', nextSection])
}
private getDefaultNavID(): DocumentAttributesNavIDs | null {
return this.visibleSections[0]?.id ?? null
}
private getNavIDForSection(section: string): DocumentAttributesNavIDs | null {
const path = section?.toLowerCase()
if (!path) return null
const found = this.visibleSections.find((s) => s.path === path)
return found?.id ?? null
}
private getSectionForNavID(navID: number): string | null {
const section = this.visibleSections.find((s) => s.id === navID)
return section?.path ?? null
}
}

View File

@@ -9,7 +9,7 @@ import { of } from 'rxjs'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SortableDirective } from 'src/app/directives/sortable.directive' import { SortableDirective } from 'src/app/directives/sortable.directive'
import { CorrespondentService } from 'src/app/services/rest/correspondent.service' import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { PageHeaderComponent } from '../../common/page-header/page-header.component' import { PageHeaderComponent } from '../../../../common/page-header/page-header.component'
import { CorrespondentListComponent } from './correspondent-list.component' import { CorrespondentListComponent } from './correspondent-list.component'
describe('CorrespondentListComponent', () => { describe('CorrespondentListComponent', () => {

View File

@@ -1,4 +1,4 @@
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common' import { NgClass, NgTemplateOutlet } from '@angular/common'
import { Component, inject } from '@angular/core' import { Component, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { RouterModule } from '@angular/router' import { RouterModule } from '@angular/router'
@@ -7,6 +7,7 @@ import {
NgbPaginationModule, NgbPaginationModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { CorrespondentEditDialogComponent } from 'src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
import { Correspondent } from 'src/app/data/correspondent' import { Correspondent } from 'src/app/data/correspondent'
import { FILTER_HAS_CORRESPONDENT_ANY } from 'src/app/data/filter-rule-type' import { FILTER_HAS_CORRESPONDENT_ANY } from 'src/app/data/filter-rule-type'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
@@ -14,21 +15,16 @@ import { SortableDirective } from 'src/app/directives/sortable.directive'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { PermissionType } from 'src/app/services/permissions.service' import { PermissionType } from 'src/app/services/permissions.service'
import { CorrespondentService } from 'src/app/services/rest/correspondent.service' import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component' import { ManagementListComponent } from '../management-list.component'
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { ManagementListComponent } from '../management-list/management-list.component'
@Component({ @Component({
selector: 'pngx-correspondent-list', selector: 'pngx-correspondent-list',
templateUrl: './../management-list/management-list.component.html', templateUrl: './../management-list.component.html',
styleUrls: ['./../management-list/management-list.component.scss'], styleUrls: ['./../management-list.component.scss'],
providers: [{ provide: CustomDatePipe }], providers: [{ provide: CustomDatePipe }],
imports: [ imports: [
SortableDirective, SortableDirective,
IfPermissionsDirective, IfPermissionsDirective,
PageHeaderComponent,
TitleCasePipe,
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
RouterModule, RouterModule,
@@ -37,11 +33,10 @@ import { ManagementListComponent } from '../management-list/management-list.comp
NgbDropdownModule, NgbDropdownModule,
NgbPaginationModule, NgbPaginationModule,
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
ClearableBadgeComponent,
], ],
}) })
export class CorrespondentListComponent extends ManagementListComponent<Correspondent> { export class CorrespondentListComponent extends ManagementListComponent<Correspondent> {
private datePipe = inject(CustomDatePipe) private readonly datePipe = inject(CustomDatePipe)
constructor() { constructor() {
super() super()

View File

@@ -9,7 +9,7 @@ import { of } from 'rxjs'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SortableDirective } from 'src/app/directives/sortable.directive' import { SortableDirective } from 'src/app/directives/sortable.directive'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service' import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { PageHeaderComponent } from '../../common/page-header/page-header.component' import { PageHeaderComponent } from '../../../../common/page-header/page-header.component'
import { DocumentTypeListComponent } from './document-type-list.component' import { DocumentTypeListComponent } from './document-type-list.component'
describe('DocumentTypeListComponent', () => { describe('DocumentTypeListComponent', () => {

View File

@@ -1,4 +1,4 @@
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common' import { NgClass, NgTemplateOutlet } from '@angular/common'
import { Component, inject } from '@angular/core' import { Component, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { RouterModule } from '@angular/router' import { RouterModule } from '@angular/router'
@@ -7,25 +7,21 @@ import {
NgbPaginationModule, NgbPaginationModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { DocumentTypeEditDialogComponent } from 'src/app/components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
import { DocumentType } from 'src/app/data/document-type' import { DocumentType } from 'src/app/data/document-type'
import { FILTER_HAS_DOCUMENT_TYPE_ANY } from 'src/app/data/filter-rule-type' import { FILTER_HAS_DOCUMENT_TYPE_ANY } from 'src/app/data/filter-rule-type'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SortableDirective } from 'src/app/directives/sortable.directive' import { SortableDirective } from 'src/app/directives/sortable.directive'
import { PermissionType } from 'src/app/services/permissions.service' import { PermissionType } from 'src/app/services/permissions.service'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service' import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component' import { ManagementListComponent } from '../management-list.component'
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { ManagementListComponent } from '../management-list/management-list.component'
@Component({ @Component({
selector: 'pngx-document-type-list', selector: 'pngx-document-type-list',
templateUrl: './../management-list/management-list.component.html', templateUrl: './../management-list.component.html',
styleUrls: ['./../management-list/management-list.component.scss'], styleUrls: ['./../management-list.component.scss'],
imports: [ imports: [
SortableDirective, SortableDirective,
PageHeaderComponent,
TitleCasePipe,
IfPermissionsDirective, IfPermissionsDirective,
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
@@ -35,7 +31,6 @@ import { ManagementListComponent } from '../management-list/management-list.comp
NgbDropdownModule, NgbDropdownModule,
NgbPaginationModule, NgbPaginationModule,
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
ClearableBadgeComponent,
], ],
}) })
export class DocumentTypeListComponent extends ManagementListComponent<DocumentType> { export class DocumentTypeListComponent extends ManagementListComponent<DocumentType> {

View File

@@ -1,50 +1,3 @@
<pngx-page-header title="{{ typeNamePlural | titlecase }}" info="View, add, edit and delete {{ typeNamePlural }}." infoLink="usage/#terms-and-definitions" [loading]="loading">
<div ngbDropdown class="btn-group flex-fill d-sm-none">
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
<i-bs name="text-indent-left"></i-bs>
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Select</ng-container></div>
@if (selectedObjects.size > 0) {
<pngx-clearable-badge [selected]="selectedObjects.size > 0" [number]="selectedObjects.size" (cleared)="selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
}
</button>
<div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow">
<button ngbDropdownItem (click)="selectNone()" i18n>Select none</button>
<button ngbDropdownItem (click)="selectPage(true)" i18n>Select page</button>
<button ngbDropdownItem (click)="selectAll()" i18n>Select all</button>
</div>
</div>
<div class="d-none d-sm-flex flex-fill me-3">
<div class="input-group input-group-sm">
<span class="input-group-text border-0" i18n>Select:</span>
</div>
<div class="btn-group btn-group-sm flex-nowrap">
@if (selectedObjects.size > 0) {
<button class="btn btn-sm btn-outline-secondary" (click)="selectNone()">
<i-bs name="slash-circle"></i-bs>&nbsp;<ng-container i18n>None</ng-container>
</button>
}
<button class="btn btn-sm btn-outline-primary" (click)="selectPage(true)">
<i-bs name="file-earmark-check"></i-bs>&nbsp;<ng-container i18n>Page</ng-container>
</button>
<button class="btn btn-sm btn-outline-primary" (click)="selectAll()">
<i-bs name="check-all"></i-bs>&nbsp;<ng-container i18n>All</ng-container>
</button>
</div>
</div>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="setPermissions()" [disabled]="!userCanBulkEdit(PermissionAction.Change) || selectedObjects.size === 0">
<i-bs name="person-fill-lock"></i-bs>&nbsp;<ng-container i18n>Permissions</ng-container>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" (click)="delete()" [disabled]="!userCanBulkEdit(PermissionAction.Delete) || selectedObjects.size === 0">
<i-bs name="trash"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
</button>
<button type="button" class="btn btn-sm btn-outline-primary ms-md-5" (click)="openCreateDialog()" *pngxIfPermissions="{ action: PermissionAction.Add, type: permissionType }">
<i-bs name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Create</ng-container>
</button>
</pngx-page-header>
<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">
@@ -76,19 +29,19 @@
<table class="table table-striped align-middle shadow-sm mb-0"> <table class="table table-striped align-middle shadow-sm mb-0">
<thead> <thead>
<tr> <tr>
<th scope="col"> <th>
<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)]="togggleAll" [disabled]="data.length === 0" (change)="selectPage($event.target.checked); $event.stopPropagation();"> <input type="checkbox" class="form-check-input" id="all-objects" [(ngModel)]="togggleAll" [disabled]="data.length === 0" (change)="$event.target.checked ? selectPage() : clearSelection(); $event.stopPropagation();">
<label class="form-check-label" for="all-objects"></label> <label class="form-check-label" for="all-objects"></label>
</div> </div>
</th> </th>
<th scope="col" class="fw-normal" pngxSortable="name" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Name</th> <th class="fw-normal" pngxSortable="name" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Name</th>
<th scope="col" class="fw-normal d-none d-sm-table-cell" pngxSortable="matching_algorithm" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Matching</th> <th class="fw-normal d-none d-sm-table-cell" pngxSortable="matching_algorithm" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Matching</th>
<th scope="col" class="fw-normal" pngxSortable="document_count" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Document count</th> <th class="fw-normal" pngxSortable="document_count" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Document count</th>
@for (column of extraColumns; track column) { @for (column of extraColumns; track column) {
<th scope="col" class="fw-normal" [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }" pngxSortable="{{column.key}}" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)">{{column.name}}</th> <th class="fw-normal" [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }" pngxSortable="{{column.key}}" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)">{{column.name}}</th>
} }
<th scope="col" class="fw-normal" i18n>Actions</th> <th class="fw-normal" i18n>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -131,16 +84,16 @@
<label class="form-check-label" for="{{typeName}}{{object.id}}"></label> <label class="form-check-label" for="{{typeName}}{{object.id}}"></label>
</div> </div>
</td> </td>
<td scope="row" class="name-cell" style="--depth: {{depth}}"> <td class="name-cell" style="--depth: {{depth}}">
@if (depth > 0) { @if (depth > 0) {
<div class="indicator"></div> <div class="indicator"></div>
} }
<button class="btn btn-link ms-0 ps-0 text-start" (click)="userCanEdit(object) ? openEditDialog(object) : null; $event.stopPropagation()">{{ object.name }}</button> <button class="btn btn-link ms-0 ps-0 text-start" (click)="userCanEdit(object) ? openEditDialog(object) : null; $event.stopPropagation()">{{ object.name }}</button>
</td> </td>
<td scope="row" class="d-none d-sm-table-cell">{{ getMatching(object) }}</td> <td class="d-none d-sm-table-cell">{{ getMatching(object) }}</td>
<td scope="row">{{ getDocumentCount(object) }}</td> <td>{{ getDocumentCount(object) }}</td>
@for (column of extraColumns; track column) { @for (column of extraColumns; track column) {
<td scope="row" [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }"> <td [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }">
@if (column.badgeFn) { @if (column.badgeFn) {
<span <span
class="badge" class="badge"
@@ -156,7 +109,7 @@
} }
</td> </td>
} }
<td scope="row"> <td>
<div class="btn-toolbar gap-2"> <div class="btn-toolbar gap-2">
<div class="btn-group d-block d-sm-none"> <div class="btn-group d-block d-sm-none">
<div ngbDropdown container="body" class="d-inline-block"> <div ngbDropdown container="body" class="d-inline-block">

View File

@@ -44,12 +44,12 @@ import { BulkEditObjectOperation } from 'src/app/services/rest/abstract-name-fil
import { TagService } from 'src/app/services/rest/tag.service' import { TagService } from 'src/app/services/rest/tag.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from '../../../common/confirm-dialog/confirm-dialog.component'
import { EditDialogComponent } from '../../common/edit-dialog/edit-dialog.component' import { EditDialogComponent } from '../../../common/edit-dialog/edit-dialog.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component' import { PageHeaderComponent } from '../../../common/page-header/page-header.component'
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component' import { PermissionsDialogComponent } from '../../../common/permissions-dialog/permissions-dialog.component'
import { TagListComponent } from '../tag-list/tag-list.component'
import { ManagementListComponent } from './management-list.component' import { ManagementListComponent } from './management-list.component'
import { TagListComponent } from './tag-list/tag-list.component'
const tags: Tag[] = [ const tags: Tag[] = [
{ {
@@ -304,12 +304,12 @@ describe('ManagementListComponent', () => {
}) })
it('selectPage should select current page items or clear selection', () => { it('selectPage should select current page items or clear selection', () => {
component.selectPage(true) component.selectPage()
expect(component.selectedObjects).toEqual(new Set(tags.map((t) => t.id))) expect(component.selectedObjects).toEqual(new Set(tags.map((t) => t.id)))
expect(component.togggleAll).toBe(true) expect(component.togggleAll).toBe(true)
component.togggleAll = true component.togggleAll = true
component.selectPage(false) component.clearSelection()
expect(component.selectedObjects.size).toBe(0) expect(component.selectedObjects.size).toBe(0)
expect(component.togggleAll).toBe(false) expect(component.togggleAll).toBe(false)
}) })

View File

@@ -16,6 +16,10 @@ import {
takeUntil, takeUntil,
tap, tap,
} from 'rxjs/operators' } from 'rxjs/operators'
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
import { EditDialogMode } from 'src/app/components/common/edit-dialog/edit-dialog.component'
import { PermissionsDialogComponent } from 'src/app/components/common/permissions-dialog/permissions-dialog.component'
import { LoadingComponentWithPermissions } from 'src/app/components/loading-component/loading.component'
import { import {
MATCH_AUTO, MATCH_AUTO,
MATCH_NONE, MATCH_NONE,
@@ -40,10 +44,6 @@ import {
} from 'src/app/services/rest/abstract-name-filter-service' } from 'src/app/services/rest/abstract-name-filter-service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
export interface ManagementListColumn { export interface ManagementListColumn {
key: string key: string
@@ -69,13 +69,14 @@ export abstract class ManagementListComponent<T extends MatchingModel>
implements OnInit, OnDestroy implements OnInit, OnDestroy
{ {
protected service: AbstractNameFilterService<T> protected service: AbstractNameFilterService<T>
private modalService: NgbModal = inject(NgbModal) private readonly modalService: NgbModal = inject(NgbModal)
protected editDialogComponent: any protected editDialogComponent: any
private toastService: ToastService = inject(ToastService) private readonly toastService: ToastService = inject(ToastService)
private documentListViewService: DocumentListViewService = inject( private readonly documentListViewService: DocumentListViewService = inject(
DocumentListViewService DocumentListViewService
) )
private permissionsService: PermissionsService = inject(PermissionsService) private readonly permissionsService: PermissionsService =
inject(PermissionsService)
protected filterRuleType: number protected filterRuleType: number
public typeName: string public typeName: string
public typeNamePlural: string public typeNamePlural: string
@@ -196,7 +197,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
} }
openCreateDialog() { openCreateDialog() {
var activeModal = this.modalService.open(this.editDialogComponent, { const activeModal = this.modalService.open(this.editDialogComponent, {
backdrop: 'static', backdrop: 'static',
}) })
activeModal.componentInstance.dialogMode = EditDialogMode.CREATE activeModal.componentInstance.dialogMode = EditDialogMode.CREATE
@@ -215,7 +216,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
} }
openEditDialog(object: T) { openEditDialog(object: T) {
var activeModal = this.modalService.open(this.editDialogComponent, { const activeModal = this.modalService.open(this.editDialogComponent, {
backdrop: 'static', backdrop: 'static',
}) })
activeModal.componentInstance.object = object activeModal.componentInstance.object = object
@@ -243,7 +244,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
} }
openDeleteDialog(object: T) { openDeleteDialog(object: T) {
var activeModal = this.modalService.open(ConfirmDialogComponent, { const activeModal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static', backdrop: 'static',
}) })
activeModal.componentInstance.title = $localize`Confirm delete` activeModal.componentInstance.title = $localize`Confirm delete`
@@ -343,13 +344,9 @@ export abstract class ManagementListComponent<T extends MatchingModel>
this.clearSelection() this.clearSelection()
} }
selectPage(select: boolean) { selectPage() {
if (select) {
this.selectedObjects = new Set(this.getSelectableIDs(this.data)) this.selectedObjects = new Set(this.getSelectableIDs(this.data))
this.togggleAll = this.areAllPageItemsSelected() this.togggleAll = this.areAllPageItemsSelected()
} else {
this.clearSelection()
}
} }
selectAll() { selectAll() {

View File

@@ -10,7 +10,7 @@ import { StoragePath } from 'src/app/data/storage-path'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SortableDirective } from 'src/app/directives/sortable.directive' import { SortableDirective } from 'src/app/directives/sortable.directive'
import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { PageHeaderComponent } from '../../common/page-header/page-header.component' import { PageHeaderComponent } from '../../../../common/page-header/page-header.component'
import { StoragePathListComponent } from './storage-path-list.component' import { StoragePathListComponent } from './storage-path-list.component'
describe('StoragePathListComponent', () => { describe('StoragePathListComponent', () => {

View File

@@ -1,4 +1,4 @@
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common' import { NgClass, NgTemplateOutlet } from '@angular/common'
import { Component, inject } from '@angular/core' import { Component, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { RouterModule } from '@angular/router' import { RouterModule } from '@angular/router'
@@ -7,25 +7,21 @@ import {
NgbPaginationModule, NgbPaginationModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { StoragePathEditDialogComponent } from 'src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
import { FILTER_HAS_STORAGE_PATH_ANY } from 'src/app/data/filter-rule-type' import { FILTER_HAS_STORAGE_PATH_ANY } from 'src/app/data/filter-rule-type'
import { StoragePath } from 'src/app/data/storage-path' import { StoragePath } from 'src/app/data/storage-path'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SortableDirective } from 'src/app/directives/sortable.directive' import { SortableDirective } from 'src/app/directives/sortable.directive'
import { PermissionType } from 'src/app/services/permissions.service' import { PermissionType } from 'src/app/services/permissions.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component' import { ManagementListComponent } from '../management-list.component'
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { ManagementListComponent } from '../management-list/management-list.component'
@Component({ @Component({
selector: 'pngx-storage-path-list', selector: 'pngx-storage-path-list',
templateUrl: './../management-list/management-list.component.html', templateUrl: './../management-list.component.html',
styleUrls: ['./../management-list/management-list.component.scss'], styleUrls: ['./../management-list.component.scss'],
imports: [ imports: [
SortableDirective, SortableDirective,
PageHeaderComponent,
TitleCasePipe,
IfPermissionsDirective, IfPermissionsDirective,
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
@@ -35,7 +31,6 @@ import { ManagementListComponent } from '../management-list/management-list.comp
NgbDropdownModule, NgbDropdownModule,
NgbPaginationModule, NgbPaginationModule,
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
ClearableBadgeComponent,
], ],
}) })
export class StoragePathListComponent extends ManagementListComponent<StoragePath> { export class StoragePathListComponent extends ManagementListComponent<StoragePath> {

View File

@@ -9,7 +9,7 @@ import { of } from 'rxjs'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SortableDirective } from 'src/app/directives/sortable.directive' import { SortableDirective } from 'src/app/directives/sortable.directive'
import { TagService } from 'src/app/services/rest/tag.service' import { TagService } from 'src/app/services/rest/tag.service'
import { PageHeaderComponent } from '../../common/page-header/page-header.component' import { PageHeaderComponent } from '../../../../common/page-header/page-header.component'
import { TagListComponent } from './tag-list.component' import { TagListComponent } from './tag-list.component'
describe('TagListComponent', () => { describe('TagListComponent', () => {
@@ -138,12 +138,12 @@ describe('TagListComponent', () => {
} }
component.data = [parent as any] component.data = [parent as any]
component.selectPage(true) component.selectPage()
expect(component.selectedObjects.has(10)).toBe(true) expect(component.selectedObjects.has(10)).toBe(true)
expect(component.selectedObjects.has(11)).toBe(true) expect(component.selectedObjects.has(11)).toBe(true)
component.selectPage(false) component.clearSelection()
expect(component.selectedObjects.size).toBe(0) expect(component.selectedObjects.size).toBe(0)
}) })
}) })

View File

@@ -1,4 +1,4 @@
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common' import { NgClass, NgTemplateOutlet } from '@angular/common'
import { Component, inject } from '@angular/core' import { Component, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { RouterModule } from '@angular/router' import { RouterModule } from '@angular/router'
@@ -7,25 +7,21 @@ import {
NgbPaginationModule, NgbPaginationModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { TagEditDialogComponent } from 'src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type' import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
import { Tag } from 'src/app/data/tag' import { Tag } from 'src/app/data/tag'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SortableDirective } from 'src/app/directives/sortable.directive' import { SortableDirective } from 'src/app/directives/sortable.directive'
import { PermissionType } from 'src/app/services/permissions.service' import { PermissionType } from 'src/app/services/permissions.service'
import { TagService } from 'src/app/services/rest/tag.service' import { TagService } from 'src/app/services/rest/tag.service'
import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component' import { ManagementListComponent } from '../management-list.component'
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { ManagementListComponent } from '../management-list/management-list.component'
@Component({ @Component({
selector: 'pngx-tag-list', selector: 'pngx-tag-list',
templateUrl: './../management-list/management-list.component.html', templateUrl: './../management-list.component.html',
styleUrls: ['./../management-list/management-list.component.scss'], styleUrls: ['./../management-list.component.scss'],
imports: [ imports: [
SortableDirective, SortableDirective,
PageHeaderComponent,
TitleCasePipe,
IfPermissionsDirective, IfPermissionsDirective,
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
@@ -35,7 +31,6 @@ import { ManagementListComponent } from '../management-list/management-list.comp
NgbDropdownModule, NgbDropdownModule,
NgbPaginationModule, NgbPaginationModule,
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
ClearableBadgeComponent,
], ],
}) })
export class TagListComponent extends ManagementListComponent<Tag> { export class TagListComponent extends ManagementListComponent<Tag> {

View File

@@ -1,5 +1,5 @@
import { PdfEditorEditMode } from '../components/common/pdf-editor/pdf-editor-edit-mode' import { PdfEditorEditMode } from '../components/common/pdf-editor/pdf-editor-edit-mode'
import { ZoomSetting } from '../components/document-detail/zoom-setting' import { PdfZoomScale } from '../components/common/pdf-viewer/pdf-viewer.types'
import { User } from './user' import { User } from './user'
export interface UiSettings { export interface UiSettings {
@@ -19,6 +19,10 @@ export enum GlobalSearchType {
TITLE_CONTENT = 'title-content', TITLE_CONTENT = 'title-content',
} }
export enum CollapsibleSection {
ATTRIBUTES = 'attributes',
}
export const PAPERLESS_GREEN_HEX = '#17541f' export const PAPERLESS_GREEN_HEX = '#17541f'
export const SETTINGS_KEYS = { export const SETTINGS_KEYS = {
@@ -51,6 +55,8 @@ export const SETTINGS_KEYS = {
NOTES_ENABLED: 'general-settings:notes-enabled', NOTES_ENABLED: 'general-settings:notes-enabled',
AUDITLOG_ENABLED: 'general-settings:auditlog-enabled', AUDITLOG_ENABLED: 'general-settings:auditlog-enabled',
SLIM_SIDEBAR: 'general-settings:slim-sidebar', SLIM_SIDEBAR: 'general-settings:slim-sidebar',
ATTRIBUTES_SECTIONS_COLLAPSED:
'general-settings:attributes-sections-collapsed',
UPDATE_CHECKING_ENABLED: 'general-settings:update-checking:enabled', UPDATE_CHECKING_ENABLED: 'general-settings:update-checking:enabled',
UPDATE_CHECKING_BACKEND_SETTING: UPDATE_CHECKING_BACKEND_SETTING:
'general-settings:update-checking:backend-setting', 'general-settings:update-checking:backend-setting',
@@ -112,6 +118,11 @@ export const SETTINGS: UiSetting[] = [
type: 'boolean', type: 'boolean',
default: false, default: false,
}, },
{
key: SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
type: 'array',
default: [],
},
{ {
key: SETTINGS_KEYS.DOCUMENT_LIST_SIZE, key: SETTINGS_KEYS.DOCUMENT_LIST_SIZE,
type: 'number', type: 'number',
@@ -310,7 +321,7 @@ export const SETTINGS: UiSetting[] = [
{ {
key: SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING, key: SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING,
type: 'string', type: 'string',
default: ZoomSetting.PageWidth, default: PdfZoomScale.PageWidth,
}, },
{ {
key: SETTINGS_KEYS.AI_ENABLED, key: SETTINGS_KEYS.AI_ENABLED,

View File

@@ -4,6 +4,7 @@ import { TestBed } from '@angular/core/testing'
import { RouterTestingModule } from '@angular/router/testing' import { RouterTestingModule } from '@angular/router/testing'
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
import { routes } from '../app-routing.module' import { routes } from '../app-routing.module'
import { ConfirmDialogComponent } from '../components/common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from '../components/common/confirm-dialog/confirm-dialog.component'
import { DocumentListComponent } from '../components/document-list/document-list.component' import { DocumentListComponent } from '../components/document-list/document-list.component'
@@ -30,6 +31,7 @@ describe('DirtySavedViewGuard', () => {
DocumentListComponent, DocumentListComponent,
provideHttpClient(withInterceptorsFromDi()), provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(), provideHttpClientTesting(),
provideUiTour(),
], ],
}) })

View File

@@ -1,10 +1,10 @@
import { TestBed } from '@angular/core/testing' import { TestBed } from '@angular/core/testing'
import { ActivatedRoute, RouterState } from '@angular/router' import { ActivatedRoute, RouterState } from '@angular/router'
import { TourService } from 'ngx-ui-tour-ng-bootstrap' import { provideUiTour, TourService } from 'ngx-ui-tour-ng-bootstrap'
import { import {
PermissionAction, PermissionAction,
PermissionType,
PermissionsService, PermissionsService,
PermissionType,
} from '../services/permissions.service' } from '../services/permissions.service'
import { ToastService } from '../services/toast.service' import { ToastService } from '../services/toast.service'
import { PermissionsGuard } from './permissions.guard' import { PermissionsGuard } from './permissions.guard'
@@ -45,6 +45,7 @@ describe('PermissionsGuard', () => {
}, },
TourService, TourService,
ToastService, ToastService,
provideUiTour(),
], ],
}) })
@@ -95,4 +96,52 @@ describe('PermissionsGuard', () => {
expect(canActivate).toHaveProperty('root') // returns UrlTree expect(canActivate).toHaveProperty('root') // returns UrlTree
expect(toastSpy).toHaveBeenCalled() expect(toastSpy).toHaveBeenCalled()
}) })
it('should activate when any required permission is granted', () => {
jest
.spyOn(permissionsService, 'currentUserCan')
.mockImplementation((action, type) => {
return type === PermissionType.Tag
})
const canActivate = guard.canActivate(
{
data: {
requiredPermissionAny: [
{ action: PermissionAction.View, type: PermissionType.Tag },
{
action: PermissionAction.View,
type: PermissionType.DocumentType,
},
],
},
} as any,
routerState.snapshot
)
expect(canActivate).toBeTruthy()
})
it('should not activate when no required permission is granted', () => {
jest
.spyOn(permissionsService, 'currentUserCan')
.mockImplementation(() => false)
const canActivate = guard.canActivate(
{
data: {
requiredPermissionAny: [
{ action: PermissionAction.View, type: PermissionType.Tag },
{
action: PermissionAction.View,
type: PermissionType.DocumentType,
},
],
},
} as any,
routerState.snapshot
)
expect(canActivate).toHaveProperty('root')
})
}) })

View File

@@ -20,12 +20,20 @@ export class PermissionsGuard {
route: ActivatedRouteSnapshot, route: ActivatedRouteSnapshot,
state: RouterStateSnapshot state: RouterStateSnapshot
): boolean | UrlTree { ): boolean | UrlTree {
const requiredPermissionAny: { action: any; type: any }[] =
route.data.requiredPermissionAny
if ( if (
(route.data.requireAdmin && !this.permissionsService.isAdmin()) || (route.data.requireAdmin && !this.permissionsService.isAdmin()) ||
(route.data.requiredPermission && (route.data.requiredPermission &&
!this.permissionsService.currentUserCan( !this.permissionsService.currentUserCan(
route.data.requiredPermission.action, route.data.requiredPermission.action,
route.data.requiredPermission.type route.data.requiredPermission.type
)) ||
(Array.isArray(requiredPermissionAny) &&
requiredPermissionAny.length > 0 &&
!requiredPermissionAny.some((p) =>
this.permissionsService.currentUserCan(p.action, p.type)
)) ))
) { ) {
// Check if tour is running 1 = TourState.ON // Check if tour is running 1 = TourState.ON

View File

@@ -70,4 +70,26 @@ describe('LocalizedDateParserFormatter', () => {
dateStr = dateParserFormatter.format(dateStruct) dateStr = dateParserFormatter.format(dateStruct)
expect(dateStr).toEqual('04.05.2023') expect(dateStr).toEqual('04.05.2023')
}) })
it('should handle years when current year % 100 < 50', () => {
jest.useFakeTimers()
jest.setSystemTime(new Date(2026, 5, 15))
let val = dateParserFormatter.parse('5/4/26')
expect(val).toEqual({ day: 4, month: 5, year: 2026 })
val = dateParserFormatter.parse('5/4/75')
expect(val).toEqual({ day: 4, month: 5, year: 2075 })
val = dateParserFormatter.parse('5/4/99')
expect(val).toEqual({ day: 4, month: 5, year: 1999 })
jest.useRealTimers()
})
it('should handle years when current year % 100 >= 50', () => {
jest.useFakeTimers()
jest.setSystemTime(new Date(2076, 5, 15))
const val = dateParserFormatter.parse('5/4/00')
expect(val).toEqual({ day: 4, month: 5, year: 2100 })
jest.useRealTimers()
})
}) })

View File

@@ -106,15 +106,25 @@ export class LocalizedDateParserFormatter extends NgbDateParserFormatter {
value = this.preformatDateInput(value) value = this.preformatDateInput(value)
let match = this.getDateParseRegex().exec(value) let match = this.getDateParseRegex().exec(value)
if (match) { if (match) {
const currentYear = new Date().getFullYear()
const currentCentury = currentYear - (currentYear % 100)
let year = +match.groups.year
if (year < 100) {
let fourDigitYear = currentCentury + year
// Mimic python-dateutil: keep result within -50/+49 years of current year
if (fourDigitYear > currentYear + 49) {
fourDigitYear -= 100
} else if (fourDigitYear <= currentYear - 50) {
fourDigitYear += 100
}
year = fourDigitYear
}
let dateStruct = { let dateStruct = {
day: +match.groups.day, day: +match.groups.day,
month: +match.groups.month, month: +match.groups.month,
year: +match.groups.year, year,
}
if (dateStruct.year <= new Date().getFullYear() - 2000) {
dateStruct.year += 2000
} else if (dateStruct.year < 100) {
dateStruct.year += 1900
} }
return dateStruct return dateStruct
} else { } else {

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