Compare commits

..
Author SHA1 Message Date
stumpylog 507889f06a Updates to ruff 0.16 and ignores, autofixes or handles all the new rules 2026-07-24 11:02:11 -07:00
stumpylog 4971be9b37 Updates the script in docker too 2026-07-24 10:41:18 -07:00
stumpylogandClaude Sonnet 4.6 219cda569c ruff: enable S324 (hashlib insecure hash functions)
Adds usedforsecurity=False to all hashlib.md5() calls, documenting
that these are used for file checksum comparison, not security.
The production call in _path_matches_checksum will be replaced with
compute_checksum() (SHA-256) in a separate branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-24 10:41:17 -07:00
stumpylogandClaude Sonnet 4.6 de227d0f71 ruff: enable PERF (perflint)
Fixes 9 violations — loop-based append replaced with comprehensions
or extend throughout production and test code:
- PERF401: list comprehensions / extend for transformed lists
- PERF402: list() around a generator for copied lists

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-24 10:40:35 -07:00
stumpylog 5cdc82ae35 ruff: enable DTZ (flake8-datetimez)
Fixes 44 violations — naive datetime usage replaced with tz-aware
equivalents throughout production and test code:
- datetime.now() → timezone.now() (Django) or datetime.now(tz=UTC)
- datetime.fromtimestamp() → datetime.fromtimestamp(ts, tz=UTC)
- datetime.date.today() → timezone.now().date()
- datetime.datetime(...) constructors → tzinfo=UTC in tests
- UP017 auto-converted datetime.timezone.utc → datetime.UTC (py3.11+)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

# Conflicts:
#	src/documents/views.py
#	src/paperless_mail/mail.py
2026-07-24 10:39:44 -07:00
stumpylogandClaude Sonnet 4.6 976eb3beaa ruff: enable B (flake8-bugbear)
Fixes 71 violations across production and test code:
- B904 (~50): raise-from in except blocks; from None at API/view
  boundaries, from exc where the cause is the direct origin
