Compare commits

..
Author SHA1 Message Date
stumpylog ee19e8dcff Remove old rules we don't actually need still 2026-09-01 15:29:53 -07:00
stumpylog b9cf877029 Enables more rules for migration dirs 2026-09-01 15:27:43 -07:00
stumpylog 82cc1016ad More linking 2026-09-01 15:23:56 -07:00
stumpylog dcedb531c6 Fixes misisng plw link 2026-09-01 15:18:43 -07:00
stumpylog 8bedb07cea Fixes doc link for PLR 2026-09-01 15:17:38 -07:00
stumpylog 5e34d566a2 Enables 'LOG' broadly 2026-09-01 15:16:29 -07:00
stumpylog 601ecce3f1 Adds 'INT' broadly 2026-09-01 15:15:47 -07:00
stumpylog d67beba9f6 Enables 'G' more broadly 2026-09-01 15:11:39 -07:00
stumpylog 3bc8ee8425 Fixes linting and does a little formatting 2026-09-01 15:09:43 -07:00
stumpylog fdef4a99a7 Chore: enable flake8-datetimez (DTZ) ruff rules
Full category (10/10 codes are all default in ruff 0.16). Of 54
hits, 46 were in test fixture code constructing naive datetimes for
comparison/input purposes only - added DTZ to the existing
per-file-ignores for */tests/*.py alongside E501/SIM117.

The 8 production hits:
- documents/consumer.py, documents/views.py (index_last_modified):
  suppressed with noqa - timezone.make_aware() requires a naive
  datetime, so wrapping fromtimestamp() in tz= would break it
- documents/double_sided.py (x2): switched to
  datetime.now(tz=UTC).timestamp() - behavior-identical since
  .timestamp() returns the same epoch value regardless of the
  attached tz, but now explicit
- documents/views.py (x2, upload temp file mtime): suppressed with
  noqa - mktime() requires a local time tuple, so an aware/UTC now()
  would introduce a timezone-offset bug
- documents/workflows/ai.py (AI suggested date parsing): suppressed
  with noqa - only .date() is used, time/tz is discarded
- paperless_mail/mail.py (IMAP fetch date filter): switched
  date.today() to timezone.localdate(), which respects
  settings.TIME_ZONE instead of the system clock - a real
  correctness improvement when they differ
- paperless_mail/views.py (placeholder name string): switched
  datetime.datetime.now() to timezone.now(), matching the app's
  existing aware-datetime convention
2026-09-01 15:03:18 -07:00
stumpylog d16d05a391 Chore: enable tryceratops (TRY002/004/201/203/401) ruff rules
Only the 5 default-subset codes; the rest of tryceratops is opt-in.

- 9 TRY201 (raise e -> raise) autofixed, preserving the traceback
  identically while dropping the redundant exception name
- 26 TRY401 (redundant exception object passed to logger.exception,
  which already logs it) fixed by removing the duplicate from the
  message; three sites still needed the exception object for
  something else (re-raising, or a separate logger.error call) and
  kept their binding
- 6 TRY002 (raise bare Exception): 2 production sites (documents/
  matching.py, paperless_mail/preprocessor.py) got dedicated
  exception classes, with their tests narrowed to match instead of
  asserting a blind Exception; the other 4 are deliberate generic
  failures in test doubles/fixtures, suppressed with noqa
2026-09-01 15:03:18 -07:00
stumpylog 10789e63cb Chore: enable pylint warning (PLW) ruff rules
Full category (not just the ruff-0.16 default subset). 35 hits:
- 4 PLW0108 (unnecessary lambda) autofixed
- 6 PLW2901 (loop/with variable shadowed) renamed to distinct names
- 2 PLW1510 (subprocess.run without explicit check) given check=False,
  matching existing behavior exactly
- 6 PLW0602 (global declared but never assigned) removed - these were
  all in-place mutations (.append/.insert), not reassignments, so
  `global` was already a no-op
- 7 PLW0603 (global statement) suppressed with noqa - these are
  genuine lazy-init singletons with no class to hold the state;
  refactoring them is a separate, larger change
- 6 PLW1508 (non-str/None env var default) fixed using the existing
  get_int_from_env/get_float_from_env typed helpers instead of raw
  os.getenv, which also fixes a real bug: LOGROTATE_MAX_SIZE and
  LOGROTATE_MAX_BACKUPS were never wrapped in int(), so a string env
  var value would have flowed into RotatingFileHandler as a string
- 1 PLW1641 (__eq__ without __hash__) fixed by adding __hash__ to
  PlaceholderString
2026-09-01 15:03:18 -07:00
stumpylog 2197781b39 Chore: enable flake8-gettext (INT001/002/003) ruff rules
All 4 hits in documents/validators.py were f-strings inside gettext
_() calls, which resolves the string before translation and breaks
extraction (confirmed: locale .po files literally contain the raw
"{value}" placeholder as msgid text). Fixed by using %(name)s-style
placeholders with Django ValidationError's existing params= kwarg,
which was already being passed but silently unused.
2026-09-01 15:03:17 -07:00
stumpylog 981492bb33 Chore: enable flake8-bandit S102/S110/S112 ruff rules
3 S110 (try-except-pass) hits, all fixed by adding a log call in the
except block rather than silently swallowing the exception, matching
this codebase's existing %s lazy-formatting logging convention.
Behavior is unchanged (still no re-raise) in all three spots.
2026-09-01 15:03:17 -07:00
stumpylog 0ef5ef5826 Chore: enable flake8-bugbear (B) default-subset ruff rules
3 B009 (getattr with a constant string, rewrite as attribute access)
hits autofixed. 7 B017 (assert blind Exception) hits: one narrowed
to the actual ValueError raised by bulk_edit.edit_pdf, the other six
suppressed with noqa since the code under test genuinely raises (or
a mock genuinely injects) a bare Exception, so a narrower assertion
would be wrong.

Only the 29 B codes ruff 0.16 enables by default; the rest of
flake8-bugbear needs a separate, deliberate decision.
2026-09-01 15:03:17 -07:00
stumpylog a6b1763149 Chore: enable flake8-logging-format G101/G202 ruff rules
G202 (redundant exc_info=True passed to logger.exception, which
already includes the traceback) had 2 hits in documents/views.py,
fixed manually since ruff has no autofix for it. G101 (hardcoded
password string) had zero hits.
2026-09-01 15:03:17 -07:00
stumpylog 90531525e2 Chore: enable flake8-2020 (YTT) ruff rules
Zero current violations. Full category (10/10 codes are all part of
ruff 0.16's default rule set already, so there's no non-default
subset to defer).
2026-09-01 15:03:17 -07:00
stumpylog 8f00bfa931 Chore: enable flake8-debugger T100 ruff rule
Zero current violations. Only T100 (import of pdb/ipdb/etc.) is part
of ruff 0.16's default rule set.
2026-09-01 15:03:17 -07:00
stumpylog a0479f1d9b Chore: enable flake8-pytest-style (PT) default-subset ruff rules
Zero current violations. Only the 6 PT codes ruff 0.16 enables by
default; the full flake8-pytest-style linter has thousands of hits
here and needs a separate, deliberate decision.
2026-09-01 15:03:17 -07:00
stumpylog 9b8bd21044 Chore: enable pylint refactor (PLR) default-subset ruff rules
Zero current violations. Only the 13 PLR codes ruff 0.16 enables by
default; the rest of pylint-refactor (e.g. PLR2004, PLR0913) has
hundreds of hits here and needs a separate, deliberate decision.
2026-09-01 15:03:17 -07:00
stumpylog a60172bc6f Chore: enable pygrep-hooks PGH005 ruff rule
Zero current violations. Only PGH005 (invalid-mock-methods) is part
of ruff 0.16's default rule set; the rest of pygrep-hooks is opt-in.
2026-09-01 15:03:17 -07:00
stumpylog 6c5bc1c0ff Chore: enable pep8-naming N999 ruff rule
Zero current violations. Only N999 (invalid-module-name) is part of
ruff 0.16's default rule set; the rest of pep8-naming is opt-in.
2026-09-01 15:03:17 -07:00
stumpylog f4a7c478a9 Chore: enable flake8-logging (LOG001/002/009/014/015) ruff rules
Zero current violations. Only these five LOG codes are part of
ruff 0.16's default rule set; the rest of the linter is opt-in.
2026-09-01 15:03:17 -07:00
stumpylog bc07c19d9b Chore: enable pydocstyle D419 ruff rule
Zero current violations. Only D419 (empty-docstring) is part of
ruff 0.16's default rule set; the rest of pydocstyle is opt-in.
2026-09-01 15:03:16 -07:00
stumpylog bfcee24572 Chore: enable flake8-async (ASYNC) ruff rules
Zero current violations. Full category (not just the ruff-0.16
default subset) since the rest is equally applicable async-blocking
guidance for this codebase's Channels/websocket code.
2026-09-01 15:03:16 -07:00
stumpylog 4fec4b0948 Chore: enable FA, G010, and PERF101/102/402 ruff rules
All part of ruff 0.16's expanded default rule set. FA and G010 had
zero existing violations; PERF402's one occurrence needed a manual
fix since ruff can't safely autofix a multi-line call expression.
2026-09-01 15:03:16 -07:00
stumpylog 3e4ffc4132 Chore: enable refurb (FURB) ruff rules
FURB is part of ruff 0.16's expanded default rule set and is
almost entirely autofixable.
2026-09-01 15:03:16 -07:00
stumpylog 6d61bcee7e Chore: enable flake8-comprehensions (C4) ruff rules
C4 is part of ruff 0.16's expanded default rule set and is almost
entirely autofixable, making it a low-risk first step towards
adopting the new defaults.
2026-09-01 15:03:16 -07:00
238 changed files with 28767 additions and 29464 deletions
+1
View File
@@ -38,6 +38,7 @@ body:
label: Installation method
options:
- Docker - official image
- Docker - linuxserver.io image
- Bare metal
- Other (please describe above)
- type: textarea
+2
View File
@@ -87,8 +87,10 @@ 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
+3 -3
View File
@@ -63,7 +63,7 @@ jobs:
- name: Detect changes
id: filter
if: steps.force.outputs.run_all != 'true'
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
with:
base: ${{ steps.range.outputs.base }}
ref: ${{ steps.range.outputs.ref }}
@@ -100,7 +100,7 @@ jobs:
with:
python-version: "${{ matrix.python-version }}"
- name: Install uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
@@ -178,7 +178,7 @@ jobs:
with:
python-version: "${{ env.DEFAULT_PYTHON }}"
- name: Install uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
+6 -6
View File
@@ -106,9 +106,9 @@ jobs:
echo "repository=${repo_name}"
echo "name=${repo_name}" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -182,22 +182,22 @@ jobs:
echo "Downloaded digests:"
ls -la /tmp/digests/
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
if: needs.build-arch.outputs.push-external == 'true'
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to Quay.io
if: needs.build-arch.outputs.push-external == 'true'
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
+2 -2
View File
@@ -50,7 +50,7 @@ jobs:
- name: Detect changes
id: filter
if: steps.force.outputs.run_all != 'true'
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
with:
base: ${{ steps.range.outputs.base }}
ref: ${{ steps.range.outputs.ref }}
@@ -78,7 +78,7 @@ jobs:
with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
+7 -7
View File
@@ -60,7 +60,7 @@ jobs:
- name: Detect changes
id: filter
if: steps.force.outputs.run_all != 'true'
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
with:
base: ${{ steps.range.outputs.base }}
ref: ${{ steps.range.outputs.ref }}
@@ -81,7 +81,7 @@ jobs:
with:
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
package_json_file: src-ui/package.json
- name: Use Node.js 24
@@ -113,7 +113,7 @@ jobs:
with:
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
package_json_file: src-ui/package.json
- name: Use Node.js 24
@@ -152,7 +152,7 @@ jobs:
with:
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
package_json_file: src-ui/package.json
- name: Use Node.js 24
@@ -201,7 +201,7 @@ jobs:
with:
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
package_json_file: src-ui/package.json
- name: Use Node.js 24
@@ -216,7 +216,7 @@ jobs:
with:
python-version: '3.12'
- name: Install uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
version: '0.12.x'
enable-cache: false
@@ -255,7 +255,7 @@ jobs:
fetch-depth: 2
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
package_json_file: src-ui/package.json
- name: Use Node.js 24
+1 -1
View File
@@ -25,4 +25,4 @@ jobs:
with:
python-version: "3.14"
- name: Run prek
uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0
uses: j178/prek-action@5337cb91e0fa35a7ff31b9ca345126d8bbbcdf16 # v2.0.6
+6 -6
View File
@@ -20,7 +20,7 @@ jobs:
statuses: read
steps:
- name: Wait for Docker build
uses: lewagon/wait-on-check-action@369769072fe522a3a8a85c03c96af1e5242a1994 # v1.9.1
uses: lewagon/wait-on-check-action@2271c86c146b96545b4e871b855e10ffa6f50773 # v1.9.0
with:
ref: ${{ github.sha }}
check-name: 'Merge and Push Manifest'
@@ -40,7 +40,7 @@ jobs:
persist-credentials: false
# ---- Frontend Build ----
- name: Install pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
package_json_file: src-ui/package.json
- name: Use Node.js 24
@@ -59,7 +59,7 @@ jobs:
with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: false
@@ -171,7 +171,7 @@ jobs:
fi
- name: Create release and changelog
id: create-release
uses: release-drafter/release-drafter@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7.7.0
uses: release-drafter/release-drafter@eada3c96a64734dd381cfbda23511034e328ddb0 # v7.6.0
with:
name: Paperless-ngx ${{ steps.get-version.outputs.version }}
tag: ${{ steps.get-version.outputs.version }}
@@ -182,7 +182,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload release archive
uses: shogo82148/actions-upload-release-asset@aaba0f56bdbc1071f4af234d5cb16055e8a400de # v1.10.4
uses: shogo82148/actions-upload-release-asset@394b3c11c3cfc038b5396ad265c074065cf875c3 # v1.10.2
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
upload_url: ${{ steps.create-release.outputs.upload_url }}
@@ -212,7 +212,7 @@ jobs:
with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: false
+2 -2
View File
@@ -26,7 +26,7 @@ jobs:
with:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1
semgrep:
name: Semgrep CE
runs-on: ubuntu-24.04
@@ -44,7 +44,7 @@ jobs:
- name: Run Semgrep
run: semgrep scan --config auto --sarif-output results.sarif
- name: Upload results to GitHub code scanning
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
if: always()
with:
sarif_file: results.sarif
+2 -2
View File
@@ -39,7 +39,7 @@ jobs:
persist-credentials: false
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -47,4 +47,4 @@ jobs:
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
token: ${{ secrets.PNGX_BOT_PAT }}
persist-credentials: false
- name: crowdin action
uses: crowdin/github-action@e4a6c1338b4063c77d46a81875265f9e8bd76f95 # v3.0.0
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
with:
upload_translations: false
download_translations: true
+1 -1
View File
@@ -19,6 +19,6 @@ jobs:
if: github.event_name == 'pull_request_target' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login != 'dependabot'
steps:
- name: Label PR with release-drafter
uses: release-drafter/release-drafter@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7.7.0
uses: release-drafter/release-drafter@eada3c96a64734dd381cfbda23511034e328ddb0 # v7.6.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
with:
days-before-stale: 7
days-before-close: 14
+2 -2
View File
@@ -29,7 +29,7 @@ jobs:
sudo apt-get update -qq
sudo apt-get install -qq --no-install-recommends gettext
- name: Install uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
@@ -43,7 +43,7 @@ jobs:
PAPERLESS_SECRET_KEY: "ci-translate-not-a-real-secret"
run: cd src/ && uv run manage.py makemessages -l en_US -i "samples*"
- name: Install pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
package_json_file: src-ui/package.json
- name: Use Node.js 24
+3 -3
View File
@@ -50,18 +50,18 @@ repos:
- 'prettier-plugin-organize-imports@4.3.0'
# Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.5
rev: v0.16.1
hooks:
- id: ruff-check
- id: ruff-format
- repo: https://github.com/tox-dev/pyproject-fmt
rev: "v2.28.1"
rev: "v2.26.0"
hooks:
- id: pyproject-fmt
additional_dependencies: [tomli]
# Dockerfile hooks
- repo: https://github.com/AleksaC/hadolint-py
rev: v2.15.1
rev: v2.14.0
hooks:
- id: hadolint
# Shell script hooks
+1 -1
View File
@@ -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.9-python3.14-trixie-slim AS s6-overlay-base
FROM ghcr.io/astral-sh/uv:0.12.5-python3.14-trixie-slim AS s6-overlay-base
WORKDIR /usr/src/s6
+3 -3
View File
@@ -4,7 +4,7 @@
# correct networking for the tests
services:
gotenberg:
image: docker.io/gotenberg/gotenberg:8.36
image: docker.io/gotenberg/gotenberg:8.34
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.13
image: docker.io/greenmail/standalone:2.1.11
hostname: greenmail
container_name: greenmail
environment:
@@ -35,7 +35,7 @@ services:
- "3143:3143" # IMAP
restart: unless-stopped
nginx:
image: docker.io/nginx:1.31.5-alpine
image: docker.io/nginx:1.31.3-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.36
image: docker.io/gotenberg/gotenberg:8.34
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.36
image: docker.io/gotenberg/gotenberg:8.34
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.36
image: docker.io/gotenberg/gotenberg:8.34
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.
+1 -1
View File
@@ -61,7 +61,7 @@ def replace_with_symlinks(
total_duplicates = 0
space_saved = 0
for file_hash, file_list in duplicate_groups.items():
for file_list in duplicate_groups.values():
# Keep the first file as the original, replace others with symlinks
original_file = file_list[0]
duplicates = file_list[1:]
-64
View File
@@ -1,69 +1,5 @@
# 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
+14 -28
View File
@@ -415,10 +415,10 @@ plain class attributes (not instance attributes or properties):
```python
class MyCustomParser:
name = "My Format Parser" # human-readable name shown in logs
version = "1.0.0" # semantic version string
author = "Acme Corp" # author / organisation
url = "https://example.com/my-parser" # docs or issue tracker
name = "My Format Parser" # human-readable name shown in logs
version = "1.0.0" # semantic version string
author = "Acme Corp" # author / organisation
url = "https://example.com/my-parser" # docs or issue tracker
```
**Declaring supported MIME types**
@@ -482,8 +482,7 @@ attribute are treated as fully local and are always considered.
@property
def can_produce_archive(self) -> bool:
"""True if parse() can produce a searchable PDF archive copy."""
return True # or False if your parser doesn't produce PDFs
return True # or False if your parser doesn't produce PDFs
@property
def requires_pdf_rendition(self) -> bool:
@@ -508,7 +507,6 @@ from types import TracebackType
from django.conf import settings
class MyCustomParser:
...
@@ -541,9 +539,8 @@ implementation is fine:
```python
from paperless.parsers import ParserContext
def configure(self, context: ParserContext) -> None:
pass # override if you need context.mailrule_id, etc.
pass # override if you need context.mailrule_id, etc.
```
**Parsing**
@@ -555,7 +552,6 @@ Raise `documents.parsers.ParseError` on any unrecoverable failure.
```python
from documents.parsers import ParseError
def parse(
self,
document_path: Path,
@@ -581,20 +577,18 @@ def get_text(self) -> str:
# Return the extracted text, or an empty string if none was found.
return self._text
def get_date(self) -> "datetime.datetime | None":
# Return a datetime extracted from the document, or None to let
# Paperless-ngx use its default date-guessing logic.
return None
def get_archive_path(self) -> Path | None:
return self._archive_path
def get_page_count(self, document_path: Path, mime_type: str) -> int | None:
# If the format doesn't have the concept of pages, return None
return count_pages(document_path)
```
**Thumbnail**
@@ -617,6 +611,7 @@ Implement them if your format supports the information; otherwise return
`None` / `[]`.
```python
def extract_metadata(
self,
document_path: Path,
@@ -624,7 +619,6 @@ def extract_metadata(
) -> "list[MetadataEntry]":
# Must never raise. Return [] if metadata cannot be read.
from paperless.parsers import MetadataEntry
return [
MetadataEntry(
namespace="https://example.com/ns/",
@@ -687,19 +681,17 @@ from paperless.parsers import ParserContext
class XmlDocumentParser:
name = "XML Parser"
name = "XML Parser"
version = "1.0.0"
author = "Acme Corp"
url = "https://example.com/xml-parser"
author = "Acme Corp"
url = "https://example.com/xml-parser"
@classmethod
def supported_mime_types(cls) -> dict[str, str]:
return {"application/xml": ".xml", "text/xml": ".xml"}
@classmethod
def score(
cls, mime_type: str, filename: str, path: Path | None = None
) -> int | None:
def score(cls, mime_type: str, filename: str, path: Path | None = None) -> int | None:
return 10
@property
@@ -712,9 +704,7 @@ class XmlDocumentParser:
def __init__(self, logging_group: object = None) -> None:
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
self._tempdir = Path(
tempfile.mkdtemp(prefix="paperless-", dir=settings.SCRATCH_DIR)
)
self._tempdir = Path(tempfile.mkdtemp(prefix="paperless-", dir=settings.SCRATCH_DIR))
self._text: str = ""
def __enter__(self) -> Self:
@@ -726,9 +716,7 @@ class XmlDocumentParser:
def configure(self, context: ParserContext) -> None:
pass
def parse(
self, document_path: Path, mime_type: str, *, produce_archive: bool = True
) -> None:
def parse(self, document_path: Path, mime_type: str, *, produce_archive: bool = True) -> None:
try:
tree = ET.parse(document_path)
self._text = " ".join(tree.getroot().itertext())
@@ -746,7 +734,6 @@ class XmlDocumentParser:
def get_thumbnail(self, document_path: Path, mime_type: str) -> Path:
from PIL import Image, ImageDraw
img = Image.new("RGB", (500, 700), color="white")
ImageDraw.Draw(img).text((10, 10), "XML Document", fill="black")
out = self._tempdir / "thumb.webp"
@@ -835,7 +822,6 @@ def _parse_string(
Parse a single date string using dateparser with configured settings.
"""
def _filter_date(
self,
date: datetime.datetime | None,
+116 -63
View File
@@ -1,6 +1,6 @@
[project]
name = "paperless-ngx"
version = "3.1.3"
version = "3.1.2"
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
readme = "README.md"
requires-python = ">=3.11"
@@ -42,8 +42,8 @@ dependencies = [
"drf-spectacular-sidecar~=2026.7.1",
"drf-writable-nested~=0.7.1",
"filelock~=3.32.0",
"flower>=2.0.1,<2.2",
"gotenberg-client[httpx]~=1.0",
"flower~=2.0.1",
"gotenberg-client~=0.14.0",
"httpx-oauth~=0.17",
"ijson>=3.5.1",
"imap-tools~=1.14.0",
@@ -56,7 +56,7 @@ dependencies = [
"llama-index-llms-ollama>=0.9.1",
"llama-index-llms-openai-like>=0.7.1",
"nltk~=3.10.0",
"ocrmypdf>=17.7,<17.11",
"ocrmypdf~=17.7.0",
"openai>=2.48",
"pathvalidate~=3.3.1",
"pdf2image~=1.17.0",
@@ -66,14 +66,14 @@ dependencies = [
"python-ipware~=3.0.0",
"python-magic~=0.4.27",
"rapidfuzz~=3.14.5",
"redis[hiredis]~=6.4.0",
"redis[hiredis]~=5.2.1",
"regex>=2026.7.19",
"scikit-learn~=1.9.0",
"sentence-transformers>=5.6.1",
"setproctitle~=1.3.4",
"sqlite-vec==0.1.9",
"tantivy~=0.26.0",
"tika-client[httpx]~=1.0",
"tika-client~=0.11.0",
"torch~=2.13.0",
"watchfiles>=1.2",
"whitenoise~=6.11",
@@ -153,6 +153,10 @@ environments = [
"sys_platform == 'linux'",
]
package = false
[[tool.uv.index]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
explicit = true
[tool.uv.sources]
# Markers are chosen to select these almost exclusively when building the Docker image
psycopg-c = [
@@ -164,10 +168,6 @@ psycopg-c = [
torch = [
{ index = "pytorch-cpu" },
]
[[tool.uv.index]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
explicit = true
[tool.ruff]
target-version = "py311"
@@ -186,68 +186,121 @@ line-ending = "lf"
# https://docs.astral.sh/ruff/rules/
select = [ "E4", "E7", "E9", "F" ]
extend-select = [
"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
"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
]
ignore = [
"DJ001",
"PLC0415",
"RUF012",
"SIM105",
"G004", # Logging statement uses f-string - good to do, but a large diff
]
# Migrations
per-file-ignores."*/migrations/*.py" = [
"E501",
"SIM",
"T201",
]
per-file-ignores."*/migrations/*.py" = []
# Testing
per-file-ignores."*/tests/*.py" = [
"E501",
"DTZ",
"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,NIN,nin"
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish"
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\
@@ -327,6 +380,12 @@ PAPERLESS_CHANNELS_BACKEND = "channels.layers.InMemoryChannelLayer"
# I don't think anything hits this, but just in case, basically infinite
PAPERLESS_TOKEN_THROTTLE_RATE = "1000/min"
[tool.coverage.report]
exclude_also = [
"if settings.AUDIT_LOG_ENABLED:",
"if AUDIT_LOG_ENABLED:",
"if TYPE_CHECKING:",
]
[tool.coverage.run]
source = [
"src/",
@@ -337,12 +396,6 @@ omit = [
"paperless/wsgi.py",
"paperless/auth.py",
]
[tool.coverage.report]
exclude_also = [
"if settings.AUDIT_LOG_ENABLED:",
"if AUDIT_LOG_ENABLED:",
"if TYPE_CHECKING:",
]
[tool.mypy-baseline]
baseline_path = ".mypy-baseline.txt"
-11
View File
@@ -48,7 +48,6 @@ def seed_database() -> None:
from django.utils import timezone
from documents.models import Correspondent
from documents.models import CustomField
from documents.models import Document
from documents.models import DocumentType
from documents.models import Note
@@ -82,16 +81,6 @@ def seed_database() -> None:
path="e2e/{created_year}/{title}",
owner=admin,
)
CustomField.objects.create(
name="Test Select Field",
data_type=CustomField.FieldDataType.SELECT,
extra_data={
"select_options": [
{"id": "abc123", "label": "Alpha"},
{"id": "def456", "label": "Beta"},
],
},
)
today = timezone.localdate()
documents = []
@@ -207,41 +207,3 @@ test('bulk edit', async ({ page }) => {
await page.getByRole('button', { name: 'Confirm' }).click()
await bulkEditPromise
})
test('permissions dropdown stays open when selecting a user', async ({
page,
}) => {
await page.goto('/documents')
await page.getByRole('button', { name: 'Permissions' }).click()
const dropdown = page.locator(
'pngx-permissions-filter-dropdown .dropdown-menu'
)
await expect(dropdown).toBeVisible()
await dropdown.locator('ng-select').click()
await page.getByRole('option', { name: 'playwright' }).click()
await expect(dropdown).toBeVisible()
await expect(dropdown.locator('.ng-value-label')).toHaveText('playwright')
})
test('custom fields query dropdown stays open when building a query', async ({
page,
}) => {
await page.goto('/documents')
await page.getByRole('button', { name: 'Custom fields' }).click()
const dropdown = page.locator(
'pngx-custom-fields-query-dropdown .dropdown-menu'
)
await expect(dropdown).toBeVisible()
// field picker
await dropdown.locator('ng-select').first().click()
await page.getByRole('option', { name: 'Test Select Field' }).click()
await expect(dropdown).toBeVisible()
// value picker, shown once the operator takes a list of options
await dropdown.locator('select').first().selectOption({ label: 'In' })
await dropdown.locator('ng-select').last().click()
await page.getByRole('option', { name: 'Alpha' }).click()
await expect(dropdown).toBeVisible()
await expect(dropdown.locator('.ng-value-label').last()).toHaveText('Alpha')
})
+259 -262
View File
File diff suppressed because it is too large Load Diff
+21 -21
View File
@@ -1,6 +1,6 @@
{
"name": "paperless-ngx-ui",
"version": "3.1.3",
"version": "3.1.2",
"scripts": {
"preinstall": "npx only-allow pnpm",
"ng": "ng",
@@ -15,16 +15,16 @@
},
"private": true,
"dependencies": {
"@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",
"@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",
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
"@ng-select/ng-select": "~24.0.2",
"@ng-select/ng-select": "~23.6.0",
"@ngneat/dirty-check-forms": "^3.0.3",
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
@@ -41,27 +41,27 @@
"rxjs": "^7.8.2",
"tslib": "^2.8.1",
"utif": "^3.1.0",
"uuid": "^14.0.2"
"uuid": "^14.0.1"
},
"devDependencies": {
"@angular-builders/jest": "^22.0.1",
"@angular-devkit/core": "^22.1.6",
"@angular-devkit/schematics": "^22.1.6",
"@angular-devkit/core": "^22.1.3",
"@angular-devkit/schematics": "^22.1.3",
"@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.6",
"@angular/cli": "22.1.6",
"@angular/compiler-cli": "~22.1.3",
"@angular/build": "22.1.3",
"@angular/cli": "22.1.3",
"@angular/compiler-cli": "~22.1.1",
"@playwright/test": "^1.62.1",
"@types/jest": "^30.0.0",
"@types/node": "^26.4.0",
"@typescript-eslint/eslint-plugin": "^8.68.0",
"@typescript-eslint/parser": "^8.68.0",
"@typescript-eslint/utils": "^8.68.0",
"eslint": "^10.9.1",
"@types/node": "^26.2.0",
"@typescript-eslint/eslint-plugin": "^8.67.0",
"@typescript-eslint/parser": "^8.67.0",
"@typescript-eslint/utils": "^8.67.0",
"eslint": "^10.8.1",
"jest": "30.4.2",
"jest-environment-jsdom": "^30.4.1",
"jest-junit": "^17.0.0",
+811 -621
View File
File diff suppressed because it is too large Load Diff
@@ -23,30 +23,17 @@
<div class="col">
<div class="card bg-light">
<div class="card-body">
<div class="card-title d-flex align-items-center flex-wrap">
<div class="card-title d-flex align-items-center">
<h6 class="mb-0">
{{option.title}}
</h6>
<a class="btn btn-sm btn-link" title="Read the documentation about this setting" i18n-title [href]="getDocsUrl(option.config_key)" target="_blank" referrerpolicy="no-referrer">
<i-bs name="info-circle"></i-bs>
</a>
@if (isExternallyConfigured(option.config_key)) {
@if (isSet(option.key)) {
<span class="badge rounded-pill bg-body-secondary text-dark fw-normal" title="This value overrides {{option.config_key}}, which is set outside Paperless." i18n-title>Overrides external</span>
} @else {
<span class="badge rounded-pill bg-body-secondary text-dark fw-normal" title="{{option.config_key}} is set outside Paperless. Enter a value here to override it." i18n-title>Set externally</span>
}
}
@if (isSet(option.key)) {
@if (isExternallyConfigured(option.config_key)) {
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Use the externally configured value" i18n-title (click)="resetOption(option.key)">
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset to external</ng-container>
</button>
} @else {
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)">
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
</button>
}
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)">
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
</button>
}
</div>
<div class="mb-n3">
@@ -163,19 +163,6 @@ describe('ConfigComponent', () => {
expect(component.configForm.get('barcodes_enabled').value).toBeNull()
})
it('should identify externally configured options', () => {
component.externallyConfiguredVariables = new Set([
'PAPERLESS_OCR_LANGUAGE',
])
expect(
component.isExternallyConfigured('PAPERLESS_OCR_LANGUAGE')
).toBeTruthy()
expect(
component.isExternallyConfigured('PAPERLESS_OCR_OUTPUT_TYPE')
).toBeFalsy()
})
it('should group options into sections within a category, or not', () => {
const sections = component.getCategorySections(ConfigCategory.OCR)
expect(sections).toEqual([null, ConfigSection.RemoteOCR])
@@ -69,7 +69,6 @@ export class ConfigComponent
public configForm = new FormGroup({})
public errors = {}
public externallyConfiguredVariables = new Set<string>()
get optionCategories(): string[] {
return Object.values(ConfigCategory)
@@ -153,9 +152,6 @@ export class ConfigComponent
}
private initialize(config: PaperlessConfig) {
this.externallyConfiguredVariables = new Set(
config.externally_configured_variables ?? []
)
if (!this.store) {
this.store = new BehaviorSubject(config)
@@ -166,9 +162,7 @@ export class ConfigComponent
this.configForm.patchValue(state, { emitEvent: false })
})
this.isDirty$ = dirtyCheck(this.configForm, this.store.asObservable(), {
excludeKeys: ['externally_configured_variables'],
})
this.isDirty$ = dirtyCheck(this.configForm, this.store.asObservable())
}
this.configForm.patchValue(config)
@@ -233,10 +227,6 @@ export class ConfigComponent
return this.configForm.get(key).value != null
}
public isExternallyConfigured(configKey: string): boolean {
return this.externallyConfiguredVariables.has(configKey)
}
public resetOption(key: string) {
this.configForm.get(key).setValue(null)
}
@@ -3,7 +3,6 @@
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">
@@ -22,6 +21,11 @@
</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
@@ -281,6 +285,6 @@
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks: tasksForSection(section), section: section}"></ng-container>
</div>
}
} @else if (!loading() || pagedTasks().length > 0) {
} @else {
<div class="alert alert-secondary fst-italic" i18n>No tasks match the current filters.</div>
}
@@ -41,8 +41,6 @@ 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())
@@ -202,7 +200,8 @@ export class TrashComponent
}
getDaysRemaining(document: Document): number {
const delay = this.emptyTrashDelaySetting()
this.settingsService.trackChanges()
const delay = this.settingsService.get(SETTINGS_KEYS.EMPTY_TRASH_DELAY)
const diff = new Date().getTime() - new Date(document.deleted_at).getTime()
const days = Math.ceil(diff / (1000 * 3600 * 24))
return delay - days
@@ -34,7 +34,9 @@
}
@media (min-width: 768px) {
--pngx-sidebar-width: var(--pngx-sidebar-expanded-width);
&.expanded {
--pngx-sidebar-width: var(--pngx-sidebar-expanded-width);
}
}
}
@media (max-width: 767.98px) {
@@ -111,13 +113,6 @@ 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;
@@ -128,6 +123,8 @@ main {
}
.sidebar.slim:not(.animating) {
transition: none;
li.nav-item span,
.sidebar-heading span {
display: none;
@@ -147,6 +144,10 @@ main {
}
}
.sidebar.slim:not(.animating) ~ main.col-slim {
transition: none;
}
.sidebar.animating {
li.nav-item span,
.sidebar-heading span {
@@ -195,26 +196,6 @@ 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,23 +193,6 @@ 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,29 +98,6 @@ 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() {
@@ -214,23 +191,33 @@ export class AppFrameComponent
}
get versionString(): string {
return `${environment.appTitle} v${this.versionSetting()}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
this.settingsService.trackChanges()
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
}
get appTitle(): string {
return this.appTitleSetting() || environment.appTitle
this.settingsService.trackChanges()
return (
this.settingsService.get(SETTINGS_KEYS.APP_TITLE) || environment.appTitle
)
}
get customAppTitle(): string {
return this.appTitleSetting()
this.settingsService.trackChanges()
return this.settingsService.get(SETTINGS_KEYS.APP_TITLE)
}
get hasCustomBranding(): boolean {
return !!(this.appTitleSetting()?.length || this.appLogoSetting()?.length)
this.settingsService.trackChanges()
return !!(
this.settingsService.get(SETTINGS_KEYS.APP_TITLE)?.length ||
this.settingsService.get(SETTINGS_KEYS.APP_LOGO)?.length
)
}
get customAppLogo(): string {
const logo = this.appLogoSetting()
this.settingsService.trackChanges()
const logo = this.settingsService.get(SETTINGS_KEYS.APP_LOGO)
return logo?.length
? environment.apiBaseUrl.replace(/\/api\/$/, logo)
: null
@@ -275,7 +262,8 @@ export class AppFrameComponent
}
get slimSidebarEnabled(): boolean {
return this.slimSidebarSetting()
this.settingsService.trackChanges()
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
}
set slimSidebarEnabled(enabled: boolean) {
@@ -298,9 +286,10 @@ export class AppFrameComponent
}
get attributesSectionsCollapsed(): boolean {
return this.attributesSectionsCollapsedSetting()?.includes(
CollapsibleSection.ATTRIBUTES
)
this.settingsService.trackChanges()
return this.settingsService
.get(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED)
?.includes(CollapsibleSection.ATTRIBUTES)
}
set attributesSectionsCollapsed(collapsed: boolean) {
@@ -323,7 +312,8 @@ export class AppFrameComponent
}
get aiEnabled(): boolean {
return this.aiEnabledSetting()
this.settingsService.trackChanges()
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
}
@HostListener('window:resize')
@@ -490,8 +480,9 @@ export class AppFrameComponent
}
get showSidebarCounts(): boolean {
this.settingsService.trackChanges()
return (
this.sidebarViewsShowCountSetting() &&
this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT) &&
!this.settingsService.organizingSidebarSavedViews()
)
}
@@ -81,10 +81,6 @@ 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)
@@ -101,7 +97,11 @@ export class GlobalSearchComponent implements OnInit {
@ViewChildren('secondaryButton') secondaryButtons: QueryList<ElementRef>
get useAdvancedForFullSearch(): boolean {
return this.searchFullTypeSetting() === GlobalSearchType.ADVANCED
this.settingsService.trackChanges()
return (
this.settingsService.get(SETTINGS_KEYS.SEARCH_FULL_TYPE) ===
GlobalSearchType.ADVANCED
)
}
constructor() {
@@ -6,7 +6,7 @@
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge>
}
</button>
<div [id]="dropdownMenuId" class="px-3 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown_{{name}}">
<div class="px-3 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown_{{name}}">
<ng-container *ngTemplateOutlet="list; context: { queries: selectionModel.queries }"></ng-container>
</div>
</div>
@@ -64,11 +64,10 @@
[disabled]="disabled"
[virtualScroll]="getSelectOptionsForField(atom.field)?.length > 100"
[searchFn]="selectOptionSearchFn"
[appendTo]="selectAppendTo"
(mousedown)="$event.stopImmediatePropagation()"
></ng-select>
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.DocumentLink) {
<pngx-input-document-link [(ngModel)]="atom.value" class="w-25 form-select doc-link-select p-0" placeholder="Search docs..." i18n-placeholder [minimal]="true" [appendTo]="selectAppendTo"></pngx-input-document-link>
<pngx-input-document-link [(ngModel)]="atom.value" class="w-25 form-select doc-link-select p-0" placeholder="Search docs..." i18n-placeholder [minimal]="true"></pngx-input-document-link>
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Monetary) {
<input class="w-25 form-control rounded-end" type="text" inputmode="decimal"
[ngModel]="atom.value"
@@ -89,7 +88,6 @@
bindLabel="name"
bindValue="id"
[searchFn]="customFieldSearchFn"
[appendTo]="selectAppendTo"
(mousedown)="$event.stopImmediatePropagation()"
></ng-select>
<select class="w-25 form-select" [(ngModel)]="atom.operator" [disabled]="disabled">
@@ -123,7 +121,7 @@
<ng-container *ngTemplateOutlet="comparisonValueTemplate; context: { atom: atom }"></ng-container>
}
@case (CustomFieldQueryOperator.Contains) {
<pngx-input-document-link [(ngModel)]="atom.value" class="w-25 form-select doc-link-select p-0" placeholder="Search docs..." i18n-placeholder [minimal]="true" [appendTo]="selectAppendTo"></pngx-input-document-link>
<pngx-input-document-link [(ngModel)]="atom.value" class="w-25 form-select doc-link-select p-0" placeholder="Search docs..." i18n-placeholder [minimal]="true"></pngx-input-document-link>
}
@case (CustomFieldQueryOperator.In) {
<ng-select
@@ -135,7 +133,6 @@
[disabled]="disabled"
[multiple]="true"
[searchFn]="selectOptionSearchFn"
[appendTo]="selectAppendTo"
(mousedown)="$event.stopImmediatePropagation()"
></ng-select>
}
@@ -1,4 +1,3 @@
import { _IdGenerator } from '@angular/cdk/a11y'
import {
getLocaleNumberSymbol,
NgClass,
@@ -251,18 +250,6 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
@Input()
useDropdown: boolean = true
private readonly idGenerator = inject(_IdGenerator)
public readonly dropdownMenuId = this.idGenerator.getId(
'pngx-custom-fields-query-dropdown-'
)
/**
* Keep ng-select dropdown panels inside the dropdown menu
*/
get selectAppendTo(): string {
return this.useDropdown ? `#${this.dropdownMenuId}` : null
}
get name(): string {
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
}
@@ -3,7 +3,7 @@
<i-bs width="1em" height="1em" name="calendar-event-fill"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
</button>
<div [id]="dropdownMenuId" class="dropdown-menu date-dropdown shadow p-2" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}">
<div class="dropdown-menu date-dropdown shadow p-2" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}">
<h6 class="dropdown-header border-bottom" i18n>Created</h6>
<div class="list-group list-group-flush">
<div class="list-group-item d-flex p-2 select-item" role="menuitem">
@@ -21,7 +21,6 @@
bindValue="id"
bindLabel="name"
clearable="false"
[appendTo]="'#' + dropdownMenuId"
placeholder="Relative dates"
i18n-placeholder
(change)="onSetCreatedRelativeDate($event)">
@@ -98,7 +97,6 @@
bindValue="id"
bindLabel="name"
clearable="false"
[appendTo]="'#' + dropdownMenuId"
placeholder="Relative dates"
i18n-placeholder
(change)="onSetAddedRelativeDate($event)">
@@ -23,7 +23,6 @@ import { SettingsService } from 'src/app/services/settings.service'
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
import { pngxPopperOptions } from 'src/app/utils/popper-options'
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
import { _IdGenerator } from '@angular/cdk/a11y'
export interface DateSelection {
createdTo?: string
@@ -69,10 +68,6 @@ export enum RelativeDate {
})
export class DatesDropdownComponent implements OnInit, OnDestroy {
public popperOptions = pngxPopperOptions
private readonly idGenerator = inject(_IdGenerator)
public readonly dropdownMenuId = this.idGenerator.getId(
'pngx-dates-dropdown-'
)
constructor() {
const settings = inject(SettingsService)
@@ -196,16 +196,6 @@ 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')
@@ -228,7 +218,7 @@ describe('WorkflowEditDialogComponent', () => {
})
it('should return source options, type options, type name, schedule date field options', () => {
setActionSettings()
jest.spyOn(settingsService, 'get').mockReturnValue(true)
component.ngOnInit()
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS)
expect(component.triggerTypeOptions).toEqual(WORKFLOW_TYPE_OPTIONS)
@@ -252,7 +242,7 @@ describe('WorkflowEditDialogComponent', () => {
)
// Email, remote OCR and AI all disabled
setActionSettings({ email: false, remoteOcr: false, ai: false })
jest.spyOn(settingsService, 'get').mockReturnValue(false)
component.ngOnInit()
expect(component.actionTypeOptions).toEqual(
WORKFLOW_ACTION_OPTIONS.filter(
@@ -265,7 +255,7 @@ describe('WorkflowEditDialogComponent', () => {
})
it('should offer remote OCR only for consumption workflows', () => {
setActionSettings()
jest.spyOn(settingsService, 'get').mockReturnValue(true)
// A consumption trigger makes the action reachable
component.object = {
@@ -295,7 +285,7 @@ describe('WorkflowEditDialogComponent', () => {
})
it('should offer remote OCR on a trigger added to a new workflow', () => {
setActionSettings()
jest.spyOn(settingsService, 'get').mockReturnValue(true)
component.ngOnInit()
// Nothing for the action to apply to yet
@@ -321,7 +311,7 @@ describe('WorkflowEditDialogComponent', () => {
})
it('should keep remote OCR listed when an action already uses it', () => {
setActionSettings()
jest.spyOn(settingsService, 'get').mockReturnValue(true)
// Otherwise changing the trigger would silently blank the selection
component.object = {
@@ -339,7 +329,9 @@ describe('WorkflowEditDialogComponent', () => {
})
it('should not offer remote OCR when no engine is configured', () => {
setActionSettings({ remoteOcr: false })
jest
.spyOn(settingsService, 'get')
.mockImplementation((key) => key !== SETTINGS_KEYS.REMOTE_OCR_CONFIGURED)
component.object = {
name: 'Workflow 1',
@@ -356,7 +348,7 @@ describe('WorkflowEditDialogComponent', () => {
})
it('should offer apply AI suggestions unless every trigger is consumption', () => {
setActionSettings()
jest.spyOn(settingsService, 'get').mockReturnValue(true)
// Consumption runs before the document has been parsed, so there would be
// no content to make suggestions from
@@ -390,7 +382,7 @@ describe('WorkflowEditDialogComponent', () => {
})
it('should keep apply AI suggestions listed when an action already uses it', () => {
setActionSettings()
jest.spyOn(settingsService, 'get').mockReturnValue(true)
// Otherwise changing the trigger would silently blank the selection
component.object = {
@@ -408,7 +400,9 @@ describe('WorkflowEditDialogComponent', () => {
})
it('should not offer apply AI suggestions when AI is disabled', () => {
setActionSettings({ ai: false })
jest
.spyOn(settingsService, 'get')
.mockImplementation((key) => key !== SETTINGS_KEYS.AI_ENABLED)
component.object = {
name: 'Workflow 1',
@@ -537,13 +537,6 @@ 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
@@ -596,7 +589,7 @@ export class WorkflowEditDialogComponent
private getAllowedActionTypes() {
let allowed = WORKFLOW_ACTION_OPTIONS
if (!this.emailEnabledSetting()) {
if (!this.settingsService.get(SETTINGS_KEYS.EMAIL_ENABLED)) {
allowed = allowed.filter((a) => a.id !== WorkflowActionType.Email)
}
@@ -604,7 +597,7 @@ export class WorkflowEditDialogComponent
// offered for workflows that run at consumption.
const formWorkflow: Workflow = this.objectForm?.value
const remoteOcrUsable =
this.remoteOcrConfiguredSetting() &&
this.settingsService.get(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED) &&
(formWorkflow?.triggers?.some(
(trigger) => trigger.type === WorkflowTriggerType.Consumption
) ||
@@ -619,7 +612,7 @@ export class WorkflowEditDialogComponent
// once every trigger is consumption, so it stays offered on a workflow
// that has no triggers yet.
const aiSuggestionsUsable =
this.aiEnabledSetting() &&
this.settingsService.get(SETTINGS_KEYS.AI_ENABLED) &&
(!formWorkflow?.triggers?.length ||
formWorkflow.triggers.some(
(trigger) => trigger.type !== WorkflowTriggerType.Consumption
@@ -1369,6 +1362,7 @@ export class WorkflowEditDialogComponent
}
get actionTypeOptions() {
this.settingsService.trackChanges()
// Computed on read rather than cached
return this.getAllowedActionTypes()
}
@@ -703,40 +703,6 @@ 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 }
@@ -773,7 +739,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
const apple: Tag = { id: 55, name: 'Apple' }
const zebra: Tag = { id: 56, name: 'Zebra' }
selectionModel.editing = true
selectionModel.documentCountSortingEnabled = true
selectionModel.items = [apple, zebra]
expect(selectionModel.items.map((item) => item?.id ?? null)).toEqual([
null,
@@ -873,9 +839,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
selectionModel.items = [memoRoot]
selectionModel.documentCounts = [{ id: memoRoot.id, document_count: 9 }]
const getRootDocCount = (selectionModel as any).createRootDocCounter(
selectionModel.items
)
const getRootDocCount = (selectionModel as any).createRootDocCounter()
expect(getRootDocCount(memoRoot.id)).toEqual(9)
selectionModel.documentCounts = []
@@ -891,9 +855,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
selectionModel.items = [rootWithoutSelection]
selectionModel.documentCounts = []
const getRootDocCount = (selectionModel as any).createRootDocCounter(
selectionModel.items
)
const getRootDocCount = (selectionModel as any).createRootDocCounter()
expect(getRootDocCount(rootWithoutSelection.id)).toEqual(4)
})
@@ -903,9 +865,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
selectionModel.items = [rootWithoutCounts]
selectionModel.documentCounts = []
const getRootDocCount = (selectionModel as any).createRootDocCounter(
selectionModel.items
)
const getRootDocCount = (selectionModel as any).createRootDocCounter()
expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0)
})
@@ -1006,7 +966,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(
@@ -64,56 +64,43 @@ 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 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()
}
private _documentCounts: SelectionDataItem[] = []
public documentCountSortingEnabled = false
public set documentCounts(counts: SelectionDataItem[]) {
this._documentCounts.set(counts)
if (this.editing) {
this._items.set(this.sortItems(this.items))
this._documentCounts = counts
if (this.documentCountSortingEnabled) {
this.sortItems()
}
}
private _items: MatchingModel[] = []
get items(): MatchingModel[] {
return this._items()
return this._items
}
set items(items: MatchingModel[]) {
if (items) {
this._items.set(this.withNullItem(this.sortItems(Array.from(items))))
this._items = Array.from(items)
this.sortItems()
this.setNullItem()
}
}
private withNullItem(items: MatchingModel[]): MatchingModel[] {
private setNullItem() {
if (this.manyToOne && this.logicalOperator === LogicalOperator.Or) {
return items[0]?.id === null ? items.slice(1) : items
if (this._items[0]?.id === null) {
this._items.shift()
}
return
}
const nullItem = {
const item = {
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
@@ -121,17 +108,22 @@ export class FilterableDropdownSelectionModel {
: NEGATIVE_NULL_FILTER_VALUE,
}
return items[0]?.id === null || items[0]?.id === NEGATIVE_NULL_FILTER_VALUE
? [nullItem, ...items.slice(1)]
: [nullItem, ...items]
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)
}
}
constructor(manyToOne: boolean = false) {
this.manyToOne = manyToOne
}
private sortItems(items: MatchingModel[]): MatchingModel[] {
const sorted = [...items].sort((a, b) => {
private sortItems() {
this._items.sort((a, b) => {
if (
(a.id == null && b.id != null) ||
(a.id == NEGATIVE_NULL_FILTER_VALUE &&
@@ -162,13 +154,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)
) {
@@ -178,11 +170,15 @@ export class FilterableDropdownSelectionModel {
}
})
return this._documentCounts().length
? this.promoteBranchesWithDocumentCounts(sorted)
: sorted
if (this._documentCounts.length) {
this.promoteBranchesWithDocumentCounts()
}
}
private selectionStates = new Map<number, ToggleableItemState>()
private temporarySelectionStates = new Map<number, ToggleableItemState>()
getSelectedItems() {
return this.items.filter(
(i) =>
@@ -198,33 +194,30 @@ export class FilterableDropdownSelectionModel {
}
set(id: number, state: ToggleableItemState, fireEvent = true) {
const states = new Map(this.temporarySelectionStates)
if (state == ToggleableItemState.NotSelected) {
states.delete(id)
this.temporarySelectionStates.delete(id)
} else {
states.set(id, state)
this.temporarySelectionStates.set(id, state)
}
this._temporarySelectionStates.set(states)
if (fireEvent) {
this.changed.next(this)
}
}
toggle(id: number, fireEvent = true) {
const states = new Map(this.temporarySelectionStates)
let state = states.get(id)
let state = this.temporarySelectionStates.get(id)
if (
state == undefined ||
(state != ToggleableItemState.Selected &&
state != ToggleableItemState.Excluded)
) {
if (this.manyToOne || this.singleSelect) {
states.set(id, ToggleableItemState.Selected)
this.temporarySelectionStates.set(id, ToggleableItemState.Selected)
if (this.singleSelect) {
for (let key of states.keys()) {
for (let key of this.temporarySelectionStates.keys()) {
if (key != id) {
states.delete(key)
this.temporarySelectionStates.delete(key)
}
}
}
@@ -240,29 +233,25 @@ export class FilterableDropdownSelectionModel {
) {
newState = ToggleableItemState.NotSelected
}
states.set(id, newState)
}
if (this.editing && states.get(id) == ToggleableItemState.Selected) {
this.addAncestorSelections(states, id)
this.temporarySelectionStates.set(id, newState)
}
} else if (
state == ToggleableItemState.Selected ||
state == ToggleableItemState.Excluded
) {
states.delete(id)
this.clearDescendantSelections(states, id)
this.temporarySelectionStates.delete(id)
this.clearDescendantSelections(id)
}
if (!id) {
for (let key of states.keys()) {
for (let key of this.temporarySelectionStates.keys()) {
if (key) {
states.delete(key)
this.temporarySelectionStates.delete(key)
}
}
} else {
states.delete(null)
this.temporarySelectionStates.delete(null)
}
this._temporarySelectionStates.set(states)
if (fireEvent) {
this.changed.next(this)
@@ -270,21 +259,20 @@ export class FilterableDropdownSelectionModel {
}
exclude(id: number, fireEvent: boolean = true) {
const states = new Map(this.temporarySelectionStates)
let state = states.get(id)
let state = this.temporarySelectionStates.get(id)
if (id && (state == null || state != ToggleableItemState.Excluded)) {
const operator = this.manyToOne ? LogicalOperator.And : LogicalOperator.Or
this.temporaryLogicalOperator.set(operator)
this._logicalOperator.set(operator)
this.temporaryLogicalOperator = this._logicalOperator = this.manyToOne
? LogicalOperator.And
: LogicalOperator.Or
if (this.manyToOne || this.singleSelect) {
states.set(id, ToggleableItemState.Excluded)
this.clearDescendantSelections(states, id)
this.temporarySelectionStates.set(id, ToggleableItemState.Excluded)
this.clearDescendantSelections(id)
if (this.singleSelect) {
for (let key of states.keys()) {
for (let key of this.temporarySelectionStates.keys()) {
if (key != id) {
states.delete(key)
this.temporarySelectionStates.delete(key)
}
}
}
@@ -299,18 +287,17 @@ export class FilterableDropdownSelectionModel {
) {
newState = ToggleableItemState.NotSelected
}
states.set(id, newState)
this.temporarySelectionStates.set(id, newState)
if (newState == ToggleableItemState.Excluded) {
this.clearDescendantSelections(states, id)
this.clearDescendantSelections(id)
}
}
} else if (!id || state == ToggleableItemState.Excluded) {
states.delete(id)
this.temporarySelectionStates.delete(id)
if (id) {
this.clearDescendantSelections(states, id)
this.clearDescendantSelections(id)
}
}
this._temporarySelectionStates.set(states)
if (fireEvent) {
this.changed.next(this)
@@ -321,27 +308,9 @@ export class FilterableDropdownSelectionModel {
return this.selectionStates.get(id) || ToggleableItemState.NotSelected
}
private clearDescendantSelections(
states: Map<number, ToggleableItemState>,
id: number
) {
private clearDescendantSelections(id: number) {
for (const descendantID of this.getDescendantIDs(id)) {
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)
this.temporarySelectionStates.delete(descendantID)
}
}
@@ -351,7 +320,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' &&
@@ -367,12 +336,12 @@ export class FilterableDropdownSelectionModel {
}
get logicalOperator(): LogicalOperator {
return this.temporaryLogicalOperator()
return this.temporaryLogicalOperator
}
set logicalOperator(operator: LogicalOperator) {
this.temporaryLogicalOperator.set(operator)
this._items.set(this.withNullItem(this.items))
this.temporaryLogicalOperator = operator
this.setNullItem()
}
toggleOperator() {
@@ -380,12 +349,12 @@ export class FilterableDropdownSelectionModel {
}
get intersection(): Intersection {
return this.temporaryIntersection()
return this.temporaryIntersection
}
set intersection(intersection: Intersection) {
this.temporaryIntersection.set(intersection)
this._items.set(this.withNullItem(this.items))
this.temporaryIntersection = intersection
this.setNullItem()
}
toggleIntersection() {
@@ -395,20 +364,18 @@ export class FilterableDropdownSelectionModel {
? ToggleableItemState.Selected
: ToggleableItemState.Excluded
const states = new Map(this.temporarySelectionStates)
states.forEach((state, key) => {
this.temporarySelectionStates.forEach((state, key) => {
if (key === null && this.intersection === Intersection.Exclude) {
states.set(NEGATIVE_NULL_FILTER_VALUE, newState)
this.temporarySelectionStates.set(NEGATIVE_NULL_FILTER_VALUE, newState)
} else if (
key === NEGATIVE_NULL_FILTER_VALUE &&
this.intersection === Intersection.Include
) {
states.set(null, newState)
this.temporarySelectionStates.set(null, newState)
} else {
states.set(key, newState)
this.temporarySelectionStates.set(key, newState)
}
})
this._temporarySelectionStates.set(states)
this.changed.next(this)
}
@@ -428,12 +395,10 @@ export class FilterableDropdownSelectionModel {
}
clear(fireEvent = true) {
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))
this.temporarySelectionStates.clear()
this.temporaryLogicalOperator = this._logicalOperator = LogicalOperator.And
this.temporaryIntersection = this._intersection = Intersection.Include
this.setNullItem()
if (fireEvent) {
this.changed.next(this)
}
@@ -454,9 +419,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
@@ -473,29 +438,23 @@ 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(
items: MatchingModel[]
): MatchingModel[] {
const parentById = this.buildParentById(items)
private promoteBranchesWithDocumentCounts() {
const parentById = this.buildParentById()
const findRootId = this.createRootFinder(parentById)
const getRootDocCount = this.createRootDocCounter(items)
const summaries = this.buildBranchSummaries(
items,
findRootId,
getRootDocCount
)
const getRootDocCount = this.createRootDocCounter()
const summaries = this.buildBranchSummaries(findRootId, getRootDocCount)
const orderedBranches = this.orderBranchesByPriority(summaries)
return orderedBranches.flatMap((summary) => summary.items)
this._items = orderedBranches.flatMap((summary) => summary.items)
}
private buildParentById(items: MatchingModel[]): Map<number, number | null> {
private buildParentById(): Map<number, number | null> {
const parentById = new Map<number, number | null>()
for (const item of items) {
for (const item of this._items) {
if (typeof item?.id === 'number') {
const parentValue = (item as any)['parent']
parentById.set(
@@ -533,9 +492,7 @@ export class FilterableDropdownSelectionModel {
return findRootId
}
private createRootDocCounter(
items: MatchingModel[]
): (rootId: number) => number {
private createRootDocCounter(): (rootId: number) => number {
const docCountMemo = new Map<number, number>()
return (rootId: number): number => {
@@ -550,7 +507,7 @@ export class FilterableDropdownSelectionModel {
return explicit
}
const rootItem = items.find((i) => i.id === rootId)
const rootItem = this._items.find((i) => i.id === rootId)
const fallback =
typeof (rootItem as any)?.['document_count'] === 'number'
? (rootItem as any)['document_count']
@@ -562,13 +519,12 @@ 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 items.entries()) {
for (const [index, item] of this._items.entries()) {
const { key, special, rootId } = this.describeBranchItem(
item,
index,
@@ -660,23 +616,28 @@ export class FilterableDropdownSelectionModel {
}
init(map: Map<number, ToggleableItemState>) {
this._temporarySelectionStates.set(new Map(map))
this.temporarySelectionStates = map
this.apply()
}
apply() {
this._selectionStates.set(new Map(this.temporarySelectionStates))
this._logicalOperator.set(this.temporaryLogicalOperator())
this._intersection.set(this.temporaryIntersection())
this._items.set(this.sortItems(this.items))
this.selectionStates.clear()
this.temporarySelectionStates.forEach((value, key) => {
this.selectionStates.set(key, value)
})
this._logicalOperator = this.temporaryLogicalOperator
this._intersection = this.temporaryIntersection
this.sortItems()
}
reset(complete: boolean = false) {
this.temporarySelectionStates.clear()
if (complete) {
this._selectionStates.set(new Map())
this._temporarySelectionStates.set(new Map())
this.selectionStates.clear()
} else {
this._temporarySelectionStates.set(new Map(this.selectionStates))
this.selectionStates.forEach((value, key) => {
this.temporarySelectionStates.set(key, value)
})
}
}
@@ -749,7 +710,7 @@ export class FilterableDropdownComponent
model.manyToOne = this.selectionModel.manyToOne
model.singleSelect = this._editing && !model.manyToOne
}
model.editing = this._editing
model.documentCountSortingEnabled = this._editing
model.changed.subscribe((updatedModel) => {
this.selectionModelChange.next(updatedModel)
})
@@ -787,7 +748,7 @@ export class FilterableDropdownComponent
if (this.selectionModel) {
this.selectionModel.singleSelect =
this._editing && !this.selectionModel.manyToOne
this.selectionModel.editing = this._editing
this.selectionModel.documentCountSortingEnabled = this._editing
}
}
@@ -7,8 +7,6 @@
padding-left: calc(calc(var(--depth) - 2) * 1rem);
display: flex;
align-items: center;
min-width: 0;
overflow-wrap: anywhere;
.indicator {
display: inline-block;
@@ -20,7 +18,3 @@
margin-left: .5rem;
}
}
.badge {
flex-shrink: 0;
}
@@ -33,7 +33,6 @@
[compareWith]="compareDocuments"
[trackByFn]="trackByFn"
[minTermLength]="2"
[appendTo]="appendTo"
[loading]="loading()"
[typeahead]="documentsInput$"
(mousedown)="$event.stopImmediatePropagation()"
@@ -81,12 +81,6 @@ export class DocumentLinkComponent
@Input()
placeholder: string = $localize`Search for documents`
/**
* Parent for ng-select dropdown, needed to prevent close on click.
*/
@Input()
appendTo: string = null
get selectedDocumentIDs(): number[] {
return this.selectedDocuments.map((d) => d.id)
}
@@ -22,7 +22,7 @@
}
// Dropdown hierarchy reveal for ng-select options
::ng-deep .ng-dropdown-panel .ng-option {
:host ::ng-deep .ng-dropdown-panel .ng-option {
overflow-x: auto !important;
.tag-option-row {
@@ -41,12 +41,12 @@
}
}
::ng-deep .ng-dropdown-panel .ng-option:hover .hierarchy-reveal,
::ng-deep .ng-dropdown-panel .ng-option.ng-option-marked .hierarchy-reveal {
:host ::ng-deep .ng-dropdown-panel .ng-option:hover .hierarchy-reveal,
:host ::ng-deep .ng-dropdown-panel .ng-option.ng-option-marked .hierarchy-reveal {
max-width: 1000px;
}
::ng-deep .ng-dropdown-panel .ng-option:hover .hierarchy-indicator,
::ng-deep .ng-dropdown-panel .ng-option.ng-option-marked .hierarchy-indicator {
:host ::ng-deep .ng-dropdown-panel .ng-option.ng-option-marked .hierarchy-indicator {
background: transparent;
}
@@ -207,21 +207,6 @@ 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,7 +173,6 @@ 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
}
}
}
@@ -22,7 +22,7 @@
}
::ng-deep .pngx-pdf-viewer-container {
overflow: hidden !important;
overflow: hidden;
}
.hover-actions {
@@ -3,11 +3,11 @@
<i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
</button>
<div [id]="dropdownMenuId" class="dropdown-menu permission-filter-dropdown shadow py-0 w-2" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}">
<div class="dropdown-menu permission-filter-dropdown shadow py-0 w-2" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}">
<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,22 +57,17 @@
</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) {
<a class="text-reset focus-variants" href="javascript:void(0)" (click)="clearIncludeUsers()" aria-label="Clear selected users" i18n-aria-label>
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
</a>
@if (selectionModel.ownerFilter === OwnerFilterType.OTHERS) {
<i-bs width="1em" height="1em" name="check"></i-bs>
}
</div>
<div class="me-1 w-100">
<ng-select
name="user"
class="user-select small"
[ngModel]="selectionModel.includeUsers()"
(ngModelChange)="selectionModel.includeUsers.set($event)"
[(ngModel)]="selectionModel.includeUsers"
[disabled]="disabled"
[clearable]="false"
[appendTo]="'#' + dropdownMenuId"
[items]="users()"
bindLabel="username"
multiple="true"
@@ -83,10 +78,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]="selectionModel.hideUnowned()" (ngModelChange)="selectionModel.hideUnowned.set($event)" (change)="onChange()" [disabled]="disabled">
<input type="checkbox" class="form-check-input" id="hideUnowned" [(ngModel)]="this.selectionModel.hideUnowned" (change)="onChange()" [disabled]="disabled">
<label class="form-check-label w-100" for="hideUnowned"><small i18n>Hide unowned</small></label>
</div>
</div>
@@ -6,18 +6,3 @@
min-width: 1em;
min-height: 1em;
}
.focus-variants {
.variant-focused {
display: none;
}
&:hover, &:focus {
.variant-unfocused {
display: none;
}
.variant-focused {
display: block;
}
}
}
@@ -90,56 +90,56 @@ describe('PermissionsFilterDropdownComponent', () => {
component.setFilter(OwnerFilterType.OTHERS)
expect(component.isActive).toBeTruthy()
component.setFilter(OwnerFilterType.NONE)
component.selectionModel.hideUnowned.set(true)
component.selectionModel.hideUnowned = true
expect(component.isActive).toBeTruthy()
})
it('should describe concrete user filters honestly', () => {
component.selectionModel.ownerFilter.set(OwnerFilterType.SELF)
component.selectionModel.userID.set(1)
component.selectionModel.ownerFilter = OwnerFilterType.SELF
component.selectionModel.userID = 1
expect(component.ownerFilterLabel).toEqual('Owned by user1')
component.selectionModel.ownerFilter.set(OwnerFilterType.NOT_SELF)
component.selectionModel.excludeUsers.set([1])
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
component.selectionModel.excludeUsers = [1]
expect(component.ownerExclusionFilterLabel).toEqual('Not owned by user1')
component.selectionModel.ownerFilter.set(OwnerFilterType.SHARED_BY_ME)
component.selectionModel.userID.set(1)
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME
component.selectionModel.userID = 1
expect(component.sharedByFilterLabel).toEqual('Shared by user1')
})
it('should describe concrete filters when usernames are unavailable', () => {
component.selectionModel.ownerFilter.set(OwnerFilterType.SELF)
component.selectionModel.userID.set(99)
component.selectionModel.ownerFilter = OwnerFilterType.SELF
component.selectionModel.userID = 99
expect(component.ownerFilterLabel).toEqual('Owned by another user')
component.selectionModel.ownerFilter.set(OwnerFilterType.NOT_SELF)
component.selectionModel.excludeUsers.set([99])
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
component.selectionModel.excludeUsers = [99]
expect(component.ownerExclusionFilterLabel).toEqual(
'Not owned by another user'
)
component.selectionModel.excludeUsers.set([98, 99])
component.selectionModel.excludeUsers = [98, 99]
expect(component.ownerExclusionFilterLabel).toEqual(
'Not owned by selected users'
)
component.selectionModel.ownerFilter.set(OwnerFilterType.SHARED_BY_ME)
component.selectionModel.userID.set(99)
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME
component.selectionModel.userID = 99
expect(component.sharedByFilterLabel).toEqual('Shared by another user')
})
it('should retain relative labels for filters bound to the current user', () => {
component.selectionModel.userID.set(currentUserID)
component.selectionModel.userID = currentUserID
expect(component.ownerFilterLabel).toEqual('My documents')
expect(component.sharedByFilterLabel).toEqual('Shared by me')
component.selectionModel.excludeUsers.set([currentUserID])
component.selectionModel.excludeUsers = [currentUserID]
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
})
it('should retain relative labels for inactive filter choices', () => {
component.selectionModel.ownerFilter.set(OwnerFilterType.NONE)
component.selectionModel.ownerFilter = OwnerFilterType.NONE
expect(component.ownerFilterLabel).toEqual('My documents')
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
@@ -148,56 +148,32 @@ 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.set(OwnerFilterType.NONE)
component.selectionModel.ownerFilter = OwnerFilterType.NONE
// this would normally be done by select component
component.selectionModel.includeUsers.set([12])
component.selectionModel.includeUsers = [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.set(null)
component.selectionModel.includeUsers = null
component.onUserSelect()
expect(component.selectionModel.ownerFilter()).toEqual(OwnerFilterType.NONE)
expect(component.selectionModel.ownerFilter).toEqual(OwnerFilterType.NONE)
})
it('should clear selected users', () => {
component.selectionModel.includeUsers.set([12])
component.onUserSelect()
component.selectionModel.hideUnowned.set(true)
expect(component.selectionModel.ownerFilter()).toEqual(
OwnerFilterType.OTHERS
)
component.clearIncludeUsers()
expect(component.selectionModel.includeUsers()).toEqual([])
expect(component.selectionModel.ownerFilter()).toEqual(OwnerFilterType.NONE)
expect(component.selectionModel.hideUnowned()).toBeTruthy()
})
it('should emit a selection model depending on the type of owner filter set', () => {
const emitted = () => ({
excludeUsers: ownerFilterSetResult.excludeUsers(),
hideUnowned: ownerFilterSetResult.hideUnowned(),
includeUsers: ownerFilterSetResult.includeUsers(),
ownerFilter: ownerFilterSetResult.ownerFilter(),
userID: ownerFilterSetResult.userID(),
})
component.selectionModel.ownerFilter.set(OwnerFilterType.NONE)
component.selectionModel.ownerFilter = OwnerFilterType.NONE
component.setFilter(OwnerFilterType.SELF)
expect(emitted()).toEqual({
expect(ownerFilterSetResult).toEqual({
excludeUsers: [],
hideUnowned: false,
includeUsers: [],
@@ -206,7 +182,7 @@ describe('PermissionsFilterDropdownComponent', () => {
})
component.setFilter(OwnerFilterType.NOT_SELF)
expect(emitted()).toEqual({
expect(ownerFilterSetResult).toEqual({
excludeUsers: [currentUserID],
hideUnowned: false,
includeUsers: [],
@@ -215,7 +191,7 @@ describe('PermissionsFilterDropdownComponent', () => {
})
component.setFilter(OwnerFilterType.NONE)
expect(emitted()).toEqual({
expect(ownerFilterSetResult).toEqual({
excludeUsers: [],
hideUnowned: false,
includeUsers: [],
@@ -224,7 +200,7 @@ describe('PermissionsFilterDropdownComponent', () => {
})
component.setFilter(OwnerFilterType.SHARED_BY_ME)
expect(emitted()).toEqual({
expect(ownerFilterSetResult).toEqual({
excludeUsers: [],
hideUnowned: false,
includeUsers: [],
@@ -233,7 +209,7 @@ describe('PermissionsFilterDropdownComponent', () => {
})
component.setFilter(OwnerFilterType.UNOWNED)
expect(emitted()).toEqual({
expect(ownerFilterSetResult).toEqual({
excludeUsers: [],
hideUnowned: false,
includeUsers: [],
@@ -1,4 +1,3 @@
import { _IdGenerator } from '@angular/cdk/a11y'
import { NgClass } from '@angular/common'
import {
Component,
@@ -26,18 +25,18 @@ import { ComponentWithPermissions } from '../../with-permissions/with-permission
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
export class PermissionsSelectionModel {
readonly ownerFilter = signal(OwnerFilterType.NONE)
readonly hideUnowned = signal(false)
readonly userID = signal<number>(null)
readonly includeUsers = signal<number[]>([])
readonly excludeUsers = signal<number[]>([])
ownerFilter: OwnerFilterType
hideUnowned: boolean
userID: number
includeUsers: number[]
excludeUsers: number[]
clear() {
this.ownerFilter.set(OwnerFilterType.NONE)
this.userID.set(null)
this.hideUnowned.set(false)
this.includeUsers.set([])
this.excludeUsers.set([])
this.ownerFilter = OwnerFilterType.NONE
this.userID = null
this.hideUnowned = false
this.includeUsers = []
this.excludeUsers = []
}
}
@@ -71,11 +70,6 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
public OwnerFilterType = OwnerFilterType
private readonly idGenerator = inject(_IdGenerator)
public readonly dropdownMenuId = this.idGenerator.getId(
'pngx-permissions-filter-dropdown-'
)
@Input()
title: string
@@ -90,31 +84,33 @@ 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)
) {
@@ -134,13 +130,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`
@@ -173,36 +169,34 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
}
setFilter(type: OwnerFilterType) {
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)
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
} else if (
this.selectionModel.ownerFilter() === OwnerFilterType.SHARED_BY_ME
this.selectionModel.ownerFilter === OwnerFilterType.SHARED_BY_ME
) {
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.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.onChange()
}
@@ -211,17 +205,12 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
this.ownerFilterSet.emit(this.selectionModel)
}
clearIncludeUsers() {
this.selectionModel.includeUsers.set([])
this.onUserSelect()
}
onUserSelect() {
this.selectionModel.ownerFilter.set(
this.selectionModel.includeUsers()?.length
? OwnerFilterType.OTHERS
: OwnerFilterType.NONE
)
if (this.selectionModel.includeUsers?.length) {
this.selectionModel.ownerFilter = OwnerFilterType.OTHERS
} else {
this.selectionModel.ownerFilter = OwnerFilterType.NONE
}
this.onChange()
}
@@ -1209,53 +1209,24 @@ describe('DocumentDetailComponent', () => {
expect(fixture.debugElement.queryAll(By.css('textarea.rtl'))).not.toBeNull()
})
it('should display built-in pdf viewer if not disabled', async () => {
it('should display built-in pdf viewer if not disabled', () => {
initNormally()
component.document.update((document) => ({
...document,
archived_file_name: 'file.pdf',
}))
component.document().archived_file_name = 'file.pdf'
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
expect(component.useNativePdfViewer).toBeFalsy()
await fixture.whenStable()
fixture.detectChanges()
expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull()
})
it('should display native pdf viewer if enabled', () => {
initNormally()
component.document.update((document) => ({
...document,
archived_file_name: 'file.pdf',
}))
component.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 }))
@@ -1714,10 +1685,7 @@ describe('DocumentDetailComponent', () => {
it('should change preview element by render type', () => {
initNormally()
component.document.update((document) => ({
...document,
archived_file_name: 'file.pdf',
}))
component.document().archived_file_name = 'file.pdf'
fixture.detectChanges()
expect(component.archiveContentRenderType).toEqual(
component.ContentRenderType.PDF
@@ -1726,11 +1694,8 @@ describe('DocumentDetailComponent', () => {
fixture.debugElement.query(By.css('pdf-viewer-container'))
).not.toBeUndefined()
component.document.update((document) => ({
...document,
archived_file_name: undefined,
mime_type: 'text/plain',
}))
component.document().archived_file_name = undefined
component.document().mime_type = 'text/plain'
fixture.detectChanges()
expect(component.archiveContentRenderType).toEqual(
component.ContentRenderType.Text
@@ -1739,10 +1704,7 @@ describe('DocumentDetailComponent', () => {
fixture.debugElement.query(By.css('div.preview-sticky'))
).not.toBeUndefined()
component.document.update((document) => ({
...document,
mime_type: 'image/jpeg',
}))
component.document().mime_type = 'image/jpeg'
fixture.detectChanges()
expect(component.archiveContentRenderType).toEqual(
component.ContentRenderType.Image
@@ -1750,12 +1712,9 @@ describe('DocumentDetailComponent', () => {
expect(
fixture.debugElement.query(By.css('.preview-sticky img'))
).not.toBeUndefined()
component.document.update((document) => ({
...document,
mime_type:
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
}))
fixture.detectChanges()
;((component.document().mime_type =
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'),
fixture.detectChanges())
expect(component.archiveContentRenderType).toEqual(
component.ContentRenderType.Other
)
@@ -227,19 +227,6 @@ 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
@@ -346,7 +333,8 @@ export class DocumentDetailComponent
}
get useNativePdfViewer(): boolean {
return this.useNativePdfViewerSetting()
this.settings.trackChanges()
return this.settings.get(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER)
}
get isMobile(): boolean {
@@ -354,10 +342,12 @@ export class DocumentDetailComponent
}
get aiEnabled(): boolean {
return this.aiEnabledSetting()
this.settings.trackChanges()
return this.settings.get(SETTINGS_KEYS.AI_ENABLED)
}
get archiveContentRenderType(): ContentRenderType {
this.settings.trackChanges()
const hasArchiveVersion =
this.metadata()?.has_archive_version ??
!!this.document()?.archived_file_name
@@ -369,17 +359,22 @@ export class DocumentDetailComponent
}
get originalContentRenderType(): ContentRenderType {
this.settings.trackChanges()
return this.getRenderType(
this.metadata()?.original_mime_type || this.document()?.mime_type
)
}
get showThumbnailOverlay(): boolean {
return this.showThumbnailOverlaySetting()
this.settings.trackChanges()
return this.settings.get(SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL)
}
isFieldHidden(fieldId: DocumentDetailFieldID): boolean {
return this.hiddenFieldsSetting().includes(fieldId)
this.settings.trackChanges()
return this.settings
.get(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS)
.includes(fieldId)
}
private getRenderType(mimeType: string): ContentRenderType {
@@ -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 d-flex align-items-start" 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" 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 flex-shrink-0" name="file-earmark"></i-bs>
<i-bs width="1em" height="1em" class="me-2 text-muted" 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 d-flex align-items-start" 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" 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 flex-shrink-0" name="folder"></i-bs>
<i-bs width="1em" height="1em" class="me-2 text-muted" name="folder"></i-bs>
<small>{{document().storage_path | storagePathName | async}}</small>
</button>
}
@@ -69,11 +69,6 @@
button {
line-height: 1;
small {
min-width: 0;
overflow-wrap: anywhere;
}
&:hover,
&:focus {
background-color: transparent !important;
@@ -121,8 +121,6 @@ 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
@@ -576,7 +574,8 @@ export class DocumentListComponent
}
get notesEnabled(): boolean {
return this.notesEnabledSetting()
this.settingsService.trackChanges()
return this.settingsService.get(SETTINGS_KEYS.NOTES_ENABLED)
}
resetFilters() {
@@ -621,43 +621,6 @@ 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 = [
@@ -1115,7 +1078,7 @@ describe('FilterEditorComponent', () => {
})
it('should ingest filter rules for owner', () => {
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
OwnerFilterType.NONE
)
component.filterRules = [
@@ -1124,38 +1087,15 @@ 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)
})
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()
expect(component.permissionsSelectionModel.hideUnowned).toBeFalsy()
expect(component.permissionsSelectionModel.userID).toEqual(100)
})
it('should ingest filter rules for owner is others', () => {
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
OwnerFilterType.NONE
)
component.filterRules = [
@@ -1164,14 +1104,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 = [
@@ -1180,14 +1120,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 = [
@@ -1196,10 +1136,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', () => {
@@ -1209,14 +1149,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', () => {
@@ -1226,7 +1166,7 @@ describe('FilterEditorComponent', () => {
value: '2',
},
]
expect(component.permissionsSelectionModel.userID()).toEqual(2)
expect(component.permissionsSelectionModel.userID).toEqual(2)
})
// GET filterRules
@@ -1992,10 +1932,7 @@ describe('FilterEditorComponent', () => {
value: '1',
},
])
component.permissionsSelectionModel.excludeUsers.update((users) => [
...users,
2,
])
component.permissionsSelectionModel.excludeUsers.push(2)
fixture.detectChanges()
expect(component.filterRules).toEqual([
{
@@ -2045,11 +1982,8 @@ describe('FilterEditorComponent', () => {
// TODO: mock input in code
// userSelect.query(By.css('input')).nativeElement.value = '3'
// userSelect.triggerEventHandler('change')
component.permissionsSelectionModel.ownerFilter.set(OwnerFilterType.OTHERS)
component.permissionsSelectionModel.includeUsers.update((users) => [
...users,
3,
])
component.permissionsSelectionModel.ownerFilter = OwnerFilterType.OTHERS
component.permissionsSelectionModel.includeUsers.push(3)
fixture.detectChanges()
expect(component.filterRules).toEqual([
{
@@ -2069,7 +2003,7 @@ describe('FilterEditorComponent', () => {
ownerToggle.nativeElement.checked = true
// ownerToggle.triggerEventHandler('change')
// TODO: ngModel isn't doing this here
component.permissionsSelectionModel.hideUnowned.set(true)
component.permissionsSelectionModel.hideUnowned = true
fixture.detectChanges()
expect(component.filterRules).toEqual([
{
@@ -735,50 +735,38 @@ export class FilterEditorComponent
this._textFilter = rule.value
break
case FILTER_OWNER:
this.permissionsSelectionModel.ownerFilter.set(OwnerFilterType.SELF)
this.permissionsSelectionModel.hideUnowned.set(false)
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.SELF
this.permissionsSelectionModel.hideUnowned = false
if (rule.value)
this.permissionsSelectionModel.userID.set(
Number.parseInt(rule.value, 10)
)
this.permissionsSelectionModel.userID = parseInt(rule.value, 10)
break
case FILTER_OWNER_ANY:
this.permissionsSelectionModel.ownerFilter.set(OwnerFilterType.OTHERS)
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.OTHERS
if (rule.value)
this.permissionsSelectionModel.includeUsers.update((users) => [
...users,
Number.parseInt(rule.value, 10),
])
this.permissionsSelectionModel.includeUsers.push(
parseInt(rule.value, 10)
)
break
case FILTER_OWNER_DOES_NOT_INCLUDE:
this.permissionsSelectionModel.ownerFilter.set(
OwnerFilterType.NOT_SELF
)
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.NOT_SELF
if (rule.value)
this.permissionsSelectionModel.excludeUsers.update((users) => [
...users,
Number.parseInt(rule.value, 10),
])
this.permissionsSelectionModel.excludeUsers.push(
parseInt(rule.value, 10)
)
break
case FILTER_SHARED_BY_USER:
this.permissionsSelectionModel.ownerFilter.set(
this.permissionsSelectionModel.ownerFilter =
OwnerFilterType.SHARED_BY_ME
)
if (rule.value)
this.permissionsSelectionModel.userID.set(
Number.parseInt(rule.value, 10)
)
this.permissionsSelectionModel.userID = parseInt(rule.value, 10)
break
case FILTER_OWNER_ISNULL:
if (rule.value === 'true' || rule.value === '1') {
this.permissionsSelectionModel.hideUnowned.set(false)
this.permissionsSelectionModel.ownerFilter.set(
OwnerFilterType.UNOWNED
)
this.permissionsSelectionModel.hideUnowned = false
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.UNOWNED
} else {
this.permissionsSelectionModel.hideUnowned.set(
this.permissionsSelectionModel.hideUnowned =
rule.value === 'false' || rule.value === '0'
)
break
}
}
@@ -1086,35 +1074,34 @@ 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,
@@ -1122,7 +1109,7 @@ export class FilterEditorComponent
})
}
if (this.permissionsSelectionModel.hideUnowned()) {
if (this.permissionsSelectionModel.hideUnowned) {
filterRules.push({
rule_type: FILTER_OWNER_ISNULL,
value: 'false',
-1
View File
@@ -422,7 +422,6 @@ export const PaperlessConfigOptions: ConfigOption[] = [
]
export interface PaperlessConfig extends ObjectWithId {
externally_configured_variables: string[]
output_type: OutputTypeConfig
pages: number
language: string
@@ -210,48 +210,6 @@ 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/`)
+4 -16
View File
@@ -2,8 +2,6 @@ import { HttpClient } from '@angular/common/http'
import {
DOCUMENT,
EventEmitter,
Signal,
computed,
inject,
Injectable,
LOCALE_ID,
@@ -299,7 +297,6 @@ 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()
@@ -329,6 +326,10 @@ 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
@@ -338,7 +339,6 @@ 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,18 +594,6 @@ 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
+1 -1
View File
@@ -8,7 +8,7 @@ export const environment = {
apiVersion: '10', // match src/paperless/settings.py
appTitle: DEFAULT_APP_TITLE,
tag: 'prod',
version: '3.1.3',
version: '3.1.2',
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

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