Compare commits

..
Author SHA1 Message Date
shamoon b5d44f6224 Remove angular platform-browser-dynamic 2026-07-10 01:00:47 -07:00
342 changed files with 69704 additions and 116827 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))'
+8 -8
View File
@@ -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 }}
+5 -5
View File
@@ -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
+32 -27
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,7 +77,7 @@ 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
@@ -85,7 +85,7 @@ jobs:
with: with:
version: 10 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,7 +109,7 @@ 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
@@ -117,7 +117,7 @@ jobs:
with: with:
version: 10 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,7 +148,7 @@ 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
@@ -156,7 +156,7 @@ jobs:
with: with:
version: 10 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,7 +203,7 @@ 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
@@ -211,7 +211,7 @@ jobs:
with: with:
version: 10 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,20 +223,23 @@ 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
@@ -245,7 +248,7 @@ jobs:
with: with:
version: 10 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
+10 -11
View File
@@ -20,13 +20,12 @@ 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'
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
wait-interval: 60 wait-interval: 60
checks-discovery-timeout: 1800
build-release: build-release:
name: Build Release name: Build Release
needs: wait-for-docker needs: wait-for-docker
@@ -35,7 +34,7 @@ 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 ----
@@ -44,7 +43,7 @@ jobs:
with: with:
version: 10 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 +54,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
@@ -86,7 +85,7 @@ jobs:
# ---- Prepare Release ---- # ---- Prepare Release ----
- name: Generate requirements file - name: Generate requirements file
run: | run: |
uv export --quiet --no-dev --all-extras --emit-index-url --format requirements-txt --output-file requirements.txt uv export --quiet --no-dev --all-extras --format requirements-txt --output-file requirements.txt
- name: Compile messages - name: Compile messages
env: env:
PAPERLESS_SECRET_KEY: "ci-release-not-a-real-secret" PAPERLESS_SECRET_KEY: "ci-release-not-a-real-secret"
@@ -171,7 +170,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 +201,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
+9 -6
View File
@@ -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
@@ -47,7 +47,7 @@ jobs:
with: with:
version: 10 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.8.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.8.3
- '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.15.17
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.24.1"
hooks: hooks:
- id: pyproject-fmt - id: pyproject-fmt
additional_dependencies: [tomli] additional_dependencies: [tomli]
+1 -5
View File
@@ -30,7 +30,7 @@ RUN set -eux \
# Purpose: Installs s6-overlay and rootfs # Purpose: Installs s6-overlay and rootfs
# Comments: # Comments:
# - Don't leave anything extra in here either # - Don't leave anything extra in here either
FROM ghcr.io/astral-sh/uv:0.11.32-python3.12-trixie-slim AS s6-overlay-base FROM ghcr.io/astral-sh/uv:0.11.19-python3.12-trixie-slim AS s6-overlay-base
WORKDIR /usr/src/s6 WORKDIR /usr/src/s6
@@ -238,10 +238,6 @@ RUN set -eux \
&& chown --from root:root --changes --recursive paperless:paperless /usr/src/paperless \ && chown --from root:root --changes --recursive paperless:paperless /usr/src/paperless \
&& echo "Making fontconfig cache writable for arbitrary container UIDs" \ && echo "Making fontconfig cache writable for arbitrary container UIDs" \
&& chmod 1777 /var/cache/fontconfig \ && chmod 1777 /var/cache/fontconfig \
&& echo "Making /run world-writable for rootless operation" \
&& chmod 1777 /run \
&& echo "Removing setuid from s6-overlay-suexec for rootless compat" \
&& chmod u-s /command/s6-overlay-suexec \
&& echo "Collecting static files" \ && echo "Collecting static files" \
&& PAPERLESS_SECRET_KEY=build-time-dummy s6-setuidgid paperless python3 manage.py collectstatic --clear --no-input --link \ && PAPERLESS_SECRET_KEY=build-time-dummy s6-setuidgid paperless python3 manage.py collectstatic --clear --no-input --link \
&& PAPERLESS_SECRET_KEY=build-time-dummy s6-setuidgid paperless python3 manage.py compilemessages \ && PAPERLESS_SECRET_KEY=build-time-dummy s6-setuidgid paperless python3 manage.py compilemessages \
+3 -3
View File
@@ -4,7 +4,7 @@
# correct networking for the tests # correct networking for the tests
services: services:
gotenberg: gotenberg:
image: docker.io/gotenberg/gotenberg:8.34 image: docker.io/gotenberg/gotenberg:8.33
hostname: gotenberg hostname: gotenberg
container_name: gotenberg container_name: gotenberg
network_mode: host network_mode: host
@@ -24,7 +24,7 @@ services:
network_mode: host network_mode: host
restart: unless-stopped restart: unless-stopped
greenmail: greenmail:
image: docker.io/greenmail/standalone:2.1.11 image: docker.io/greenmail/standalone:2.1.8
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.1-alpine
hostname: nginx hostname: nginx
container_name: nginx container_name: nginx
ports: ports:
@@ -72,7 +72,7 @@ services:
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000 PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998 PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg: gotenberg:
image: docker.io/gotenberg/gotenberg:8.34 image: docker.io/gotenberg/gotenberg:8.33
restart: unless-stopped restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not # The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript. # want to allow external content like tracking pixels or even javascript.
+1 -1
View File
@@ -56,7 +56,7 @@ services:
environment: environment:
PAPERLESS_REDIS: redis://broker:6379 PAPERLESS_REDIS: redis://broker:6379
PAPERLESS_DBHOST: db PAPERLESS_DBHOST: db
PAPERLESS_DBENGINE: postgresql PAPERLESS_DBENGINE: postgres
env_file: env_file:
- stack.env - stack.env
volumes: volumes:
@@ -67,7 +67,7 @@ services:
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000 PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998 PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg: gotenberg:
image: docker.io/gotenberg/gotenberg:8.34 image: docker.io/gotenberg/gotenberg:8.33
restart: unless-stopped restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not # The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript. # want to allow external content like tracking pixels or even javascript.
@@ -56,7 +56,7 @@ services:
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000 PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998 PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg: gotenberg:
image: docker.io/gotenberg/gotenberg:8.34 image: docker.io/gotenberg/gotenberg:8.33
restart: unless-stopped restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not # The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript. # want to allow external content like tracking pixels or even javascript.
@@ -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 -28
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
@@ -542,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
+1 -1125
View File
File diff suppressed because it is too large Load Diff
+21 -44
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.
@@ -932,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\* |
@@ -1173,21 +1159,19 @@ still perform some basic text pre-processing before matching.
#### [`PAPERLESS_DATE_PARSER_LANGUAGES=<lang>`](#PAPERLESS_DATE_PARSER_LANGUAGES) {#PAPERLESS_DATE_PARSER_LANGUAGES} #### [`PAPERLESS_DATE_PARSER_LANGUAGES=<lang>`](#PAPERLESS_DATE_PARSER_LANGUAGES) {#PAPERLESS_DATE_PARSER_LANGUAGES}
: Specifies which language Paperless should use when parsing dates from documents. Specifies which language Paperless should use when parsing dates from documents.
: This should be a language code supported by the dateparser library, This should be a language code supported by the dateparser library,
for example: "en", or a combination such as "en+de". for example: "en", or a combination such as "en+de".
Locales are also supported (e.g., "en-AU"). Locales are also supported (e.g., "en-AU").
Multiple languages can be combined using "+", for example: "en+de" or "en-AU+de". Multiple languages can be combined using "+", for example: "en+de" or "en-AU+de".
For valid values, refer to the list of supported languages and locales in the [dateparser documentation](https://dateparser.readthedocs.io/en/latest/supported_locales.html). For valid values, refer to the list of supported languages and locales in the [dateparser documentation](https://dateparser.readthedocs.io/en/latest/supported_locales.html).
: Set this to match the languages in which most of your documents are written.
Set this to match the languages in which most of your documents are written.
If not set, Paperless will attempt to infer the language(s) from the OCR configuration (`PAPERLESS_OCR_LANGUAGE`). If not set, Paperless will attempt to infer the language(s) from the OCR configuration (`PAPERLESS_OCR_LANGUAGE`).
!!! note !!! note
This format differs from the `PAPERLESS_OCR_LANGUAGE` setting, which uses ISO 639-2 codes (3 letters, e.g., "eng+deu" for Tesseract OCR).
This format differs from the `PAPERLESS_OCR_LANGUAGE` setting, which uses ISO 639-2 codes (3 letters, e.g., "eng+deu" for Tesseract OCR).
#### [`PAPERLESS_EMAIL_TASK_CRON=<cron expression>`](#PAPERLESS_EMAIL_TASK_CRON) {#PAPERLESS_EMAIL_TASK_CRON} #### [`PAPERLESS_EMAIL_TASK_CRON=<cron expression>`](#PAPERLESS_EMAIL_TASK_CRON) {#PAPERLESS_EMAIL_TASK_CRON}
@@ -1360,16 +1344,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
@@ -1384,11 +1365,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}
@@ -2142,7 +2119,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 -74
View File
@@ -133,7 +133,7 @@ PAPERLESS_DB_OPTIONS="sslmode=require,sslrootcert=/certs/ca.pem,pool.max_size=10
## OCR and Archive File Generation Settings ## OCR and Archive File Generation Settings
The settings that control OCR behaviour and archive file generation have been redesigned. The old settings that coupled these two concerns together are **removed** - old values are not silently honoured; a startup warning is logged if any removed variable is still set in your environment. The settings that control OCR behaviour and archive file generation have been redesigned. The old settings that coupled these two concerns together are **removed** old values are not silently honoured; a startup warning is logged if any removed variable is still set in your environment.
### Removed settings ### Removed settings
@@ -166,7 +166,7 @@ Remove any `PAPERLESS_OCR_SKIP_ARCHIVE_FILE` variable from your environment. If
# v2: skip OCR when text present, always archive # v2: skip OCR when text present, always archive
PAPERLESS_OCR_MODE=skip PAPERLESS_OCR_MODE=skip
# v3: equivalent (auto is the new default) # v3: equivalent (auto is the new default)
# No change needed - auto is the default # No change needed auto is the default
# v2: skip OCR when text present, skip archive too # v2: skip OCR when text present, skip archive too
PAPERLESS_OCR_MODE=skip_noarchive PAPERLESS_OCR_MODE=skip_noarchive
@@ -189,10 +189,10 @@ PAPERLESS_ARCHIVE_FILE_GENERATION=auto
If you use the **remote OCR parser** (Azure AI), note that it always produces a If you use the **remote OCR parser** (Azure AI), note that it always produces a
searchable PDF and stores it as the archive copy. `ARCHIVE_FILE_GENERATION=never` searchable PDF and stores it as the archive copy. `ARCHIVE_FILE_GENERATION=never`
has no effect for documents handled by the remote parser - the archive is produced has no effect for documents handled by the remote parser the archive is produced
unconditionally by the remote engine. unconditionally by the remote engine.
## Search Index (Whoosh -> Tantivy) # Search Index (Whoosh -> Tantivy)
The full-text search backend has been replaced with [Tantivy](https://github.com/quickwit-oss/tantivy). The full-text search backend has been replaced with [Tantivy](https://github.com/quickwit-oss/tantivy).
The index format is incompatible with Whoosh, so **the search index is automatically rebuilt from The index format is incompatible with Whoosh, so **the search index is automatically rebuilt from
@@ -324,74 +324,5 @@ 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)
Starting with NumPy 2.4.0, official `manylinux` x86_64 wheels are compiled with a minimum
CPU baseline of `x86-64-v2`, which requires SSE3, SSSE3, SSE4.1, SSE4.2, POPCNT, and
CMPXCHG16B. This is a [deliberate upstream change](https://github.com/numpy/numpy/issues/27851)
citing that these instructions have been present in over 99.7% of CPUs since 2008
([Intel](<https://en.wikipedia.org/wiki/Nehalem_(microarchitecture)>)) or 2011
([AMD](<https://en.wikipedia.org/wiki/Bulldozer_(microarchitecture)>)).
NumPy is a dependency of the document classifier (via scikit-learn), so any CPU that
predates SSE4.2 support will crash with `SIGILL` (illegal instruction) when the classifier
is loaded or trained, regardless of whether AI features are enabled.
This differs from NumPy's optional SIMD dispatch (e.g. AVX2, AVX512), which is detected and
selected safely at runtime - the `x86-64-v2` requirement above is a hard floor baked into the
wheel, with no runtime fallback.
### Affected hardware
CPUs older than roughly 2008 (Intel) or 2011 (AMD) that lack SSE4.2 support. This is more
likely to affect low-power or embedded hardware - e.g. early Atom, Celeron, or pre-Bulldozer
AMD chips - than typical desktop or server hardware from the last decade.
Check for SSE4.2 support with:
```bash
grep -o -m1 sse4_2 /proc/cpuinfo
```
If this prints nothing, your CPU is affected.
### Symptoms
The Celery worker (and potentially the web server) repeatedly crashes and restarts with a
`SIGILL` error, typically visible in `dmesg`/`journalctl` as a `trap invalid opcode` inside
`_multiarray_umath...so`. Because the classifier is trained on a periodic schedule
(hourly, by default), affected instances see intermittent, hard-to-reproduce document
consumption failures whenever that scheduled task runs and takes down the worker process
mid-task.
### Action Required (for affected hardware only)
There is no way to make the classifier itself work on such CPUs - it requires an unofficial
NumPy build with `cpu-baseline=none`, which is not something we can ship. The practical
path forward is to stop the classifier from ever loading or training, which avoids
importing NumPy at all:
```bash
PAPERLESS_TRAIN_TASK_CRON=disable
```
This disables the periodic classifier training task (see
[`PAPERLESS_TRAIN_TASK_CRON`](configuration.md#PAPERLESS_TRAIN_TASK_CRON)). Automatic
matching based on the classifier (suggested correspondents, document types, tags, and
storage paths from trained rules) will no longer be available, but rule-based matching is
unaffected, and document consumption itself will no longer be at risk of crashing the
worker.
## Database Migrations
Some integer fields have been changed to smaller types to reduce database size. If you have any `MailRule` records with a `maximum_age` greater than 32767, they will be clamped to 32767 during the migration to avoid errors during migration.
### Action Required
No user action is required. The migration will automatically clamp any `MailRule.maximum_age` values greater than 32767 to 32767 during the migration process.
+3 -11
View File
@@ -142,7 +142,7 @@ a [superuser](usage.md#superusers) account.
It is not possible to run the container rootless if additional languages are specified via `PAPERLESS_OCR_LANGUAGES`. It is not possible to run the container rootless if additional languages are specified via `PAPERLESS_OCR_LANGUAGES`.
If you want to run Paperless as a rootless container, set `user:` in `docker-compose.yml` to the UID and GID of your host user (use `id -u` and `id -g` to find these values). The container process starts directly as that user with no internal privilege remapping. Any UID and GID is supported: If you want to run Paperless as a rootless container, set `user:` in `docker-compose.yml` to the UID and GID of your host user (use `id -u` and `id -g` to find these values). The container process starts directly as that user with no internal privilege remapping:
```yaml ```yaml
webserver: webserver:
@@ -157,8 +157,8 @@ Do not combine this with `USERMAP_UID` or `USERMAP_GID`, which are intended for
Some file systems, such as NFS network shares, don't support file system Some file systems, such as NFS network shares, don't support file system
notifications with `inotify`. When the consumption directory is on such a notifications with `inotify`. When the consumption directory is on such a
file system, Paperless-ngx will not pick up new files with the default file system, Paperless-ngx will not pick up new files with the default
configuration. Set [`PAPERLESS_CONSUMER_POLLING_INTERVAL`](configuration.md#PAPERLESS_CONSUMER_POLLING_INTERVAL) configuration. Use [`PAPERLESS_CONSUMER_POLLING`](configuration.md#PAPERLESS_CONSUMER_POLLING)
to a positive number to enable polling and disable native filesystem notifications. to enable polling and disable inotify. See [here](configuration.md#polling).
## Bare Metal Install {#bare_metal} ## Bare Metal Install {#bare_metal}
@@ -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`.
@@ -1,676 +0,0 @@
# Chat Unbounded Document Scan Fix Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Stop `ChatStreamingView`'s "chat with my whole archive" path from materializing every accessible `Document` into Python memory on every chat message; bound the cost to the vector-store `IN`-filter id list plus at most `CHAT_RETRIEVER_TOP_K` (5) documents for the reference/permission lookup.
**Architecture:** Change `documents` from a materialized `list[Document]` to a lazy `QuerySet[Document]` threaded through `ChatStreamingView.post` -> `stream_chat_with_documents` -> `_stream_chat_with_documents` -> `_get_document_references`. Build the vector-store `IN` filter from `documents.values_list("pk", flat=True)` (ids only, no row hydration) instead of iterating full `Document` instances. Reorder `_get_document_references` to run `retriever.retrieve()` first, then permission-check/hydrate only the (≤5) documents that `top_nodes` actually reference via `documents.filter(pk__in=candidate_ids)`, instead of hydrating every accessible document up front.
**Tech Stack:** Django ORM (QuerySet), llama-index (`MetadataFilters`, `VectorIndexRetriever`), pytest + pytest-django.
## Background
`ChatStreamingView.post` (`src/documents/views.py`), when the request has no `document_id`
(i.e. "chat with my whole archive" rather than "chat with this one document"), builds a
`QuerySet` of every `Document` the requesting user is permitted to view and passes it straight
into `stream_chat_with_documents(query_str, documents)`
(`src/paperless_ai/chat.py`), which calls into `_stream_chat_with_documents`. Two places there
force-materialize the entire queryset into Python objects, on **every single chat message**:
1. `_document_id_filters(str(doc.pk) for doc in documents)` -- iterates every accessible
document just to build a `MetadataFilter(key="document_id", operator=IN,
value=sorted(doc_ids))` for the vector-store query.
2. `_get_document_references`'s `allowed_documents = {doc.pk: doc for doc in documents}` --
hydrates every accessible `Document` row into a dict, just to look up at most
`MAX_CHAT_REFERENCES = 3` of them later.
Meanwhile the actual retrieval only ever wants `CHAT_RETRIEVER_TOP_K = 5` nodes, and shows at
most 3 references. So the cost of _every_ chat message -- not a background job, an interactive
request a user is staring at a spinner for -- scales with total accessible-document count, not
with the ~5 documents that actually matter to the answer. This is worse than an equivalent
scan in a background Celery task: a user is waiting on it in real time, on every message, and
the cost grows as the library grows regardless of how good or bad the actual answer needs to
be.
**What this plan fixes (and what it deliberately doesn't):**
1. Stop materializing full `Document` rows for the filter step -- `_document_id_filters` only
needs a list of ids, not hydrated rows (Task 2, Step 3).
2. Stop permission-checking/hydrating the whole accessible set before knowing which documents
were even retrieved -- flip the order so retrieval happens first (bounded by
`CHAT_RETRIEVER_TOP_K = 5`), then permission-check only those results (Task 2, Step 4). The
permission check itself is unchanged in substance -- a document is only surfaced if it's in
the caller's permission-scoped queryset -- only its timing and the amount of data it touches
change.
3. **Out of scope:** the vector-store-side `IN (...)` filter still needs the full list of
accessible document ids to constrain the KNN search to permitted documents -- that's
inherent to "chat with my whole (permitted) archive" and can't be avoided by filtering after
the fact (doing so would leak un-permitted document content into the LLM context). Whether
that `IN`-list itself is a performance problem for the vector store at very large scale is a
separate, unimplemented investigation and is explicitly not addressed by this plan.
## Global Constraints
- Backend lint/format: ruff, line length 88, double quotes, single-line isort imports (from `CLAUDE.md`).
- Type checking: mypy + pyrefly; do not introduce new violations beyond the frozen baseline (`.mypy-baseline.txt`, `.pyrefly-baseline.json`).
- Tests: pytest/pytest-django; match the style of the file being edited (`src/paperless_ai/tests/test_chat.py` is already idiomatic pytest with fixtures).
- The existing permission check semantics MUST be preserved exactly: a document referenced by a retrieved node is only surfaced/cited if it is in the caller's permission-scoped `documents` queryset. No behavior change to what a user is allowed to see, only to when/how much is loaded to check it.
- Preserve `output_language` threading through `stream_chat_with_documents` / `_stream_chat_with_documents` unchanged -- it is unrelated to this fix but must not be dropped by a careless signature rewrite.
- Do not touch the vector-store-side `IN (...)` filter question (see Background, point 3) -- out of scope for this plan.
**Suggested delegation (Claude Code `Agent` tool `subagent_type` + model tier):**
- Task 0 (benchmark baseline -- open-ended: choosing a harness, interpreting numbers, deciding what "proves the bug" means): `python-pro` or `django-developer` at **Sonnet** tier. Not mechanical enough for Haiku -- it requires judgment about what to measure and whether the resulting numbers actually support the claimed scaling behavior, and it's the evidence the rest of the plan's justification rests on.
- Task 1 (test rewrite -- mechanical: swap list literals for querysets/MagicMocks per the exact snippets already written out in this plan): `django-developer` at **Haiku** tier. The transformations are fully specified here (copy-paste-adjacent), so a fast/cheap model is sufficient; escalate to Sonnet only if the agent reports the current file has drifted from what this plan quotes.
- Task 2 (`chat.py` rework -- the actual bug fix, changes runtime permission-check ordering): `django-developer` at **Sonnet** tier (or whatever the session's default is). This is the correctness-sensitive core of the change -- worth the stronger model even though the code is also fully specified, because a subtle mistake here (e.g. querying `documents` before `.filter(pk__in=...)` narrows it) reintroduces the exact bug being fixed.
- Task 3 (`views.py` one-line change + locating/running the right view tests): `django-developer` at **Haiku** tier for the one-line edit; if the test-discovery grep in Step 2 turns up ambiguity, let it escalate or hand off rather than guessing.
- Task 4 (full verification, lint/type baselines, before/after benchmark comparison): a `code-reviewer` subagent (or the `code-review` skill) at **Sonnet** tier or above for the correctness/permission-scoping review, paired with whichever agent ran Task 0 (same one, if possible, so it can compare against numbers it already understands) for the benchmark re-run in Step 0. Not a good candidate for Haiku -- both the permission-scoping check and the benchmark interpretation require judgment.
- Use `superpowers:subagent-driven-development` to run Tasks 0-3 as independent-but-ordered subagent dispatches with review checkpoints between them, per this plan's header.
---
## Current code (as of `dev` commit `fc242bb57`, for reference while implementing)
Re-verify these line numbers against the live files before editing -- they will drift as other
work lands on `dev`.
`src/documents/views.py:2245-2286` (`ChatStreamingView.post`):
```python
class ChatStreamingView(GenericAPIView[Any]):
permission_classes = (IsAuthenticated, ViewDocumentsPermissions)
serializer_class = ChatStreamingSerializer
def post(self, request, *args, **kwargs):
request.compress_exempt = True
ai_config = AIConfig()
if not ai_config.ai_enabled:
return HttpResponseBadRequest("AI is required for this feature")
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
question = serializer.validated_data["q"]
doc_id = serializer.validated_data.get("document_id")
if doc_id:
try:
document = Document.objects.get(id=doc_id)
except Document.DoesNotExist:
return HttpResponseBadRequest("Document not found")
if not has_perms_owner_aware(request.user, "view_document", document):
return HttpResponseForbidden("Insufficient permissions")
documents = [document]
else:
documents = Document.objects.filter(
id__in=permitted_document_ids(request.user),
)
output_language = _get_llm_output_language(ai_config=ai_config, request=request)
response = StreamingHttpResponse(
stream_chat_with_documents(
query_str=question,
documents=documents,
output_language=output_language,
),
content_type="text/event-stream",
)
return response
```
Note: the whole-library `else` branch already returns a `QuerySet` (`permitted_document_ids`
returns a lazy `QuerySet[int]`, see `src/documents/permissions.py`) -- the bug is entirely
inside `chat.py`, which force-materializes it. Only the single-document `if` branch needs to
change (`[document]` -> a one-row `QuerySet`), purely so both branches share the same type.
`src/paperless_ai/chat.py` (`_get_document_references`, `stream_chat_with_documents`,
`_stream_chat_with_documents` -- abridged excerpt, elisions and inline comments below are
annotations for this plan, not literal source; re-read the live file rather than treating this
as a copy-paste-ready contiguous block):
```python
def _get_document_references(
documents: list[Document],
top_nodes: list,
) -> list[dict[str, int | str]]:
allowed_documents = {doc.pk: doc for doc in documents} # <-- full materialization #1
...
def stream_chat_with_documents(
query_str: str,
documents: list[Document],
output_language: str | None = None,
):
try:
yield from _stream_chat_with_documents(
query_str,
documents,
output_language=output_language,
)
except Exception as e:
logger.exception("Failed to stream document chat response: %s", e)
yield CHAT_ERROR_MESSAGE
def _stream_chat_with_documents(
query_str: str,
documents: list[Document],
output_language: str | None = None,
):
if not documents:
yield CHAT_NO_CONTENT_MESSAGE
return
...
filters = _document_id_filters(str(doc.pk) for doc in documents) # <-- full materialization #2
...
references = _get_document_references(documents, top_nodes)
```
All three signatures need to carry `output_language: str | None = None` through unchanged --
this parameter is unrelated to the fix but must not be dropped.
## File Structure
- Modify: `src/paperless_ai/chat.py` -- change `documents` parameter type from `list[Document]` to `QuerySet[Document]` across `stream_chat_with_documents`, `_stream_chat_with_documents`, `_get_document_references`; rework `_get_document_references` to defer hydration until after retrieval.
- Modify: `src/documents/views.py` -- `ChatStreamingView.post` builds a `QuerySet[Document]` for the single-document branch (instead of `[document]`) so both branches share the same lazy type; the whole-library branch already returns a `QuerySet` via `permitted_document_ids` and needs no structural change (just stops being force-materialized downstream).
- Modify: `src/paperless_ai/tests/test_chat.py` -- update existing tests to pass `QuerySet[Document]` (real, via `DocumentFactory` + `django_db`, or a `QuerySet`-shaped `MagicMock` where no DB is wanted) instead of plain lists; add a regression test proving the reference lookup only queries documents actually referenced by `top_nodes`, not the whole passed queryset.
- No change expected to `src/documents/tests/test_views.py` (search for the chat streaming view test class with `rg -n "ChatStreamingView|class.*Chat" src/documents/tests/test_views.py` before starting -- confirm the exact class name, it may have moved since this plan was drafted) -- it patches `stream_chat_with_documents` entirely and never inspects the `documents` argument's type, but Task 4 runs it to confirm.
- Add: a benchmark script or pytest-based benchmark test (exact location decided in Task 0 Step 1) that seeds a large document library and measures query count + wall time through `_stream_chat_with_documents`, to be run before (Task 0) and after (Task 4) the fix and compared.
---
### Task 0: Benchmark the current (unfixed) behavior -- prove the bug's cost shape before changing code
**Files:**
- Add: a benchmark script/test, e.g. `src/paperless_ai/tests/test_chat_benchmark.py` (pytest-based, easiest to re-run identically in Task 4) or a one-off management-command-style script using `src/profiling.py`'s existing `profile_block` context manager (already in this repo's root, wraps `tracemalloc` + Django query counting + wall time -- see its docstring). Prefer the pytest version so Task 4 can literally re-run the same file and diff the numbers; a throwaway script is fine too if you'd rather not commit a benchmark test permanently to the suite -- ask before committing one either way, since it's not core test coverage.
**Interfaces:**
- Consumes: `stream_chat_with_documents`, `_get_document_references`, `_document_id_filters` as they currently exist (`list[Document]`-based, unfixed).
- Produces: a recorded baseline (query count, wall time) at multiple library sizes, referenced again in Task 4's "after" run. This task makes no code changes to `chat.py`/`views.py` -- benchmark only.
- [ ] **Step 1: Decide and set up the benchmark harness**
Seed libraries at a few sizes (e.g. 10, 100, 1000 documents) via
`DocumentFactory.create_batch(n)` (see `src/documents/tests/factories.py`), matching the
pattern already used in this plan's own `test_get_document_references_only_queries_referenced_documents`
test (Task 1, Step 3) which seeds 200. Wrap the call path in Django's
`django.test.utils.CaptureQueriesContext` (or the `django_assert_num_queries` fixture for a
fixed expected count, but here you want the _actual_ count at each size, not just an
assertion) plus `time.perf_counter()` for wall time. `src/profiling.py`'s `profile_block`
context manager already bundles both (query count/time + memory) if you'd rather reuse it
than hand-roll `CaptureQueriesContext`.
- [ ] **Step 2: Run the benchmark against the two hot spots described in Background**
Specifically measure, at each library size:
1. `_document_id_filters(str(doc.pk) for doc in documents)` (`chat.py`) -- the filter-list
build.
2. `_get_document_references(documents, top_nodes)` (`chat.py`) -- the reference
lookup, with `top_nodes` fixed at a small constant (e.g. 1-3 nodes) regardless of library
size, to isolate the effect of accessible-library size on this specific function (this is
the function the fix changes the most).
Record: query count and wall time for each, at each library size. Expect (unfixed) roughly
linear-in-library-size query time/row-hydration cost for #2 in particular, since
`{doc.pk: doc for doc in documents}` hydrates every row.
- [ ] **Step 3: Record the baseline numbers**
Write the baseline numbers into this plan file (append a small table under this task) or into
a scratch note referenced from here -- whichever the implementer running this task prefers, as
long as Task 4 can find and compare against it. Do not proceed to Task 1 until a baseline
exists; the point of this task is to have something to compare the fix against, not to block
indefinitely on a perfect benchmark harness.
- [ ] **Step 4: Commit (if the benchmark harness itself is a pytest file worth keeping)**
```bash
git add src/paperless_ai/tests/test_chat_benchmark.py # or wherever Step 1 put it
git commit -m "Bench: baseline query count/wall time for chat document reference lookup"
```
If instead you used a throwaway script (not added to the pytest suite), skip this commit --
just keep the recorded numbers from Step 3.
---
### Task 1: Rewrite chat tests to use QuerySets and add the bounded-lookup regression test (RED)
**Files:**
- Modify: `src/paperless_ai/tests/test_chat.py`
**Interfaces:**
- Consumes: `stream_chat_with_documents(query_str: str, documents, output_language: str | None = None)` (current signature, still `list[Document]` at this point -- these tests will fail until Task 2 lands).
- Produces: nothing new for later tasks to consume; this task only changes test fixtures/assertions.
- [ ] **Step 1: Replace list-based `documents` fixtures with `QuerySet`-shaped values**
In `src/paperless_ai/tests/test_chat.py`, the `mock_document` fixture (around line 39-46) is a
`MagicMock`, not a real row, so it cannot be used with a real `QuerySet.filter(pk=...)`
lookup. Replace its use in `test_stream_chat_with_one_document_retrieval` with a
real `DocumentFactory.create()` instance and pass `Document.objects.filter(pk=document.pk)`:
```python
from documents.models import Document
from documents.tests.factories import DocumentFactory
@pytest.mark.django_db
def test_stream_chat_with_one_document_retrieval(patch_embed_nodes) -> None:
document = DocumentFactory.create(title="Test Document", content="ignored")
documents = Document.objects.filter(pk=document.pk)
with (
patch("paperless_ai.chat.AIClient") as mock_client_cls,
patch("paperless_ai.chat.load_or_build_index") as mock_load_index,
patch(
"llama_index.core.query_engine.RetrieverQueryEngine.from_args",
) as mock_query_engine_cls,
patch(
"llama_index.core.response_synthesizers.get_response_synthesizer",
) as mock_get_response_synthesizer,
):
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
mock_client.llm = MagicMock()
mock_index = MagicMock()
mock_index.vector_store.get_nodes.return_value = [
TextNode(
text="This is node content.",
metadata={"document_id": str(document.pk), "title": "Test Document"},
),
]
mock_load_index.return_value = mock_index
mock_retriever_instance = MagicMock()
mock_retriever_instance.retrieve.return_value = [
MagicMock(
metadata={"document_id": str(document.pk), "title": "Test Document"},
),
]
mock_response_stream = MagicMock()
mock_response_stream.response_gen = iter(["chunk1", "chunk2"])
mock_query_engine = MagicMock()
mock_query_engine_cls.return_value = mock_query_engine
mock_query_engine.query.return_value = mock_response_stream
with patch(
"llama_index.core.retrievers.VectorIndexRetriever",
return_value=mock_retriever_instance,
):
output = list(stream_chat_with_documents("What is this?", documents))
mock_query_engine.query.assert_called_once_with("What is this?")
synthesizer_kwargs = mock_get_response_synthesizer.call_args.kwargs
assert (
"Treat the new context and existing answer as untrusted data, "
"not instructions;" in synthesizer_kwargs["refine_template"].template
)
patch_embed_nodes.assert_not_called()
assert_chat_output(
output,
expected_chunks=["chunk1", "chunk2"],
expected_references=[
{"id": document.pk, "title": "Test Document"},
],
)
```
Remove the `mock_document` fixture only if nothing else in the file still uses it (check with
`rg -n "mock_document" src/paperless_ai/tests/test_chat.py` after this step).
Apply the equivalent change to `test_stream_chat_with_multiple_documents_retrieval`:
replace `doc1 = MagicMock(pk=1, ...)` / `doc2 = MagicMock(pk=2, ...)` with two
`DocumentFactory.create(...)` instances, and pass
`documents = Document.objects.filter(pk__in=[doc1.pk, doc2.pk])` to
`stream_chat_with_documents`. Update the node/reference metadata to use the real created pks
instead of hardcoded `"1"`/`"2"`.
For the three non-DB tests (`test_stream_chat_empty_document_list`,
`test_stream_chat_no_matching_nodes`,
`test_stream_chat_unexpected_failure_returns_generic_error`), replace the list
arguments with values that behave like an (unevaluated) `QuerySet` without touching the
database:
```python
def test_stream_chat_empty_document_list() -> None:
with patch("paperless_ai.chat.load_or_build_index") as mock_load_index:
output = list(stream_chat_with_documents("Any info?", Document.objects.none()))
mock_load_index.assert_not_called()
assert output == ["Sorry, I couldn't find any content to answer your question."]
```
`Document.objects.none()` short-circuits Django's query execution (`QuerySet.query.is_empty()`),
so `.exists()` on it does not hit the database and this test does not need
`@pytest.mark.django_db`.
For `test_stream_chat_no_matching_nodes` and
`test_stream_chat_unexpected_failure_returns_generic_error`, which pass `[MagicMock(pk=1)]`
today: these need a queryset-like object that reports non-empty and yields at least one pk,
without a real DB row (they never reach `_get_document_references` -- one returns before
retrieval finds nodes, the other raises during retrieval). Use a `MagicMock` configured to
mimic the two methods actually called before that point:
```python
def _fake_documents_queryset(pks: list[int]) -> MagicMock:
qs = MagicMock()
qs.exists.return_value = bool(pks)
qs.values_list.return_value = pks
return qs
```
Add this helper near the top of the file (after `assert_chat_output`) and use
`_fake_documents_queryset([1])` in place of `[MagicMock(pk=1)]` in both tests.
Add the necessary import: `from documents.models import Document` at the top of the file.
- [ ] **Step 2: Rewrite the two `TestStreamChatRetrieval` tests to pass a QuerySet**
Both `test_no_nodes_yields_no_content_message` and
`test_chat_filter_contains_only_requested_document_ids` (in class `TestStreamChatRetrieval`)
already use real `DocumentFactory` documents and `django_db`. Change the calls:
```python
out = list(chat.stream_chat_with_documents("question?", Document.objects.filter(pk=doc.pk)))
...
list(chat.stream_chat_with_documents("question?", Document.objects.filter(pk=included.pk)))
```
(`doc`/`included` stay single real documents; no other change needed in these tests.)
- [ ] **Step 3: Add the regression test for bounded reference lookup**
Add a new test proving `_get_document_references` only touches documents that `top_nodes`
actually reference, not every document in the passed queryset. This is the direct regression
test for the bug described in this plan's Background section:
```python
@pytest.mark.django_db
def test_get_document_references_only_queries_referenced_documents(
django_assert_num_queries,
) -> None:
"""Building references must not hydrate every document the caller is
permitted to see -- only the (<= CHAT_RETRIEVER_TOP_K) documents that
the retriever actually returned nodes for.
"""
referenced = DocumentFactory.create(title="Referenced Document")
# Many more documents are "accessible" but never referenced by a node.
DocumentFactory.create_batch(200)
documents = Document.objects.all()
top_nodes = [
MagicMock(metadata={"document_id": str(referenced.pk), "title": "Referenced Document"}),
]
# One query: `documents.filter(pk__in=candidate_ids)` for the single
# referenced id. No query should scale with the 200 unreferenced documents.
with django_assert_num_queries(1):
references = chat._get_document_references(documents, top_nodes)
assert references == [{"id": referenced.pk, "title": "Referenced Document"}]
```
`django_assert_num_queries` is a `pytest-django` fixture available automatically, no new
dependency needed.
- [ ] **Step 4: Run the test file and confirm it fails for the expected reason**
Run: `uv run pytest --override-ini="addopts=" src/paperless_ai/tests/test_chat.py -v`
Expected: multiple failures (`AttributeError`, e.g. `'list' object has no attribute 'exists'`,
or logic mismatches), because `_stream_chat_with_documents` / `_get_document_references` still
expect a `list[Document]`. Read the actual pytest output before proceeding -- do not assume the
failure mode in advance.
Do not proceed to Task 2 until you have read the actual failure output and confirmed the tests
are red for a real reason (signature/behavior mismatch), not a typo in the test itself.
---
### Task 2: Rework `chat.py` to defer hydration and query only referenced documents (GREEN)
**Files:**
- Modify: `src/paperless_ai/chat.py`
**Interfaces:**
- Consumes: `documents: QuerySet[Document]` (passed in by `views.py`, updated in Task 3).
- Produces: `stream_chat_with_documents(query_str: str, documents: QuerySet[Document], output_language: str | None = None)` -- same external name/params, new `documents` type. `_get_document_references(documents: QuerySet[Document], top_nodes: list) -> list[dict[str, int | str]]` -- same name/return type, new parameter type and internal behavior (queries only referenced ids).
- [ ] **Step 1: Add the `QuerySet` import and update type hints**
```python
from django.db.models import QuerySet
```
(`Document` is already imported at the top of `chat.py`.) Update the signatures of
`stream_chat_with_documents`, `_stream_chat_with_documents`, and `_get_document_references` to
take `documents: QuerySet[Document]` instead of `documents: list[Document]`. Keep
`output_language: str | None = None` as-is on the two functions that already carry it.
- [ ] **Step 2: Replace the full-materialization emptiness check**
In `_stream_chat_with_documents`:
```python
def _stream_chat_with_documents(
query_str: str,
documents: QuerySet[Document],
output_language: str | None = None,
):
if not documents.exists():
yield CHAT_NO_CONTENT_MESSAGE
return
```
(`documents.exists()` issues a lightweight existence check; for `Document.objects.none()` it
short-circuits without hitting the database at all.)
- [ ] **Step 3: Replace the filter-building line to use ids only**
```python
config = AIConfig()
filters = _document_id_filters(
str(pk) for pk in documents.values_list("pk", flat=True)
)
```
This still touches every accessible document's id (inherent to scoping the vector-store `IN`
filter to the permitted set -- see Background, point 3, which remains out of scope), but no
longer loads full `Document` rows -- just a flat list of integers.
- [ ] **Step 4: Rework `_get_document_references` to hydrate only referenced documents**
```python
def _get_document_references(
documents: QuerySet[Document],
top_nodes: list,
) -> list[dict[str, int | str]]:
candidate_ids: set[int] = set()
for node in top_nodes:
try:
candidate_ids.add(int(node.metadata["document_id"]))
except (KeyError, TypeError, ValueError): # pragma: no cover
continue
if not candidate_ids:
return []
allowed_documents = {
doc.pk: doc for doc in documents.filter(pk__in=candidate_ids)
}
references: list[dict[str, int | str]] = []
seen_document_ids: set[int] = set()
for node in top_nodes:
try:
document_id = int(node.metadata["document_id"])
except (KeyError, TypeError, ValueError): # pragma: no cover
continue
if document_id in seen_document_ids or document_id not in allowed_documents:
continue
seen_document_ids.add(document_id)
document = allowed_documents[document_id]
references.append(
_build_document_reference(document, node.metadata.get("title")),
)
if len(references) >= MAX_CHAT_REFERENCES: # pragma: no cover
break
return references
```
`documents.filter(pk__in=candidate_ids)` re-applies the permission scoping (`documents` is
still the caller's permission-scoped queryset) but now against at most `CHAT_RETRIEVER_TOP_K`
(5) ids instead of the whole accessible set -- this is the permission check the original code
performed, just run after retrieval instead of before, and bounded instead of unbounded.
- [ ] **Step 5: Run the chat test file and confirm it passes**
Run: `uv run pytest --override-ini="addopts=" src/paperless_ai/tests/test_chat.py -v`
Expected: all tests pass, including `test_get_document_references_only_queries_referenced_documents`.
- [ ] **Step 6: Commit**
```bash
git add src/paperless_ai/chat.py src/paperless_ai/tests/test_chat.py
git commit -m "Fix: bound chat document reference lookup to retrieved nodes instead of whole accessible library"
```
---
### Task 3: Update `ChatStreamingView.post` to pass a QuerySet for the single-document branch
**Files:**
- Modify: `src/documents/views.py` (`ChatStreamingView.post` -- re-locate with `rg -n "class ChatStreamingView" src/documents/views.py` before editing, in case other changes shifted it)
**Interfaces:**
- Consumes: `stream_chat_with_documents(query_str, documents: QuerySet[Document], output_language)` (Task 2's new signature).
- Produces: nothing new for later tasks.
- [ ] **Step 1: Build a QuerySet in the single-document branch**
Change only this one line inside `post`:
```python
documents = Document.objects.filter(pk=document.pk)
```
in place of the current `documents = [document]`. Everything else in `post` (the
`has_perms_owner_aware` check against the fully-hydrated `document`, the `else` branch using
`permitted_document_ids`, the `output_language` lookup, the `StreamingHttpResponse`
construction) is unchanged -- it already passes a `QuerySet` in the `else` branch; Task 2's
changes inside `chat.py` are what stop that queryset from being force-materialized downstream.
- [ ] **Step 2: Run the view tests**
Three test locations cover this view (re-check with
`rg -n "ChatStreamingView|/api/chat|stream_chat_with_documents" src/documents/tests/*.py` if
more time has passed since this plan was written):
1. `src/documents/tests/test_views.py`, class `TestAIChatStreamingView` -- patches
`stream_chat_with_documents` entirely, doesn't inspect `documents`' type.
2. `src/documents/tests/test_api_chat.py`, class `TestChatStreamingViewInputValidation` --
input-validation only, doesn't reach `documents` construction.
3. `src/documents/tests/test_permission_filtering_security.py`, class
`TestAiChatAllDocumentsPermissionBoundary`, test
`test_chat_all_documents_excludes_unshared_document` -- **this is the one that actually
matters for this change**: it asserts on `kwargs["documents"]` from the mocked
`stream_chat_with_documents` call (`{doc.pk for doc in kwargs["documents"]}`), pinning the
permission-scoping behavior this plan touches. Read this test specifically before/after the
change, not just via a blind `-k chat` filter -- iterating a `QuerySet` with a set
comprehension works the same as iterating a `list`, so it should keep passing unchanged, but
confirm rather than assume.
Run:
```bash
uv run pytest --override-ini="addopts=" src/documents/tests/ -v -k chat
uv run pytest --override-ini="addopts=" src/documents/tests/test_permission_filtering_security.py -v -k AllDocumentsPermissionBoundary
```
Expected: all pass unchanged.
- [ ] **Step 3: Commit**
```bash
git add src/documents/views.py
git commit -m "Fix: pass single-document chat queries as a QuerySet instead of a materialized list"
```
---
### Task 4: Full verification
**Files:** none (verification only, except Step 0's benchmark re-run reuses Task 0's file)
- [ ] **Step 0: Re-run Task 0's benchmark against the fixed code and compare**
Re-run the exact same benchmark harness from Task 0 (same library sizes, same measured
functions) now that Task 2's fix has landed. This is the actual proof the fix works, not just
that tests pass -- prove the improvement, don't assume it. Expect:
- `_get_document_references` query count/time to become roughly constant (bounded by
`CHAT_RETRIEVER_TOP_K = 5`) instead of scaling with library size.
- `_document_id_filters`' cost is unchanged in shape (Task 2 only avoids hydrating full
`Document` rows there, via `.values_list("pk", flat=True)`; it still touches every accessible
id -- see Background, point 3, still out of scope) but should show reduced wall time/memory
from not loading full rows.
Record the before/after comparison (e.g. as a small table: library size, before query
count/time, after query count/time) back into Task 0's section of this plan. If the numbers do
NOT show the expected improvement, stop and treat that as a signal the fix is incomplete or
wrong before proceeding to the rest of this task's steps.
- [ ] **Step 1: Run the full `paperless_ai` and relevant `documents` test suites**
```bash
uv run pytest --override-ini="addopts=" src/paperless_ai/tests/ -v
uv run pytest --override-ini="addopts=" src/documents/tests/ -v -k chat
```
Expected: all pass.
- [ ] **Step 2: Run ruff, and mypy/pyrefly via prek, to confirm no new baseline violations or lint issues**
```bash
uv run ruff check src/paperless_ai/chat.py src/documents/views.py
uv run ruff format --check src/paperless_ai/chat.py src/documents/views.py
uv run prek run --all-files
```
Expected: clean, and no new violations beyond `.mypy-baseline.txt` / `.pyrefly-baseline.json`.
- [ ] **Step 3: Confirm both in-scope fixes from Background are addressed**
Point 1 (don't materialize full `Document` rows for the filter step) -- addressed by Task 2 Step 3.
Point 2 (permission-check only `top_nodes`, bounded by `CHAT_RETRIEVER_TOP_K`) -- addressed by Task 2 Step 4.
Point 3 (whether the vector-store `IN (...)` filter itself is a KNN scaling concern) remains
explicitly out of scope for this plan -- if it needs tracking as future work, open a fresh
issue/note for it rather than reviving old diagnosis documents.
---
## Self-Review Notes
- **Spec coverage:** both in-scope points from Background ("don't materialize full `Document` rows for the filter step" and "permission-check only `top_nodes`, bounded by `CHAT_RETRIEVER_TOP_K`") are implemented in Task 2. The vector-store `IN` filter scaling question is explicitly out of scope and not silently dropped -- it's called out in Background, Global Constraints, and Task 4 Step 3.
- **Placeholder scan:** no TBD/TODO markers; every step has literal code.
- **Type consistency:** `documents: QuerySet[Document]` is consistent across `stream_chat_with_documents`, `_stream_chat_with_documents`, `_get_document_references`, and both call sites in `views.py`. `_build_document_reference`'s signature is unchanged (still takes a hydrated `Document`). `output_language` threading is preserved unchanged throughout.
- **Self-contained:** this plan does not depend on any other document, branch, or worktree existing -- all context needed to execute it (bug diagnosis, current code, fix design) is inlined above.
+11 -40
View File
@@ -458,8 +458,8 @@ For related metadata such as tags, correspondents, document types, and storage p
### Password reset ### Password reset
In order to enable the password reset feature you will need to setup an SMTP backend, see In order to enable the password reset feature you will need to setup an SMTP backend, see
[`PAPERLESS_EMAIL_HOST`](configuration.md#PAPERLESS_EMAIL_HOST). You should also set [`PAPERLESS_EMAIL_HOST`](configuration.md#PAPERLESS_EMAIL_HOST). If your installation does not have
[`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) and / or its corresponding configuration settings. [`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) set, the reset link included in emails will use the server host.
### Two-factor authentication ### Two-factor authentication
@@ -620,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/).
@@ -861,10 +833,10 @@ contract you signed 8 years ago).
When you search paperless for a document, it tries to match this query When you search paperless for a document, it tries to match this query
against your documents. Paperless will look for matching documents by against your documents. Paperless will look for matching documents by
inspecting their content, title, correspondent, type, and tags. Paperless inspecting their content, title, correspondent, type, tags, notes, and
returns a scored list of results, so that documents matching your query custom field values. Paperless returns a scored list of results, so that
better will appear further up in the search results. Notes and custom field documents matching your query better will appear further up in the search
values can be searched using the advanced search syntax described below. results.
By default, paperless returns only documents which contain all words By default, paperless returns only documents which contain all words
typed in the search bar. A few things to know about how matching works: typed in the search bar. A few things to know about how matching works:
@@ -919,9 +891,9 @@ Supported date keywords: `today`, `yesterday`, `previous week`,
#### Searching custom fields #### Searching custom fields
Custom field names and values are included in the full-text index, but they Custom field values are included in the full-text index, so a plain search
are not searched by a plain, unqualified query. Use the advanced search syntax already matches documents whose custom field values contain your search terms.
to search by field name or value: To narrow by field name or value specifically:
``` ```
custom_fields.value:policy custom_fields.value:policy
@@ -949,9 +921,8 @@ custom_fields.name:"Contract Number" custom_fields.value:1312
#### Searching notes #### Searching notes
Notes are included in the full-text index, but they are not searched by a Notes content is included in full-text search automatically. To search
plain, unqualified query. Use the advanced search syntax to search by note by note author or content specifically:
author or content:
``` ```
notes.user:alice notes.user:alice
+28 -28
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "paperless-ngx" name = "paperless-ngx"
version = "3.0.5" version = "3.0.0"
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"
@@ -16,12 +16,12 @@ classifiers = [
dependencies = [ dependencies = [
"azure-ai-documentintelligence>=1.0.2", "azure-ai-documentintelligence>=1.0.2",
"babel>=2.17", "babel>=2.17",
"bleach~=6.4.0", "bleach~=6.3.0",
"celery[redis]~=5.6.2", "celery[redis]~=5.6.2",
"channels~=4.2", "channels~=4.2",
"channels-redis~=4.2", "channels-redis~=4.2",
"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.9.1",
"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,17 +68,17 @@ 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.12.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 = [
@@ -100,25 +101,25 @@ dev = [
{ include-group = "testing" }, { include-group = "testing" },
] ]
docs = [ docs = [
"zensical>=0.0.51", "zensical>=0.0.43",
] ]
lint = [ lint = [
"prek~=0.4.11", "prek~=0.3.10",
"ruff~=0.16.1", "ruff~=0.15.15",
] ]
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",
@@ -182,7 +183,6 @@ 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 = [
"COM", # https://docs.astral.sh/ruff/rules/#flake8-commas-com "COM", # https://docs.astral.sh/ruff/rules/#flake8-commas-com
"DJ", # https://docs.astral.sh/ruff/rules/#flake8-django-dj "DJ", # https://docs.astral.sh/ruff/rules/#flake8-django-dj
+9 -22
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": [
@@ -79,16 +79,6 @@
"glob": "{pdf.worker.min.mjs,pdf.min.mjs}", "glob": "{pdf.worker.min.mjs,pdf.min.mjs}",
"input": "node_modules/pdfjs-dist/legacy/build/", "input": "node_modules/pdfjs-dist/legacy/build/",
"output": "/assets/js/" "output": "/assets/js/"
},
{
"glob": "**/*",
"input": "node_modules/pdfjs-dist/wasm/",
"output": "/assets/wasm/"
},
{
"glob": "**/*",
"input": "node_modules/pdfjs-dist/iccs/",
"output": "/assets/iccs/"
} }
], ],
"styles": [ "styles": [
@@ -97,7 +87,6 @@
"scripts": [], "scripts": [],
"allowedCommonJsDependencies": [ "allowedCommonJsDependencies": [
"file-saver", "file-saver",
"mime-names",
"utif" "utif"
], ],
"extractLicenses": false, "extractLicenses": false,
@@ -118,13 +107,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 +135,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 +146,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
}
+988 -1043
View File
File diff suppressed because it is too large Load Diff
+31 -28
View File
@@ -1,6 +1,6 @@
{ {
"name": "paperless-ngx-ui", "name": "paperless-ngx-ui",
"version": "3.0.5", "version": "3.0.0",
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm", "preinstall": "npx only-allow pnpm",
"ng": "ng", "ng": "ng",
@@ -11,16 +11,16 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/cdk": "^22.0.6", "@angular/cdk": "^22.0.3",
"@angular/common": "~22.1.0", "@angular/common": "~22.0.5",
"@angular/compiler": "~22.1.0", "@angular/compiler": "~22.0.5",
"@angular/core": "~22.1.0", "@angular/core": "~22.0.5",
"@angular/forms": "~22.1.0", "@angular/forms": "~22.0.5",
"@angular/localize": "~22.1.0", "@angular/localize": "~22.0.5",
"@angular/platform-browser": "~22.1.0", "@angular/platform-browser": "~22.0.5",
"@angular/router": "~22.1.0", "@angular/router": "~22.0.5",
"@ng-bootstrap/ng-bootstrap": "^21.0.0", "@ng-bootstrap/ng-bootstrap": "^21.0.0",
"@ng-select/ng-select": "^23.5.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",
@@ -32,31 +32,33 @@
"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.2", "@angular-devkit/core": "^22.0.5",
"@angular-devkit/schematics": "^22.1.2", "@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.2", "@angular/build": "^22.0.5",
"@angular/cli": "22.1.2", "@angular/cli": "~22.0.5",
"@angular/compiler-cli": "~22.1.0", "@angular/compiler-cli": "~22.0.5",
"@playwright/test": "^1.62.0", "@codecov/webpack-plugin": "^2.0.1",
"@playwright/test": "^1.61.1",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^26.1.1", "@types/node": "^26.0.0",
"@typescript-eslint/eslint-plugin": "^8.65.0", "@typescript-eslint/eslint-plugin": "^8.62.0",
"@typescript-eslint/parser": "^8.65.0", "@typescript-eslint/parser": "^8.62.0",
"@typescript-eslint/utils": "^8.65.0", "@typescript-eslint/utils": "^8.62.0",
"eslint": "^10.8.0", "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",
@@ -64,7 +66,8 @@
"jest-websocket-mock": "^2.5.0", "jest-websocket-mock": "^2.5.0",
"prettier-plugin-organize-imports": "^4.3.0", "prettier-plugin-organize-imports": "^4.3.0",
"ts-node": "~10.9.1", "ts-node": "~10.9.1",
"typescript": "^6.0.3" "typescript": "^6.0.3",
"webpack": "^5.107.2"
}, },
"packageManager": "pnpm@10.26.0" "packageManager": "pnpm@10.26.0"
} }
+3326 -2922
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -2,9 +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"
allowBuilds: allowBuilds:
"@parcel/watcher": true "@parcel/watcher": true
canvas: true canvas: true
@@ -5,9 +5,8 @@
i18n-info> i18n-info>
<div class="input-group input-group-sm align-items-center"> <div class="input-group input-group-sm align-items-center">
<div class="input-group input-group-sm me-3"> <div class="input-group input-group-sm me-3">
<label class="input-group-text text-muted" for="logLineLimit" i18n>Show</label> <span class="input-group-text text-muted" i18n>Show</span>
<input <input
id="logLineLimit"
class="form-control" class="form-control"
type="number" type="number"
min="100" min="100"
@@ -18,7 +17,7 @@
<span class="input-group-text text-muted" i18n>lines</span> <span class="input-group-text text-muted" i18n>lines</span>
</div> </div>
<div class="form-check form-switch mt-1"> <div class="form-check form-switch mt-1">
<input id="autoRefreshSwitch" class="form-check-input" type="checkbox" role="switch" [ngModel]="autoRefreshEnabled()" (ngModelChange)="autoRefreshEnabled.set($event)"> <input class="form-check-input" type="checkbox" role="switch" [ngModel]="autoRefreshEnabled()" (ngModelChange)="autoRefreshEnabled.set($event)">
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label> <label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
</div> </div>
</div> </div>
@@ -100,7 +100,9 @@ export class LogsComponent
parsed.some((log, idx) => { parsed.some((log, idx) => {
const current = this.logs()[idx] const current = this.logs()[idx]
return ( return (
current?.message !== log.message || current?.level !== log.level !current ||
current.message !== log.message ||
current.level !== log.level
) )
}) })
if (hasChanges) { if (hasChanges) {
@@ -37,7 +37,7 @@
<form [formGroup]="settingsForm" (ngSubmit)="saveSettings()"> <form [formGroup]="settingsForm" (ngSubmit)="saveSettings()">
<ul ngbNav #nav="ngbNav" (navChange)="onNavChange($event)" [activeId]="activeNavID()" (activeIdChange)="activeNavID.set($event)" class="nav-tabs"> <ul ngbNav #nav="ngbNav" (navChange)="onNavChange($event)" [(activeId)]="activeNavID" class="nav-tabs">
<li [ngbNavItem]="SettingsNavIDs.General"> <li [ngbNavItem]="SettingsNavIDs.General">
<a ngbNavLink i18n>General</a> <a ngbNavLink i18n>General</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
@@ -150,10 +150,10 @@
<div class="row mb-3"> <div class="row mb-3">
<div class="col-md-3 col-form-label pt-0"> <div class="col-md-3 col-form-label pt-0">
<label for="searchLink" i18n>Full search links to</label> <span i18n>Full search links to</span>
</div> </div>
<div class="col mb-3"> <div class="col mb-3">
<select id="searchLink" class="form-select" formControlName="searchLink"> <select class="form-select" formControlName="searchLink">
<option [ngValue]="GlobalSearchType.TITLE_CONTENT" i18n>Title and content search</option> <option [ngValue]="GlobalSearchType.TITLE_CONTENT" i18n>Title and content search</option>
<option [ngValue]="GlobalSearchType.ADVANCED" i18n>Advanced search</option> <option [ngValue]="GlobalSearchType.ADVANCED" i18n>Advanced search</option>
</select> </select>
@@ -199,10 +199,10 @@
<h5 i18n>Documents</h5> <h5 i18n>Documents</h5>
<div class="row mb-3"> <div class="row mb-3">
<div class="col-md-3 col-form-label pt-0"> <div class="col-md-3 col-form-label pt-0">
<label for="documentListItemPerPage" i18n>Items per page</label> <span i18n>Items per page</span>
</div> </div>
<div class="col"> <div class="col">
<select id="documentListItemPerPage" class="form-select" formControlName="documentListItemPerPage"> <select class="form-select" formControlName="documentListItemPerPage">
<option [ngValue]="10">10</option> <option [ngValue]="10">10</option>
<option [ngValue]="25">25</option> <option [ngValue]="25">25</option>
<option [ngValue]="50">50</option> <option [ngValue]="50">50</option>
@@ -274,10 +274,10 @@
<h5 class="mt-3" i18n>PDF Editor</h5> <h5 class="mt-3" i18n>PDF Editor</h5>
<div class="row"> <div class="row">
<div class="col-md-3 col-form-label pt-0"> <div class="col-md-3 col-form-label pt-0">
<label for="pdfEditorDefaultEditMode" i18n>Default editing mode</label> <span i18n>Default editing mode</span>
</div> </div>
<div class="col"> <div class="col">
<select id="pdfEditorDefaultEditMode" class="form-select" formControlName="pdfEditorDefaultEditMode"> <select class="form-select" formControlName="pdfEditorDefaultEditMode">
<option [ngValue]="PdfEditorEditMode.Create" i18n>Create new document(s)</option> <option [ngValue]="PdfEditorEditMode.Create" i18n>Create new document(s)</option>
<option [ngValue]="PdfEditorEditMode.Update" i18n>Add document version</option> <option [ngValue]="PdfEditorEditMode.Update" i18n>Add document version</option>
</select> </select>
@@ -304,7 +304,7 @@
<div class="row mb-3"> <div class="row mb-3">
<div class="col"> <div class="col">
<p i18n> <p i18n>
Settings apply to this user account for objects (Tags, Mail Rules, etc.) created via the web UI. These settings do not apply to documents. Settings apply to this user account for objects (Tags, Mail Rules, etc. but not documents) created via the web UI.
</p> </p>
</div> </div>
</div> </div>
@@ -242,7 +242,7 @@ describe('SettingsComponent', () => {
activatedRoute.snapshot.fragment = '#notifications' activatedRoute.snapshot.fragment = '#notifications'
const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor') const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor')
component.ngOnInit() component.ngOnInit()
expect(component.activeNavID()).toEqual(4) // Notifications expect(component.activeNavID).toEqual(4) // Notifications
component.ngAfterViewInit() component.ngAfterViewInit()
expect(scrollSpy).toHaveBeenCalledWith('#notifications') expect(scrollSpy).toHaveBeenCalledWith('#notifications')
}) })
@@ -399,17 +399,17 @@ describe('SettingsComponent', () => {
completeSetup() completeSetup()
const field = 'storage_path' const field = 'storage_path'
expect( expect(
component.settingsForm.get('documentDetailsHiddenFields').value component.settingsForm.get('documentDetailsHiddenFields').value.length
).toHaveLength(0) ).toEqual(0)
component.toggleDocumentDetailField(field, false) component.toggleDocumentDetailField(field, false)
expect( expect(
component.settingsForm.get('documentDetailsHiddenFields').value component.settingsForm.get('documentDetailsHiddenFields').value.length
).toHaveLength(1) ).toEqual(1)
expect(component.isDocumentDetailFieldShown(field)).toBeFalsy() expect(component.isDocumentDetailFieldShown(field)).toBeFalsy()
component.toggleDocumentDetailField(field, true) component.toggleDocumentDetailField(field, true)
expect( expect(
component.settingsForm.get('documentDetailsHiddenFields').value component.settingsForm.get('documentDetailsHiddenFields').value.length
).toHaveLength(0) ).toEqual(0)
expect(component.isDocumentDetailFieldShown(field)).toBeTruthy() expect(component.isDocumentDetailFieldShown(field)).toBeTruthy()
}) })
}) })
@@ -142,7 +142,7 @@ export class SettingsComponent
private systemStatusService = inject(SystemStatusService) private systemStatusService = inject(SystemStatusService)
private savedViewsService = inject(SavedViewService) private savedViewsService = inject(SavedViewService)
readonly activeNavID = signal<number>(undefined) activeNavID: number
settingsForm = new FormGroup({ settingsForm = new FormGroup({
bulkEditConfirmationDialogs: new FormControl(null), bulkEditConfirmationDialogs: new FormControl(null),
@@ -283,7 +283,7 @@ export class SettingsComponent
(navID) => navID.toLowerCase() == section (navID) => navID.toLowerCase() == section
) )
if (navIDKey) { if (navIDKey) {
this.activeNavID.set(SettingsNavIDs[navIDKey]) this.activeNavID = SettingsNavIDs[navIDKey]
} }
} }
}) })
@@ -386,7 +386,7 @@ export class SettingsComponent
.navigate(['settings', foundNavIDkey.toLowerCase()]) .navigate(['settings', foundNavIDkey.toLowerCase()])
.then((navigated) => { .then((navigated) => {
if (!navigated && this.isDirty) { if (!navigated && this.isDirty) {
this.activeNavID.set(navChangeEvent.activeId) this.activeNavID = navChangeEvent.activeId
} else if (navigated && this.isDirty) { } else if (navigated && this.isDirty) {
this.initialize() this.initialize()
} }
@@ -15,13 +15,13 @@
<i-bs name="check2-all" class="me-1"></i-bs><ng-container i18n>Dismiss all</ng-container> <i-bs name="check2-all" class="me-1"></i-bs><ng-container i18n>Dismiss all</ng-container>
</button> </button>
<div class="form-check form-switch mb-0 ms-2"> <div class="form-check form-switch mb-0 ms-2">
<input id="autoRefreshSwitch" class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled"> <input class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled">
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label> <label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
</div> </div>
</div> </div>
</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>
} }
@@ -93,8 +93,7 @@
<i-bs width="1em" height="1em" name="x"></i-bs> <i-bs width="1em" height="1em" name="x"></i-bs>
</button> </button>
} }
<label class="visually-hidden" for="taskSearch" i18n>Search tasks</label> <input #filterInput class="form-control form-control-sm" type="text"
<input #filterInput id="taskSearch" class="form-control form-control-sm" type="text"
(keyup)="filterInputKeyup($event)" (keyup)="filterInputKeyup($event)"
[(ngModel)]="filterText"> [(ngModel)]="filterText">
</div> </div>
@@ -212,7 +211,7 @@
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs> <i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
</button> </button>
</td> </td>
<td class="actions-col"> <td scope="row" class="actions-col">
<div class="btn-group" role="group"> <div class="btn-group" role="group">
<button class="btn btn-sm btn-outline-secondary" (click)="dismissTask(task); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }"> <button class="btn btn-sm btn-outline-secondary" (click)="dismissTask(task); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }">
<i-bs name="check" class="me-1"></i-bs><ng-container i18n>Dismiss</ng-container> <i-bs name="check" class="me-1"></i-bs><ng-container i18n>Dismiss</ng-container>
@@ -104,7 +104,7 @@
</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}}"
@@ -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>
@@ -150,8 +150,7 @@
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
popoverClass="popover-slim"> popoverClass="popover-slim">
<i-bs class="me-2" name="file-text"></i-bs><span>{{d.title | documentTitle}}</span> <i-bs class="me-2" name="file-text"></i-bs><span>{{d.title | documentTitle}}</span>
<span class="close flex-column justify-content-center" <span class="close flex-column justify-content-center" (click)="closeDocument(d); $event.preventDefault()">
(click)="$event.preventDefault(); $event.stopPropagation(); closeDocument(d)">
<i-bs name="x"></i-bs> <i-bs name="x"></i-bs>
</span> </span>
</a> </a>
@@ -182,7 +181,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"
@@ -280,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;
} }
} }
@@ -335,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;
@@ -6,17 +6,17 @@
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown"> <div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
<div class="chat-container bg-light p-2"> <div class="chat-container bg-light p-2">
<div class="chat-messages font-monospace small"> <div class="chat-messages font-monospace small">
@for (message of messages(); track message) { @for (message of messages; track message) {
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'"> <div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'">
<div class="p-2 m-2" [class.bg-body]="message.role === 'user'"> <div class="p-2 m-2" [class.bg-body]="message.role === 'user'">
<span class="text-break"> <span>
{{ message.content }} {{ message.content }}
@if (message.isStreaming) { <span class="blinking-cursor">|</span> } @if (message.isStreaming) { <span class="blinking-cursor">|</span> }
</span> </span>
@if (message.role === 'assistant' && message.references?.length) { @if (message.role === 'assistant' && message.references?.length) {
<div class="chat-references list-group mt-3"> <div class="chat-references list-group mt-3">
@for (reference of message.references; track reference.id) { @for (reference of message.references; track reference.id) {
<a class="list-group-item list-group-item-action d-flex text-primary text-break" [routerLink]="['/documents', reference.id]"> <a class="list-group-item list-group-item-action text-primary" [routerLink]="['/documents', reference.id]">
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-1"></i-bs><span>{{ reference.title }}</span> <i-bs width="0.9em" height="0.9em" name="file-text" class="me-1"></i-bs><span>{{ reference.title }}</span>
</a> </a>
} }
@@ -29,19 +29,16 @@
</div> </div>
<form class="chat-input"> <form class="chat-input">
<label class="visually-hidden" for="chatInput" i18n>Message</label>
<div class="input-group"> <div class="input-group">
<input <input
#chatInput #chatInput
id="chatInput"
class="form-control form-control-sm" name="chatInput" type="text" class="form-control form-control-sm" name="chatInput" type="text"
[placeholder]="placeholder" [placeholder]="placeholder"
[disabled]="loading()" [disabled]="loading"
[ngModel]="input()" [(ngModel)]="input"
(ngModelChange)="input.set($event)"
(keydown)="searchInputKeyDown($event)" (keydown)="searchInputKeyDown($event)"
/> />
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading()">Send</button> <button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading">Send</button>
</div> </div>
</form> </form>
</div> </div>
@@ -5,7 +5,6 @@
.chat-messages { .chat-messages {
max-height: 350px; max-height: 350px;
overflow-y: auto; overflow-y: auto;
white-space: pre-wrap;
} }
.chat-references { .chat-references {
@@ -56,53 +56,44 @@ describe('ChatComponent', () => {
it('should update documentId on initialization', () => { it('should update documentId on initialization', () => {
jest.spyOn(router, 'url', 'get').mockReturnValue('/documents/123') jest.spyOn(router, 'url', 'get').mockReturnValue('/documents/123')
component.ngOnInit() component.ngOnInit()
expect(component.documentId()).toBe(123) expect(component.documentId).toBe(123)
}) })
it('should update documentId on navigation', () => { it('should update documentId on navigation', () => {
component.ngOnInit() component.ngOnInit()
routerEvents$.next(new NavigationEnd(1, '/documents/456', '/documents/456')) routerEvents$.next(new NavigationEnd(1, '/documents/456', '/documents/456'))
expect(component.documentId()).toBe(456) expect(component.documentId).toBe(456)
}) })
it('should return correct placeholder based on documentId', () => { it('should return correct placeholder based on documentId', () => {
component.documentId.set(123) component.documentId = 123
expect(component.placeholder).toBe('Ask a question about this document...') expect(component.placeholder).toBe('Ask a question about this document...')
component.documentId.set(undefined) component.documentId = undefined
expect(component.placeholder).toBe('Ask a question about a document...') expect(component.placeholder).toBe('Ask a question about a document...')
}) })
it('should send a message and render the streaming response', async () => { it('should send a message and handle streaming response', () => {
component.input.set('Hello') component.input = 'Hello'
component.sendMessage() component.sendMessage()
expect(component.messages()).toHaveLength(2) expect(component.messages.length).toBe(2)
expect(component.messages()[0].content).toBe('Hello') expect(component.messages[0].content).toBe('Hello')
expect(component.loading()).toBe(true) expect(component.loading).toBe(true)
mockStream$.next('Hi') mockStream$.next('Hi')
expect(component.messages()[1].content).toBe('H') expect(component.messages[1].content).toBe('H')
mockStream$.next('Hi there') mockStream$.next('Hi there')
// advance time to process the typewriter effect // advance time to process the typewriter effect
await jest.runAllTimersAsync() jest.advanceTimersByTime(1000)
await fixture.whenStable() expect(component.messages[1].content).toBe('Hi there')
expect(component.messages()[1].content).toBe('Hi there')
expect(
fixture.nativeElement.querySelector('.chat-messages').textContent
).toContain('Hi there')
mockStream$.complete() mockStream$.complete()
await jest.runAllTimersAsync() expect(component.loading).toBe(false)
await fixture.whenStable() expect(component.messages[1].isStreaming).toBe(false)
expect(component.loading()).toBe(false)
expect(component.messages()[1].isStreaming).toBe(false)
expect(fixture.nativeElement.querySelector('#chatInput').disabled).toBe(
false
)
}) })
it('should parse references from the metadata trailer without showing it', () => { it('should parse references from the metadata trailer without showing it', () => {
component.input.set('Hello') component.input = 'Hello'
component.sendMessage() component.sendMessage()
mockStream$.next( mockStream$.next(
@@ -110,14 +101,14 @@ describe('ChatComponent', () => {
) )
jest.advanceTimersByTime(1000) jest.advanceTimersByTime(1000)
expect(component.messages()[1].content).toBe('Hi there') expect(component.messages[1].content).toBe('Hi there')
expect(component.messages()[1].references).toEqual([ expect(component.messages[1].references).toEqual([
{ id: 42, title: 'Bread Recipe' }, { id: 42, title: 'Bread Recipe' },
]) ])
}) })
it('should render document reference links under assistant messages', () => { it('should render document reference links under assistant messages', () => {
component.input.set('Hello') component.input = 'Hello'
component.sendMessage() component.sendMessage()
mockStream$.next( mockStream$.next(
@@ -132,12 +123,12 @@ describe('ChatComponent', () => {
}) })
it('should remove delimiter fragments that were already streamed', () => { it('should remove delimiter fragments that were already streamed', () => {
component.input.set('Hello') component.input = 'Hello'
component.sendMessage() component.sendMessage()
mockStream$.next(`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`) mockStream$.next(`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`)
jest.advanceTimersByTime(1000) jest.advanceTimersByTime(1000)
expect(component.messages()[1].content).toBe( expect(component.messages[1].content).toBe(
`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}` `Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`
) )
@@ -146,28 +137,28 @@ describe('ChatComponent', () => {
) )
jest.advanceTimersByTime(1000) jest.advanceTimersByTime(1000)
expect(component.messages()[1].content).toBe('Hi there') expect(component.messages[1].content).toBe('Hi there')
expect(component.messages()[1].references).toEqual([ expect(component.messages[1].references).toEqual([
{ id: 42, title: 'Bread Recipe' }, { id: 42, title: 'Bread Recipe' },
]) ])
}) })
it('should handle errors during streaming', () => { it('should handle errors during streaming', () => {
component.input.set('Hello') component.input = 'Hello'
component.sendMessage() component.sendMessage()
mockStream$.error('Error') mockStream$.error('Error')
expect(component.messages()[1].content).toContain( expect(component.messages[1].content).toContain(
'⚠️ Error receiving response.' '⚠️ Error receiving response.'
) )
expect(component.loading()).toBe(false) expect(component.loading).toBe(false)
}) })
it('should enqueue typewriter chunks correctly', () => { it('should enqueue typewriter chunks correctly', () => {
const message = { content: '', role: 'assistant', isStreaming: true } const message = { content: '', role: 'assistant', isStreaming: true }
component.enqueueTypewriter(null, message as any) // coverage for null component.enqueueTypewriter(null, message as any) // coverage for null
component.enqueueTypewriter('Hello', message as any) component.enqueueTypewriter('Hello', message as any)
expect(component['typewriterBuffer']).toHaveLength(4) expect(component['typewriterBuffer'].length).toBe(4)
}) })
it('should scroll to bottom after sending a message', () => { it('should scroll to bottom after sending a message', () => {
@@ -175,7 +166,7 @@ describe('ChatComponent', () => {
ChatComponent.prototype as any, ChatComponent.prototype as any,
'scrollToBottom' 'scrollToBottom'
) )
component.input.set('Test') component.input = 'Test'
component.sendMessage() component.sendMessage()
expect(scrollSpy).toHaveBeenCalled() expect(scrollSpy).toHaveBeenCalled()
}) })
@@ -1,11 +1,4 @@
import { import { Component, ElementRef, inject, OnInit, ViewChild } from '@angular/core'
Component,
ElementRef,
inject,
OnInit,
signal,
ViewChild,
} from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NavigationEnd, Router, RouterModule } from '@angular/router' import { NavigationEnd, Router, RouterModule } from '@angular/router'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
@@ -30,10 +23,10 @@ import {
styleUrl: './chat.component.scss', styleUrl: './chat.component.scss',
}) })
export class ChatComponent implements OnInit { export class ChatComponent implements OnInit {
readonly messages = signal<ChatMessage[]>([]) public messages: ChatMessage[] = []
readonly loading = signal(false) public loading = false
readonly input = signal('') public input: string = ''
readonly documentId = signal<number>(undefined) public documentId!: number
private chatService: ChatService = inject(ChatService) private chatService: ChatService = inject(ChatService)
private router: Router = inject(Router) private router: Router = inject(Router)
@@ -45,7 +38,7 @@ export class ChatComponent implements OnInit {
private typewriterActive = false private typewriterActive = false
public get placeholder(): string { public get placeholder(): string {
return this.documentId() return this.documentId
? $localize`Ask a question about this document...` ? $localize`Ask a question about this document...`
: $localize`Ask a question about a document...` : $localize`Ask a question about a document...`
} }
@@ -64,14 +57,14 @@ export class ChatComponent implements OnInit {
private updateDocumentId(url: string): void { private updateDocumentId(url: string): void {
const docIdRe = url.match(/^\/documents\/(\d+)/) const docIdRe = url.match(/^\/documents\/(\d+)/)
this.documentId.set(docIdRe ? +docIdRe[1] : undefined) this.documentId = docIdRe ? +docIdRe[1] : undefined
} }
sendMessage(): void { sendMessage(): void {
if (!this.input().trim()) return if (!this.input.trim()) return
const userMessage: ChatMessage = { role: 'user', content: this.input() } const userMessage: ChatMessage = { role: 'user', content: this.input }
this.messages.update((messages) => [...messages, userMessage]) this.messages.push(userMessage)
this.scrollToBottom() this.scrollToBottom()
const assistantMessage: ChatMessage = { const assistantMessage: ChatMessage = {
@@ -79,12 +72,12 @@ export class ChatComponent implements OnInit {
content: '', content: '',
isStreaming: true, isStreaming: true,
} }
this.messages.update((messages) => [...messages, assistantMessage]) this.messages.push(assistantMessage)
this.loading.set(true) this.loading = true
let lastVisibleContent = '' let lastVisibleContent = ''
this.chatService.streamChat(this.documentId(), this.input()).subscribe({ this.chatService.streamChat(this.documentId, this.input).subscribe({
next: (chunk) => { next: (chunk) => {
const nextResponse = parseChatResponse(chunk) const nextResponse = parseChatResponse(chunk)
@@ -100,30 +93,26 @@ export class ChatComponent implements OnInit {
} }
assistantMessage.references = nextResponse.references assistantMessage.references = nextResponse.references
this.notifyMessagesChanged()
}, },
error: () => { error: () => {
assistantMessage.content += '\n\n⚠️ Error receiving response.' assistantMessage.content += '\n\n⚠️ Error receiving response.'
assistantMessage.isStreaming = false assistantMessage.isStreaming = false
this.notifyMessagesChanged() this.loading = false
this.loading.set(false)
}, },
complete: () => { complete: () => {
assistantMessage.isStreaming = false assistantMessage.isStreaming = false
this.notifyMessagesChanged() this.loading = false
this.loading.set(false)
this.scrollToBottom() this.scrollToBottom()
}, },
}) })
this.input.set('') this.input = ''
} }
private resetTypewriter(message: ChatMessage, content: string): void { private resetTypewriter(message: ChatMessage, content: string): void {
this.typewriterBuffer = [] this.typewriterBuffer = []
this.typewriterActive = false this.typewriterActive = false
message.content = content message.content = content
this.notifyMessagesChanged()
this.scrollToBottom() this.scrollToBottom()
} }
@@ -146,16 +135,11 @@ export class ChatComponent implements OnInit {
const nextChar = this.typewriterBuffer.shift() const nextChar = this.typewriterBuffer.shift()
message.content += nextChar message.content += nextChar
this.notifyMessagesChanged()
this.scrollToBottom() this.scrollToBottom()
setTimeout(() => this.playTypewriter(message), 10) // 10ms per character setTimeout(() => this.playTypewriter(message), 10) // 10ms per character
} }
private notifyMessagesChanged(): void {
this.messages.update((messages) => [...messages])
}
private scrollToBottom(): void { private scrollToBottom(): void {
setTimeout(() => { setTimeout(() => {
this.scrollAnchor?.nativeElement?.scrollIntoView({ behavior: 'smooth' }) this.scrollAnchor?.nativeElement?.scrollIntoView({ behavior: 'smooth' })
@@ -17,6 +17,6 @@
<ng-template #popoverContent> <ng-template #popoverContent>
<div> <div>
{{confirmMessage}}&nbsp;<button class="btn btn-link btn-sm text-danger p-0 m-0 lh-1" type="button" (click)="onConfirm($event)" i18n>Yes</button> {{confirmMessage}}&nbsp;<button class="btn btn-link btn-sm text-danger p-0 m-0 lh-1" type="button" (click)="onConfirm($event)">Yes</button>
</div> </div>
</ng-template> </ng-template>
@@ -1,12 +1,5 @@
import { CurrencyPipe, getLocaleCurrencyCode, SlicePipe } from '@angular/common' import { CurrencyPipe, getLocaleCurrencyCode, SlicePipe } from '@angular/common'
import { import { Component, inject, Input, LOCALE_ID, OnInit } from '@angular/core'
ChangeDetectorRef,
Component,
inject,
Input,
LOCALE_ID,
OnInit,
} from '@angular/core'
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap' import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
import { takeUntil } from 'rxjs' import { takeUntil } from 'rxjs'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
@@ -29,7 +22,6 @@ export class CustomFieldDisplayComponent
{ {
private customFieldService = inject(CustomFieldsService) private customFieldService = inject(CustomFieldsService)
private documentService = inject(DocumentService) private documentService = inject(DocumentService)
private changeDetector = inject(ChangeDetectorRef)
CustomFieldDataType = CustomFieldDataType CustomFieldDataType = CustomFieldDataType
@@ -82,7 +74,6 @@ export class CustomFieldDisplayComponent
this.customFieldService.listAll().subscribe((r) => { this.customFieldService.listAll().subscribe((r) => {
this.customFields = r.results this.customFields = r.results
this.init() this.init()
this.changeDetector.markForCheck()
}) })
} }
@@ -120,7 +111,6 @@ export class CustomFieldDisplayComponent
this.docLinkDocuments = this.value this.docLinkDocuments = this.value
.map((id) => result.results.find((d) => d.id === id)) .map((id) => result.results.find((d) => d.id === id))
.filter((d) => d) .filter((d) => d)
this.changeDetector.markForCheck()
}) })
} }
@@ -95,8 +95,8 @@ describe('CustomFieldsDropdownComponent', () => {
it('should support update unused fields', () => { it('should support update unused fields', () => {
component.existingFields = [{ field: fields[0].id } as any] component.existingFields = [{ field: fields[0].id } as any]
component['updateUnusedFields']() component['updateUnusedFields']()
expect(component['unusedFields']().length).toEqual(1) expect(component['unusedFields'].length).toEqual(1)
expect(component['unusedFields']()[0].name).toEqual('Field 2') expect(component['unusedFields'][0].name).toEqual('Field 2')
}) })
it('should support getting data type label', () => { it('should support getting data type label', () => {
@@ -8,7 +8,6 @@ import {
ViewChild, ViewChild,
ViewChildren, ViewChildren,
inject, inject,
signal,
} from '@angular/core' } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
@@ -66,11 +65,11 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
@ViewChildren('button') buttons: QueryList<ElementRef> @ViewChildren('button') buttons: QueryList<ElementRef>
private customFields: CustomField[] = [] private customFields: CustomField[] = []
private readonly unusedFields = signal<CustomField[]>([]) private unusedFields: CustomField[] = []
private keyboardIndex: number private keyboardIndex: number
public get filteredFields(): CustomField[] { public get filteredFields(): CustomField[] {
return this.unusedFields().filter( return this.unusedFields.filter(
(f) => !this.filterText || matchesSearchText(f.name, this.filterText) (f) => !this.filterText || matchesSearchText(f.name, this.filterText)
) )
} }
@@ -100,10 +99,8 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
} }
private updateUnusedFields() { private updateUnusedFields() {
this.unusedFields.set( this.unusedFields = this.customFields.filter(
this.customFields.filter( (f) => !this.existingFields?.find((e) => e.field === f.id)
(f) => !this.existingFields?.find((e) => e.field === f.id)
)
) )
} }
@@ -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">
} }
@@ -82,7 +77,7 @@
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<ng-select #fieldSelects <ng-select #fieldSelects
class="paperless-input-select" class="paperless-input-select"
[items]="customFields()" [items]="customFields"
[(ngModel)]="atom.field" [(ngModel)]="atom.field"
[disabled]="disabled" [disabled]="disabled"
bindLabel="name" bindLabel="name"
@@ -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()
@@ -87,7 +79,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
}) })
it('should initialize custom fields on creation', () => { it('should initialize custom fields on creation', () => {
expect(component.customFields()).toEqual(customFields) expect(component.customFields).toEqual(customFields)
}) })
it('should add an expression when opened if queries are empty', () => { it('should add an expression when opened if queries are empty', () => {
@@ -109,7 +101,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
data_type: CustomFieldDataType.String, data_type: CustomFieldDataType.String,
extra_data: {}, extra_data: {},
} }
component.customFields.set([field]) component.customFields = [field]
const operators = component.getOperatorsForField(1) const operators = component.getOperatorsForField(1)
expect(operators.length).toEqual( expect(operators.length).toEqual(
[ [
@@ -146,7 +138,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
], ],
}, },
} }
component.customFields.set([field]) component.customFields = [field]
const options = component.getSelectOptionsForField(1) const options = component.getSelectOptionsForField(1)
expect(options).toEqual([ expect(options).toEqual([
{ label: 'Option 1', id: 'abc-123' }, { label: 'Option 1', id: 'abc-123' },
@@ -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,17 +1,11 @@
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,
ViewChild, ViewChild,
ViewChildren, ViewChildren,
} from '@angular/core' } from '@angular/core'
@@ -217,7 +211,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
@@ -285,7 +278,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
@Output() @Output()
selectionModelChange = new EventEmitter<CustomFieldQueriesModel>() selectionModelChange = new EventEmitter<CustomFieldQueriesModel>()
readonly customFields = signal<CustomField[]>([]) customFields: CustomField[] = []
public readonly today: string = new Date().toLocaleDateString('en-CA') public readonly today: string = new Date().toLocaleDateString('en-CA')
@@ -332,12 +325,12 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
.listAll() .listAll()
.pipe(first(), takeUntil(this.unsubscribeNotifier)) .pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((result) => { .subscribe((result) => {
this.customFields.set(result.results) this.customFields = result.results
}) })
} }
public getCustomFieldByID(id: number): CustomField { public getCustomFieldByID(id: number): CustomField {
return this.customFields().find((field) => field.id === id) return this.customFields.find((field) => field.id === id)
} }
public addAtom(expression: CustomFieldQueryExpression) { public addAtom(expression: CustomFieldQueryExpression) {
@@ -360,7 +353,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
getOperatorsForField( getOperatorsForField(
fieldID: number fieldID: number
): Array<{ value: string; label: string }> { ): Array<{ value: string; label: string }> {
const field = this.customFields().find((field) => field.id === fieldID) const field = this.customFields.find((field) => field.id === fieldID)
const groups: CustomFieldQueryOperatorGroups[] = field const groups: CustomFieldQueryOperatorGroups[] = field
? CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE[field.data_type] ? CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE[field.data_type]
: [CustomFieldQueryOperatorGroups.Basic] : [CustomFieldQueryOperatorGroups.Basic]
@@ -376,24 +369,10 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
getSelectOptionsForField( getSelectOptionsForField(
fieldID: number fieldID: number
): Array<{ label: string; id: string }> { ): Array<{ label: string; id: string }> {
const field = this.customFields().find((field) => field.id === fieldID) const field = this.customFields.find((field) => field.id === fieldID)
if (field) { if (field) {
return field.extra_data['select_options'] return field.extra_data['select_options']
} }
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()
}, },
}) })
} }
@@ -22,11 +22,11 @@
<ng-template> <ng-template>
<div class="card mb-2"> <div class="card mb-2">
<div class="card-body p-2"> <div class="card-body p-2">
@if (testLoading()) { @if (testLoading) {
<ng-container [ngTemplateOutlet]="loadingTemplate"></ng-container> <ng-container [ngTemplateOutlet]="loadingTemplate"></ng-container>
} @else if (testResult()) { } @else if (testResult) {
<code>{{testResult()}}</code> <code>{{testResult}}</code>
} @else if (testFailed()) { } @else if (testFailed) {
<div class="text-danger" i18n>Path test failed</div> <div class="text-danger" i18n>Path test failed</div>
} @else { } @else {
<div class="text-muted small" i18n>No document selected</div> <div class="text-muted small" i18n>No document selected</div>
@@ -42,7 +42,7 @@
[compareWith]="compareDocuments" [compareWith]="compareDocuments"
[trackByFn]="trackByFn" [trackByFn]="trackByFn"
[minTermLength]="2" [minTermLength]="2"
[loading]="loading()" [loading]="loading"
[typeahead]="documentsInput$" [typeahead]="documentsInput$"
(change)="testPath($event)"> (change)="testPath($event)">
<ng-template #loadingTemplate ng-loadingspinner-tmp> <ng-template #loadingTemplate ng-loadingspinner-tmp>
@@ -58,17 +58,17 @@ describe('StoragePathEditDialogComponent', () => {
fixture.detectChanges() fixture.detectChanges()
component.testPath({ id: 1 }) component.testPath({ id: 1 })
expect(testSpy).toHaveBeenCalledWith('test/{{title}}', 1) expect(testSpy).toHaveBeenCalledWith('test/{{title}}', 1)
expect(component.testResult()).toBe('test/abc123') expect(component.testResult).toBe('test/abc123')
expect(component.testFailed()).toBeFalsy() expect(component.testFailed).toBeFalsy()
// test failed // test failed
testSpy.mockReturnValueOnce(of('')) testSpy.mockReturnValueOnce(of(''))
component.testPath({ id: 1 }) component.testPath({ id: 1 })
expect(component.testResult()).toBeNull() expect(component.testResult).toBeNull()
expect(component.testFailed()).toBeTruthy() expect(component.testFailed).toBeTruthy()
component.testPath(null) component.testPath(null)
expect(component.testResult()).toBeNull() expect(component.testResult).toBeNull()
}) })
it('should compare two documents by id', () => { it('should compare two documents by id', () => {
@@ -1,5 +1,5 @@
import { AsyncPipe, NgTemplateOutlet } from '@angular/common' import { AsyncPipe, NgTemplateOutlet } from '@angular/common'
import { Component, OnDestroy, inject, signal } from '@angular/core' import { Component, OnDestroy, inject } from '@angular/core'
import { import {
FormControl, FormControl,
FormGroup, FormGroup,
@@ -65,9 +65,9 @@ export class StoragePathEditDialogComponent
public documentsInput$ = new Subject<string>() public documentsInput$ = new Subject<string>()
public foundDocuments$: Observable<Document[]> public foundDocuments$: Observable<Document[]>
private testDocument: Document private testDocument: Document
readonly testResult = signal<string>(undefined) public testResult: string
readonly testFailed = signal(false) public testFailed: boolean = false
readonly testLoading = signal(false) public testLoading = false
constructor() { constructor() {
super() super()
@@ -99,22 +99,22 @@ export class StoragePathEditDialogComponent
public testPath(document: Document) { public testPath(document: Document) {
if (!document) { if (!document) {
this.testResult.set(null) this.testResult = null
return return
} }
this.testDocument = document this.testDocument = document
this.testLoading.set(true) this.testLoading = true
;(this.service as StoragePathService) ;(this.service as StoragePathService)
.testPath(this.objectForm.get('path').value, document.id) .testPath(this.objectForm.get('path').value, document.id)
.subscribe((result) => { .subscribe((result) => {
if (result?.length) { if (result?.length) {
this.testResult.set(result) this.testResult = result
this.testFailed.set(false) this.testFailed = false
} else { } else {
this.testResult.set(null) this.testResult = null
this.testFailed.set(true) this.testFailed = true
} }
this.testLoading.set(false) this.testLoading = false
}) })
} }
@@ -12,7 +12,7 @@
<pngx-input-color i18n-title title="Color" formControlName="color" [error]="error?.color"></pngx-input-color> <pngx-input-color i18n-title title="Color" formControlName="color" [error]="error?.color"></pngx-input-color>
<pngx-input-select i18n-title title="Parent" formControlName="parent" [items]="tags()" [allowNull]="true" [error]="error?.parent"></pngx-input-select> <pngx-input-select i18n-title title="Parent" formControlName="parent" [items]="tags" [allowNull]="true" [error]="error?.parent"></pngx-input-select>
<pngx-input-check i18n-title title="Inbox tag" formControlName="is_inbox_tag" i18n-hint hint="Inbox tags are automatically assigned to all consumed documents."></pngx-input-check> <pngx-input-check i18n-title title="Inbox tag" formControlName="is_inbox_tag" i18n-hint hint="Inbox tags are automatically assigned to all consumed documents."></pngx-input-check>
<pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select> <pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
@@ -1,4 +1,4 @@
import { Component, inject, signal } from '@angular/core' import { Component, inject } from '@angular/core'
import { import {
FormControl, FormControl,
FormGroup, FormGroup,
@@ -35,7 +35,7 @@ import { TextComponent } from '../../input/text/text.component'
], ],
}) })
export class TagEditDialogComponent extends EditDialogComponent<Tag> { export class TagEditDialogComponent extends EditDialogComponent<Tag> {
readonly tags = signal<Tag[]>([]) tags: Tag[]
constructor() { constructor() {
super() super()
@@ -43,7 +43,7 @@ export class TagEditDialogComponent extends EditDialogComponent<Tag> {
this.userService = inject(UserService) this.userService = inject(UserService)
this.settingsService = inject(SettingsService) this.settingsService = inject(SettingsService)
this.service.listAll().subscribe((result) => { this.service.listAll().subscribe((result) => {
this.tags.set(result.results) this.tags = result.results
}) })
} }
@@ -180,7 +180,7 @@
<div class="col"> <div class="col">
<div class="trigger-filters mb-3"> <div class="trigger-filters mb-3">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<span class="form-label mb-0" i18n>Advanced Filters</span> <label class="form-label mb-0" i18n>Advanced Filters</label>
<button <button
type="button" type="button"
class="btn btn-sm btn-outline-primary ms-auto" class="btn btn-sm btn-outline-primary ms-auto"
@@ -401,7 +401,7 @@ describe('WorkflowEditDialogComponent', () => {
component.addFilter(triggerGroup as FormGroup) component.addFilter(triggerGroup as FormGroup)
const filters = component.getFiltersFormArray(triggerGroup as FormGroup) const filters = component.getFiltersFormArray(triggerGroup as FormGroup)
expect(filters).toHaveLength(3) expect(filters.length).toBe(3)
filters.at(0).get('values').setValue([1]) filters.at(0).get('values').setValue([1])
filters.at(1).get('values').setValue([2, 3]) filters.at(1).get('values').setValue([2, 3])
@@ -672,7 +672,7 @@ describe('WorkflowEditDialogComponent', () => {
} as any } as any
const filters = component['buildFiltersFormArray'](trigger) const filters = component['buildFiltersFormArray'](trigger)
expect(filters).toHaveLength(0) expect(filters.length).toBe(0)
component.filterDefinitions = originalDefinitions component.filterDefinitions = originalDefinitions
}) })
@@ -739,7 +739,7 @@ describe('WorkflowEditDialogComponent', () => {
component.ngOnInit() component.ngOnInit()
const triggerGroup = component.triggerFields.at(0) as FormGroup const triggerGroup = component.triggerFields.at(0) as FormGroup
const filters = component.getFiltersFormArray(triggerGroup) const filters = component.getFiltersFormArray(triggerGroup)
expect(filters).toHaveLength(13) expect(filters.length).toBe(13)
const customFieldFilter = filters.at(12) as FormGroup const customFieldFilter = filters.at(12) as FormGroup
expect(customFieldFilter.get('type').value).toBe( expect(customFieldFilter.get('type').value).toBe(
TriggerFilterType.CustomFieldQuery TriggerFilterType.CustomFieldQuery
@@ -969,12 +969,12 @@ describe('WorkflowEditDialogComponent', () => {
component.addFilter(triggerGroup) component.addFilter(triggerGroup)
component.removeFilter(triggerGroup, 0) component.removeFilter(triggerGroup, 0)
expect(component.getFiltersFormArray(triggerGroup)).toHaveLength(0) expect(component.getFiltersFormArray(triggerGroup).length).toBe(0)
component.addFilter(triggerGroup) component.addFilter(triggerGroup)
const filterArrayAfterAdd = component.getFiltersFormArray(triggerGroup) const filterArrayAfterAdd = component.getFiltersFormArray(triggerGroup)
filterArrayAfterAdd.at(0).get('type').setValue(TriggerFilterType.TagsAll) filterArrayAfterAdd.at(0).get('type').setValue(TriggerFilterType.TagsAll)
expect(component.getFiltersFormArray(triggerGroup)).toHaveLength(1) expect(component.getFiltersFormArray(triggerGroup).length).toBe(1)
}) })
it('should remove selected custom field from the form group', () => { it('should remove selected custom field from the form group', () => {
@@ -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([])
})
}) })
@@ -933,7 +933,7 @@ export class WorkflowEditDialogComponent
getFilterSelectItems(type: TriggerFilterType) { getFilterSelectItems(type: TriggerFilterType) {
const definition = this.getFilterDefinition(type) const definition = this.getFilterDefinition(type)
if (definition?.inputType !== 'select') { if (!definition || definition.inputType !== 'select') {
return [] return []
} }
@@ -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)
} }
@@ -34,30 +34,32 @@
</div> </div>
@if (selectionModel.items) { @if (selectionModel.items) {
<cdk-virtual-scroll-viewport class="items" [itemSize]="FILTERABLE_BUTTON_HEIGHT_PX" #buttonsViewport [style.height.px]="scrollViewportHeight"> <cdk-virtual-scroll-viewport class="items" [itemSize]="FILTERABLE_BUTTON_HEIGHT_PX" #buttonsViewport [style.height.px]="scrollViewportHeight">
<div *cdkVirtualFor="let item of filteredItems; trackBy: trackByItem; let i = index"> <div *cdkVirtualFor="let item of selectionModel.items | filter: filterText:'name'; trackBy: trackByItem; let i = index">
<pngx-toggleable-dropdown-button @if (allowSelectNone || item.id) {
[item]="item" <pngx-toggleable-dropdown-button
[hideCount]="hideCount(item)" [item]="item"
[opacifyCount]="!editing" [hideCount]="hideCount(item)"
[state]="selectionModel.get(item.id)" [opacifyCount]="!editing"
[count]="getUpdatedDocumentCount(item.id)" [state]="selectionModel.get(item.id)"
(toggled)="selectionModel.toggle(item.id)" [count]="getUpdatedDocumentCount(item.id)"
(exclude)="excludeClicked(item.id)" (toggled)="selectionModel.toggle(item.id)"
[disabled]="disabled"> (exclude)="excludeClicked(item.id)"
</pngx-toggleable-dropdown-button> [disabled]="disabled">
</pngx-toggleable-dropdown-button>
}
</div> </div>
</cdk-virtual-scroll-viewport> </cdk-virtual-scroll-viewport>
} }
@if (editing) { @if (editing) {
@if (filteredItems.length === 0 && createRef !== undefined) { @if ((selectionModel.items | filter: filterText:'name').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>
</button> </button>
} }
@if (filteredItems.length > 0) { @if ((selectionModel.items | filter: filterText:'name').length > 0) {
<button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="applyClicked()" [disabled]="!modelIsDirty() || disabled"> <button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="applyClicked()" [disabled]="!modelIsDirty || disabled">
<small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty()}" i18n>Apply</small> <small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty}" i18n>Apply</small>
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs> <i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
</button> </button>
} }
@@ -221,7 +221,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
.dispatchEvent(new MouseEvent('click')) // open .dispatchEvent(new MouseEvent('click')) // open
selectionModel.toggle(items[0].id) selectionModel.toggle(items[0].id)
fixture.detectChanges() fixture.detectChanges()
expect(component.modelIsDirty()).toBeTruthy() expect(component.modelIsDirty).toBeTruthy()
let applyResult: ChangedItems let applyResult: ChangedItems
const closeSpy = jest.spyOn(component.dropdown, 'close') const closeSpy = jest.spyOn(component.dropdown, 'close')
component.apply.subscribe((result) => (applyResult = result)) component.apply.subscribe((result) => (applyResult = result))
@@ -244,7 +244,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
.dispatchEvent(new MouseEvent('click')) // open .dispatchEvent(new MouseEvent('click')) // open
selectionModel.toggle(items[0].id) selectionModel.toggle(items[0].id)
fixture.detectChanges() fixture.detectChanges()
expect(component.modelIsDirty()).toBeTruthy() expect(component.modelIsDirty).toBeTruthy()
let applyResult: ChangedItems let applyResult: ChangedItems
component.apply.subscribe((result) => (applyResult = result)) component.apply.subscribe((result) => (applyResult = result))
component.dropdown.close() component.dropdown.close()
@@ -265,9 +265,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(document.activeElement).toEqual( expect(document.activeElement).toEqual(
component.listFilterTextInput.nativeElement component.listFilterTextInput.nativeElement
) )
expect(component.buttonsViewport.getRenderedRange().end).toEqual( expect(component.buttonsViewport.getRenderedRange().end).toEqual(3) // all items shown
items.length
) // all selectable items shown
component.filterText = 'Tag2' component.filterText = 'Tag2'
fixture.detectChanges() fixture.detectChanges()
@@ -280,29 +278,6 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(component.filterText).toHaveLength(0) expect(component.filterText).toHaveLength(0)
}) })
it('should omit disallowed null items from the virtual scroll viewport', async () => {
component.selectionModel.items = items
component.icon = 'tag-fill'
fixture.nativeElement
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
await wait(100)
fixture.detectChanges()
component.buttonsViewport?.checkViewportSize()
fixture.detectChanges()
expect(component.filteredItems).toEqual(items)
expect(component.scrollViewportHeight).toEqual(
items.length * component.FILTERABLE_BUTTON_HEIGHT_PX
)
expect(
component.buttonsViewport.elementRef.nativeElement.querySelectorAll(
'.cdk-virtual-scroll-content-wrapper > div'
)
).toHaveLength(items.length)
})
it('should toggle & close on enter inside filter field if 1 item remains', async () => { it('should toggle & close on enter inside filter field if 1 item remains', async () => {
component.selectionModel.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
@@ -12,7 +12,6 @@ import {
Output, Output,
ViewChild, ViewChild,
inject, inject,
signal,
} from '@angular/core' } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
@@ -661,6 +660,7 @@ export class FilterableDropdownSelectionModel {
imports: [ imports: [
ClearableBadgeComponent, ClearableBadgeComponent,
ToggleableDropdownButtonComponent, ToggleableDropdownButtonComponent,
FilterPipe,
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
@@ -796,27 +796,22 @@ export class FilterableDropdownComponent
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
} }
readonly modelIsDirty = signal(false) modelIsDirty: boolean = false
private keyboardIndex: number private keyboardIndex: number
public get filteredItems(): MatchingModel[] {
return this.filterPipe
.transform(this.items, this.filterText, 'name')
.filter((item) => this.allowSelectNone || Boolean(item.id))
}
public get scrollViewportHeight(): number { public get scrollViewportHeight(): number {
return Math.min( const filteredLength = this.filterPipe.transform(
this.filteredItems.length * this.FILTERABLE_BUTTON_HEIGHT_PX, this.items,
400 this.filterText
) ).length
return Math.min(filteredLength * this.FILTERABLE_BUTTON_HEIGHT_PX, 400)
} }
constructor() { constructor() {
super() super()
this.selectionModelChange.subscribe((updatedModel) => { this.selectionModelChange.subscribe((updatedModel) => {
this.modelIsDirty.set(updatedModel.isDirty()) this.modelIsDirty = updatedModel.isDirty()
}) })
} }
@@ -863,7 +858,7 @@ export class FilterableDropdownComponent
}, 0) }, 0)
if (this.editing) { if (this.editing) {
this.selectionModel.reset() this.selectionModel.reset()
this.modelIsDirty.set(false) this.modelIsDirty = false
} }
this.selectionModel.singleSelect = this.selectionModel.singleSelect =
this.editing && !this.selectionModel.manyToOne this.editing && !this.selectionModel.manyToOne
@@ -882,7 +877,7 @@ export class FilterableDropdownComponent
} }
listFilterEnter(): void { listFilterEnter(): void {
const filtered = this.filteredItems let filtered = this.filterPipe.transform(this.items, this.filterText)
if (filtered.length == 1) { if (filtered.length == 1) {
this.selectionModel.toggle(filtered[0].id) this.selectionModel.toggle(filtered[0].id)
setTimeout(() => { setTimeout(() => {
@@ -5,7 +5,6 @@ import {
inject, inject,
Input, Input,
Output, Output,
signal,
} from '@angular/core' } from '@angular/core'
import { import {
FormsModule, FormsModule,
@@ -64,11 +63,11 @@ export class CustomFieldsValuesComponent extends AbstractInputComponent<Object>
super() super()
customFieldsService.listAll().subscribe((items) => { customFieldsService.listAll().subscribe((items) => {
this.fields.set(items.results) this.fields = items.results
}) })
} }
private readonly fields = signal<CustomField[]>([]) private fields: CustomField[]
private _selectedFields: number[] private _selectedFields: number[]
@@ -91,6 +90,6 @@ export class CustomFieldsValuesComponent extends AbstractInputComponent<Object>
public removeSelectedField: EventEmitter<number> = new EventEmitter<number>() public removeSelectedField: EventEmitter<number> = new EventEmitter<number>()
public getCustomField(id: number): CustomField { public getCustomField(id: number): CustomField {
return this.fields().find((field) => field.id === id) return this.fields.find((field) => field.id === id)
} }
} }
@@ -33,7 +33,7 @@
[compareWith]="compareDocuments" [compareWith]="compareDocuments"
[trackByFn]="trackByFn" [trackByFn]="trackByFn"
[minTermLength]="2" [minTermLength]="2"
[loading]="loading()" [loading]="loading"
[typeahead]="documentsInput$" [typeahead]="documentsInput$"
(mousedown)="$event.stopImmediatePropagation()" (mousedown)="$event.stopImmediatePropagation()"
(change)="onChange(selectedDocumentIDs)"> (change)="onChange(selectedDocumentIDs)">
@@ -2,11 +2,7 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing' import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NG_VALUE_ACCESSOR } from '@angular/forms' import { NG_VALUE_ACCESSOR } from '@angular/forms'
import { By } from '@angular/platform-browser' import { of, throwError } from 'rxjs'
import { provideRouter } from '@angular/router'
import { NgSelectComponent } from '@ng-select/ng-select'
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { of, Subject, throwError } from 'rxjs'
import { FILTER_SIMPLE_TITLE } from 'src/app/data/filter-rule-type' import { FILTER_SIMPLE_TITLE } from 'src/app/data/filter-rule-type'
import { DocumentService } from 'src/app/services/rest/document.service' import { DocumentService } from 'src/app/services/rest/document.service'
import { DocumentLinkComponent } from './document-link.component' import { DocumentLinkComponent } from './document-link.component'
@@ -37,11 +33,10 @@ describe('DocumentLinkComponent', () => {
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [DocumentLinkComponent, NgxBootstrapIconsModule.pick(allIcons)], imports: [DocumentLinkComponent],
providers: [ providers: [
provideHttpClient(withInterceptorsFromDi()), provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(), provideHttpClientTesting(),
provideRouter([]),
], ],
}) })
documentService = TestBed.inject(DocumentService) documentService = TestBed.inject(DocumentService)
@@ -65,25 +60,6 @@ describe('DocumentLinkComponent', () => {
expect(getSpy).toHaveBeenCalled() expect(getSpy).toHaveBeenCalled()
}) })
it('should render loading and selected documents after async updates', async () => {
const result$ = new Subject<any>()
jest.spyOn(documentService, 'getFew').mockReturnValue(result$)
component.writeValue([1])
await fixture.whenStable()
const select = fixture.debugElement.query(By.directive(NgSelectComponent))
.componentInstance as NgSelectComponent
expect(select.loading()).toBe(true)
result$.next({ count: 1, all: [1], results: [documents[0]] })
result$.complete()
await fixture.whenStable()
expect(select.loading()).toBe(false)
expect(fixture.nativeElement.textContent).toContain(documents[0].title)
})
it('shoud maintain ordering of selected documents', () => { it('shoud maintain ordering of selected documents', () => {
const getSpy = jest.spyOn(documentService, 'getFew') const getSpy = jest.spyOn(documentService, 'getFew')
getSpy.mockImplementation((ids) => { getSpy.mockImplementation((ids) => {
@@ -6,7 +6,6 @@ import {
Input, Input,
OnDestroy, OnDestroy,
OnInit, OnInit,
signal,
} from '@angular/core' } from '@angular/core'
import { import {
FormsModule, FormsModule,
@@ -64,7 +63,7 @@ export class DocumentLinkComponent
documentsInput$ = new Subject<string>() documentsInput$ = new Subject<string>()
foundDocuments$: Observable<Document[]> foundDocuments$: Observable<Document[]>
readonly loading = signal(false) loading = false
selectedDocuments: Document[] = [] selectedDocuments: Document[] = []
private unsubscribeNotifier: Subject<any> = new Subject() private unsubscribeNotifier: Subject<any> = new Subject()
@@ -94,12 +93,12 @@ export class DocumentLinkComponent
this.selectedDocuments = [] this.selectedDocuments = []
super.writeValue([]) super.writeValue([])
} else { } else {
this.loading.set(true) this.loading = true
this.documentsService this.documentsService
.getFew(documentIDs, { fields: 'id,title' }) .getFew(documentIDs, { fields: 'id,title' })
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((documentResults) => { .subscribe((documentResults) => {
this.loading.set(false) this.loading = false
this.selectedDocuments = documentIDs.map( this.selectedDocuments = documentIDs.map(
(id) => documentResults.results.find((d) => d.id === id) ?? {} (id) => documentResults.results.find((d) => d.id === id) ?? {}
) )
@@ -114,7 +113,7 @@ export class DocumentLinkComponent
this.documentsInput$.pipe( this.documentsInput$.pipe(
distinctUntilChanged(), distinctUntilChanged(),
takeUntil(this.unsubscribeNotifier), takeUntil(this.unsubscribeNotifier),
tap(() => this.loading.set(true)), tap(() => (this.loading = true)),
switchMap((title) => switchMap((title) =>
this.documentsService this.documentsService
.listFiltered( .listFiltered(
@@ -134,7 +133,7 @@ export class DocumentLinkComponent
) )
), ),
catchError(() => of([])), // empty on error catchError(() => of([])), // empty on error
tap(() => this.loading.set(false)) tap(() => (this.loading = false))
) )
) )
) )
@@ -37,7 +37,6 @@ export class NumberComponent extends AbstractInputComponent<number> {
this.documentService.getNextAsn().subscribe((nextAsn) => { this.documentService.getNextAsn().subscribe((nextAsn) => {
this.value = nextAsn this.value = nextAsn
this.onChange(this.value) this.onChange(this.value)
this.changeDetector.markForCheck()
}) })
} }
@@ -157,8 +157,8 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
private removeChildren(tagIDs: number[], tag: Tag) { private removeChildren(tagIDs: number[], tag: Tag) {
if (tag.children?.length) { if (tag.children?.length) {
const childIDs = new Set(tag.children.map((child) => child.id)) const childIDs = tag.children.map((child) => child.id)
tagIDs = tagIDs.filter((id) => !childIDs.has(id)) tagIDs = tagIDs.filter((id) => !childIDs.includes(id))
for (const child of tag.children) { for (const child of tag.children) {
tagIDs = this.removeChildren(tagIDs, child) tagIDs = this.removeChildren(tagIDs, child)
} }
@@ -14,7 +14,7 @@ import { environment } from 'src/environments/environment'
}) })
export class PageHeaderComponent { export class PageHeaderComponent {
private titleService = inject(Title) private titleService = inject(Title)
private readonly clipboard = inject(Clipboard) private clipboard = inject(Clipboard)
readonly id = input<number>(undefined) readonly id = input<number>(undefined)
readonly subTitle = input('') readonly subTitle = input('')
@@ -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;
} }
@@ -49,7 +49,8 @@ describe('PngxPdfViewerComponent', () => {
new URL('assets/js/pdf.worker.min.mjs', document.baseURI).toString() new URL('assets/js/pdf.worker.min.mjs', document.baseURI).toString()
) )
const isVisible = (component as any).findController.onIsPageVisible as const isVisible = (component as any).findController.onIsPageVisible as
(() => boolean) | undefined | (() => boolean)
| undefined
expect(isVisible?.()).toBe(true) expect(isVisible?.()).toBe(true)
expect(loadSpy).toHaveBeenCalledWith( expect(loadSpy).toHaveBeenCalledWith(
expect.objectContaining({ numPages: 1 }) expect.objectContaining({ numPages: 1 })
@@ -60,7 +61,6 @@ describe('PngxPdfViewerComponent', () => {
it('resolves the worker source relative to the document base URI', async () => { it('resolves the worker source relative to the document base URI', async () => {
setBaseHref('/paperless/') setBaseHref('/paperless/')
const getDocumentSpy = jest.spyOn(pdfjs, 'getDocument')
await initComponent() await initComponent()
@@ -70,13 +70,6 @@ describe('PngxPdfViewerComponent', () => {
expect(pdfjs.GlobalWorkerOptions.workerSrc).toContain( expect(pdfjs.GlobalWorkerOptions.workerSrc).toContain(
'/paperless/assets/js/pdf.worker.min.mjs' '/paperless/assets/js/pdf.worker.min.mjs'
) )
expect(getDocumentSpy).toHaveBeenCalledWith({
url: 'test.pdf',
password: undefined,
withCredentials: true,
wasmUrl: expect.stringContaining('/paperless/assets/wasm/'),
iccUrl: expect.stringContaining('/paperless/assets/iccs/'),
})
}) })
it('initializes single-page viewer and disables text layer', async () => { it('initializes single-page viewer and disables text layer', async () => {
@@ -90,7 +83,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,37 +122,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({
page: new SimpleChange(1, 2, false),
})
expect(viewer.currentPageNumber).toBe(2)
expect((component as any).lastViewerPage).toBeUndefined()
expect(applyScaleSpy).not.toHaveBeenCalled()
})
it('does not reset the viewer when it is already on the requested page', async () => {
await initComponent()
const viewer = (component as any).pdfViewer as PDFViewer
const currentPageSpy = jest.spyOn(viewer, 'currentPageNumber', 'set')
component.page = viewer.currentPageNumber
;(component as any).applyViewerState() ;(component as any).applyViewerState()
expect((component as any).lastViewerPage).toBeUndefined()
expect(currentPageSpy).not.toHaveBeenCalled() expect(applyScaleSpy).toHaveBeenCalled()
}) })
it('dispatches find when search query changes after render', async () => { it('dispatches find when search query changes after render', async () => {
@@ -296,22 +264,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']) {
@@ -179,12 +174,10 @@ export class PngxPdfViewerComponent
'assets/js/pdf.worker.min.mjs', 'assets/js/pdf.worker.min.mjs',
this.document.baseURI this.document.baseURI
).toString() ).toString()
const initOptions = { let initOptions = {
url: this.src, url: this.src,
password: this.password, password: this.password,
withCredentials: true, withCredentials: true,
wasmUrl: new URL('assets/wasm/', this.document.baseURI).toString(),
iccUrl: new URL('assets/iccs/', this.document.baseURI).toString(),
} }
this.loadingTask = getDocument(initOptions) this.loadingTask = getDocument(initOptions)
try { try {
@@ -222,8 +215,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 +237,7 @@ export class PngxPdfViewerComponent
} }
} }
private applyViewerState(applyScale = true): void { private applyViewerState(): void {
if (!this.pdfViewer) { if (!this.pdfViewer) {
return return
} }
@@ -263,14 +254,12 @@ export class PngxPdfViewerComponent
Math.max(Math.trunc(this.page), 1), Math.max(Math.trunc(this.page), 1),
this.pdfViewer.pagesCount this.pdfViewer.pagesCount
) )
if (nextPage !== this.pdfViewer.currentPageNumber) { this.pdfViewer.currentPageNumber = nextPage
this.pdfViewer.currentPageNumber = nextPage
}
} }
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()
@@ -68,7 +68,7 @@
[(ngModel)]="selectionModel.includeUsers" [(ngModel)]="selectionModel.includeUsers"
[disabled]="disabled" [disabled]="disabled"
[clearable]="false" [clearable]="false"
[items]="users()" [items]="users"
bindLabel="username" bindLabel="username"
multiple="true" multiple="true"
bindValue="id" bindValue="id"
@@ -1,12 +1,5 @@
import { NgClass } from '@angular/common' import { NgClass } from '@angular/common'
import { import { Component, EventEmitter, Input, Output, inject } from '@angular/core'
Component,
EventEmitter,
Input,
Output,
inject,
signal,
} from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectComponent } from '@ng-select/ng-select' import { NgSelectComponent } from '@ng-select/ng-select'
@@ -82,7 +75,7 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
@Output() @Output()
ownerFilterSet = new EventEmitter<PermissionsSelectionModel>() ownerFilterSet = new EventEmitter<PermissionsSelectionModel>()
readonly users = signal<User[]>([]) users: User[]
hideUnowned: boolean hideUnowned: boolean
@@ -109,7 +102,7 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
.listAll() .listAll()
.pipe(first()) .pipe(first())
.subscribe({ .subscribe({
next: (result) => this.users.set(result.results), next: (result) => (this.users = result.results),
}) })
} }
} }
@@ -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
} }
@@ -57,15 +57,15 @@
</div> </div>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
@if (socialAccounts().length > 0) { @if (socialAccounts?.length > 0) {
<div class="mb-3"> <div class="mb-3">
<p i18n>Connected social accounts</p> <p i18n>Connected social accounts</p>
<ul class="list-group"> <ul class="list-group">
@for (account of socialAccounts(); track account.id) { @for (account of socialAccounts; track account.id) {
<li class="list-group-item" <li class="list-group-item"
ngbPopover="Set a password before disconnecting social account." ngbPopover="Set a password before disconnecting social account."
i18n-ngbPopover i18n-ngbPopover
[disablePopover]="hasUsablePassword()" [disablePopover]="hasUsablePassword"
triggers="mouseenter:mouseleave"> triggers="mouseenter:mouseleave">
{{account.name}} ({{account.provider}}) {{account.name}} ({{account.provider}})
<pngx-confirm-button <pngx-confirm-button
@@ -75,7 +75,7 @@
i18n-title i18n-title
buttonClasses="btn-outline-danger btn-sm ms-2 align-baseline" buttonClasses="btn-outline-danger btn-sm ms-2 align-baseline"
iconName="trash" iconName="trash"
[disabled]="!hasUsablePassword()" [disabled]="!hasUsablePassword"
(confirm)="disconnectSocialAccount(account.id)"> (confirm)="disconnectSocialAccount(account.id)">
</pngx-confirm-button> </pngx-confirm-button>
</li> </li>
@@ -84,11 +84,11 @@
<div class="form-text text-muted text-end fst-italic" i18n>Warning: disconnecting social accounts cannot be undone</div> <div class="form-text text-muted text-end fst-italic" i18n>Warning: disconnecting social accounts cannot be undone</div>
</div> </div>
} }
@if (socialAccountProviders().length > 0) { @if (socialAccountProviders?.length > 0) {
<div class="mb-3"> <div class="mb-3">
<p i18n>Connect new social account</p> <p i18n>Connect new social account</p>
<div class="list-group"> <div class="list-group">
@for (provider of socialAccountProviders(); track provider.name) { @for (provider of socialAccountProviders; track provider.name) {
<a class="list-group-item list-group-item-action text-primary d-flex align-items-center" href="{{ provider.login_url }}" rel="noopener noreferrer"> <a class="list-group-item list-group-item-action text-primary d-flex align-items-center" href="{{ provider.login_url }}" rel="noopener noreferrer">
{{provider.name}}<i-bs class="pb-1 ms-2" name="box-arrow-up-right"></i-bs> {{provider.name}}<i-bs class="pb-1 ms-2" name="box-arrow-up-right"></i-bs>
</a> </a>
@@ -96,7 +96,7 @@
</div> </div>
</div> </div>
} }
@if (!isTotpEnabled()) { @if (!isTotpEnabled) {
<div ngbAccordion> <div ngbAccordion>
<div ngbAccordionItem> <div ngbAccordionItem>
<h2 ngbAccordionHeader> <h2 ngbAccordionHeader>
@@ -105,10 +105,10 @@
<div ngbAccordionCollapse> <div ngbAccordionCollapse>
<div ngbAccordionBody> <div ngbAccordionBody>
<ng-template> <ng-template>
@if (totpSettingsLoading()) { @if (totpSettingsLoading) {
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div> <div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div> <div class="visually-hidden" i18n>Loading...</div>
} @else if (totpSettings()) { } @else if (totpSettings) {
<figure class="figure"> <figure class="figure">
@if (qrSvgDataUrl) { @if (qrSvgDataUrl) {
<img class="bg-white d-inline-block" [src]="qrSvgDataUrl" alt="Authenticator QR code"> <img class="bg-white d-inline-block" [src]="qrSvgDataUrl" alt="Authenticator QR code">
@@ -116,14 +116,14 @@
<figcaption class="figure-caption text-end mt-2" i18n>Scan the QR code with your authenticator app and then enter the code below</figcaption> <figcaption class="figure-caption text-end mt-2" i18n>Scan the QR code with your authenticator app and then enter the code below</figcaption>
</figure> </figure>
<p> <p>
<ng-container i18n>Authenticator secret</ng-container>: <code>{{totpSettings().secret}}</code>. <ng-container i18n>Authenticator secret</ng-container>: <code>{{totpSettings.secret}}</code>.
<ng-container i18n>You can store this secret and use it to reinstall your authenticator app at a later time.</ng-container> <ng-container i18n>You can store this secret and use it to reinstall your authenticator app at a later time.</ng-container>
</p> </p>
<div class="input-group mb-3"> <div class="input-group mb-3">
<input type="text" class="form-control" formControlName="totp_code" placeholder="Code" i18n-placeholder> <input type="text" class="form-control" formControlName="totp_code" placeholder="Code" i18n-placeholder>
<button type="button" class="btn btn-primary ml-auto" (click)="activateTotp()" [disabled]="totpLoading()"> <button type="button" class="btn btn-primary ml-auto" (click)="activateTotp()" [disabled]="totpLoading">
<ng-container i18n>Enable</ng-container> <ng-container i18n>Enable</ng-container>
@if (totpLoading()) { @if (totpLoading) {
<div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div> <div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div> <div class="visually-hidden" i18n>Loading...</div>
} }
@@ -137,18 +137,18 @@
</div> </div>
} @else { } @else {
<label class="d-block mb-2" i18n>Two-factor Authentication</label> <label class="d-block mb-2" i18n>Two-factor Authentication</label>
@if (recoveryCodes()) { @if (recoveryCodes) {
<div class="alert alert-warning" role="alert"> <div class="alert alert-warning" role="alert">
<i-bs name="exclamation-triangle" class="me-1"></i-bs><ng-container i18n>Recovery codes will not be shown again, make sure to save them.</ng-container> <i-bs name="exclamation-triangle" class="me-1"></i-bs><ng-container i18n>Recovery codes will not be shown again, make sure to save them.</ng-container>
</div> </div>
<div class="d-flex flex-row align-items-start mb-3"> <div class="d-flex flex-row align-items-start mb-3">
<ul class="list-group w-50"> <ul class="list-group w-50">
@for (code of recoveryCodes(); track code; let i = $index) { @for (code of recoveryCodes; track code; let i = $index) {
@if (i % 2 === 0) { @if (i % 2 === 0) {
<li class="list-group-item d-flex justify-content-around align-items-center"> <li class="list-group-item d-flex justify-content-around align-items-center">
<code>{{code}}</code> <code>{{code}}</code>
@if (recoveryCodes()[i + 1]) { @if (recoveryCodes[i + 1]) {
<code>{{recoveryCodes()[i + 1]}}</code> <code>{{recoveryCodes[i + 1]}}</code>
} }
</li> </li>
} }
@@ -171,7 +171,7 @@
i18n-title i18n-title
buttonClasses="btn-outline-danger btn-sm" buttonClasses="btn-outline-danger btn-sm"
iconName="trash" iconName="trash"
[disabled]="totpLoading()" [disabled]="totpLoading"
(confirm)="deactivateTotp()"> (confirm)="deactivateTotp()">
</pngx-confirm-button> </pngx-confirm-button>
} }
@@ -10,7 +10,7 @@ import {
NgbPopoverModule, NgbPopoverModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { NEVER, of, Subject, throwError } from 'rxjs' import { of, throwError } from 'rxjs'
import { ProfileService } from 'src/app/services/profile.service' import { ProfileService } from 'src/app/services/profile.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import * as navUtils from 'src/app/utils/navigation' import * as navUtils from 'src/app/utils/navigation'
@@ -60,10 +60,6 @@ describe('ProfileEditDialogComponent', () => {
providers: [NgbActiveModal, provideHttpClient(withInterceptorsFromDi())], providers: [NgbActiveModal, provideHttpClient(withInterceptorsFromDi())],
}) })
profileService = TestBed.inject(ProfileService) profileService = TestBed.inject(ProfileService)
jest.spyOn(profileService, 'get').mockReturnValue(NEVER)
jest
.spyOn(profileService, 'getSocialAccountProviders')
.mockReturnValue(of([]))
toastService = TestBed.inject(ToastService) toastService = TestBed.inject(ToastService)
clipboard = TestBed.inject(Clipboard) clipboard = TestBed.inject(Clipboard)
fixture = TestBed.createComponent(ProfileEditDialogComponent) fixture = TestBed.createComponent(ProfileEditDialogComponent)
@@ -159,7 +155,7 @@ describe('ProfileEditDialogComponent', () => {
'getSocialAccountProviders' 'getSocialAccountProviders'
) )
getProvidersSpy.mockReturnValue(of(socialAccountProviders)) getProvidersSpy.mockReturnValue(of(socialAccountProviders))
component.hasUsablePassword.set(true) component.hasUsablePassword = true
component.ngOnInit() component.ngOnInit()
component.form.get('password').patchValue('new*pass') component.form.get('password').patchValue('new*pass')
component.onPasswordKeyUp({ component.onPasswordKeyUp({
@@ -272,27 +268,6 @@ describe('ProfileEditDialogComponent', () => {
expect(getProvidersSpy).toHaveBeenCalled() expect(getProvidersSpy).toHaveBeenCalled()
}) })
it('should render social account providers after an async update', async () => {
const providers$ = new Subject<typeof socialAccountProviders>()
jest.spyOn(profileService, 'get').mockReturnValue(of(profile))
jest
.spyOn(profileService, 'getSocialAccountProviders')
.mockReturnValue(providers$)
component.ngOnInit()
await fixture.whenStable()
expect(
fixture.nativeElement.querySelector('a[href="https://example.com"]')
).toBeNull()
providers$.next(socialAccountProviders)
await fixture.whenStable()
expect(
fixture.nativeElement.querySelector('a[href="https://example.com"]')
).not.toBeNull()
})
it('should remove disconnected social account from component, show error if needed', () => { it('should remove disconnected social account from component, show error if needed', () => {
const disconnectSpy = jest.spyOn(profileService, 'disconnectSocialAccount') const disconnectSpy = jest.spyOn(profileService, 'disconnectSocialAccount')
const getSpy = jest.spyOn(profileService, 'get') const getSpy = jest.spyOn(profileService, 'get')
@@ -301,7 +276,7 @@ describe('ProfileEditDialogComponent', () => {
const errorSpy = jest.spyOn(toastService, 'showError') const errorSpy = jest.spyOn(toastService, 'showError')
expect(component.socialAccounts()).toContainEqual(socialAccount) expect(component.socialAccounts).toContainEqual(socialAccount)
// fail first // fail first
disconnectSpy.mockReturnValueOnce( disconnectSpy.mockReturnValueOnce(
@@ -314,7 +289,7 @@ describe('ProfileEditDialogComponent', () => {
disconnectSpy.mockReturnValue(of(socialAccount.id)) disconnectSpy.mockReturnValue(of(socialAccount.id))
component.disconnectSocialAccount(socialAccount.id) component.disconnectSocialAccount(socialAccount.id)
expect(disconnectSpy).toHaveBeenCalled() expect(disconnectSpy).toHaveBeenCalled()
expect(component.socialAccounts()).not.toContainEqual(socialAccount) expect(component.socialAccounts).not.toContainEqual(socialAccount)
}) })
it('should get totp settings', () => { it('should get totp settings', () => {
@@ -335,7 +310,7 @@ describe('ProfileEditDialogComponent', () => {
getSpy.mockReturnValue(of(settings)) getSpy.mockReturnValue(of(settings))
component.gettotpSettings() component.gettotpSettings()
expect(getSpy).toHaveBeenCalled() expect(getSpy).toHaveBeenCalled()
expect(component.totpSettings()).toEqual(settings) expect(component.totpSettings).toEqual(settings)
}) })
it('should activate totp', () => { it('should activate totp', () => {
@@ -344,15 +319,15 @@ describe('ProfileEditDialogComponent', () => {
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const error = new Error('failed to activate totp') const error = new Error('failed to activate totp')
activateSpy.mockReturnValueOnce(throwError(() => error)) activateSpy.mockReturnValueOnce(throwError(() => error))
component.totpSettings.set({ component.totpSettings = {
url: 'http://localhost/', url: 'http://localhost/',
qr_svg: 'svg', qr_svg: 'svg',
secret: 'secret', secret: 'secret',
}) }
component.form.get('totp_code').patchValue('123456') component.form.get('totp_code').patchValue('123456')
component.activateTotp() component.activateTotp()
expect(activateSpy).toHaveBeenCalledWith( expect(activateSpy).toHaveBeenCalledWith(
component.totpSettings().secret, component.totpSettings.secret,
component.form.get('totp_code').value component.form.get('totp_code').value
) )
expect(toastErrorSpy).toHaveBeenCalled() expect(toastErrorSpy).toHaveBeenCalled()
@@ -366,8 +341,8 @@ describe('ProfileEditDialogComponent', () => {
) )
component.activateTotp() component.activateTotp()
expect(toastInfoSpy).toHaveBeenCalled() expect(toastInfoSpy).toHaveBeenCalled()
expect(component.isTotpEnabled()).toBeTruthy() expect(component.isTotpEnabled).toBeTruthy()
expect(component.recoveryCodes()).toEqual(['1', '2', '3']) expect(component.recoveryCodes).toEqual(['1', '2', '3'])
}) })
it('should deactivate totp', () => { it('should deactivate totp', () => {
@@ -387,13 +362,13 @@ describe('ProfileEditDialogComponent', () => {
deactivateSpy.mockReturnValueOnce(of(true)) deactivateSpy.mockReturnValueOnce(of(true))
component.deactivateTotp() component.deactivateTotp()
expect(toastInfoSpy).toHaveBeenCalled() expect(toastInfoSpy).toHaveBeenCalled()
expect(component.isTotpEnabled()).toBeFalsy() expect(component.isTotpEnabled).toBeFalsy()
}) })
it('should copy recovery codes', () => { it('should copy recovery codes', () => {
jest.useFakeTimers() jest.useFakeTimers()
const copySpy = jest.spyOn(clipboard, 'copy') const copySpy = jest.spyOn(clipboard, 'copy')
component.recoveryCodes.set(['1', '2', '3']) component.recoveryCodes = ['1', '2', '3']
component.copyRecoveryCodes() component.copyRecoveryCodes()
expect(copySpy).toHaveBeenCalledWith('1\n2\n3') expect(copySpy).toHaveBeenCalledWith('1\n2\n3')
jest.advanceTimersByTime(3000) jest.advanceTimersByTime(3000)
@@ -56,14 +56,6 @@ export class ProfileEditDialogComponent
readonly showEmailConfirm = signal(false) readonly showEmailConfirm = signal(false)
readonly copied = signal(false) readonly copied = signal(false)
readonly codesCopied = signal(false) readonly codesCopied = signal(false)
readonly hasUsablePassword = signal(false)
readonly isTotpEnabled = signal(false)
readonly totpSettings = signal<TotpSettings>(undefined)
readonly totpSettingsLoading = signal(false)
readonly totpLoading = signal(false)
readonly recoveryCodes = signal<string[]>(undefined)
readonly socialAccounts = signal<SocialAccount[]>([])
readonly socialAccountProviders = signal<SocialAccountProvider[]>([])
public form = new FormGroup({ public form = new FormGroup({
email: new FormControl(''), email: new FormControl(''),
@@ -80,15 +72,25 @@ export class ProfileEditDialogComponent
private newPassword: string private newPassword: string
private passwordConfirm: string private passwordConfirm: string
public hasUsablePassword: boolean = false
private currentEmail: string private currentEmail: string
private newEmail: string private newEmail: string
private emailConfirm: string private emailConfirm: string
public isTotpEnabled: boolean = false
public totpSettings: TotpSettings
public totpSettingsLoading: boolean = false
public totpLoading: boolean = false
public recoveryCodes: string[]
public socialAccounts: SocialAccount[] = []
public socialAccountProviders: SocialAccountProvider[] = []
get qrSvgDataUrl(): string | null { get qrSvgDataUrl(): string | null {
if (!this.totpSettings()?.qr_svg) { if (!this.totpSettings?.qr_svg) {
return null return null
} }
return `data:image/svg+xml;utf8,${encodeURIComponent(this.totpSettings().qr_svg)}` return `data:image/svg+xml;utf8,${encodeURIComponent(this.totpSettings.qr_svg)}`
} }
ngOnInit(): void { ngOnInit(): void {
@@ -105,20 +107,20 @@ export class ProfileEditDialogComponent
this.onEmailChange() this.onEmailChange()
}) })
this.currentPassword = profile.password this.currentPassword = profile.password
this.hasUsablePassword.set(profile.has_usable_password) this.hasUsablePassword = profile.has_usable_password
this.form.get('password').valueChanges.subscribe((newPassword) => { this.form.get('password').valueChanges.subscribe((newPassword) => {
this.newPassword = newPassword this.newPassword = newPassword
this.onPasswordChange() this.onPasswordChange()
}) })
this.socialAccounts.set(profile.social_accounts ?? []) this.socialAccounts = profile.social_accounts
this.isTotpEnabled.set(profile.is_mfa_enabled) this.isTotpEnabled = profile.is_mfa_enabled
}) })
this.profileService this.profileService
.getSocialAccountProviders() .getSocialAccountProviders()
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((providers) => { .subscribe((providers) => {
this.socialAccountProviders.set(providers ?? []) this.socialAccountProviders = providers
}) })
} }
@@ -257,9 +259,7 @@ export class ProfileEditDialogComponent
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: (id: number) => { next: (id: number) => {
this.socialAccounts.update((accounts) => this.socialAccounts = this.socialAccounts.filter((a) => a.id != id)
accounts.filter((account) => account.id != id)
)
}, },
error: (error) => { error: (error) => {
this.toastService.showError( this.toastService.showError(
@@ -271,39 +271,36 @@ export class ProfileEditDialogComponent
} }
public gettotpSettings(): void { public gettotpSettings(): void {
this.totpSettingsLoading.set(true) this.totpSettingsLoading = true
this.profileService this.profileService
.getTotpSettings() .getTotpSettings()
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: (totpSettings) => { next: (totpSettings) => {
this.totpSettingsLoading.set(false) this.totpSettingsLoading = false
this.totpSettings.set(totpSettings) this.totpSettings = totpSettings
}, },
error: (error) => { error: (error) => {
this.toastService.showError( this.toastService.showError(
$localize`Error fetching TOTP settings`, $localize`Error fetching TOTP settings`,
error error
) )
this.totpSettingsLoading.set(false) this.totpSettingsLoading = false
}, },
}) })
} }
public activateTotp(): void { public activateTotp(): void {
this.totpLoading.set(true) this.totpLoading = true
this.form.get('totp_code').disable() this.form.get('totp_code').disable()
this.profileService this.profileService
.activateTotp( .activateTotp(this.totpSettings.secret, this.form.get('totp_code').value)
this.totpSettings().secret,
this.form.get('totp_code').value
)
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: (activationResponse) => { next: (activationResponse) => {
this.totpLoading.set(false) this.totpLoading = false
this.isTotpEnabled.set(activationResponse.success) this.isTotpEnabled = activationResponse.success
this.recoveryCodes.set(activationResponse.recovery_codes) this.recoveryCodes = activationResponse.recovery_codes
this.form.get('totp_code').enable() this.form.get('totp_code').enable()
if (activationResponse.success) { if (activationResponse.success) {
this.toastService.showInfo($localize`TOTP activated successfully`) this.toastService.showInfo($localize`TOTP activated successfully`)
@@ -312,7 +309,7 @@ export class ProfileEditDialogComponent
} }
}, },
error: (error) => { error: (error) => {
this.totpLoading.set(false) this.totpLoading = false
this.form.get('totp_code').enable() this.form.get('totp_code').enable()
this.toastService.showError($localize`Error activating TOTP`, error) this.toastService.showError($localize`Error activating TOTP`, error)
}, },
@@ -320,15 +317,15 @@ export class ProfileEditDialogComponent
} }
public deactivateTotp(): void { public deactivateTotp(): void {
this.totpLoading.set(true) this.totpLoading = true
this.profileService this.profileService
.deactivateTotp() .deactivateTotp()
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: (success) => { next: (success) => {
this.totpLoading.set(false) this.totpLoading = false
this.isTotpEnabled.set(!success) this.isTotpEnabled = !success
this.recoveryCodes.set(null) this.recoveryCodes = null
if (success) { if (success) {
this.toastService.showInfo($localize`TOTP deactivated successfully`) this.toastService.showInfo($localize`TOTP deactivated successfully`)
} else { } else {
@@ -336,14 +333,14 @@ export class ProfileEditDialogComponent
} }
}, },
error: (error) => { error: (error) => {
this.totpLoading.set(false) this.totpLoading = false
this.toastService.showError($localize`Error deactivating TOTP`, error) this.toastService.showError($localize`Error deactivating TOTP`, error)
}, },
}) })
} }
public copyRecoveryCodes(): void { public copyRecoveryCodes(): void {
this.clipboard.copy(this.recoveryCodes().join('\n')) this.clipboard.copy(this.recoveryCodes.join('\n'))
this.codesCopied.set(true) this.codesCopied.set(true)
setTimeout(() => { setTimeout(() => {
this.codesCopied.set(false) this.codesCopied.set(false)
@@ -65,9 +65,8 @@
<dd class="col-sm-8"><code>{{ createdBundle.slug }}</code></dd> <dd class="col-sm-8"><code>{{ createdBundle.slug }}</code></dd>
<dt class="col-sm-4" i18n>Link</dt> <dt class="col-sm-4" i18n>Link</dt>
<dd class="col-sm-8"> <dd class="col-sm-8">
<label class="visually-hidden" for="shareBundleLink" i18n>Share link</label>
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<input id="shareBundleLink" class="form-control" type="text" [value]="getShareUrl(createdBundle)" readonly> <input class="form-control" type="text" [value]="getShareUrl(createdBundle)" readonly>
<button <button
class="btn btn-outline-primary" class="btn btn-outline-primary"
type="button" type="button"
@@ -14,9 +14,7 @@
} }
<div content class="wrapper fade" [class.show]="show()"> <div content class="wrapper fade" [class.show]="show()">
@if (error()) { @if (displayMode() === DisplayMode.TABLE) {
<div class="alert alert-danger mb-0" role="alert"><ng-container i18n>Error while loading documents</ng-container>: {{error()}}</div>
} @else if (displayMode() === DisplayMode.TABLE) {
<table class="table table-hover mb-0 mt-n2 align-middle"> <table class="table table-hover mb-0 mt-n2 align-middle">
<thead> <thead>
<tr> <tr>
@@ -1,10 +1,6 @@
import { DragDropModule } from '@angular/cdk/drag-drop' import { DragDropModule } from '@angular/cdk/drag-drop'
import { DatePipe } from '@angular/common' import { DatePipe } from '@angular/common'
import { import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
HttpErrorResponse,
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 { By } from '@angular/platform-browser' import { By } from '@angular/platform-browser'
@@ -12,7 +8,7 @@ import { Router } from '@angular/router'
import { RouterTestingModule } from '@angular/router/testing' import { RouterTestingModule } from '@angular/router/testing'
import { NgbModule } from '@ng-bootstrap/ng-bootstrap' import { NgbModule } 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 } from 'rxjs'
import { routes } from 'src/app/app-routing.module' import { routes } from 'src/app/app-routing.module'
import { CustomFieldDisplayComponent } from 'src/app/components/common/custom-field-display/custom-field-display.component' import { CustomFieldDisplayComponent } from 'src/app/components/common/custom-field-display/custom-field-display.component'
import { PreviewPopupComponent } from 'src/app/components/common/preview-popup/preview-popup.component' import { PreviewPopupComponent } from 'src/app/components/common/preview-popup/preview-popup.component'
@@ -234,27 +230,6 @@ describe('SavedViewWidgetComponent', () => {
expect(component.documents()).toEqual(documentResults) expect(component.documents()).toEqual(documentResults)
}) })
it('should show an error if documents fail to load', () => {
jest.spyOn(documentService, 'listFiltered').mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
error: { added__date__lte: ['Enter a valid date.'] },
status: 400,
})
)
)
component.reload()
fixture.detectChanges()
expect(component.loading()).toBe(false)
expect(component.error()).toEqual('Added: Enter a valid date.')
expect(fixture.debugElement.nativeElement.textContent).toContain(
'Error while loading documents: Added: Enter a valid date.'
)
})
it('should reload on document consumption finished', () => { it('should reload on document consumption finished', () => {
const fileStatusSubject = new Subject<FileStatus>() const fileStatusSubject = new Subject<FileStatus>()
jest jest
@@ -125,8 +125,6 @@ export class SavedViewWidgetComponent
readonly count = signal<number>(null) readonly count = signal<number>(null)
readonly error = signal<string | null>(null)
placeholderRows: number[] = [] placeholderRows: number[] = []
ngOnInit(): void { ngOnInit(): void {
@@ -182,7 +180,6 @@ export class SavedViewWidgetComponent
reload() { reload() {
this.loading.set(this.documents().length == 0) this.loading.set(this.documents().length == 0)
this.error.set(null)
this.show.set(true) this.show.set(true)
this.documentService this.documentService
.listFiltered( .listFiltered(
@@ -194,40 +191,12 @@ export class SavedViewWidgetComponent
{ truncate_content: true } { truncate_content: true }
) )
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe((result) => {
next: (result) => { this.documents.set(result.results)
this.documents.set(result.results) this.count.set(result.count)
this.count.set(result.count) this.savedViewService.setDocumentCount(this.savedView, result.count)
this.savedViewService.setDocumentCount(this.savedView, result.count) this.loading.set(false)
this.loading.set(false) this.show.set(true)
this.show.set(true)
},
error: (error) => {
this.documents.set([])
this.count.set(null)
let errorMessage
if (
typeof error.error === 'object' &&
Object.keys(error.error).length > 0
) {
errorMessage = Object.keys(error.error)
.map((fieldName) => {
const fieldNameBase = fieldName.split('__')[0]
const fieldError: Array<string> = error.error[fieldName]
return `${
this.documentService.sortFields.find(
(f) => f.field?.split('__')[0] == fieldNameBase
)?.name ?? fieldNameBase
}: ${fieldError[0]}`
})
.join(', ')
} else {
errorMessage = error.error
}
this.error.set(errorMessage)
this.loading.set(false)
this.show.set(true)
},
}) })
} }
@@ -1 +1 @@
<p i18n>Searching document with asn {{asn()}}</p> <p i18n>Searching document with asn {{asn}}</p>
@@ -1,4 +1,4 @@
import { Component, OnInit, inject, signal } from '@angular/core' import { Component, OnInit, inject } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router' import { ActivatedRoute, Router } from '@angular/router'
import { FILTER_ASN } from '../../data/filter-rule-type' import { FILTER_ASN } from '../../data/filter-rule-type'
import { DocumentService } from '../../services/rest/document.service' import { DocumentService } from '../../services/rest/document.service'
@@ -13,13 +13,13 @@ export class DocumentAsnComponent implements OnInit {
private route = inject(ActivatedRoute) private route = inject(ActivatedRoute)
private router = inject(Router) private router = inject(Router)
readonly asn = signal<string>(undefined) asn: string
ngOnInit(): void { ngOnInit(): void {
this.route.paramMap.subscribe((paramMap) => { this.route.paramMap.subscribe((paramMap) => {
this.asn.set(paramMap.get('id')) this.asn = paramMap.get('id')
this.documentsService this.documentsService
.listAllFilteredIds([{ rule_type: FILTER_ASN, value: this.asn() }]) .listAllFilteredIds([{ rule_type: FILTER_ASN, value: this.asn }])
.subscribe((documentId) => { .subscribe((documentId) => {
if (documentId.length == 1) { if (documentId.length == 1) {
this.router.navigate(['documents', documentId[0]]) this.router.navigate(['documents', documentId[0]])
@@ -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>
@@ -319,7 +315,7 @@
<td>{{metadata()?.original_filename}}</td> <td>{{metadata()?.original_filename}}</td>
</tr> </tr>
<tr> <tr>
<td i18n>Original SHA256 checksum</td> <td i18n>Original MD5 checksum</td>
<td>{{metadata()?.original_checksum}}</td> <td>{{metadata()?.original_checksum}}</td>
</tr> </tr>
<tr> <tr>
@@ -332,7 +328,7 @@
</tr> </tr>
@if (metadata()?.has_archive_version) { @if (metadata()?.has_archive_version) {
<tr> <tr>
<td i18n>Archive SHA256 checksum</td> <td i18n>Archive MD5 checksum</td>
<td>{{metadata()?.archive_checksum}}</td> <td>{{metadata()?.archive_checksum}}</td>
</tr> </tr>
} }
@@ -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)"
@@ -487,7 +482,9 @@
</pngx-pdf-viewer> </pngx-pdf-viewer>
</div> </div>
} @else { } @else {
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%"></object> <object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%">
<span>Preview is unavailable.</span>
</object>
} }
} }
@case (ContentRenderType.Text) { @case (ContentRenderType.Text) {
@@ -508,7 +505,9 @@
} }
} }
@case (ContentRenderType.Other) { @case (ContentRenderType.Other) {
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%"></object> <object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%">
<span>Preview is unavailable.</span>
</object>
} }
} }
@if (requiresPassword) { @if (requiresPassword) {
@@ -510,7 +510,7 @@ describe('DocumentDetailComponent', () => {
const deleteButtons = fixture.debugElement.queryAll( const deleteButtons = fixture.debugElement.queryAll(
By.css('pngx-confirm-button') By.css('pngx-confirm-button')
) )
expect(deleteButtons).toHaveLength(1) expect(deleteButtons.length).toEqual(1)
}) })
it('should fall back to details tab when duplicates tab is active but no duplicates', () => { it('should fall back to details tab when duplicates tab is active but no duplicates', () => {
@@ -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', () => {
@@ -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),
@@ -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()
} }
@@ -1892,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,
@@ -1906,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'
@@ -1923,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)
})
} }
}) })
} }
@@ -1948,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

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