mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-04 17:05:05 +00:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
143b225019 | ||
|
|
a07e1cea75 | ||
|
|
99f2ef9b89 | ||
|
|
705b45e507 | ||
|
|
0a392a2efa | ||
|
|
ac381d2344 | ||
|
|
16568439c0 | ||
|
|
3f4d2a4b5a | ||
|
|
8da770c3d7 | ||
|
|
ae9529551d | ||
|
|
d48663e9eb | ||
|
|
a28a6fe23b | ||
|
|
e75a9d8c55 | ||
|
|
93ec102156 | ||
|
|
1806141983 | ||
|
|
593a65c157 | ||
|
|
a3851c157a | ||
|
|
853b57c71d | ||
|
|
1e5afe0968 | ||
|
|
78ea120e0e | ||
|
|
6b2a5e1a2e | ||
|
|
bb88453a5f | ||
|
|
135f9f6251 | ||
|
|
1c0fbeb6f3 | ||
|
|
1ba1f2b9c2 | ||
|
|
07f1a356f8 | ||
|
|
8d41d31bd7 | ||
|
|
351892bbab | ||
|
|
5d6ea11828 | ||
|
|
c5765a50a1 | ||
|
|
c2a9532b8f | ||
|
|
713c857a08 | ||
|
|
912c6eb52e | ||
|
|
73ef14f37a |
@@ -38,7 +38,6 @@ body:
|
||||
label: Installation method
|
||||
options:
|
||||
- Docker - official image
|
||||
- Docker - linuxserver.io image
|
||||
- Bare metal
|
||||
- Other (please describe above)
|
||||
- type: textarea
|
||||
|
||||
@@ -87,10 +87,8 @@ body:
|
||||
label: Installation method
|
||||
options:
|
||||
- Docker - official image
|
||||
- Docker - linuxserver.io image
|
||||
- Bare metal
|
||||
- Other (please describe above)
|
||||
description: Note there are significant differences from the official image and linuxserver.io, please check if your issue is specific to the third-party image.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ RUN set -eux \
|
||||
# Purpose: Installs s6-overlay and rootfs
|
||||
# Comments:
|
||||
# - Don't leave anything extra in here either
|
||||
FROM ghcr.io/astral-sh/uv:0.12.5-python3.14-trixie-slim AS s6-overlay-base
|
||||
FROM ghcr.io/astral-sh/uv:0.12.9-python3.14-trixie-slim AS s6-overlay-base
|
||||
|
||||
WORKDIR /usr/src/s6
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# correct networking for the tests
|
||||
services:
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.34
|
||||
image: docker.io/gotenberg/gotenberg:8.36
|
||||
hostname: gotenberg
|
||||
container_name: gotenberg
|
||||
network_mode: host
|
||||
@@ -24,7 +24,7 @@ services:
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
greenmail:
|
||||
image: docker.io/greenmail/standalone:2.1.11
|
||||
image: docker.io/greenmail/standalone:2.1.13
|
||||
hostname: greenmail
|
||||
container_name: greenmail
|
||||
environment:
|
||||
@@ -35,7 +35,7 @@ services:
|
||||
- "3143:3143" # IMAP
|
||||
restart: unless-stopped
|
||||
nginx:
|
||||
image: docker.io/nginx:1.31.3-alpine
|
||||
image: docker.io/nginx:1.31.5-alpine
|
||||
hostname: nginx
|
||||
container_name: nginx
|
||||
ports:
|
||||
|
||||
@@ -72,7 +72,7 @@ services:
|
||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.34
|
||||
image: docker.io/gotenberg/gotenberg:8.36
|
||||
restart: unless-stopped
|
||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||
# want to allow external content like tracking pixels or even javascript.
|
||||
|
||||
@@ -67,7 +67,7 @@ services:
|
||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.34
|
||||
image: docker.io/gotenberg/gotenberg:8.36
|
||||
restart: unless-stopped
|
||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||
# want to allow external content like tracking pixels or even javascript.
|
||||
|
||||
@@ -56,7 +56,7 @@ services:
|
||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.34
|
||||
image: docker.io/gotenberg/gotenberg:8.36
|
||||
restart: unless-stopped
|
||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||
# want to allow external content like tracking pixels or even javascript.
|
||||
|
||||
@@ -61,7 +61,7 @@ def replace_with_symlinks(
|
||||
total_duplicates = 0
|
||||
space_saved = 0
|
||||
|
||||
for file_list in duplicate_groups.values():
|
||||
for file_hash, file_list in duplicate_groups.items():
|
||||
# Keep the first file as the original, replace others with symlinks
|
||||
original_file = file_list[0]
|
||||
duplicates = file_list[1:]
|
||||
|
||||
@@ -1,5 +1,69 @@
|
||||
# Changelog
|
||||
|
||||
## paperless-ngx 3.1.3
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: use the header loading indicator on tasks page [@shamoon](https://github.com/shamoon) ([#13949](https://github.com/paperless-ngx/paperless-ngx/pull/13949))
|
||||
- Fix: fix load sidebar size animating [@shamoon](https://github.com/shamoon) ([#13947](https://github.com/paperless-ngx/paperless-ngx/pull/13947))
|
||||
- Fix: wrap long words without spaces in dropdowns [@shamoon](https://github.com/shamoon) ([#13945](https://github.com/paperless-ngx/paperless-ngx/pull/13945))
|
||||
- Fix: tweak tool calling localization prompt [@shamoon](https://github.com/shamoon) ([#13943](https://github.com/paperless-ngx/paperless-ngx/pull/13943))
|
||||
- Fix: skip vector store document id filter for unrestricted chat users [@stumpylog](https://github.com/stumpylog) ([#13937](https://github.com/paperless-ngx/paperless-ngx/pull/13937))
|
||||
- Fix: adopt the request stream when pinning an outbound host [@ThomasSteinbach](https://github.com/ThomasSteinbach) ([#13927](https://github.com/paperless-ngx/paperless-ngx/pull/13927))
|
||||
- Fix: ensure apply ai suggestions always runs after document created [@shamoon](https://github.com/shamoon) ([#13940](https://github.com/paperless-ngx/paperless-ngx/pull/13940))
|
||||
- Fix: Handle failures when enqueuing files for consumption [@stumpylog](https://github.com/stumpylog) ([#13935](https://github.com/paperless-ngx/paperless-ngx/pull/13935))
|
||||
- Fix: Handle Celery mail task chord errors [@stumpylog](https://github.com/stumpylog) ([#13936](https://github.com/paperless-ngx/paperless-ngx/pull/13936))
|
||||
- Fix/chore: refactor some signal-backed conversion technical debt [@shamoon](https://github.com/shamoon) ([#13902](https://github.com/paperless-ngx/paperless-ngx/pull/13902))
|
||||
- Fix: fix slim sidebar saved view dragging appearance [@shamoon](https://github.com/shamoon) ([#13906](https://github.com/paperless-ngx/paperless-ngx/pull/13906))
|
||||
- Fix: use signal-backed queries input in CF dropdown to reflect changes immediately under zoneless [@shamoon](https://github.com/shamoon) ([#13901](https://github.com/paperless-ngx/paperless-ngx/pull/13901))
|
||||
- Fix: use root doc metadata for filename generation [@shamoon](https://github.com/shamoon) ([#13893](https://github.com/paperless-ngx/paperless-ngx/pull/13893))
|
||||
- Fix: some css cleanup [@shamoon](https://github.com/shamoon) ([#13891](https://github.com/paperless-ngx/paperless-ngx/pull/13891))
|
||||
|
||||
### Dependencies
|
||||
|
||||
<details>
|
||||
<summary>7 changes</summary>
|
||||
|
||||
- Chore(deps): Bump the uv group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13958](https://github.com/paperless-ngx/paperless-ngx/pull/13958))
|
||||
- docker-compose(deps): bump nginx from 1.31.3-alpine to 1.31.5-alpine in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#13909](https://github.com/paperless-ngx/paperless-ngx/pull/13909))
|
||||
- docker-compose(deps): Bump greenmail/standalone from 2.1.11 to 2.1.13 in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#13907](https://github.com/paperless-ngx/paperless-ngx/pull/13907))
|
||||
- Chore(deps-dev): Bump postcss-selector-parser from 6.1.2 to 6.1.4 in /src/paperless\_mail/templates in the npm\_and\_yarn group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13905](https://github.com/paperless-ngx/paperless-ngx/pull/13905))
|
||||
- Chore(deps): Bump nltk from 3.10.0 to 3.10.3 in the data-nlp-search group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13917](https://github.com/paperless-ngx/paperless-ngx/pull/13917))
|
||||
- Chore(deps): Bump the frontend-angular-dependencies group across 1 directory with 14 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13915](https://github.com/paperless-ngx/paperless-ngx/pull/13915))
|
||||
- Chore(deps): Bump djangorestframework from 3.17.1 to 3.17.2 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13904](https://github.com/paperless-ngx/paperless-ngx/pull/13904))
|
||||
|
||||
</details>
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>22 changes</summary>
|
||||
|
||||
- Chore(deps): Bump the uv group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13958](https://github.com/paperless-ngx/paperless-ngx/pull/13958))
|
||||
- Chore(deps-dev): Bump postcss-selector-parser from 6.1.2 to 6.1.4 in /src/paperless\_mail/templates in the npm\_and\_yarn group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13905](https://github.com/paperless-ngx/paperless-ngx/pull/13905))
|
||||
- Chore(deps): Bump nltk from 3.10.0 to 3.10.3 in the data-nlp-search group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13917](https://github.com/paperless-ngx/paperless-ngx/pull/13917))
|
||||
- Fix: use the header loading indicator on tasks page [@shamoon](https://github.com/shamoon) ([#13949](https://github.com/paperless-ngx/paperless-ngx/pull/13949))
|
||||
- Chore(deps): Bump the frontend-angular-dependencies group across 1 directory with 14 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13915](https://github.com/paperless-ngx/paperless-ngx/pull/13915))
|
||||
- Chore(deps): Bump djangorestframework from 3.17.1 to 3.17.2 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13904](https://github.com/paperless-ngx/paperless-ngx/pull/13904))
|
||||
- Fix: fix load sidebar size animating [@shamoon](https://github.com/shamoon) ([#13947](https://github.com/paperless-ngx/paperless-ngx/pull/13947))
|
||||
- Fix: wrap long words without spaces in dropdowns [@shamoon](https://github.com/shamoon) ([#13945](https://github.com/paperless-ngx/paperless-ngx/pull/13945))
|
||||
- Fix: tweak tool calling localization prompt [@shamoon](https://github.com/shamoon) ([#13943](https://github.com/paperless-ngx/paperless-ngx/pull/13943))
|
||||
- Fix: skip vector store document id filter for unrestricted chat users [@stumpylog](https://github.com/stumpylog) ([#13937](https://github.com/paperless-ngx/paperless-ngx/pull/13937))
|
||||
- Fix: adopt the request stream when pinning an outbound host [@ThomasSteinbach](https://github.com/ThomasSteinbach) ([#13927](https://github.com/paperless-ngx/paperless-ngx/pull/13927))
|
||||
- Fix: ensure apply ai suggestions always runs after document created [@shamoon](https://github.com/shamoon) ([#13940](https://github.com/paperless-ngx/paperless-ngx/pull/13940))
|
||||
- Fix: Handle failures when enqueuing files for consumption [@stumpylog](https://github.com/stumpylog) ([#13935](https://github.com/paperless-ngx/paperless-ngx/pull/13935))
|
||||
- Fix: Handle Celery mail task chord errors [@stumpylog](https://github.com/stumpylog) ([#13936](https://github.com/paperless-ngx/paperless-ngx/pull/13936))
|
||||
- Fix/chore: refactor some signal-backed conversion technical debt [@shamoon](https://github.com/shamoon) ([#13902](https://github.com/paperless-ngx/paperless-ngx/pull/13902))
|
||||
- Security: validate remote OCR endpoint [@stumpylog](https://github.com/stumpylog) ([#13897](https://github.com/paperless-ngx/paperless-ngx/pull/13897))
|
||||
- Fix: fix slim sidebar saved view dragging appearance [@shamoon](https://github.com/shamoon) ([#13906](https://github.com/paperless-ngx/paperless-ngx/pull/13906))
|
||||
- Security: Minor additional hardening [@stumpylog](https://github.com/stumpylog) ([#13898](https://github.com/paperless-ngx/paperless-ngx/pull/13898))
|
||||
- Chore: consolidate pickle hmac signing [@shamoon](https://github.com/shamoon) ([#13899](https://github.com/paperless-ngx/paperless-ngx/pull/13899))
|
||||
- Fix: use signal-backed queries input in CF dropdown to reflect changes immediately under zoneless [@shamoon](https://github.com/shamoon) ([#13901](https://github.com/paperless-ngx/paperless-ngx/pull/13901))
|
||||
- Fix: use root doc metadata for filename generation [@shamoon](https://github.com/shamoon) ([#13893](https://github.com/paperless-ngx/paperless-ngx/pull/13893))
|
||||
- Fix: some css cleanup [@shamoon](https://github.com/shamoon) ([#13891](https://github.com/paperless-ngx/paperless-ngx/pull/13891))
|
||||
|
||||
</details>
|
||||
|
||||
## paperless-ngx 3.1.2
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
+48
-101
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "paperless-ngx"
|
||||
version = "3.1.2"
|
||||
version = "3.1.3"
|
||||
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -186,121 +186,68 @@ line-ending = "lf"
|
||||
# https://docs.astral.sh/ruff/rules/
|
||||
select = [ "E4", "E7", "E9", "F" ]
|
||||
extend-select = [
|
||||
"ASYNC", # https://docs.astral.sh/ruff/rules/#flake8-async-async
|
||||
"B002", # https://docs.astral.sh/ruff/rules/#flake8-bugbear-b
|
||||
"B003",
|
||||
"B004",
|
||||
"B005",
|
||||
"B006",
|
||||
"B008",
|
||||
"B009",
|
||||
"B010",
|
||||
"B012",
|
||||
"B013",
|
||||
"B014",
|
||||
"B015",
|
||||
"B016",
|
||||
"B017",
|
||||
"B018",
|
||||
"B019",
|
||||
"B020",
|
||||
"B021",
|
||||
"B022",
|
||||
"B023",
|
||||
"B025",
|
||||
"B026",
|
||||
"B029",
|
||||
"B030",
|
||||
"B031",
|
||||
"B032",
|
||||
"B033",
|
||||
"B035",
|
||||
"B039",
|
||||
"C4", # https://docs.astral.sh/ruff/rules/#flake8-comprehensions-c4
|
||||
"COM", # https://docs.astral.sh/ruff/rules/#flake8-commas-com
|
||||
"D419", # https://docs.astral.sh/ruff/rules/#pydocstyle-d
|
||||
"DJ", # https://docs.astral.sh/ruff/rules/#flake8-django-dj
|
||||
"DTZ", # https://docs.astral.sh/ruff/rules/#flake8-datetimez-dtz
|
||||
"EXE", # https://docs.astral.sh/ruff/rules/#flake8-executable-exe
|
||||
"FA", # https://docs.astral.sh/ruff/rules/#flake8-future-annotations-fa
|
||||
"FBT", # https://docs.astral.sh/ruff/rules/#flake8-boolean-trap-fbt
|
||||
"FLY", # https://docs.astral.sh/ruff/rules/#flynt-fly
|
||||
"FURB", # https://docs.astral.sh/ruff/rules/#refurb-furb
|
||||
"G", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g
|
||||
"I", # https://docs.astral.sh/ruff/rules/#isort-i
|
||||
"ICN", # https://docs.astral.sh/ruff/rules/#flake8-import-conventions-icn
|
||||
"INP", # https://docs.astral.sh/ruff/rules/#flake8-no-pep420-inp
|
||||
"INT", # https://docs.astral.sh/ruff/rules/#flake8-gettext-int
|
||||
"ISC", # https://docs.astral.sh/ruff/rules/#flake8-implicit-str-concat-isc
|
||||
"LOG", # https://docs.astral.sh/ruff/rules/#flake8-logging-log
|
||||
"N999", # https://docs.astral.sh/ruff/rules/#pep8-naming-n
|
||||
"PERF101", # https://docs.astral.sh/ruff/rules/#perflint-perf
|
||||
"PERF102",
|
||||
"PERF402",
|
||||
"PGH005", # https://docs.astral.sh/ruff/rules/#pygrep-hooks-pgh
|
||||
"PIE", # https://docs.astral.sh/ruff/rules/#flake8-pie-pie
|
||||
"PLC", # https://docs.astral.sh/ruff/rules/#pylint-pl
|
||||
"PLE", # https://docs.astral.sh/ruff/rules/#error-ple
|
||||
"PLR0124", # https://docs.astral.sh/ruff/rules/#refactor-plr
|
||||
"PLR0133",
|
||||
"PLR0206",
|
||||
"PLR0402",
|
||||
"PLR1704",
|
||||
"PLR1708",
|
||||
"PLR1711",
|
||||
"PLR1716",
|
||||
"PLR1722",
|
||||
"PLR1730",
|
||||
"PLR1733",
|
||||
"PLR1736",
|
||||
"PLR2044",
|
||||
"PLW", # https://docs.astral.sh/ruff/rules/#warning-plw
|
||||
"PT010", # https://docs.astral.sh/ruff/rules/#flake8-pytest-style-pt
|
||||
"PT014",
|
||||
"PT020",
|
||||
"PT025",
|
||||
"PT026",
|
||||
"PT031",
|
||||
"PTH", # https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth
|
||||
"Q", # https://docs.astral.sh/ruff/rules/#flake8-quotes-q
|
||||
"RSE", # https://docs.astral.sh/ruff/rules/#flake8-raise-rse
|
||||
"RUF", # https://docs.astral.sh/ruff/rules/#ruff-specific-rules-ruf
|
||||
"S102", # https://docs.astral.sh/ruff/rules/#flake8-bandit-s
|
||||
"S110",
|
||||
"S112",
|
||||
"S113",
|
||||
"SIM", # https://docs.astral.sh/ruff/rules/#flake8-simplify-sim
|
||||
"T100", # https://docs.astral.sh/ruff/rules/#flake8-debugger-t10
|
||||
"T20", # https://docs.astral.sh/ruff/rules/#flake8-print-t20
|
||||
"TC", # https://docs.astral.sh/ruff/rules/#flake8-type-checking-tc
|
||||
"TID", # https://docs.astral.sh/ruff/rules/#flake8-tidy-imports-tid
|
||||
"TRY002", # https://docs.astral.sh/ruff/rules/#tryceratops-try
|
||||
"TRY004",
|
||||
"TRY201",
|
||||
"TRY203",
|
||||
"TRY401",
|
||||
"UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up
|
||||
"W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
|
||||
"YTT", # https://docs.astral.sh/ruff/rules/#flake8-2020-ytt
|
||||
"COM", # https://docs.astral.sh/ruff/rules/#flake8-commas-com
|
||||
"DJ", # https://docs.astral.sh/ruff/rules/#flake8-django-dj
|
||||
"EXE", # https://docs.astral.sh/ruff/rules/#flake8-executable-exe
|
||||
"FBT", # https://docs.astral.sh/ruff/rules/#flake8-boolean-trap-fbt
|
||||
"FLY", # https://docs.astral.sh/ruff/rules/#flynt-fly
|
||||
"G201", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g
|
||||
"I", # https://docs.astral.sh/ruff/rules/#isort-i
|
||||
"ICN", # https://docs.astral.sh/ruff/rules/#flake8-import-conventions-icn
|
||||
"INP", # https://docs.astral.sh/ruff/rules/#flake8-no-pep420-inp
|
||||
"ISC", # https://docs.astral.sh/ruff/rules/#flake8-implicit-str-concat-isc
|
||||
"PIE", # https://docs.astral.sh/ruff/rules/#flake8-pie-pie
|
||||
"PLC", # https://docs.astral.sh/ruff/rules/#pylint-pl
|
||||
"PLE", # https://docs.astral.sh/ruff/rules/#pylint-pl
|
||||
"PTH", # https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth
|
||||
"Q", # https://docs.astral.sh/ruff/rules/#flake8-quotes-q
|
||||
"RSE", # https://docs.astral.sh/ruff/rules/#flake8-raise-rse
|
||||
"RUF", # https://docs.astral.sh/ruff/rules/#ruff-specific-rules-ruf
|
||||
"SIM", # https://docs.astral.sh/ruff/rules/#flake8-simplify-sim
|
||||
"T20", # https://docs.astral.sh/ruff/rules/#flake8-print-t20
|
||||
"TC", # https://docs.astral.sh/ruff/rules/#flake8-type-checking-tc
|
||||
"TID", # https://docs.astral.sh/ruff/rules/#flake8-tidy-imports-tid
|
||||
"UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up
|
||||
"W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
|
||||
]
|
||||
ignore = [
|
||||
"DJ001",
|
||||
"PLC0415",
|
||||
"RUF012",
|
||||
"SIM105",
|
||||
"G004", # Logging statement uses f-string - good to do, but a large diff
|
||||
]
|
||||
# Migrations
|
||||
per-file-ignores."*/migrations/*.py" = []
|
||||
per-file-ignores."*/migrations/*.py" = [
|
||||
"E501",
|
||||
"SIM",
|
||||
"T201",
|
||||
]
|
||||
# Testing
|
||||
per-file-ignores."*/tests/*.py" = [
|
||||
"DTZ",
|
||||
"E501",
|
||||
"SIM117",
|
||||
]
|
||||
per-file-ignores.".github/scripts/*.py" = [
|
||||
"E501",
|
||||
"INP001",
|
||||
"SIM117",
|
||||
]
|
||||
# Docker specific
|
||||
per-file-ignores."docker/rootfs/usr/local/bin/wait-for-redis.py" = [
|
||||
"INP001",
|
||||
"T201",
|
||||
]
|
||||
per-file-ignores."docker/wait-for-redis.py" = [
|
||||
"INP001",
|
||||
"T201",
|
||||
]
|
||||
per-file-ignores."src/documents/models.py" = [
|
||||
"SIM115",
|
||||
]
|
||||
isort.force-single-line = true
|
||||
|
||||
[tool.codespell]
|
||||
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish"
|
||||
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish,NIN,nin"
|
||||
skip = """\
|
||||
src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/paperless/tests/samples\
|
||||
/mail/*,src/documents/tests/samples/*,*.po,*.json\
|
||||
|
||||
+201
-205
File diff suppressed because it is too large
Load Diff
+14
-14
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paperless-ngx-ui",
|
||||
"version": "3.1.2",
|
||||
"version": "3.1.3",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"ng": "ng",
|
||||
@@ -15,14 +15,14 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^22.1.1",
|
||||
"@angular/common": "~22.1.1",
|
||||
"@angular/compiler": "~22.1.1",
|
||||
"@angular/core": "~22.1.1",
|
||||
"@angular/forms": "~22.1.1",
|
||||
"@angular/localize": "~22.1.1",
|
||||
"@angular/platform-browser": "~22.1.1",
|
||||
"@angular/router": "~22.1.1",
|
||||
"@angular/cdk": "^22.1.4",
|
||||
"@angular/common": "~22.1.3",
|
||||
"@angular/compiler": "~22.1.3",
|
||||
"@angular/core": "~22.1.3",
|
||||
"@angular/forms": "~22.1.3",
|
||||
"@angular/localize": "~22.1.3",
|
||||
"@angular/platform-browser": "~22.1.3",
|
||||
"@angular/router": "~22.1.3",
|
||||
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
||||
"@ng-select/ng-select": "~23.6.0",
|
||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||
@@ -45,16 +45,16 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-builders/jest": "^22.0.1",
|
||||
"@angular-devkit/core": "^22.1.3",
|
||||
"@angular-devkit/schematics": "^22.1.3",
|
||||
"@angular-devkit/core": "^22.1.6",
|
||||
"@angular-devkit/schematics": "^22.1.6",
|
||||
"@angular-eslint/builder": "22.1.0",
|
||||
"@angular-eslint/eslint-plugin": "22.1.0",
|
||||
"@angular-eslint/eslint-plugin-template": "22.1.0",
|
||||
"@angular-eslint/schematics": "22.1.0",
|
||||
"@angular-eslint/template-parser": "22.1.0",
|
||||
"@angular/build": "22.1.3",
|
||||
"@angular/cli": "22.1.3",
|
||||
"@angular/compiler-cli": "~22.1.1",
|
||||
"@angular/build": "22.1.6",
|
||||
"@angular/cli": "22.1.6",
|
||||
"@angular/compiler-cli": "~22.1.3",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^26.2.0",
|
||||
|
||||
Generated
+437
-629
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
||||
i18n-title
|
||||
info="Tasks shows detailed information about document consumption and system tasks."
|
||||
i18n-info
|
||||
[loading]="loading() && pagedTasks().length === 0"
|
||||
>
|
||||
<div class="btn-toolbar col col-md-auto align-items-center gap-2">
|
||||
<button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedTasks.size === 0">
|
||||
@@ -21,11 +22,6 @@
|
||||
</div>
|
||||
</pngx-page-header>
|
||||
|
||||
@if (loading() && pagedTasks().length === 0) {
|
||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||
<div class="visually-hidden" i18n>Loading...</div>
|
||||
}
|
||||
|
||||
<div class="task-controls mb-3 gap-3 btn-toolbar align-items-center" role="toolbar">
|
||||
<div class="task-view-scope btn-group btn-group-sm" role="group">
|
||||
<input
|
||||
@@ -285,6 +281,6 @@
|
||||
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks: tasksForSection(section), section: section}"></ng-container>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
} @else if (!loading() || pagedTasks().length > 0) {
|
||||
<div class="alert alert-secondary fst-italic" i18n>No tasks match the current filters.</div>
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ export class TrashComponent
|
||||
private modalService = inject(NgbModal)
|
||||
private settingsService = inject(SettingsService)
|
||||
private router = inject(Router)
|
||||
private readonly emptyTrashDelaySetting =
|
||||
this.settingsService.getSignal<number>(SETTINGS_KEYS.EMPTY_TRASH_DELAY)
|
||||
|
||||
readonly documentsInTrash = signal<Document[]>([])
|
||||
readonly selectedDocuments = signal<Set<number>>(new Set())
|
||||
@@ -200,8 +202,7 @@ export class TrashComponent
|
||||
}
|
||||
|
||||
getDaysRemaining(document: Document): number {
|
||||
this.settingsService.trackChanges()
|
||||
const delay = this.settingsService.get(SETTINGS_KEYS.EMPTY_TRASH_DELAY)
|
||||
const delay = this.emptyTrashDelaySetting()
|
||||
const diff = new Date().getTime() - new Date(document.deleted_at).getTime()
|
||||
const days = Math.ceil(diff / (1000 * 3600 * 24))
|
||||
return delay - days
|
||||
|
||||
@@ -34,9 +34,7 @@
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
&.expanded {
|
||||
--pngx-sidebar-width: var(--pngx-sidebar-expanded-width);
|
||||
}
|
||||
--pngx-sidebar-width: var(--pngx-sidebar-expanded-width);
|
||||
}
|
||||
}
|
||||
@media (max-width: 767.98px) {
|
||||
@@ -113,6 +111,13 @@ main {
|
||||
}
|
||||
}
|
||||
|
||||
// only animate when the user toggles slim mode
|
||||
.sidebar:not(.animating),
|
||||
.sidebar:not(.animating) ~ main,
|
||||
.sidebar:not(.animating) .sidebar-slim-toggler {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.sidebar.slim {
|
||||
max-width: 55px;
|
||||
|
||||
@@ -123,8 +128,6 @@ main {
|
||||
}
|
||||
|
||||
.sidebar.slim:not(.animating) {
|
||||
transition: none;
|
||||
|
||||
li.nav-item span,
|
||||
.sidebar-heading span {
|
||||
display: none;
|
||||
@@ -144,10 +147,6 @@ main {
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar.slim:not(.animating) ~ main.col-slim {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.sidebar.animating {
|
||||
li.nav-item span,
|
||||
.sidebar-heading span {
|
||||
@@ -196,6 +195,26 @@ main {
|
||||
--bs-popover-body-padding-y: .5rem;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.sidebar-sticky > ul,
|
||||
.sidebar-sticky > .nav-group {
|
||||
animation: sidebar-nav-in .3s cubic-bezier(.22, .61, .36, 1) backwards;
|
||||
}
|
||||
|
||||
@for $i from 2 through 5 {
|
||||
.sidebar-sticky > :nth-child(#{$i}) {
|
||||
animation-delay: #{($i - 1) * 0.04}s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sidebar-nav-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-sticky {
|
||||
position: relative;
|
||||
top: 0;
|
||||
|
||||
@@ -193,6 +193,23 @@ describe('AppFrameComponent', () => {
|
||||
expect(savedViewSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update reinitialized signal-backed settings without manual change detection', async () => {
|
||||
settingsService.initializeSettings().subscribe()
|
||||
httpTestingController
|
||||
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
|
||||
.flush({
|
||||
settings: { app_title: 'Reactive title' },
|
||||
user: {},
|
||||
permissions: [],
|
||||
})
|
||||
|
||||
await fixture.whenStable()
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.brand-title').textContent
|
||||
).toBe('Reactive title')
|
||||
})
|
||||
|
||||
it('should check for update if enabled', () => {
|
||||
const updateCheckSpy = jest.spyOn(remoteVersionService, 'checkForUpdates')
|
||||
updateCheckSpy.mockImplementation(() => {
|
||||
|
||||
@@ -98,6 +98,29 @@ export class AppFrameComponent
|
||||
readonly isMenuCollapsed = signal(true)
|
||||
readonly slimSidebarAnimating = signal(false)
|
||||
readonly mobileSearchHidden = signal(false)
|
||||
private readonly versionSetting = this.settingsService.getSignal<string>(
|
||||
SETTINGS_KEYS.VERSION
|
||||
)
|
||||
private readonly appTitleSetting = this.settingsService.getSignal<string>(
|
||||
SETTINGS_KEYS.APP_TITLE
|
||||
)
|
||||
private readonly appLogoSetting = this.settingsService.getSignal<string>(
|
||||
SETTINGS_KEYS.APP_LOGO
|
||||
)
|
||||
private readonly slimSidebarSetting = this.settingsService.getSignal<boolean>(
|
||||
SETTINGS_KEYS.SLIM_SIDEBAR
|
||||
)
|
||||
private readonly attributesSectionsCollapsedSetting =
|
||||
this.settingsService.getSignal<CollapsibleSection[]>(
|
||||
SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED
|
||||
)
|
||||
private readonly aiEnabledSetting = this.settingsService.getSignal<boolean>(
|
||||
SETTINGS_KEYS.AI_ENABLED
|
||||
)
|
||||
private readonly sidebarViewsShowCountSetting =
|
||||
this.settingsService.getSignal<boolean>(
|
||||
SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT
|
||||
)
|
||||
private lastScrollY: number = 0
|
||||
|
||||
constructor() {
|
||||
@@ -191,33 +214,23 @@ export class AppFrameComponent
|
||||
}
|
||||
|
||||
get versionString(): string {
|
||||
this.settingsService.trackChanges()
|
||||
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
|
||||
return `${environment.appTitle} v${this.versionSetting()}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
|
||||
}
|
||||
|
||||
get appTitle(): string {
|
||||
this.settingsService.trackChanges()
|
||||
return (
|
||||
this.settingsService.get(SETTINGS_KEYS.APP_TITLE) || environment.appTitle
|
||||
)
|
||||
return this.appTitleSetting() || environment.appTitle
|
||||
}
|
||||
|
||||
get customAppTitle(): string {
|
||||
this.settingsService.trackChanges()
|
||||
return this.settingsService.get(SETTINGS_KEYS.APP_TITLE)
|
||||
return this.appTitleSetting()
|
||||
}
|
||||
|
||||
get hasCustomBranding(): boolean {
|
||||
this.settingsService.trackChanges()
|
||||
return !!(
|
||||
this.settingsService.get(SETTINGS_KEYS.APP_TITLE)?.length ||
|
||||
this.settingsService.get(SETTINGS_KEYS.APP_LOGO)?.length
|
||||
)
|
||||
return !!(this.appTitleSetting()?.length || this.appLogoSetting()?.length)
|
||||
}
|
||||
|
||||
get customAppLogo(): string {
|
||||
this.settingsService.trackChanges()
|
||||
const logo = this.settingsService.get(SETTINGS_KEYS.APP_LOGO)
|
||||
const logo = this.appLogoSetting()
|
||||
return logo?.length
|
||||
? environment.apiBaseUrl.replace(/\/api\/$/, logo)
|
||||
: null
|
||||
@@ -262,8 +275,7 @@ export class AppFrameComponent
|
||||
}
|
||||
|
||||
get slimSidebarEnabled(): boolean {
|
||||
this.settingsService.trackChanges()
|
||||
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
|
||||
return this.slimSidebarSetting()
|
||||
}
|
||||
|
||||
set slimSidebarEnabled(enabled: boolean) {
|
||||
@@ -286,10 +298,9 @@ export class AppFrameComponent
|
||||
}
|
||||
|
||||
get attributesSectionsCollapsed(): boolean {
|
||||
this.settingsService.trackChanges()
|
||||
return this.settingsService
|
||||
.get(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED)
|
||||
?.includes(CollapsibleSection.ATTRIBUTES)
|
||||
return this.attributesSectionsCollapsedSetting()?.includes(
|
||||
CollapsibleSection.ATTRIBUTES
|
||||
)
|
||||
}
|
||||
|
||||
set attributesSectionsCollapsed(collapsed: boolean) {
|
||||
@@ -312,8 +323,7 @@ export class AppFrameComponent
|
||||
}
|
||||
|
||||
get aiEnabled(): boolean {
|
||||
this.settingsService.trackChanges()
|
||||
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
|
||||
return this.aiEnabledSetting()
|
||||
}
|
||||
|
||||
@HostListener('window:resize')
|
||||
@@ -480,9 +490,8 @@ export class AppFrameComponent
|
||||
}
|
||||
|
||||
get showSidebarCounts(): boolean {
|
||||
this.settingsService.trackChanges()
|
||||
return (
|
||||
this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT) &&
|
||||
this.sidebarViewsShowCountSetting() &&
|
||||
!this.settingsService.organizingSidebarSavedViews()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -81,6 +81,10 @@ export class GlobalSearchComponent implements OnInit {
|
||||
private hotkeyService = inject(HotKeyService)
|
||||
private settingsService = inject(SettingsService)
|
||||
private locationStrategy = inject(LocationStrategy)
|
||||
private readonly searchFullTypeSetting =
|
||||
this.settingsService.getSignal<GlobalSearchType>(
|
||||
SETTINGS_KEYS.SEARCH_FULL_TYPE
|
||||
)
|
||||
|
||||
public DataType = DataType
|
||||
readonly query = signal<string>(null)
|
||||
@@ -97,11 +101,7 @@ export class GlobalSearchComponent implements OnInit {
|
||||
@ViewChildren('secondaryButton') secondaryButtons: QueryList<ElementRef>
|
||||
|
||||
get useAdvancedForFullSearch(): boolean {
|
||||
this.settingsService.trackChanges()
|
||||
return (
|
||||
this.settingsService.get(SETTINGS_KEYS.SEARCH_FULL_TYPE) ===
|
||||
GlobalSearchType.ADVANCED
|
||||
)
|
||||
return this.searchFullTypeSetting() === GlobalSearchType.ADVANCED
|
||||
}
|
||||
|
||||
constructor() {
|
||||
|
||||
+19
-13
@@ -196,6 +196,16 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
function setActionSettings({
|
||||
email = true,
|
||||
remoteOcr = true,
|
||||
ai = true,
|
||||
} = {}) {
|
||||
settingsService.set(SETTINGS_KEYS.EMAIL_ENABLED, email)
|
||||
settingsService.set(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED, remoteOcr)
|
||||
settingsService.set(SETTINGS_KEYS.AI_ENABLED, ai)
|
||||
}
|
||||
|
||||
it('should support create and edit modes, support adding triggers and actions on new workflow', () => {
|
||||
component.dialogMode.set(EditDialogMode.CREATE)
|
||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||
@@ -218,7 +228,7 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
})
|
||||
|
||||
it('should return source options, type options, type name, schedule date field options', () => {
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||
setActionSettings()
|
||||
component.ngOnInit()
|
||||
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS)
|
||||
expect(component.triggerTypeOptions).toEqual(WORKFLOW_TYPE_OPTIONS)
|
||||
@@ -242,7 +252,7 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
)
|
||||
|
||||
// Email, remote OCR and AI all disabled
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue(false)
|
||||
setActionSettings({ email: false, remoteOcr: false, ai: false })
|
||||
component.ngOnInit()
|
||||
expect(component.actionTypeOptions).toEqual(
|
||||
WORKFLOW_ACTION_OPTIONS.filter(
|
||||
@@ -255,7 +265,7 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
})
|
||||
|
||||
it('should offer remote OCR only for consumption workflows', () => {
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||
setActionSettings()
|
||||
|
||||
// A consumption trigger makes the action reachable
|
||||
component.object = {
|
||||
@@ -285,7 +295,7 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
})
|
||||
|
||||
it('should offer remote OCR on a trigger added to a new workflow', () => {
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||
setActionSettings()
|
||||
component.ngOnInit()
|
||||
|
||||
// Nothing for the action to apply to yet
|
||||
@@ -311,7 +321,7 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
})
|
||||
|
||||
it('should keep remote OCR listed when an action already uses it', () => {
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||
setActionSettings()
|
||||
|
||||
// Otherwise changing the trigger would silently blank the selection
|
||||
component.object = {
|
||||
@@ -329,9 +339,7 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
})
|
||||
|
||||
it('should not offer remote OCR when no engine is configured', () => {
|
||||
jest
|
||||
.spyOn(settingsService, 'get')
|
||||
.mockImplementation((key) => key !== SETTINGS_KEYS.REMOTE_OCR_CONFIGURED)
|
||||
setActionSettings({ remoteOcr: false })
|
||||
|
||||
component.object = {
|
||||
name: 'Workflow 1',
|
||||
@@ -348,7 +356,7 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
})
|
||||
|
||||
it('should offer apply AI suggestions unless every trigger is consumption', () => {
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||
setActionSettings()
|
||||
|
||||
// Consumption runs before the document has been parsed, so there would be
|
||||
// no content to make suggestions from
|
||||
@@ -382,7 +390,7 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
})
|
||||
|
||||
it('should keep apply AI suggestions listed when an action already uses it', () => {
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||
setActionSettings()
|
||||
|
||||
// Otherwise changing the trigger would silently blank the selection
|
||||
component.object = {
|
||||
@@ -400,9 +408,7 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
})
|
||||
|
||||
it('should not offer apply AI suggestions when AI is disabled', () => {
|
||||
jest
|
||||
.spyOn(settingsService, 'get')
|
||||
.mockImplementation((key) => key !== SETTINGS_KEYS.AI_ENABLED)
|
||||
setActionSettings({ ai: false })
|
||||
|
||||
component.object = {
|
||||
name: 'Workflow 1',
|
||||
|
||||
+10
-4
@@ -537,6 +537,13 @@ export class WorkflowEditDialogComponent
|
||||
readonly dateCustomFields = computed(() =>
|
||||
this.customFields()?.filter((f) => f.data_type === CustomFieldDataType.Date)
|
||||
)
|
||||
private readonly emailEnabledSetting =
|
||||
this.settingsService.getSignal<boolean>(SETTINGS_KEYS.EMAIL_ENABLED)
|
||||
private readonly remoteOcrConfiguredSetting =
|
||||
this.settingsService.getSignal<boolean>(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED)
|
||||
private readonly aiEnabledSetting = this.settingsService.getSignal<boolean>(
|
||||
SETTINGS_KEYS.AI_ENABLED
|
||||
)
|
||||
|
||||
expandedItem: number = null
|
||||
|
||||
@@ -589,7 +596,7 @@ export class WorkflowEditDialogComponent
|
||||
private getAllowedActionTypes() {
|
||||
let allowed = WORKFLOW_ACTION_OPTIONS
|
||||
|
||||
if (!this.settingsService.get(SETTINGS_KEYS.EMAIL_ENABLED)) {
|
||||
if (!this.emailEnabledSetting()) {
|
||||
allowed = allowed.filter((a) => a.id !== WorkflowActionType.Email)
|
||||
}
|
||||
|
||||
@@ -597,7 +604,7 @@ export class WorkflowEditDialogComponent
|
||||
// offered for workflows that run at consumption.
|
||||
const formWorkflow: Workflow = this.objectForm?.value
|
||||
const remoteOcrUsable =
|
||||
this.settingsService.get(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED) &&
|
||||
this.remoteOcrConfiguredSetting() &&
|
||||
(formWorkflow?.triggers?.some(
|
||||
(trigger) => trigger.type === WorkflowTriggerType.Consumption
|
||||
) ||
|
||||
@@ -612,7 +619,7 @@ export class WorkflowEditDialogComponent
|
||||
// once every trigger is consumption, so it stays offered on a workflow
|
||||
// that has no triggers yet.
|
||||
const aiSuggestionsUsable =
|
||||
this.settingsService.get(SETTINGS_KEYS.AI_ENABLED) &&
|
||||
this.aiEnabledSetting() &&
|
||||
(!formWorkflow?.triggers?.length ||
|
||||
formWorkflow.triggers.some(
|
||||
(trigger) => trigger.type !== WorkflowTriggerType.Consumption
|
||||
@@ -1362,7 +1369,6 @@ export class WorkflowEditDialogComponent
|
||||
}
|
||||
|
||||
get actionTypeOptions() {
|
||||
this.settingsService.trackChanges()
|
||||
// Computed on read rather than cached
|
||||
return this.getAllowedActionTypes()
|
||||
}
|
||||
|
||||
+45
-5
@@ -703,6 +703,40 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
expect(selectionModel.getSelectedItems()).toEqual([other])
|
||||
})
|
||||
|
||||
it('re-selects ancestors when a child is re-selected while editing', () => {
|
||||
// https://github.com/paperless-ngx/paperless-ngx/issues/13970
|
||||
const inbox: Tag = { id: 200, name: 'Inbox' }
|
||||
const parent: Tag = { id: 201, name: 'Parent Tag' }
|
||||
const child: Tag = { id: 202, name: 'Child Tag', parent: parent.id }
|
||||
|
||||
selectionModel.editing = true
|
||||
selectionModel.items = [inbox, parent, child]
|
||||
selectionModel.init(
|
||||
new Map([
|
||||
[inbox.id, ToggleableItemState.Selected],
|
||||
[parent.id, ToggleableItemState.Selected],
|
||||
[child.id, ToggleableItemState.Selected],
|
||||
])
|
||||
)
|
||||
|
||||
// deselecting the parent also deselects the child
|
||||
selectionModel.toggle(parent.id, false)
|
||||
expect(selectionModel.getSelectedItems()).toEqual([inbox])
|
||||
|
||||
// re-selecting the child brings its parent back, so nothing is changed
|
||||
selectionModel.toggle(child.id, false)
|
||||
expect(
|
||||
selectionModel
|
||||
.getSelectedItems()
|
||||
.map((item) => item.id)
|
||||
.sort((a, b) => a - b)
|
||||
).toEqual([inbox.id, parent.id, child.id])
|
||||
expect(selectionModel.diff()).toEqual({
|
||||
itemsToAdd: [],
|
||||
itemsToRemove: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('un-excluding a parent clears excluded descendants', () => {
|
||||
const root: Tag = { id: 110, name: 'Root Tag' }
|
||||
const child: Tag = { id: 111, name: 'Child Tag', parent: root.id }
|
||||
@@ -739,7 +773,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
const apple: Tag = { id: 55, name: 'Apple' }
|
||||
const zebra: Tag = { id: 56, name: 'Zebra' }
|
||||
|
||||
selectionModel.documentCountSortingEnabled = true
|
||||
selectionModel.editing = true
|
||||
selectionModel.items = [apple, zebra]
|
||||
expect(selectionModel.items.map((item) => item?.id ?? null)).toEqual([
|
||||
null,
|
||||
@@ -839,7 +873,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
selectionModel.items = [memoRoot]
|
||||
selectionModel.documentCounts = [{ id: memoRoot.id, document_count: 9 }]
|
||||
|
||||
const getRootDocCount = (selectionModel as any).createRootDocCounter()
|
||||
const getRootDocCount = (selectionModel as any).createRootDocCounter(
|
||||
selectionModel.items
|
||||
)
|
||||
|
||||
expect(getRootDocCount(memoRoot.id)).toEqual(9)
|
||||
selectionModel.documentCounts = []
|
||||
@@ -855,7 +891,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
selectionModel.items = [rootWithoutSelection]
|
||||
selectionModel.documentCounts = []
|
||||
|
||||
const getRootDocCount = (selectionModel as any).createRootDocCounter()
|
||||
const getRootDocCount = (selectionModel as any).createRootDocCounter(
|
||||
selectionModel.items
|
||||
)
|
||||
|
||||
expect(getRootDocCount(rootWithoutSelection.id)).toEqual(4)
|
||||
})
|
||||
@@ -865,7 +903,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
selectionModel.items = [rootWithoutCounts]
|
||||
selectionModel.documentCounts = []
|
||||
|
||||
const getRootDocCount = (selectionModel as any).createRootDocCounter()
|
||||
const getRootDocCount = (selectionModel as any).createRootDocCounter(
|
||||
selectionModel.items
|
||||
)
|
||||
|
||||
expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0)
|
||||
})
|
||||
@@ -966,7 +1006,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
component.selectionModel['temporarySelectionStates'].set(id, state)
|
||||
const changedSpy = jest.spyOn(component.selectionModel.changed, 'next')
|
||||
component.selectionModel.exclude(id)
|
||||
expect(component.selectionModel.temporaryLogicalOperator).toBe(
|
||||
expect(component.selectionModel.temporaryLogicalOperator()).toBe(
|
||||
LogicalOperator.And
|
||||
)
|
||||
expect(component.selectionModel['temporarySelectionStates'].get(id)).toBe(
|
||||
|
||||
+147
-108
@@ -64,43 +64,56 @@ export class FilterableDropdownSelectionModel {
|
||||
|
||||
manyToOne = false
|
||||
singleSelect = false
|
||||
private _logicalOperator: LogicalOperator = LogicalOperator.And
|
||||
temporaryLogicalOperator: LogicalOperator = this._logicalOperator
|
||||
private _intersection: Intersection = Intersection.Include
|
||||
temporaryIntersection: Intersection = this._intersection
|
||||
|
||||
private _documentCounts: SelectionDataItem[] = []
|
||||
public documentCountSortingEnabled = false
|
||||
private readonly _logicalOperator = signal(LogicalOperator.And)
|
||||
readonly temporaryLogicalOperator = signal(LogicalOperator.And)
|
||||
private readonly _intersection = signal(Intersection.Include)
|
||||
readonly temporaryIntersection = signal(Intersection.Include)
|
||||
private readonly _documentCounts = signal<SelectionDataItem[]>([])
|
||||
private readonly _items = signal<MatchingModel[]>([])
|
||||
private readonly _selectionStates = signal(
|
||||
new Map<number, ToggleableItemState>()
|
||||
)
|
||||
private readonly _temporarySelectionStates = signal(
|
||||
new Map<number, ToggleableItemState>()
|
||||
)
|
||||
|
||||
public editing = false
|
||||
|
||||
private get selectionStates(): ReadonlyMap<number, ToggleableItemState> {
|
||||
return this._selectionStates()
|
||||
}
|
||||
|
||||
private get temporarySelectionStates(): ReadonlyMap<
|
||||
number,
|
||||
ToggleableItemState
|
||||
> {
|
||||
return this._temporarySelectionStates()
|
||||
}
|
||||
|
||||
public set documentCounts(counts: SelectionDataItem[]) {
|
||||
this._documentCounts = counts
|
||||
if (this.documentCountSortingEnabled) {
|
||||
this.sortItems()
|
||||
this._documentCounts.set(counts)
|
||||
if (this.editing) {
|
||||
this._items.set(this.sortItems(this.items))
|
||||
}
|
||||
}
|
||||
|
||||
private _items: MatchingModel[] = []
|
||||
get items(): MatchingModel[] {
|
||||
return this._items
|
||||
return this._items()
|
||||
}
|
||||
|
||||
set items(items: MatchingModel[]) {
|
||||
if (items) {
|
||||
this._items = Array.from(items)
|
||||
this.sortItems()
|
||||
this.setNullItem()
|
||||
this._items.set(this.withNullItem(this.sortItems(Array.from(items))))
|
||||
}
|
||||
}
|
||||
|
||||
private setNullItem() {
|
||||
private withNullItem(items: MatchingModel[]): MatchingModel[] {
|
||||
if (this.manyToOne && this.logicalOperator === LogicalOperator.Or) {
|
||||
if (this._items[0]?.id === null) {
|
||||
this._items.shift()
|
||||
}
|
||||
return
|
||||
return items[0]?.id === null ? items.slice(1) : items
|
||||
}
|
||||
|
||||
const item = {
|
||||
const nullItem = {
|
||||
name: $localize`:Filter drop down element to filter for documents with no correspondent/type/tag assigned:Not assigned`,
|
||||
id:
|
||||
this.manyToOne || this.intersection === Intersection.Include
|
||||
@@ -108,22 +121,17 @@ export class FilterableDropdownSelectionModel {
|
||||
: NEGATIVE_NULL_FILTER_VALUE,
|
||||
}
|
||||
|
||||
if (
|
||||
this._items[0]?.id === null ||
|
||||
this._items[0]?.id === NEGATIVE_NULL_FILTER_VALUE
|
||||
) {
|
||||
this._items[0] = item
|
||||
} else if (this._items) {
|
||||
this._items.unshift(item)
|
||||
}
|
||||
return items[0]?.id === null || items[0]?.id === NEGATIVE_NULL_FILTER_VALUE
|
||||
? [nullItem, ...items.slice(1)]
|
||||
: [nullItem, ...items]
|
||||
}
|
||||
|
||||
constructor(manyToOne: boolean = false) {
|
||||
this.manyToOne = manyToOne
|
||||
}
|
||||
|
||||
private sortItems() {
|
||||
this._items.sort((a, b) => {
|
||||
private sortItems(items: MatchingModel[]): MatchingModel[] {
|
||||
const sorted = [...items].sort((a, b) => {
|
||||
if (
|
||||
(a.id == null && b.id != null) ||
|
||||
(a.id == NEGATIVE_NULL_FILTER_VALUE &&
|
||||
@@ -154,13 +162,13 @@ export class FilterableDropdownSelectionModel {
|
||||
) {
|
||||
return -1
|
||||
} else if (
|
||||
this._documentCounts.length &&
|
||||
this._documentCounts().length &&
|
||||
this.getDocumentCount(b.id) === 0 &&
|
||||
this.getDocumentCount(a.id) > this.getDocumentCount(b.id)
|
||||
) {
|
||||
return -1
|
||||
} else if (
|
||||
this._documentCounts.length &&
|
||||
this._documentCounts().length &&
|
||||
this.getDocumentCount(a.id) === 0 &&
|
||||
this.getDocumentCount(a.id) < this.getDocumentCount(b.id)
|
||||
) {
|
||||
@@ -170,15 +178,11 @@ export class FilterableDropdownSelectionModel {
|
||||
}
|
||||
})
|
||||
|
||||
if (this._documentCounts.length) {
|
||||
this.promoteBranchesWithDocumentCounts()
|
||||
}
|
||||
return this._documentCounts().length
|
||||
? this.promoteBranchesWithDocumentCounts(sorted)
|
||||
: sorted
|
||||
}
|
||||
|
||||
private selectionStates = new Map<number, ToggleableItemState>()
|
||||
|
||||
private temporarySelectionStates = new Map<number, ToggleableItemState>()
|
||||
|
||||
getSelectedItems() {
|
||||
return this.items.filter(
|
||||
(i) =>
|
||||
@@ -194,30 +198,33 @@ export class FilterableDropdownSelectionModel {
|
||||
}
|
||||
|
||||
set(id: number, state: ToggleableItemState, fireEvent = true) {
|
||||
const states = new Map(this.temporarySelectionStates)
|
||||
if (state == ToggleableItemState.NotSelected) {
|
||||
this.temporarySelectionStates.delete(id)
|
||||
states.delete(id)
|
||||
} else {
|
||||
this.temporarySelectionStates.set(id, state)
|
||||
states.set(id, state)
|
||||
}
|
||||
this._temporarySelectionStates.set(states)
|
||||
if (fireEvent) {
|
||||
this.changed.next(this)
|
||||
}
|
||||
}
|
||||
|
||||
toggle(id: number, fireEvent = true) {
|
||||
let state = this.temporarySelectionStates.get(id)
|
||||
const states = new Map(this.temporarySelectionStates)
|
||||
let state = states.get(id)
|
||||
if (
|
||||
state == undefined ||
|
||||
(state != ToggleableItemState.Selected &&
|
||||
state != ToggleableItemState.Excluded)
|
||||
) {
|
||||
if (this.manyToOne || this.singleSelect) {
|
||||
this.temporarySelectionStates.set(id, ToggleableItemState.Selected)
|
||||
states.set(id, ToggleableItemState.Selected)
|
||||
|
||||
if (this.singleSelect) {
|
||||
for (let key of this.temporarySelectionStates.keys()) {
|
||||
for (let key of states.keys()) {
|
||||
if (key != id) {
|
||||
this.temporarySelectionStates.delete(key)
|
||||
states.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,25 +240,29 @@ export class FilterableDropdownSelectionModel {
|
||||
) {
|
||||
newState = ToggleableItemState.NotSelected
|
||||
}
|
||||
this.temporarySelectionStates.set(id, newState)
|
||||
states.set(id, newState)
|
||||
}
|
||||
if (this.editing && states.get(id) == ToggleableItemState.Selected) {
|
||||
this.addAncestorSelections(states, id)
|
||||
}
|
||||
} else if (
|
||||
state == ToggleableItemState.Selected ||
|
||||
state == ToggleableItemState.Excluded
|
||||
) {
|
||||
this.temporarySelectionStates.delete(id)
|
||||
this.clearDescendantSelections(id)
|
||||
states.delete(id)
|
||||
this.clearDescendantSelections(states, id)
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
for (let key of this.temporarySelectionStates.keys()) {
|
||||
for (let key of states.keys()) {
|
||||
if (key) {
|
||||
this.temporarySelectionStates.delete(key)
|
||||
states.delete(key)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.temporarySelectionStates.delete(null)
|
||||
states.delete(null)
|
||||
}
|
||||
this._temporarySelectionStates.set(states)
|
||||
|
||||
if (fireEvent) {
|
||||
this.changed.next(this)
|
||||
@@ -259,20 +270,21 @@ export class FilterableDropdownSelectionModel {
|
||||
}
|
||||
|
||||
exclude(id: number, fireEvent: boolean = true) {
|
||||
let state = this.temporarySelectionStates.get(id)
|
||||
const states = new Map(this.temporarySelectionStates)
|
||||
let state = states.get(id)
|
||||
if (id && (state == null || state != ToggleableItemState.Excluded)) {
|
||||
this.temporaryLogicalOperator = this._logicalOperator = this.manyToOne
|
||||
? LogicalOperator.And
|
||||
: LogicalOperator.Or
|
||||
const operator = this.manyToOne ? LogicalOperator.And : LogicalOperator.Or
|
||||
this.temporaryLogicalOperator.set(operator)
|
||||
this._logicalOperator.set(operator)
|
||||
|
||||
if (this.manyToOne || this.singleSelect) {
|
||||
this.temporarySelectionStates.set(id, ToggleableItemState.Excluded)
|
||||
this.clearDescendantSelections(id)
|
||||
states.set(id, ToggleableItemState.Excluded)
|
||||
this.clearDescendantSelections(states, id)
|
||||
|
||||
if (this.singleSelect) {
|
||||
for (let key of this.temporarySelectionStates.keys()) {
|
||||
for (let key of states.keys()) {
|
||||
if (key != id) {
|
||||
this.temporarySelectionStates.delete(key)
|
||||
states.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -287,17 +299,18 @@ export class FilterableDropdownSelectionModel {
|
||||
) {
|
||||
newState = ToggleableItemState.NotSelected
|
||||
}
|
||||
this.temporarySelectionStates.set(id, newState)
|
||||
states.set(id, newState)
|
||||
if (newState == ToggleableItemState.Excluded) {
|
||||
this.clearDescendantSelections(id)
|
||||
this.clearDescendantSelections(states, id)
|
||||
}
|
||||
}
|
||||
} else if (!id || state == ToggleableItemState.Excluded) {
|
||||
this.temporarySelectionStates.delete(id)
|
||||
states.delete(id)
|
||||
if (id) {
|
||||
this.clearDescendantSelections(id)
|
||||
this.clearDescendantSelections(states, id)
|
||||
}
|
||||
}
|
||||
this._temporarySelectionStates.set(states)
|
||||
|
||||
if (fireEvent) {
|
||||
this.changed.next(this)
|
||||
@@ -308,9 +321,27 @@ export class FilterableDropdownSelectionModel {
|
||||
return this.selectionStates.get(id) || ToggleableItemState.NotSelected
|
||||
}
|
||||
|
||||
private clearDescendantSelections(id: number) {
|
||||
private clearDescendantSelections(
|
||||
states: Map<number, ToggleableItemState>,
|
||||
id: number
|
||||
) {
|
||||
for (const descendantID of this.getDescendantIDs(id)) {
|
||||
this.temporarySelectionStates.delete(descendantID)
|
||||
states.delete(descendantID)
|
||||
}
|
||||
}
|
||||
|
||||
private addAncestorSelections(
|
||||
states: Map<number, ToggleableItemState>,
|
||||
id: number
|
||||
) {
|
||||
const parentById = this.buildParentById(this.items)
|
||||
const seen = new Set<number>([id])
|
||||
let parentID = parentById.get(id)
|
||||
|
||||
while (typeof parentID === 'number' && !seen.has(parentID)) {
|
||||
seen.add(parentID)
|
||||
states.set(parentID, ToggleableItemState.Selected)
|
||||
parentID = parentById.get(parentID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,7 +351,7 @@ export class FilterableDropdownSelectionModel {
|
||||
|
||||
while (queue.length) {
|
||||
const parentID = queue.shift()
|
||||
for (const item of this._items) {
|
||||
for (const item of this.items) {
|
||||
if (
|
||||
typeof item?.id === 'number' &&
|
||||
typeof (item as any)['parent'] === 'number' &&
|
||||
@@ -336,12 +367,12 @@ export class FilterableDropdownSelectionModel {
|
||||
}
|
||||
|
||||
get logicalOperator(): LogicalOperator {
|
||||
return this.temporaryLogicalOperator
|
||||
return this.temporaryLogicalOperator()
|
||||
}
|
||||
|
||||
set logicalOperator(operator: LogicalOperator) {
|
||||
this.temporaryLogicalOperator = operator
|
||||
this.setNullItem()
|
||||
this.temporaryLogicalOperator.set(operator)
|
||||
this._items.set(this.withNullItem(this.items))
|
||||
}
|
||||
|
||||
toggleOperator() {
|
||||
@@ -349,12 +380,12 @@ export class FilterableDropdownSelectionModel {
|
||||
}
|
||||
|
||||
get intersection(): Intersection {
|
||||
return this.temporaryIntersection
|
||||
return this.temporaryIntersection()
|
||||
}
|
||||
|
||||
set intersection(intersection: Intersection) {
|
||||
this.temporaryIntersection = intersection
|
||||
this.setNullItem()
|
||||
this.temporaryIntersection.set(intersection)
|
||||
this._items.set(this.withNullItem(this.items))
|
||||
}
|
||||
|
||||
toggleIntersection() {
|
||||
@@ -364,18 +395,20 @@ export class FilterableDropdownSelectionModel {
|
||||
? ToggleableItemState.Selected
|
||||
: ToggleableItemState.Excluded
|
||||
|
||||
this.temporarySelectionStates.forEach((state, key) => {
|
||||
const states = new Map(this.temporarySelectionStates)
|
||||
states.forEach((state, key) => {
|
||||
if (key === null && this.intersection === Intersection.Exclude) {
|
||||
this.temporarySelectionStates.set(NEGATIVE_NULL_FILTER_VALUE, newState)
|
||||
states.set(NEGATIVE_NULL_FILTER_VALUE, newState)
|
||||
} else if (
|
||||
key === NEGATIVE_NULL_FILTER_VALUE &&
|
||||
this.intersection === Intersection.Include
|
||||
) {
|
||||
this.temporarySelectionStates.set(null, newState)
|
||||
states.set(null, newState)
|
||||
} else {
|
||||
this.temporarySelectionStates.set(key, newState)
|
||||
states.set(key, newState)
|
||||
}
|
||||
})
|
||||
this._temporarySelectionStates.set(states)
|
||||
|
||||
this.changed.next(this)
|
||||
}
|
||||
@@ -395,10 +428,12 @@ export class FilterableDropdownSelectionModel {
|
||||
}
|
||||
|
||||
clear(fireEvent = true) {
|
||||
this.temporarySelectionStates.clear()
|
||||
this.temporaryLogicalOperator = this._logicalOperator = LogicalOperator.And
|
||||
this.temporaryIntersection = this._intersection = Intersection.Include
|
||||
this.setNullItem()
|
||||
this._temporarySelectionStates.set(new Map())
|
||||
this.temporaryLogicalOperator.set(LogicalOperator.And)
|
||||
this._logicalOperator.set(LogicalOperator.And)
|
||||
this.temporaryIntersection.set(Intersection.Include)
|
||||
this._intersection.set(Intersection.Include)
|
||||
this._items.set(this.withNullItem(this.items))
|
||||
if (fireEvent) {
|
||||
this.changed.next(this)
|
||||
}
|
||||
@@ -419,9 +454,9 @@ export class FilterableDropdownSelectionModel {
|
||||
)
|
||||
) {
|
||||
return true
|
||||
} else if (this.temporaryLogicalOperator !== this._logicalOperator) {
|
||||
} else if (this.temporaryLogicalOperator() !== this._logicalOperator()) {
|
||||
return true
|
||||
} else if (this.temporaryIntersection !== this._intersection) {
|
||||
} else if (this.temporaryIntersection() !== this._intersection()) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
@@ -438,23 +473,29 @@ export class FilterableDropdownSelectionModel {
|
||||
}
|
||||
|
||||
getDocumentCount(id: number) {
|
||||
return this._documentCounts.find((c) => c.id === id)?.document_count
|
||||
return this._documentCounts().find((c) => c.id === id)?.document_count
|
||||
}
|
||||
|
||||
private promoteBranchesWithDocumentCounts() {
|
||||
const parentById = this.buildParentById()
|
||||
private promoteBranchesWithDocumentCounts(
|
||||
items: MatchingModel[]
|
||||
): MatchingModel[] {
|
||||
const parentById = this.buildParentById(items)
|
||||
const findRootId = this.createRootFinder(parentById)
|
||||
const getRootDocCount = this.createRootDocCounter()
|
||||
const summaries = this.buildBranchSummaries(findRootId, getRootDocCount)
|
||||
const getRootDocCount = this.createRootDocCounter(items)
|
||||
const summaries = this.buildBranchSummaries(
|
||||
items,
|
||||
findRootId,
|
||||
getRootDocCount
|
||||
)
|
||||
const orderedBranches = this.orderBranchesByPriority(summaries)
|
||||
|
||||
this._items = orderedBranches.flatMap((summary) => summary.items)
|
||||
return orderedBranches.flatMap((summary) => summary.items)
|
||||
}
|
||||
|
||||
private buildParentById(): Map<number, number | null> {
|
||||
private buildParentById(items: MatchingModel[]): Map<number, number | null> {
|
||||
const parentById = new Map<number, number | null>()
|
||||
|
||||
for (const item of this._items) {
|
||||
for (const item of items) {
|
||||
if (typeof item?.id === 'number') {
|
||||
const parentValue = (item as any)['parent']
|
||||
parentById.set(
|
||||
@@ -492,7 +533,9 @@ export class FilterableDropdownSelectionModel {
|
||||
return findRootId
|
||||
}
|
||||
|
||||
private createRootDocCounter(): (rootId: number) => number {
|
||||
private createRootDocCounter(
|
||||
items: MatchingModel[]
|
||||
): (rootId: number) => number {
|
||||
const docCountMemo = new Map<number, number>()
|
||||
|
||||
return (rootId: number): number => {
|
||||
@@ -507,7 +550,7 @@ export class FilterableDropdownSelectionModel {
|
||||
return explicit
|
||||
}
|
||||
|
||||
const rootItem = this._items.find((i) => i.id === rootId)
|
||||
const rootItem = items.find((i) => i.id === rootId)
|
||||
const fallback =
|
||||
typeof (rootItem as any)?.['document_count'] === 'number'
|
||||
? (rootItem as any)['document_count']
|
||||
@@ -519,12 +562,13 @@ export class FilterableDropdownSelectionModel {
|
||||
}
|
||||
|
||||
private buildBranchSummaries(
|
||||
items: MatchingModel[],
|
||||
findRootId: (id: number) => number,
|
||||
getRootDocCount: (rootId: number) => number
|
||||
): Map<string, BranchSummary> {
|
||||
const summaries = new Map<string, BranchSummary>()
|
||||
|
||||
for (const [index, item] of this._items.entries()) {
|
||||
for (const [index, item] of items.entries()) {
|
||||
const { key, special, rootId } = this.describeBranchItem(
|
||||
item,
|
||||
index,
|
||||
@@ -616,28 +660,23 @@ export class FilterableDropdownSelectionModel {
|
||||
}
|
||||
|
||||
init(map: Map<number, ToggleableItemState>) {
|
||||
this.temporarySelectionStates = map
|
||||
this._temporarySelectionStates.set(new Map(map))
|
||||
this.apply()
|
||||
}
|
||||
|
||||
apply() {
|
||||
this.selectionStates.clear()
|
||||
this.temporarySelectionStates.forEach((value, key) => {
|
||||
this.selectionStates.set(key, value)
|
||||
})
|
||||
this._logicalOperator = this.temporaryLogicalOperator
|
||||
this._intersection = this.temporaryIntersection
|
||||
this.sortItems()
|
||||
this._selectionStates.set(new Map(this.temporarySelectionStates))
|
||||
this._logicalOperator.set(this.temporaryLogicalOperator())
|
||||
this._intersection.set(this.temporaryIntersection())
|
||||
this._items.set(this.sortItems(this.items))
|
||||
}
|
||||
|
||||
reset(complete: boolean = false) {
|
||||
this.temporarySelectionStates.clear()
|
||||
if (complete) {
|
||||
this.selectionStates.clear()
|
||||
this._selectionStates.set(new Map())
|
||||
this._temporarySelectionStates.set(new Map())
|
||||
} else {
|
||||
this.selectionStates.forEach((value, key) => {
|
||||
this.temporarySelectionStates.set(key, value)
|
||||
})
|
||||
this._temporarySelectionStates.set(new Map(this.selectionStates))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -710,7 +749,7 @@ export class FilterableDropdownComponent
|
||||
model.manyToOne = this.selectionModel.manyToOne
|
||||
model.singleSelect = this._editing && !model.manyToOne
|
||||
}
|
||||
model.documentCountSortingEnabled = this._editing
|
||||
model.editing = this._editing
|
||||
model.changed.subscribe((updatedModel) => {
|
||||
this.selectionModelChange.next(updatedModel)
|
||||
})
|
||||
@@ -748,7 +787,7 @@ export class FilterableDropdownComponent
|
||||
if (this.selectionModel) {
|
||||
this.selectionModel.singleSelect =
|
||||
this._editing && !this.selectionModel.manyToOne
|
||||
this.selectionModel.documentCountSortingEnabled = this._editing
|
||||
this.selectionModel.editing = this._editing
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
@@ -7,6 +7,8 @@
|
||||
padding-left: calc(calc(var(--depth) - 2) * 1rem);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
.indicator {
|
||||
display: inline-block;
|
||||
@@ -18,3 +20,7 @@
|
||||
margin-left: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -207,6 +207,21 @@ describe('TagsComponent', () => {
|
||||
expect(component.value).toEqual([2, 1])
|
||||
})
|
||||
|
||||
it('should propagate parents added via ng-select selection', () => {
|
||||
const parent: Tag = { id: 1, name: 'parent' }
|
||||
const child: Tag = { id: 2, name: 'child', parent: 1 }
|
||||
component.tags = [parent, child]
|
||||
fixture.detectChanges()
|
||||
let propagated: number[]
|
||||
component.registerOnChange((value: number[]) => (propagated = value))
|
||||
|
||||
component.value = []
|
||||
component.select.select(component.select.itemsList.findItem(child.id))
|
||||
|
||||
expect(component.value).toEqual([2, 1])
|
||||
expect(propagated).toEqual([2, 1])
|
||||
})
|
||||
|
||||
it('should not duplicate parents when adding sibling nested tags', () => {
|
||||
const root: Tag = { id: 1, name: 'root' }
|
||||
const parent: Tag = { id: 2, name: 'parent', parent: 1 }
|
||||
|
||||
@@ -173,6 +173,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
||||
if (parent && !this.value.includes(parent.id)) {
|
||||
this.value = [...this.value, parent.id]
|
||||
this.onAdd(parent)
|
||||
this.onChange(this.value) // re-emit for ng-select
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-9
@@ -7,7 +7,7 @@
|
||||
<div class="list-group list-group-flush">
|
||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NONE)" [disabled]="disabled">
|
||||
<div class="selected-icon me-1">
|
||||
@if (selectionModel.ownerFilter === OwnerFilterType.NONE) {
|
||||
@if (selectionModel.ownerFilter() === OwnerFilterType.NONE) {
|
||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||
}
|
||||
</div>
|
||||
@@ -17,7 +17,7 @@
|
||||
</button>
|
||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SELF)" [disabled]="disabled">
|
||||
<div class="selected-icon me-1">
|
||||
@if (selectionModel.ownerFilter === OwnerFilterType.SELF) {
|
||||
@if (selectionModel.ownerFilter() === OwnerFilterType.SELF) {
|
||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||
}
|
||||
</div>
|
||||
@@ -27,7 +27,7 @@
|
||||
</button>
|
||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NOT_SELF)" [disabled]="disabled">
|
||||
<div class="selected-icon me-1">
|
||||
@if (selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) {
|
||||
@if (selectionModel.ownerFilter() === OwnerFilterType.NOT_SELF) {
|
||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||
}
|
||||
</div>
|
||||
@@ -37,7 +37,7 @@
|
||||
</button>
|
||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SHARED_BY_ME)" [disabled]="disabled">
|
||||
<div class="selected-icon me-1">
|
||||
@if (selectionModel.ownerFilter === OwnerFilterType.SHARED_BY_ME) {
|
||||
@if (selectionModel.ownerFilter() === OwnerFilterType.SHARED_BY_ME) {
|
||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||
}
|
||||
</div>
|
||||
@@ -47,7 +47,7 @@
|
||||
</button>
|
||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.UNOWNED)" [disabled]="disabled">
|
||||
<div class="selected-icon me-1">
|
||||
@if (selectionModel.ownerFilter === OwnerFilterType.UNOWNED) {
|
||||
@if (selectionModel.ownerFilter() === OwnerFilterType.UNOWNED) {
|
||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||
}
|
||||
</div>
|
||||
@@ -57,7 +57,7 @@
|
||||
</button>
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }" class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" [disabled]="disabled">
|
||||
<div class="selected-icon me-1">
|
||||
@if (selectionModel.ownerFilter === OwnerFilterType.OTHERS) {
|
||||
@if (selectionModel.ownerFilter() === OwnerFilterType.OTHERS) {
|
||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||
}
|
||||
</div>
|
||||
@@ -65,7 +65,8 @@
|
||||
<ng-select
|
||||
name="user"
|
||||
class="user-select small"
|
||||
[(ngModel)]="selectionModel.includeUsers"
|
||||
[ngModel]="selectionModel.includeUsers()"
|
||||
(ngModelChange)="selectionModel.includeUsers.set($event)"
|
||||
[disabled]="disabled"
|
||||
[clearable]="false"
|
||||
[items]="users()"
|
||||
@@ -78,10 +79,10 @@
|
||||
</ng-select>
|
||||
</div>
|
||||
</button>
|
||||
@if (selectionModel.ownerFilter === OwnerFilterType.NONE || selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) {
|
||||
@if (selectionModel.ownerFilter() === OwnerFilterType.NONE || selectionModel.ownerFilter() === OwnerFilterType.NOT_SELF) {
|
||||
<div class="list-group-item list-group-item-action d-flex align-items-center p-2 ps-3 border-bottom-0 border-start-0 border-end-0">
|
||||
<div class="form-check form-switch w-100">
|
||||
<input type="checkbox" class="form-check-input" id="hideUnowned" [(ngModel)]="this.selectionModel.hideUnowned" (change)="onChange()" [disabled]="disabled">
|
||||
<input type="checkbox" class="form-check-input" id="hideUnowned" [ngModel]="selectionModel.hideUnowned()" (ngModelChange)="selectionModel.hideUnowned.set($event)" (change)="onChange()" [disabled]="disabled">
|
||||
<label class="form-check-label w-100" for="hideUnowned"><small i18n>Hide unowned</small></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+39
-30
@@ -90,56 +90,56 @@ describe('PermissionsFilterDropdownComponent', () => {
|
||||
component.setFilter(OwnerFilterType.OTHERS)
|
||||
expect(component.isActive).toBeTruthy()
|
||||
component.setFilter(OwnerFilterType.NONE)
|
||||
component.selectionModel.hideUnowned = true
|
||||
component.selectionModel.hideUnowned.set(true)
|
||||
expect(component.isActive).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should describe concrete user filters honestly', () => {
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.SELF
|
||||
component.selectionModel.userID = 1
|
||||
component.selectionModel.ownerFilter.set(OwnerFilterType.SELF)
|
||||
component.selectionModel.userID.set(1)
|
||||
expect(component.ownerFilterLabel).toEqual('Owned by user1')
|
||||
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
|
||||
component.selectionModel.excludeUsers = [1]
|
||||
component.selectionModel.ownerFilter.set(OwnerFilterType.NOT_SELF)
|
||||
component.selectionModel.excludeUsers.set([1])
|
||||
expect(component.ownerExclusionFilterLabel).toEqual('Not owned by user1')
|
||||
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME
|
||||
component.selectionModel.userID = 1
|
||||
component.selectionModel.ownerFilter.set(OwnerFilterType.SHARED_BY_ME)
|
||||
component.selectionModel.userID.set(1)
|
||||
expect(component.sharedByFilterLabel).toEqual('Shared by user1')
|
||||
})
|
||||
|
||||
it('should describe concrete filters when usernames are unavailable', () => {
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.SELF
|
||||
component.selectionModel.userID = 99
|
||||
component.selectionModel.ownerFilter.set(OwnerFilterType.SELF)
|
||||
component.selectionModel.userID.set(99)
|
||||
expect(component.ownerFilterLabel).toEqual('Owned by another user')
|
||||
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
|
||||
component.selectionModel.excludeUsers = [99]
|
||||
component.selectionModel.ownerFilter.set(OwnerFilterType.NOT_SELF)
|
||||
component.selectionModel.excludeUsers.set([99])
|
||||
expect(component.ownerExclusionFilterLabel).toEqual(
|
||||
'Not owned by another user'
|
||||
)
|
||||
|
||||
component.selectionModel.excludeUsers = [98, 99]
|
||||
component.selectionModel.excludeUsers.set([98, 99])
|
||||
expect(component.ownerExclusionFilterLabel).toEqual(
|
||||
'Not owned by selected users'
|
||||
)
|
||||
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME
|
||||
component.selectionModel.userID = 99
|
||||
component.selectionModel.ownerFilter.set(OwnerFilterType.SHARED_BY_ME)
|
||||
component.selectionModel.userID.set(99)
|
||||
expect(component.sharedByFilterLabel).toEqual('Shared by another user')
|
||||
})
|
||||
|
||||
it('should retain relative labels for filters bound to the current user', () => {
|
||||
component.selectionModel.userID = currentUserID
|
||||
component.selectionModel.userID.set(currentUserID)
|
||||
expect(component.ownerFilterLabel).toEqual('My documents')
|
||||
expect(component.sharedByFilterLabel).toEqual('Shared by me')
|
||||
|
||||
component.selectionModel.excludeUsers = [currentUserID]
|
||||
component.selectionModel.excludeUsers.set([currentUserID])
|
||||
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
|
||||
})
|
||||
|
||||
it('should retain relative labels for inactive filter choices', () => {
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.NONE
|
||||
component.selectionModel.ownerFilter.set(OwnerFilterType.NONE)
|
||||
|
||||
expect(component.ownerFilterLabel).toEqual('My documents')
|
||||
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
|
||||
@@ -148,32 +148,41 @@ describe('PermissionsFilterDropdownComponent', () => {
|
||||
|
||||
it('should support reset', () => {
|
||||
component.setFilter(OwnerFilterType.OTHERS)
|
||||
expect(component.selectionModel.ownerFilter).not.toEqual(
|
||||
expect(component.selectionModel.ownerFilter()).not.toEqual(
|
||||
OwnerFilterType.NONE
|
||||
)
|
||||
component.reset()
|
||||
expect(component.selectionModel.ownerFilter).toEqual(OwnerFilterType.NONE)
|
||||
expect(component.selectionModel.ownerFilter()).toEqual(OwnerFilterType.NONE)
|
||||
})
|
||||
|
||||
it('should toggle owner filter type when users selected', () => {
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.NONE
|
||||
component.selectionModel.ownerFilter.set(OwnerFilterType.NONE)
|
||||
|
||||
// this would normally be done by select component
|
||||
component.selectionModel.includeUsers = [12]
|
||||
component.selectionModel.includeUsers.set([12])
|
||||
component.onUserSelect()
|
||||
expect(component.selectionModel.ownerFilter).toEqual(OwnerFilterType.OTHERS)
|
||||
expect(component.selectionModel.ownerFilter()).toEqual(
|
||||
OwnerFilterType.OTHERS
|
||||
)
|
||||
|
||||
// this would normally be done by select component
|
||||
component.selectionModel.includeUsers = null
|
||||
component.selectionModel.includeUsers.set(null)
|
||||
component.onUserSelect()
|
||||
|
||||
expect(component.selectionModel.ownerFilter).toEqual(OwnerFilterType.NONE)
|
||||
expect(component.selectionModel.ownerFilter()).toEqual(OwnerFilterType.NONE)
|
||||
})
|
||||
it('should emit a selection model depending on the type of owner filter set', () => {
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.NONE
|
||||
const emitted = () => ({
|
||||
excludeUsers: ownerFilterSetResult.excludeUsers(),
|
||||
hideUnowned: ownerFilterSetResult.hideUnowned(),
|
||||
includeUsers: ownerFilterSetResult.includeUsers(),
|
||||
ownerFilter: ownerFilterSetResult.ownerFilter(),
|
||||
userID: ownerFilterSetResult.userID(),
|
||||
})
|
||||
component.selectionModel.ownerFilter.set(OwnerFilterType.NONE)
|
||||
|
||||
component.setFilter(OwnerFilterType.SELF)
|
||||
expect(ownerFilterSetResult).toEqual({
|
||||
expect(emitted()).toEqual({
|
||||
excludeUsers: [],
|
||||
hideUnowned: false,
|
||||
includeUsers: [],
|
||||
@@ -182,7 +191,7 @@ describe('PermissionsFilterDropdownComponent', () => {
|
||||
})
|
||||
|
||||
component.setFilter(OwnerFilterType.NOT_SELF)
|
||||
expect(ownerFilterSetResult).toEqual({
|
||||
expect(emitted()).toEqual({
|
||||
excludeUsers: [currentUserID],
|
||||
hideUnowned: false,
|
||||
includeUsers: [],
|
||||
@@ -191,7 +200,7 @@ describe('PermissionsFilterDropdownComponent', () => {
|
||||
})
|
||||
|
||||
component.setFilter(OwnerFilterType.NONE)
|
||||
expect(ownerFilterSetResult).toEqual({
|
||||
expect(emitted()).toEqual({
|
||||
excludeUsers: [],
|
||||
hideUnowned: false,
|
||||
includeUsers: [],
|
||||
@@ -200,7 +209,7 @@ describe('PermissionsFilterDropdownComponent', () => {
|
||||
})
|
||||
|
||||
component.setFilter(OwnerFilterType.SHARED_BY_ME)
|
||||
expect(ownerFilterSetResult).toEqual({
|
||||
expect(emitted()).toEqual({
|
||||
excludeUsers: [],
|
||||
hideUnowned: false,
|
||||
includeUsers: [],
|
||||
@@ -209,7 +218,7 @@ describe('PermissionsFilterDropdownComponent', () => {
|
||||
})
|
||||
|
||||
component.setFilter(OwnerFilterType.UNOWNED)
|
||||
expect(ownerFilterSetResult).toEqual({
|
||||
expect(emitted()).toEqual({
|
||||
excludeUsers: [],
|
||||
hideUnowned: false,
|
||||
includeUsers: [],
|
||||
|
||||
+53
-53
@@ -25,18 +25,18 @@ import { ComponentWithPermissions } from '../../with-permissions/with-permission
|
||||
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
|
||||
|
||||
export class PermissionsSelectionModel {
|
||||
ownerFilter: OwnerFilterType
|
||||
hideUnowned: boolean
|
||||
userID: number
|
||||
includeUsers: number[]
|
||||
excludeUsers: number[]
|
||||
readonly ownerFilter = signal(OwnerFilterType.NONE)
|
||||
readonly hideUnowned = signal(false)
|
||||
readonly userID = signal<number>(null)
|
||||
readonly includeUsers = signal<number[]>([])
|
||||
readonly excludeUsers = signal<number[]>([])
|
||||
|
||||
clear() {
|
||||
this.ownerFilter = OwnerFilterType.NONE
|
||||
this.userID = null
|
||||
this.hideUnowned = false
|
||||
this.includeUsers = []
|
||||
this.excludeUsers = []
|
||||
this.ownerFilter.set(OwnerFilterType.NONE)
|
||||
this.userID.set(null)
|
||||
this.hideUnowned.set(false)
|
||||
this.includeUsers.set([])
|
||||
this.excludeUsers.set([])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,33 +84,31 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
||||
|
||||
readonly users = signal<User[]>([])
|
||||
|
||||
hideUnowned: boolean
|
||||
|
||||
get isActive(): boolean {
|
||||
return (
|
||||
this.selectionModel.ownerFilter !== OwnerFilterType.NONE ||
|
||||
this.selectionModel.hideUnowned
|
||||
this.selectionModel.ownerFilter() !== OwnerFilterType.NONE ||
|
||||
this.selectionModel.hideUnowned()
|
||||
)
|
||||
}
|
||||
|
||||
get ownerFilterLabel(): string {
|
||||
if (
|
||||
this.selectionModel?.ownerFilter !== OwnerFilterType.SELF ||
|
||||
this.selectionModel?.userID === this.settingsService.currentUser()?.id
|
||||
this.selectionModel?.ownerFilter() !== OwnerFilterType.SELF ||
|
||||
this.selectionModel?.userID() === this.settingsService.currentUser()?.id
|
||||
) {
|
||||
return $localize`My documents`
|
||||
}
|
||||
|
||||
const username = this.getUsername(this.selectionModel?.userID)
|
||||
const username = this.getUsername(this.selectionModel?.userID())
|
||||
return username
|
||||
? $localize`Owned by ${username}`
|
||||
: $localize`Owned by another user`
|
||||
}
|
||||
|
||||
get ownerExclusionFilterLabel(): string {
|
||||
const excludedUsers = this.selectionModel?.excludeUsers ?? []
|
||||
const excludedUsers = this.selectionModel?.excludeUsers() ?? []
|
||||
if (
|
||||
this.selectionModel?.ownerFilter !== OwnerFilterType.NOT_SELF ||
|
||||
this.selectionModel?.ownerFilter() !== OwnerFilterType.NOT_SELF ||
|
||||
(excludedUsers.length === 1 &&
|
||||
excludedUsers[0] === this.settingsService.currentUser()?.id)
|
||||
) {
|
||||
@@ -130,13 +128,13 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
||||
|
||||
get sharedByFilterLabel(): string {
|
||||
if (
|
||||
this.selectionModel?.ownerFilter !== OwnerFilterType.SHARED_BY_ME ||
|
||||
this.selectionModel?.userID === this.settingsService.currentUser()?.id
|
||||
this.selectionModel?.ownerFilter() !== OwnerFilterType.SHARED_BY_ME ||
|
||||
this.selectionModel?.userID() === this.settingsService.currentUser()?.id
|
||||
) {
|
||||
return $localize`Shared by me`
|
||||
}
|
||||
|
||||
const username = this.getUsername(this.selectionModel?.userID)
|
||||
const username = this.getUsername(this.selectionModel?.userID())
|
||||
return username
|
||||
? $localize`Shared by ${username}`
|
||||
: $localize`Shared by another user`
|
||||
@@ -169,34 +167,36 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
||||
}
|
||||
|
||||
setFilter(type: OwnerFilterType) {
|
||||
this.selectionModel.ownerFilter = type
|
||||
if (this.selectionModel.ownerFilter === OwnerFilterType.SELF) {
|
||||
this.selectionModel.includeUsers = []
|
||||
this.selectionModel.excludeUsers = []
|
||||
this.selectionModel.userID = this.settingsService.currentUser().id
|
||||
this.selectionModel.hideUnowned = false
|
||||
} else if (this.selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) {
|
||||
this.selectionModel.userID = null
|
||||
this.selectionModel.includeUsers = []
|
||||
this.selectionModel.excludeUsers = [this.settingsService.currentUser().id]
|
||||
this.selectionModel.hideUnowned = false
|
||||
} else if (this.selectionModel.ownerFilter === OwnerFilterType.NONE) {
|
||||
this.selectionModel.userID = null
|
||||
this.selectionModel.includeUsers = []
|
||||
this.selectionModel.excludeUsers = []
|
||||
this.selectionModel.hideUnowned = false
|
||||
this.selectionModel.ownerFilter.set(type)
|
||||
if (this.selectionModel.ownerFilter() === OwnerFilterType.SELF) {
|
||||
this.selectionModel.includeUsers.set([])
|
||||
this.selectionModel.excludeUsers.set([])
|
||||
this.selectionModel.userID.set(this.settingsService.currentUser().id)
|
||||
this.selectionModel.hideUnowned.set(false)
|
||||
} else if (this.selectionModel.ownerFilter() === OwnerFilterType.NOT_SELF) {
|
||||
this.selectionModel.userID.set(null)
|
||||
this.selectionModel.includeUsers.set([])
|
||||
this.selectionModel.excludeUsers.set([
|
||||
this.settingsService.currentUser().id,
|
||||
])
|
||||
this.selectionModel.hideUnowned.set(false)
|
||||
} else if (this.selectionModel.ownerFilter() === OwnerFilterType.NONE) {
|
||||
this.selectionModel.userID.set(null)
|
||||
this.selectionModel.includeUsers.set([])
|
||||
this.selectionModel.excludeUsers.set([])
|
||||
this.selectionModel.hideUnowned.set(false)
|
||||
} else if (
|
||||
this.selectionModel.ownerFilter === OwnerFilterType.SHARED_BY_ME
|
||||
this.selectionModel.ownerFilter() === OwnerFilterType.SHARED_BY_ME
|
||||
) {
|
||||
this.selectionModel.userID = this.settingsService.currentUser()?.id
|
||||
this.selectionModel.includeUsers = []
|
||||
this.selectionModel.excludeUsers = []
|
||||
this.selectionModel.hideUnowned = false
|
||||
} else if (this.selectionModel.ownerFilter === OwnerFilterType.UNOWNED) {
|
||||
this.selectionModel.userID = null
|
||||
this.selectionModel.includeUsers = []
|
||||
this.selectionModel.excludeUsers = []
|
||||
this.selectionModel.hideUnowned = false
|
||||
this.selectionModel.userID.set(this.settingsService.currentUser()?.id)
|
||||
this.selectionModel.includeUsers.set([])
|
||||
this.selectionModel.excludeUsers.set([])
|
||||
this.selectionModel.hideUnowned.set(false)
|
||||
} else if (this.selectionModel.ownerFilter() === OwnerFilterType.UNOWNED) {
|
||||
this.selectionModel.userID.set(null)
|
||||
this.selectionModel.includeUsers.set([])
|
||||
this.selectionModel.excludeUsers.set([])
|
||||
this.selectionModel.hideUnowned.set(false)
|
||||
}
|
||||
this.onChange()
|
||||
}
|
||||
@@ -206,11 +206,11 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
||||
}
|
||||
|
||||
onUserSelect() {
|
||||
if (this.selectionModel.includeUsers?.length) {
|
||||
this.selectionModel.ownerFilter = OwnerFilterType.OTHERS
|
||||
} else {
|
||||
this.selectionModel.ownerFilter = OwnerFilterType.NONE
|
||||
}
|
||||
this.selectionModel.ownerFilter.set(
|
||||
this.selectionModel.includeUsers()?.length
|
||||
? OwnerFilterType.OTHERS
|
||||
: OwnerFilterType.NONE
|
||||
)
|
||||
this.onChange()
|
||||
}
|
||||
|
||||
|
||||
@@ -1209,24 +1209,53 @@ describe('DocumentDetailComponent', () => {
|
||||
expect(fixture.debugElement.queryAll(By.css('textarea.rtl'))).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should display built-in pdf viewer if not disabled', () => {
|
||||
it('should display built-in pdf viewer if not disabled', async () => {
|
||||
initNormally()
|
||||
component.document().archived_file_name = 'file.pdf'
|
||||
component.document.update((document) => ({
|
||||
...document,
|
||||
archived_file_name: 'file.pdf',
|
||||
}))
|
||||
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
|
||||
expect(component.useNativePdfViewer).toBeFalsy()
|
||||
fixture.detectChanges()
|
||||
await fixture.whenStable()
|
||||
expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should display native pdf viewer if enabled', () => {
|
||||
initNormally()
|
||||
component.document().archived_file_name = 'file.pdf'
|
||||
component.document.update((document) => ({
|
||||
...document,
|
||||
archived_file_name: 'file.pdf',
|
||||
}))
|
||||
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, true)
|
||||
expect(component.useNativePdfViewer).toBeTruthy()
|
||||
fixture.detectChanges()
|
||||
expect(fixture.debugElement.query(By.css('object'))).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should reflect signal-backed document detail display settings', () => {
|
||||
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL, false)
|
||||
settingsService.set(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS, [
|
||||
component.DocumentDetailFieldID.Correspondent,
|
||||
])
|
||||
|
||||
expect(component.showThumbnailOverlay).toBeFalsy()
|
||||
expect(
|
||||
component.isFieldHidden(component.DocumentDetailFieldID.Correspondent)
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
component.isFieldHidden(component.DocumentDetailFieldID.DocumentType)
|
||||
).toBeFalsy()
|
||||
|
||||
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL, true)
|
||||
settingsService.set(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS, [])
|
||||
|
||||
expect(component.showThumbnailOverlay).toBeTruthy()
|
||||
expect(
|
||||
component.isFieldHidden(component.DocumentDetailFieldID.Correspondent)
|
||||
).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should attempt to retrieve metadata', () => {
|
||||
const metadataSpy = jest.spyOn(documentService, 'getMetadata')
|
||||
metadataSpy.mockReturnValue(of({ has_archive_version: true }))
|
||||
@@ -1685,7 +1714,10 @@ describe('DocumentDetailComponent', () => {
|
||||
|
||||
it('should change preview element by render type', () => {
|
||||
initNormally()
|
||||
component.document().archived_file_name = 'file.pdf'
|
||||
component.document.update((document) => ({
|
||||
...document,
|
||||
archived_file_name: 'file.pdf',
|
||||
}))
|
||||
fixture.detectChanges()
|
||||
expect(component.archiveContentRenderType).toEqual(
|
||||
component.ContentRenderType.PDF
|
||||
@@ -1694,8 +1726,11 @@ describe('DocumentDetailComponent', () => {
|
||||
fixture.debugElement.query(By.css('pdf-viewer-container'))
|
||||
).not.toBeUndefined()
|
||||
|
||||
component.document().archived_file_name = undefined
|
||||
component.document().mime_type = 'text/plain'
|
||||
component.document.update((document) => ({
|
||||
...document,
|
||||
archived_file_name: undefined,
|
||||
mime_type: 'text/plain',
|
||||
}))
|
||||
fixture.detectChanges()
|
||||
expect(component.archiveContentRenderType).toEqual(
|
||||
component.ContentRenderType.Text
|
||||
@@ -1704,7 +1739,10 @@ describe('DocumentDetailComponent', () => {
|
||||
fixture.debugElement.query(By.css('div.preview-sticky'))
|
||||
).not.toBeUndefined()
|
||||
|
||||
component.document().mime_type = 'image/jpeg'
|
||||
component.document.update((document) => ({
|
||||
...document,
|
||||
mime_type: 'image/jpeg',
|
||||
}))
|
||||
fixture.detectChanges()
|
||||
expect(component.archiveContentRenderType).toEqual(
|
||||
component.ContentRenderType.Image
|
||||
@@ -1712,9 +1750,12 @@ describe('DocumentDetailComponent', () => {
|
||||
expect(
|
||||
fixture.debugElement.query(By.css('.preview-sticky img'))
|
||||
).not.toBeUndefined()
|
||||
;((component.document().mime_type =
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'),
|
||||
fixture.detectChanges())
|
||||
component.document.update((document) => ({
|
||||
...document,
|
||||
mime_type:
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
}))
|
||||
fixture.detectChanges()
|
||||
expect(component.archiveContentRenderType).toEqual(
|
||||
component.ContentRenderType.Other
|
||||
)
|
||||
|
||||
@@ -227,6 +227,19 @@ export class DocumentDetailComponent
|
||||
private deviceDetectorService = inject(DeviceDetectorService)
|
||||
private savedViewService = inject(SavedViewService)
|
||||
private readonly websocketStatusService = inject(WebsocketStatusService)
|
||||
private readonly useNativePdfViewerSetting = this.settings.getSignal<boolean>(
|
||||
SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER
|
||||
)
|
||||
private readonly aiEnabledSetting = this.settings.getSignal<boolean>(
|
||||
SETTINGS_KEYS.AI_ENABLED
|
||||
)
|
||||
private readonly showThumbnailOverlaySetting =
|
||||
this.settings.getSignal<boolean>(
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL
|
||||
)
|
||||
private readonly hiddenFieldsSetting = this.settings.getSignal<
|
||||
DocumentDetailFieldID[]
|
||||
>(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS)
|
||||
|
||||
@ViewChild('inputTitle')
|
||||
titleInput: TextComponent
|
||||
@@ -333,8 +346,7 @@ export class DocumentDetailComponent
|
||||
}
|
||||
|
||||
get useNativePdfViewer(): boolean {
|
||||
this.settings.trackChanges()
|
||||
return this.settings.get(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER)
|
||||
return this.useNativePdfViewerSetting()
|
||||
}
|
||||
|
||||
get isMobile(): boolean {
|
||||
@@ -342,12 +354,10 @@ export class DocumentDetailComponent
|
||||
}
|
||||
|
||||
get aiEnabled(): boolean {
|
||||
this.settings.trackChanges()
|
||||
return this.settings.get(SETTINGS_KEYS.AI_ENABLED)
|
||||
return this.aiEnabledSetting()
|
||||
}
|
||||
|
||||
get archiveContentRenderType(): ContentRenderType {
|
||||
this.settings.trackChanges()
|
||||
const hasArchiveVersion =
|
||||
this.metadata()?.has_archive_version ??
|
||||
!!this.document()?.archived_file_name
|
||||
@@ -359,22 +369,17 @@ export class DocumentDetailComponent
|
||||
}
|
||||
|
||||
get originalContentRenderType(): ContentRenderType {
|
||||
this.settings.trackChanges()
|
||||
return this.getRenderType(
|
||||
this.metadata()?.original_mime_type || this.document()?.mime_type
|
||||
)
|
||||
}
|
||||
|
||||
get showThumbnailOverlay(): boolean {
|
||||
this.settings.trackChanges()
|
||||
return this.settings.get(SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL)
|
||||
return this.showThumbnailOverlaySetting()
|
||||
}
|
||||
|
||||
isFieldHidden(fieldId: DocumentDetailFieldID): boolean {
|
||||
this.settings.trackChanges()
|
||||
return this.settings
|
||||
.get(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS)
|
||||
.includes(fieldId)
|
||||
return this.hiddenFieldsSetting().includes(fieldId)
|
||||
}
|
||||
|
||||
private getRenderType(mimeType: string): ContentRenderType {
|
||||
|
||||
+4
-4
@@ -60,16 +60,16 @@
|
||||
<div class="list-group list-group-flush border-0 pt-1 pb-2 card-info">
|
||||
@if (document()) {
|
||||
@if (displayFields().includes(DisplayField.DOCUMENT_TYPE) && document().document_type) {
|
||||
<button type="button" class="list-group-item list-group-item-action bg-transparent ps-0 p-1 border-0" title="Toggle document type filter" i18n-title
|
||||
<button type="button" class="list-group-item list-group-item-action bg-transparent ps-0 p-1 border-0 d-flex align-items-start" title="Toggle document type filter" i18n-title
|
||||
(click)="clickDocumentType.emit(document().document_type);$event.stopPropagation()">
|
||||
<i-bs width="1em" height="1em" class="me-2 text-muted" name="file-earmark"></i-bs>
|
||||
<i-bs width="1em" height="1em" class="me-2 text-muted flex-shrink-0" name="file-earmark"></i-bs>
|
||||
<small>{{document().document_type | documentTypeName | async}}</small>
|
||||
</button>
|
||||
}
|
||||
@if (displayFields().includes(DisplayField.STORAGE_PATH) && document().storage_path) {
|
||||
<button type="button" class="list-group-item list-group-item-action bg-transparent ps-0 p-1 border-0" title="Toggle storage path filter" i18n-title
|
||||
<button type="button" class="list-group-item list-group-item-action bg-transparent ps-0 p-1 border-0 d-flex align-items-start" title="Toggle storage path filter" i18n-title
|
||||
(click)="clickStoragePath.emit(document().storage_path);$event.stopPropagation()">
|
||||
<i-bs width="1em" height="1em" class="me-2 text-muted" name="folder"></i-bs>
|
||||
<i-bs width="1em" height="1em" class="me-2 text-muted flex-shrink-0" name="folder"></i-bs>
|
||||
<small>{{document().storage_path | storagePathName | async}}</small>
|
||||
</button>
|
||||
}
|
||||
|
||||
+5
@@ -69,6 +69,11 @@
|
||||
button {
|
||||
line-height: 1;
|
||||
|
||||
small {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: transparent !important;
|
||||
|
||||
@@ -121,6 +121,8 @@ export class DocumentListComponent
|
||||
settingsService = inject(SettingsService)
|
||||
private hotKeyService = inject(HotKeyService)
|
||||
permissionService = inject(PermissionsService)
|
||||
private readonly notesEnabledSetting =
|
||||
this.settingsService.getSignal<boolean>(SETTINGS_KEYS.NOTES_ENABLED)
|
||||
|
||||
DisplayField = DisplayField
|
||||
DisplayMode = DisplayMode
|
||||
@@ -574,8 +576,7 @@ export class DocumentListComponent
|
||||
}
|
||||
|
||||
get notesEnabled(): boolean {
|
||||
this.settingsService.trackChanges()
|
||||
return this.settingsService.get(SETTINGS_KEYS.NOTES_ENABLED)
|
||||
return this.notesEnabledSetting()
|
||||
}
|
||||
|
||||
resetFilters() {
|
||||
|
||||
+86
-20
@@ -621,6 +621,43 @@ describe('FilterEditorComponent', () => {
|
||||
component.toggleTag(2) // coverage
|
||||
})
|
||||
|
||||
it('should reflect ingested tag filter rules in the dropdown toggle', () => {
|
||||
const dropdown = fixture.debugElement.query(
|
||||
By.css('pngx-filterable-dropdown')
|
||||
)
|
||||
const toggle = dropdown.nativeElement.querySelector('#dropdown_tags')
|
||||
expect(toggle.classList.contains('btn-primary')).toBeFalsy()
|
||||
expect(
|
||||
dropdown.nativeElement.querySelector('pngx-clearable-badge')
|
||||
).toBeNull()
|
||||
|
||||
// switching to a view with a tag filter
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_HAS_TAGS_ALL,
|
||||
value: '2',
|
||||
},
|
||||
]
|
||||
fixture.detectChanges()
|
||||
expect(toggle.classList.contains('btn-primary')).toBeTruthy()
|
||||
expect(
|
||||
dropdown.nativeElement.querySelector('pngx-clearable-badge')
|
||||
).not.toBeNull()
|
||||
|
||||
// and back to a view without one
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_HAS_CORRESPONDENT_ANY,
|
||||
value: '12',
|
||||
},
|
||||
]
|
||||
fixture.detectChanges()
|
||||
expect(toggle.classList.contains('btn-primary')).toBeFalsy()
|
||||
expect(
|
||||
dropdown.nativeElement.querySelector('pngx-clearable-badge')
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('should ingest filter rules for has any tags', () => {
|
||||
expect(component.tagSelectionModel.getSelectedItems()).toHaveLength(0)
|
||||
component.filterRules = [
|
||||
@@ -1078,7 +1115,7 @@ describe('FilterEditorComponent', () => {
|
||||
})
|
||||
|
||||
it('should ingest filter rules for owner', () => {
|
||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
||||
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||
OwnerFilterType.NONE
|
||||
)
|
||||
component.filterRules = [
|
||||
@@ -1087,15 +1124,38 @@ describe('FilterEditorComponent', () => {
|
||||
value: '100',
|
||||
},
|
||||
]
|
||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
||||
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||
OwnerFilterType.SELF
|
||||
)
|
||||
expect(component.permissionsSelectionModel.hideUnowned).toBeFalsy()
|
||||
expect(component.permissionsSelectionModel.userID).toEqual(100)
|
||||
expect(component.permissionsSelectionModel.hideUnowned()).toBeFalsy()
|
||||
expect(component.permissionsSelectionModel.userID()).toEqual(100)
|
||||
})
|
||||
|
||||
it('should reflect ingested owner filter rules in the dropdown toggle', () => {
|
||||
const dropdown = fixture.debugElement.query(
|
||||
By.css('pngx-permissions-filter-dropdown')
|
||||
)
|
||||
const toggle = dropdown.nativeElement.querySelector('button')
|
||||
expect(toggle.classList.contains('btn-primary')).toBeFalsy()
|
||||
|
||||
// switching to a view with an owner filter
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_OWNER,
|
||||
value: '100',
|
||||
},
|
||||
]
|
||||
fixture.detectChanges()
|
||||
expect(toggle.classList.contains('btn-primary')).toBeTruthy()
|
||||
|
||||
// and back to a view without one
|
||||
component.filterRules = []
|
||||
fixture.detectChanges()
|
||||
expect(toggle.classList.contains('btn-primary')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should ingest filter rules for owner is others', () => {
|
||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
||||
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||
OwnerFilterType.NONE
|
||||
)
|
||||
component.filterRules = [
|
||||
@@ -1104,14 +1164,14 @@ describe('FilterEditorComponent', () => {
|
||||
value: '50',
|
||||
},
|
||||
]
|
||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
||||
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||
OwnerFilterType.OTHERS
|
||||
)
|
||||
expect(component.permissionsSelectionModel.includeUsers).toContain(50)
|
||||
expect(component.permissionsSelectionModel.includeUsers()).toContain(50)
|
||||
})
|
||||
|
||||
it('should ingest filter rules for owner does not include others', () => {
|
||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
||||
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||
OwnerFilterType.NONE
|
||||
)
|
||||
component.filterRules = [
|
||||
@@ -1120,14 +1180,14 @@ describe('FilterEditorComponent', () => {
|
||||
value: '50',
|
||||
},
|
||||
]
|
||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
||||
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||
OwnerFilterType.NOT_SELF
|
||||
)
|
||||
expect(component.permissionsSelectionModel.excludeUsers).toContain(50)
|
||||
expect(component.permissionsSelectionModel.excludeUsers()).toContain(50)
|
||||
})
|
||||
|
||||
it('should ingest filter rules for owner is null', () => {
|
||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
||||
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||
OwnerFilterType.NONE
|
||||
)
|
||||
component.filterRules = [
|
||||
@@ -1136,10 +1196,10 @@ describe('FilterEditorComponent', () => {
|
||||
value: 'true',
|
||||
},
|
||||
]
|
||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
||||
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||
OwnerFilterType.UNOWNED
|
||||
)
|
||||
expect(component.permissionsSelectionModel.hideUnowned).toBeFalsy()
|
||||
expect(component.permissionsSelectionModel.hideUnowned()).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should ingest filter rules for owner is not null', () => {
|
||||
@@ -1149,14 +1209,14 @@ describe('FilterEditorComponent', () => {
|
||||
value: 'false',
|
||||
},
|
||||
]
|
||||
expect(component.permissionsSelectionModel.hideUnowned).toBeTruthy()
|
||||
expect(component.permissionsSelectionModel.hideUnowned()).toBeTruthy()
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_OWNER_ISNULL,
|
||||
value: '0',
|
||||
},
|
||||
]
|
||||
expect(component.permissionsSelectionModel.hideUnowned).toBeTruthy()
|
||||
expect(component.permissionsSelectionModel.hideUnowned()).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should ingest filter rules for shared by me', () => {
|
||||
@@ -1166,7 +1226,7 @@ describe('FilterEditorComponent', () => {
|
||||
value: '2',
|
||||
},
|
||||
]
|
||||
expect(component.permissionsSelectionModel.userID).toEqual(2)
|
||||
expect(component.permissionsSelectionModel.userID()).toEqual(2)
|
||||
})
|
||||
|
||||
// GET filterRules
|
||||
@@ -1932,7 +1992,10 @@ describe('FilterEditorComponent', () => {
|
||||
value: '1',
|
||||
},
|
||||
])
|
||||
component.permissionsSelectionModel.excludeUsers.push(2)
|
||||
component.permissionsSelectionModel.excludeUsers.update((users) => [
|
||||
...users,
|
||||
2,
|
||||
])
|
||||
fixture.detectChanges()
|
||||
expect(component.filterRules).toEqual([
|
||||
{
|
||||
@@ -1982,8 +2045,11 @@ describe('FilterEditorComponent', () => {
|
||||
// TODO: mock input in code
|
||||
// userSelect.query(By.css('input')).nativeElement.value = '3'
|
||||
// userSelect.triggerEventHandler('change')
|
||||
component.permissionsSelectionModel.ownerFilter = OwnerFilterType.OTHERS
|
||||
component.permissionsSelectionModel.includeUsers.push(3)
|
||||
component.permissionsSelectionModel.ownerFilter.set(OwnerFilterType.OTHERS)
|
||||
component.permissionsSelectionModel.includeUsers.update((users) => [
|
||||
...users,
|
||||
3,
|
||||
])
|
||||
fixture.detectChanges()
|
||||
expect(component.filterRules).toEqual([
|
||||
{
|
||||
@@ -2003,7 +2069,7 @@ describe('FilterEditorComponent', () => {
|
||||
ownerToggle.nativeElement.checked = true
|
||||
// ownerToggle.triggerEventHandler('change')
|
||||
// TODO: ngModel isn't doing this here
|
||||
component.permissionsSelectionModel.hideUnowned = true
|
||||
component.permissionsSelectionModel.hideUnowned.set(true)
|
||||
fixture.detectChanges()
|
||||
expect(component.filterRules).toEqual([
|
||||
{
|
||||
|
||||
@@ -735,38 +735,50 @@ export class FilterEditorComponent
|
||||
this._textFilter = rule.value
|
||||
break
|
||||
case FILTER_OWNER:
|
||||
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.SELF
|
||||
this.permissionsSelectionModel.hideUnowned = false
|
||||
this.permissionsSelectionModel.ownerFilter.set(OwnerFilterType.SELF)
|
||||
this.permissionsSelectionModel.hideUnowned.set(false)
|
||||
if (rule.value)
|
||||
this.permissionsSelectionModel.userID = parseInt(rule.value, 10)
|
||||
this.permissionsSelectionModel.userID.set(
|
||||
Number.parseInt(rule.value, 10)
|
||||
)
|
||||
break
|
||||
case FILTER_OWNER_ANY:
|
||||
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.OTHERS
|
||||
this.permissionsSelectionModel.ownerFilter.set(OwnerFilterType.OTHERS)
|
||||
if (rule.value)
|
||||
this.permissionsSelectionModel.includeUsers.push(
|
||||
parseInt(rule.value, 10)
|
||||
)
|
||||
this.permissionsSelectionModel.includeUsers.update((users) => [
|
||||
...users,
|
||||
Number.parseInt(rule.value, 10),
|
||||
])
|
||||
break
|
||||
case FILTER_OWNER_DOES_NOT_INCLUDE:
|
||||
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.NOT_SELF
|
||||
this.permissionsSelectionModel.ownerFilter.set(
|
||||
OwnerFilterType.NOT_SELF
|
||||
)
|
||||
if (rule.value)
|
||||
this.permissionsSelectionModel.excludeUsers.push(
|
||||
parseInt(rule.value, 10)
|
||||
)
|
||||
this.permissionsSelectionModel.excludeUsers.update((users) => [
|
||||
...users,
|
||||
Number.parseInt(rule.value, 10),
|
||||
])
|
||||
break
|
||||
case FILTER_SHARED_BY_USER:
|
||||
this.permissionsSelectionModel.ownerFilter =
|
||||
this.permissionsSelectionModel.ownerFilter.set(
|
||||
OwnerFilterType.SHARED_BY_ME
|
||||
)
|
||||
if (rule.value)
|
||||
this.permissionsSelectionModel.userID = parseInt(rule.value, 10)
|
||||
this.permissionsSelectionModel.userID.set(
|
||||
Number.parseInt(rule.value, 10)
|
||||
)
|
||||
break
|
||||
case FILTER_OWNER_ISNULL:
|
||||
if (rule.value === 'true' || rule.value === '1') {
|
||||
this.permissionsSelectionModel.hideUnowned = false
|
||||
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.UNOWNED
|
||||
this.permissionsSelectionModel.hideUnowned.set(false)
|
||||
this.permissionsSelectionModel.ownerFilter.set(
|
||||
OwnerFilterType.UNOWNED
|
||||
)
|
||||
} else {
|
||||
this.permissionsSelectionModel.hideUnowned =
|
||||
this.permissionsSelectionModel.hideUnowned.set(
|
||||
rule.value === 'false' || rule.value === '0'
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -1074,34 +1086,35 @@ export class FilterEditorComponent
|
||||
})
|
||||
}
|
||||
}
|
||||
if (this.permissionsSelectionModel.ownerFilter == OwnerFilterType.SELF) {
|
||||
if (this.permissionsSelectionModel.ownerFilter() == OwnerFilterType.SELF) {
|
||||
filterRules.push({
|
||||
rule_type: FILTER_OWNER,
|
||||
value: this.permissionsSelectionModel.userID.toString(),
|
||||
value: this.permissionsSelectionModel.userID().toString(),
|
||||
})
|
||||
} else if (
|
||||
this.permissionsSelectionModel.ownerFilter == OwnerFilterType.NOT_SELF
|
||||
this.permissionsSelectionModel.ownerFilter() == OwnerFilterType.NOT_SELF
|
||||
) {
|
||||
filterRules.push({
|
||||
rule_type: FILTER_OWNER_DOES_NOT_INCLUDE,
|
||||
value: this.permissionsSelectionModel.excludeUsers?.join(','),
|
||||
value: this.permissionsSelectionModel.excludeUsers()?.join(','),
|
||||
})
|
||||
} else if (
|
||||
this.permissionsSelectionModel.ownerFilter == OwnerFilterType.OTHERS
|
||||
this.permissionsSelectionModel.ownerFilter() == OwnerFilterType.OTHERS
|
||||
) {
|
||||
filterRules.push({
|
||||
rule_type: FILTER_OWNER_ANY,
|
||||
value: this.permissionsSelectionModel.includeUsers?.join(','),
|
||||
value: this.permissionsSelectionModel.includeUsers()?.join(','),
|
||||
})
|
||||
} else if (
|
||||
this.permissionsSelectionModel.ownerFilter == OwnerFilterType.SHARED_BY_ME
|
||||
this.permissionsSelectionModel.ownerFilter() ==
|
||||
OwnerFilterType.SHARED_BY_ME
|
||||
) {
|
||||
filterRules.push({
|
||||
rule_type: FILTER_SHARED_BY_USER,
|
||||
value: this.permissionsSelectionModel.userID.toString(),
|
||||
value: this.permissionsSelectionModel.userID().toString(),
|
||||
})
|
||||
} else if (
|
||||
this.permissionsSelectionModel.ownerFilter == OwnerFilterType.UNOWNED
|
||||
this.permissionsSelectionModel.ownerFilter() == OwnerFilterType.UNOWNED
|
||||
) {
|
||||
filterRules.push({
|
||||
rule_type: FILTER_OWNER_ISNULL,
|
||||
@@ -1109,7 +1122,7 @@ export class FilterEditorComponent
|
||||
})
|
||||
}
|
||||
|
||||
if (this.permissionsSelectionModel.hideUnowned) {
|
||||
if (this.permissionsSelectionModel.hideUnowned()) {
|
||||
filterRules.push({
|
||||
rule_type: FILTER_OWNER_ISNULL,
|
||||
value: 'false',
|
||||
|
||||
@@ -210,6 +210,48 @@ describe('SettingsService', () => {
|
||||
expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('#000000')
|
||||
})
|
||||
|
||||
it('provides stable signals that update when settings change', () => {
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}ui_settings/`
|
||||
)
|
||||
req.flush(ui_settings)
|
||||
|
||||
const notesEnabled = settingsService.getSignal<boolean>(
|
||||
SETTINGS_KEYS.NOTES_ENABLED
|
||||
)
|
||||
|
||||
expect(notesEnabled()).toBeTruthy()
|
||||
expect(
|
||||
settingsService.getSignal<boolean>(SETTINGS_KEYS.NOTES_ENABLED)
|
||||
).toBe(notesEnabled)
|
||||
|
||||
settingsService.set(SETTINGS_KEYS.NOTES_ENABLED, false)
|
||||
|
||||
expect(notesEnabled()).toBeFalsy()
|
||||
})
|
||||
|
||||
it('updates setting signals when settings are reinitialized', () => {
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}ui_settings/`
|
||||
)
|
||||
req.flush(ui_settings)
|
||||
const appTitle = settingsService.getSignal<string>(SETTINGS_KEYS.APP_TITLE)
|
||||
|
||||
settingsService.initializeSettings().subscribe()
|
||||
req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}ui_settings/`
|
||||
)
|
||||
req.flush({
|
||||
...ui_settings,
|
||||
settings: {
|
||||
...ui_settings.settings,
|
||||
app_title: 'Updated title',
|
||||
},
|
||||
})
|
||||
|
||||
expect(appTitle()).toBe('Updated title')
|
||||
})
|
||||
|
||||
it('sets django cookie for languages', () => {
|
||||
httpTestingController
|
||||
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
|
||||
|
||||
@@ -2,6 +2,8 @@ import { HttpClient } from '@angular/common/http'
|
||||
import {
|
||||
DOCUMENT,
|
||||
EventEmitter,
|
||||
Signal,
|
||||
computed,
|
||||
inject,
|
||||
Injectable,
|
||||
LOCALE_ID,
|
||||
@@ -297,6 +299,7 @@ export class SettingsService {
|
||||
|
||||
private settings: Record<string, any> = {}
|
||||
private readonly settingsVersion = signal(0)
|
||||
private readonly settingSignals = new Map<string, Signal<unknown>>()
|
||||
readonly currentUser = signal<User>(undefined)
|
||||
|
||||
public settingsSaved: EventEmitter<any> = new EventEmitter()
|
||||
@@ -326,10 +329,6 @@ export class SettingsService {
|
||||
return !UNSAFE_OBJECT_KEYS.has(key)
|
||||
}
|
||||
|
||||
public trackChanges(): void {
|
||||
this.settingsVersion()
|
||||
}
|
||||
|
||||
private assignSafeSettings(source: Record<string, any>) {
|
||||
if (!source || typeof source !== 'object' || Array.isArray(source)) {
|
||||
return
|
||||
@@ -339,6 +338,7 @@ export class SettingsService {
|
||||
if (!this.isSafeObjectKey(key)) continue
|
||||
this.settings[key] = source[key]
|
||||
}
|
||||
this.settingsVersion.update((version) => version + 1)
|
||||
}
|
||||
|
||||
// this is called by the app initializer in app.module
|
||||
@@ -594,6 +594,18 @@ export class SettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
getSignal<T = any>(key: string): Signal<T> {
|
||||
let settingSignal = this.settingSignals.get(key)
|
||||
if (!settingSignal) {
|
||||
settingSignal = computed(() => {
|
||||
this.settingsVersion()
|
||||
return this.get(key)
|
||||
})
|
||||
this.settingSignals.set(key, settingSignal)
|
||||
}
|
||||
return settingSignal as Signal<T>
|
||||
}
|
||||
|
||||
set(key: string, value: any) {
|
||||
// parse key:key:key into nested object
|
||||
let settingObj = this.settings
|
||||
|
||||
@@ -8,7 +8,7 @@ export const environment = {
|
||||
apiVersion: '10', // match src/paperless/settings.py
|
||||
appTitle: DEFAULT_APP_TITLE,
|
||||
tag: 'prod',
|
||||
version: '3.1.2',
|
||||
version: '3.1.3',
|
||||
webSocketHost: window.location.host,
|
||||
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
||||
webSocketBaseUrl: base_url.pathname + 'ws/',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+10
-10
@@ -507,8 +507,8 @@ def rotate(
|
||||
logger.info(
|
||||
f"Queued new rotated version for document {pair.root_doc.id} by {degrees} degrees",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(f"Error rotating document {pair.root_doc.id}")
|
||||
except Exception as e:
|
||||
logger.exception(f"Error rotating document {pair.root_doc.id}: {e}")
|
||||
|
||||
return "OK"
|
||||
|
||||
@@ -554,9 +554,9 @@ def merge(
|
||||
affected_docs.append(doc.id)
|
||||
if handoff_asn is None and doc.archive_serial_number is not None:
|
||||
handoff_asn = doc.archive_serial_number
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Error merging document {doc.id}, it will not be included in the merge",
|
||||
f"Error merging document {doc.id}, it will not be included in the merge: {e}",
|
||||
)
|
||||
if len(affected_docs) == 0:
|
||||
logger.warning("No documents were merged")
|
||||
@@ -805,8 +805,8 @@ def split(
|
||||
else:
|
||||
group(consume_tasks).delay()
|
||||
|
||||
except Exception:
|
||||
logger.exception(f"Error splitting document {doc.id}")
|
||||
except Exception as e:
|
||||
logger.exception(f"Error splitting document {doc.id}: {e}")
|
||||
|
||||
return "OK"
|
||||
|
||||
@@ -858,8 +858,8 @@ def delete_pages(
|
||||
logger.info(
|
||||
f"Queued new version for document {pair.root_doc.id} after deleting pages {pages}",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(f"Error deleting pages from document {pair.root_doc.id}")
|
||||
except Exception as e:
|
||||
logger.exception(f"Error deleting pages from document {pair.root_doc.id}: {e}")
|
||||
|
||||
return "OK"
|
||||
|
||||
@@ -986,7 +986,7 @@ def edit_pdf(
|
||||
group(consume_tasks).delay()
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error editing document {pair.root_doc.id}")
|
||||
logger.exception(f"Error editing document {pair.root_doc.id}: {e}")
|
||||
raise ValueError(
|
||||
f"An error occurred while editing the document: {e}",
|
||||
) from e
|
||||
@@ -1097,7 +1097,7 @@ def remove_password(
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Error removing password from document {pair.root_doc.id}",
|
||||
f"Error removing password from document {pair.root_doc.id}: {e}",
|
||||
)
|
||||
raise ValueError(
|
||||
f"An error occurred while removing the password: {e}",
|
||||
|
||||
@@ -72,8 +72,8 @@ def load_classifier(*, raise_exception: bool = False) -> DocumentClassifier | No
|
||||
Path(settings.MODEL_FILE).unlink()
|
||||
classifier = None
|
||||
if raise_exception:
|
||||
raise
|
||||
except ClassifierModelCorruptError:
|
||||
raise e
|
||||
except ClassifierModelCorruptError as e:
|
||||
# there's something wrong with the model file.
|
||||
logger.exception(
|
||||
"Unrecoverable error while loading document "
|
||||
@@ -82,17 +82,17 @@ def load_classifier(*, raise_exception: bool = False) -> DocumentClassifier | No
|
||||
Path(settings.MODEL_FILE).unlink()
|
||||
classifier = None
|
||||
if raise_exception:
|
||||
raise
|
||||
except OSError:
|
||||
raise e
|
||||
except OSError as e:
|
||||
logger.exception("IO error while loading document classification model")
|
||||
classifier = None
|
||||
if raise_exception:
|
||||
raise
|
||||
except Exception: # pragma: no cover
|
||||
raise e
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.exception("Unknown error while loading document classification model")
|
||||
classifier = None
|
||||
if raise_exception:
|
||||
raise
|
||||
raise e
|
||||
|
||||
return classifier
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ class ConsumerPluginMixin:
|
||||
current_progress,
|
||||
max_progress,
|
||||
document_id=document_id,
|
||||
owner_id=self.metadata.owner_id or None,
|
||||
owner_id=self.metadata.owner_id if self.metadata.owner_id else None,
|
||||
users_can_view=(self.metadata.view_users or [])
|
||||
+ (self.metadata.change_users or []),
|
||||
groups_can_view=(self.metadata.view_groups or [])
|
||||
@@ -675,7 +675,9 @@ class ConsumerPlugin(
|
||||
document=document,
|
||||
logging_group=self.logging_group,
|
||||
classifier=classifier,
|
||||
original_file=self.unmodified_original or self.working_copy,
|
||||
original_file=self.unmodified_original
|
||||
if self.unmodified_original
|
||||
else self.working_copy,
|
||||
)
|
||||
|
||||
# After everything is in the database, copy the files into
|
||||
@@ -856,7 +858,7 @@ class ConsumerPlugin(
|
||||
else:
|
||||
stats = Path(self.input_doc.original_file).stat()
|
||||
create_date = timezone.make_aware(
|
||||
datetime.datetime.fromtimestamp(stats.st_mtime), # noqa: DTZ006 - make_aware() requires a naive datetime
|
||||
datetime.datetime.fromtimestamp(stats.st_mtime),
|
||||
)
|
||||
self.log.debug(f"Creation date from st_mtime: {create_date}")
|
||||
|
||||
@@ -970,7 +972,7 @@ class ConsumerPlugin(
|
||||
try:
|
||||
copy_basic_file_stats(source, target)
|
||||
except Exception: # pragma: no cover
|
||||
self.log.debug("Unable to copy file stats from %s to %s", source, target)
|
||||
pass
|
||||
|
||||
|
||||
class ConsumerPreflightPlugin(
|
||||
|
||||
@@ -78,9 +78,7 @@ class CollatePlugin(NoCleanupPluginMixin, NoSetupPluginMixin, ConsumeTaskPlugin)
|
||||
stats = staging.stat()
|
||||
# if the file is older than the timeout, we don't consider
|
||||
# it valid
|
||||
if (
|
||||
dt.datetime.now(tz=dt.UTC).timestamp() - stats.st_mtime
|
||||
) > TIMEOUT_SECONDS:
|
||||
if (dt.datetime.now().timestamp() - stats.st_mtime) > TIMEOUT_SECONDS:
|
||||
logger.warning("Outdated double sided staging file exists, deleting it")
|
||||
staging.unlink()
|
||||
else:
|
||||
@@ -136,7 +134,7 @@ class CollatePlugin(NoCleanupPluginMixin, NoSetupPluginMixin, ConsumeTaskPlugin)
|
||||
shutil.move(pdf_file, staging)
|
||||
# update access to modification time so we know if the file
|
||||
# is outdated when another file gets uploaded
|
||||
timestamp = dt.datetime.now(tz=dt.UTC).timestamp()
|
||||
timestamp = dt.datetime.now().timestamp()
|
||||
os.utime(staging, (timestamp, timestamp))
|
||||
logger.info(
|
||||
"Got scan with odd numbered pages of double-sided scan, moved it to %s",
|
||||
|
||||
@@ -734,7 +734,7 @@ class CustomFieldQueryParser:
|
||||
)
|
||||
|
||||
# Check if any of the requested IDs are missing.
|
||||
missing_ids = set(value) - {link.document_id for link in links}
|
||||
missing_ids = set(value) - set(link.document_id for link in links)
|
||||
if missing_ids:
|
||||
# The result should be an empty set in this case.
|
||||
return Q(id__in=[])
|
||||
|
||||
@@ -314,7 +314,7 @@ def _consume_file(
|
||||
consumption_dir: Path,
|
||||
*,
|
||||
subdirs_as_tags: bool,
|
||||
) -> None:
|
||||
) -> bool:
|
||||
"""
|
||||
Queue a file for consumption.
|
||||
|
||||
@@ -322,15 +322,20 @@ def _consume_file(
|
||||
filepath: Path to the file to consume.
|
||||
consumption_dir: Base consumption directory.
|
||||
subdirs_as_tags: Whether to create tags from subdirectory names.
|
||||
|
||||
Returns:
|
||||
True if the file was successfully handed to Celery, False otherwise.
|
||||
Callers must not record the file as queued on failure, or the rescan
|
||||
will never retry it.
|
||||
"""
|
||||
# Verify file still exists and is accessible
|
||||
try:
|
||||
if not filepath.is_file():
|
||||
logger.debug(f"Not consuming {filepath}: not a file or doesn't exist")
|
||||
return
|
||||
return False
|
||||
except OSError as e:
|
||||
logger.warning(f"Not consuming {filepath}: {e}")
|
||||
return
|
||||
return False
|
||||
|
||||
# Get tags from path if configured
|
||||
tag_ids: list[int] | None = None
|
||||
@@ -355,6 +360,9 @@ def _consume_file(
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(f"Error while queuing document {filepath}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -492,12 +500,12 @@ class Command(BaseCommand):
|
||||
if not consumer_filter(Change.added, str(filepath)):
|
||||
continue
|
||||
|
||||
_consume_file(
|
||||
if _consume_file(
|
||||
filepath=filepath,
|
||||
consumption_dir=directory,
|
||||
subdirs_as_tags=subdirs_as_tags,
|
||||
)
|
||||
queued.add(filepath.resolve())
|
||||
):
|
||||
queued.add(filepath.resolve())
|
||||
|
||||
return queued
|
||||
|
||||
@@ -631,36 +639,36 @@ class Command(BaseCommand):
|
||||
):
|
||||
# Process each change
|
||||
for change_type, path in changes:
|
||||
resolved_path = Path(path).resolve()
|
||||
path = Path(path).resolve()
|
||||
if change_type == Change.deleted:
|
||||
# Consumed (or otherwise removed); a later file
|
||||
# reusing this name must not be skipped as
|
||||
# already-queued.
|
||||
queued.discard(resolved_path)
|
||||
if not resolved_path.is_file():
|
||||
queued.discard(path)
|
||||
if not path.is_file():
|
||||
continue
|
||||
if resolved_path in queued:
|
||||
if path in queued:
|
||||
# Already queued and awaiting consumption; a stray
|
||||
# event (NAS metadata touch, AV scan, etc.) while
|
||||
# the file sits on disk mid-consumption must not
|
||||
# cause it to be queued a second time (GH #13511).
|
||||
logger.debug(
|
||||
f"Ignoring event for queued file: {resolved_path}",
|
||||
)
|
||||
logger.debug(f"Ignoring event for queued file: {path}")
|
||||
continue
|
||||
logger.debug(f"Event: {change_type.name} for {resolved_path}")
|
||||
tracker.track(resolved_path, change_type)
|
||||
logger.debug(f"Event: {change_type.name} for {path}")
|
||||
tracker.track(path, change_type)
|
||||
|
||||
# Check for stable files
|
||||
for stable_path in tracker.get_stable_files():
|
||||
_consume_file(
|
||||
# Only remember files that were actually queued, so the
|
||||
# rescan does not re-queue them while the consume task
|
||||
# has yet to remove them from disk, but does retry a
|
||||
# failed publish instead of stranding it
|
||||
if _consume_file(
|
||||
filepath=stable_path,
|
||||
consumption_dir=directory,
|
||||
subdirs_as_tags=subdirs_as_tags,
|
||||
)
|
||||
# Remember it so the rescan does not re-queue it while
|
||||
# the consume task has yet to remove it from disk
|
||||
queued.add(stable_path)
|
||||
):
|
||||
queued.add(stable_path)
|
||||
|
||||
# Exit watch loop to reconfigure timeout
|
||||
break
|
||||
|
||||
@@ -30,10 +30,6 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger("paperless.matching")
|
||||
|
||||
|
||||
class UnsupportedWorkflowTriggerTypeError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def log_reason(
|
||||
matching_model: MatchingModel | WorkflowTrigger,
|
||||
document: Document,
|
||||
@@ -695,9 +691,7 @@ def document_matches_workflow(
|
||||
)
|
||||
else:
|
||||
# New trigger types need to be explicitly checked above
|
||||
raise UnsupportedWorkflowTriggerTypeError(
|
||||
f"Trigger type {trigger_type} not yet supported",
|
||||
)
|
||||
raise Exception(f"Trigger type {trigger_type} not yet supported")
|
||||
|
||||
if trigger_matched:
|
||||
logger.info(f"Document matched {trigger} from {workflow}")
|
||||
|
||||
@@ -75,7 +75,7 @@ def recompute_checksums(apps, schema_editor):
|
||||
if updated_fields:
|
||||
batch.append(doc)
|
||||
|
||||
processed += 1 # noqa: SIM113
|
||||
processed += 1
|
||||
|
||||
if len(batch) >= _BATCH_SIZE:
|
||||
Document.objects.bulk_update(batch, ["checksum", "archive_checksum"])
|
||||
|
||||
@@ -377,7 +377,7 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
||||
from documents.versioning import versions_newest_first
|
||||
|
||||
if hasattr(self, "effective_content"):
|
||||
return self.effective_content
|
||||
return getattr(self, "effective_content")
|
||||
|
||||
if self.root_document_id is not None or self.pk is None:
|
||||
return self.content
|
||||
|
||||
@@ -41,7 +41,7 @@ def get_default_file_extension(mime_type: str) -> str:
|
||||
return supported[mime_type]
|
||||
|
||||
ext = mimetypes.guess_extension(mime_type)
|
||||
return ext or ""
|
||||
return ext if ext else ""
|
||||
|
||||
|
||||
def is_file_ext_supported(ext: str) -> bool:
|
||||
@@ -110,7 +110,7 @@ def run_convert(
|
||||
args += ["-define", "pdf:use-cropbox=true"] if use_cropbox else []
|
||||
args += [str(input_file), str(output_file)]
|
||||
|
||||
logger.debug("Execute: %s", " ".join(args), extra={"group": logging_group})
|
||||
logger.debug("Execute: " + " ".join(args), extra={"group": logging_group})
|
||||
|
||||
try:
|
||||
run_subprocess(args, environment, logger)
|
||||
|
||||
@@ -43,8 +43,8 @@ def _discover_parser_class() -> type[DateParserPluginBase]:
|
||||
valid_plugins.append(ep)
|
||||
else:
|
||||
logger.warning(f"Plugin {ep.name} does not subclass DateParser.")
|
||||
except Exception:
|
||||
logger.exception(f"Unable to load date parser plugin {ep.name}")
|
||||
except Exception as e:
|
||||
logger.exception(f"Unable to load date parser plugin {ep.name}: {e}")
|
||||
|
||||
if not valid_plugins:
|
||||
return RegexDateParserPlugin
|
||||
|
||||
@@ -91,8 +91,8 @@ class DateParserPluginBase(ABC):
|
||||
},
|
||||
locales=self.config.languages,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(f"Error while parsing date string '{date_string}'")
|
||||
except Exception as e:
|
||||
logger.exception(f"Error while parsing date string '{date_string}': {e}")
|
||||
return None
|
||||
|
||||
def _filter_date(
|
||||
|
||||
@@ -59,10 +59,11 @@ def safe_regex_match(pattern: str, text: str, *, flags: int = 0):
|
||||
try:
|
||||
validate_regex_pattern(pattern)
|
||||
compiled = regex.compile(pattern, flags=flags)
|
||||
except (regex.error, ValueError):
|
||||
except (regex.error, ValueError) as exc:
|
||||
logger.exception(
|
||||
"Error while processing regular expression %s",
|
||||
"Error while processing regular expression %s: %s",
|
||||
textwrap.shorten(pattern, width=80, placeholder="…"),
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -85,10 +86,11 @@ def safe_regex_sub(pattern: str, repl: str, text: str, *, flags: int = 0) -> str
|
||||
try:
|
||||
validate_regex_pattern(pattern)
|
||||
compiled = regex.compile(pattern, flags=flags)
|
||||
except (regex.error, ValueError):
|
||||
except (regex.error, ValueError) as exc:
|
||||
logger.exception(
|
||||
"Error while processing regular expression %s",
|
||||
"Error while processing regular expression %s: %s",
|
||||
textwrap.shorten(pattern, width=80, placeholder="…"),
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@@ -1142,7 +1142,7 @@ def get_backend() -> TantivyBackend:
|
||||
Returns:
|
||||
Thread-safe singleton TantivyBackend instance
|
||||
"""
|
||||
global _backend, _backend_path # noqa: PLW0603 - module-level singleton, no class to hold this state
|
||||
global _backend, _backend_path
|
||||
|
||||
current_path: Path = settings.INDEX_DIR
|
||||
|
||||
@@ -1173,7 +1173,7 @@ def reset_backend() -> None:
|
||||
Forces creation of a new backend instance on the next get_backend() call.
|
||||
Used for test isolation and when switching between different index directories.
|
||||
"""
|
||||
global _backend, _backend_path # noqa: PLW0603 - module-level singleton, no class to hold this state
|
||||
global _backend, _backend_path
|
||||
|
||||
with _backend_lock:
|
||||
if _backend is not None:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user