- B017 (9): pytest.raises(Exception) → specific type or match= arg
- B007 (5): unused loop vars renamed to _
- B027 (1): missing @abstractmethod on DateParserPluginBase.__exit__
- B028 (3): warnings.warn without stacklevel=2 in test utils
- B011 (1): assert False → raise AssertionError()
- B905 (3): zip() without strict=False
- B009 (3): getattr with constant string (auto-fixed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-24 10:37:52 -07:00
stumpylogandClaude Sonnet 4.6 6f1673e0ae ruff: enable G (logging format), ignore G004 (f-strings)
Replaces the single G201 selector with the full G group.
Fixes 2x G003 (string concat in log calls) and 2x G202 (redundant
exc_info on logger.exception). G004 (f-strings in logging) is ignored
as f-string style is accepted throughout this codebase.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-24 10:36:59 -07:00
386 changed files with 19832 additions and 28226 deletions
+35 -37
View File
@@ -1,51 +1,49 @@
categories: categories:
- type: 'pre-include'
when:
- label: 'enhancement'
- label: 'bug'
- label: 'chore'
- label: 'deployment'
- label: 'translation'
- label: 'dependencies'
- label: 'documentation'
- label: 'frontend'
- label: 'backend'
- label: 'ci-cd'
- label: 'breaking-change'
- label: 'notable'
- type: 'pre-exclude'
when:
- label: 'skip-changelog'
- title: 'Breaking Changes' - title: 'Breaking Changes'
when: labels:
- label: 'breaking-change' - 'breaking-change'
- title: 'Notable Changes' - title: 'Notable Changes'
when: labels:
- label: 'notable' - 'notable'
- title: 'Features / Enhancements' - title: 'Features / Enhancements'
when: labels:
- label: 'enhancement' - 'enhancement'
- title: 'Bug Fixes' - title: 'Bug Fixes'
when: labels:
- label: 'bug' - 'bug'
- title: 'Documentation' - title: 'Documentation'
when: labels:
- label: 'documentation' - 'documentation'
- title: 'Maintenance' - title: 'Maintenance'
when: labels:
- label: 'chore' - 'chore'
- label: 'deployment' - 'deployment'
- label: 'translation' - 'translation'
- label: 'ci-cd' - 'ci-cd'
- title: 'Dependencies' - title: 'Dependencies'
collapse-after: 3 collapse-after: 3
when: labels:
- label: 'dependencies' - 'dependencies'
- title: 'All App Changes' - title: 'All App Changes'
labels:
- 'frontend'
- 'backend'
collapse-after: 1 collapse-after: 1
when: include-labels:
- label: 'frontend' - 'enhancement'
- label: 'backend' - 'bug'
- 'chore'
- 'deployment'
- 'translation'
- 'dependencies'
- 'documentation'
- 'frontend'
- 'backend'
- 'ci-cd'
- 'breaking-change'
- 'notable'
exclude-labels:
- 'skip-changelog'
filter-by-commitish: true filter-by-commitish: true
category-template: '### $TITLE' category-template: '### $TITLE'
change-template: '- $TITLE @$AUTHOR ([#$NUMBER]($URL))' change-template: '- $TITLE @$AUTHOR ([#$NUMBER]($URL))'
+9 -9
View File
@@ -11,7 +11,7 @@ concurrency:
group: backend-${{ github.event.pull_request.number || github.ref }} group: backend-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true cancel-in-progress: true
env: env:
DEFAULT_UV_VERSION: "0.12.x" DEFAULT_UV_VERSION: "0.11.x"
NLTK_DATA: "/usr/share/nltk_data" NLTK_DATA: "/usr/share/nltk_data"
permissions: {} permissions: {}
jobs: jobs:
@@ -24,7 +24,7 @@ jobs:
backend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.backend == 'true' }} backend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.backend == 'true' }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false persist-credentials: false
@@ -63,7 +63,7 @@ jobs:
- name: Detect changes - name: Detect changes
id: filter id: filter
if: steps.force.outputs.run_all != 'true' if: steps.force.outputs.run_all != 'true'
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
with: with:
base: ${{ steps.range.outputs.base }} base: ${{ steps.range.outputs.base }}
ref: ${{ steps.range.outputs.ref }} ref: ${{ steps.range.outputs.ref }}
@@ -87,7 +87,7 @@ jobs:
fail-fast: false fail-fast: false
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Start containers - name: Start containers
@@ -96,11 +96,11 @@ jobs:
docker compose --file docker/compose/docker-compose.ci-test.yml up --detach docker compose --file docker/compose/docker-compose.ci-test.yml up --detach
- name: Set up Python - name: Set up Python
id: setup-python id: setup-python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with: with:
python-version: "${{ matrix.python-version }}" python-version: "${{ matrix.python-version }}"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
@@ -169,16 +169,16 @@ jobs:
PAPERLESS_SECRET_KEY: "ci-typing-not-a-real-secret" PAPERLESS_SECRET_KEY: "ci-typing-not-a-real-secret"
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Python - name: Set up Python
id: setup-python id: setup-python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with: with:
python-version: "${{ env.DEFAULT_PYTHON }}" python-version: "${{ env.DEFAULT_PYTHON }}"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
+10 -10
View File
@@ -41,7 +41,7 @@ jobs:
ref-name: ${{ steps.ref.outputs.name }} ref-name: ${{ steps.ref.outputs.name }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Determine ref name - name: Determine ref name
@@ -106,9 +106,9 @@ jobs:
echo "repository=${repo_name}" echo "repository=${repo_name}"
echo "name=${repo_name}" >> $GITHUB_OUTPUT echo "name=${repo_name}" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -121,7 +121,7 @@ jobs:
sudo rm -rf "$AGENT_TOOLSDIRECTORY" sudo rm -rf "$AGENT_TOOLSDIRECTORY"
- name: Docker metadata - name: Docker metadata
id: docker-meta id: docker-meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with: with:
images: | images: |
${{ env.REGISTRY }}/${{ steps.repo.outputs.name }} ${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}
@@ -132,7 +132,7 @@ jobs:
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
- name: Build and push by digest - name: Build and push by digest
id: build id: build
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
@@ -182,29 +182,29 @@ jobs:
echo "Downloaded digests:" echo "Downloaded digests:"
ls -la /tmp/digests/ ls -la /tmp/digests/
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub - name: Login to Docker Hub
if: needs.build-arch.outputs.push-external == 'true' if: needs.build-arch.outputs.push-external == 'true'
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to Quay.io - name: Login to Quay.io
if: needs.build-arch.outputs.push-external == 'true' if: needs.build-arch.outputs.push-external == 'true'
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with: with:
registry: quay.io registry: quay.io
username: ${{ secrets.QUAY_USERNAME }} username: ${{ secrets.QUAY_USERNAME }}
password: ${{ secrets.QUAY_ROBOT_TOKEN }} password: ${{ secrets.QUAY_ROBOT_TOKEN }}
- name: Docker metadata - name: Docker metadata
id: docker-meta id: docker-meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with: with:
images: | images: |
${{ env.REGISTRY }}/${{ needs.build-arch.outputs.repository }} ${{ env.REGISTRY }}/${{ needs.build-arch.outputs.repository }}
+6 -6
View File
@@ -11,7 +11,7 @@ concurrency:
permissions: permissions:
contents: read contents: read
env: env:
DEFAULT_UV_VERSION: "0.12.x" DEFAULT_UV_VERSION: "0.11.x"
DEFAULT_PYTHON_VERSION: "3.12" DEFAULT_PYTHON_VERSION: "3.12"
jobs: jobs:
changes: changes:
@@ -21,7 +21,7 @@ jobs:
docs_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.docs == 'true' }} docs_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.docs == 'true' }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false persist-credentials: false
@@ -50,7 +50,7 @@ jobs:
- name: Detect changes - name: Detect changes
id: filter id: filter
if: steps.force.outputs.run_all != 'true' if: steps.force.outputs.run_all != 'true'
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
with: with:
base: ${{ steps.range.outputs.base }} base: ${{ steps.range.outputs.base }}
ref: ${{ steps.range.outputs.ref }} ref: ${{ steps.range.outputs.ref }}
@@ -69,16 +69,16 @@ jobs:
steps: steps:
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Python - name: Set up Python
id: setup-python id: setup-python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }} python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
+37 -32
View File
@@ -21,7 +21,7 @@ jobs:
frontend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.frontend == 'true' }} frontend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.frontend == 'true' }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false persist-credentials: false
@@ -60,7 +60,7 @@ jobs:
- name: Detect changes - name: Detect changes
id: filter id: filter
if: steps.force.outputs.run_all != 'true' if: steps.force.outputs.run_all != 'true'
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
with: with:
base: ${{ steps.range.outputs.base }} base: ${{ steps.range.outputs.base }}
ref: ${{ steps.range.outputs.ref }} ref: ${{ steps.range.outputs.ref }}
@@ -77,15 +77,15 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with: with:
package_json_file: src-ui/package.json version: 10
- name: Use Node.js 24 - name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version: 24.x node-version: 24.x
cache: 'pnpm' cache: 'pnpm'
@@ -109,15 +109,15 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with: with:
package_json_file: src-ui/package.json version: 10
- name: Use Node.js 24 - name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version: 24.x node-version: 24.x
cache: 'pnpm' cache: 'pnpm'
@@ -129,8 +129,8 @@ jobs:
~/.pnpm-store ~/.pnpm-store
~/.cache ~/.cache
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }} key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
- name: Install dependencies - name: Re-link Angular CLI
run: cd src-ui && pnpm install --frozen-lockfile run: cd src-ui && pnpm link @angular/cli
- name: Run lint - name: Run lint
run: cd src-ui && pnpm run lint run: cd src-ui && pnpm run lint
unit-tests: unit-tests:
@@ -148,15 +148,15 @@ jobs:
shard-count: [4] shard-count: [4]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with: with:
package_json_file: src-ui/package.json version: 10
- name: Use Node.js 24 - name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version: 24.x node-version: 24.x
cache: 'pnpm' cache: 'pnpm'
@@ -168,8 +168,8 @@ jobs:
~/.pnpm-store ~/.pnpm-store
~/.cache ~/.cache
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }} key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
- name: Install dependencies - name: Re-link Angular CLI
run: cd src-ui && pnpm install --frozen-lockfile run: cd src-ui && pnpm link @angular/cli
- name: Run Jest unit tests - name: Run Jest unit tests
run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }} run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
- name: Upload test results to Codecov - name: Upload test results to Codecov
@@ -191,7 +191,7 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions: permissions:
contents: read contents: read
container: mcr.microsoft.com/playwright:v1.62.0-noble container: mcr.microsoft.com/playwright:v1.61.1-noble
env: env:
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
@@ -203,15 +203,15 @@ jobs:
shard-count: [2] shard-count: [2]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with: with:
package_json_file: src-ui/package.json version: 10
- name: Use Node.js 24 - name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version: 24.x node-version: 24.x
cache: 'pnpm' cache: 'pnpm'
@@ -223,29 +223,32 @@ jobs:
~/.pnpm-store ~/.pnpm-store
~/.cache ~/.cache
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }} key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
- name: Re-link Angular CLI
run: cd src-ui && pnpm link @angular/cli
- name: Install dependencies - name: Install dependencies
run: cd src-ui && pnpm install --frozen-lockfile run: cd src-ui && pnpm install --no-frozen-lockfile
- name: Run Playwright E2E tests - name: Run Playwright E2E tests
run: cd src-ui && pnpm exec playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }} run: cd src-ui && pnpm exec playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }}
frontend-build: bundle-analysis:
name: Frontend Build name: Bundle Analysis
needs: [changes, unit-tests, e2e-tests] needs: [changes, unit-tests, e2e-tests]
if: needs.changes.outputs.frontend_changed == 'true' if: needs.changes.outputs.frontend_changed == 'true'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
environment: bundle-analysis
permissions: permissions:
contents: read contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 2 fetch-depth: 2
persist-credentials: false persist-credentials: false
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with: with:
package_json_file: src-ui/package.json version: 10
- name: Use Node.js 24 - name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version: 24.x node-version: 24.x
cache: 'pnpm' cache: 'pnpm'
@@ -257,19 +260,21 @@ jobs:
~/.pnpm-store ~/.pnpm-store
~/.cache ~/.cache
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }} key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
- name: Install dependencies - name: Re-link Angular CLI
run: cd src-ui && pnpm install --frozen-lockfile run: cd src-ui && pnpm link @angular/cli
- name: Build - name: Build and analyze
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
run: cd src-ui && pnpm run build --configuration=production run: cd src-ui && pnpm run build --configuration=production
gate: gate:
name: Frontend CI Gate name: Frontend CI Gate
needs: [changes, install-dependencies, lint, unit-tests, e2e-tests, frontend-build] needs: [changes, install-dependencies, lint, unit-tests, e2e-tests, bundle-analysis]
if: always() if: always()
runs-on: ubuntu-slim runs-on: ubuntu-slim
steps: steps:
- name: Check gate - name: Check gate
env: env:
BUILD_RESULT: ${{ needs['frontend-build'].result }} BUNDLE_ANALYSIS_RESULT: ${{ needs['bundle-analysis'].result }}
E2E_RESULT: ${{ needs['e2e-tests'].result }} E2E_RESULT: ${{ needs['e2e-tests'].result }}
FRONTEND_CHANGED: ${{ needs.changes.outputs.frontend_changed }} FRONTEND_CHANGED: ${{ needs.changes.outputs.frontend_changed }}
INSTALL_RESULT: ${{ needs['install-dependencies'].result }} INSTALL_RESULT: ${{ needs['install-dependencies'].result }}
@@ -301,8 +306,8 @@ jobs:
exit 1 exit 1
fi fi
if [[ "${BUILD_RESULT}" != "success" ]]; then if [[ "${BUNDLE_ANALYSIS_RESULT}" != "success" ]]; then
echo "::error::Frontend build job result: ${BUILD_RESULT}" echo "::error::Frontend bundle-analysis job result: ${BUNDLE_ANALYSIS_RESULT}"
exit 1 exit 1
fi fi
+3 -3
View File
@@ -17,12 +17,12 @@ jobs:
runs-on: ubuntu-slim runs-on: ubuntu-slim
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Install Python - name: Install Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with: with:
python-version: "3.14" python-version: "3.14"
- name: Run prek - name: Run prek
uses: j178/prek-action@5337cb91e0fa35a7ff31b9ca345126d8bbbcdf16 # v2.0.6 uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4
+11 -11
View File
@@ -8,7 +8,7 @@ concurrency:
group: release-${{ github.ref }} group: release-${{ github.ref }}
cancel-in-progress: false cancel-in-progress: false
env: env:
DEFAULT_UV_VERSION: "0.12.x" DEFAULT_UV_VERSION: "0.11.x"
DEFAULT_PYTHON_VERSION: "3.12" DEFAULT_PYTHON_VERSION: "3.12"
permissions: {} permissions: {}
jobs: jobs:
@@ -20,7 +20,7 @@ jobs:
statuses: read statuses: read
steps: steps:
- name: Wait for Docker build - name: Wait for Docker build
uses: lewagon/wait-on-check-action@2271c86c146b96545b4e871b855e10ffa6f50773 # v1.9.0 uses: lewagon/wait-on-check-action@96d9100b431964d10e0136aff8b9ccb92470505e # v1.8.0
with: with:
ref: ${{ github.sha }} ref: ${{ github.sha }}
check-name: 'Merge and Push Manifest' check-name: 'Merge and Push Manifest'
@@ -35,16 +35,16 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
# ---- Frontend Build ---- # ---- Frontend Build ----
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with: with:
package_json_file: src-ui/package.json version: 10
- name: Use Node.js 24 - name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version: 24.x node-version: 24.x
package-manager-cache: false package-manager-cache: false
@@ -55,11 +55,11 @@ jobs:
# ---- Backend Setup ---- # ---- Backend Setup ----
- name: Set up Python - name: Set up Python
id: setup-python id: setup-python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }} python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: false enable-cache: false
@@ -171,7 +171,7 @@ jobs:
fi fi
- name: Create release and changelog - name: Create release and changelog
id: create-release id: create-release
uses: release-drafter/release-drafter@eada3c96a64734dd381cfbda23511034e328ddb0 # v7.6.0 uses: release-drafter/release-drafter@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1
with: with:
name: Paperless-ngx ${{ steps.get-version.outputs.version }} name: Paperless-ngx ${{ steps.get-version.outputs.version }}
tag: ${{ steps.get-version.outputs.version }} tag: ${{ steps.get-version.outputs.version }}
@@ -202,17 +202,17 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
ref: main ref: main
persist-credentials: true # for pushing changelog branch persist-credentials: true # for pushing changelog branch
- name: Set up Python - name: Set up Python
id: setup-python id: setup-python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }} python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: false enable-cache: false
+4 -4
View File
@@ -22,11 +22,11 @@ jobs:
security-events: write security-events: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Run zizmor - name: Run zizmor
uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1 uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
semgrep: semgrep:
name: Semgrep CE name: Semgrep CE
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
@@ -38,13 +38,13 @@ jobs:
security-events: write security-events: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Run Semgrep - name: Run Semgrep
run: semgrep scan --config auto --sarif-output results.sarif run: semgrep scan --config auto --sarif-output results.sarif
- name: Upload results to GitHub code scanning - name: Upload results to GitHub code scanning
uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
if: always() if: always()
with: with:
sarif_file: results.sarif sarif_file: results.sarif
+3 -3
View File
@@ -34,12 +34,12 @@ jobs:
# Learn more about CodeQL language support at https://git.io/codeql-language-support # Learn more about CodeQL language support at https://git.io/codeql-language-support
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # 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. # 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 # queries: ./path/to/local/query, your-org/your-repo/queries@main
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
+2 -2
View File
@@ -17,12 +17,12 @@ jobs:
environment: translation-sync environment: translation-sync
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
token: ${{ secrets.PNGX_BOT_PAT }} token: ${{ secrets.PNGX_BOT_PAT }}
persist-credentials: false persist-credentials: false
- name: crowdin action - name: crowdin action
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0 uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
with: with:
upload_translations: false upload_translations: false
download_translations: true download_translations: true
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
steps: steps:
- name: Label PR by file path or branch name - name: Label PR by file path or branch name
# see .github/labeler.yml for the labeler config # see .github/labeler.yml for the labeler config
uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0 uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Label by size - name: Label by size
+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' if: github.event_name == 'pull_request_target' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login != 'dependabot'
steps: steps:
- name: Label PR with release-drafter - name: Label PR with release-drafter
uses: release-drafter/release-drafter@eada3c96a64734dd381cfbda23511034e328ddb0 # v7.6.0 uses: release-drafter/release-drafter@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
issues: write issues: write
pull-requests: write pull-requests: write
steps: steps:
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0 - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with: with:
days-before-stale: 7 days-before-stale: 7
days-before-close: 14 days-before-close: 14
+11 -8
View File
@@ -4,7 +4,7 @@ on:
branches: branches:
- dev - dev
env: env:
DEFAULT_UV_VERSION: "0.12.x" DEFAULT_UV_VERSION: "0.11.x"
jobs: jobs:
generate-translate-strings: generate-translate-strings:
name: Generate Translation Strings name: Generate Translation Strings
@@ -14,7 +14,7 @@ jobs:
contents: write contents: write
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
env: env:
GH_REF: ${{ github.ref }} # sonar rule:githubactions:S7630 - avoid injection GH_REF: ${{ github.ref }} # sonar rule:githubactions:S7630 - avoid injection
with: with:
@@ -23,13 +23,13 @@ jobs:
persist-credentials: true # for pushing translation branch persist-credentials: true # for pushing translation branch
- name: Set up Python - name: Set up Python
id: setup-python id: setup-python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
- name: Install system dependencies - name: Install system dependencies
run: | run: |
sudo apt-get update -qq sudo apt-get update -qq
sudo apt-get install -qq --no-install-recommends gettext sudo apt-get install -qq --no-install-recommends gettext
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
@@ -45,9 +45,9 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with: with:
package_json_file: src-ui/package.json version: 10
- name: Use Node.js 24 - name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version: 24.x node-version: 24.x
cache: 'pnpm' cache: 'pnpm'
@@ -61,13 +61,16 @@ jobs:
~/.cache ~/.cache
key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/pnpm-lock.yaml') }} key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
- name: Install frontend dependencies - name: Install frontend dependencies
run: cd src-ui && pnpm install --frozen-lockfile if: steps.cache-frontend-deps.outputs.cache-hit != 'true'
run: cd src-ui && pnpm install
- name: Re-link Angular cli
run: cd src-ui && pnpm link @angular/cli
- name: Generate frontend translation strings - name: Generate frontend translation strings
run: | run: |
cd src-ui cd src-ui
pnpm run ng extract-i18n pnpm run ng extract-i18n
- name: Commit changes - name: Commit changes
uses: stefanzweifel/git-auto-commit-action@4a55954c782fc1ea30b9056cd3e7a2b40ca8887d # v7.2.0 uses: stefanzweifel/git-auto-commit-action@04702edda442b2e678b25b537cec683a1493fcb9 # v7.1.0
with: with:
file_pattern: 'src-ui/messages.xlf src/locale/en_US/LC_MESSAGES/django.po' file_pattern: 'src-ui/messages.xlf src/locale/en_US/LC_MESSAGES/django.po'
commit_message: "Auto translate strings" commit_message: "Auto translate strings"
+5 -5
View File
@@ -29,7 +29,7 @@ repos:
- id: check-case-conflict - id: check-case-conflict
- id: detect-private-key - id: detect-private-key
- repo: https://github.com/codespell-project/codespell - repo: https://github.com/codespell-project/codespell
rev: v2.4.3 rev: v2.4.2
hooks: hooks:
- id: codespell - id: codespell
additional_dependencies: [tomli] additional_dependencies: [tomli]
@@ -38,7 +38,7 @@ repos:
- json - json
# See https://github.com/prettier/prettier/issues/15742 for the fork reason # See https://github.com/prettier/prettier/issues/15742 for the fork reason
- repo: https://github.com/rbubley/mirrors-prettier - repo: https://github.com/rbubley/mirrors-prettier
rev: 'v3.9.6' rev: 'v3.9.4'
hooks: hooks:
- id: prettier - id: prettier
types_or: types_or:
@@ -46,16 +46,16 @@ repos:
- ts - ts
- markdown - markdown
additional_dependencies: additional_dependencies:
- prettier@3.9.6 - prettier@3.9.4
- 'prettier-plugin-organize-imports@4.3.0' - 'prettier-plugin-organize-imports@4.3.0'
# Python hooks # Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.1 rev: v0.16.0
hooks: hooks:
- id: ruff-check - id: ruff-check
- id: ruff-format - id: ruff-format
- repo: https://github.com/tox-dev/pyproject-fmt - repo: https://github.com/tox-dev/pyproject-fmt
rev: "v2.26.0" rev: "v2.25.1"
hooks: hooks:
- id: pyproject-fmt - id: pyproject-fmt
additional_dependencies: [tomli] additional_dependencies: [tomli]
+1 -1
View File
@@ -30,7 +30,7 @@ RUN set -eux \
# Purpose: Installs s6-overlay and rootfs # Purpose: Installs s6-overlay and rootfs
# Comments: # Comments:
# - Don't leave anything extra in here either # - Don't leave anything extra in here either
FROM ghcr.io/astral-sh/uv:0.12.5-python3.14-trixie-slim AS s6-overlay-base FROM ghcr.io/astral-sh/uv:0.11.28-python3.12-trixie-slim AS s6-overlay-base
WORKDIR /usr/src/s6 WORKDIR /usr/src/s6
+2 -2
View File
@@ -24,7 +24,7 @@ services:
network_mode: host network_mode: host
restart: unless-stopped restart: unless-stopped
greenmail: greenmail:
image: docker.io/greenmail/standalone:2.1.11 image: docker.io/greenmail/standalone:2.1.9
hostname: greenmail hostname: greenmail
container_name: greenmail container_name: greenmail
environment: environment:
@@ -35,7 +35,7 @@ services:
- "3143:3143" # IMAP - "3143:3143" # IMAP
restart: unless-stopped restart: unless-stopped
nginx: nginx:
image: docker.io/nginx:1.31.3-alpine image: docker.io/nginx:1.31.2-alpine
hostname: nginx hostname: nginx
container_name: nginx container_name: nginx
ports: ports:
@@ -68,14 +68,7 @@
<!-- <policy domain="resource" name="thread" value="4"/> --> <!-- <policy domain="resource" name="thread" value="4"/> -->
<!-- <policy domain="resource" name="throttle" value="0"/> --> <!-- <policy domain="resource" name="throttle" value="0"/> -->
<!-- <policy domain="resource" name="time" value="3600"/> --> <!-- <policy domain="resource" name="time" value="3600"/> -->
<!-- Paperless does not process SVG or ImageMagick scripting formats. --> <!-- <policy domain="coder" rights="none" pattern="MVG" /> -->
<policy domain="coder" rights="none" pattern="SVG" />
<policy domain="coder" rights="none" pattern="SVGZ" />
<policy domain="coder" rights="none" pattern="MSVG" />
<policy domain="coder" rights="none" pattern="RSVG" />
<policy domain="coder" rights="none" pattern="MSL" />
<policy domain="coder" rights="none" pattern="MVG" />
<policy domain="coder" rights="none" pattern="EPHEMERAL" />
<!-- <policy domain="module" rights="none" pattern="{PS,PDF,XPS}" /> --> <!-- <policy domain="module" rights="none" pattern="{PS,PDF,XPS}" /> -->
<!-- <policy domain="delegate" rights="none" pattern="HTTPS" /> --> <!-- <policy domain="delegate" rights="none" pattern="HTTPS" /> -->
<!-- <policy domain="path" rights="none" pattern="@*" /> --> <!-- <policy domain="path" rights="none" pattern="@*" /> -->
@@ -85,6 +78,8 @@
<!-- <policy domain="system" name="pixel-cache-memory" value="anonymous"/> --> <!-- <policy domain="system" name="pixel-cache-memory" value="anonymous"/> -->
<!-- <policy domain="system" name="shred" value="2"/> --> <!-- <policy domain="system" name="shred" value="2"/> -->
<!-- <policy domain="system" name="precision" value="6"/> --> <!-- <policy domain="system" name="precision" value="6"/> -->
<!-- not needed due to the need to use explicitly by mvg: -->
<!-- <policy domain="delegate" rights="none" pattern="MVG" /> -->
<!-- use curl --> <!-- use curl -->
<policy domain="delegate" rights="none" pattern="URL" /> <policy domain="delegate" rights="none" pattern="URL" />
<policy domain="delegate" rights="none" pattern="HTTPS" /> <policy domain="delegate" rights="none" pattern="HTTPS" />
@@ -1,12 +0,0 @@
#!/command/with-contenv /usr/bin/bash
# shellcheck shell=bash
declare -r log_prefix="[init-llmindex-migrate]"
echo "${log_prefix} Checking for pending LLM index migrations..."
cd "${PAPERLESS_SRC_DIR}"
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py document_llmindex migrate
else
s6-setuidgid paperless python3 manage.py document_llmindex migrate
fi
@@ -1 +0,0 @@
oneshot
@@ -1 +0,0 @@
/etc/s6-overlay/s6-rc.d/init-llmindex-migrate/run
+1 -1
View File
@@ -61,7 +61,7 @@ def replace_with_symlinks(
total_duplicates = 0 total_duplicates = 0
space_saved = 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 # Keep the first file as the original, replace others with symlinks
original_file = file_list[0] original_file = file_list[0]
duplicates = file_list[1:] duplicates = file_list[1:]
+1 -44
View File
@@ -151,23 +151,6 @@ the background.
### Bare Metal Route {#bare-metal-updating} ### Bare Metal Route {#bare-metal-updating}
!!! warning
Extracting a new release archive on top of an existing installation
(e.g. `tar -xf paperless-ngx-vX.Y.Z.tar.xz -C /opt/paperless`) only adds
and overwrites files -- it does not remove files that were deleted in
the new release. Leftover files from an old version, such as
superseded database migrations, can remain on disk and cause errors
like `NodeNotFoundError` during `manage.py migrate`.
Before unpacking a new release over an existing bare-metal
installation, remove the previous source tree first (everything
except your `media`, `data`, and `consume` directories and your
`paperless.conf`/`.env`), or unpack into a fresh directory and move
your persistent data and configuration over. Installations updated via
`git pull` on a clean checkout are not affected, since Git removes
files that no longer exist upstream.
After grabbing the new release and unpacking the contents, do the After grabbing the new release and unpacking the contents, do the
following: following:
@@ -212,16 +195,6 @@ following:
This is a no-op if the index is already up to date, so it is safe to This is a no-op if the index is already up to date, so it is safe to
run on every upgrade. run on every upgrade.
5. Migrate the LLM index if needed.
```shell-session
cd src
python3 manage.py document_llmindex migrate
```
This is a no-op if the index schema is already current, so it is safe
to run on every upgrade.
### Database Upgrades ### Database Upgrades
Paperless-ngx is compatible with Django-supported versions of PostgreSQL and MariaDB and it is generally Paperless-ngx is compatible with Django-supported versions of PostgreSQL and MariaDB and it is generally
@@ -299,8 +272,6 @@ optional arguments:
-sm, --split-manifest -sm, --split-manifest
-z, --zip -z, --zip
-zn, --zip-name -zn, --zip-name
--zip-compression
--zip-compression-level
--data-only --data-only
--no-progress-bar --no-progress-bar
--passphrase --passphrase
@@ -363,19 +334,6 @@ If `-z` or `--zip` is provided, the export will be a zip file
in the target directory, named according to the current local date or the in the target directory, named according to the current local date or the
value set in `-zn` or `--zip-name`. value set in `-zn` or `--zip-name`.
The compression method for the zip can be set with `--zip-compression`
(`stored`, `deflated` (default), `bzip2`, `lzma`, or `zstd`) and tuned with
`--zip-compression-level` (deflated: 09, bzip2: 19, zstd: -2222; ignored
for `stored` and `lzma`). Both options require `--zip`.
!!! warning
`zstd` compression requires Python 3.14 or newer on **both** the machine
creating the export and any machine importing it. An archive compressed with
`zstd` (or `lzma`/`bzip2` where those modules are unavailable) cannot be
imported on a runtime that lacks the codec; the importer will refuse it with
a clear error. The default `deflated` is universally readable.
If `--data-only` is provided, only the database will be exported. This option is intended If `--data-only` is provided, only the database will be exported. This option is intended
to facilitate database upgrades without needing to clean documents and thumbnails from the media directory. to facilitate database upgrades without needing to clean documents and thumbnails from the media directory.
@@ -557,7 +515,7 @@ index is updated automatically on the schedule set by
can manage it manually: can manage it manually:
``` ```
document_llmindex {rebuild,update,compact,migrate} document_llmindex {rebuild,update,compact}
``` ```
Specify `rebuild` to build the index from scratch from all documents in the database. Use Specify `rebuild` to build the index from scratch from all documents in the database. Use
@@ -714,7 +672,6 @@ document_fuzzy_match [--ratio] [--processes N]
| --ratio | No | 85.0 | a number between 0 and 100, setting how similar a document must be for it to be reported. Higher numbers mean more similarity. | | --ratio | No | 85.0 | a number between 0 and 100, setting how similar a document must be for it to be reported. Higher numbers mean more similarity. |
| --processes | No | 1/4 of system cores | Number of processes to use for matching. Setting 1 disables multiple processes | | --processes | No | 1/4 of system cores | Number of processes to use for matching. Setting 1 disables multiple processes |
| --delete | No | False | If provided, one document of a matched pair above the ratio will be deleted. | | --delete | No | False | If provided, one document of a matched pair above the ratio will be deleted. |
| --url | No | blank | If an instance URL is provided, the output table will show URLs to each documents instead of the document ID and name. |
!!! warning !!! warning
-1
View File
@@ -227,7 +227,6 @@ Version-aware endpoints:
- `PATCH /api/documents/{id}/`: content updates target the selected version (`?version={version_id}`) or latest version by default; non-content metadata updates target the root document. - `PATCH /api/documents/{id}/`: content updates target the selected version (`?version={version_id}`) or latest version by default; non-content metadata updates target the root document.
- `GET /api/documents/{id}/download/`, `GET /api/documents/{id}/preview/`, `GET /api/documents/{id}/thumb/`, `GET /api/documents/{id}/metadata/`: accept `?version={version_id}`. - `GET /api/documents/{id}/download/`, `GET /api/documents/{id}/preview/`, `GET /api/documents/{id}/thumb/`, `GET /api/documents/{id}/metadata/`: accept `?version={version_id}`.
- `POST /api/documents/{id}/update_version/`: uploads a new version using multipart form field `document` and optional `version_label`. - `POST /api/documents/{id}/update_version/`: uploads a new version using multipart form field `document` and optional `version_label`.
- `POST /api/documents/merge_as_versions/`: merges existing top-level documents as versions of a selected root. The JSON body must contain `documents` (at least two document IDs) and `root_document_id` (one of those IDs). When merging one source document, an optional `version_label` may be provided.
- `PATCH /api/documents/{id}/versions/{version_id}/`: updates the `version_label` of a specific version. - `PATCH /api/documents/{id}/versions/{version_id}/`: updates the `version_label` of a specific version.
- `DELETE /api/documents/{root_id}/versions/{version_id}/`: deletes a non-root version. - `DELETE /api/documents/{root_id}/versions/{version_id}/`: deletes a non-root version.
-199
View File
@@ -1,204 +1,5 @@
# Changelog # Changelog
## paperless-ngx 3.0.5
### Bug Fixes
- Fix: accept Whoosh-era abbreviated relative-date units (yrs, mos, wks, etc) in search queries [@stumpylog](https://github.com/stumpylog) ([#13486](https://github.com/paperless-ngx/paperless-ngx/pull/13486))
- Fix: fix edit dialog error change detection [@shamoon](https://github.com/shamoon) ([#13483](https://github.com/paperless-ngx/paperless-ngx/pull/13483))
- Fix: key the AI suggestion cache by model and endpoint [@lunetics](https://github.com/lunetics) ([#13449](https://github.com/paperless-ngx/paperless-ngx/pull/13449))
- Fix: validate custom field values in bulk operations [@shamoon](https://github.com/shamoon) ([#13457](https://github.com/paperless-ngx/paperless-ngx/pull/13457))
- Fixhancement: better handle empty fields from AI suggestions [@shamoon](https://github.com/shamoon) ([#13454](https://github.com/paperless-ngx/paperless-ngx/pull/13454))
- Fix: normalize monetary decimal symbol by locale [@shamoon](https://github.com/shamoon) ([#13427](https://github.com/paperless-ngx/paperless-ngx/pull/13427))
- Fix: fold overflowing path text in sanity checker [@stumpylog](https://github.com/stumpylog) ([#13426](https://github.com/paperless-ngx/paperless-ngx/pull/13426))
- Fix: correct delayed add field button for custom fields [@shamoon](https://github.com/shamoon) ([#13424](https://github.com/paperless-ngx/paperless-ngx/pull/13424))
- Fix: hide attributes collapse / show button without UI settings [@shamoon](https://github.com/shamoon) ([#13425](https://github.com/paperless-ngx/paperless-ngx/pull/13425))
- Fix: consolidate born-digital PDF detection between archive decision and OCR [@stumpylog](https://github.com/stumpylog) ([#13409](https://github.com/paperless-ngx/paperless-ngx/pull/13409))
- Fix: prevent scale vs page loop in pngx PDF viewer [@shamoon](https://github.com/shamoon) ([#13406](https://github.com/paperless-ngx/paperless-ngx/pull/13406))
- Fix: handle hidden line breaks in email subjects when sending email [@shamoon](https://github.com/shamoon) ([#13402](https://github.com/paperless-ngx/paperless-ngx/pull/13402))
- Fix: avoid NotSupportedError from document\_importer on MariaDB [@stumpylog](https://github.com/stumpylog) ([#13400](https://github.com/paperless-ngx/paperless-ngx/pull/13400))
- Fix: exclude next-period start from relative date-range filters [@stumpylog](https://github.com/stumpylog) ([#13381](https://github.com/paperless-ngx/paperless-ngx/pull/13381))
- Fix: close non-atomic db connections in before\_task\_publish [@shamoon](https://github.com/shamoon) ([#13366](https://github.com/paperless-ngx/paperless-ngx/pull/13366))
- Chore/fix: refactor frontend task service [@shamoon](https://github.com/shamoon) ([#13365](https://github.com/paperless-ngx/paperless-ngx/pull/13365))
- Fix: fix Enter selection in search autocomplete [@shamoon](https://github.com/shamoon) ([#13361](https://github.com/paperless-ngx/paperless-ngx/pull/13361))
### Documentation
- Documentation: PAPERLESS\_CONSUMER\_IGNORE\_PATTERNS clarifications [@shamoon](https://github.com/shamoon) ([#13489](https://github.com/paperless-ngx/paperless-ngx/pull/13489))
- Documentation: Add Password Removal workflow action documentation [@stumpylog](https://github.com/stumpylog) ([#13377](https://github.com/paperless-ngx/paperless-ngx/pull/13377))
### Dependencies
- Chore(deps): Bump pymdown-extensions from 10.21.3 to 11.0 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13378](https://github.com/paperless-ngx/paperless-ngx/pull/13378))
### All App Changes
<details>
<summary>21 changes</summary>
- Fix: accept Whoosh-era abbreviated relative-date units (yrs, mos, wks, etc) in search queries [@stumpylog](https://github.com/stumpylog) ([#13486](https://github.com/paperless-ngx/paperless-ngx/pull/13486))
- Fix: fix edit dialog error change detection [@shamoon](https://github.com/shamoon) ([#13483](https://github.com/paperless-ngx/paperless-ngx/pull/13483))
- Performance: sqlite-vec point-delete for document chunks [@stumpylog](https://github.com/stumpylog) ([#13438](https://github.com/paperless-ngx/paperless-ngx/pull/13438))
- Fix: key the AI suggestion cache by model and endpoint [@lunetics](https://github.com/lunetics) ([#13449](https://github.com/paperless-ngx/paperless-ngx/pull/13449))
- Fix: validate custom field values in bulk operations [@shamoon](https://github.com/shamoon) ([#13457](https://github.com/paperless-ngx/paperless-ngx/pull/13457))
- Fixhancement: better handle empty fields from AI suggestions [@shamoon](https://github.com/shamoon) ([#13454](https://github.com/paperless-ngx/paperless-ngx/pull/13454))
- Performance: Use server side iterators during LLM index updating [@stumpylog](https://github.com/stumpylog) ([#13430](https://github.com/paperless-ngx/paperless-ngx/pull/13430))
- Fix: normalize monetary decimal symbol by locale [@shamoon](https://github.com/shamoon) ([#13427](https://github.com/paperless-ngx/paperless-ngx/pull/13427))
- Fix: fold overflowing path text in sanity checker [@stumpylog](https://github.com/stumpylog) ([#13426](https://github.com/paperless-ngx/paperless-ngx/pull/13426))
- Fix: correct delayed add field button for custom fields [@shamoon](https://github.com/shamoon) ([#13424](https://github.com/paperless-ngx/paperless-ngx/pull/13424))
- Fix: hide attributes collapse / show button without UI settings [@shamoon](https://github.com/shamoon) ([#13425](https://github.com/paperless-ngx/paperless-ngx/pull/13425))
- Fix: consolidate born-digital PDF detection between archive decision and OCR [@stumpylog](https://github.com/stumpylog) ([#13409](https://github.com/paperless-ngx/paperless-ngx/pull/13409))
- Fix: prevent scale vs page loop in pngx PDF viewer [@shamoon](https://github.com/shamoon) ([#13406](https://github.com/paperless-ngx/paperless-ngx/pull/13406))
- Fix: handle hidden line breaks in email subjects when sending email [@shamoon](https://github.com/shamoon) ([#13402](https://github.com/paperless-ngx/paperless-ngx/pull/13402))
- Fix: avoid NotSupportedError from document\_importer on MariaDB [@stumpylog](https://github.com/stumpylog) ([#13400](https://github.com/paperless-ngx/paperless-ngx/pull/13400))
- Tweak: adjust doc details button toolbar flow [@shamoon](https://github.com/shamoon) ([#13382](https://github.com/paperless-ngx/paperless-ngx/pull/13382))
- Fix: exclude next-period start from relative date-range filters [@stumpylog](https://github.com/stumpylog) ([#13381](https://github.com/paperless-ngx/paperless-ngx/pull/13381))
- Chore(deps): Bump pymdown-extensions from 10.21.3 to 11.0 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13378](https://github.com/paperless-ngx/paperless-ngx/pull/13378))
- Fix: close non-atomic db connections in before\_task\_publish [@shamoon](https://github.com/shamoon) ([#13366](https://github.com/paperless-ngx/paperless-ngx/pull/13366))
- Chore/fix: refactor frontend task service [@shamoon](https://github.com/shamoon) ([#13365](https://github.com/paperless-ngx/paperless-ngx/pull/13365))
- Fix: fix Enter selection in search autocomplete [@shamoon](https://github.com/shamoon) ([#13361](https://github.com/paperless-ngx/paperless-ngx/pull/13361))
</details>
## paperless-ngx 3.0.4
### Bug Fixes
- Fix: prevent pdfjs highlight scrolling from affecting the entire page [@shamoon](https://github.com/shamoon) ([#13355](https://github.com/paperless-ngx/paperless-ngx/pull/13355))
- Fix: don't skip OCR/archive for tagged PDFs with no actual text [@stumpylog](https://github.com/stumpylog) ([#13351](https://github.com/paperless-ngx/paperless-ngx/pull/13351))
- Performance: more efficient diacritic normalization in dropdown filtering [@shamoon](https://github.com/shamoon) ([#13347](https://github.com/paperless-ngx/paperless-ngx/pull/13347))
- Fix: dedupe permission-visible documents when combined with multi-tag filters [@stumpylog](https://github.com/stumpylog) ([#13345](https://github.com/paperless-ngx/paperless-ngx/pull/13345))
- Fixhancement: pass LLM output language to chat if specified [@shamoon](https://github.com/shamoon) ([#13340](https://github.com/paperless-ngx/paperless-ngx/pull/13340))
- Fix: ensure preview reload on live changes [@shamoon](https://github.com/shamoon) ([#13321](https://github.com/paperless-ngx/paperless-ngx/pull/13321))
- Fix: clamp all out out-of-range possible fields [@stumpylog](https://github.com/stumpylog) ([#13316](https://github.com/paperless-ngx/paperless-ngx/pull/13316))
- Fix: add docstring to DocumentClassifierSchema for cleaner LLM tool description [@stumpylog](https://github.com/stumpylog) ([#13315](https://github.com/paperless-ngx/paperless-ngx/pull/13315))
- Fix: guard build\_document\_node against stale FK on deleted correspondent/doc type [@stumpylog](https://github.com/stumpylog) ([#13318](https://github.com/paperless-ngx/paperless-ngx/pull/13318))
- Fix: prevent search filter loss when closing document with Escape key [@shamoon](https://github.com/shamoon) ([#13317](https://github.com/paperless-ngx/paperless-ngx/pull/13317))
- Fix: fix frontend permissions display for stats/system perms [@shamoon](https://github.com/shamoon) ([#13305](https://github.com/paperless-ngx/paperless-ngx/pull/13305))
### Documentation
- Documentation: fix PAPERLESS\_AI\_LLM\_OUTPUT\_LANGUAGE heading level [@SuperSandro2000](https://github.com/SuperSandro2000) ([#13341](https://github.com/paperless-ngx/paperless-ngx/pull/13341))
### Dependencies
- Chore: resolve npm provenance issues with chokidar and semver [@shamoon](https://github.com/shamoon) ([#13323](https://github.com/paperless-ngx/paperless-ngx/pull/13323))
### All App Changes
<details>
<summary>14 changes</summary>
- Fix: prevent pdfjs highlight scrolling from affecting the entire page [@shamoon](https://github.com/shamoon) ([#13355](https://github.com/paperless-ngx/paperless-ngx/pull/13355))
- Fix: don't skip OCR/archive for tagged PDFs with no actual text [@stumpylog](https://github.com/stumpylog) ([#13351](https://github.com/paperless-ngx/paperless-ngx/pull/13351))
- Performance: more efficient diacritic normalization in dropdown filtering [@shamoon](https://github.com/shamoon) ([#13347](https://github.com/paperless-ngx/paperless-ngx/pull/13347))
- Performance: prefetch notes and custom fields for LLM index text building [@stumpylog](https://github.com/stumpylog) ([#13350](https://github.com/paperless-ngx/paperless-ngx/pull/13350))
- Fix: dedupe permission-visible documents when combined with multi-tag filters [@stumpylog](https://github.com/stumpylog) ([#13345](https://github.com/paperless-ngx/paperless-ngx/pull/13345))
- Performance: Scope llm index updates to actually modified documents [@stumpylog](https://github.com/stumpylog) ([#13322](https://github.com/paperless-ngx/paperless-ngx/pull/13322))
- Fixhancement: pass LLM output language to chat if specified [@shamoon](https://github.com/shamoon) ([#13340](https://github.com/paperless-ngx/paperless-ngx/pull/13340))
- Chore: resolve npm provenance issues with chokidar and semver [@shamoon](https://github.com/shamoon) ([#13323](https://github.com/paperless-ngx/paperless-ngx/pull/13323))
- Fix: ensure preview reload on live changes [@shamoon](https://github.com/shamoon) ([#13321](https://github.com/paperless-ngx/paperless-ngx/pull/13321))
- Fix: clamp all out out-of-range possible fields [@stumpylog](https://github.com/stumpylog) ([#13316](https://github.com/paperless-ngx/paperless-ngx/pull/13316))
- Fix: add docstring to DocumentClassifierSchema for cleaner LLM tool description [@stumpylog](https://github.com/stumpylog) ([#13315](https://github.com/paperless-ngx/paperless-ngx/pull/13315))
- Fix: guard build\_document\_node against stale FK on deleted correspondent/doc type [@stumpylog](https://github.com/stumpylog) ([#13318](https://github.com/paperless-ngx/paperless-ngx/pull/13318))
- Fix: prevent search filter loss when closing document with Escape key [@shamoon](https://github.com/shamoon) ([#13317](https://github.com/paperless-ngx/paperless-ngx/pull/13317))
- Fix: fix frontend permissions display for stats/system perms [@shamoon](https://github.com/shamoon) ([#13305](https://github.com/paperless-ngx/paperless-ngx/pull/13305))
</details>
## paperless-ngx 3.0.3
### Bug Fixes
- Fixhancement: PAPERLESS\_ALLAUTH\_TRUSTED\_PROXY\_COUNT [@shamoon](https://github.com/shamoon) ([#13281](https://github.com/paperless-ngx/paperless-ngx/pull/13281))
- Fix: preserve document fields during Gotenberg conversion to PDF [@stumpylog](https://github.com/stumpylog) ([#13271](https://github.com/paperless-ngx/paperless-ngx/pull/13271))
- Fix: Makes the email date aware as soon as possible during parsing [@stumpylog](https://github.com/stumpylog) ([#13266](https://github.com/paperless-ngx/paperless-ngx/pull/13266))
- Fix: Handle a plain string as Celery sometimes provides for the traceback [@stumpylog](https://github.com/stumpylog) ([#13267](https://github.com/paperless-ngx/paperless-ngx/pull/13267))
- Fix: Emit the torch index into the requirements.txt for people still using it [@stumpylog](https://github.com/stumpylog) ([#13265](https://github.com/paperless-ngx/paperless-ngx/pull/13265))
- Fix: handle notes without a user when building the search index [@matthiasmast](https://github.com/matthiasmast) ([#13260](https://github.com/paperless-ngx/paperless-ngx/pull/13260))
### Documentation
- Docs: warn bare-metal users about stale files when upgrading [@stumpylog](https://github.com/stumpylog) ([#13296](https://github.com/paperless-ngx/paperless-ngx/pull/13296))
- Documentation: Add the NumPy CPU baseline increase to the migration guide [@stumpylog](https://github.com/stumpylog) ([#13269](https://github.com/paperless-ngx/paperless-ngx/pull/13269))
### Maintenance
- Fix: Emit the torch index into the requirements.txt for people still using it [@stumpylog](https://github.com/stumpylog) ([#13265](https://github.com/paperless-ngx/paperless-ngx/pull/13265))
### All App Changes
<details>
<summary>6 changes</summary>
- Fixhancement: PAPERLESS\_ALLAUTH\_TRUSTED\_PROXY\_COUNT [@shamoon](https://github.com/shamoon) ([#13281](https://github.com/paperless-ngx/paperless-ngx/pull/13281))
- Tweak: adjust top navbar wrapping when AI chat button visible [@shamoon](https://github.com/shamoon) ([#13280](https://github.com/paperless-ngx/paperless-ngx/pull/13280))
- Fix: preserve document fields during Gotenberg conversion to PDF [@stumpylog](https://github.com/stumpylog) ([#13271](https://github.com/paperless-ngx/paperless-ngx/pull/13271))
- Fix: Makes the email date aware as soon as possible during parsing [@stumpylog](https://github.com/stumpylog) ([#13266](https://github.com/paperless-ngx/paperless-ngx/pull/13266))
- Fix: Handle a plain string as Celery sometimes provides for the traceback [@stumpylog](https://github.com/stumpylog) ([#13267](https://github.com/paperless-ngx/paperless-ngx/pull/13267))
- Fix: handle notes without a user when building the search index [@matthiasmast](https://github.com/matthiasmast) ([#13260](https://github.com/paperless-ngx/paperless-ngx/pull/13260))
</details>
## paperless-ngx 3.0.2
### Bug Fixes
- Fix: fix broken migration in 3.0.1 [@shamoon](https://github.com/shamoon) ([#13242](https://github.com/paperless-ngx/paperless-ngx/pull/13242))
### All App Changes
- Fix: fix broken migration in 3.0.1 [@shamoon](https://github.com/shamoon) ([#13242](https://github.com/paperless-ngx/paperless-ngx/pull/13242))
## paperless-ngx 3.0.1
### Bug Fixes
- Fix: selection\_data re-derives the filtered document set 5 times over [@stumpylog](https://github.com/stumpylog) ([#13229](https://github.com/paperless-ngx/paperless-ngx/pull/13229))
- Fix: exclude source document from its own RAG similarity results [@stumpylog](https://github.com/stumpylog) ([#13233](https://github.com/paperless-ngx/paperless-ngx/pull/13233))
- Fix: split mailrule maximum\_age clamp into its own migration [@stumpylog](https://github.com/stumpylog) ([#13231](https://github.com/paperless-ngx/paperless-ngx/pull/13231))
- Chore: mark yes in confirm button for translation [@shamoon](https://github.com/shamoon) ([#13225](https://github.com/paperless-ngx/paperless-ngx/pull/13225))
- Fix: correct URL for W001 check [@shamoon](https://github.com/shamoon) ([#13220](https://github.com/paperless-ngx/paperless-ngx/pull/13220))
- Fix: ensure create dialog uses correct attribute type [@shamoon](https://github.com/shamoon) ([#13221](https://github.com/paperless-ngx/paperless-ngx/pull/13221))
- Fix: correct database engine from postgres to postgresql [@OberstVonGatow](https://github.com/OberstVonGatow) ([#13213](https://github.com/paperless-ngx/paperless-ngx/pull/13213))
- Fix: remove unnecessary .distinct() dominating document list queries [@stumpylog](https://github.com/stumpylog) ([#13205](https://github.com/paperless-ngx/paperless-ngx/pull/13205))
- Fix: also wrap non-breaking words in chat messages, handle whitespace [@shamoon](https://github.com/shamoon) ([#13211](https://github.com/paperless-ngx/paperless-ngx/pull/13211))
- Fix: tag/custom-field document\_count scales badly with tag count [@stumpylog](https://github.com/stumpylog) ([#13203](https://github.com/paperless-ngx/paperless-ngx/pull/13203))
- Fix: batch document user\_can\_change checks to avoid per-row N+1 [@stumpylog](https://github.com/stumpylog) ([#13204](https://github.com/paperless-ngx/paperless-ngx/pull/13204))
- Fix: fix app title restoration [@shamoon](https://github.com/shamoon) ([#13208](https://github.com/paperless-ngx/paperless-ngx/pull/13208))
- Fix: handle long wrapping titles in AI chat document list [@shamoon](https://github.com/shamoon) ([#13206](https://github.com/paperless-ngx/paperless-ngx/pull/13206))
### Dependencies
- Chore(deps-dev): Bump postcss from 8.5.6 to 8.5.22 in /src/paperless\_mail/templates in the npm\_and\_yarn group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13236](https://github.com/paperless-ngx/paperless-ngx/pull/13236))
- Chore(deps): Bump pyasn1 from 0.6.3 to 0.6.4 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13237](https://github.com/paperless-ngx/paperless-ngx/pull/13237))
- Chore(deps): Bump the uv group across 1 directory with 8 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13189](https://github.com/paperless-ngx/paperless-ngx/pull/13189))
### All App Changes
<details>
<summary>17 changes</summary>
- Chore(deps-dev): Bump postcss from 8.5.6 to 8.5.22 in /src/paperless\_mail/templates in the npm\_and\_yarn group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13236](https://github.com/paperless-ngx/paperless-ngx/pull/13236))
- Fix: selection\_data re-derives the filtered document set 5 times over [@stumpylog](https://github.com/stumpylog) ([#13229](https://github.com/paperless-ngx/paperless-ngx/pull/13229))
- Chore: reword "Settings > Default permissions" description for clarity [@NotaInutilis](https://github.com/NotaInutilis) ([#13232](https://github.com/paperless-ngx/paperless-ngx/pull/13232))
- Chore(deps): Bump pyasn1 from 0.6.3 to 0.6.4 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13237](https://github.com/paperless-ngx/paperless-ngx/pull/13237))
- Chore(deps): Bump the uv group across 1 directory with 8 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13189](https://github.com/paperless-ngx/paperless-ngx/pull/13189))
- Fix: exclude source document from its own RAG similarity results [@stumpylog](https://github.com/stumpylog) ([#13233](https://github.com/paperless-ngx/paperless-ngx/pull/13233))
- Fix: split mailrule maximum\_age clamp into its own migration [@stumpylog](https://github.com/stumpylog) ([#13231](https://github.com/paperless-ngx/paperless-ngx/pull/13231))
- Chore: mark yes in confirm button for translation [@shamoon](https://github.com/shamoon) ([#13225](https://github.com/paperless-ngx/paperless-ngx/pull/13225))
- Fix: correct URL for W001 check [@shamoon](https://github.com/shamoon) ([#13220](https://github.com/paperless-ngx/paperless-ngx/pull/13220))
- Fix: ensure create dialog uses correct attribute type [@shamoon](https://github.com/shamoon) ([#13221](https://github.com/paperless-ngx/paperless-ngx/pull/13221))
- Fix: correct database engine from postgres to postgresql [@OberstVonGatow](https://github.com/OberstVonGatow) ([#13213](https://github.com/paperless-ngx/paperless-ngx/pull/13213))
- Fix: remove unnecessary .distinct() dominating document list queries [@stumpylog](https://github.com/stumpylog) ([#13205](https://github.com/paperless-ngx/paperless-ngx/pull/13205))
- Fix: also wrap non-breaking words in chat messages, handle whitespace [@shamoon](https://github.com/shamoon) ([#13211](https://github.com/paperless-ngx/paperless-ngx/pull/13211))
- Fix: tag/custom-field document\_count scales badly with tag count [@stumpylog](https://github.com/stumpylog) ([#13203](https://github.com/paperless-ngx/paperless-ngx/pull/13203))
- Fix: batch document user\_can\_change checks to avoid per-row N+1 [@stumpylog](https://github.com/stumpylog) ([#13204](https://github.com/paperless-ngx/paperless-ngx/pull/13204))
- Fix: fix app title restoration [@shamoon](https://github.com/shamoon) ([#13208](https://github.com/paperless-ngx/paperless-ngx/pull/13208))
- Fix: handle long wrapping titles in AI chat document list [@shamoon](https://github.com/shamoon) ([#13206](https://github.com/paperless-ngx/paperless-ngx/pull/13206))
</details>
## paperless-ngx 3.0.0 ## paperless-ngx 3.0.0
### Breaking Changes ### Breaking Changes
+16 -56
View File
@@ -522,36 +522,22 @@ do CORS calls. Set this to your public domain name.
fail2ban with log entries for failed authorization attempts. Value should be fail2ban with log entries for failed authorization attempts. Value should be
IP address(es). IP address(es).
By default, this setting also controls allauth's trusted proxy count, This setting also controls allauth's
which is set to the number of proxies listed here. Override that default [`ALLAUTH_TRUSTED_PROXY_COUNT`](https://docs.allauth.org/en/latest/account/configuration.html),
with [`PAPERLESS_ALLAUTH_TRUSTED_PROXY_COUNT`](#PAPERLESS_ALLAUTH_TRUSTED_PROXY_COUNT) which is set to the number of proxies listed here. Without this,
when the list length does not match the number of proxy hops. allauth cannot determine the client IP address for rate limiting when
running behind a reverse proxy, resulting in a `403 Forbidden` on login.
Defaults to empty string. Defaults to empty string.
#### [`PAPERLESS_ALLAUTH_TRUSTED_PROXY_COUNT=<integer>`](#PAPERLESS_ALLAUTH_TRUSTED_PROXY_COUNT) {#PAPERLESS_ALLAUTH_TRUSTED_PROXY_COUNT}
: Sets allauth's
[`ALLAUTH_TRUSTED_PROXY_COUNT`](https://docs.allauth.org/en/latest/common/rate_limits.html#configuration).
This is the number of trusted proxy **hops** represented in each
`X-Forwarded-For` header, not the number of IP addresses through which those
proxies may be reached. For example, a single dual-stack proxy is one hop even
when its IPv4 and IPv6 addresses are both listed in
[`PAPERLESS_TRUSTED_PROXIES`](#PAPERLESS_TRUSTED_PROXIES).
Only trust `X-Forwarded-For` when untrusted clients cannot connect directly
to Paperless-ngx.
Defaults to the number of entries in `PAPERLESS_TRUSTED_PROXIES`.
#### [`PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER=<header-name>`](#PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER) {#PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER} #### [`PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER=<header-name>`](#PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER) {#PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER}
: Sets allauth's : Sets allauth's
[`ALLAUTH_TRUSTED_CLIENT_IP_HEADER`](https://docs.allauth.org/en/latest/common/rate_limits.html#configuration). [`ALLAUTH_TRUSTED_CLIENT_IP_HEADER`](https://docs.allauth.org/en/latest/account/configuration.html).
Use this when your reverse proxy sets a dedicated header for the real Use this when your reverse proxy sets a dedicated header for the real
client IP instead of `X-Forwarded-For`, for example `X-Real-IP` (nginx) client IP instead of `X-Forwarded-For`, for example `X-Real-IP` (nginx)
or `CF-Connecting-IP` (Cloudflare). When set, this takes precedence over or `CF-Connecting-IP` (Cloudflare). When set, this takes precedence over
`PAPERLESS_ALLAUTH_TRUSTED_PROXY_COUNT`. [`PAPERLESS_TRUSTED_PROXIES`](#PAPERLESS_TRUSTED_PROXIES).
Defaults to none. Defaults to none.
@@ -776,24 +762,6 @@ system. See the corresponding
Defaults to "groups" Defaults to "groups"
#### [`PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP=<str>`](#PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP) {#PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP}
: Allows you to define a group name that, if present in the third-party authentication system's groups claim, will grant the user superuser (admin) and staff status in Paperless-ngx. If the group is not present in the claim, superuser status will be revoked upon next login.
!!! warning
This is a direct reflection of the claim on every login, including the connecting user, with no exemption for the last remaining admin. If the group is missing or misconfigured on the identity provider side, the logged-in user will immediately lose their own superuser access. Fix the group membership or claim mapping on the identity provider to restore it. If the identity provider itself is unreachable or misconfigured and you are locked out, you can recover admin access locally with `manage.py createsuperuser`.
Defaults to None
#### [`PAPERLESS_SOCIAL_ACCOUNT_SYNC_STAFF_GROUP=<str>`](#PAPERLESS_SOCIAL_ACCOUNT_SYNC_STAFF_GROUP) {#PAPERLESS_SOCIAL_ACCOUNT_SYNC_STAFF_GROUP}
: Allows you to define a group name that, if present in the third-party authentication system's groups claim, will grant the user staff status in Paperless-ngx. If the group is not present in the claim and the user is not a superuser, staff status will be revoked upon next login.
!!! warning
As with [`PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP`](#PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP), this is applied on every login unconditionally, including for the connecting user themselves.
Defaults to None
#### [`PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS=<comma-separated-list>`](#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS) {#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS} #### [`PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS=<comma-separated-list>`](#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS) {#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS}
: A list of group names that users who signup via social accounts will be added to upon signup. Groups listed here must already exist. : A list of group names that users who signup via social accounts will be added to upon signup. Groups listed here must already exist.
@@ -950,8 +918,8 @@ for display in the web interface.
| Document type | `never` | `auto` (default) | `always` | | Document type | `never` | `auto` (default) | `always` |
| -------------------------- | ------- | -------------------------- | -------- | | -------------------------- | ------- | -------------------------- | -------- |
| Scanned image (TIFF, JPEG) | No | **Yes** | Yes | | Scanned image (TIFF, JPEG) | No | **Yes** | Yes |
| Image-based PDF | No | **Yes** (no embedded text) | Yes | | Image-based PDF | No | **Yes** (short/no text, untagged) | Yes |
| Born-digital PDF | No | No (has embedded text, optionally confirmed by tag) | Yes | | Born-digital PDF | No | No (tagged or has embedded text) | Yes |
| Plain text, email, HTML | No | No | No | | Plain text, email, HTML | No | No | No |
| DOCX / ODT (via Tika) | Yes\* | Yes\* | Yes\* | | DOCX / ODT (via Tika) | Yes\* | Yes\* | Yes\* |
@@ -966,11 +934,10 @@ for display in the web interface.
!!! note !!! note
The **remote OCR parser** (Azure AI) also honors this setting: when The **remote OCR parser** (Azure AI) always produces a searchable
no archive is requested (`never`, or `auto` with a born-digital PDF), PDF and stores it as the archive copy, regardless of this setting.
the remote engine is skipped entirely and locally-extracted text is `ARCHIVE_FILE_GENERATION=never` has no effect when the remote
used instead, avoiding an unnecessary API call and a duplicate text parser handles a document.
layer.
#### [`PAPERLESS_OCR_CLEAN=<mode>`](#PAPERLESS_OCR_CLEAN) {#PAPERLESS_OCR_CLEAN} #### [`PAPERLESS_OCR_CLEAN=<mode>`](#PAPERLESS_OCR_CLEAN) {#PAPERLESS_OCR_CLEAN}
@@ -1379,16 +1346,13 @@ don't exist yet.
#### [`PAPERLESS_CONSUMER_IGNORE_PATTERNS=<json>`](#PAPERLESS_CONSUMER_IGNORE_PATTERNS) {#PAPERLESS_CONSUMER_IGNORE_PATTERNS} #### [`PAPERLESS_CONSUMER_IGNORE_PATTERNS=<json>`](#PAPERLESS_CONSUMER_IGNORE_PATTERNS) {#PAPERLESS_CONSUMER_IGNORE_PATTERNS}
: Additional regex patterns for files to ignore in the consumption directory. Patterns are matched against filenames only (not full paths) : Additional regex patterns for files to ignore in the consumption directory. Patterns are matched against filenames only (not full paths)
using Python's `re.search()`. Use `^` to anchor a pattern to the start of the filename and `$` to anchor it to the end. using Python's `re.match()`, which anchors at the start of the filename.
See the [watchfiles documentation](https://watchfiles.helpmanual.io/api/filters/#watchfiles.BaseFilter.ignore_entity_patterns) See the [watchfiles documentation](https://watchfiles.helpmanual.io/api/filters/#watchfiles.BaseFilter.ignore_entity_patterns)
This setting is for additional patterns beyond the built-in defaults. Common system files and directories are already ignored automatically. This setting is for additional patterns beyond the built-in defaults. Common system files and directories are already ignored automatically.
The patterns will be compiled via Python's standard `re` module. The patterns will be compiled via Python's standard `re` module.
These are regular expressions, not glob patterns. For example, the glob pattern `._*` does not mean "starts with `._`" when used as a
regular expression; it matches nearly any non-empty filename. Use `^\._.*` for that behavior instead.
Example custom patterns: Example custom patterns:
```json ```json
@@ -1403,11 +1367,7 @@ using Python's `re.search()`. Use `^` to anchor a pattern to the start of the fi
Defaults to `[]` (empty list, uses only built-in defaults). Defaults to `[]` (empty list, uses only built-in defaults).
The built-in file patterns are equivalent to the following regular expressions and cannot be overridden: The default ignores are `[.DS_Store, .DS_STORE, ._*, desktop.ini, Thumbs.db]` and cannot be overridden.
```json
["^\\.DS_Store$", "^\\.DS_STORE$", "^\\._.*", "^desktop\\.ini$", "^Thumbs\\.db$"]
```
#### [`PAPERLESS_CONSUMER_IGNORE_DIRS=<json>`](#PAPERLESS_CONSUMER_IGNORE_DIRS) {#PAPERLESS_CONSUMER_IGNORE_DIRS} #### [`PAPERLESS_CONSUMER_IGNORE_DIRS=<json>`](#PAPERLESS_CONSUMER_IGNORE_DIRS) {#PAPERLESS_CONSUMER_IGNORE_DIRS}
@@ -2161,7 +2121,7 @@ used with the OpenAI-compatible backend to target a custom provider or local gat
Defaults to None. Defaults to None.
#### [`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE=<str>`](#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE) {#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE} ### [`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE=<str>`](#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE) {#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE}
: The language to use for AI suggestions (results may vary by LLM model). If not supplied, defaults to the user's UI language setting or None. : The language to use for AI suggestions (results may vary by LLM model). If not supplied, defaults to the user's UI language setting or None.
+5 -9
View File
@@ -187,11 +187,10 @@ PAPERLESS_ARCHIVE_FILE_GENERATION=auto
### Remote OCR parser ### Remote OCR parser
If you use the **remote OCR parser** (Azure AI), `ARCHIVE_FILE_GENERATION` is If you use the **remote OCR parser** (Azure AI), note that it always produces a
honored the same way as for the local engine: when no archive is requested searchable PDF and stores it as the archive copy. `ARCHIVE_FILE_GENERATION=never`
(`never`, or `auto` with a born-digital PDF), the remote engine is skipped has no effect for documents handled by the remote parser - the archive is produced
entirely and locally-extracted text is used instead, avoiding an unnecessary unconditionally by the remote engine.
API call and a duplicate text layer.
## Search Index (Whoosh -> Tantivy) ## Search Index (Whoosh -> Tantivy)
@@ -325,11 +324,8 @@ option since v1.8.0.
Allauth changed how it determines the client IP address for login rate limiting. Users running Allauth changed how it determines the client IP address for login rate limiting. Users running
behind a reverse proxy may need to set behind a reverse proxy may need to set
[`PAPERLESS_TRUSTED_PROXIES`](configuration.md#PAPERLESS_TRUSTED_PROXIES), [`PAPERLESS_TRUSTED_PROXIES`](configuration.md#PAPERLESS_TRUSTED_PROXIES),
[`PAPERLESS_ALLAUTH_TRUSTED_PROXY_COUNT`](configuration.md#PAPERLESS_ALLAUTH_TRUSTED_PROXY_COUNT),
[`PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER`](configuration.md#PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER), [`PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER`](configuration.md#PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER),
or a combination of these settings to avoid `403 Forbidden` errors on login. or both, to avoid `403 Forbidden` errors on login.
The proxy count is the number of proxy hops in `X-Forwarded-For`, which may
differ from the number of configured proxy IP addresses.
## Minimum CPU Requirements (NumPy Baseline) ## Minimum CPU Requirements (NumPy Baseline)
+15 -16
View File
@@ -262,14 +262,6 @@ to a positive number to enable polling and disable native filesystem notificatio
and copy the contents to the home directory of the user you created and copy the contents to the home directory of the user you created
earlier (`/opt/paperless`). earlier (`/opt/paperless`).
!!! note
If you are updating an existing bare-metal installation rather than
installing for the first time, see the
[bare metal update instructions](administration.md#bare-metal-updating)
instead -- extracting a new release on top of an old one can leave
behind stale files from the previous version.
Optional: If you cloned the Git repository, you will need to Optional: If you cloned the Git repository, you will need to
compile the frontend yourself. See [here](development.md#front-end-development) compile the frontend yourself. See [here](development.md#front-end-development)
and use the `build` step, not `serve`. and use the `build` step, not `serve`.
@@ -416,15 +408,22 @@ to a positive number to enable polling and disable native filesystem notificatio
You may need to change the path in the files. Example: You may need to change the path in the files. Example:
`ExecStart=/opt/paperless/.local/bin/celery --app paperless worker --loglevel INFO` `ExecStart=/opt/paperless/.local/bin/celery --app paperless worker --loglevel INFO`
12. Configure ImageMagick to allow processing of PDF documents and disable 12. Configure ImageMagick to allow processing of PDF documents. Most
formats that Paperless-ngx does not use. Most distributions disable PDF distributions have this disabled by default, since PDF documents can
processing by default, since PDF documents can contain malware. If you contain malware. If you don't do this, Paperless-ngx will fall back to
don't enable it, Paperless-ngx will fall back to Ghostscript for certain Ghostscript for certain steps such as thumbnail generation.
steps such as thumbnail generation.
Configure the active ImageMagick policy file (commonly Edit `/etc/ImageMagick-6/policy.xml` and adjust
`/etc/ImageMagick-6/policy.xml` or `/etc/ImageMagick-7/policy.xml`) and
adjust similar to [the docker policy file](https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/refs/heads/main/docker/rootfs/etc/ImageMagick-6/paperless-policy.xml). You should also include restrictions as noted there. ```
<policy domain="coder" rights="none" pattern="PDF" />
```
to
```
<policy domain="coder" rights="read|write" pattern="PDF" />
```
**Optional: Install the [jbig2enc](https://ocrmypdf.readthedocs.io/en/latest/jbig2.html) encoder.** **Optional: Install the [jbig2enc](https://ocrmypdf.readthedocs.io/en/latest/jbig2.html) encoder.**
This will reduce the size of generated PDF documents. You'll most likely need to compile this yourself, because this This will reduce the size of generated PDF documents. You'll most likely need to compile this yourself, because this
+1 -35
View File
@@ -99,10 +99,6 @@ Think of versions as **file history** for a document.
- By default, search and document content use the latest version. - By default, search and document content use the latest version.
- In document detail, selecting a version switches the preview, file metadata and content (and download etc buttons) to that version. - In document detail, selecting a version switches the preview, file metadata and content (and download etc buttons) to that version.
- Deleting a non-root version keeps metadata and falls back to the latest remaining version. - Deleting a non-root version keeps metadata and falls back to the latest remaining version.
- From the document list, select two or more documents and choose **Merge as versions** to combine them under one entry. Select the root document whose metadata and permissions should be retained; the other selected documents become file versions. The root may already have versions, but documents being added as versions must not have version histories of their own.
- From a document's **Versions** menu, choose **Existing** to search for another document and add it as a version of the current document.
- Documents merged as versions give up their archive serial number. If the root has no ASN of its own it takes the first one, otherwise the ASNs are released and the removal is logged.
- Merging as versions cannot be undone from the UI, and deleting the root document moves its versions to the trash as well.
### Management Lists ### Management Lists
@@ -580,9 +576,7 @@ The following workflow action types are available:
- Tags, correspondent, document type and storage path - Tags, correspondent, document type and storage path
- Document owner - Document owner
- View and / or edit permissions to users or groups - View and / or edit permissions to users or groups
- Custom fields, optionally with a value. If no value is set, the field is only added to the - Custom fields. Note that no value for the field will be set
document and any value it may already have is left untouched. If a value is set, it will
overwrite an existing value of that field on the document.
##### Removal {#workflow-action-removal} ##### Removal {#workflow-action-removal}
@@ -626,34 +620,6 @@ no other workflow will be executed on the document.
If a "Move to Trash" action is executed in a consume pipeline, the consumption If a "Move to Trash" action is executed in a consume pipeline, the consumption
will be aborted and the file will be deleted. will be aborted and the file will be deleted.
##### Password Removal {#workflow-action-password-removal}
"Password Removal" actions attempt to remove password protection from encrypted PDF documents. You can specify:
- One or more passwords to try, separated by commas or new lines
- Each password is tried in order until one successfully unlocks the document
Password removal never modifies a file in place. Instead, once a working password is found, the
decrypted content is consumed as a new [document version](#document-file-versions), leaving the
original (still encrypted) version in the document's version history.
**Consumption Started**: because this trigger fires before the document exists yet, the password
removal itself is deferred until after the initial consumption of the encrypted file has completed.
OCR engines cannot process an encrypted PDF, so this first version is typically stored with no
extracted text (unless the file already contained extractable text outside of OCR). Immediately
afterwards, the password is removed and the decrypted file is automatically re-consumed as a second,
new version of the same document, this time with normal OCR/text extraction applied. In other words,
a password-protected file added with this trigger will briefly exist as an un-OCR'd version before
the properly processed version is created.
**Document Added**, **Document Updated**, **Scheduled**: these triggers run against a document that
already exists, so password removal happens immediately: the decrypted content is queued for
consumption as a new version right away. Note that if the document's initial consumption also
happened while it was still encrypted, that original version will likewise be missing OCR text.
**Current limitation**: Passwords are stored as a simple list without descriptions. To handle
multiple PDF types with different passwords, create separate workflows for each use case.
#### Workflow placeholders #### Workflow placeholders
Titles and webhook payloads can be generated by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/). Titles and webhook payloads can be generated by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
-2
View File
@@ -34,8 +34,6 @@ PAPERLESS_SECRET_KEY=change-me
#PAPERLESS_AUTO_LOGIN_USERNAME= #PAPERLESS_AUTO_LOGIN_USERNAME=
#PAPERLESS_COOKIE_PREFIX= #PAPERLESS_COOKIE_PREFIX=
#PAPERLESS_ENABLE_HTTP_REMOTE_USER=false #PAPERLESS_ENABLE_HTTP_REMOTE_USER=false
#PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP=
#PAPERLESS_SOCIAL_ACCOUNT_SYNC_STAFF_GROUP=
# OCR settings # OCR settings
+39 -32
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "paperless-ngx" name = "paperless-ngx"
version = "3.0.5" version = "3.0.2"
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents" description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
@@ -21,7 +21,7 @@ dependencies = [
"channels~=4.2", "channels~=4.2",
"channels-redis~=4.2", "channels-redis~=4.2",
"concurrent-log-handler~=0.9.25", "concurrent-log-handler~=0.9.25",
"dateparser~=1.4", "dateparser~=1.2",
# WARNING: django does not use semver. # WARNING: django does not use semver.
# Only patch versions are guaranteed to not introduce breaking changes. # Only patch versions are guaranteed to not introduce breaking changes.
"django~=5.2.13", "django~=5.2.13",
@@ -32,32 +32,33 @@ dependencies = [
"django-cors-headers~=4.9.0", "django-cors-headers~=4.9.0",
"django-extensions~=4.1", "django-extensions~=4.1",
"django-filter~=25.1", "django-filter~=25.1",
"django-guardian~=3.3.3", "django-guardian~=3.3.0",
"django-multiselectfield~=1.0.1", "django-multiselectfield~=1.0.1",
"django-rich~=2.2.0", "django-rich~=2.2.0",
"django-soft-delete~=1.0.18", "django-soft-delete~=1.0.18",
"django-treenode>=0.24", "django-treenode>=0.24",
"djangorestframework~=3.16", "djangorestframework~=3.16",
"drf-spectacular~=0.30", "djangorestframework-guardian~=0.4.0",
"drf-spectacular-sidecar~=2026.7.1", "drf-spectacular~=0.28",
"drf-spectacular-sidecar~=2026.5.1",
"drf-writable-nested~=0.7.1", "drf-writable-nested~=0.7.1",
"filelock~=3.32.0", "filelock~=3.29.0",
"flower~=2.0.1", "flower~=2.0.1",
"gotenberg-client~=0.14.0", "gotenberg-client~=0.14.0",
"httpx-oauth~=0.17", "httpx-oauth~=0.16",
"ijson>=3.5.1", "ijson>=3.2",
"imap-tools~=1.14.0", "imap-tools~=1.13.0",
"jinja2~=3.1.5", "jinja2~=3.1.5",
"langdetect~=1.0.9", "langdetect~=1.0.9",
"llama-index-core>=0.14.23", "llama-index-core>=0.14.22",
"llama-index-embeddings-huggingface>=0.6.1", "llama-index-embeddings-huggingface>=0.6.1",
"llama-index-embeddings-ollama>=0.9", "llama-index-embeddings-ollama>=0.9",
"llama-index-embeddings-openai-like>=0.2.2", "llama-index-embeddings-openai-like>=0.2.2",
"llama-index-llms-ollama>=0.9.1", "llama-index-llms-ollama>=0.9.1",
"llama-index-llms-openai-like>=0.7.1", "llama-index-llms-openai-like>=0.7.1",
"nltk~=3.10.0", "nltk~=3.10.0",
"ocrmypdf~=17.7.0", "ocrmypdf~=17.4.2",
"openai>=2.48", "openai>=2.32",
"pathvalidate~=3.3.1", "pathvalidate~=3.3.1",
"pdf2image~=1.17.0", "pdf2image~=1.17.0",
"python-dateutil~=2.9.0", "python-dateutil~=2.9.0",
@@ -67,26 +68,26 @@ dependencies = [
"python-magic~=0.4.27", "python-magic~=0.4.27",
"rapidfuzz~=3.14.5", "rapidfuzz~=3.14.5",
"redis[hiredis]~=5.2.1", "redis[hiredis]~=5.2.1",
"regex>=2026.7.19", "regex>=2026.4.4",
"scikit-learn~=1.9.0", "scikit-learn~=1.8.0",
"sentence-transformers>=5.6.1", "sentence-transformers>=5.4.1",
"setproctitle~=1.3.4", "setproctitle~=1.3.4",
"sqlite-vec==0.1.9", "sqlite-vec==0.1.9",
"tantivy~=0.26.0", "tantivy~=0.26.0",
"tika-client~=0.11.0", "tika-client~=0.11.0",
"torch~=2.13.0", "torch~=2.13.0",
"watchfiles>=1.2", "watchfiles>=1.1.1",
"whitenoise~=6.11", "whitenoise~=6.11",
"zxing-cpp~=3.1.0", "zxing-cpp~=3.0.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
mariadb = [ mariadb = [
"mysqlclient~=2.2.7", "mysqlclient~=2.2.7",
] ]
postgres = [ postgres = [
"psycopg[c,pool]==3.3.4", "psycopg[c,pool]==3.3",
# Direct dependency for proper resolution of the pre-built wheels # Direct dependency for proper resolution of the pre-built wheels
"psycopg-c==3.3.4", "psycopg-c==3.3",
"psycopg-pool==3.3.1", "psycopg-pool==3.3.1",
] ]
webserver = [ webserver = [
@@ -100,25 +101,25 @@ dev = [
{ include-group = "testing" }, { include-group = "testing" },
] ]
docs = [ docs = [
"zensical>=0.0.51", "zensical>=0.0.47",
] ]
lint = [ lint = [
"prek~=0.4.11", "prek~=0.3.10",
"ruff~=0.16.1", "ruff~=0.16.0",
] ]
testing = [ testing = [
"daphne", "daphne",
"factory-boy~=3.3.1", "factory-boy~=3.3.1",
"faker~=40.36.0", "faker~=40.15.0",
"imagehash", "imagehash",
"pytest~=9.1.1", "pytest~=9.0.3",
"pytest-cov~=7.1.0", "pytest-cov~=7.1.0",
"pytest-django~=4.12.0", "pytest-django~=4.12.0",
"pytest-env~=1.7.0", "pytest-env~=1.6.0",
"pytest-httpx", "pytest-httpx",
"pytest-mock~=3.15.1", "pytest-mock~=3.15.1",
# "pytest-randomly~=4.0.1", # "pytest-randomly~=4.0.1",
"pytest-rerunfailures~=16.4", "pytest-rerunfailures~=16.1",
"pytest-sugar", "pytest-sugar",
"pytest-xdist~=3.8.0", "pytest-xdist~=3.8.0",
"time-machine>=2.13", "time-machine>=2.13",
@@ -160,10 +161,8 @@ explicit = true
[tool.uv.sources] [tool.uv.sources]
# Markers are chosen to select these almost exclusively when building the Docker image # Markers are chosen to select these almost exclusively when building the Docker image
psycopg-c = [ psycopg-c = [
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.4/psycopg_c-3.3.4-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" }, { url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.0/psycopg_c-3.3.0-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.4/psycopg_c-3.3.4-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" }, { url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.0/psycopg_c-3.3.0-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.4/psycopg_c-3.3.4-cp314-cp314-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.14'" },
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.4/psycopg_c-3.3.4-cp314-cp314-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.14'" },
] ]
torch = [ torch = [
{ index = "pytorch-cpu" }, { index = "pytorch-cpu" },
@@ -184,14 +183,17 @@ output-format = "grouped"
line-ending = "lf" line-ending = "lf"
[tool.ruff.lint] [tool.ruff.lint]
# https://docs.astral.sh/ruff/rules/ # https://docs.astral.sh/ruff/rules/
select = [ "E4", "E7", "E9", "F" ]
extend-select = [ extend-select = [
"B", # https://docs.astral.sh/ruff/rules/#flake8-bugbear-b
"COM", # https://docs.astral.sh/ruff/rules/#flake8-commas-com "COM", # https://docs.astral.sh/ruff/rules/#flake8-commas-com
"DTZ", # https://docs.astral.sh/ruff/rules/#flake8-datetimez-dtz
"PERF", # https://docs.astral.sh/ruff/rules/#perflint-perf
"S324", # https://docs.astral.sh/ruff/rules/hashlib-insecure-hash-functions/
"DJ", # https://docs.astral.sh/ruff/rules/#flake8-django-dj "DJ", # https://docs.astral.sh/ruff/rules/#flake8-django-dj
"EXE", # https://docs.astral.sh/ruff/rules/#flake8-executable-exe "EXE", # https://docs.astral.sh/ruff/rules/#flake8-executable-exe
"FBT", # https://docs.astral.sh/ruff/rules/#flake8-boolean-trap-fbt "FBT", # https://docs.astral.sh/ruff/rules/#flake8-boolean-trap-fbt
"FLY", # https://docs.astral.sh/ruff/rules/#flynt-fly "FLY", # https://docs.astral.sh/ruff/rules/#flynt-fly
"G201", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g "G", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g
"I", # https://docs.astral.sh/ruff/rules/#isort-i "I", # https://docs.astral.sh/ruff/rules/#isort-i
"ICN", # https://docs.astral.sh/ruff/rules/#flake8-import-conventions-icn "ICN", # https://docs.astral.sh/ruff/rules/#flake8-import-conventions-icn
"INP", # https://docs.astral.sh/ruff/rules/#flake8-no-pep420-inp "INP", # https://docs.astral.sh/ruff/rules/#flake8-no-pep420-inp
@@ -211,10 +213,15 @@ extend-select = [
"W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w "W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
] ]
ignore = [ ignore = [
"BLE001", # blind except: too invasive to fix wholesale on ruff 0.16's default bump; revisit incrementally
"DJ001", "DJ001",
"G004", # f-strings in logging: accepted style in this codebase
"PLC0415", "PLC0415",
"RUF012", "RUF012",
"SIM105", "SIM105",
"TRY002", # create-your-own-exception: too invasive to fix wholesale on ruff 0.16's default bump; revisit incrementally
"TRY201", # verbose-raise: too invasive to fix wholesale on ruff 0.16's default bump; revisit incrementally
"TRY401", # redundant exception in logging.exception: too invasive to fix wholesale on ruff 0.16's default bump; revisit incrementally
] ]
# Migrations # Migrations
per-file-ignores."*/migrations/*.py" = [ per-file-ignores."*/migrations/*.py" = [
+9 -12
View File
@@ -56,13 +56,13 @@
}, },
"architect": { "architect": {
"build": { "build": {
"builder": "@angular/build:application", "builder": "@angular-builders/custom-webpack:browser",
"options": { "options": {
"outputPath": { "customWebpackConfig": {
"base": "dist/paperless-ui", "path": "./extra-webpack.config.ts"
"browser": ""
}, },
"browser": "src/main.ts", "outputPath": "dist/paperless-ui",
"main": "src/main.ts",
"outputHashing": "none", "outputHashing": "none",
"index": "src/index.html", "index": "src/index.html",
"polyfills": [ "polyfills": [
@@ -97,7 +97,6 @@
"scripts": [], "scripts": [],
"allowedCommonJsDependencies": [ "allowedCommonJsDependencies": [
"file-saver", "file-saver",
"mime-names",
"utif" "utif"
], ],
"extractLicenses": false, "extractLicenses": false,
@@ -118,13 +117,11 @@
"with": "src/environments/environment.prod.ts" "with": "src/environments/environment.prod.ts"
} }
], ],
"outputPath": { "outputPath": "../src/documents/static/frontend/",
"base": "../src/documents/static/frontend/",
"browser": ""
},
"optimization": true, "optimization": true,
"outputHashing": "none", "outputHashing": "none",
"sourceMap": false, "sourceMap": false,
"namedChunks": false,
"extractLicenses": true, "extractLicenses": true,
"budgets": [ "budgets": [
{ {
@@ -148,7 +145,7 @@
"defaultConfiguration": "" "defaultConfiguration": ""
}, },
"serve": { "serve": {
"builder": "@angular/build:dev-server", "builder": "@angular-builders/custom-webpack:dev-server",
"options": { "options": {
"buildTarget": "paperless-ui:build:en-US" "buildTarget": "paperless-ui:build:en-US"
}, },
@@ -159,7 +156,7 @@
} }
}, },
"extract-i18n": { "extract-i18n": {
"builder": "@angular/build:extract-i18n", "builder": "@angular-builders/custom-webpack:extract-i18n",
"options": { "options": {
"buildTarget": "paperless-ui:build" "buildTarget": "paperless-ui:build"
} }
@@ -1,4 +1,4 @@
import { expect, test, type WebSocketRoute } from '@playwright/test' import { expect, test } from '@playwright/test'
import path from 'node:path' import path from 'node:path'
const REQUESTS_HAR = path.join(__dirname, 'requests/api-document-detail.har') const REQUESTS_HAR = path.join(__dirname, 'requests/api-document-detail.har')
@@ -95,60 +95,3 @@ test('should support quick filters', async ({ page }) => {
.click() .click()
await expect(page).toHaveURL(/tags__id__all=4&sort=created&reverse=1&page=1/) await expect(page).toHaveURL(/tags__id__all=4&sort=created&reverse=1&page=1/)
}) })
test('should finish reloading the preview after a remote document update', async ({
page,
}) => {
let resolveStatusSocket: (socket: WebSocketRoute) => void
const statusSocketReady = new Promise<WebSocketRoute>((resolve) => {
resolveStatusSocket = resolve
})
await page.routeWebSocket(/\/ws\/status\/$/, (socket) => {
resolveStatusSocket(socket)
})
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
let previewRequestCount = 0
page.on('request', (request) => {
if (request.url().includes('/api/documents/175/preview/')) {
previewRequestCount++
}
})
await page.goto('/documents/175/details')
await page.locator('pngx-document-detail').waitFor()
await expect(page.getByTitle('Storage path', { exact: true })).toHaveText(
/\w+/
)
const previewWasLoaded = await page.evaluate(() => {
const detail = document.querySelector('pngx-document-detail')
const component = (window as any).ng.getComponent(detail)
component.pdfPreviewLoaded({ numPages: 1 })
return component.previewLoaded()
})
expect(previewWasLoaded).toBe(true)
const previewRequestsBeforeReload = previewRequestCount
const statusSocket = await statusSocketReady
const documentReloaded = page.waitForResponse(
(response) =>
response.url().includes('/api/documents/175/?full_perms=true') &&
response.request().method() === 'GET'
)
statusSocket.send(
JSON.stringify({
type: 'document_updated',
data: {
document_id: 175,
modified: '2026-07-26T20:00:00Z',
},
})
)
await documentReloaded
await expect(
page.getByText('Document reloaded with latest changes.').first()
).toBeVisible()
await expect
.poll(() => previewRequestCount)
.toBeGreaterThan(previewRequestsBeforeReload + 1)
})
+24
View File
@@ -0,0 +1,24 @@
import {
CustomWebpackBrowserSchema,
TargetOptions,
} from '@angular-builders/custom-webpack'
import * as webpack from 'webpack'
const { codecovWebpackPlugin } = require('@codecov/webpack-plugin')
export default (
config: webpack.Configuration,
options: CustomWebpackBrowserSchema,
targetOptions: TargetOptions
) => {
if (config.plugins) {
config.plugins.push(
codecovWebpackPlugin({
enableBundleAnalysis: process.env.CODECOV_TOKEN !== undefined,
bundleName: 'paperless-ngx',
uploadToken: process.env.CODECOV_TOKEN,
})
)
}
return config
}
+959 -1528
View File
File diff suppressed because it is too large Load Diff
+33 -32
View File
@@ -1,6 +1,6 @@
{ {
"name": "paperless-ngx-ui", "name": "paperless-ngx-ui",
"version": "3.0.5", "version": "3.0.2",
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm", "preinstall": "npx only-allow pnpm",
"ng": "ng", "ng": "ng",
@@ -11,21 +11,20 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/cdk": "^22.1.1", "@angular/cdk": "^22.0.3",
"@angular/common": "~22.1.1", "@angular/common": "~22.0.5",
"@angular/compiler": "~22.1.1", "@angular/compiler": "~22.0.5",
"@angular/core": "~22.1.1", "@angular/core": "~22.0.5",
"@angular/forms": "~22.1.1", "@angular/forms": "~22.0.5",
"@angular/localize": "~22.1.1", "@angular/localize": "~22.0.5",
"@angular/platform-browser": "~22.1.1", "@angular/platform-browser": "~22.0.5",
"@angular/router": "~22.1.1", "@angular/router": "~22.0.5",
"@ng-bootstrap/ng-bootstrap": "^21.0.0", "@ng-bootstrap/ng-bootstrap": "^21.0.0",
"@ng-select/ng-select": "~23.6.0", "@ng-select/ng-select": "^23.2.0",
"@ngneat/dirty-check-forms": "^3.0.3", "@ngneat/dirty-check-forms": "^3.0.3",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"lodash-es": "^4.18.1",
"mime-names": "^1.0.0", "mime-names": "^1.0.0",
"ngx-bootstrap-icons": "^1.9.3", "ngx-bootstrap-icons": "^1.9.3",
"ngx-color": "^10.1.0", "ngx-color": "^10.1.0",
@@ -33,40 +32,42 @@
"ngx-device-detector": "^12.0.0", "ngx-device-detector": "^12.0.0",
"ngx-ui-tour-ng-bootstrap": "^19.0.0", "ngx-ui-tour-ng-bootstrap": "^19.0.0",
"normalize-diacritics": "^5.0.0", "normalize-diacritics": "^5.0.0",
"pdfjs-dist": "^6.2.108", "pdfjs-dist": "^6.0.227",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"utif": "^3.1.0", "utif": "^3.1.0",
"uuid": "^14.0.1" "uuid": "^14.0.1"
}, },
"devDependencies": { "devDependencies": {
"@angular-builders/custom-webpack": "^22.0.1",
"@angular-builders/jest": "^22.0.1", "@angular-builders/jest": "^22.0.1",
"@angular-devkit/core": "^22.1.3", "@angular-devkit/core": "^22.0.5",
"@angular-devkit/schematics": "^22.1.3", "@angular-devkit/schematics": "^22.0.5",
"@angular-eslint/builder": "22.1.0", "@angular-eslint/builder": "22.0.0",
"@angular-eslint/eslint-plugin": "22.1.0", "@angular-eslint/eslint-plugin": "22.0.0",
"@angular-eslint/eslint-plugin-template": "22.1.0", "@angular-eslint/eslint-plugin-template": "22.0.0",
"@angular-eslint/schematics": "22.1.0", "@angular-eslint/schematics": "22.0.0",
"@angular-eslint/template-parser": "22.1.0", "@angular-eslint/template-parser": "22.0.0",
"@angular/build": "22.1.3", "@angular/build": "^22.0.5",
"@angular/cli": "22.1.3", "@angular/cli": "~22.0.5",
"@angular/compiler-cli": "~22.1.1", "@angular/compiler-cli": "~22.0.5",
"@playwright/test": "^1.62.1", "@codecov/webpack-plugin": "^2.0.1",
"@playwright/test": "^1.61.1",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^26.2.0", "@types/node": "^26.0.0",
"@typescript-eslint/eslint-plugin": "^8.67.0", "@typescript-eslint/eslint-plugin": "^8.62.0",
"@typescript-eslint/parser": "^8.67.0", "@typescript-eslint/parser": "^8.62.0",
"@typescript-eslint/utils": "^8.67.0", "@typescript-eslint/utils": "^8.62.0",
"eslint": "^10.8.1", "eslint": "^10.5.0",
"jest": "30.4.2", "jest": "30.4.2",
"jest-environment-jsdom": "^30.4.1", "jest-environment-jsdom": "^30.4.1",
"jest-junit": "^17.0.0", "jest-junit": "^17.0.0",
"jest-preset-angular": "^17.0.0", "jest-preset-angular": "^17.0.0",
"jest-websocket-mock": "^2.5.0", "jest-websocket-mock": "^2.5.0",
"prettier": "^3.9.6",
"prettier-plugin-organize-imports": "^4.3.0", "prettier-plugin-organize-imports": "^4.3.0",
"ts-node": "~10.9.2", "ts-node": "~10.9.1",
"typescript": "^6.0.3" "typescript": "^6.0.3",
"webpack": "^5.107.2"
}, },
"packageManager": "pnpm@11.15.1" "packageManager": "pnpm@10.26.0"
} }
+6705 -2043
View File
File diff suppressed because it is too large Load Diff
-5
View File
@@ -2,11 +2,6 @@ packages:
- "." - "."
minimumReleaseAge: 10080 minimumReleaseAge: 10080
trustPolicy: no-downgrade trustPolicy: no-downgrade
trustPolicyExclude:
- "chokidar@4.0.3"
- "semver@6.3.1 || 5.7.2"
blockExoticSubdeps: true
autoInstallPeers: false
allowBuilds: allowBuilds:
"@parcel/watcher": true "@parcel/watcher": true
canvas: true canvas: true
@@ -21,7 +21,7 @@
</div> </div>
</pngx-page-header> </pngx-page-header>
@if (loading() && pagedTasks().length === 0) { @if (!tasksService.completedFileTasks && tasksService.loading) {
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div> <div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div> <div class="visually-hidden" i18n>Loading...</div>
} }
@@ -576,7 +576,7 @@ describe('TasksComponent', () => {
expect(dismissSpy).toHaveBeenCalledWith(new Set([tasks[0].id, tasks[1].id])) expect(dismissSpy).toHaveBeenCalledWith(new Set([tasks[0].id, tasks[1].id]))
expect(toastSpy).toHaveBeenCalledWith('Error dismissing tasks', error) expect(toastSpy).toHaveBeenCalledWith('Error dismissing tasks', error)
expect(modal.componentInstance.buttonsEnabled()).toBe(true) expect(modal.componentInstance.buttonsEnabled).toBe(true)
expect(component.selectedTasks.size).toBe(0) expect(component.selectedTasks.size).toBe(0)
}) })
@@ -642,7 +642,7 @@ describe('TasksComponent', () => {
expect(dismissSpy).toHaveBeenCalled() expect(dismissSpy).toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalledWith('Error dismissing tasks', error) expect(toastSpy).toHaveBeenCalledWith('Error dismissing tasks', error)
expect(modal.componentInstance.buttonsEnabled()).toBe(true) expect(modal.componentInstance.buttonsEnabled).toBe(true)
}) })
it('should dismiss the currently visible scoped and filtered tasks', () => { it('should dismiss the currently visible scoped and filtered tasks', () => {
@@ -316,7 +316,7 @@ export class TasksComponent
modal.componentInstance.btnClass = 'btn-warning' modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Dismiss` modal.componentInstance.btnCaption = $localize`Dismiss`
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => { modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled = false
modal.close() modal.close()
this.tasksService.dismissTasks(tasks).subscribe({ this.tasksService.dismissTasks(tasks).subscribe({
next: () => { next: () => {
@@ -324,7 +324,7 @@ export class TasksComponent
}, },
error: (e) => { error: (e) => {
this.toastService.showError($localize`Error dismissing tasks`, e) this.toastService.showError($localize`Error dismissing tasks`, e)
modal.componentInstance.buttonsEnabled.set(true) modal.componentInstance.buttonsEnabled = true
}, },
}) })
this.clearSelection() this.clearSelection()
@@ -350,7 +350,7 @@ export class TasksComponent
modal.componentInstance.btnClass = 'btn-warning' modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Dismiss` modal.componentInstance.btnCaption = $localize`Dismiss`
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => { modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled = false
modal.close() modal.close()
this.tasksService.dismissAllTasks().subscribe({ this.tasksService.dismissAllTasks().subscribe({
next: () => { next: () => {
@@ -358,7 +358,7 @@ export class TasksComponent
}, },
error: (e) => { error: (e) => {
this.toastService.showError($localize`Error dismissing tasks`, e) this.toastService.showError($localize`Error dismissing tasks`, e)
modal.componentInstance.buttonsEnabled.set(true) modal.componentInstance.buttonsEnabled = true
}, },
}) })
this.clearSelection() this.clearSelection()
@@ -2,8 +2,3 @@
.d-block.d-sm-none .dropdown-toggle::after { .d-block.d-sm-none .dropdown-toggle::after {
display: none; display: none;
} }
tbody tr:last-child td,
table:not(:has(tbody tr)) thead th {
border-bottom: none;
}
@@ -82,7 +82,7 @@ export class TrashComponent
modal.componentInstance.confirmClicked modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => { .subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled = false
this.trashService.emptyTrash([document.id]).subscribe({ this.trashService.emptyTrash([document.id]).subscribe({
next: () => { next: () => {
this.toastService.showInfo( this.toastService.showInfo(
@@ -146,7 +146,7 @@ export class UsersAndGroupsComponent
modal.componentInstance.btnClass = 'btn-danger' modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed` modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => { modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled = false
this.usersService.delete(user).subscribe({ this.usersService.delete(user).subscribe({
next: () => { next: () => {
modal.close() modal.close()
@@ -199,7 +199,7 @@ export class UsersAndGroupsComponent
modal.componentInstance.btnClass = 'btn-danger' modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed` modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => { modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled = false
this.groupsService.delete(group).subscribe({ this.groupsService.delete(group).subscribe({
next: () => { next: () => {
modal.close() modal.close()
@@ -104,14 +104,14 @@
</h6> </h6>
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)"> <ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
@for (view of savedViewService.sidebarViews; track view.id) { @for (view of savedViewService.sidebarViews; track view.id) {
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews() || !canSaveSettings" <li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews()"
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)" cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
(cdkDragEnded)="onDragEnd($event)"> (cdkDragEnded)="onDragEnd($event)">
<a class="nav-link" routerLink="view/{{view.id}}" <a class="nav-link" routerLink="view/{{view.id}}"
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name" routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name"
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
popoverClass="popover-slim"> popoverClass="popover-slim">
<i-bs class="me-2" [name]="view.icon || 'funnel'"></i-bs><span><div class="d-inline-flex view-name"><span class="overflow-hidden" [class.text-wrap]="!slimSidebarEnabled">{{view.name}}</span></div> <i-bs class="me-2" name="funnel"></i-bs><span><div class="d-inline-flex view-name"><span class="overflow-hidden" [class.text-wrap]="!slimSidebarEnabled">{{view.name}}</span></div>
@if (showSidebarCounts && !slimSidebarEnabled) { @if (showSidebarCounts && !slimSidebarEnabled) {
<span class="badge bg-info text-dark ms-2 d-inline">{{ savedViewService.getDocumentCount(view) }}</span> <span class="badge bg-info text-dark ms-2 d-inline">{{ savedViewService.getDocumentCount(view) }}</span>
} }
@@ -120,7 +120,7 @@
<span class="badge bg-info text-dark position-absolute top-0 end-0 d-none d-md-block">{{ savedViewService.getDocumentCount(view) }}</span> <span class="badge bg-info text-dark position-absolute top-0 end-0 d-none d-md-block">{{ savedViewService.getDocumentCount(view) }}</span>
} }
</a> </a>
@if (settingsService.organizingSidebarSavedViews() && canSaveSettings) { @if (settingsService.organizingSidebarSavedViews()) {
<div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle> <div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>
<i-bs name="grip-vertical"></i-bs> <i-bs name="grip-vertical"></i-bs>
</div> </div>
@@ -182,7 +182,7 @@
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="stack"></i-bs><span><ng-container i18n>Attributes</ng-container></span> <i-bs class="me-2" name="stack"></i-bs><span><ng-container i18n>Attributes</ng-container></span>
</a> </a>
@if (!slimSidebarEnabled && canSaveSettings) { @if (!slimSidebarEnabled) {
<button <button
type="button" type="button"
class="btn btn-link btn-sm text-muted p-0 me-3 attributes-expand-btn" class="btn btn-link btn-sm text-muted p-0 me-3 attributes-expand-btn"
@@ -195,32 +195,32 @@
} }
</div> </div>
<div <div
class="attributes-submenu ms-3" class="attributes-submenu ms-2"
[ngbCollapse]="slimSidebarEnabled || attributesSectionsCollapsed" [ngbCollapse]="slimSidebarEnabled || attributesSectionsCollapsed"
> >
<ul class="nav flex-column"> <ul class="nav flex-column">
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }">
<a class="nav-link" routerLink="attributes/tags" routerLinkActive="active" (click)="closeMenu()"> <a class="nav-link py-1" routerLink="attributes/tags" routerLinkActive="active" (click)="closeMenu()">
<i-bs class="me-2" name="tags"></i-bs><span><ng-container i18n>Tags</ng-container></span> <i-bs class="me-2" name="tags"></i-bs><span><ng-container i18n>Tags</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
<a class="nav-link" routerLink="attributes/correspondents" routerLinkActive="active" (click)="closeMenu()"> <a class="nav-link py-1" routerLink="attributes/correspondents" routerLinkActive="active" (click)="closeMenu()">
<i-bs class="me-2" name="person"></i-bs><span><ng-container i18n>Correspondents</ng-container></span> <i-bs class="me-2" name="person"></i-bs><span><ng-container i18n>Correspondents</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
<a class="nav-link" routerLink="attributes/documenttypes" routerLinkActive="active" (click)="closeMenu()"> <a class="nav-link py-1" routerLink="attributes/documenttypes" routerLinkActive="active" (click)="closeMenu()">
<i-bs class="me-2" name="hash"></i-bs><span><ng-container i18n>Document types</ng-container></span> <i-bs class="me-2" name="hash"></i-bs><span><ng-container i18n>Document types</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
<a class="nav-link" routerLink="attributes/storagepaths" routerLinkActive="active" (click)="closeMenu()"> <a class="nav-link py-1" routerLink="attributes/storagepaths" routerLinkActive="active" (click)="closeMenu()">
<i-bs class="me-2" name="folder"></i-bs><span><ng-container i18n>Storage paths</ng-container></span> <i-bs class="me-2" name="folder"></i-bs><span><ng-container i18n>Storage paths</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
<a class="nav-link" routerLink="attributes/customfields" routerLinkActive="active" (click)="closeMenu()"> <a class="nav-link py-1" routerLink="attributes/customfields" routerLinkActive="active" (click)="closeMenu()">
<i-bs class="me-2" name="ui-radios"></i-bs><span><ng-container i18n>Custom fields</ng-container></span> <i-bs class="me-2" name="ui-radios"></i-bs><span><ng-container i18n>Custom fields</ng-container></span>
</a> </a>
</li> </li>
@@ -19,11 +19,6 @@
height: 0.8em; height: 0.8em;
} }
.sidebar-heading,
.text-uppercase {
letter-spacing: 0.06em;
}
.view-name { .view-name {
max-width: calc(100% - 50px) max-width: calc(100% - 50px)
} }
@@ -52,12 +47,11 @@
.search-container { .search-container {
max-height: 4.5rem; max-height: 4.5rem;
overflow: visible; overflow: hidden;
transition: max-height .2s ease, opacity .2s ease, padding-top .2s ease, padding-bottom .2s ease; transition: max-height .2s ease, opacity .2s ease, padding-top .2s ease, padding-bottom .2s ease;
&.mobile-hidden { &.mobile-hidden {
max-height: 0; max-height: 0;
overflow: hidden;
opacity: 0; opacity: 0;
padding-top: 0 !important; padding-top: 0 !important;
padding-bottom: 0 !important; padding-bottom: 0 !important;
@@ -86,7 +80,7 @@ main {
.sidebar li.nav-item span, .sidebar li.nav-item span,
.sidebar .sidebar-heading span { .sidebar .sidebar-heading span {
transition: opacity .1s ease; transition: all .1s ease;
} }
@media(min-width: 768px) { @media(min-width: 768px) {
@@ -154,7 +148,7 @@ main {
z-index: 996; z-index: 996;
--bs-btn-padding-x: 0.35rem; --bs-btn-padding-x: 0.35rem;
--bs-btn-padding-y: 0.125rem; --bs-btn-padding-y: 0.125rem;
transition: left .2s ease; transition: all .2s ease;
} }
.sidebar.slim .sidebar-slim-toggler { .sidebar.slim .sidebar-slim-toggler {
@@ -186,8 +180,6 @@ main {
.sidebar .nav-link { .sidebar .nav-link {
font-weight: 500; font-weight: 500;
white-space: nowrap; white-space: nowrap;
border-left: 2px solid transparent;
transition: color .15s ease-in-out;
&:hover, &.active, &:focus { &:hover, &.active, &:focus {
color: var(--bs-primary); color: var(--bs-primary);
@@ -200,7 +192,6 @@ main {
&.active { &.active {
font-weight: bold; font-weight: bold;
border-left-color: var(--bs-primary);
} }
i-bs { i-bs {
@@ -209,17 +200,6 @@ main {
} }
} }
// sub-page gets marker only
.nav-item:has(.attributes-submenu.show .nav-link.active) > .attributes-row > .nav-link.active {
border-left-color: transparent;
}
// bring sub-menu markers back out to L edge
.attributes-submenu .nav-link {
margin-left: -1rem;
padding-left: calc(var(--bs-nav-link-padding-x) + 1rem);
}
.attributes-row .attributes-expand-btn { .attributes-row .attributes-expand-btn {
opacity: 0.2; opacity: 0.2;
transition: opacity 0.15s ease-in-out; transition: opacity 0.15s ease-in-out;
@@ -300,52 +280,10 @@ main {
} }
} }
@media screen and (max-width: 575.98px) { @media screen and (min-width: 376px) and (max-width: 768px) {
.navbar {
display: grid;
grid-template-columns: 1fr auto 1fr;
grid-template-areas:
"toggler brand actions"
"search search search";
}
.navbar-toggler { .navbar-toggler {
grid-area: toggler; // compensate for 2 buttons on the right
text-align: left; margin-right: 45px;
}
.navbar-brand {
grid-area: brand;
min-width: 0;
max-width: 100%;
justify-self: center;
overflow: hidden;
> div,
.custom-title {
min-width: 0;
max-width: 100%;
overflow: hidden;
}
.title,
.byline {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.search-container {
grid-area: search;
width: 100%;
}
.navbar > ul {
grid-area: actions;
justify-self: end;
flex-wrap: nowrap;
} }
} }
@@ -355,6 +293,13 @@ main {
} }
} }
@media screen and (max-width: 345px) {
.custom-title {
max-width: 110px;
overflow: hidden;
}
}
:host ::ng-deep .dropdown.show .dropdown-toggle, :host ::ng-deep .dropdown.show .dropdown-toggle,
:host ::ng-deep .dropdown-toggle:hover { :host ::ng-deep .dropdown-toggle:hover {
opacity: 0.7; opacity: 0.7;
@@ -12,10 +12,10 @@
} }
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()"> <button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span> <span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button> </button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled()"> <button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
<span> <span>
{{btnCaption}} {{btnCaption}}
<span class="visually-hidden">{{ seconds | number: '1.0-0' }} seconds</span> <span class="visually-hidden">{{ seconds | number: '1.0-0' }} seconds</span>
@@ -25,7 +25,7 @@
} }
</button> </button>
@if (alternativeBtnCaption) { @if (alternativeBtnCaption) {
<button type="button" class="btn" [class]="alternativeBtnClass" (click)="alternative()" [disabled]="!alternativeButtonEnabled || !buttonsEnabled()"> <button type="button" class="btn" [class]="alternativeBtnClass" (click)="alternative()" [disabled]="!alternativeButtonEnabled || !buttonsEnabled">
{{alternativeBtnCaption}} {{alternativeBtnCaption}}
</button> </button>
} }
@@ -64,22 +64,6 @@ describe('ConfirmDialogComponent', () => {
expect(confirmSubjectResult).toBeTruthy() expect(confirmSubjectResult).toBeTruthy()
}) })
it('should re-render the buttons when they are toggled from outside', async () => {
const confirmButton: HTMLButtonElement =
fixture.nativeElement.querySelectorAll('.modal-footer button')[1]
expect(confirmButton.disabled).toBeFalsy()
// Deliberately no detectChanges: a request callback toggling this is all
// that happens, and nothing else schedules a render for the modal
component.buttonsEnabled.set(false)
await fixture.whenStable()
expect(confirmButton.disabled).toBeTruthy()
component.buttonsEnabled.set(true)
await fixture.whenStable()
expect(confirmButton.disabled).toBeFalsy()
})
it('should support cancel & close modal', () => { it('should support cancel & close modal', () => {
let confirmSubjectResult let confirmSubjectResult
const closeModalSpy = jest.spyOn(modal, 'close') const closeModalSpy = jest.spyOn(modal, 'close')
@@ -1,12 +1,5 @@
import { DecimalPipe } from '@angular/common' import { DecimalPipe } from '@angular/common'
import { import { Component, EventEmitter, Input, Output, inject } from '@angular/core'
Component,
EventEmitter,
Input,
Output,
inject,
signal,
} from '@angular/core'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { Subject } from 'rxjs' import { Subject } from 'rxjs'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
@@ -53,7 +46,8 @@ export class ConfirmDialogComponent extends LoadingComponentWithPermissions {
@Input() @Input()
cancelBtnCaption = $localize`Cancel` cancelBtnCaption = $localize`Cancel`
readonly buttonsEnabled = signal(true) @Input()
buttonsEnabled = true
confirmButtonEnabled = true confirmButtonEnabled = true
alternativeButtonEnabled = true alternativeButtonEnabled = true
@@ -1,58 +0,0 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button>
</div>
<div class="modal-body">
<p>{{message}}</p>
<div class="form-group">
<label class="form-label" for="rootDocumentID" i18n>Root document:</label>
<select id="rootDocumentID" class="form-select" [ngModel]="rootDocumentID()" (ngModelChange)="rootDocumentID.set($event)">
@for (document of documents(); track document.id) {
<option [ngValue]="document.id">{{document.title}}</option>
}
</select>
</div>
<div class="form-group mt-4">
<span class="form-label d-inline-block" i18n>Versions (oldest first):</span>
<ul class="list-group"
cdkDropList
[cdkDropListData]="versionDocumentIDs()"
(cdkDropListDropped)="onDrop($event)">
@for (documentID of versionDocumentIDs(); track documentID) {
@let document = getDocument(documentID);
@if (document) {
<li class="list-group-item d-flex align-items-center" cdkDrag>
<i-bs name="grip-vertical" class="me-2"></i-bs>
<div class="d-flex flex-column">
<div>
@if (document.correspondent) {
<b>{{document.correspondent | correspondentName | async}}: </b>
}{{document.title}}
</div>
<small class="text-muted">
{{document.created | customDate:'mediumDate'}}
@if (document.page_count) {
| {document.page_count, plural, =1 {One page} other {{{document.page_count}} pages}}
}
</small>
</div>
@if ($last) {
<span class="badge bg-primary ms-auto" i18n>Current version</span>
}
</li>
}
}
</ul>
@if (versionDocumentIDs().length > 1) {
<div class="form-text" i18n>Drag to reorder.</div>
}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()">
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled()">
{{btnCaption}}
</button>
</div>
@@ -1,70 +0,0 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of } from 'rxjs'
import { DocumentService } from 'src/app/services/rest/document.service'
import { MergeAsVersionsConfirmDialogComponent } from './merge-as-versions-confirm-dialog.component'
describe('MergeAsVersionsConfirmDialogComponent', () => {
let component: MergeAsVersionsConfirmDialogComponent
let fixture: ComponentFixture<MergeAsVersionsConfirmDialogComponent>
let documentService: DocumentService
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
NgxBootstrapIconsModule.pick(allIcons),
MergeAsVersionsConfirmDialogComponent,
],
providers: [
NgbActiveModal,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
}).compileComponents()
fixture = TestBed.createComponent(MergeAsVersionsConfirmDialogComponent)
documentService = TestBed.inject(DocumentService)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should fetch selected documents', () => {
const documents = [
{ id: 1, title: 'Document 1' },
{ id: 2, title: 'Document 2' },
]
jest.spyOn(documentService, 'getFew').mockReturnValue(
of({
all: [1, 2],
count: 2,
results: documents,
})
)
component.documentIDs.set([1, 2])
component.ngOnInit()
expect(component.documents()).toEqual(documents)
expect(documentService.getFew).toHaveBeenCalledWith([1, 2])
})
it('should exclude the root from the draggable documents', () => {
component.documentIDs.set([1, 2, 3])
component.rootDocumentID.set(2)
expect(component.versionDocumentIDs()).toEqual([1, 3])
})
it('should move draggable documents while keeping the root fixed', () => {
component.documentIDs.set([1, 2, 3])
component.rootDocumentID.set(1)
component.onDrop({ previousIndex: 1, currentIndex: 0 } as any)
expect(component.documentIDs()).toEqual([1, 3, 2])
expect(component.versionDocumentIDs()).toEqual([3, 2])
})
})
@@ -1,70 +0,0 @@
import {
CdkDragDrop,
DragDropModule,
moveItemInArray,
} from '@angular/cdk/drag-drop'
import { AsyncPipe } from '@angular/common'
import { Component, OnInit, computed, inject, signal } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { takeUntil } from 'rxjs'
import { Document } from 'src/app/data/document'
import { CorrespondentNamePipe } from 'src/app/pipes/correspondent-name.pipe'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DocumentService } from 'src/app/services/rest/document.service'
import { ConfirmDialogComponent } from '../confirm-dialog.component'
@Component({
selector: 'pngx-merge-as-versions-confirm-dialog',
templateUrl: './merge-as-versions-confirm-dialog.component.html',
styleUrl: './merge-as-versions-confirm-dialog.component.scss',
imports: [
AsyncPipe,
CorrespondentNamePipe,
CustomDatePipe,
DragDropModule,
FormsModule,
NgxBootstrapIconsModule,
],
})
export class MergeAsVersionsConfirmDialogComponent
extends ConfirmDialogComponent
implements OnInit
{
private readonly documentService = inject(DocumentService)
readonly documentIDs = signal<number[]>([])
readonly documents = signal<Document[]>([])
readonly rootDocumentID = signal(-1)
readonly versionDocumentIDs = computed(() =>
this.documentIDs().filter(
(documentID) => documentID !== this.rootDocumentID()
)
)
ngOnInit() {
this.documentService
.getFew(this.documentIDs())
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((response) => this.documents.set(response.results))
}
onDrop(event: CdkDragDrop<number[]>) {
const versionDocumentIDs = this.versionDocumentIDs().concat()
moveItemInArray(versionDocumentIDs, event.previousIndex, event.currentIndex)
// The root keeps its place in the list, only the versions move around it
let versionIndex = 0
this.documentIDs.update((documentIDs) =>
documentIDs.map((documentID) =>
documentID === this.rootDocumentID()
? documentID
: versionDocumentIDs[versionIndex++]
)
)
}
getDocument(documentID: number): Document | undefined {
return this.documents().find((document) => document.id === documentID)
}
}
@@ -36,7 +36,7 @@
</div> </div>
<div class="form-group mt-4"> <div class="form-group mt-4">
<label class="form-label" for="metadataDocumentID" i18n>Use metadata from:</label> <label class="form-label" for="metadataDocumentID" i18n>Use metadata from:</label>
<select id="metadataDocumentID" class="form-select" [ngModel]="metadataDocumentID()" (ngModelChange)="metadataDocumentID.set($event)"> <select class="form-select" [ngModel]="metadataDocumentID()" (ngModelChange)="metadataDocumentID.set($event)">
<option [ngValue]="-1" i18n>Regenerate all metadata</option> <option [ngValue]="-1" i18n>Regenerate all metadata</option>
@for (document of documents(); track document.id) { @for (document of documents(); track document.id) {
<option [ngValue]="document.id">{{document.title}}</option> <option [ngValue]="document.id">{{document.title}}</option>
@@ -56,10 +56,10 @@
} }
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()"> <button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span> <span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button> </button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled()"> <button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
{{btnCaption}} {{btnCaption}}
</button> </button>
</div> </div>
@@ -57,7 +57,7 @@
class="btn" class="btn"
[class]="cancelBtnClass" [class]="cancelBtnClass"
(click)="cancel()" (click)="cancel()"
[disabled]="!buttonsEnabled()" [disabled]="!buttonsEnabled"
> >
<span class="d-inline-block" style="padding-bottom: 1px;"> <span class="d-inline-block" style="padding-bottom: 1px;">
{{cancelBtnCaption}} {{cancelBtnCaption}}
@@ -68,7 +68,7 @@
class="btn" class="btn"
[class]="btnClass" [class]="btnClass"
(click)="confirm()" (click)="confirm()"
[disabled]="!confirmButtonEnabled || !buttonsEnabled()" [disabled]="!confirmButtonEnabled || !buttonsEnabled"
> >
{{btnCaption}} {{btnCaption}}
</button> </button>
@@ -34,10 +34,10 @@
<p class="mb-0 small"><b>{{messageBold}}</b></p> <p class="mb-0 small"><b>{{messageBold}}</b></p>
} }
</div> </div>
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()"> <button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span> <span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button> </button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled() || degrees === 0"> <button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled || degrees === 0">
{{btnCaption}} {{btnCaption}}
@if (!confirmButtonEnabled) { @if (!confirmButtonEnabled) {
<ngb-progressbar style="height: 1px;" type="dark" [max]="secondsTotal" [value]="seconds"></ngb-progressbar> <ngb-progressbar style="height: 1px;" type="dark" [max]="secondsTotal" [value]="seconds"></ngb-progressbar>
@@ -68,11 +68,6 @@
></ng-select> ></ng-select>
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.DocumentLink) { } @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"></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"
(ngModelChange)="setMonetaryValue(atom, $event)"
[disabled]="disabled">
} @else { } @else {
<input class="w-25 form-control rounded-end" type="text" [(ngModel)]="atom.value" [disabled]="disabled"> <input class="w-25 form-control rounded-end" type="text" [(ngModel)]="atom.value" [disabled]="disabled">
} }
@@ -1,6 +1,5 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing' import { provideHttpClientTesting } from '@angular/common/http/testing'
import { LOCALE_ID } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
@@ -42,12 +41,6 @@ const customFields = [
], ],
}, },
}, },
{
id: 3,
name: 'Test Monetary Field',
data_type: CustomFieldDataType.Monetary,
extra_data: { default_currency: 'EUR' },
},
] ]
describe('CustomFieldsQueryDropdownComponent', () => { describe('CustomFieldsQueryDropdownComponent', () => {
@@ -68,7 +61,6 @@ describe('CustomFieldsQueryDropdownComponent', () => {
providers: [ providers: [
provideHttpClient(withInterceptorsFromDi()), provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(), provideHttpClientTesting(),
{ provide: LOCALE_ID, useValue: 'de' },
], ],
}).compileComponents() }).compileComponents()
@@ -158,22 +150,6 @@ describe('CustomFieldsQueryDropdownComponent', () => {
expect(options2).toEqual([]) expect(options2).toEqual([])
}) })
it('should normalize localized monetary comparison values', () => {
const atom = new CustomFieldQueryAtom([3, 'exact', null])
component.setMonetaryValue(atom, '1.234,56')
expect(atom.value).toEqual('1234.56')
})
it('should preserve API-formatted monetary comparison values', () => {
const atom = new CustomFieldQueryAtom([3, 'exact', null])
component.setMonetaryValue(atom, '1234.56')
expect(atom.value).toEqual('1234.56')
})
it('should remove an element from the selection model', () => { it('should remove an element from the selection model', () => {
const expression = new CustomFieldQueryExpression() const expression = new CustomFieldQueryExpression()
const atom = new CustomFieldQueryAtom() const atom = new CustomFieldQueryAtom()
@@ -1,14 +1,9 @@
import { import { NgTemplateOutlet } from '@angular/common'
getLocaleNumberSymbol,
NgTemplateOutlet,
NumberSymbol,
} from '@angular/common'
import { import {
Component, Component,
EventEmitter, EventEmitter,
inject, inject,
Input, Input,
LOCALE_ID,
Output, Output,
QueryList, QueryList,
signal, signal,
@@ -217,7 +212,6 @@ export class CustomFieldQueriesModel {
}) })
export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPermissions { export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPermissions {
protected customFieldsService = inject(CustomFieldsService) protected customFieldsService = inject(CustomFieldsService)
private readonly locale = inject(LOCALE_ID)
public CustomFieldQueryComponentType = CustomFieldQueryElementType public CustomFieldQueryComponentType = CustomFieldQueryElementType
public CustomFieldQueryOperator = CustomFieldQueryOperator public CustomFieldQueryOperator = CustomFieldQueryOperator
@@ -382,18 +376,4 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
} }
return [] return []
} }
setMonetaryValue(atom: CustomFieldQueryAtom, value: string) {
// Normalize the decimal symbol e.g. . vs , by locale
const decimalSymbol = getLocaleNumberSymbol(
this.locale,
NumberSymbol.Decimal
)
if (decimalSymbol !== '.' && value.includes(decimalSymbol)) {
const groupSymbol = getLocaleNumberSymbol(this.locale, NumberSymbol.Group)
value = value.split(groupSymbol).join('').split(decimalSymbol).join('.')
}
atom.value = value
}
} }
@@ -32,8 +32,6 @@ import { EditDialogComponent, EditDialogMode } from './edit-dialog.component'
template: ` template: `
<div> <div>
<h4 class="modal-title" id="modal-basic-title">{{ getTitle() }}</h4> <h4 class="modal-title" id="modal-basic-title">{{ getTitle() }}</h4>
<span class="error">{{ error?.name }}</span>
<button [disabled]="networkActive" (click)="save()">Save</button>
</div> </div>
`, `,
imports: [FormsModule, ReactiveFormsModule], imports: [FormsModule, ReactiveFormsModule],
@@ -278,22 +276,4 @@ describe('EditDialogComponent', () => {
expect(failedSpy).toHaveBeenCalled() expect(failedSpy).toHaveBeenCalled()
expect(component.error).toEqual('error') expect(component.error).toEqual('error')
}) })
it('should update the view after a failed save', async () => {
const button: HTMLButtonElement =
fixture.nativeElement.querySelector('button')
button.click()
await fixture.whenStable()
expect(button.disabled).toBe(true)
httpTestingController
.expectOne(`${environment.apiBaseUrl}tags/`)
.flush({ name: ['Name is required.'] }, { status: 400, statusText: '' })
await fixture.whenStable()
expect(button.disabled).toBe(false)
expect(fixture.nativeElement.querySelector('.error').textContent).toContain(
'Name is required.'
)
})
}) })
@@ -1,5 +1,4 @@
import { import {
ChangeDetectorRef,
Directive, Directive,
EventEmitter, EventEmitter,
Input, Input,
@@ -46,7 +45,6 @@ export abstract class EditDialogComponent<
protected userService = inject(UserService) protected userService = inject(UserService)
protected settingsService = inject(SettingsService) protected settingsService = inject(SettingsService)
protected permissionsService = inject(PermissionsService) protected permissionsService = inject(PermissionsService)
protected changeDetector = inject(ChangeDetectorRef)
dialogMode = model(EditDialogMode.CREATE) dialogMode = model(EditDialogMode.CREATE)
@@ -110,12 +108,10 @@ export abstract class EditDialogComponent<
// wait to enable close button so it doesn't steal focus from input since its the first clickable element in the DOM // wait to enable close button so it doesn't steal focus from input since its the first clickable element in the DOM
setTimeout(() => { setTimeout(() => {
this.closeEnabled = true this.closeEnabled = true
this.changeDetector.markForCheck()
}) })
this.userService.listAll().subscribe((r) => { this.userService.listAll().subscribe((r) => {
this.users = r.results this.users = r.results
this.changeDetector.markForCheck()
}) })
} }
@@ -195,7 +191,6 @@ export abstract class EditDialogComponent<
this.error = error.error this.error = error.error
this.networkActive = false this.networkActive = false
this.failed.next(error) this.failed.next(error)
this.changeDetector.markForCheck()
}, },
}) })
} }
@@ -1019,28 +1019,4 @@ describe('WorkflowEditDialogComponent', () => {
'pass3', 'pass3',
]) ])
}) })
it('should parse passwords again when retrying a failed save', () => {
component.object = {
name: 'Workflow with blank Passwords',
id: 1,
order: null,
enabled: true,
triggers: [],
actions: [
{
id: 1,
type: WorkflowActionType.PasswordRemoval,
passwords: [],
},
],
}
component.ngOnInit()
component.save()
component.objectForm.get('order').setValue(1)
expect(() => component.save()).not.toThrow()
expect(component.objectForm.get('actions').value[0].passwords).toEqual([])
})
}) })
@@ -1207,8 +1207,9 @@ export class WorkflowEditDialogComponent
return passwords.join('\n') return passwords.join('\n')
} }
private parsePasswords(value: string | string[] = ''): string[] { private parsePasswords(value: string = ''): string[] {
return (Array.isArray(value) ? value : value.split(/[\n,]+/)) return value
.split(/[\n,]+/)
.map((entry) => entry.trim()) .map((entry) => entry.trim())
.filter((entry) => entry.length > 0) .filter((entry) => entry.length > 0)
} }
@@ -1,4 +1,4 @@
<div class="btn-group w-100" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown" (keydown)="listKeyDown($event)" [popperOptions]="popperOptions" [autoClose]="!creating()"> <div class="btn-group w-100" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown" (keydown)="listKeyDown($event)" [popperOptions]="popperOptions">
<button class="btn btn-sm" id="dropdown_{{name}}" ngbDropdownToggle [ngClass]="!editing && selectionModel.selectionSize() > 0 ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled"> <button class="btn btn-sm" id="dropdown_{{name}}" ngbDropdownToggle [ngClass]="!editing && selectionModel.selectionSize() > 0 ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
<i-bs name="{{icon}}"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div> <i-bs name="{{icon}}"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
@if (!editing && selectionModel.totalCount > 0) { @if (!editing && selectionModel.totalCount > 0) {
@@ -49,7 +49,7 @@
</cdk-virtual-scroll-viewport> </cdk-virtual-scroll-viewport>
} }
@if (editing) { @if (editing) {
@if (filteredItems.length === 0 && createRef !== undefined && filterText?.length > 0) { @if (filteredItems.length === 0 && createRef !== undefined) {
<button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled"> <button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled">
<small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small> <small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small>
<i-bs width="1.5em" height="1em" name="plus"></i-bs> <i-bs width="1.5em" height="1em" name="plus"></i-bs>
@@ -62,7 +62,7 @@
</button> </button>
} }
} }
@if (extraButtonTitle && (showExtraButtonIfEmpty || filteredItems?.length > 0)) { @if (extraButtonTitle) {
<button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="extraButtonClicked($event)" [disabled]="disabled"> <button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="extraButtonClicked($event)" [disabled]="disabled">
<small class="ms-2 fw-bold">{{extraButtonTitle}}</small> <small class="ms-2 fw-bold">{{extraButtonTitle}}</small>
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs> <i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
@@ -3,7 +3,6 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing' import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { NEVER, Subject } from 'rxjs'
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type' import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
import { import {
DEFAULT_MATCHING_ALGORITHM, DEFAULT_MATCHING_ALGORITHM,
@@ -49,7 +48,6 @@ const negativeNullItem = {
let selectionModel: FilterableDropdownSelectionModel let selectionModel: FilterableDropdownSelectionModel
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
const createModalRef = () => ({ closed: NEVER, dismissed: NEVER }) as any
describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => { describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => {
let component: FilterableDropdownComponent let component: FilterableDropdownComponent
@@ -870,7 +868,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0) expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0)
}) })
it('should keep the dropdown open while the create modal is active', async () => { it('should set support create, keep open model and call createRef method', async () => {
component.selectionModel.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
component.selectionModel = selectionModel component.selectionModel = selectionModel
@@ -884,44 +882,20 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
fixture.detectChanges() fixture.detectChanges()
component.filterText = 'Test Filter Text' component.filterText = 'Test Filter Text'
const modalClosed = new Subject<void>() component.createRef = jest.fn()
component.createRef = jest.fn(
() =>
({
closed: modalClosed,
dismissed: NEVER,
}) as any
)
component.createClicked() component.createClicked()
expect(component.creating()).toBeTruthy() expect(component.creating).toBeTruthy()
expect(component.createRef).toHaveBeenCalledWith('Test Filter Text') expect(component.createRef).toHaveBeenCalledWith('Test Filter Text')
fixture.detectChanges()
expect(component.dropdown.autoClose).toBeFalsy()
document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
document.body.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
await wait(10)
expect(component.dropdown.isOpen()).toBeTruthy()
// Also cover a close that was already scheduled before autoClose changed.
const openSpy = jest.spyOn(component.dropdown, 'open') const openSpy = jest.spyOn(component.dropdown, 'open')
component.dropdownOpenChange(false) component.dropdownOpenChange(false)
expect(openSpy).toHaveBeenCalled() // should keep open expect(openSpy).toHaveBeenCalled() // should keep open
component.dropdownOpenChange(false)
expect(openSpy).toHaveBeenCalledTimes(2) // modal interactions keep it open
modalClosed.next()
fixture.detectChanges()
expect(component.creating()).toBeFalsy()
expect(component.dropdown.autoClose).toBeTruthy()
expect(component.dropdown.isOpen()).toBeTruthy()
}) })
it('should call create on enter inside filter field if 0 items remain while editing', async () => { it('should call create on enter inside filter field if 0 items remain while editing', async () => {
component.selectionModel.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
component.editing = true component.editing = true
component.createRef = jest.fn(createModalRef) component.createRef = jest.fn()
const createSpy = jest.spyOn(component, 'createClicked') const createSpy = jest.spyOn(component, 'createClicked')
expect(component.selectionModel.getSelectedItems()).toEqual([]) expect(component.selectionModel.getSelectedItems()).toEqual([])
fixture.nativeElement fixture.nativeElement
@@ -937,25 +911,6 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(createSpy).toHaveBeenCalled() expect(createSpy).toHaveBeenCalled()
}) })
it('should only show create when a non-empty filter has no matches', () => {
component.selectionModel.items = []
component.icon = 'tag-fill'
component.editing = true
component.createRef = jest.fn(createModalRef)
fixture.detectChanges()
expect(fixture.nativeElement.textContent).not.toContain('Create')
component.listFilterEnter()
expect(component.createRef).not.toHaveBeenCalled()
const filterInput: HTMLInputElement =
fixture.nativeElement.querySelector('input[type="text"]')
filterInput.value = 'FooBar'
filterInput.dispatchEvent(new Event('input'))
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Create "FooBar"')
})
it('should exclude item and trigger change event', () => { it('should exclude item and trigger change event', () => {
const id = 1 const id = 1
const state = ToggleableItemState.Selected const state = ToggleableItemState.Selected
@@ -1015,18 +970,4 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(extraButtonClicked).toBeTruthy() expect(extraButtonClicked).toBeTruthy()
expect(applied).toBeFalsy() expect(applied).toBeFalsy()
}) })
it('should only show the extra button for an empty result when enabled', () => {
component.selectionModel.items = items
component.icon = 'tag-fill'
component.extraButtonTitle = 'Extra'
component.filterText = 'FooBar'
fixture.detectChanges()
expect(fixture.nativeElement.textContent).not.toContain('Extra')
fixture.componentRef.setInput('showExtraButtonIfEmpty', true)
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Extra')
})
}) })
@@ -15,13 +15,9 @@ import {
signal, signal,
} from '@angular/core' } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
NgbDropdown,
NgbDropdownModule,
NgbModalRef,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subject, filter, first, merge, takeUntil } from 'rxjs' import { Subject, filter, takeUntil } from 'rxjs'
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type' import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
import { MatchingModel } from 'src/app/data/matching-model' import { MatchingModel } from 'src/app/data/matching-model'
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions' import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
@@ -763,7 +759,7 @@ export class FilterableDropdownComponent
disabled = false disabled = false
@Input() @Input()
createRef: (name: string) => NgbModalRef createRef: (name) => void
@Input() @Input()
set documentCounts(counts: SelectionDataItem[]) { set documentCounts(counts: SelectionDataItem[]) {
@@ -778,10 +774,7 @@ export class FilterableDropdownComponent
@Input() @Input()
extraButtonTitle: string extraButtonTitle: string
@Input() creating: boolean = false
showExtraButtonIfEmpty: boolean = false
readonly creating = signal(false)
@Output() @Output()
apply = new EventEmitter<ChangedItems>() apply = new EventEmitter<ChangedItems>()
@@ -858,18 +851,12 @@ export class FilterableDropdownComponent
} }
createClicked() { createClicked() {
this.creating.set(true) this.creating = true
const modal = this.createRef(this.filterText) this.createRef(this.filterText)
merge(modal.closed, modal.dismissed)
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe(() => this.creating.set(false))
} }
dropdownOpenChange(open: boolean): void { dropdownOpenChange(open: boolean): void {
if (open) { if (open) {
// Dont let a create modal close this
if (this.creating()) return
setTimeout(() => { setTimeout(() => {
this.listFilterTextInput?.nativeElement.focus() this.listFilterTextInput?.nativeElement.focus()
this.buttonsViewport?.checkViewportSize() this.buttonsViewport?.checkViewportSize()
@@ -882,8 +869,9 @@ export class FilterableDropdownComponent
this.editing && !this.selectionModel.manyToOne this.editing && !this.selectionModel.manyToOne
this.opened.next(this) this.opened.next(this)
} else { } else {
if (this.creating()) { if (this.creating) {
this.dropdown?.open() this.dropdown?.open()
this.creating = false
} else { } else {
this.filterText = '' this.filterText = ''
if (this.applyOnClose && this.selectionModel.isDirty()) { if (this.applyOnClose && this.selectionModel.isDirty()) {
@@ -904,11 +892,7 @@ export class FilterableDropdownComponent
this.dropdown.close() this.dropdown.close()
} }
}, 200) }, 200)
} else if ( } else if (filtered.length == 0 && this.createRef) {
filtered.length == 0 &&
this.createRef &&
this.filterText?.length > 0
) {
this.createClicked() this.createClicked()
} }
} }
@@ -52,10 +52,10 @@ describe('CustomFieldsValuesComponent', () => {
}) })
it('should set selectedFields and map values correctly', () => { it('should set selectedFields and map values correctly', () => {
component.value = { 1: 'value1', 3: 0, 4: false } component.value = { 1: 'value1' }
component.selectedFields = [1, 2, 3, 4] component.selectedFields = [1, 2]
expect(component.selectedFields).toEqual([1, 2, 3, 4]) expect(component.selectedFields).toEqual([1, 2])
expect(component.value).toEqual({ 1: 'value1', 2: null, 3: 0, 4: false }) expect(component.value).toEqual({ 1: 'value1', 2: null })
}) })
it('should return the correct custom field by id', () => { it('should return the correct custom field by id', () => {
@@ -77,7 +77,7 @@ export class CustomFieldsValuesComponent extends AbstractInputComponent<Object>
this._selectedFields = newFields this._selectedFields = newFields
// map the selected fields to an object with field_id as key and value as value // map the selected fields to an object with field_id as key and value as value
this.value = newFields.reduce((acc, fieldId) => { this.value = newFields.reduce((acc, fieldId) => {
acc[fieldId] = this.value?.[fieldId] ?? null acc[fieldId] = this.value?.[fieldId] || null
return acc return acc
}, {}) }, {})
this.onChange(this.value) this.onChange(this.value)
@@ -36,16 +36,7 @@
(focus)="clearLastSearchTerm()" (focus)="clearLastSearchTerm()"
(clear)="clearLastSearchTerm()" (clear)="clearLastSearchTerm()"
(blur)="onBlur()"> (blur)="onBlur()">
<ng-template ng-label-tmp let-item="item">
@if (iconField && item[iconField]) {
<i-bs class="me-2" [name]="item[iconField]"></i-bs>
}
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
</ng-template>
<ng-template ng-option-tmp let-item="item"> <ng-template ng-option-tmp let-item="item">
@if (iconField && item[iconField]) {
<i-bs class="me-2" [name]="item[iconField]"></i-bs>
}
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span> <span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
</ng-template> </ng-template>
</ng-select> </ng-select>
@@ -35,7 +35,7 @@ import { AbstractInputComponent } from '../abstract-input'
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
], ],
}) })
export class SelectComponent extends AbstractInputComponent<number | string> { export class SelectComponent extends AbstractInputComponent<number> {
constructor() { constructor() {
super() super()
this.addItemRef = this.addItem.bind(this) this.addItemRef = this.addItem.bind(this)
@@ -100,9 +100,6 @@ export class SelectComponent extends AbstractInputComponent<number | string> {
@Input() @Input()
bindLabel: string = 'name' bindLabel: string = 'name'
@Input()
iconField: string
public searchFn = (term: string, item: any): boolean => public searchFn = (term: string, item: any): boolean =>
matchesSearchText(item?.[this.bindLabel], term) matchesSearchText(item?.[this.bindLabel], term)
@@ -100,7 +100,7 @@
</div> </div>
} }
<div class="form-group ms-md-auto"> <div class="form-group ms-md-auto">
<button type="button" class="btn me-2" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()">{{ cancelBtnCaption }}</button> <button type="button" class="btn me-2" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">{{ cancelBtnCaption }}</button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="pages.length === 0">{{ btnCaption }}</button> <button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="pages.length === 0">{{ btnCaption }}</button>
</div> </div>
</div> </div>
@@ -151,13 +151,6 @@
inset: 0; inset: 0;
pointer-events: none; pointer-events: none;
& section {
position: absolute;
text-align: initial;
box-sizing: border-box;
transform-origin: 0 0;
}
& .annotationTextContent { & .annotationTextContent {
opacity: 0; opacity: 0;
} }
@@ -90,7 +90,6 @@ describe('PngxPdfViewerComponent', () => {
} }
expect(viewer).toBeInstanceOf(PDFSinglePageViewer) expect(viewer).toBeInstanceOf(PDFSinglePageViewer)
expect(viewer.options.textLayerMode).toBe(0) expect(viewer.options.textLayerMode).toBe(0)
expect(viewer.options.enableSelectionRendering).toBe(false)
}) })
it('applies zoom, rotation, and page changes', async () => { it('applies zoom, rotation, and page changes', async () => {
@@ -130,25 +129,13 @@ describe('PngxPdfViewerComponent', () => {
;(component as any).applyScale() ;(component as any).applyScale()
expect(viewer.currentScaleValue).toBe(PdfZoomScale.PageFit) expect(viewer.currentScaleValue).toBe(PdfZoomScale.PageFit)
expect(viewer.currentScale).toBe(2) expect(viewer.currentScale).toBe(2)
})
it('does not reapply scale for page-only changes', async () => {
await initComponent()
const pdf = (component as any).pdf as { numPages: number }
pdf.numPages = 3
const viewer = (component as any).pdfViewer as PDFViewer
viewer.setDocument(pdf)
const applyScaleSpy = jest.spyOn(component as any, 'applyScale') const applyScaleSpy = jest.spyOn(component as any, 'applyScale')
component.page = 2 component.page = 2
;(component as any).lastViewerPage = 2
component.ngOnChanges({ ;(component as any).applyViewerState()
page: new SimpleChange(1, 2, false),
})
expect(viewer.currentPageNumber).toBe(2)
expect((component as any).lastViewerPage).toBeUndefined() expect((component as any).lastViewerPage).toBeUndefined()
expect(applyScaleSpy).not.toHaveBeenCalled() expect(applyScaleSpy).toHaveBeenCalled()
}) })
it('does not reset the viewer when it is already on the requested page', async () => { it('does not reset the viewer when it is already on the requested page', async () => {
@@ -296,22 +283,6 @@ describe('PngxPdfViewerComponent', () => {
expect(mockViewer.currentPageNumber).toBe(1) expect(mockViewer.currentPageNumber).toBe(1)
}) })
it('reloads when the source revision changes', () => {
const resetSpy = jest.spyOn(component as any, 'resetViewerState')
const loadSpy = jest
.spyOn(component as any, 'loadDocument')
.mockImplementation(() => {})
component.src = 'test.pdf'
component.sourceRevision = 1
component.ngOnChanges({
sourceRevision: new SimpleChange(0, 1, false),
})
expect(resetSpy).toHaveBeenCalled()
expect(loadSpy).toHaveBeenCalled()
})
it('applies viewer state after view init when already loaded', () => { it('applies viewer state after view init when already loaded', () => {
const applySpy = jest.spyOn(component as any, 'applyViewerState') const applySpy = jest.spyOn(component as any, 'applyViewerState')
;(component as any).hasLoaded = true ;(component as any).hasLoaded = true
@@ -13,7 +13,6 @@ import {
ViewChild, ViewChild,
} from '@angular/core' } from '@angular/core'
import { import {
AnnotationMode,
getDocument, getDocument,
GlobalWorkerOptions, GlobalWorkerOptions,
PDFDocumentLoadingTask, PDFDocumentLoadingTask,
@@ -44,7 +43,6 @@ export class PngxPdfViewerComponent
private readonly document = inject<Document>(DOCUMENT) private readonly document = inject<Document>(DOCUMENT)
@Input() src!: string @Input() src!: string
@Input() sourceRevision = 0
@Input() password?: string @Input() password?: string
@Input() page?: number @Input() page?: number
@Output() pageChange = new EventEmitter<number>() @Output() pageChange = new EventEmitter<number>()
@@ -95,7 +93,7 @@ export class PngxPdfViewerComponent
} }
ngOnChanges(changes: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {
if (changes['src'] || changes['sourceRevision'] || changes['password']) { if (changes['src'] || changes['password']) {
this.resetViewerState() this.resetViewerState()
if (this.src) { if (this.src) {
this.loadDocument() this.loadDocument()
@@ -117,10 +115,7 @@ export class PngxPdfViewerComponent
changes['zoomScale'] || changes['zoomScale'] ||
changes['rotation'] changes['rotation']
) { ) {
// Prevent loop with page / scale application see https://github.com/paperless-ngx/paperless-ngx/issues/13404 this.applyViewerState()
this.applyViewerState(
!!(changes['zoom'] || changes['zoomScale'] || changes['rotation'])
)
} }
if (changes['searchQuery']) { if (changes['searchQuery']) {
@@ -222,8 +217,6 @@ export class PngxPdfViewerComponent
linkService: this.linkService, linkService: this.linkService,
findController: this.findController, findController: this.findController,
textLayerMode, textLayerMode,
annotationMode: AnnotationMode.ENABLE,
enableSelectionRendering: false,
removePageBorders: true, removePageBorders: true,
} }
@@ -246,7 +239,7 @@ export class PngxPdfViewerComponent
} }
} }
private applyViewerState(applyScale = true): void { private applyViewerState(): void {
if (!this.pdfViewer) { if (!this.pdfViewer) {
return return
} }
@@ -270,7 +263,7 @@ export class PngxPdfViewerComponent
if (this.page === this.lastViewerPage) { if (this.page === this.lastViewerPage) {
this.lastViewerPage = undefined this.lastViewerPage = undefined
} }
if (hasPages && applyScale) { if (hasPages) {
this.applyScale() this.applyScale()
} }
this.dispatchFindIfReady() this.dispatchFindIfReady()
@@ -22,7 +22,7 @@
} }
</div> </div>
<div class="me-1"> <div class="me-1">
<small>{{ownerFilterLabel}}</small> <small i18n>My documents</small>
</div> </div>
</button> </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"> <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">
@@ -32,7 +32,7 @@
} }
</div> </div>
<div class="me-1"> <div class="me-1">
<small>{{ownerExclusionFilterLabel}}</small> <small i18n>Shared with me</small>
</div> </div>
</button> </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"> <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">
@@ -42,7 +42,7 @@
} }
</div> </div>
<div class="me-1"> <div class="me-1">
<small>{{sharedByFilterLabel}}</small> <small i18n>Shared by me</small>
</div> </div>
</button> </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"> <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">
@@ -94,58 +94,6 @@ describe('PermissionsFilterDropdownComponent', () => {
expect(component.isActive).toBeTruthy() expect(component.isActive).toBeTruthy()
}) })
it('should describe concrete user filters honestly', () => {
component.selectionModel.ownerFilter = OwnerFilterType.SELF
component.selectionModel.userID = 1
expect(component.ownerFilterLabel).toEqual('Owned by user1')
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
component.selectionModel.excludeUsers = [1]
expect(component.ownerExclusionFilterLabel).toEqual('Not owned by user1')
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 = OwnerFilterType.SELF
component.selectionModel.userID = 99
expect(component.ownerFilterLabel).toEqual('Owned by another user')
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
component.selectionModel.excludeUsers = [99]
expect(component.ownerExclusionFilterLabel).toEqual(
'Not owned by another user'
)
component.selectionModel.excludeUsers = [98, 99]
expect(component.ownerExclusionFilterLabel).toEqual(
'Not owned by selected users'
)
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 = currentUserID
expect(component.ownerFilterLabel).toEqual('My documents')
expect(component.sharedByFilterLabel).toEqual('Shared by me')
component.selectionModel.excludeUsers = [currentUserID]
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
})
it('should retain relative labels for inactive filter choices', () => {
component.selectionModel.ownerFilter = OwnerFilterType.NONE
expect(component.ownerFilterLabel).toEqual('My documents')
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
expect(component.sharedByFilterLabel).toEqual('Shared by me')
})
it('should support reset', () => { it('should support reset', () => {
component.setFilter(OwnerFilterType.OTHERS) component.setFilter(OwnerFilterType.OTHERS)
expect(component.selectionModel.ownerFilter).not.toEqual( expect(component.selectionModel.ownerFilter).not.toEqual(
@@ -93,55 +93,6 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
) )
} }
get ownerFilterLabel(): string {
if (
this.selectionModel?.ownerFilter !== OwnerFilterType.SELF ||
this.selectionModel?.userID === this.settingsService.currentUser()?.id
) {
return $localize`My documents`
}
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 ?? []
if (
this.selectionModel?.ownerFilter !== OwnerFilterType.NOT_SELF ||
(excludedUsers.length === 1 &&
excludedUsers[0] === this.settingsService.currentUser()?.id)
) {
return $localize`Shared with me`
}
const usernames = excludedUsers
.map((id) => this.getUsername(id))
.filter(Boolean)
if (usernames.length === excludedUsers.length && usernames.length > 0) {
return $localize`Not owned by ${usernames.join(', ')}`
}
return excludedUsers.length === 1
? $localize`Not owned by another user`
: $localize`Not owned by selected users`
}
get sharedByFilterLabel(): string {
if (
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)
return username
? $localize`Shared by ${username}`
: $localize`Shared by another user`
}
constructor() { constructor() {
const userService = inject(UserService) const userService = inject(UserService)
@@ -213,8 +164,4 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
} }
this.onChange() this.onChange()
} }
private getUsername(userID: number): string {
return this.users().find((user) => user.id === userID)?.username
}
} }
@@ -17,10 +17,6 @@ const permissions = [
'view_document', 'view_document',
'change_document', 'change_document',
'delete_document', 'delete_document',
'add_sharelinkbundle',
'view_sharelinkbundle',
'change_sharelinkbundle',
'delete_sharelinkbundle',
'change_tag', 'change_tag',
'view_documenttype', 'view_documenttype',
] ]
@@ -79,7 +75,6 @@ describe('PermissionsSelectComponent', () => {
component.ngOnInit() component.ngOnInit()
component.writeValue(permissions) component.writeValue(permissions)
expect(component.typesWithAllActions).toContain('Document') expect(component.typesWithAllActions).toContain('Document')
expect(component.typesWithAllActions).toContain('ShareLinkBundle')
}) })
it('should update checkboxes on permissions set', () => { it('should update checkboxes on permissions set', () => {
@@ -90,10 +85,6 @@ describe('PermissionsSelectComponent', () => {
expect(input1.nativeElement.checked).toBeTruthy() expect(input1.nativeElement.checked).toBeTruthy()
const input2 = fixture.debugElement.query(By.css('input#Tag_Change')) const input2 = fixture.debugElement.query(By.css('input#Tag_Change'))
expect(input2.nativeElement.checked).toBeTruthy() expect(input2.nativeElement.checked).toBeTruthy()
const bundleInput = fixture.debugElement.query(
By.css('input#ShareLinkBundle_Add')
)
expect(bundleInput.nativeElement.checked).toBeTruthy()
}) })
it('disable checkboxes when permissions are inherited', () => { it('disable checkboxes when permissions are inherited', () => {
@@ -1,7 +1,6 @@
<a [href]="link ?? previewUrl" class="{{linkClasses}}" [target]="linkTarget" [title]="linkTitle" <a [href]="link ?? previewUrl" class="{{linkClasses}}" [target]="linkTarget" [title]="linkTitle"
[ngbPopover]="previewContent" [popoverTitle]="document.title | documentTitle" container="body" [ngbPopover]="previewContent" [popoverTitle]="document.title | documentTitle" container="body"
autoClose="true" [popoverClass]="popoverClass()" [popperOptions]="popperOptions" autoClose="true" [popoverClass]="popoverClass()" (mouseenter)="mouseEnterPreview()" (mouseleave)="mouseLeavePreview()" #popover="ngbPopover">
(mouseenter)="mouseEnterPreview()" (mouseleave)="mouseLeavePreview()" #popover="ngbPopover">
<ng-content></ng-content> <ng-content></ng-content>
</a> </a>
<ng-template #previewContent> <ng-template #previewContent>
@@ -67,10 +67,6 @@ describe('PreviewPopupComponent', () => {
expect(component.useNativePdfViewer).toBeTruthy() expect(component.useNativePdfViewer).toBeTruthy()
}) })
it('should use fixed positioning for the preview popover', () => {
expect(component.popperOptions({}).strategy).toEqual('fixed')
})
it('should render object if native PDF viewer enabled', () => { it('should render object if native PDF viewer enabled', () => {
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, true) settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, true)
component.popover.open() component.popover.open()
@@ -8,7 +8,6 @@ import {
ViewChild, ViewChild,
} from '@angular/core' } from '@angular/core'
import { NgbPopover, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap' import { NgbPopover, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
import { Options } from '@popperjs/core'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { first, Subject, takeUntil } from 'rxjs' import { first, Subject, takeUntil } from 'rxjs'
import { Document } from 'src/app/data/document' import { Document } from 'src/app/data/document'
@@ -75,11 +74,6 @@ export class PreviewPopupComponent implements OnDestroy {
readonly popoverClass = signal('shadow popover-preview') readonly popoverClass = signal('shadow popover-preview')
readonly popperOptions = (options: Partial<Options>): Partial<Options> => ({
...options,
strategy: 'fixed',
})
get renderAsObject(): boolean { get renderAsObject(): boolean {
return (this.isPdf && this.useNativePdfViewer) || !this.isPdf return (this.isPdf && this.useNativePdfViewer) || !this.isPdf
} }
@@ -119,7 +119,7 @@
type="button" type="button"
class="btn btn-primary btn-sm d-inline-flex align-items-center gap-2 text-nowrap" class="btn btn-primary btn-sm d-inline-flex align-items-center gap-2 text-nowrap"
(click)="submit()" (click)="submit()"
[disabled]="loading() || !buttonsEnabled()"> [disabled]="loading() || !buttonsEnabled">
@if (loading()) { @if (loading()) {
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> <span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
} }
@@ -69,7 +69,7 @@ describe('ShareLinkBundleDialogComponent', () => {
file_version: FileVersion.Original, file_version: FileVersion.Original,
expiration_days: 3, expiration_days: 3,
}) })
expect(component.buttonsEnabled()).toBe(false) expect(component.buttonsEnabled).toBe(false)
expect(confirmSpy).toHaveBeenCalled() expect(confirmSpy).toHaveBeenCalled()
component.form.setValue({ component.form.setValue({
@@ -78,7 +78,7 @@ export class ShareLinkBundleDialogComponent extends ConfirmDialogComponent {
: FileVersion.Original, : FileVersion.Original,
expiration_days: this.form.value.expirationDays, expiration_days: this.form.value.expirationDays,
} }
this.buttonsEnabled.set(false) this.buttonsEnabled = false
super.confirm() super.confirm()
} }
@@ -2,16 +2,10 @@
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading() || (suggestions() && !aiEnabled())"> <button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading() || (suggestions() && !aiEnabled())">
@if (loading()) { @if (loading()) {
<div class="spinner-border spinner-border-sm" role="status"></div> <div class="spinner-border spinner-border-sm" role="status"></div>
} @else if (noSuggestions) {
<i-bs width="1.2em" height="1.2em" name="check-circle"></i-bs>
} @else { } @else {
<i-bs width="1.2em" height="1.2em" name="stars"></i-bs> <i-bs width="1.2em" height="1.2em" name="stars"></i-bs>
} }
@if (noSuggestions) { <span class="d-none d-lg-inline ps-1" i18n>Suggest</span>
<span class="d-none d-lg-inline ps-1" i18n>No suggestions</span>
} @else {
<span class="d-none d-lg-inline ps-1" i18n>Suggest</span>
}
@if (totalSuggestions > 0) { @if (totalSuggestions > 0) {
<span class="badge bg-primary ms-2">{{ totalSuggestions }}</span> <span class="badge bg-primary ms-2">{{ totalSuggestions }}</span>
} }
@@ -25,7 +19,7 @@
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown"> <div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
<div class="list-group list-group-flush small pb-0"> <div class="list-group list-group-flush small pb-0">
@if (totalSuggestions === 0) { @if (!suggestions()?.suggested_tags && !suggestions()?.suggested_document_types && !suggestions()?.suggested_correspondents) {
<div class="list-group-item text-muted fst-italic"> <div class="list-group-item text-muted fst-italic">
<small class="text-muted small fst-italic" i18n>No novel suggestions</small> <small class="text-muted small fst-italic" i18n>No novel suggestions</small>
</div> </div>
@@ -30,34 +30,6 @@ describe('SuggestionsDropdownComponent', () => {
expect(component.totalSuggestions).toBe(4) expect(component.totalSuggestions).toBe(4)
}) })
it('should show when a completed request returned no suggestions', () => {
fixture.componentRef.setInput('suggestions', {
correspondents: [],
tags: [],
document_types: [],
storage_paths: [],
dates: [],
})
fixture.detectChanges()
expect(component.noSuggestions).toBeTruthy()
expect(fixture.nativeElement.textContent).toContain('No suggestions')
})
it('should not show the empty state before a request or with suggestions', () => {
expect(component.noSuggestions).toBeFalsy()
fixture.componentRef.setInput('suggestions', {
correspondents: [],
tags: [42],
document_types: [],
storage_paths: [],
dates: [],
})
expect(component.noSuggestions).toBeFalsy()
})
it('should emit getSuggestions when clickSuggest is called and suggestions are null', () => { it('should emit getSuggestions when clickSuggest is called and suggestions are null', () => {
jest.spyOn(component.getSuggestions, 'emit') jest.spyOn(component.getSuggestions, 'emit')
fixture.componentRef.setInput('suggestions', null) fixture.componentRef.setInput('suggestions', null)
@@ -87,6 +59,5 @@ describe('SuggestionsDropdownComponent', () => {
}) })
component.clickSuggest() component.clickSuggest()
expect(component.dropdown.open).toBeTruthy() expect(component.dropdown.open).toBeTruthy()
expect(fixture.nativeElement.textContent).toContain('No novel suggestions')
}) })
}) })
@@ -61,21 +61,4 @@ export class SuggestionsDropdownComponent {
this.suggestions()?.suggested_document_types?.length || 0 this.suggestions()?.suggested_document_types?.length || 0
) )
} }
get noSuggestions(): boolean {
const suggestions = this.suggestions()
return (
suggestions != null &&
!suggestions.title &&
!suggestions.tags?.length &&
!suggestions.suggested_tags?.length &&
!suggestions.correspondents?.length &&
!suggestions.suggested_correspondents?.length &&
!suggestions.document_types?.length &&
!suggestions.suggested_document_types?.length &&
!suggestions.storage_paths?.length &&
!suggestions.suggested_storage_paths?.length &&
!suggestions.dates?.length
)
}
} }
@@ -1,7 +1,6 @@
<pngx-widget-frame <pngx-widget-frame
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
[title]="savedView.name" [title]="savedView.name"
[titleIcon]="savedView.icon || 'funnel'"
[loading]="false" [loading]="false"
[draggable]="savedView" [draggable]="savedView"
> >
@@ -8,12 +8,7 @@
<i-bs name="grip-vertical"></i-bs> <i-bs name="grip-vertical"></i-bs>
</div> </div>
} }
<h6 class="card-title mb-0"> <h6 class="card-title mb-0">{{title()}}</h6>
@if (titleIcon()) {
<i-bs class="me-2" [name]="titleIcon()"></i-bs>
}
{{title()}}
</h6>
<ng-content select="[title-badge]"></ng-content> <ng-content select="[title-badge]"></ng-content>
@if (badge() !== null && badge() !== undefined) { @if (badge() !== null && badge() !== undefined) {
<span class="badge bg-info text-dark ms-2">{{badge()}}</span> <span class="badge bg-info text-dark ms-2">{{badge()}}</span>
@@ -16,8 +16,6 @@ export class WidgetFrameComponent implements AfterViewInit {
title = input<string>() title = input<string>()
titleIcon = input<string>()
draggable = input<any>() draggable = input<any>()
cardless = input(false) cardless = input(false)
@@ -113,8 +113,8 @@
<form [formGroup]='documentForm' (ngSubmit)="save()"> <form [formGroup]='documentForm' (ngSubmit)="save()">
<div class="btn-toolbar justify-content-end mb-1 pb-3 gap-2 row-gap-2 border-bottom"> <div class="btn-toolbar mb-1 border-bottom">
<div class="btn-group me-auto"> <div class="btn-group pb-3">
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Close" (click)="close()"> <button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Close" (click)="close()">
<i-bs width="1.2em" height="1.2em" name="x"></i-bs> <i-bs width="1.2em" height="1.2em" name="x"></i-bs>
</button> </button>
@@ -127,36 +127,32 @@
</div> </div>
<ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"> <ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
<div class="d-flex gap-2"> <div class="btn-group pb-3 ms-auto">
<div class="btn-group"> <pngx-suggestions-dropdown *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"
<pngx-suggestions-dropdown *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }" [disabled]="!userCanEdit || suggestionsLoading()"
[disabled]="!userCanEdit || suggestionsLoading()" [loading]="suggestionsLoading()"
[loading]="suggestionsLoading()" [suggestions]="suggestions()"
[suggestions]="suggestions()" [aiEnabled]="aiEnabled"
[aiEnabled]="aiEnabled" (getSuggestions)="getSuggestions()"
(getSuggestions)="getSuggestions()" (addTag)="createTag($event)"
(addTag)="createTag($event)" (addDocumentType)="createDocumentType($event)"
(addDocumentType)="createDocumentType($event)" (addCorrespondent)="createCorrespondent($event)">
(addCorrespondent)="createCorrespondent($event)"> </pngx-suggestions-dropdown>
</pngx-suggestions-dropdown>
</div>
<div class="btn-group">
<pngx-custom-fields-dropdown
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }"
[documentId]="documentId()"
[disabled]="!userCanEdit"
[existingFields]="document()?.custom_fields"
(created)="refreshCustomFields()"
(added)="addField($event)">
</pngx-custom-fields-dropdown>
</div>
</div> </div>
<div class="ps-3"> <div class="btn-group pb-3 ms-2">
<ng-container *ngTemplateOutlet="saveButtons"></ng-container> <pngx-custom-fields-dropdown
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }"
[documentId]="documentId()"
[disabled]="!userCanEdit"
[existingFields]="document()?.custom_fields"
(created)="refreshCustomFields()"
(added)="addField($event)">
</pngx-custom-fields-dropdown>
</div> </div>
</ng-container> </ng-container>
<ng-container *ngTemplateOutlet="saveButtons"></ng-container>
</div> </div>
<ul ngbNav #nav="ngbNav" class="nav-underline flex-nowrap flex-md-wrap overflow-auto" (navChange)="onNavChange($event)" [activeId]="activeNavID()" (activeIdChange)="activeNavID.set($event)"> <ul ngbNav #nav="ngbNav" class="nav-underline flex-nowrap flex-md-wrap overflow-auto" (navChange)="onNavChange($event)" [activeId]="activeNavID()" (activeIdChange)="activeNavID.set($event)">
@@ -280,7 +276,7 @@
} }
</div> </div>
<div class="d-flex justify-content-end border-top pt-3"> <div class="d-flex border-top pt-3">
<ng-container *ngTemplateOutlet="saveButtons"></ng-container> <ng-container *ngTemplateOutlet="saveButtons"></ng-container>
</div> </div>
</ng-template> </ng-template>
@@ -444,7 +440,7 @@
</div> </div>
<ng-template #saveButtons> <ng-template #saveButtons>
<div class="btn-group"> <div class="btn-group pb-3 ms-4">
<ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"> <ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
<button type="submit" class="order-3 btn btn-sm btn-primary" i18n [disabled]="!userCanEdit || networkActive() || (isDirty$ | async) !== true">Save</button> <button type="submit" class="order-3 btn btn-sm btn-primary" i18n [disabled]="!userCanEdit || networkActive() || (isDirty$ | async) !== true">Save</button>
@if (hasNext()) { @if (hasNext()) {
@@ -476,7 +472,6 @@
<div class="preview-sticky pdf-viewer-container"> <div class="preview-sticky pdf-viewer-container">
<pngx-pdf-viewer <pngx-pdf-viewer
[src]="pdfSource()" [src]="pdfSource()"
[sourceRevision]="previewRevision()"
[password]="pdfPassword()" [password]="pdfPassword()"
[renderMode]="PdfRenderMode.All" [renderMode]="PdfRenderMode.All"
[page]="previewCurrentPage()" (pageChange)="previewCurrentPage.set($event)" [page]="previewCurrentPage()" (pageChange)="previewCurrentPage.set($event)"
@@ -467,13 +467,6 @@ describe('DocumentDetailComponent', () => {
const docWithVersions = { const docWithVersions = {
...doc, ...doc,
versions: [ versions: [
{
id: 10,
added: new Date('2024-01-02T00:00:00Z'),
version_label: 'Edited',
checksum: 'bbbb',
is_root: false,
},
{ {
id: doc.id, id: doc.id,
added: new Date('2024-01-01T00:00:00Z'), added: new Date('2024-01-01T00:00:00Z'),
@@ -481,6 +474,13 @@ describe('DocumentDetailComponent', () => {
checksum: 'aaaa', checksum: 'aaaa',
is_root: true, is_root: true,
}, },
{
id: 10,
added: new Date('2024-01-02T00:00:00Z'),
version_label: 'Edited',
checksum: 'bbbb',
is_root: false,
},
], ],
} as Document } as Document
@@ -1232,8 +1232,8 @@ describe('DocumentDetailComponent', () => {
metadataSpy.mockClear() metadataSpy.mockClear()
component.document().versions = [ component.document().versions = [
{ id: 10, is_root: false },
{ id: doc.id, is_root: true }, { id: doc.id, is_root: true },
{ id: 10, is_root: false },
] as any ] as any
jest.spyOn(documentService, 'getPreviewUrl').mockReturnValue('preview-root') jest.spyOn(documentService, 'getPreviewUrl').mockReturnValue('preview-root')
jest.spyOn(documentService, 'getThumbUrl').mockReturnValue('thumb-root') jest.spyOn(documentService, 'getThumbUrl').mockReturnValue('thumb-root')
@@ -1564,7 +1564,7 @@ describe('DocumentDetailComponent', () => {
dialog.confirmClicked.next() dialog.confirmClicked.next()
await openModal.result await openModal.result
expect(dialog.buttonsEnabled()).toBe(false) expect(dialog.buttonsEnabled).toBe(false)
expect(reloadSpy).toHaveBeenCalled() expect(reloadSpy).toHaveBeenCalled()
expect((component as any).incomingUpdateModal).toBeNull() expect((component as any).incomingUpdateModal).toBeNull()
}) })
@@ -1602,7 +1602,6 @@ describe('DocumentDetailComponent', () => {
expect(openDoc.__changedFields).toEqual([]) expect(openDoc.__changedFields).toEqual([])
expect(setDirtySpy).toHaveBeenCalledWith(openDoc, false) expect(setDirtySpy).toHaveBeenCalledWith(openDoc, false)
expect(saveSpy).toHaveBeenCalled() expect(saveSpy).toHaveBeenCalled()
expect(component.previewRevision()).toBe(1)
}) })
it('should ignore incoming update for a different document id', () => { it('should ignore incoming update for a different document id', () => {
@@ -1789,7 +1788,7 @@ describe('DocumentDetailComponent', () => {
expect(errorSpy).toHaveBeenCalled() expect(errorSpy).toHaveBeenCalled()
expect(component.networkActive()).toBe(false) expect(component.networkActive()).toBe(false)
expect(dialog.buttonsEnabled()).toBe(true) expect(dialog.buttonsEnabled).toBe(true)
}) })
it('should refresh the document when removing password in update mode', () => { it('should refresh the document when removing password in update mode', () => {
@@ -1929,8 +1928,8 @@ describe('DocumentDetailComponent', () => {
component.documentId.set(doc.id) component.documentId.set(doc.id)
component.document.set({ ...doc, versions: [] } as Document) component.document.set({ ...doc, versions: [] } as Document)
const updatedVersions = [ const updatedVersions = [
{ id: 10, is_root: false },
{ id: doc.id, is_root: true }, { id: doc.id, is_root: true },
{ id: 10, is_root: false },
] as any ] as any
const openDoc = { ...doc, versions: [] } as Document const openDoc = { ...doc, versions: [] } as Document
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc) jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
@@ -2046,8 +2045,8 @@ describe('DocumentDetailComponent', () => {
it('should include version in download and print only for non-latest selected version', () => { it('should include version in download and print only for non-latest selected version', () => {
initNormally() initNormally()
component.document().versions = [ component.document().versions = [
{ id: 10, is_root: false },
{ id: doc.id, is_root: true }, { id: doc.id, is_root: true },
{ id: 10, is_root: false },
] as any ] as any
const getDownloadUrlSpy = jest const getDownloadUrlSpy = jest
@@ -2161,14 +2160,8 @@ describe('DocumentDetailComponent', () => {
it('should support open share links and email modals', () => { it('should support open share links and email modals', () => {
const modalSpy = jest.spyOn(modalService, 'open') const modalSpy = jest.spyOn(modalService, 'open')
initNormally() initNormally()
component.selectedVersionId.set(10)
component.openShareLinks() component.openShareLinks()
expect(modalSpy).toHaveBeenCalled() expect(modalSpy).toHaveBeenCalled()
expect(
(
modalSpy.mock.results[0].value as NgbModalRef
).componentInstance.documentId()
).toBe(10)
component.openEmailDocument() component.openEmailDocument()
expect(modalSpy).toHaveBeenCalled() expect(modalSpy).toHaveBeenCalled()
}) })
@@ -2197,6 +2190,9 @@ describe('DocumentDetailComponent', () => {
const appendChildSpy = jest const appendChildSpy = jest
.spyOn(document.body, 'appendChild') .spyOn(document.body, 'appendChild')
.mockImplementation((node: Node) => node) .mockImplementation((node: Node) => node)
const removeChildSpy = jest
.spyOn(document.body, 'removeChild')
.mockImplementation((node: Node) => node)
const createObjectURLSpy = jest const createObjectURLSpy = jest
.spyOn(URL, 'createObjectURL') .spyOn(URL, 'createObjectURL')
.mockReturnValue('blob:mock-url') .mockReturnValue('blob:mock-url')
@@ -2215,7 +2211,6 @@ describe('DocumentDetailComponent', () => {
src: '', src: '',
onload: null, onload: null,
contentWindow: mockContentWindow, contentWindow: mockContentWindow,
remove: jest.fn(),
} }
const createElementSpy = jest const createElementSpy = jest
@@ -2259,11 +2254,12 @@ describe('DocumentDetailComponent', () => {
mockContentWindow.onafterprint(new Event('afterprint')) mockContentWindow.onafterprint(new Event('afterprint'))
} }
expect(mockIframe.remove).toHaveBeenCalled() expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url') expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
createElementSpy.mockRestore() createElementSpy.mockRestore()
appendChildSpy.mockRestore() appendChildSpy.mockRestore()
removeChildSpy.mockRestore()
createObjectURLSpy.mockRestore() createObjectURLSpy.mockRestore()
revokeObjectURLSpy.mockRestore() revokeObjectURLSpy.mockRestore()
}) })
@@ -2310,6 +2306,9 @@ describe('DocumentDetailComponent', () => {
const appendChildSpy = jest const appendChildSpy = jest
.spyOn(document.body, 'appendChild') .spyOn(document.body, 'appendChild')
.mockImplementation((node: Node) => node) .mockImplementation((node: Node) => node)
const removeChildSpy = jest
.spyOn(document.body, 'removeChild')
.mockImplementation((node: Node) => node)
const createObjectURLSpy = jest const createObjectURLSpy = jest
.spyOn(URL, 'createObjectURL') .spyOn(URL, 'createObjectURL')
.mockReturnValue('blob:mock-url') .mockReturnValue('blob:mock-url')
@@ -2332,7 +2331,6 @@ describe('DocumentDetailComponent', () => {
src: '', src: '',
onload: null, onload: null,
contentWindow: mockContentWindow, contentWindow: mockContentWindow,
remove: jest.fn(),
} }
const createElementSpy = jest const createElementSpy = jest
@@ -2355,21 +2353,15 @@ describe('DocumentDetailComponent', () => {
if (expectToast) { if (expectToast) {
expect(toastSpy).toHaveBeenCalled() expect(toastSpy).toHaveBeenCalled()
expect(mockIframe.remove).toHaveBeenCalled()
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
} else { } else {
expect(toastSpy).not.toHaveBeenCalled() expect(toastSpy).not.toHaveBeenCalled()
expect(mockIframe.remove).not.toHaveBeenCalled()
expect(revokeObjectURLSpy).not.toHaveBeenCalled()
component.ngOnDestroy()
expect(mockIframe.remove).toHaveBeenCalled()
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
} }
expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
createElementSpy.mockRestore() createElementSpy.mockRestore()
appendChildSpy.mockRestore() appendChildSpy.mockRestore()
removeChildSpy.mockRestore()
createObjectURLSpy.mockRestore() createObjectURLSpy.mockRestore()
revokeObjectURLSpy.mockRestore() revokeObjectURLSpy.mockRestore()
}) })
@@ -249,7 +249,6 @@ export class DocumentDetailComponent
titleSubject: Subject<string> = new Subject() titleSubject: Subject<string> = new Subject()
readonly previewUrl = signal<string>(undefined) readonly previewUrl = signal<string>(undefined)
readonly pdfSource = signal<string>(undefined) readonly pdfSource = signal<string>(undefined)
readonly previewRevision = signal(0)
readonly pdfPassword = signal<string>(undefined) readonly pdfPassword = signal<string>(undefined)
readonly thumbUrl = signal<string>(undefined) readonly thumbUrl = signal<string>(undefined)
readonly previewText = signal<string>(undefined) readonly previewText = signal<string>(undefined)
@@ -289,8 +288,6 @@ export class DocumentDetailComponent
private incomingUpdateModal: NgbModalRef private incomingUpdateModal: NgbModalRef
private pendingIncomingUpdate: IncomingDocumentUpdate private pendingIncomingUpdate: IncomingDocumentUpdate
private lastLocalSaveModified: string | null = null private lastLocalSaveModified: string | null = null
private printIframe: HTMLIFrameElement | null = null
private printBlobUrl: string | null = null
requiresPassword: boolean = false requiresPassword: boolean = false
password: string password: string
@@ -612,9 +609,6 @@ export class DocumentDetailComponent
.subscribe() .subscribe()
} }
this.updateComponent(useDoc) this.updateComponent(useDoc)
if (forceRemote) {
this.previewRevision.update((revision) => revision + 1)
}
this.titleSubject this.titleSubject
.pipe( .pipe(
debounceTime(1000), debounceTime(1000),
@@ -659,7 +653,7 @@ export class DocumentDetailComponent
modal.componentInstance.cancelBtnCaption = $localize`Dismiss` modal.componentInstance.cancelBtnCaption = $localize`Dismiss`
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => { modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled = false
modal.close() modal.close()
this.reloadRemoteVersion() this.reloadRemoteVersion()
}) })
@@ -870,7 +864,6 @@ export class DocumentDetailComponent
} }
ngOnDestroy(): void { ngOnDestroy(): void {
this.cleanupPrintDocument()
this.unsubscribeNotifier.next(this) this.unsubscribeNotifier.next(this)
this.unsubscribeNotifier.complete() this.unsubscribeNotifier.complete()
} }
@@ -889,9 +882,13 @@ export class DocumentDetailComponent
updateComponent(doc: Document) { updateComponent(doc: Document) {
this.document.set(doc) this.document.set(doc)
// Default selected version is the newest version, which the API returns first // Default selected version is the newest version
const versions = doc.versions ?? [] const versions = doc.versions ?? []
this.selectedVersionId.set(versions.length ? versions[0].id : doc.id) this.selectedVersionId.set(
versions.length
? Math.max(...versions.map((version) => version.id))
: doc.id
)
this.previewLoaded.set(false) this.previewLoaded.set(false)
this.requiresPassword = false this.requiresPassword = false
this.updateFormForCustomFields() this.updateFormForCustomFields()
@@ -1370,7 +1367,7 @@ export class DocumentDetailComponent
modal.componentInstance.confirmClicked modal.componentInstance.confirmClicked
.pipe( .pipe(
switchMap(() => { switchMap(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled = false
return this.documentsService.delete(this.document()) return this.documentsService.delete(this.document())
}) })
) )
@@ -1382,7 +1379,7 @@ export class DocumentDetailComponent
}, },
error: (error) => { error: (error) => {
this.toastService.showError($localize`Error deleting document`, error) this.toastService.showError($localize`Error deleting document`, error)
modal.componentInstance.buttonsEnabled.set(true) modal.componentInstance.buttonsEnabled = true
this.subscribeModalDelete(modal) this.subscribeModalDelete(modal)
}, },
}) })
@@ -1407,7 +1404,7 @@ export class DocumentDetailComponent
modal.componentInstance.btnClass = 'btn-danger' modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed` modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => { modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled = false
this.documentsService this.documentsService
.reprocessDocuments({ documents: [this.document().id] }) .reprocessDocuments({ documents: [this.document().id] })
.subscribe({ .subscribe({
@@ -1421,7 +1418,7 @@ export class DocumentDetailComponent
}, },
error: (error) => { error: (error) => {
if (modal) { if (modal) {
modal.componentInstance.buttonsEnabled.set(true) modal.componentInstance.buttonsEnabled = true
} }
this.toastService.showError( this.toastService.showError(
$localize`Error executing operation`, $localize`Error executing operation`,
@@ -1437,8 +1434,7 @@ export class DocumentDetailComponent
if (!versions.length || !this.selectedVersionId()) { if (!versions.length || !this.selectedVersionId()) {
return null return null
} }
// The API returns versions newest first const latestVersionId = Math.max(...versions.map((version) => version.id))
const latestVersionId = versions[0].id
return this.selectedVersionId() === latestVersionId return this.selectedVersionId() === latestVersionId
? null ? null
: this.selectedVersionId() : this.selectedVersionId()
@@ -1795,7 +1791,7 @@ export class DocumentDetailComponent
modal.componentInstance.confirmClicked modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => { .subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled = false
this.documentsService this.documentsService
.editPdfDocuments([sourceDocumentId], { .editPdfDocuments([sourceDocumentId], {
operations: modal.componentInstance.getOperations(), operations: modal.componentInstance.getOperations(),
@@ -1818,7 +1814,7 @@ export class DocumentDetailComponent
}, },
error: (error) => { error: (error) => {
if (modal) { if (modal) {
modal.componentInstance.buttonsEnabled.set(true) modal.componentInstance.buttonsEnabled = true
} }
this.toastService.showError( this.toastService.showError(
$localize`Error executing PDF edit operation`, $localize`Error executing PDF edit operation`,
@@ -1852,7 +1848,7 @@ export class DocumentDetailComponent
const sourceDocumentId = this.selectedVersionId() ?? this.document().id const sourceDocumentId = this.selectedVersionId() ?? this.document().id
const dialog = const dialog =
modal.componentInstance as PasswordRemovalConfirmDialogComponent modal.componentInstance as PasswordRemovalConfirmDialogComponent
dialog.buttonsEnabled.set(false) dialog.buttonsEnabled = false
this.networkActive.set(true) this.networkActive.set(true)
this.documentsService this.documentsService
.removePasswordDocuments([sourceDocumentId], { .removePasswordDocuments([sourceDocumentId], {
@@ -1877,7 +1873,7 @@ export class DocumentDetailComponent
} }
}, },
error: (error) => { error: (error) => {
dialog.buttonsEnabled.set(true) dialog.buttonsEnabled = true
this.networkActive.set(false) this.networkActive.set(false)
this.toastService.showError( this.toastService.showError(
$localize`Error executing password removal operation`, $localize`Error executing password removal operation`,
@@ -1889,7 +1885,6 @@ export class DocumentDetailComponent
} }
printDocument() { printDocument() {
this.cleanupPrintDocument()
const selectedVersionId = this.getSelectedNonLatestVersionId() const selectedVersionId = this.getSelectedNonLatestVersionId()
const printUrl = this.documentsService.getDownloadUrl( const printUrl = this.documentsService.getDownloadUrl(
this.document().id, this.document().id,
@@ -1903,8 +1898,6 @@ export class DocumentDetailComponent
next: (blob) => { next: (blob) => {
const blobUrl = URL.createObjectURL(blob) const blobUrl = URL.createObjectURL(blob)
const iframe = document.createElement('iframe') const iframe = document.createElement('iframe')
this.printIframe = iframe
this.printBlobUrl = blobUrl
iframe.style.position = 'fixed' iframe.style.position = 'fixed'
iframe.style.right = '0' iframe.style.right = '0'
iframe.style.bottom = '0' iframe.style.bottom = '0'
@@ -1920,19 +1913,22 @@ export class DocumentDetailComponent
iframe.contentWindow.focus() iframe.contentWindow.focus()
iframe.contentWindow.print() iframe.contentWindow.print()
iframe.contentWindow.onafterprint = () => { iframe.contentWindow.onafterprint = () => {
this.cleanupPrintDocument() document.body.removeChild(iframe)
URL.revokeObjectURL(blobUrl)
} }
} catch (err) { } catch (err) {
// FF throws cross-origin error on onafterprint // FF throws cross-origin error on onafterprint
const isCrossOriginAfterPrintError = const isCrossOriginAfterPrintError =
err instanceof DOMException && err instanceof DOMException &&
err.message.includes('onafterprint') err.message.includes('onafterprint')
// FF throws here while print preview is still reading the iframe
// so keep it alive until the next print or teardown
if (!isCrossOriginAfterPrintError) { if (!isCrossOriginAfterPrintError) {
this.toastService.showError($localize`Print failed.`, err) this.toastService.showError($localize`Print failed.`, err)
timer(100).subscribe(() => this.cleanupPrintDocument())
} }
timer(100).subscribe(() => {
// delay to avoid FF print failure
document.body.removeChild(iframe)
URL.revokeObjectURL(blobUrl)
})
} }
}) })
} }
@@ -1945,20 +1941,9 @@ export class DocumentDetailComponent
}) })
} }
private cleanupPrintDocument() {
if (this.printIframe) this.printIframe.remove()
this.printIframe = null
if (this.printBlobUrl) {
URL.revokeObjectURL(this.printBlobUrl)
this.printBlobUrl = null
}
}
public openShareLinks() { public openShareLinks() {
const modal = this.modalService.open(ShareLinksDialogComponent) const modal = this.modalService.open(ShareLinksDialogComponent)
modal.componentInstance.documentId.set( modal.componentInstance.documentId.set(this.document().id)
this.selectedVersionId() ?? this.document().id
)
modal.componentInstance.hasArchiveVersion.set( modal.componentInstance.hasArchiveVersion.set(
this.metadata()?.has_archive_version ?? this.metadata()?.has_archive_version ??
!!this.document()?.archived_file_name !!this.document()?.archived_file_name
@@ -1,18 +0,0 @@
<div class="modal-header">
<h4 class="modal-title" i18n>Add existing document as version</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button>
</div>
<div class="modal-body">
<pngx-input-document-link
[(ngModel)]="selectedDocumentIDs"
[parentDocumentID]="rootDocumentID"
[minimal]="true"
placeholder="Search for a document"
i18n-placeholder
></pngx-input-document-link>
<div class="form-text mt-2" i18n>Select one document to add as a version.</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" [disabled]="!buttonsEnabled()" i18n>Cancel</button>
<button type="button" class="btn btn-primary" (click)="confirm()" [disabled]="!buttonsEnabled() || selectedDocumentIDs.length !== 1" i18n>Add version</button>
</div>
@@ -1,73 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { DocumentService } from 'src/app/services/rest/document.service'
import { AddExistingDocumentVersionDialogComponent } from './add-existing-document-version-dialog.component'
describe('AddExistingDocumentVersionDialogComponent', () => {
let component: AddExistingDocumentVersionDialogComponent
let fixture: ComponentFixture<AddExistingDocumentVersionDialogComponent>
let activeModal: jest.Mocked<Pick<NgbActiveModal, 'dismiss'>>
beforeEach(async () => {
activeModal = { dismiss: jest.fn() }
await TestBed.configureTestingModule({
imports: [AddExistingDocumentVersionDialogComponent],
providers: [
{
provide: NgbActiveModal,
useValue: activeModal,
},
{
provide: DocumentService,
useValue: {},
},
],
}).compileComponents()
fixture = TestBed.createComponent(AddExistingDocumentVersionDialogComponent)
component = fixture.componentInstance
component.rootDocumentID = 3
fixture.detectChanges()
})
it('should emit the single selected document', () => {
const emitSpy = jest.spyOn(component.confirmClicked, 'emit')
component.selectedDocumentIDs = [20]
component.confirm()
expect(emitSpy).toHaveBeenCalledWith(20)
})
it('should require exactly one selected document', () => {
const emitSpy = jest.spyOn(component.confirmClicked, 'emit')
component.selectedDocumentIDs = [20, 21]
component.confirm()
expect(emitSpy).not.toHaveBeenCalled()
})
it('should dismiss on cancel', () => {
component.cancel()
expect(activeModal.dismiss).toHaveBeenCalled()
})
it('should re-render the buttons when they are toggled from outside', async () => {
const cancelButton: HTMLButtonElement = fixture.nativeElement.querySelector(
'.modal-footer button'
)
expect(cancelButton.disabled).toBeFalsy()
// No detectChanges: the dropdown toggling this from a request callback is
// all that happens, and nothing else schedules a render for the modal
component.buttonsEnabled.set(false)
await fixture.whenStable()
expect(cancelButton.disabled).toBeTruthy()
component.buttonsEnabled.set(true)
await fixture.whenStable()
expect(cancelButton.disabled).toBeFalsy()
})
})
@@ -1,35 +0,0 @@
import {
Component,
EventEmitter,
Input,
Output,
inject,
signal,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { DocumentLinkComponent } from 'src/app/components/common/input/document-link/document-link.component'
@Component({
selector: 'pngx-add-existing-document-version-dialog',
templateUrl: './add-existing-document-version-dialog.component.html',
imports: [DocumentLinkComponent, FormsModule],
})
export class AddExistingDocumentVersionDialogComponent {
private readonly activeModal = inject(NgbActiveModal)
@Input() rootDocumentID: number
@Output() confirmClicked = new EventEmitter<number>()
selectedDocumentIDs: number[] = []
readonly buttonsEnabled = signal(true)
confirm(): void {
if (this.selectedDocumentIDs.length !== 1) return
this.confirmClicked.emit(this.selectedDocumentIDs[0])
}
cancel(): void {
this.activeModal.dismiss()
}
}
@@ -24,26 +24,13 @@
class="visually-hidden" class="visually-hidden"
(change)="onVersionFileSelected($event)" (change)="onVersionFileSelected($event)"
/> />
<div class="btn-group btn-group-sm w-100"> <button
<button class="btn btn-sm btn-outline-secondary w-100"
class="btn btn-sm btn-outline-secondary w-100" (click)="versionFileInput.click()"
(click)="versionFileInput.click()" [disabled]="!userIsOwner || !userCanEdit"
[disabled]="!userIsOwner || !userCanEdit" >
title="Upload a new version" <i-bs name="file-earmark-plus"></i-bs><span class="ps-1" i18n>Add new version</span>
i18n-title </button>
>
<i-bs name="file-earmark-plus"></i-bs><span class="ps-1" i18n>Upload</span>
</button>
<button
class="btn btn-sm btn-outline-secondary w-100"
(click)="addExistingDocumentAsVersion()"
[disabled]="!userIsOwner || !userCanEdit"
title="Use an existing document"
i18n-title
>
<i-bs name="file-earmark"></i-bs><span class="ps-1" i18n>Existing</span>
</button>
</div>
} @else { } @else {
@switch (versionUploadState()) { @switch (versionUploadState()) {
@case (UploadState.Uploading) { @case (UploadState.Uploading) {
@@ -141,7 +128,6 @@
i18n-confirmMessage i18n-confirmMessage
[disabled]="!userIsOwner || !userCanEdit" [disabled]="!userIsOwner || !userCanEdit"
(confirm)="deleteVersion(version.id)" (confirm)="deleteVersion(version.id)"
*pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }"
> >
<span class="visually-hidden" i18n>Delete version</span> <span class="visually-hidden" i18n>Delete version</span>
</pngx-confirm-button> </pngx-confirm-button>
@@ -1,16 +1,9 @@
import { DatePipe } from '@angular/common' import { DatePipe } from '@angular/common'
import { SimpleChange, signal } from '@angular/core' import { SimpleChange } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { Subject, of, throwError } from 'rxjs' import { Subject, of, throwError } from 'rxjs'
import { DocumentVersionInfo } from 'src/app/data/document' import { DocumentVersionInfo } from 'src/app/data/document'
import {
PermissionAction,
PermissionsService,
PermissionType,
} from 'src/app/services/permissions.service'
import { DocumentService } from 'src/app/services/rest/document.service' import { DocumentService } from 'src/app/services/rest/document.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
@@ -26,20 +19,12 @@ describe('DocumentVersionDropdownComponent', () => {
let documentService: jest.Mocked< let documentService: jest.Mocked<
Pick< Pick<
DocumentService, DocumentService,
| 'deleteVersion' 'deleteVersion' | 'getVersions' | 'uploadVersion' | 'updateVersionLabel'
| 'getVersions'
| 'mergeDocumentsAsVersions'
| 'uploadVersion'
| 'updateVersionLabel'
> >
> >
let toastService: jest.Mocked<Pick<ToastService, 'showError' | 'showInfo'>> let toastService: jest.Mocked<Pick<ToastService, 'showError' | 'showInfo'>>
let finished$: Subject<{ taskId: string }> let finished$: Subject<{ taskId: string }>
let failed$: Subject<{ taskId: string; message?: string }> let failed$: Subject<{ taskId: string; message?: string }>
let modalService: jest.Mocked<Pick<NgbModal, 'open'>>
let permissionsService: jest.Mocked<
Pick<PermissionsService, 'currentUserCan'>
>
beforeEach(async () => { beforeEach(async () => {
finished$ = new Subject<{ taskId: string }>() finished$ = new Subject<{ taskId: string }>()
@@ -47,18 +32,13 @@ describe('DocumentVersionDropdownComponent', () => {
documentService = { documentService = {
deleteVersion: jest.fn(), deleteVersion: jest.fn(),
getVersions: jest.fn(), getVersions: jest.fn(),
mergeDocumentsAsVersions: jest.fn(),
uploadVersion: jest.fn(), uploadVersion: jest.fn(),
updateVersionLabel: jest.fn(), updateVersionLabel: jest.fn(),
} }
modalService = { open: jest.fn() }
toastService = { toastService = {
showError: jest.fn(), showError: jest.fn(),
showInfo: jest.fn(), showInfo: jest.fn(),
} }
permissionsService = {
currentUserCan: jest.fn().mockReturnValue(true),
}
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [ imports: [
@@ -81,14 +61,6 @@ describe('DocumentVersionDropdownComponent', () => {
provide: ToastService, provide: ToastService,
useValue: toastService, useValue: toastService,
}, },
{
provide: NgbModal,
useValue: modalService,
},
{
provide: PermissionsService,
useValue: permissionsService,
},
{ {
provide: WebsocketStatusService, provide: WebsocketStatusService,
useValue: { useValue: {
@@ -159,31 +131,6 @@ describe('DocumentVersionDropdownComponent', () => {
) )
}) })
it('should not show version delete buttons without document delete permission', () => {
fixture.destroy()
permissionsService.currentUserCan.mockReturnValue(false)
fixture = TestBed.createComponent(DocumentVersionDropdownComponent)
component = fixture.componentInstance
component.documentId = 3
component.selectedVersionId = 3
component.userIsOwner = true
component.userCanEdit = true
component.versions = [
{ id: 3, is_root: true, checksum: 'aaaa' },
{ id: 10, is_root: false, checksum: 'bbbb' },
]
fixture.detectChanges()
expect(permissionsService.currentUserCan).toHaveBeenCalledWith(
PermissionAction.Delete,
PermissionType.Document
)
expect(
fixture.debugElement.queryAll(By.css('pngx-confirm-button'))
).toHaveLength(0)
})
it('beginEditingVersion should set active row and draft label', () => { it('beginEditingVersion should set active row and draft label', () => {
component.userCanEdit = true component.userCanEdit = true
component.userIsOwner = true component.userIsOwner = true
@@ -275,10 +222,9 @@ describe('DocumentVersionDropdownComponent', () => {
}) })
it('onVersionFileSelected should upload and update versions after websocket success', () => { it('onVersionFileSelected should upload and update versions after websocket success', () => {
// Newest first, as the API returns them
const versions: DocumentVersionInfo[] = [ const versions: DocumentVersionInfo[] = [
{ id: 20, is_root: false, checksum: 'cccc' },
{ id: 3, is_root: true, checksum: 'aaaa' }, { id: 3, is_root: true, checksum: 'aaaa' },
{ id: 20, is_root: false, checksum: 'cccc' },
] ]
const file = new File(['test'], 'new-version.pdf', { const file = new File(['test'], 'new-version.pdf', {
type: 'application/pdf', type: 'application/pdf',
@@ -377,45 +323,4 @@ describe('DocumentVersionDropdownComponent', () => {
expect(component.editingVersionId).toBeNull() expect(component.editingVersionId).toBeNull()
expect(component.versionLabelDraft).toEqual('') expect(component.versionLabelDraft).toEqual('')
}) })
it('addExistingDocumentAsVersion should merge with a label and refresh versions', () => {
const confirmClicked = new Subject<number>()
const modal = {
componentInstance: {
rootDocumentID: null,
buttonsEnabled: signal(true),
confirmClicked,
},
close: jest.fn(),
}
modalService.open.mockReturnValue(modal as any)
documentService.mergeDocumentsAsVersions.mockReturnValue(of({} as any))
// Newest first, as the API returns them. The merged document has a lower id
// than the root, which is the whole point of merging an existing document.
const versions: DocumentVersionInfo[] = [
{ id: 2, is_root: false, checksum: 'cccc' },
{ id: 3, is_root: true, checksum: 'aaaa' },
]
documentService.getVersions.mockReturnValue(of({ id: 3, versions } as any))
component.newVersionLabel = ' Imported '
const versionsEmitSpy = jest.spyOn(component.versionsUpdated, 'emit')
const selectedEmitSpy = jest.spyOn(component.versionSelected, 'emit')
component.addExistingDocumentAsVersion()
expect(modal.componentInstance.rootDocumentID).toEqual(3)
confirmClicked.next(2)
expect(documentService.mergeDocumentsAsVersions).toHaveBeenCalledWith(
[3, 2],
3,
'Imported'
)
expect(documentService.updateVersionLabel).not.toHaveBeenCalled()
expect(documentService.getVersions).toHaveBeenCalledWith(3)
expect(versionsEmitSpy).toHaveBeenCalledWith(versions)
expect(selectedEmitSpy).toHaveBeenCalledWith(2)
expect(component.newVersionLabel).toEqual('')
expect(modal.close).toHaveBeenCalled()
expect(toastService.showInfo).toHaveBeenCalled()
})
}) })

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