Compare commits

..

2 Commits

Author SHA1 Message Date
Trenton H
6b0f3e93cd Fix: Add directory marker entries to zip exports
Without explicit directory entries, some zip viewers (simpler tools,
web-based viewers) don't show the folder structure when browsing the
archive. Add a _ensure_zip_dirs() helper that writes directory markers
for all parent paths of each file entry, deduplicating via a set.
Uses ZipFile.mkdir() (available since Python 3.11, the project minimum).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 09:19:42 -07:00
Trenton H
882ae7a3d8 Refactor: Write zip exports directly into ZipFile instead of temp dir
Replace the temp-dir + shutil.make_archive() workaround with direct
zipfile.ZipFile writes. Document files are added via zf.write() and
JSON manifests via zf.writestr()/StringIO buffering, eliminating the
double-I/O and 2x disk usage of the previous approach.

Key changes:
- Removed tempfile.TemporaryDirectory and shutil.make_archive() from handle()
- ZipFile opened on a .tmp path; renamed to final .zip atomically on success;
  .tmp cleaned up on failure
- StreamingManifestWriter: zip mode buffers manifest in io.StringIO and
  writes to zip atomically on close() (zipfile allows only one open write
  handle at a time)
- check_and_copy(): zip mode calls zf.write(source, arcname=...) directly
- check_and_write_json(): zip mode calls zf.writestr(arcname, ...) directly
- files_in_export_dir scan skipped in zip mode (always fresh write)
- --compare-checksums and --compare-json emit warnings when used with --zip
- --delete in zip mode removes pre-existing files from target dir, skipping
  the in-progress .tmp and any prior .zip
- Added tests: atomicity on failure, no SCRATCH_DIR usage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 09:13:12 -07:00
310 changed files with 14111 additions and 22856 deletions

View File

@@ -21,7 +21,6 @@ body:
- [The installation instructions](https://docs.paperless-ngx.com/setup/#installation). - [The installation instructions](https://docs.paperless-ngx.com/setup/#installation).
- [Existing issues and discussions](https://github.com/paperless-ngx/paperless-ngx/search?q=&type=issues). - [Existing issues and discussions](https://github.com/paperless-ngx/paperless-ngx/search?q=&type=issues).
- Disable any custom container initialization scripts, if using - Disable any custom container initialization scripts, if using
- Remove any third-party parser plugins — issues caused by or requiring changes to a third-party plugin will be closed without investigation.
If you encounter issues while installing or configuring Paperless-ngx, please post in the ["Support" section of the discussions](https://github.com/paperless-ngx/paperless-ngx/discussions/new?category=support). If you encounter issues while installing or configuring Paperless-ngx, please post in the ["Support" section of the discussions](https://github.com/paperless-ngx/paperless-ngx/discussions/new?category=support).
- type: textarea - type: textarea

View File

@@ -12,8 +12,6 @@ updates:
open-pull-requests-limit: 10 open-pull-requests-limit: 10
schedule: schedule:
interval: "monthly" interval: "monthly"
cooldown:
default-days: 7
labels: labels:
- "frontend" - "frontend"
- "dependencies" - "dependencies"
@@ -38,9 +36,7 @@ updates:
directory: "/" directory: "/"
# Check for updates once a week # Check for updates once a week
schedule: schedule:
interval: "monthly" interval: "weekly"
cooldown:
default-days: 7
labels: labels:
- "backend" - "backend"
- "dependencies" - "dependencies"
@@ -101,8 +97,6 @@ updates:
schedule: schedule:
# Check for updates to GitHub Actions every month # Check for updates to GitHub Actions every month
interval: "monthly" interval: "monthly"
cooldown:
default-days: 7
labels: labels:
- "ci-cd" - "ci-cd"
- "dependencies" - "dependencies"
@@ -118,9 +112,7 @@ updates:
- "/" - "/"
- "/.devcontainer/" - "/.devcontainer/"
schedule: schedule:
interval: "monthly" interval: "weekly"
cooldown:
default-days: 7
open-pull-requests-limit: 5 open-pull-requests-limit: 5
labels: labels:
- "dependencies" - "dependencies"
@@ -131,9 +123,7 @@ updates:
- package-ecosystem: "docker-compose" - package-ecosystem: "docker-compose"
directory: "/docker/compose/" directory: "/docker/compose/"
schedule: schedule:
interval: "monthly" interval: "weekly"
cooldown:
default-days: 7
open-pull-requests-limit: 5 open-pull-requests-limit: 5
labels: labels:
- "dependencies" - "dependencies"
@@ -157,14 +147,3 @@ updates:
postgres: postgres:
patterns: patterns:
- "docker.io/library/postgres*" - "docker.io/library/postgres*"
greenmail:
patterns:
- "docker.io/greenmail*"
- package-ecosystem: "pre-commit" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "monthly"
groups:
pre-commit-dependencies:
patterns:
- "*"

View File

@@ -3,9 +3,21 @@ on:
push: push:
branches-ignore: branches-ignore:
- 'translations**' - 'translations**'
paths:
- 'src/**'
- 'pyproject.toml'
- 'uv.lock'
- 'docker/compose/docker-compose.ci-test.yml'
- '.github/workflows/ci-backend.yml'
pull_request: pull_request:
branches-ignore: branches-ignore:
- 'translations**' - 'translations**'
paths:
- 'src/**'
- 'pyproject.toml'
- 'uv.lock'
- 'docker/compose/docker-compose.ci-test.yml'
- '.github/workflows/ci-backend.yml'
workflow_dispatch: workflow_dispatch:
concurrency: concurrency:
group: backend-${{ github.event.pull_request.number || github.ref }} group: backend-${{ github.event.pull_request.number || github.ref }}
@@ -14,55 +26,7 @@ env:
DEFAULT_UV_VERSION: "0.10.x" DEFAULT_UV_VERSION: "0.10.x"
NLTK_DATA: "/usr/share/nltk_data" NLTK_DATA: "/usr/share/nltk_data"
jobs: jobs:
changes:
name: Detect Backend Changes
runs-on: ubuntu-slim
outputs:
backend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.backend == 'true' }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Decide run mode
id: force
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "run_all=true" >> "$GITHUB_OUTPUT"
elif [[ "${{ github.event_name }}" == "push" && ( "${{ github.ref_name }}" == "main" || "${{ github.ref_name }}" == "dev" ) ]]; then
echo "run_all=true" >> "$GITHUB_OUTPUT"
else
echo "run_all=false" >> "$GITHUB_OUTPUT"
fi
- name: Set diff range
id: range
if: steps.force.outputs.run_all != 'true'
run: |
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "base=${{ github.event.pull_request.base.sha }}" >> "$GITHUB_OUTPUT"
elif [[ "${{ github.event.created }}" == "true" ]]; then
echo "base=${{ github.event.repository.default_branch }}" >> "$GITHUB_OUTPUT"
else
echo "base=${{ github.event.before }}" >> "$GITHUB_OUTPUT"
fi
echo "ref=${{ github.sha }}" >> "$GITHUB_OUTPUT"
- name: Detect changes
id: filter
if: steps.force.outputs.run_all != 'true'
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
base: ${{ steps.range.outputs.base }}
ref: ${{ steps.range.outputs.ref }}
filters: |
backend:
- 'src/**'
- 'pyproject.toml'
- 'uv.lock'
- 'docker/compose/docker-compose.ci-test.yml'
- '.github/workflows/ci-backend.yml'
test: test:
needs: changes
if: needs.changes.outputs.backend_changed == 'true'
name: "Python ${{ matrix.python-version }}" name: "Python ${{ matrix.python-version }}"
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
strategy: strategy:
@@ -71,18 +35,18 @@ jobs:
fail-fast: false fail-fast: false
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@v6.0.2
- name: Start containers - name: Start containers
run: | run: |
docker compose --file docker/compose/docker-compose.ci-test.yml pull --quiet docker compose --file docker/compose/docker-compose.ci-test.yml pull --quiet
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@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 uses: actions/setup-python@v6.2.0
with: with:
python-version: "${{ matrix.python-version }}" python-version: "${{ matrix.python-version }}"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1 uses: astral-sh/setup-uv@v7.3.1
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
@@ -119,13 +83,13 @@ jobs:
pytest pytest
- name: Upload test results to Codecov - name: Upload test results to Codecov
if: always() if: always()
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 uses: codecov/codecov-action@v5.5.2
with: with:
flags: backend-python-${{ matrix.python-version }} flags: backend-python-${{ matrix.python-version }}
files: junit.xml files: junit.xml
report_type: test_results report_type: test_results
- name: Upload coverage to Codecov - name: Upload coverage to Codecov
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 uses: codecov/codecov-action@v5.5.2
with: with:
flags: backend-python-${{ matrix.python-version }} flags: backend-python-${{ matrix.python-version }}
files: coverage.xml files: coverage.xml
@@ -136,22 +100,20 @@ jobs:
docker compose --file docker/compose/docker-compose.ci-test.yml logs docker compose --file docker/compose/docker-compose.ci-test.yml logs
docker compose --file docker/compose/docker-compose.ci-test.yml down docker compose --file docker/compose/docker-compose.ci-test.yml down
typing: typing:
needs: changes
if: needs.changes.outputs.backend_changed == 'true'
name: Check project typing name: Check project typing
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
env: env:
DEFAULT_PYTHON: "3.12" DEFAULT_PYTHON: "3.12"
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@v6.0.2
- name: Set up Python - name: Set up Python
id: setup-python id: setup-python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 uses: actions/setup-python@v6.2.0
with: with:
python-version: "${{ env.DEFAULT_PYTHON }}" python-version: "${{ env.DEFAULT_PYTHON }}"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1 uses: astral-sh/setup-uv@v7.3.1
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
@@ -173,7 +135,7 @@ jobs:
check \ check \
src/ src/
- name: Cache Mypy - name: Cache Mypy
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 uses: actions/cache@v5.0.3
with: with:
path: .mypy_cache path: .mypy_cache
# Keyed by OS, Python version, and dependency hashes # Keyed by OS, Python version, and dependency hashes
@@ -188,27 +150,3 @@ jobs:
--show-error-codes \ --show-error-codes \
--warn-unused-configs \ --warn-unused-configs \
src/ | uv run mypy-baseline filter src/ | uv run mypy-baseline filter
gate:
name: Backend CI Gate
needs: [changes, test, typing]
if: always()
runs-on: ubuntu-slim
steps:
- name: Check gate
run: |
if [[ "${{ needs.changes.outputs.backend_changed }}" != "true" ]]; then
echo "No backend-relevant changes detected."
exit 0
fi
if [[ "${{ needs.test.result }}" != "success" ]]; then
echo "::error::Backend test job result: ${{ needs.test.result }}"
exit 1
fi
if [[ "${{ needs.typing.result }}" != "success" ]]; then
echo "::error::Backend typing job result: ${{ needs.typing.result }}"
exit 1
fi
echo "Backend checks passed."

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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@v6.0.2
- name: Determine ref name - name: Determine ref name
id: ref id: ref
run: | run: |
@@ -104,9 +104,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@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 uses: docker/setup-buildx-action@v3.12.0
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 uses: docker/login-action@v3.7.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -119,7 +119,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@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 uses: docker/metadata-action@v5.10.0
with: with:
images: | images: |
${{ env.REGISTRY }}/${{ steps.repo.outputs.name }} ${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}
@@ -130,7 +130,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@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 uses: docker/build-push-action@v6.19.2
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
@@ -152,7 +152,7 @@ jobs:
echo "${digest}" > "/tmp/digests/digest-${{ matrix.arch }}.txt" echo "${digest}" > "/tmp/digests/digest-${{ matrix.arch }}.txt"
- name: Upload digest - name: Upload digest
if: steps.check-push.outputs.should-push == 'true' if: steps.check-push.outputs.should-push == 'true'
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 uses: actions/upload-artifact@v7.0.0
with: with:
name: digests-${{ matrix.arch }} name: digests-${{ matrix.arch }}
path: /tmp/digests/digest-${{ matrix.arch }}.txt path: /tmp/digests/digest-${{ matrix.arch }}.txt
@@ -169,7 +169,7 @@ jobs:
packages: write packages: write
steps: steps:
- name: Download digests - name: Download digests
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 uses: actions/download-artifact@v8.0.0
with: with:
path: /tmp/digests path: /tmp/digests
pattern: digest-*.txt pattern: digest-*.txt
@@ -179,29 +179,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@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 uses: docker/setup-buildx-action@v3.12.0
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 uses: docker/login-action@v3.7.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@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 uses: docker/login-action@v3.7.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@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 uses: docker/login-action@v3.7.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@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 uses: docker/metadata-action@v5.10.0
with: with:
images: | images: |
${{ env.REGISTRY }}/${{ needs.build-arch.outputs.repository }} ${{ env.REGISTRY }}/${{ needs.build-arch.outputs.repository }}

View File

@@ -1,9 +1,22 @@
name: Documentation name: Documentation
on: on:
push: push:
branches-ignore: branches:
- 'translations**' - main
- dev
paths:
- 'docs/**'
- 'zensical.toml'
- 'pyproject.toml'
- 'uv.lock'
- '.github/workflows/ci-docs.yml'
pull_request: pull_request:
paths:
- 'docs/**'
- 'zensical.toml'
- 'pyproject.toml'
- 'uv.lock'
- '.github/workflows/ci-docs.yml'
workflow_dispatch: workflow_dispatch:
concurrency: concurrency:
group: docs-${{ github.event.pull_request.number || github.ref }} group: docs-${{ github.event.pull_request.number || github.ref }}
@@ -16,68 +29,20 @@ env:
DEFAULT_UV_VERSION: "0.10.x" DEFAULT_UV_VERSION: "0.10.x"
DEFAULT_PYTHON_VERSION: "3.12" DEFAULT_PYTHON_VERSION: "3.12"
jobs: jobs:
changes:
name: Detect Docs Changes
runs-on: ubuntu-slim
outputs:
docs_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.docs == 'true' }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Decide run mode
id: force
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "run_all=true" >> "$GITHUB_OUTPUT"
elif [[ "${{ github.event_name }}" == "push" && ( "${{ github.ref_name }}" == "main" || "${{ github.ref_name }}" == "dev" ) ]]; then
echo "run_all=true" >> "$GITHUB_OUTPUT"
else
echo "run_all=false" >> "$GITHUB_OUTPUT"
fi
- name: Set diff range
id: range
if: steps.force.outputs.run_all != 'true'
run: |
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "base=${{ github.event.pull_request.base.sha }}" >> "$GITHUB_OUTPUT"
elif [[ "${{ github.event.created }}" == "true" ]]; then
echo "base=${{ github.event.repository.default_branch }}" >> "$GITHUB_OUTPUT"
else
echo "base=${{ github.event.before }}" >> "$GITHUB_OUTPUT"
fi
echo "ref=${{ github.sha }}" >> "$GITHUB_OUTPUT"
- name: Detect changes
id: filter
if: steps.force.outputs.run_all != 'true'
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
base: ${{ steps.range.outputs.base }}
ref: ${{ steps.range.outputs.ref }}
filters: |
docs:
- 'docs/**'
- 'zensical.toml'
- 'pyproject.toml'
- 'uv.lock'
- '.github/workflows/ci-docs.yml'
build: build:
needs: changes
if: needs.changes.outputs.docs_changed == 'true'
name: Build Documentation name: Build Documentation
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0 - uses: actions/configure-pages@v5.0.0
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@v6.0.2
- name: Set up Python - name: Set up Python
id: setup-python id: setup-python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 uses: actions/setup-python@v6.2.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@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1 uses: astral-sh/setup-uv@v7.3.1
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
@@ -93,40 +58,21 @@ jobs:
--frozen \ --frozen \
zensical build --clean zensical build --clean
- name: Upload GitHub Pages artifact - name: Upload GitHub Pages artifact
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0 uses: actions/upload-pages-artifact@v4.0.0
with: with:
path: site path: site
name: github-pages-${{ github.run_id }}-${{ github.run_attempt }} name: github-pages-${{ github.run_id }}-${{ github.run_attempt }}
deploy: deploy:
name: Deploy Documentation name: Deploy Documentation
needs: [changes, build] needs: build
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changes.outputs.docs_changed == 'true' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
environment: environment:
name: github-pages name: github-pages
url: ${{ steps.deployment.outputs.page_url }} url: ${{ steps.deployment.outputs.page_url }}
steps: steps:
- name: Deploy GitHub Pages - name: Deploy GitHub Pages
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 uses: actions/deploy-pages@v4.0.5
id: deployment id: deployment
with: with:
artifact_name: github-pages-${{ github.run_id }}-${{ github.run_attempt }} artifact_name: github-pages-${{ github.run_id }}-${{ github.run_attempt }}
gate:
name: Docs CI Gate
needs: [changes, build]
if: always()
runs-on: ubuntu-slim
steps:
- name: Check gate
run: |
if [[ "${{ needs.changes.outputs.docs_changed }}" != "true" ]]; then
echo "No docs-relevant changes detected."
exit 0
fi
if [[ "${{ needs.build.result }}" != "success" ]]; then
echo "::error::Docs build job result: ${{ needs.build.result }}"
exit 1
fi
echo "Docs checks passed."

View File

@@ -3,78 +3,39 @@ on:
push: push:
branches-ignore: branches-ignore:
- 'translations**' - 'translations**'
paths:
- 'src-ui/**'
- '.github/workflows/ci-frontend.yml'
pull_request: pull_request:
branches-ignore: branches-ignore:
- 'translations**' - 'translations**'
paths:
- 'src-ui/**'
- '.github/workflows/ci-frontend.yml'
workflow_dispatch: workflow_dispatch:
concurrency: concurrency:
group: frontend-${{ github.event.pull_request.number || github.ref }} group: frontend-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
changes:
name: Detect Frontend Changes
runs-on: ubuntu-slim
outputs:
frontend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.frontend == 'true' }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Decide run mode
id: force
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "run_all=true" >> "$GITHUB_OUTPUT"
elif [[ "${{ github.event_name }}" == "push" && ( "${{ github.ref_name }}" == "main" || "${{ github.ref_name }}" == "dev" ) ]]; then
echo "run_all=true" >> "$GITHUB_OUTPUT"
else
echo "run_all=false" >> "$GITHUB_OUTPUT"
fi
- name: Set diff range
id: range
if: steps.force.outputs.run_all != 'true'
run: |
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "base=${{ github.event.pull_request.base.sha }}" >> "$GITHUB_OUTPUT"
elif [[ "${{ github.event.created }}" == "true" ]]; then
echo "base=${{ github.event.repository.default_branch }}" >> "$GITHUB_OUTPUT"
else
echo "base=${{ github.event.before }}" >> "$GITHUB_OUTPUT"
fi
echo "ref=${{ github.sha }}" >> "$GITHUB_OUTPUT"
- name: Detect changes
id: filter
if: steps.force.outputs.run_all != 'true'
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
base: ${{ steps.range.outputs.base }}
ref: ${{ steps.range.outputs.ref }}
filters: |
frontend:
- 'src-ui/**'
- '.github/workflows/ci-frontend.yml'
install-dependencies: install-dependencies:
needs: changes
if: needs.changes.outputs.frontend_changed == 'true'
name: Install Dependencies name: Install Dependencies
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@v6.0.2
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@v4.2.0
with: with:
version: 10 version: 10
- name: Use Node.js 24 - name: Use Node.js 24
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@v6.2.0
with: with:
node-version: 24.x node-version: 24.x
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml' cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies - name: Cache frontend dependencies
id: cache-frontend-deps id: cache-frontend-deps
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 uses: actions/cache@v5.0.3
with: with:
path: | path: |
~/.pnpm-store ~/.pnpm-store
@@ -84,24 +45,23 @@ jobs:
run: cd src-ui && pnpm install run: cd src-ui && pnpm install
lint: lint:
name: Lint name: Lint
needs: [changes, install-dependencies] needs: install-dependencies
if: needs.changes.outputs.frontend_changed == 'true'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@v6.0.2
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@v4.2.0
with: with:
version: 10 version: 10
- name: Use Node.js 24 - name: Use Node.js 24
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@v6.2.0
with: with:
node-version: 24.x node-version: 24.x
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml' cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies - name: Cache frontend dependencies
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 uses: actions/cache@v5.0.3
with: with:
path: | path: |
~/.pnpm-store ~/.pnpm-store
@@ -113,8 +73,7 @@ jobs:
run: cd src-ui && pnpm run lint run: cd src-ui && pnpm run lint
unit-tests: unit-tests:
name: "Unit Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})" name: "Unit Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})"
needs: [changes, install-dependencies] needs: install-dependencies
if: needs.changes.outputs.frontend_changed == 'true'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
strategy: strategy:
fail-fast: false fail-fast: false
@@ -124,19 +83,19 @@ jobs:
shard-count: [4] shard-count: [4]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@v6.0.2
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@v4.2.0
with: with:
version: 10 version: 10
- name: Use Node.js 24 - name: Use Node.js 24
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@v6.2.0
with: with:
node-version: 24.x node-version: 24.x
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml' cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies - name: Cache frontend dependencies
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 uses: actions/cache@v5.0.3
with: with:
path: | path: |
~/.pnpm-store ~/.pnpm-store
@@ -148,20 +107,19 @@ jobs:
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
if: always() if: always()
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 uses: codecov/codecov-action@v5.5.2
with: with:
flags: frontend-node-${{ matrix.node-version }} flags: frontend-node-${{ matrix.node-version }}
directory: src-ui/ directory: src-ui/
report_type: test_results report_type: test_results
- name: Upload coverage to Codecov - name: Upload coverage to Codecov
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 uses: codecov/codecov-action@v5.5.2
with: with:
flags: frontend-node-${{ matrix.node-version }} flags: frontend-node-${{ matrix.node-version }}
directory: src-ui/coverage/ directory: src-ui/coverage/
e2e-tests: e2e-tests:
name: "E2E Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})" name: "E2E Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})"
needs: [changes, install-dependencies] needs: install-dependencies
if: needs.changes.outputs.frontend_changed == 'true'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
container: mcr.microsoft.com/playwright:v1.58.2-noble container: mcr.microsoft.com/playwright:v1.58.2-noble
env: env:
@@ -175,19 +133,19 @@ jobs:
shard-count: [2] shard-count: [2]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@v6.0.2
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@v4.2.0
with: with:
version: 10 version: 10
- name: Use Node.js 24 - name: Use Node.js 24
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@v6.2.0
with: with:
node-version: 24.x node-version: 24.x
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml' cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies - name: Cache frontend dependencies
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 uses: actions/cache@v5.0.3
with: with:
path: | path: |
~/.pnpm-store ~/.pnpm-store
@@ -201,26 +159,25 @@ jobs:
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 }}
bundle-analysis: bundle-analysis:
name: Bundle Analysis name: Bundle Analysis
needs: [changes, unit-tests, e2e-tests] needs: [unit-tests, e2e-tests]
if: needs.changes.outputs.frontend_changed == 'true'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@v6.0.2
with: with:
fetch-depth: 2 fetch-depth: 2
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@v4.2.0
with: with:
version: 10 version: 10
- name: Use Node.js 24 - name: Use Node.js 24
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@v6.2.0
with: with:
node-version: 24.x node-version: 24.x
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml' cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies - name: Cache frontend dependencies
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 uses: actions/cache@v5.0.3
with: with:
path: | path: |
~/.pnpm-store ~/.pnpm-store
@@ -232,42 +189,3 @@ jobs:
env: env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
run: cd src-ui && pnpm run build --configuration=production run: cd src-ui && pnpm run build --configuration=production
gate:
name: Frontend CI Gate
needs: [changes, install-dependencies, lint, unit-tests, e2e-tests, bundle-analysis]
if: always()
runs-on: ubuntu-slim
steps:
- name: Check gate
run: |
if [[ "${{ needs.changes.outputs.frontend_changed }}" != "true" ]]; then
echo "No frontend-relevant changes detected."
exit 0
fi
if [[ "${{ needs['install-dependencies'].result }}" != "success" ]]; then
echo "::error::Frontend install job result: ${{ needs['install-dependencies'].result }}"
exit 1
fi
if [[ "${{ needs.lint.result }}" != "success" ]]; then
echo "::error::Frontend lint job result: ${{ needs.lint.result }}"
exit 1
fi
if [[ "${{ needs['unit-tests'].result }}" != "success" ]]; then
echo "::error::Frontend unit-tests job result: ${{ needs['unit-tests'].result }}"
exit 1
fi
if [[ "${{ needs['e2e-tests'].result }}" != "success" ]]; then
echo "::error::Frontend e2e-tests job result: ${{ needs['e2e-tests'].result }}"
exit 1
fi
if [[ "${{ needs['bundle-analysis'].result }}" != "success" ]]; then
echo "::error::Frontend bundle-analysis job result: ${{ needs['bundle-analysis'].result }}"
exit 1
fi
echo "Frontend checks passed."

View File

@@ -15,10 +15,10 @@ jobs:
runs-on: ubuntu-slim runs-on: ubuntu-slim
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@v6.0.2
- name: Install Python - name: Install Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 uses: actions/setup-python@v6.2.0
with: with:
python-version: "3.14" python-version: "3.14"
- name: Run prek - name: Run prek
uses: j178/prek-action@0bb87d7f00b0c99306c8bcb8b8beba1eb581c037 # v1.1.1 uses: j178/prek-action@v1.1.1

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Wait for Docker build - name: Wait for Docker build
uses: lewagon/wait-on-check-action@74049309dfeff245fe8009a0137eacf28136cb3c # v1.5.0 uses: lewagon/wait-on-check-action@v1.5.0
with: with:
ref: ${{ github.sha }} ref: ${{ github.sha }}
check-name: 'Build Docker Image' check-name: 'Build Docker Image'
@@ -28,14 +28,14 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@v6.0.2
# ---- Frontend Build ---- # ---- Frontend Build ----
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@v4.2.0
with: with:
version: 10 version: 10
- name: Use Node.js 24 - name: Use Node.js 24
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@v6.2.0
with: with:
node-version: 24.x node-version: 24.x
cache: 'pnpm' cache: 'pnpm'
@@ -47,11 +47,11 @@ jobs:
# ---- Backend Setup ---- # ---- Backend Setup ----
- name: Set up Python - name: Set up Python
id: setup-python id: setup-python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 uses: actions/setup-python@v6.2.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@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1 uses: astral-sh/setup-uv@v7.3.1
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
@@ -118,7 +118,7 @@ jobs:
sudo chown -R 1000:1000 paperless-ngx/ sudo chown -R 1000:1000 paperless-ngx/
tar -cJf paperless-ngx.tar.xz paperless-ngx/ tar -cJf paperless-ngx.tar.xz paperless-ngx/
- name: Upload release artifact - name: Upload release artifact
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 uses: actions/upload-artifact@v7.0.0
with: with:
name: release name: release
path: dist/paperless-ngx.tar.xz path: dist/paperless-ngx.tar.xz
@@ -133,7 +133,7 @@ jobs:
version: ${{ steps.get-version.outputs.version }} version: ${{ steps.get-version.outputs.version }}
steps: steps:
- name: Download release artifact - name: Download release artifact
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 uses: actions/download-artifact@v8.0.0
with: with:
name: release name: release
path: ./ path: ./
@@ -148,7 +148,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@6db134d15f3909ccc9eefd369f02bd1e9cffdf97 # v6.2.0 uses: release-drafter/release-drafter@v6.2.0
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 }}
@@ -159,7 +159,7 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload release archive - name: Upload release archive
uses: shogo82148/actions-upload-release-asset@8f6863c6c894ba46f9e676ef5cccec4752723c1e # v1.9.2 uses: shogo82148/actions-upload-release-asset@v1.9.2
with: with:
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}
upload_url: ${{ steps.create-release.outputs.upload_url }} upload_url: ${{ steps.create-release.outputs.upload_url }}
@@ -176,16 +176,16 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@v6.0.2
with: with:
ref: main ref: main
- name: Set up Python - name: Set up Python
id: setup-python id: setup-python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 uses: actions/setup-python@v6.2.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@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1 uses: astral-sh/setup-uv@v7.3.1
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
@@ -218,7 +218,7 @@ jobs:
git commit -am "Changelog ${{ needs.publish-release.outputs.version }} - GHA" git commit -am "Changelog ${{ needs.publish-release.outputs.version }} - GHA"
git push origin ${{ needs.publish-release.outputs.version }}-changelog git push origin ${{ needs.publish-release.outputs.version }}-changelog
- name: Create pull request - name: Create pull request
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 uses: actions/github-script@v8.0.0
with: with:
script: | script: |
const { repo, owner } = context.repo; const { repo, owner } = context.repo;

View File

@@ -27,7 +27,7 @@ jobs:
steps: steps:
- name: Clean temporary images - name: Clean temporary images
if: "${{ env.TOKEN != '' }}" if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/ephemeral@4fe057d991d63b8f6d5d22c40f17c1bca2226537 # v0.12.0 uses: stumpylog/image-cleaner-action/ephemeral@v0.12.0
with: with:
token: "${{ env.TOKEN }}" token: "${{ env.TOKEN }}"
owner: "${{ github.repository_owner }}" owner: "${{ github.repository_owner }}"
@@ -53,7 +53,7 @@ jobs:
steps: steps:
- name: Clean untagged images - name: Clean untagged images
if: "${{ env.TOKEN != '' }}" if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/untagged@4fe057d991d63b8f6d5d22c40f17c1bca2226537 # v0.12.0 uses: stumpylog/image-cleaner-action/untagged@v0.12.0
with: with:
token: "${{ env.TOKEN }}" token: "${{ env.TOKEN }}"
owner: "${{ github.repository_owner }}" owner: "${{ github.repository_owner }}"

View File

@@ -34,10 +34,10 @@ 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@v6.0.2
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5 uses: github/codeql-action/init@v4.32.5
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.
@@ -45,4 +45,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@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5 uses: github/codeql-action/analyze@v4.32.5

View File

@@ -13,11 +13,11 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@v6.0.2
with: with:
token: ${{ secrets.PNGX_BOT_PAT }} token: ${{ secrets.PNGX_BOT_PAT }}
- name: crowdin action - name: crowdin action
uses: crowdin/github-action@8818ff65bfc4322384f983ea37e3926948c11745 # v2.15.0 uses: crowdin/github-action@v2.15.0
with: with:
upload_translations: false upload_translations: false
download_translations: true download_translations: true

View File

@@ -10,7 +10,7 @@ jobs:
issues: read issues: read
pull-requests: write pull-requests: write
steps: steps:
- uses: peakoss/anti-slop@85daca1880e9e1af197fc06ea03349daf08f4202 # v0.2.1 - uses: peakoss/anti-slop@v0.2.1
with: with:
max-failures: 4 max-failures: 4
failure-add-pr-labels: 'ai' failure-add-pr-labels: 'ai'
@@ -23,11 +23,11 @@ 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@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 uses: actions/labeler@v6.0.1
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Label by size - name: Label by size
uses: Gascon1/pr-size-labeler@deff8ed00a76639a7c0f197525bafa3350ba4c36 # v1.3.0 uses: Gascon1/pr-size-labeler@v1.3.0
with: with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
xs_label: 'small-change' xs_label: 'small-change'
@@ -37,7 +37,7 @@ jobs:
fail_if_xl: 'false' fail_if_xl: 'false'
excluded_files: /\.lock$/ /\.txt$/ ^src-ui/pnpm-lock\.yaml$ ^src-ui/messages\.xlf$ ^src/locale/en_US/LC_MESSAGES/django\.po$ excluded_files: /\.lock$/ /\.txt$/ ^src-ui/pnpm-lock\.yaml$ ^src-ui/messages\.xlf$ ^src/locale/en_US/LC_MESSAGES/django\.po$
- name: Label by PR title - name: Label by PR title
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 uses: actions/github-script@v8.0.0
with: with:
script: | script: |
const pr = context.payload.pull_request; const pr = context.payload.pull_request;
@@ -63,7 +63,7 @@ jobs:
} }
- name: Label bot-generated PRs - name: Label bot-generated PRs
if: ${{ contains(github.actor, 'dependabot') || contains(github.actor, 'crowdin-bot') }} if: ${{ contains(github.actor, 'dependabot') || contains(github.actor, 'crowdin-bot') }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 uses: actions/github-script@v8.0.0
with: with:
script: | script: |
const pr = context.payload.pull_request; const pr = context.payload.pull_request;
@@ -88,7 +88,7 @@ jobs:
} }
- name: Welcome comment - name: Welcome comment
if: ${{ !contains(github.actor, 'bot') }} if: ${{ !contains(github.actor, 'bot') }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 uses: actions/github-script@v8.0.0
with: with:
script: | script: |
const pr = context.payload.pull_request; const pr = context.payload.pull_request;

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@6db134d15f3909ccc9eefd369f02bd1e9cffdf97 # v6.2.0 uses: release-drafter/release-drafter@v6.2.0
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -15,7 +15,7 @@ jobs:
if: github.repository_owner == 'paperless-ngx' if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 - uses: actions/stale@v10.2.0
with: with:
days-before-stale: 7 days-before-stale: 7
days-before-close: 14 days-before-close: 14
@@ -37,7 +37,7 @@ jobs:
if: github.repository_owner == 'paperless-ngx' if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0 - uses: dessant/lock-threads@v6.0.0
with: with:
issue-inactive-days: '30' issue-inactive-days: '30'
pr-inactive-days: '30' pr-inactive-days: '30'
@@ -57,7 +57,7 @@ jobs:
if: github.repository_owner == 'paperless-ngx' if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - uses: actions/github-script@v8.0.0
with: with:
script: | script: |
function sleep(ms) { function sleep(ms) {
@@ -114,7 +114,7 @@ jobs:
if: github.repository_owner == 'paperless-ngx' if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - uses: actions/github-script@v8.0.0
with: with:
script: | script: |
function sleep(ms) { function sleep(ms) {
@@ -206,7 +206,7 @@ jobs:
if: github.repository_owner == 'paperless-ngx' if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - uses: actions/github-script@v8.0.0
with: with:
script: | script: |
function sleep(ms) { function sleep(ms) {

View File

@@ -11,7 +11,7 @@ jobs:
contents: write contents: write
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@v6.0.2
env: env:
GH_REF: ${{ github.ref }} # sonar rule:githubactions:S7630 - avoid injection GH_REF: ${{ github.ref }} # sonar rule:githubactions:S7630 - avoid injection
with: with:
@@ -19,13 +19,13 @@ jobs:
ref: ${{ env.GH_REF }} ref: ${{ env.GH_REF }}
- name: Set up Python - name: Set up Python
id: setup-python id: setup-python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 uses: actions/setup-python@v6.2.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@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1 uses: astral-sh/setup-uv@v7.3.1
with: with:
enable-cache: true enable-cache: true
- name: Install backend python dependencies - name: Install backend python dependencies
@@ -36,18 +36,18 @@ jobs:
- name: Generate backend translation strings - name: Generate backend translation strings
run: cd src/ && uv run manage.py makemessages -l en_US -i "samples*" run: cd src/ && uv run manage.py makemessages -l en_US -i "samples*"
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@v4.2.0
with: with:
version: 10 version: 10
- name: Use Node.js 24 - name: Use Node.js 24
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@v6.2.0
with: with:
node-version: 24.x node-version: 24.x
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml' cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies - name: Cache frontend dependencies
id: cache-frontend-deps id: cache-frontend-deps
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 uses: actions/cache@v5.0.3
with: with:
path: | path: |
~/.pnpm-store ~/.pnpm-store
@@ -63,7 +63,7 @@ jobs:
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@04702edda442b2e678b25b537cec683a1493fcb9 # v7.1.0 uses: stefanzweifel/git-auto-commit-action@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"

View File

@@ -2437,3 +2437,17 @@ src/paperless_tesseract/tests/test_parser_custom_settings.py:0: error: Item "Non
src/paperless_tesseract/tests/test_parser_custom_settings.py:0: error: Item "None" of "ApplicationConfiguration | None" has no attribute "unpaper_clean" [union-attr] src/paperless_tesseract/tests/test_parser_custom_settings.py:0: error: Item "None" of "ApplicationConfiguration | None" has no attribute "unpaper_clean" [union-attr]
src/paperless_tesseract/tests/test_parser_custom_settings.py:0: error: Item "None" of "ApplicationConfiguration | None" has no attribute "unpaper_clean" [union-attr] src/paperless_tesseract/tests/test_parser_custom_settings.py:0: error: Item "None" of "ApplicationConfiguration | None" has no attribute "unpaper_clean" [union-attr]
src/paperless_tesseract/tests/test_parser_custom_settings.py:0: error: Item "None" of "ApplicationConfiguration | None" has no attribute "user_args" [union-attr] src/paperless_tesseract/tests/test_parser_custom_settings.py:0: error: Item "None" of "ApplicationConfiguration | None" has no attribute "user_args" [union-attr]
src/paperless_text/parsers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
src/paperless_text/parsers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
src/paperless_text/parsers.py:0: error: Incompatible types in assignment (expression has type "str", variable has type "None") [assignment]
src/paperless_text/signals.py:0: error: Function is missing a type annotation [no-untyped-def]
src/paperless_text/signals.py:0: error: Function is missing a type annotation [no-untyped-def]
src/paperless_tika/parsers.py:0: error: Argument 1 to "make_thumbnail_from_pdf" has incompatible type "None"; expected "Path" [arg-type]
src/paperless_tika/parsers.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/paperless_tika/parsers.py:0: error: Function is missing a type annotation [no-untyped-def]
src/paperless_tika/parsers.py:0: error: Function is missing a type annotation [no-untyped-def]
src/paperless_tika/parsers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
src/paperless_tika/parsers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
src/paperless_tika/parsers.py:0: error: Incompatible types in assignment (expression has type "str | None", variable has type "None") [assignment]
src/paperless_tika/signals.py:0: error: Function is missing a type annotation [no-untyped-def]
src/paperless_tika/signals.py:0: error: Function is missing a type annotation [no-untyped-def]

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.2 rev: v2.4.1
hooks: hooks:
- id: codespell - id: codespell
additional_dependencies: [tomli] additional_dependencies: [tomli]
@@ -46,11 +46,11 @@ repos:
- ts - ts
- markdown - markdown
additional_dependencies: additional_dependencies:
- prettier@3.8.1 - prettier@3.3.3
- 'prettier-plugin-organize-imports@4.3.0' - 'prettier-plugin-organize-imports@4.1.0'
# Python hooks # Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.6 rev: v0.15.0
hooks: hooks:
- id: ruff-check - id: ruff-check
- id: ruff-format - id: ruff-format
@@ -65,7 +65,7 @@ repos:
- id: hadolint - id: hadolint
# Shell script hooks # Shell script hooks
- repo: https://github.com/lovesegfault/beautysh - repo: https://github.com/lovesegfault/beautysh
rev: v6.4.3 rev: v6.4.2
hooks: hooks:
- id: beautysh - id: beautysh
types: [file] types: [file]

View File

@@ -5,6 +5,14 @@ const config = {
singleQuote: true, singleQuote: true,
// https://prettier.io/docs/en/options.html#trailing-commas // https://prettier.io/docs/en/options.html#trailing-commas
trailingComma: 'es5', trailingComma: 'es5',
overrides: [
{
files: ['docs/*.md'],
options: {
tabWidth: 4,
},
},
],
plugins: [require('prettier-plugin-organize-imports')], plugins: [require('prettier-plugin-organize-imports')],
} }

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

View File

@@ -18,13 +18,13 @@ services:
- "--log-level=warn" - "--log-level=warn"
- "--log-format=text" - "--log-format=text"
tika: tika:
image: docker.io/apache/tika:3.2.3.0 image: docker.io/apache/tika:latest
hostname: tika hostname: tika
container_name: tika container_name: tika
network_mode: host network_mode: host
restart: unless-stopped restart: unless-stopped
greenmail: greenmail:
image: docker.io/greenmail/standalone:2.1.8 image: greenmail/standalone:2.1.8
hostname: greenmail hostname: greenmail
container_name: greenmail container_name: greenmail
environment: environment:

View File

@@ -56,7 +56,6 @@ services:
environment: environment:
PAPERLESS_REDIS: redis://broker:6379 PAPERLESS_REDIS: redis://broker:6379
PAPERLESS_DBHOST: db PAPERLESS_DBHOST: db
PAPERLESS_DBENGINE: postgres
env_file: env_file:
- stack.env - stack.env
volumes: volumes:

View File

@@ -62,7 +62,6 @@ services:
environment: environment:
PAPERLESS_REDIS: redis://broker:6379 PAPERLESS_REDIS: redis://broker:6379
PAPERLESS_DBHOST: db PAPERLESS_DBHOST: db
PAPERLESS_DBENGINE: postgresql
PAPERLESS_TIKA_ENABLED: 1 PAPERLESS_TIKA_ENABLED: 1
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

View File

@@ -56,7 +56,6 @@ services:
environment: environment:
PAPERLESS_REDIS: redis://broker:6379 PAPERLESS_REDIS: redis://broker:6379
PAPERLESS_DBHOST: db PAPERLESS_DBHOST: db
PAPERLESS_DBENGINE: postgresql
volumes: volumes:
data: data:
media: media:

View File

@@ -51,7 +51,6 @@ services:
env_file: docker-compose.env env_file: docker-compose.env
environment: environment:
PAPERLESS_REDIS: redis://broker:6379 PAPERLESS_REDIS: redis://broker:6379
PAPERLESS_DBENGINE: sqlite
PAPERLESS_TIKA_ENABLED: 1 PAPERLESS_TIKA_ENABLED: 1
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

View File

@@ -42,7 +42,6 @@ services:
env_file: docker-compose.env env_file: docker-compose.env
environment: environment:
PAPERLESS_REDIS: redis://broker:6379 PAPERLESS_REDIS: redis://broker:6379
PAPERLESS_DBENGINE: sqlite
volumes: volumes:
data: data:
media: media:

View File

@@ -10,10 +10,8 @@ cd "${PAPERLESS_SRC_DIR}"
# The whole migrate, with flock, needs to run as the right user # The whole migrate, with flock, needs to run as the right user
if [[ -n "${USER_IS_NON_ROOT}" ]]; then if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py check --tag compatibility paperless || exit 1
exec s6-setlock -n "${data_dir}/migration_lock" python3 manage.py migrate --skip-checks --no-input exec s6-setlock -n "${data_dir}/migration_lock" python3 manage.py migrate --skip-checks --no-input
else else
s6-setuidgid paperless python3 manage.py check --tag compatibility paperless || exit 1
exec s6-setuidgid paperless \ exec s6-setuidgid paperless \
s6-setlock -n "${data_dir}/migration_lock" \ s6-setlock -n "${data_dir}/migration_lock" \
python3 manage.py migrate --skip-checks --no-input python3 manage.py migrate --skip-checks --no-input

View File

@@ -2,17 +2,6 @@
# shellcheck shell=bash # shellcheck shell=bash
declare -r log_prefix="[init-user]" declare -r log_prefix="[init-user]"
# When the container is started as a non-root user (e.g. via `user: 999:999`
# in Docker Compose), usermod/groupmod require root and are meaningless.
# USERMAP_* variables only apply to the root-started path.
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
if [[ -n "${USERMAP_UID}" || -n "${USERMAP_GID}" ]]; then
echo "${log_prefix} WARNING: USERMAP_UID/USERMAP_GID are set but have no effect when the container is started as a non-root user"
fi
echo "${log_prefix} Running as non-root user ($(id --user):$(id --group)), skipping UID/GID remapping"
exit 0
fi
declare -r usermap_original_uid=$(id -u paperless) declare -r usermap_original_uid=$(id -u paperless)
declare -r usermap_original_gid=$(id -g paperless) declare -r usermap_original_gid=$(id -g paperless)
declare -r usermap_new_uid=${USERMAP_UID:-$usermap_original_uid} declare -r usermap_new_uid=${USERMAP_UID:-$usermap_original_uid}

View File

@@ -10,16 +10,16 @@ consuming documents at that time.
Options available to any installation of paperless: Options available to any installation of paperless:
- Use the [document exporter](#exporter). The document exporter exports all your documents, - Use the [document exporter](#exporter). The document exporter exports all your documents,
thumbnails, metadata, and database contents to a specific folder. You may import your thumbnails, metadata, and database contents to a specific folder. You may import your
documents and settings into a fresh instance of paperless again or store your documents and settings into a fresh instance of paperless again or store your
documents in another DMS with this export. documents in another DMS with this export.
The document exporter is also able to update an already existing The document exporter is also able to update an already existing
export. Therefore, incremental backups with `rsync` are entirely export. Therefore, incremental backups with `rsync` are entirely
possible. possible.
The exporter does not include API tokens and they will need to be re-generated after importing. The exporter does not include API tokens and they will need to be re-generated after importing.
!!! caution !!! caution
@@ -29,27 +29,28 @@ Options available to any installation of paperless:
Options available to docker installations: Options available to docker installations:
- Backup the docker volumes. These usually reside within - Backup the docker volumes. These usually reside within
`/var/lib/docker/volumes` on the host and you need to be root in `/var/lib/docker/volumes` on the host and you need to be root in
order to access them. order to access them.
Paperless uses 4 volumes: Paperless uses 4 volumes:
- `paperless_media`: This is where your documents are stored.
- `paperless_data`: This is where auxiliary data is stored. This - `paperless_media`: This is where your documents are stored.
folder also contains the SQLite database, if you use it. - `paperless_data`: This is where auxiliary data is stored. This
- `paperless_pgdata`: Exists only if you use PostgreSQL and folder also contains the SQLite database, if you use it.
contains the database. - `paperless_pgdata`: Exists only if you use PostgreSQL and
- `paperless_dbdata`: Exists only if you use MariaDB and contains contains the database.
the database. - `paperless_dbdata`: Exists only if you use MariaDB and contains
the database.
Options available to bare-metal and non-docker installations: Options available to bare-metal and non-docker installations:
- Backup the entire paperless folder. This ensures that if your - Backup the entire paperless folder. This ensures that if your
paperless instance crashes at some point or your disk fails, you can paperless instance crashes at some point or your disk fails, you can
simply copy the folder back into place and it works. simply copy the folder back into place and it works.
When using PostgreSQL or MariaDB, you'll also have to backup the When using PostgreSQL or MariaDB, you'll also have to backup the
database. database.
### Restoring {#migrating-restoring} ### Restoring {#migrating-restoring}
@@ -508,19 +509,19 @@ collection for issues.
The issues detected by the sanity checker are as follows: The issues detected by the sanity checker are as follows:
- Missing original files. - Missing original files.
- Missing archive files. - Missing archive files.
- Inaccessible original files due to improper permissions. - Inaccessible original files due to improper permissions.
- Inaccessible archive files due to improper permissions. - Inaccessible archive files due to improper permissions.
- Corrupted original documents by comparing their checksum against - Corrupted original documents by comparing their checksum against
what is stored in the database. what is stored in the database.
- Corrupted archive documents by comparing their checksum against what - Corrupted archive documents by comparing their checksum against what
is stored in the database. is stored in the database.
- Missing thumbnails. - Missing thumbnails.
- Inaccessible thumbnails due to improper permissions. - Inaccessible thumbnails due to improper permissions.
- Documents without any content (warning). - Documents without any content (warning).
- Orphaned files in the media directory (warning). These are files - Orphaned files in the media directory (warning). These are files
that are not referenced by any document in paperless. that are not referenced by any document in paperless.
``` ```
document_sanity_checker document_sanity_checker

View File

@@ -25,20 +25,20 @@ documents.
The following algorithms are available: The following algorithms are available:
- **None:** No matching will be performed. - **None:** No matching will be performed.
- **Any:** Looks for any occurrence of any word provided in match in - **Any:** Looks for any occurrence of any word provided in match in
the PDF. If you define the match as `Bank1 Bank2`, it will match the PDF. If you define the match as `Bank1 Bank2`, it will match
documents containing either of these terms. documents containing either of these terms.
- **All:** Requires that every word provided appears in the PDF, - **All:** Requires that every word provided appears in the PDF,
albeit not in the order provided. albeit not in the order provided.
- **Exact:** Matches only if the match appears exactly as provided - **Exact:** Matches only if the match appears exactly as provided
(i.e. preserve ordering) in the PDF. (i.e. preserve ordering) in the PDF.
- **Regular expression:** Parses the match as a regular expression and - **Regular expression:** Parses the match as a regular expression and
tries to find a match within the document. tries to find a match within the document.
- **Fuzzy match:** Uses a partial matching based on locating the tag text - **Fuzzy match:** Uses a partial matching based on locating the tag text
inside the document, using a [partial ratio](https://rapidfuzz.github.io/RapidFuzz/Usage/fuzz.html#partial-ratio) inside the document, using a [partial ratio](https://rapidfuzz.github.io/RapidFuzz/Usage/fuzz.html#partial-ratio)
- **Auto:** Tries to automatically match new documents. This does not - **Auto:** Tries to automatically match new documents. This does not
require you to set a match. See the [notes below](#automatic-matching). require you to set a match. See the [notes below](#automatic-matching).
When using the _any_ or _all_ matching algorithms, you can search for When using the _any_ or _all_ matching algorithms, you can search for
terms that consist of multiple words by enclosing them in double quotes. terms that consist of multiple words by enclosing them in double quotes.
@@ -69,33 +69,33 @@ Paperless tries to hide much of the involved complexity with this
approach. However, there are a couple caveats you need to keep in mind approach. However, there are a couple caveats you need to keep in mind
when using this feature: when using this feature:
- Changes to your documents are not immediately reflected by the - Changes to your documents are not immediately reflected by the
matching algorithm. The neural network needs to be _trained_ on your matching algorithm. The neural network needs to be _trained_ on your
documents after changes. Paperless periodically (default: once each documents after changes. Paperless periodically (default: once each
hour) checks for changes and does this automatically for you. hour) checks for changes and does this automatically for you.
- The Auto matching algorithm only takes documents into account which - The Auto matching algorithm only takes documents into account which
are NOT placed in your inbox (i.e. have any inbox tags assigned to are NOT placed in your inbox (i.e. have any inbox tags assigned to
them). This ensures that the neural network only learns from them). This ensures that the neural network only learns from
documents which you have correctly tagged before. documents which you have correctly tagged before.
- The matching algorithm can only work if there is a correlation - The matching algorithm can only work if there is a correlation
between the tag, correspondent, document type, or storage path and between the tag, correspondent, document type, or storage path and
the document itself. Your bank statements usually contain your bank the document itself. Your bank statements usually contain your bank
account number and the name of the bank, so this works reasonably account number and the name of the bank, so this works reasonably
well, However, tags such as "TODO" cannot be automatically well, However, tags such as "TODO" cannot be automatically
assigned. assigned.
- The matching algorithm needs a reasonable number of documents to - The matching algorithm needs a reasonable number of documents to
identify when to assign tags, correspondents, storage paths, and identify when to assign tags, correspondents, storage paths, and
types. If one out of a thousand documents has the correspondent types. If one out of a thousand documents has the correspondent
"Very obscure web shop I bought something five years ago", it will "Very obscure web shop I bought something five years ago", it will
probably not assign this correspondent automatically if you buy probably not assign this correspondent automatically if you buy
something from them again. The more documents, the better. something from them again. The more documents, the better.
- Paperless also needs a reasonable amount of negative examples to - Paperless also needs a reasonable amount of negative examples to
decide when not to assign a certain tag, correspondent, document decide when not to assign a certain tag, correspondent, document
type, or storage path. This will usually be the case as you start type, or storage path. This will usually be the case as you start
filling up paperless with documents. Example: If all your documents filling up paperless with documents. Example: If all your documents
are either from "Webshop" or "Bank", paperless will assign one are either from "Webshop" or "Bank", paperless will assign one
of these correspondents to ANY new document, if both are set to of these correspondents to ANY new document, if both are set to
automatic matching. automatic matching.
## Hooking into the consumption process {#consume-hooks} ## Hooking into the consumption process {#consume-hooks}
@@ -243,12 +243,12 @@ webserver:
Troubleshooting: Troubleshooting:
- Monitor the Docker Compose log - Monitor the Docker Compose log
`cd ~/paperless-ngx; docker compose logs -f` `cd ~/paperless-ngx; docker compose logs -f`
- Check your script's permission e.g. in case of permission error - Check your script's permission e.g. in case of permission error
`sudo chmod 755 post-consumption-example.sh` `sudo chmod 755 post-consumption-example.sh`
- Pipe your scripts's output to a log file e.g. - Pipe your scripts's output to a log file e.g.
`echo "${DOCUMENT_ID}" | tee --append /usr/src/paperless/scripts/post-consumption-example.log` `echo "${DOCUMENT_ID}" | tee --append /usr/src/paperless/scripts/post-consumption-example.log`
## File name handling {#file-name-handling} ## File name handling {#file-name-handling}
@@ -307,35 +307,35 @@ will create a directory structure as follows:
Paperless provides the following variables for use within filenames: Paperless provides the following variables for use within filenames:
- `{{ asn }}`: The archive serial number of the document, or "none". - `{{ asn }}`: The archive serial number of the document, or "none".
- `{{ correspondent }}`: The name of the correspondent, or "none". - `{{ correspondent }}`: The name of the correspondent, or "none".
- `{{ document_type }}`: The name of the document type, or "none". - `{{ document_type }}`: The name of the document type, or "none".
- `{{ tag_list }}`: A comma separated list of all tags assigned to the - `{{ tag_list }}`: A comma separated list of all tags assigned to the
document. document.
- `{{ title }}`: The title of the document. - `{{ title }}`: The title of the document.
- `{{ created }}`: The full date (ISO 8601 format, e.g. `2024-03-14`) the document was created. - `{{ created }}`: The full date (ISO 8601 format, e.g. `2024-03-14`) the document was created.
- `{{ created_year }}`: Year created only, formatted as the year with - `{{ created_year }}`: Year created only, formatted as the year with
century. century.
- `{{ created_year_short }}`: Year created only, formatted as the year - `{{ created_year_short }}`: Year created only, formatted as the year
without century, zero padded. without century, zero padded.
- `{{ created_month }}`: Month created only (number 01-12). - `{{ created_month }}`: Month created only (number 01-12).
- `{{ created_month_name }}`: Month created name, as per locale - `{{ created_month_name }}`: Month created name, as per locale
- `{{ created_month_name_short }}`: Month created abbreviated name, as per - `{{ created_month_name_short }}`: Month created abbreviated name, as per
locale locale
- `{{ created_day }}`: Day created only (number 01-31). - `{{ created_day }}`: Day created only (number 01-31).
- `{{ added }}`: The full date (ISO format) the document was added to - `{{ added }}`: The full date (ISO format) the document was added to
paperless. paperless.
- `{{ added_year }}`: Year added only. - `{{ added_year }}`: Year added only.
- `{{ added_year_short }}`: Year added only, formatted as the year without - `{{ added_year_short }}`: Year added only, formatted as the year without
century, zero padded. century, zero padded.
- `{{ added_month }}`: Month added only (number 01-12). - `{{ added_month }}`: Month added only (number 01-12).
- `{{ added_month_name }}`: Month added name, as per locale - `{{ added_month_name }}`: Month added name, as per locale
- `{{ added_month_name_short }}`: Month added abbreviated name, as per - `{{ added_month_name_short }}`: Month added abbreviated name, as per
locale locale
- `{{ added_day }}`: Day added only (number 01-31). - `{{ added_day }}`: Day added only (number 01-31).
- `{{ owner_username }}`: Username of document owner, if any, or "none" - `{{ owner_username }}`: Username of document owner, if any, or "none"
- `{{ original_name }}`: Document original filename, minus the extension, if any, or "none" - `{{ original_name }}`: Document original filename, minus the extension, if any, or "none"
- `{{ doc_pk }}`: The paperless identifier (primary key) for the document. - `{{ doc_pk }}`: The paperless identifier (primary key) for the document.
!!! warning !!! warning
@@ -388,10 +388,10 @@ before empty placeholders are removed as well, empty directories are omitted.
When a single storage layout is not sufficient for your use case, storage paths allow for more complex When a single storage layout is not sufficient for your use case, storage paths allow for more complex
structure to set precisely where each document is stored in the file system. structure to set precisely where each document is stored in the file system.
- Each storage path is a [`PAPERLESS_FILENAME_FORMAT`](configuration.md#PAPERLESS_FILENAME_FORMAT) and - Each storage path is a [`PAPERLESS_FILENAME_FORMAT`](configuration.md#PAPERLESS_FILENAME_FORMAT) and
follows the rules described above follows the rules described above
- Each document is assigned a storage path using the matching algorithms described above, but can be - Each document is assigned a storage path using the matching algorithms described above, but can be
overwritten at any time overwritten at any time
For example, you could define the following two storage paths: For example, you could define the following two storage paths:
@@ -457,13 +457,13 @@ The `get_cf_value` filter retrieves a value from custom field data with optional
###### Parameters ###### Parameters
- `custom_fields`: This _must_ be the provided custom field data - `custom_fields`: This _must_ be the provided custom field data
- `name` (str): Name of the custom field to retrieve - `name` (str): Name of the custom field to retrieve
- `default` (str, optional): Default value to return if field is not found or has no value - `default` (str, optional): Default value to return if field is not found or has no value
###### Returns ###### Returns
- `str | None`: The field value, default value, or `None` if neither exists - `str | None`: The field value, default value, or `None` if neither exists
###### Examples ###### Examples
@@ -487,12 +487,12 @@ The `datetime` filter formats a datetime string or datetime object using Python'
###### Parameters ###### Parameters
- `value` (str | datetime): Date/time value to format (strings will be parsed automatically) - `value` (str | datetime): Date/time value to format (strings will be parsed automatically)
- `format` (str): Python strftime format string - `format` (str): Python strftime format string
###### Returns ###### Returns
- `str`: Formatted datetime string - `str`: Formatted datetime string
###### Examples ###### Examples
@@ -525,13 +525,13 @@ An ISO string can also be provided to control the output format.
###### Parameters ###### Parameters
- `value` (date | datetime | str): Date, datetime object or ISO string to format (datetime should be timezone-aware) - `value` (date | datetime | str): Date, datetime object or ISO string to format (datetime should be timezone-aware)
- `format` (str): Format type - either a Babel preset ('short', 'medium', 'long', 'full') or custom pattern - `format` (str): Format type - either a Babel preset ('short', 'medium', 'long', 'full') or custom pattern
- `locale` (str): Locale code for localization (e.g., 'en_US', 'fr_FR', 'de_DE') - `locale` (str): Locale code for localization (e.g., 'en_US', 'fr_FR', 'de_DE')
###### Returns ###### Returns
- `str`: Localized, formatted date string - `str`: Localized, formatted date string
###### Examples ###### Examples
@@ -565,15 +565,15 @@ See the [supported format codes](https://unicode.org/reports/tr35/tr35-dates.htm
### Format Presets ### Format Presets
- **short**: Abbreviated format (e.g., "1/15/24") - **short**: Abbreviated format (e.g., "1/15/24")
- **medium**: Medium-length format (e.g., "Jan 15, 2024") - **medium**: Medium-length format (e.g., "Jan 15, 2024")
- **long**: Long format with full month name (e.g., "January 15, 2024") - **long**: Long format with full month name (e.g., "January 15, 2024")
- **full**: Full format including day of week (e.g., "Monday, January 15, 2024") - **full**: Full format including day of week (e.g., "Monday, January 15, 2024")
#### Additional Variables #### Additional Variables
- `{{ tag_name_list }}`: A list of tag names applied to the document, ordered by the tag name. Note this is a list, not a single string - `{{ tag_name_list }}`: A list of tag names applied to the document, ordered by the tag name. Note this is a list, not a single string
- `{{ custom_fields }}`: A mapping of custom field names to their type and value. A user can access the mapping by field name or check if a field is applied by checking its existence in the variable. - `{{ custom_fields }}`: A mapping of custom field names to their type and value. A user can access the mapping by field name or check if a field is applied by checking its existence in the variable.
!!! tip !!! tip
@@ -675,15 +675,15 @@ installation, you can use volumes to accomplish this:
```yaml ```yaml
services: services:
# ...
webserver:
environment:
- PAPERLESS_ENABLE_FLOWER
ports:
- 5555:5555 # (2)!
# ... # ...
volumes: webserver:
- /path/to/my/flowerconfig.py:/usr/src/paperless/src/paperless/flowerconfig.py:ro # (1)! environment:
- PAPERLESS_ENABLE_FLOWER
ports:
- 5555:5555 # (2)!
# ...
volumes:
- /path/to/my/flowerconfig.py:/usr/src/paperless/src/paperless/flowerconfig.py:ro # (1)!
``` ```
1. Note the `:ro` tag means the file will be mounted as read only. 1. Note the `:ro` tag means the file will be mounted as read only.
@@ -714,90 +714,15 @@ For example, using Docker Compose:
```yaml ```yaml
services: services:
# ...
webserver:
# ... # ...
volumes: webserver:
- /path/to/my/scripts:/custom-cont-init.d:ro # (1)! # ...
volumes:
- /path/to/my/scripts:/custom-cont-init.d:ro # (1)!
``` ```
1. Note the `:ro` tag means the folder will be mounted as read only. This is for extra security against changes 1. Note the `:ro` tag means the folder will be mounted as read only. This is for extra security against changes
## Installing third-party parser plugins {#parser-plugins}
Third-party parser plugins extend Paperless-ngx to support additional file
formats. A plugin is a Python package that advertises itself under the
`paperless_ngx.parsers` entry point group. Refer to the
[developer documentation](development.md#making-custom-parsers) for how to
create one.
!!! warning "Third-party plugins are not officially supported"
The Paperless-ngx maintainers do not provide support for third-party
plugins. Issues caused by or requiring changes to a third-party plugin
will be closed without further investigation. Always reproduce problems
with all plugins removed before filing a bug report.
### Docker
Use a [custom container initialization script](#custom-container-initialization)
to install the package before the webserver starts. Create a shell script and
mount it into `/custom-cont-init.d`:
```bash
#!/bin/bash
# /path/to/my/scripts/install-parsers.sh
pip install my-paperless-parser-package
```
Mount it in your `docker-compose.yml`:
```yaml
services:
webserver:
# ...
volumes:
- /path/to/my/scripts:/custom-cont-init.d:ro
```
The script runs as `root` before the webserver starts, so the package will be
available when Paperless-ngx discovers plugins at startup.
### Bare metal
Install the package into the same Python environment that runs Paperless-ngx.
If you followed the standard bare-metal install guide, that is the `paperless`
user's environment:
```bash
sudo -Hu paperless pip3 install my-paperless-parser-package
```
If you are using `uv` or a virtual environment, activate it first and then run:
```bash
uv pip install my-paperless-parser-package
# or
pip install my-paperless-parser-package
```
Restart all Paperless-ngx services after installation so the new plugin is
discovered.
### Verifying installation
On the next startup, check the application logs for a line confirming
discovery:
```
Loaded third-party parser 'My Parser' v1.0.0 by Acme Corp (entrypoint: 'my_parser').
```
If this line does not appear, verify that the package is installed in the
correct environment and that its `pyproject.toml` declares the
`paperless_ngx.parsers` entry point.
## MySQL Caveats {#mysql-caveats} ## MySQL Caveats {#mysql-caveats}
### Case Sensitivity ### Case Sensitivity
@@ -846,16 +771,16 @@ Paperless is able to utilize barcodes for automatically performing some tasks.
At this time, the library utilized for detection of barcodes supports the following types: At this time, the library utilized for detection of barcodes supports the following types:
- AN-13/UPC-A - AN-13/UPC-A
- UPC-E - UPC-E
- EAN-8 - EAN-8
- Code 128 - Code 128
- Code 93 - Code 93
- Code 39 - Code 39
- Codabar - Codabar
- Interleaved 2 of 5 - Interleaved 2 of 5
- QR Code - QR Code
- SQ Code - SQ Code
For usage in Paperless, the type of barcode does not matter, only the contents of it. For usage in Paperless, the type of barcode does not matter, only the contents of it.
@@ -868,8 +793,8 @@ below.
If document splitting is enabled, Paperless splits _after_ a separator barcode by default. If document splitting is enabled, Paperless splits _after_ a separator barcode by default.
This means: This means:
- any page containing the configured separator barcode starts a new document, starting with the **next** page - any page containing the configured separator barcode starts a new document, starting with the **next** page
- pages containing the separator barcode are discarded - pages containing the separator barcode are discarded
This is intended for dedicated separator sheets such as PATCH-T pages. This is intended for dedicated separator sheets such as PATCH-T pages.
@@ -906,10 +831,10 @@ to `true`.
When enabled, documents will be split at pages containing tag barcodes, similar to how When enabled, documents will be split at pages containing tag barcodes, similar to how
ASN barcodes work. Key features: ASN barcodes work. Key features:
- The page with the tag barcode is **retained** in the resulting document - The page with the tag barcode is **retained** in the resulting document
- **Each split document extracts its own tags** - only tags on pages within that document are assigned - **Each split document extracts its own tags** - only tags on pages within that document are assigned
- Multiple tag barcodes can trigger multiple splits in the same document - Multiple tag barcodes can trigger multiple splits in the same document
- Works seamlessly with ASN barcodes - each split document gets its own ASN and tags - Works seamlessly with ASN barcodes - each split document gets its own ASN and tags
This is useful for batch scanning where you place tag barcode pages between different This is useful for batch scanning where you place tag barcode pages between different
documents to both separate and categorize them in a single operation. documents to both separate and categorize them in a single operation.
@@ -1071,9 +996,9 @@ If using docker, you'll need to add the following volume mounts to your `docker-
```yaml ```yaml
webserver: webserver:
volumes: volumes:
- /home/user/.gnupg/pubring.gpg:/usr/src/paperless/.gnupg/pubring.gpg - /home/user/.gnupg/pubring.gpg:/usr/src/paperless/.gnupg/pubring.gpg
- <path to gpg-agent socket>:/usr/src/paperless/.gnupg/S.gpg-agent - <path to gpg-agent socket>:/usr/src/paperless/.gnupg/S.gpg-agent
``` ```
For a 'bare-metal' installation no further configuration is necessary. If you For a 'bare-metal' installation no further configuration is necessary. If you
@@ -1081,9 +1006,9 @@ want to use a separate `GNUPG_HOME`, you can do so by configuring the [PAPERLESS
### Troubleshooting ### Troubleshooting
- Make sure, that `gpg-agent` is running on your host machine - Make sure, that `gpg-agent` is running on your host machine
- Make sure, that encryption and decryption works from inside the container using the `gpg` commands from above. - Make sure, that encryption and decryption works from inside the container using the `gpg` commands from above.
- Check that all files in `/usr/src/paperless/.gnupg` have correct permissions - Check that all files in `/usr/src/paperless/.gnupg` have correct permissions
```shell ```shell
paperless@9da1865df327:~/.gnupg$ ls -al paperless@9da1865df327:~/.gnupg$ ls -al

View File

@@ -66,10 +66,10 @@ Full text searching is available on the `/api/documents/` endpoint. Two
specific query parameters cause the API to return full text search specific query parameters cause the API to return full text search
results: results:
- `/api/documents/?query=your%20search%20query`: Search for a document - `/api/documents/?query=your%20search%20query`: Search for a document
using a full text query. For details on the syntax, see [Basic Usage - Searching](usage.md#basic-usage_searching). using a full text query. For details on the syntax, see [Basic Usage - Searching](usage.md#basic-usage_searching).
- `/api/documents/?more_like_id=1234`: Search for documents similar to - `/api/documents/?more_like_id=1234`: Search for documents similar to
the document with id 1234. the document with id 1234.
Pagination works exactly the same as it does for normal requests on this Pagination works exactly the same as it does for normal requests on this
endpoint. endpoint.
@@ -106,12 +106,12 @@ attribute with various information about the search results:
} }
``` ```
- `score` is an indication how well this document matches the query - `score` is an indication how well this document matches the query
relative to the other search results. relative to the other search results.
- `highlights` is an excerpt from the document content and highlights - `highlights` is an excerpt from the document content and highlights
the search terms with `<span>` tags as shown above. the search terms with `<span>` tags as shown above.
- `rank` is the index of the search results. The first result will - `rank` is the index of the search results. The first result will
have rank 0. have rank 0.
### Filtering by custom fields ### Filtering by custom fields
@@ -122,33 +122,33 @@ use cases:
1. Documents with a custom field "due" (date) between Aug 1, 2024 and 1. Documents with a custom field "due" (date) between Aug 1, 2024 and
Sept 1, 2024 (inclusive): Sept 1, 2024 (inclusive):
`?custom_field_query=["due", "range", ["2024-08-01", "2024-09-01"]]` `?custom_field_query=["due", "range", ["2024-08-01", "2024-09-01"]]`
2. Documents with a custom field "customer" (text) that equals "bob" 2. Documents with a custom field "customer" (text) that equals "bob"
(case sensitive): (case sensitive):
`?custom_field_query=["customer", "exact", "bob"]` `?custom_field_query=["customer", "exact", "bob"]`
3. Documents with a custom field "answered" (boolean) set to `true`: 3. Documents with a custom field "answered" (boolean) set to `true`:
`?custom_field_query=["answered", "exact", true]` `?custom_field_query=["answered", "exact", true]`
4. Documents with a custom field "favorite animal" (select) set to either 4. Documents with a custom field "favorite animal" (select) set to either
"cat" or "dog": "cat" or "dog":
`?custom_field_query=["favorite animal", "in", ["cat", "dog"]]` `?custom_field_query=["favorite animal", "in", ["cat", "dog"]]`
5. Documents with a custom field "address" (text) that is empty: 5. Documents with a custom field "address" (text) that is empty:
`?custom_field_query=["OR", [["address", "isnull", true], ["address", "exact", ""]]]` `?custom_field_query=["OR", [["address", "isnull", true], ["address", "exact", ""]]]`
6. Documents that don't have a field called "foo": 6. Documents that don't have a field called "foo":
`?custom_field_query=["foo", "exists", false]` `?custom_field_query=["foo", "exists", false]`
7. Documents that have document links "references" to both document 3 and 7: 7. Documents that have document links "references" to both document 3 and 7:
`?custom_field_query=["references", "contains", [3, 7]]` `?custom_field_query=["references", "contains", [3, 7]]`
All field types support basic operations including `exact`, `in`, `isnull`, All field types support basic operations including `exact`, `in`, `isnull`,
and `exists`. String, URL, and monetary fields support case-insensitive and `exists`. String, URL, and monetary fields support case-insensitive
@@ -164,8 +164,8 @@ Get auto completions for a partial search term.
Query parameters: Query parameters:
- `term`: The incomplete term. - `term`: The incomplete term.
- `limit`: Amount of results. Defaults to 10. - `limit`: Amount of results. Defaults to 10.
Results returned by the endpoint are ordered by importance of the term Results returned by the endpoint are ordered by importance of the term
in the document index. The first result is the term that has the highest in the document index. The first result is the term that has the highest
@@ -189,19 +189,19 @@ from there.
The endpoint supports the following optional form fields: The endpoint supports the following optional form fields:
- `title`: Specify a title that the consumer should use for the - `title`: Specify a title that the consumer should use for the
document. document.
- `created`: Specify a DateTime where the document was created (e.g. - `created`: Specify a DateTime where the document was created (e.g.
"2016-04-19" or "2016-04-19 06:15:00+02:00"). "2016-04-19" or "2016-04-19 06:15:00+02:00").
- `correspondent`: Specify the ID of a correspondent that the consumer - `correspondent`: Specify the ID of a correspondent that the consumer
should use for the document. should use for the document.
- `document_type`: Similar to correspondent. - `document_type`: Similar to correspondent.
- `storage_path`: Similar to correspondent. - `storage_path`: Similar to correspondent.
- `tags`: Similar to correspondent. Specify this multiple times to - `tags`: Similar to correspondent. Specify this multiple times to
have multiple tags added to the document. have multiple tags added to the document.
- `archive_serial_number`: An optional archive serial number to set. - `archive_serial_number`: An optional archive serial number to set.
- `custom_fields`: Either an array of custom field ids to assign (with an empty - `custom_fields`: Either an array of custom field ids to assign (with an empty
value) to the document or an object mapping field id -> value. value) to the document or an object mapping field id -> value.
The endpoint will immediately return HTTP 200 if the document consumption The endpoint will immediately return HTTP 200 if the document consumption
process was started successfully, with the UUID of the consumption task process was started successfully, with the UUID of the consumption task
@@ -215,16 +215,16 @@ consumption including the ID of a created document if consumption succeeded.
Document versions are file-level versions linked to one root document. Document versions are file-level versions linked to one root document.
- Root document metadata (title, tags, correspondent, document type, storage path, custom fields, permissions) remains shared. - Root document metadata (title, tags, correspondent, document type, storage path, custom fields, permissions) remains shared.
- Version-specific file data (file, mime type, checksums, archive info, extracted text content) belongs to the selected/latest version. - Version-specific file data (file, mime type, checksums, archive info, extracted text content) belongs to the selected/latest version.
Version-aware endpoints: Version-aware endpoints:
- `GET /api/documents/{id}/`: returns root document data; `content` resolves to latest version content by default. Use `?version={version_id}` to resolve content for a specific version. - `GET /api/documents/{id}/`: returns root document data; `content` resolves to latest version content by default. Use `?version={version_id}` to resolve content for a specific version.
- `PATCH /api/documents/{id}/`: content updates target the selected version (`?version={version_id}`) or latest version by default; non-content metadata updates target the root document. - `PATCH /api/documents/{id}/`: content updates target the selected version (`?version={version_id}`) or latest version by default; non-content metadata updates target the root document.
- `GET /api/documents/{id}/download/`, `GET /api/documents/{id}/preview/`, `GET /api/documents/{id}/thumb/`, `GET /api/documents/{id}/metadata/`: accept `?version={version_id}`. - `GET /api/documents/{id}/download/`, `GET /api/documents/{id}/preview/`, `GET /api/documents/{id}/thumb/`, `GET /api/documents/{id}/metadata/`: accept `?version={version_id}`.
- `POST /api/documents/{id}/update_version/`: uploads a new version using multipart form field `document` and optional `version_label`. - `POST /api/documents/{id}/update_version/`: uploads a new version using multipart form field `document` and optional `version_label`.
- `DELETE /api/documents/{root_id}/versions/{version_id}/`: deletes a non-root version. - `DELETE /api/documents/{root_id}/versions/{version_id}/`: deletes a non-root version.
## Permissions ## Permissions
@@ -282,38 +282,74 @@ a json payload of the format:
The following methods are supported: The following methods are supported:
- `set_correspondent` - `set_correspondent`
- Requires `parameters`: `{ "correspondent": CORRESPONDENT_ID }` - Requires `parameters`: `{ "correspondent": CORRESPONDENT_ID }`
- `set_document_type` - `set_document_type`
- Requires `parameters`: `{ "document_type": DOCUMENT_TYPE_ID }` - Requires `parameters`: `{ "document_type": DOCUMENT_TYPE_ID }`
- `set_storage_path` - `set_storage_path`
- Requires `parameters`: `{ "storage_path": STORAGE_PATH_ID }` - Requires `parameters`: `{ "storage_path": STORAGE_PATH_ID }`
- `add_tag` - `add_tag`
- Requires `parameters`: `{ "tag": TAG_ID }` - Requires `parameters`: `{ "tag": TAG_ID }`
- `remove_tag` - `remove_tag`
- Requires `parameters`: `{ "tag": TAG_ID }` - Requires `parameters`: `{ "tag": TAG_ID }`
- `modify_tags` - `modify_tags`
- Requires `parameters`: `{ "add_tags": [LIST_OF_TAG_IDS] }` and `{ "remove_tags": [LIST_OF_TAG_IDS] }` - Requires `parameters`: `{ "add_tags": [LIST_OF_TAG_IDS] }` and `{ "remove_tags": [LIST_OF_TAG_IDS] }`
- `delete` - `delete`
- No `parameters` required - No `parameters` required
- `reprocess` - `reprocess`
- No `parameters` required - No `parameters` required
- `set_permissions` - `set_permissions`
- Requires `parameters`: - Requires `parameters`:
- `"set_permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or - `"set_permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or
- `"owner": OWNER_ID or null` - `"owner": OWNER_ID or null`
- `"merge": true or false` (defaults to false) - `"merge": true or false` (defaults to false)
- The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including - The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including
removing them) or be merged with existing permissions. removing them) or be merged with existing permissions.
- `modify_custom_fields` - `edit_pdf`
- Requires `parameters`: - Requires `parameters`:
- `"add_custom_fields": { CUSTOM_FIELD_ID: VALUE }`: JSON object consisting of custom field id:value pairs to add to the document, can also be a list of custom field IDs - `"doc_ids": [DOCUMENT_ID]` A list of a single document ID to edit.
to add with empty values. - `"operations": [OPERATION, ...]` A list of operations to perform on the documents. Each operation is a dictionary
- `"remove_custom_fields": [CUSTOM_FIELD_ID]`: custom field ids to remove from the document. with the following keys:
- `"page": PAGE_NUMBER` The page number to edit (1-based).
#### Document-editing operations - `"rotate": DEGREES` Optional rotation in degrees (90, 180, 270).
- `"doc": OUTPUT_DOCUMENT_INDEX` Optional index of the output document for split operations.
Beginning with version 10+, the API supports individual endpoints for document-editing operations (`merge`, `rotate`, `edit_pdf`, etc), thus their documentation can be found in the API spec / viewer. Legacy document-editing methods via `/api/documents/bulk_edit/` are still supported for compatibility, are deprecated and clients should migrate to the individual endpoints before they are removed in a future version. - Optional `parameters`:
- `"delete_original": true` to delete the original documents after editing.
- `"update_document": true` to add the edited PDF as a new version of the root document.
- `"include_metadata": true` to copy metadata from the original document to the edited document.
- `remove_password`
- Requires `parameters`:
- `"password": "PASSWORD_STRING"` The password to remove from the PDF documents.
- Optional `parameters`:
- `"update_document": true` to add the password-less PDF as a new version of the root document.
- `"delete_original": true` to delete the original document after editing.
- `"include_metadata": true` to copy metadata from the original document to the new password-less document.
- `merge`
- No additional `parameters` required.
- The ordering of the merged document is determined by the list of IDs.
- Optional `parameters`:
- `"metadata_document_id": DOC_ID` apply metadata (tags, correspondent, etc.) from this document to the merged document.
- `"delete_originals": true` to delete the original documents. This requires the calling user being the owner of
all documents that are merged.
- `split`
- Requires `parameters`:
- `"pages": [..]` The list should be a list of pages and/or a ranges, separated by commas e.g. `"[1,2-3,4,5-7]"`
- Optional `parameters`:
- `"delete_originals": true` to delete the original document after consumption. This requires the calling user being the owner of
the document.
- The split operation only accepts a single document.
- `rotate`
- Requires `parameters`:
- `"degrees": DEGREES`. Must be an integer i.e. 90, 180, 270
- `delete_pages`
- Requires `parameters`:
- `"pages": [..]` The list should be a list of integers e.g. `"[2,3,4]"`
- The delete_pages operation only accepts a single document.
- `modify_custom_fields`
- Requires `parameters`:
- `"add_custom_fields": { CUSTOM_FIELD_ID: VALUE }`: JSON object consisting of custom field id:value pairs to add to the document, can also be a list of custom field IDs
to add with empty values.
- `"remove_custom_fields": [CUSTOM_FIELD_ID]`: custom field ids to remove from the document.
### Objects ### Objects
@@ -333,38 +369,41 @@ operations, using the endpoint: `/api/bulk_edit_objects/`, which requires a json
## API Versioning ## API Versioning
The REST API is versioned. The REST API is versioned since Paperless-ngx 1.3.0.
- Versioning ensures that changes to the API don't break older - Versioning ensures that changes to the API don't break older
clients. clients.
- Clients specify the specific version of the API they wish to use - Clients specify the specific version of the API they wish to use
with every request and Paperless will handle the request using the with every request and Paperless will handle the request using the
specified API version. specified API version.
- Even if the underlying data model changes, supported older API - Even if the underlying data model changes, older API versions will
versions continue to serve compatible data. always serve compatible data.
- If no version is specified, Paperless serves the configured default - If no version is specified, Paperless will serve version 1 to ensure
API version (currently `10`). compatibility with older clients that do not request a specific API
- Supported API versions are currently `9` and `10`. version.
API versions are specified by submitting an additional HTTP `Accept` API versions are specified by submitting an additional HTTP `Accept`
header with every request: header with every request:
``` ```
Accept: application/json; version=10 Accept: application/json; version=6
``` ```
If an invalid version is specified, Paperless responds with If an invalid version is specified, Paperless 1.3.0 will respond with
`406 Not Acceptable` and an error message in the body. "406 Not Acceptable" and an error message in the body. Earlier
versions of Paperless will serve API version 1 regardless of whether a
version is specified via the `Accept` header.
If a client wishes to verify whether it is compatible with any given If a client wishes to verify whether it is compatible with any given
server, the following procedure should be performed: server, the following procedure should be performed:
1. Perform an _authenticated_ request against any API endpoint. The 1. Perform an _authenticated_ request against any API endpoint. If the
server will add two custom headers to the response: server is on version 1.3.0 or newer, the server will add two custom
headers to the response:
``` ```
X-Api-Version: 10 X-Api-Version: 2
X-Version: <server-version> X-Version: 1.3.0
``` ```
2. Determine whether the client is compatible with this server based on 2. Determine whether the client is compatible with this server based on
@@ -384,59 +423,51 @@ Initial API version.
#### Version 2 #### Version 2
- Added field `Tag.color`. This read/write string field contains a hex - Added field `Tag.color`. This read/write string field contains a hex
color such as `#a6cee3`. color such as `#a6cee3`.
- Added read-only field `Tag.text_color`. This field contains the text - Added read-only field `Tag.text_color`. This field contains the text
color to use for a specific tag, which is either black or white color to use for a specific tag, which is either black or white
depending on the brightness of `Tag.color`. depending on the brightness of `Tag.color`.
- Removed field `Tag.colour`. - Removed field `Tag.colour`.
#### Version 3 #### Version 3
- Permissions endpoints have been added. - Permissions endpoints have been added.
- The format of the `/api/ui_settings/` has changed. - The format of the `/api/ui_settings/` has changed.
#### Version 4 #### Version 4
- Consumption templates were refactored to workflows and API endpoints - Consumption templates were refactored to workflows and API endpoints
changed as such. changed as such.
#### Version 5 #### Version 5
- Added bulk deletion methods for documents and objects. - Added bulk deletion methods for documents and objects.
#### Version 6 #### Version 6
- Moved acknowledge tasks endpoint to be under `/api/tasks/acknowledge/`. - Moved acknowledge tasks endpoint to be under `/api/tasks/acknowledge/`.
#### Version 7 #### Version 7
- The format of select type custom fields has changed to return the options - The format of select type custom fields has changed to return the options
as an array of objects with `id` and `label` fields as opposed to a simple as an array of objects with `id` and `label` fields as opposed to a simple
list of strings. When creating or updating a custom field value of a list of strings. When creating or updating a custom field value of a
document for a select type custom field, the value should be the `id` of document for a select type custom field, the value should be the `id` of
the option whereas previously was the index of the option. the option whereas previously was the index of the option.
#### Version 8 #### Version 8
- The user field of document notes now returns a simplified user object - The user field of document notes now returns a simplified user object
rather than just the user ID. rather than just the user ID.
#### Version 9 #### Version 9
- The document `created` field is now a date, not a datetime. The - The document `created` field is now a date, not a datetime. The
`created_date` field is considered deprecated and will be removed in a `created_date` field is considered deprecated and will be removed in a
future version. future version.
#### Version 10 #### Version 10
- The `show_on_dashboard` and `show_in_sidebar` fields of saved views have been - The `show_on_dashboard` and `show_in_sidebar` fields of saved views have been
removed. Relevant settings are now stored in the UISettings model. Compatibility is maintained removed. Relevant settings are now stored in the UISettings model.
for versions < 10 until support for API v9 is dropped.
- Document-editing operations such as `merge`, `rotate`, and `edit_pdf` have been
moved from the bulk edit endpoint to their own individual endpoints. Using these methods via
the bulk edit endpoint is still supported for compatibility with versions < 10 until support
for API v9 is dropped.
- The `all` parameter of list endpoints is now deprecated and will be removed in a future version.
- The bulk edit objects endpoint now supports `all` and `filters` parameters to avoid having to send
large lists of object IDs for operations affecting many objects.

File diff suppressed because it is too large Load Diff

View File

@@ -8,17 +8,17 @@ common [OCR](#ocr) related settings and some frontend settings. If set, these wi
preference over the settings via environment variables. If not set, the environment setting preference over the settings via environment variables. If not set, the environment setting
or applicable default will be utilized instead. or applicable default will be utilized instead.
- If you run paperless on docker, `paperless.conf` is not used. - If you run paperless on docker, `paperless.conf` is not used.
Rather, configure paperless by copying necessary options to Rather, configure paperless by copying necessary options to
`docker-compose.env`. `docker-compose.env`.
- If you are running paperless on anything else, paperless will search - If you are running paperless on anything else, paperless will search
for the configuration file in these locations and use the first one for the configuration file in these locations and use the first one
it finds: it finds:
- The environment variable `PAPERLESS_CONFIGURATION_PATH` - The environment variable `PAPERLESS_CONFIGURATION_PATH`
- `/path/to/paperless/paperless.conf` - `/path/to/paperless/paperless.conf`
- `/etc/paperless.conf` - `/etc/paperless.conf`
- `/usr/local/etc/paperless.conf` - `/usr/local/etc/paperless.conf`
## Required services ## Required services
@@ -674,9 +674,6 @@ See the corresponding [django-allauth documentation](https://docs.allauth.org/en
for a list of provider configurations. You will also need to include the relevant Django 'application' inside the for a list of provider configurations. You will also need to include the relevant Django 'application' inside the
[PAPERLESS_APPS](#PAPERLESS_APPS) setting to activate that specific authentication provider (e.g. `allauth.socialaccount.providers.openid_connect` for the [OIDC Connect provider](https://docs.allauth.org/en/latest/socialaccount/providers/openid_connect.html)). [PAPERLESS_APPS](#PAPERLESS_APPS) setting to activate that specific authentication provider (e.g. `allauth.socialaccount.providers.openid_connect` for the [OIDC Connect provider](https://docs.allauth.org/en/latest/socialaccount/providers/openid_connect.html)).
: For OpenID Connect providers, set `settings.token_auth_method` if your identity provider
requires a specific token endpoint authentication method.
Defaults to None, which does not enable any third party authentication systems. Defaults to None, which does not enable any third party authentication systems.
#### [`PAPERLESS_SOCIAL_AUTO_SIGNUP=<bool>`](#PAPERLESS_SOCIAL_AUTO_SIGNUP) {#PAPERLESS_SOCIAL_AUTO_SIGNUP} #### [`PAPERLESS_SOCIAL_AUTO_SIGNUP=<bool>`](#PAPERLESS_SOCIAL_AUTO_SIGNUP) {#PAPERLESS_SOCIAL_AUTO_SIGNUP}
@@ -1950,12 +1947,6 @@ current backend. If not supplied, defaults to "gpt-3.5-turbo" for OpenAI and "ll
Defaults to None. Defaults to None.
#### [`PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS=<bool>`](#PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS) {#PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS}
: If set to false, Paperless blocks AI endpoint URLs that resolve to non-public addresses (e.g., localhost, etc).
Defaults to true, which allows internal endpoints.
#### [`PAPERLESS_AI_LLM_INDEX_TASK_CRON=<cron expression>`](#PAPERLESS_AI_LLM_INDEX_TASK_CRON) {#PAPERLESS_AI_LLM_INDEX_TASK_CRON} #### [`PAPERLESS_AI_LLM_INDEX_TASK_CRON=<cron expression>`](#PAPERLESS_AI_LLM_INDEX_TASK_CRON) {#PAPERLESS_AI_LLM_INDEX_TASK_CRON}
: Configures the schedule to update the AI embeddings of text content and metadata for all documents. Only performed if : Configures the schedule to update the AI embeddings of text content and metadata for all documents. Only performed if

View File

@@ -6,23 +6,23 @@ on Paperless-ngx.
Check out the source from GitHub. The repository is organized in the Check out the source from GitHub. The repository is organized in the
following way: following way:
- `main` always represents the latest release and will only see - `main` always represents the latest release and will only see
changes when a new release is made. changes when a new release is made.
- `dev` contains the code that will be in the next release. - `dev` contains the code that will be in the next release.
- `feature-X` contains bigger changes that will be in some release, but - `feature-X` contains bigger changes that will be in some release, but
not necessarily the next one. not necessarily the next one.
When making functional changes to Paperless-ngx, _always_ make your changes When making functional changes to Paperless-ngx, _always_ make your changes
on the `dev` branch. on the `dev` branch.
Apart from that, the folder structure is as follows: Apart from that, the folder structure is as follows:
- `docs/` - Documentation. - `docs/` - Documentation.
- `src-ui/` - Code of the front end. - `src-ui/` - Code of the front end.
- `src/` - Code of the back end. - `src/` - Code of the back end.
- `scripts/` - Various scripts that help with different parts of - `scripts/` - Various scripts that help with different parts of
development. development.
- `docker/` - Files required to build the docker image. - `docker/` - Files required to build the docker image.
## Contributing to Paperless-ngx ## Contributing to Paperless-ngx
@@ -94,17 +94,18 @@ first-time setup.
``` ```
7. You can now either ... 7. You can now either ...
- install Redis or
- use the included `scripts/start_services.sh` to use Docker to fire - install Redis or
up a Redis instance (and some other services such as Tika,
Gotenberg and a database server) or
- spin up a bare Redis container - use the included `scripts/start_services.sh` to use Docker to fire
up a Redis instance (and some other services such as Tika,
Gotenberg and a database server) or
```bash - spin up a bare Redis container
docker run -d -p 6379:6379 --restart unless-stopped redis:latest
``` ```bash
docker run -d -p 6379:6379 --restart unless-stopped redis:latest
```
8. Continue with either back-end or front-end development or both :-). 8. Continue with either back-end or front-end development or both :-).
@@ -117,9 +118,9 @@ work well for development, but you can use whatever you want.
Configure the IDE to use the `src/`-folder as the base source folder. Configure the IDE to use the `src/`-folder as the base source folder.
Configure the following launch configurations in your IDE: Configure the following launch configurations in your IDE:
- `uv run manage.py runserver` - `uv run manage.py runserver`
- `uv run manage.py document_consumer` - `uv run manage.py document_consumer`
- `uv run celery --app paperless worker -l DEBUG` (or any other log level) - `uv run celery --app paperless worker -l DEBUG` (or any other log level)
To start them all: To start them all:
@@ -145,11 +146,11 @@ pnpm ng build --configuration production
### Testing ### Testing
- Run `pytest` in the `src/` directory to execute all tests. This also - Run `pytest` in the `src/` directory to execute all tests. This also
generates a HTML coverage report. When running tests, `paperless.conf` generates a HTML coverage report. When running tests, `paperless.conf`
is loaded as well. However, the tests rely on the default is loaded as well. However, the tests rely on the default
configuration. This is not ideal. But for now, make sure no settings configuration. This is not ideal. But for now, make sure no settings
except for DEBUG are overridden when testing. except for DEBUG are overridden when testing.
!!! note !!! note
@@ -253,14 +254,14 @@ these parts have to be translated separately.
### Front end localization ### Front end localization
- The AngularJS front end does localization according to the [Angular - The AngularJS front end does localization according to the [Angular
documentation](https://angular.io/guide/i18n). documentation](https://angular.io/guide/i18n).
- The source language of the project is "en_US". - The source language of the project is "en_US".
- The source strings end up in the file `src-ui/messages.xlf`. - The source strings end up in the file `src-ui/messages.xlf`.
- The translated strings need to be placed in the - The translated strings need to be placed in the
`src-ui/src/locale/` folder. `src-ui/src/locale/` folder.
- In order to extract added or changed strings from the source files, - In order to extract added or changed strings from the source files,
call `ng extract-i18n`. call `ng extract-i18n`.
Adding new languages requires adding the translated files in the Adding new languages requires adding the translated files in the
`src-ui/src/locale/` folder and adjusting a couple files. `src-ui/src/locale/` folder and adjusting a couple files.
@@ -306,18 +307,18 @@ A majority of the strings that appear in the back end appear only when
the admin is used. However, some of these are still shown on the front the admin is used. However, some of these are still shown on the front
end (such as error messages). end (such as error messages).
- The django application does localization according to the [Django - The django application does localization according to the [Django
documentation](https://docs.djangoproject.com/en/3.1/topics/i18n/translation/). documentation](https://docs.djangoproject.com/en/3.1/topics/i18n/translation/).
- The source language of the project is "en_US". - The source language of the project is "en_US".
- Localization files end up in the folder `src/locale/`. - Localization files end up in the folder `src/locale/`.
- In order to extract strings from the application, call - In order to extract strings from the application, call
`uv run manage.py makemessages -l en_US`. This is important after `uv run manage.py makemessages -l en_US`. This is important after
making changes to translatable strings. making changes to translatable strings.
- The message files need to be compiled for them to show up in the - The message files need to be compiled for them to show up in the
application. Call `uv run manage.py compilemessages` to do this. application. Call `uv run manage.py compilemessages` to do this.
The generated files don't get committed into git, since these are The generated files don't get committed into git, since these are
derived artifacts. The build pipeline takes care of executing this derived artifacts. The build pipeline takes care of executing this
command. command.
Adding new languages requires adding the translated files in the Adding new languages requires adding the translated files in the
`src/locale/`-folder and adjusting the file `src/locale/`-folder and adjusting the file
@@ -370,367 +371,122 @@ docker build --file Dockerfile --tag paperless:local .
## Extending Paperless-ngx ## Extending Paperless-ngx
Paperless-ngx supports third-party document parsers via a Python entry point Paperless-ngx does not have any fancy plugin systems and will probably never
plugin system. Plugins are distributed as ordinary Python packages and have. However, some parts of the application have been designed to allow
discovered automatically at startup — no changes to the Paperless-ngx source easy integration of additional features without any modification to the
are required. base code.
!!! warning "Third-party plugins are not officially supported"
The Paperless-ngx maintainers do not provide support for third-party
plugins. Issues that are caused by or require changes to a third-party
plugin will be closed without further investigation. If you believe you
have found a bug in Paperless-ngx itself (not in a plugin), please
reproduce it with all third-party plugins removed before filing an issue.
### Making custom parsers ### Making custom parsers
Paperless-ngx uses parsers to add documents. A parser is responsible for: Paperless-ngx uses parsers to add documents. A parser is
responsible for:
- Extracting plain-text content from the document - Retrieving the content from the original
- Generating a thumbnail image - Creating a thumbnail
- _optional:_ Detecting the document's creation date - _optional:_ Retrieving a created date from the original
- _optional:_ Producing a searchable PDF archive copy - _optional:_ Creating an archived document from the original
Custom parsers are distributed as ordinary Python packages and registered Custom parsers can be added to Paperless-ngx to support more file types. In
via a [setuptools entry point](https://setuptools.pypa.io/en/latest/userguide/entry_point.html). order to do that, you need to write the parser itself and announce its
No changes to the Paperless-ngx source are required. existence to Paperless-ngx.
#### 1. Implementing the parser class The parser itself must extend `documents.parsers.DocumentParser` and
must implement the methods `parse` and `get_thumbnail`. You can provide
Your parser must satisfy the `ParserProtocol` structural interface defined in your own implementation to `get_date` if you don't want to rely on
`paperless.parsers`. The simplest approach is to write a plain class — no base Paperless-ngx' default date guessing mechanisms.
class is required, only the right attributes and methods.
**Class-level identity attributes**
The registry reads these before instantiating the parser, so they must be
plain class attributes (not instance attributes or properties):
```python ```python
class MyCustomParser: class MyCustomParser(DocumentParser):
name = "My Format Parser" # human-readable name shown in logs
version = "1.0.0" # semantic version string def parse(self, document_path, mime_type):
author = "Acme Corp" # author / organisation # This method does not return anything. Rather, you should assign
url = "https://example.com/my-parser" # docs or issue tracker # whatever you got from the document to the following fields:
# The content of the document.
self.text = "content"
# Optional: path to a PDF document that you created from the original.
self.archive_path = os.path.join(self.tempdir, "archived.pdf")
# Optional: "created" date of the document.
self.date = get_created_from_metadata(document_path)
def get_thumbnail(self, document_path, mime_type):
# This should return the path to a thumbnail you created for this
# document.
return os.path.join(self.tempdir, "thumb.webp")
``` ```
**Declaring supported MIME types** If you encounter any issues during parsing, raise a
`documents.parsers.ParseError`.
Return a `dict` mapping MIME type strings to preferred file extensions The `self.tempdir` directory is a temporary directory that is guaranteed
(including the leading dot). Paperless-ngx uses the extension when storing to be empty and removed after consumption finished. You can use that
archive copies and serving files for download. directory to store any intermediate files and also use it to store the
thumbnail / archived document.
After that, you need to announce your parser to Paperless-ngx. You need to
connect a handler to the `document_consumer_declaration` signal. Have a
look in the file `src/paperless_tesseract/apps.py` on how that's done.
The handler is a method that returns information about your parser:
```python ```python
@classmethod def myparser_consumer_declaration(sender, **kwargs):
def supported_mime_types(cls) -> dict[str, str]:
return { return {
"application/x-my-format": ".myf", "parser": MyCustomParser,
"application/x-my-format-alt": ".myf", "weight": 0,
"mime_types": {
"application/pdf": ".pdf",
"image/jpeg": ".jpg",
}
} }
``` ```
**Scoring** - `parser` is a reference to a class that extends `DocumentParser`.
- `weight` is used whenever two or more parsers are able to parse a
file: The parser with the higher weight wins. This can be used to
override the parsers provided by Paperless-ngx.
- `mime_types` is a dictionary. The keys are the mime types your
parser supports and the value is the default file extension that
Paperless-ngx should use when storing files and serving them for
download. We could guess that from the file extensions, but some
mime types have many extensions associated with them and the Python
methods responsible for guessing the extension do not always return
the same value.
When more than one parser can handle a file, the registry calls `score()` on ## Using Visual Studio Code devcontainer
each candidate and picks the one with the highest result and equal scores favor third-party parsers over built-ins. Return `None` to
decline handling a file even though the MIME type is listed as supported (for
example, when a required external service is not configured).
| Score | Meaning | Another easy way to get started with development is to use Visual Studio
| ------ | --------------------------------------------------------------------------------- | Code devcontainers. This approach will create a preconfigured development
| `None` | Decline — do not handle this file | environment with all of the required tools and dependencies.
| `10` | Default priority used by all built-in parsers | [Learn more about devcontainers](https://code.visualstudio.com/docs/devcontainers/containers).
| `20` | Priority used by the remote OCR built-in parser, allowing it to replace Tesseract | The .devcontainer/vscode/tasks.json and .devcontainer/vscode/launch.json files
| `> 10` | Override a built-in parser for the same MIME type | contain more information about the specific tasks and launch configurations (see the
non-standard "description" field).
```python To get started:
@classmethod
def score(
cls,
mime_type: str,
filename: str,
path: "Path | None" = None,
) -> int | None:
# Inspect filename or file bytes here if needed.
return 10
```
**Archive and rendition flags** 1. Clone the repository on your machine and open the Paperless-ngx folder in VS Code.
```python 2. VS Code will prompt you with "Reopen in container". Do so and wait for the environment to start.
@property
def can_produce_archive(self) -> bool:
"""True if parse() can produce a searchable PDF archive copy."""
return True # or False if your parser doesn't produce PDFs
@property 3. In case your host operating system is Windows:
def requires_pdf_rendition(self) -> bool:
"""True if the original format cannot be displayed by a browser
(e.g. DOCX, ODT) and the PDF output must always be kept."""
return False
```
**Context manager — temp directory lifecycle** - The Source Control view in Visual Studio Code might show: "The detected Git repository is potentially unsafe as the folder is owned by someone other than the current user." Use "Manage Unsafe Repositories" to fix this.
- Git might have detecteded modifications for all files, because Windows is using CRLF line endings. Run `git checkout .` in the containers terminal to fix this issue.
Paperless-ngx always uses parsers as context managers. Create a temporary 4. Initialize the project by running the task **Project Setup: Run all Init Tasks**. This
working directory in `__enter__` (or `__init__`) and remove it in `__exit__` will initialize the database tables and create a superuser. Then you can compile the front end
regardless of whether an exception occurred. Store intermediate files, for production or run the frontend in debug mode.
thumbnails, and archive PDFs inside this directory.
```python 5. The project is ready for debugging, start either run the fullstack debug or individual debug
import shutil processes. Yo spin up the project without debugging run the task **Project Start: Run all Services**
import tempfile
from pathlib import Path
from typing import Self
from types import TracebackType
from django.conf import settings ## Developing Date Parser Plugins
class MyCustomParser:
...
def __init__(self, logging_group: object = None) -> None:
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
self._tempdir = Path(
tempfile.mkdtemp(prefix="paperless-", dir=settings.SCRATCH_DIR)
)
self._text: str | None = None
self._archive_path: Path | None = None
def __enter__(self) -> Self:
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
shutil.rmtree(self._tempdir, ignore_errors=True)
```
**Optional context — `configure()`**
The consumer calls `configure()` with a `ParserContext` after instantiation
and before `parse()`. If your parser doesn't need context, a no-op
implementation is fine:
```python
from paperless.parsers import ParserContext
def configure(self, context: ParserContext) -> None:
pass # override if you need context.mailrule_id, etc.
```
**Parsing**
`parse()` is the core method. It must not return a value; instead, store
results in instance attributes and expose them via the accessor methods below.
Raise `documents.parsers.ParseError` on any unrecoverable failure.
```python
from documents.parsers import ParseError
def parse(
self,
document_path: Path,
mime_type: str,
*,
produce_archive: bool = True,
) -> None:
try:
self._text = extract_text_from_my_format(document_path)
except Exception as e:
raise ParseError(f"Failed to parse {document_path}: {e}") from e
if produce_archive and self.can_produce_archive:
archive = self._tempdir / "archived.pdf"
convert_to_pdf(document_path, archive)
self._archive_path = archive
```
**Result accessors**
```python
def get_text(self) -> str | None:
return self._text
def get_date(self) -> "datetime.datetime | None":
# Return a datetime extracted from the document, or None to let
# Paperless-ngx use its default date-guessing logic.
return None
def get_archive_path(self) -> Path | None:
return self._archive_path
def get_page_count(self, document_path: Path, mime_type: str) -> int | None:
# If the format doesn't have the concept of pages, return None
return count_pages(document_path)
```
**Thumbnail**
`get_thumbnail()` may be called independently of `parse()`. Return the path
to a WebP image inside `self._tempdir`. The image should be roughly 500 × 700
pixels.
```python
def get_thumbnail(self, document_path: Path, mime_type: str) -> Path:
thumb = self._tempdir / "thumb.webp"
render_thumbnail(document_path, thumb)
return thumb
```
**Optional methods**
These are called by the API on demand, not during the consumption pipeline.
Implement them if your format supports the information; otherwise return
`None` / `[]`.
```python
def extract_metadata(
self,
document_path: Path,
mime_type: str,
) -> "list[MetadataEntry]":
# Must never raise. Return [] if metadata cannot be read.
from paperless.parsers import MetadataEntry
return [
MetadataEntry(
namespace="https://example.com/ns/",
prefix="ex",
key="Author",
value="Alice",
)
]
```
#### 2. Registering via entry point
Add the following to your package's `pyproject.toml`. The key (left of `=`)
is an arbitrary name used only in log output; the value is the
`module:ClassName` import path.
```toml
[project.entry-points."paperless_ngx.parsers"]
my_parser = "my_package.parsers:MyCustomParser"
```
Install your package into the same Python environment as Paperless-ngx (or
add it to the Docker image), and the parser will be discovered automatically
on the next startup. No configuration changes are needed.
To verify discovery, check the application logs at startup for a line like:
```
Loaded third-party parser 'My Format Parser' v1.0.0 by Acme Corp (entrypoint: 'my_parser').
```
#### 3. Utilities
`paperless.parsers.utils` provides helpers you can import directly:
| Function | Description |
| --------------------------------------- | ---------------------------------------------------------------- |
| `read_file_handle_unicode_errors(path)` | Read a file as UTF-8, replacing invalid bytes instead of raising |
| `get_page_count_for_pdf(path)` | Count pages in a PDF using pikepdf |
| `extract_pdf_metadata(path)` | Extract XMP metadata from a PDF as a `list[MetadataEntry]` |
#### Minimal example
A complete, working parser for a hypothetical plain-XML format:
```python
from __future__ import annotations
import shutil
import tempfile
from pathlib import Path
from typing import Self
from types import TracebackType
import xml.etree.ElementTree as ET
from django.conf import settings
from documents.parsers import ParseError
from paperless.parsers import ParserContext
class XmlDocumentParser:
name = "XML Parser"
version = "1.0.0"
author = "Acme Corp"
url = "https://example.com/xml-parser"
@classmethod
def supported_mime_types(cls) -> dict[str, str]:
return {"application/xml": ".xml", "text/xml": ".xml"}
@classmethod
def score(cls, mime_type: str, filename: str, path: Path | None = None) -> int | None:
return 10
@property
def can_produce_archive(self) -> bool:
return False
@property
def requires_pdf_rendition(self) -> bool:
return False
def __init__(self, logging_group: object = None) -> None:
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
self._tempdir = Path(tempfile.mkdtemp(prefix="paperless-", dir=settings.SCRATCH_DIR))
self._text: str | None = None
def __enter__(self) -> Self:
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
shutil.rmtree(self._tempdir, ignore_errors=True)
def configure(self, context: ParserContext) -> None:
pass
def parse(self, document_path: Path, mime_type: str, *, produce_archive: bool = True) -> None:
try:
tree = ET.parse(document_path)
self._text = " ".join(tree.getroot().itertext())
except ET.ParseError as e:
raise ParseError(f"XML parse error: {e}") from e
def get_text(self) -> str | None:
return self._text
def get_date(self):
return None
def get_archive_path(self) -> Path | None:
return None
def get_thumbnail(self, document_path: Path, mime_type: str) -> Path:
from PIL import Image, ImageDraw
img = Image.new("RGB", (500, 700), color="white")
ImageDraw.Draw(img).text((10, 10), "XML Document", fill="black")
out = self._tempdir / "thumb.webp"
img.save(out, format="WEBP")
return out
def get_page_count(self, document_path: Path, mime_type: str) -> int | None:
return None
def extract_metadata(self, document_path: Path, mime_type: str) -> list:
return []
```
### Developing date parser plugins
Paperless-ngx uses a plugin system for date parsing, allowing you to extend or replace the default date parsing behavior. Plugins are discovered using [Python entry points](https://setuptools.pypa.io/en/latest/userguide/entry_point.html). Paperless-ngx uses a plugin system for date parsing, allowing you to extend or replace the default date parsing behavior. Plugins are discovered using [Python entry points](https://setuptools.pypa.io/en/latest/userguide/entry_point.html).
#### Creating a Date Parser Plugin ### Creating a Date Parser Plugin
To create a custom date parser plugin, you need to: To create a custom date parser plugin, you need to:
@@ -738,7 +494,7 @@ To create a custom date parser plugin, you need to:
2. Implement the required abstract method 2. Implement the required abstract method
3. Register your plugin via an entry point 3. Register your plugin via an entry point
##### 1. Implementing the Parser Class #### 1. Implementing the Parser Class
Your parser must extend `documents.plugins.date_parsing.DateParserPluginBase` and implement the `parse` method: Your parser must extend `documents.plugins.date_parsing.DateParserPluginBase` and implement the `parse` method:
@@ -778,16 +534,16 @@ class MyDateParserPlugin(DateParserPluginBase):
yield another_datetime yield another_datetime
``` ```
##### 2. Configuration and Helper Methods #### 2. Configuration and Helper Methods
Your parser instance is initialized with a `DateParserConfig` object accessible via `self.config`. This provides: Your parser instance is initialized with a `DateParserConfig` object accessible via `self.config`. This provides:
- `languages: list[str]` - List of language codes for date parsing - `languages: list[str]` - List of language codes for date parsing
- `timezone_str: str` - Timezone string for date localization - `timezone_str: str` - Timezone string for date localization
- `ignore_dates: set[datetime.date]` - Dates that should be filtered out - `ignore_dates: set[datetime.date]` - Dates that should be filtered out
- `reference_time: datetime.datetime` - Current time for filtering future dates - `reference_time: datetime.datetime` - Current time for filtering future dates
- `filename_date_order: str | None` - Date order preference for filenames (e.g., "DMY", "MDY") - `filename_date_order: str | None` - Date order preference for filenames (e.g., "DMY", "MDY")
- `content_date_order: str` - Date order preference for content - `content_date_order: str` - Date order preference for content
The base class provides two helper methods you can use: The base class provides two helper methods you can use:
@@ -811,11 +567,11 @@ def _filter_date(
""" """
``` ```
##### 3. Resource Management (Optional) #### 3. Resource Management (Optional)
If your plugin needs to acquire or release resources (database connections, API clients, etc.), override the context manager methods. Paperless-ngx will always use plugins as context managers, ensuring resources can be released even in the event of errors. If your plugin needs to acquire or release resources (database connections, API clients, etc.), override the context manager methods. Paperless-ngx will always use plugins as context managers, ensuring resources can be released even in the event of errors.
##### 4. Registering Your Plugin #### 4. Registering Your Plugin
Register your plugin using a setuptools entry point in your package's `pyproject.toml`: Register your plugin using a setuptools entry point in your package's `pyproject.toml`:
@@ -826,7 +582,7 @@ my_parser = "my_package.parsers:MyDateParserPlugin"
The entry point name (e.g., `"my_parser"`) is used for sorting when multiple plugins are found. Paperless-ngx will use the first plugin alphabetically by name if multiple plugins are discovered. The entry point name (e.g., `"my_parser"`) is used for sorting when multiple plugins are found. Paperless-ngx will use the first plugin alphabetically by name if multiple plugins are discovered.
#### Plugin Discovery ### Plugin Discovery
Paperless-ngx automatically discovers and loads date parser plugins at runtime. The discovery process: Paperless-ngx automatically discovers and loads date parser plugins at runtime. The discovery process:
@@ -837,7 +593,7 @@ Paperless-ngx automatically discovers and loads date parser plugins at runtime.
If multiple plugins are installed, a warning is logged indicating which plugin was selected. If multiple plugins are installed, a warning is logged indicating which plugin was selected.
#### Example: Simple Date Parser ### Example: Simple Date Parser
Here's a minimal example that only looks for ISO 8601 dates: Here's a minimal example that only looks for ISO 8601 dates:
@@ -869,30 +625,3 @@ class ISODateParserPlugin(DateParserPluginBase):
if filtered_date is not None: if filtered_date is not None:
yield filtered_date yield filtered_date
``` ```
## Using Visual Studio Code devcontainer
Another easy way to get started with development is to use Visual Studio
Code devcontainers. This approach will create a preconfigured development
environment with all of the required tools and dependencies.
[Learn more about devcontainers](https://code.visualstudio.com/docs/devcontainers/containers).
The .devcontainer/vscode/tasks.json and .devcontainer/vscode/launch.json files
contain more information about the specific tasks and launch configurations (see the
non-standard "description" field).
To get started:
1. Clone the repository on your machine and open the Paperless-ngx folder in VS Code.
2. VS Code will prompt you with "Reopen in container". Do so and wait for the environment to start.
3. In case your host operating system is Windows:
- The Source Control view in Visual Studio Code might show: "The detected Git repository is potentially unsafe as the folder is owned by someone other than the current user." Use "Manage Unsafe Repositories" to fix this.
- Git might have detecteded modifications for all files, because Windows is using CRLF line endings. Run `git checkout .` in the containers terminal to fix this issue.
4. Initialize the project by running the task **Project Setup: Run all Init Tasks**. This
will initialize the database tables and create a superuser. Then you can compile the front end
for production or run the frontend in debug mode.
5. The project is ready for debugging, start either run the fullstack debug or individual debug
processes. Yo spin up the project without debugging run the task **Project Start: Run all Services**

View File

@@ -44,28 +44,28 @@ system. On Linux, chances are high that this location is
You can always drag those files out of that folder to use them You can always drag those files out of that folder to use them
elsewhere. Here are a couple notes about that. elsewhere. Here are a couple notes about that.
- Paperless-ngx never modifies your original documents. It keeps - Paperless-ngx never modifies your original documents. It keeps
checksums of all documents and uses a scheduled sanity checker to checksums of all documents and uses a scheduled sanity checker to
check that they remain the same. check that they remain the same.
- By default, paperless uses the internal ID of each document as its - By default, paperless uses the internal ID of each document as its
filename. This might not be very convenient for export. However, you filename. This might not be very convenient for export. However, you
can adjust the way files are stored in paperless by can adjust the way files are stored in paperless by
[configuring the filename format](advanced_usage.md#file-name-handling). [configuring the filename format](advanced_usage.md#file-name-handling).
- [The exporter](administration.md#exporter) is - [The exporter](administration.md#exporter) is
another easy way to get your files out of paperless with reasonable another easy way to get your files out of paperless with reasonable
file names. file names.
## _What file types does paperless-ngx support?_ ## _What file types does paperless-ngx support?_
**A:** Currently, the following files are supported: **A:** Currently, the following files are supported:
- PDF documents, PNG images, JPEG images, TIFF images, GIF images and - PDF documents, PNG images, JPEG images, TIFF images, GIF images and
WebP images are processed with OCR and converted into PDF documents. WebP images are processed with OCR and converted into PDF documents.
- Plain text documents are supported as well and are added verbatim to - Plain text documents are supported as well and are added verbatim to
paperless. paperless.
- With the optional Tika integration enabled (see [Tika configuration](https://docs.paperless-ngx.com/configuration#tika)), - With the optional Tika integration enabled (see [Tika configuration](https://docs.paperless-ngx.com/configuration#tika)),
Paperless also supports various Office documents (.docx, .doc, odt, Paperless also supports various Office documents (.docx, .doc, odt,
.ppt, .pptx, .odp, .xls, .xlsx, .ods). .ppt, .pptx, .odp, .xls, .xlsx, .ods).
Paperless-ngx determines the type of a file by inspecting its content Paperless-ngx determines the type of a file by inspecting its content
rather than its file extensions. However, files processed via the rather than its file extensions. However, files processed via the

View File

@@ -28,36 +28,36 @@ physical documents into a searchable online archive so you can keep, well, _less
## Features ## Features
- **Organize and index** your scanned documents with tags, correspondents, types, and more. - **Organize and index** your scanned documents with tags, correspondents, types, and more.
- _Your_ data is stored locally on _your_ server and is never transmitted or shared in any way, unless you explicitly choose to do so. - _Your_ data is stored locally on _your_ server and is never transmitted or shared in any way, unless you explicitly choose to do so.
- Performs **OCR** on your documents, adding searchable and selectable text, even to documents scanned with only images. - Performs **OCR** on your documents, adding searchable and selectable text, even to documents scanned with only images.
- Utilizes the open-source Tesseract engine to recognize more than 100 languages. - Utilizes the open-source Tesseract engine to recognize more than 100 languages.
- _New!_ Supports remote OCR with Azure AI (opt-in). - _New!_ Supports remote OCR with Azure AI (opt-in).
- Documents are saved as PDF/A format which is designed for long term storage, alongside the unaltered originals. - Documents are saved as PDF/A format which is designed for long term storage, alongside the unaltered originals.
- Uses machine-learning to automatically add tags, correspondents and document types to your documents. - Uses machine-learning to automatically add tags, correspondents and document types to your documents.
- **New**: Paperless-ngx can now leverage AI (Large Language Models or LLMs) for document suggestions. This is an optional feature that can be enabled (and is disabled by default). - **New**: Paperless-ngx can now leverage AI (Large Language Models or LLMs) for document suggestions. This is an optional feature that can be enabled (and is disabled by default).
- Supports PDF documents, images, plain text files, Office documents (Word, Excel, PowerPoint, and LibreOffice equivalents)[^1] and more. - Supports PDF documents, images, plain text files, Office documents (Word, Excel, PowerPoint, and LibreOffice equivalents)[^1] and more.
- Paperless stores your documents plain on disk. Filenames and folders are managed by paperless and their format can be configured freely with different configurations assigned to different documents. - Paperless stores your documents plain on disk. Filenames and folders are managed by paperless and their format can be configured freely with different configurations assigned to different documents.
- **Beautiful, modern web application** that features: - **Beautiful, modern web application** that features:
- Customizable dashboard with statistics. - Customizable dashboard with statistics.
- Filtering by tags, correspondents, types, and more. - Filtering by tags, correspondents, types, and more.
- Bulk editing of tags, correspondents, types and more. - Bulk editing of tags, correspondents, types and more.
- Drag-and-drop uploading of documents throughout the app. - Drag-and-drop uploading of documents throughout the app.
- Customizable views can be saved and displayed on the dashboard and / or sidebar. - Customizable views can be saved and displayed on the dashboard and / or sidebar.
- Support for custom fields of various data types. - Support for custom fields of various data types.
- Shareable public links with optional expiration. - Shareable public links with optional expiration.
- **Full text search** helps you find what you need: - **Full text search** helps you find what you need:
- Auto completion suggests relevant words from your documents. - Auto completion suggests relevant words from your documents.
- Results are sorted by relevance to your search query. - Results are sorted by relevance to your search query.
- Highlighting shows you which parts of the document matched the query. - Highlighting shows you which parts of the document matched the query.
- Searching for similar documents ("More like this") - Searching for similar documents ("More like this")
- **Email processing**[^1]: import documents from your email accounts: - **Email processing**[^1]: import documents from your email accounts:
- Configure multiple accounts and rules for each account. - Configure multiple accounts and rules for each account.
- After processing, paperless can perform actions on the messages such as marking as read, deleting and more. - After processing, paperless can perform actions on the messages such as marking as read, deleting and more.
- A built-in robust **multi-user permissions** system that supports 'global' permissions as well as per document or object. - A built-in robust **multi-user permissions** system that supports 'global' permissions as well as per document or object.
- A powerful workflow system that gives you even more control. - A powerful workflow system that gives you even more control.
- **Optimized** for multi core systems: Paperless-ngx consumes multiple documents in parallel. - **Optimized** for multi core systems: Paperless-ngx consumes multiple documents in parallel.
- The integrated sanity checker makes sure that your document archive is in good health. - The integrated sanity checker makes sure that your document archive is in good health.
[^1]: Office document and email consumption support is optional and provided by Apache Tika (see [configuration](https://docs.paperless-ngx.com/configuration/#tika)) [^1]: Office document and email consumption support is optional and provided by Apache Tika (see [configuration](https://docs.paperless-ngx.com/configuration/#tika))

View File

@@ -42,12 +42,12 @@ The `CONSUMER_BARCODE_SCANNER` setting has been removed. zxing-cpp is now the on
### Action Required ### Action Required
- If you were already using `CONSUMER_BARCODE_SCANNER=ZXING`, simply remove the setting. - If you were already using `CONSUMER_BARCODE_SCANNER=ZXING`, simply remove the setting.
- If you had `CONSUMER_BARCODE_SCANNER=PYZBAR` or were using the default, no functional changes are needed beyond - If you had `CONSUMER_BARCODE_SCANNER=PYZBAR` or were using the default, no functional changes are needed beyond
removing the setting. zxing-cpp supports all the same barcode formats and you should see improved detection removing the setting. zxing-cpp supports all the same barcode formats and you should see improved detection
reliability. reliability.
- The `libzbar0` / `libzbar-dev` system packages are no longer required and can be removed from any custom Docker - The `libzbar0` / `libzbar-dev` system packages are no longer required and can be removed from any custom Docker
images or host installations. images or host installations.
## Database Engine ## Database Engine
@@ -103,30 +103,3 @@ Multiple options are combined in a single value:
```bash ```bash
PAPERLESS_DB_OPTIONS="sslmode=require;sslrootcert=/certs/ca.pem;pool.max_size=10" PAPERLESS_DB_OPTIONS="sslmode=require;sslrootcert=/certs/ca.pem;pool.max_size=10"
``` ```
## OpenID Connect Token Endpoint Authentication
Some existing OpenID Connect setups may require an explicit token endpoint authentication method after upgrading to v3.
#### Action Required
If OIDC login fails at the callback with an `invalid_client` error, add `token_auth_method` to the provider `settings` in
[`PAPERLESS_SOCIALACCOUNT_PROVIDERS`](configuration.md#PAPERLESS_SOCIALACCOUNT_PROVIDERS).
For example:
```json
{
"openid_connect": {
"APPS": [
{
...
"settings": {
"server_url": "https://login.example.com",
"token_auth_method": "client_secret_basic"
}
}
]
}
}
```

View File

@@ -44,8 +44,8 @@ account. In short, it automates the [Docker Compose setup](#docker) described be
#### Prerequisites #### Prerequisites
- Docker and Docker Compose must be [installed](https://docs.docker.com/engine/install/){:target="\_blank"}. - Docker and Docker Compose must be [installed](https://docs.docker.com/engine/install/){:target="\_blank"}.
- macOS users will need [GNU sed](https://formulae.brew.sh/formula/gnu-sed) with support for running as `sed` as well as [wget](https://formulae.brew.sh/formula/wget). - macOS users will need [GNU sed](https://formulae.brew.sh/formula/gnu-sed) with support for running as `sed` as well as [wget](https://formulae.brew.sh/formula/wget).
#### Run the installation script #### Run the installation script
@@ -63,7 +63,7 @@ credentials you provided during the installation script.
#### Prerequisites #### Prerequisites
- Docker and Docker Compose must be [installed](https://docs.docker.com/engine/install/){:target="\_blank"}. - Docker and Docker Compose must be [installed](https://docs.docker.com/engine/install/){:target="\_blank"}.
#### Installation #### Installation
@@ -101,7 +101,7 @@ credentials you provided during the installation script.
```yaml ```yaml
ports: ports:
- 8010:8000 - 8010:8000
``` ```
3. Modify `docker-compose.env` with any configuration options you need. 3. Modify `docker-compose.env` with any configuration options you need.
@@ -140,17 +140,24 @@ a [superuser](usage.md#superusers) account.
!!! warning !!! warning
It is not possible to run the container rootless if additional languages are specified via `PAPERLESS_OCR_LANGUAGES`. It is currently 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: If you want to run Paperless as a rootless container, make this
change in `docker-compose.yml`:
```yaml - Set the `user` running the container to map to the `paperless`
webserver: user in the container. This value (`user_id` below) should be
image: ghcr.io/paperless-ngx/paperless-ngx:latest the same ID that `USERMAP_UID` and `USERMAP_GID` are set to in
user: '1000:1000' `docker-compose.env`. See `USERMAP_UID` and `USERMAP_GID`
``` [here](configuration.md#docker).
Do not combine this with `USERMAP_UID` or `USERMAP_GID`, which are intended for the non-rootless case described in step 3. Your entry for Paperless should contain something like:
> ```
> webserver:
> image: ghcr.io/paperless-ngx/paperless-ngx:latest
> user: <user_id>
> ```
**File systems without inotify support (e.g. NFS)** **File systems without inotify support (e.g. NFS)**
@@ -164,25 +171,26 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
#### Prerequisites #### Prerequisites
- Paperless runs on Linux only, Windows is not supported. - Paperless runs on Linux only, Windows is not supported.
- Python 3.11, 3.12, 3.13, or 3.14 is required. As a policy, Paperless-ngx aims to support at least the three most recent Python versions and drops support for versions as they reach end-of-life. Newer versions may work, but some dependencies may not be fully compatible. - Python 3.11, 3.12, 3.13, or 3.14 is required. As a policy, Paperless-ngx aims to support at least the three most recent Python versions and drops support for versions as they reach end-of-life. Newer versions may work, but some dependencies may not be fully compatible.
#### Installation #### Installation
1. Install dependencies. Paperless requires the following packages: 1. Install dependencies. Paperless requires the following packages:
- `python3`
- `python3-pip` - `python3`
- `python3-dev` - `python3-pip`
- `default-libmysqlclient-dev` for MariaDB - `python3-dev`
- `pkg-config` for mysqlclient (python dependency) - `default-libmysqlclient-dev` for MariaDB
- `fonts-liberation` for generating thumbnails for plain text - `pkg-config` for mysqlclient (python dependency)
files - `fonts-liberation` for generating thumbnails for plain text
- `imagemagick` >= 6 for PDF conversion files
- `gnupg` for handling encrypted documents - `imagemagick` >= 6 for PDF conversion
- `libpq-dev` for PostgreSQL - `gnupg` for handling encrypted documents
- `libmagic-dev` for mime type detection - `libpq-dev` for PostgreSQL
- `mariadb-client` for MariaDB compile time - `libmagic-dev` for mime type detection
- `poppler-utils` for barcode detection - `mariadb-client` for MariaDB compile time
- `poppler-utils` for barcode detection
Use this list for your preferred package management: Use this list for your preferred package management:
@@ -192,17 +200,18 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
These dependencies are required for OCRmyPDF, which is used for text These dependencies are required for OCRmyPDF, which is used for text
recognition. recognition.
- `unpaper`
- `ghostscript` - `unpaper`
- `icc-profiles-free` - `ghostscript`
- `qpdf` - `icc-profiles-free`
- `liblept5` - `qpdf`
- `libxml2` - `liblept5`
- `pngquant` (suggested for certain PDF image optimizations) - `libxml2`
- `zlib1g` - `pngquant` (suggested for certain PDF image optimizations)
- `tesseract-ocr` >= 4.0.0 for OCR - `zlib1g`
- `tesseract-ocr` language packs (`tesseract-ocr-eng`, - `tesseract-ocr` >= 4.0.0 for OCR
`tesseract-ocr-deu`, etc) - `tesseract-ocr` language packs (`tesseract-ocr-eng`,
`tesseract-ocr-deu`, etc)
Use this list for your preferred package management: Use this list for your preferred package management:
@@ -211,14 +220,16 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
``` ```
On Raspberry Pi, these libraries are required as well: On Raspberry Pi, these libraries are required as well:
- `libatlas-base-dev`
- `libxslt1-dev` - `libatlas-base-dev`
- `mime-support` - `libxslt1-dev`
- `mime-support`
You will also need these for installing some of the python dependencies: You will also need these for installing some of the python dependencies:
- `build-essential`
- `python3-setuptools` - `build-essential`
- `python3-wheel` - `python3-setuptools`
- `python3-wheel`
Use this list for your preferred package management: Use this list for your preferred package management:
@@ -268,41 +279,44 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
6. Configure Paperless-ngx. See [configuration](configuration.md) for details. 6. Configure Paperless-ngx. See [configuration](configuration.md) for details.
Edit the included `paperless.conf` and adjust the settings to your Edit the included `paperless.conf` and adjust the settings to your
needs. Required settings for getting Paperless-ngx running are: needs. Required settings for getting Paperless-ngx running are:
- [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) should point to your Redis server, such as
`redis://localhost:6379`. - [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) should point to your Redis server, such as
- [`PAPERLESS_DBENGINE`](configuration.md#PAPERLESS_DBENGINE) is optional, and should be one of `postgres`, `redis://localhost:6379`.
`mariadb`, or `sqlite` - [`PAPERLESS_DBENGINE`](configuration.md#PAPERLESS_DBENGINE) is optional, and should be one of `postgres`,
- [`PAPERLESS_DBHOST`](configuration.md#PAPERLESS_DBHOST) should be the hostname on which your `mariadb`, or `sqlite`
PostgreSQL server is running. Do not configure this to use - [`PAPERLESS_DBHOST`](configuration.md#PAPERLESS_DBHOST) should be the hostname on which your
SQLite instead. Also configure port, database name, user and PostgreSQL server is running. Do not configure this to use
password as necessary. SQLite instead. Also configure port, database name, user and
- [`PAPERLESS_CONSUMPTION_DIR`](configuration.md#PAPERLESS_CONSUMPTION_DIR) should point to the folder password as necessary.
that Paperless-ngx should watch for incoming documents. - [`PAPERLESS_CONSUMPTION_DIR`](configuration.md#PAPERLESS_CONSUMPTION_DIR) should point to the folder
Likewise, [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) and that Paperless-ngx should watch for incoming documents.
[`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) define where Paperless-ngx stores its data. Likewise, [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) and
If needed, these can point to the same directory. [`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) define where Paperless-ngx stores its data.
- [`PAPERLESS_SECRET_KEY`](configuration.md#PAPERLESS_SECRET_KEY) should be a random sequence of If needed, these can point to the same directory.
characters. It's used for authentication. Failure to do so - [`PAPERLESS_SECRET_KEY`](configuration.md#PAPERLESS_SECRET_KEY) should be a random sequence of
allows third parties to forge authentication credentials. characters. It's used for authentication. Failure to do so
- Set [`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) if you are behind a reverse proxy. This should allows third parties to forge authentication credentials.
point to your domain. Please see - Set [`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) if you are behind a reverse proxy. This should
[configuration](configuration.md) for more point to your domain. Please see
information. [configuration](configuration.md) for more
information.
You can make many more adjustments, especially for OCR. You can make many more adjustments, especially for OCR.
The following options are recommended for most users: The following options are recommended for most users:
- Set [`PAPERLESS_OCR_LANGUAGE`](configuration.md#PAPERLESS_OCR_LANGUAGE) to the language most of your
documents are written in. - Set [`PAPERLESS_OCR_LANGUAGE`](configuration.md#PAPERLESS_OCR_LANGUAGE) to the language most of your
- Set [`PAPERLESS_TIME_ZONE`](configuration.md#PAPERLESS_TIME_ZONE) to your local time zone. documents are written in.
- Set [`PAPERLESS_TIME_ZONE`](configuration.md#PAPERLESS_TIME_ZONE) to your local time zone.
!!! warning !!! warning
Ensure your Redis instance [is secured](https://redis.io/docs/latest/operate/oss_and_stack/management/security/). Ensure your Redis instance [is secured](https://redis.io/docs/latest/operate/oss_and_stack/management/security/).
7. Create the following directories if they do not already exist: 7. Create the following directories if they do not already exist:
- `/opt/paperless/media`
- `/opt/paperless/data` - `/opt/paperless/media`
- `/opt/paperless/consume` - `/opt/paperless/data`
- `/opt/paperless/consume`
Adjust these paths if you configured different folders. Adjust these paths if you configured different folders.
Then verify that the `paperless` user has write permissions: Then verify that the `paperless` user has write permissions:
@@ -377,10 +391,11 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
starting point. starting point.
Paperless needs: Paperless needs:
- The `webserver` script to run the webserver.
- The `consumer` script to watch the input folder. - The `webserver` script to run the webserver.
- The `taskqueue` script for background workers (document consumption, etc.). - The `consumer` script to watch the input folder.
- The `scheduler` script for periodic tasks such as email checking. - The `taskqueue` script for background workers (document consumption, etc.).
- The `scheduler` script for periodic tasks such as email checking.
!!! note !!! note
@@ -486,19 +501,19 @@ your setup depending on how you installed Paperless.
This section describes how to update an existing Paperless Docker This section describes how to update an existing Paperless Docker
installation. Keep these points in mind: installation. Keep these points in mind:
- Read the [changelog](changelog.md) and - Read the [changelog](changelog.md) and
take note of breaking changes. take note of breaking changes.
- Decide whether to stay on SQLite or migrate to PostgreSQL. - Decide whether to stay on SQLite or migrate to PostgreSQL.
Both work fine with Paperless-ngx. Both work fine with Paperless-ngx.
However, if you already have a database server running However, if you already have a database server running
for other services, you might as well use it for Paperless as well. for other services, you might as well use it for Paperless as well.
- The task scheduler of Paperless, which is used to execute periodic - The task scheduler of Paperless, which is used to execute periodic
tasks such as email checking and maintenance, requires a tasks such as email checking and maintenance, requires a
[Redis](https://redis.io/) message broker instance. The [Redis](https://redis.io/) message broker instance. The
Docker Compose route takes care of that. Docker Compose route takes care of that.
- The layout of the folder structure for your documents and data - The layout of the folder structure for your documents and data
remains the same, so you can plug your old Docker volumes into remains the same, so you can plug your old Docker volumes into
paperless-ngx and expect it to find everything where it should be. paperless-ngx and expect it to find everything where it should be.
Migration to Paperless-ngx is then performed in a few simple steps: Migration to Paperless-ngx is then performed in a few simple steps:
@@ -583,6 +598,7 @@ commands as well.
1. Stop and remove the Paperless container. 1. Stop and remove the Paperless container.
2. If using an external database, stop that container. 2. If using an external database, stop that container.
3. Update Redis configuration. 3. Update Redis configuration.
1. If `REDIS_URL` is already set, change it to [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) 1. If `REDIS_URL` is already set, change it to [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS)
and continue to step 4. and continue to step 4.
@@ -594,18 +610,22 @@ commands as well.
the new Redis container. the new Redis container.
4. Update user mapping. 4. Update user mapping.
1. If set, change the environment variable `PUID` to `USERMAP_UID`. 1. If set, change the environment variable `PUID` to `USERMAP_UID`.
1. If set, change the environment variable `PGID` to `USERMAP_GID`. 1. If set, change the environment variable `PGID` to `USERMAP_GID`.
5. Update configuration paths. 5. Update configuration paths.
1. Set the environment variable [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) to `/config`. 1. Set the environment variable [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) to `/config`.
6. Update media paths. 6. Update media paths.
1. Set the environment variable [`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) to 1. Set the environment variable [`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) to
`/data/media`. `/data/media`.
7. Update timezone. 7. Update timezone.
1. Set the environment variable [`PAPERLESS_TIME_ZONE`](configuration.md#PAPERLESS_TIME_ZONE) to the same 1. Set the environment variable [`PAPERLESS_TIME_ZONE`](configuration.md#PAPERLESS_TIME_ZONE) to the same
value as `TZ`. value as `TZ`.
@@ -619,33 +639,33 @@ commands as well.
Paperless runs on Raspberry Pi. Some tasks can be slow on lower-powered Paperless runs on Raspberry Pi. Some tasks can be slow on lower-powered
hardware, but a few settings can improve performance: hardware, but a few settings can improve performance:
- Stick with SQLite to save some resources. See [troubleshooting](troubleshooting.md#log-reports-creating-paperlesstask-failed) - Stick with SQLite to save some resources. See [troubleshooting](troubleshooting.md#log-reports-creating-paperlesstask-failed)
if you encounter issues with SQLite locking. if you encounter issues with SQLite locking.
- If you do not need the filesystem-based consumer, consider disabling it - If you do not need the filesystem-based consumer, consider disabling it
entirely by setting [`PAPERLESS_CONSUMER_DISABLE`](configuration.md#PAPERLESS_CONSUMER_DISABLE) to `true`. entirely by setting [`PAPERLESS_CONSUMER_DISABLE`](configuration.md#PAPERLESS_CONSUMER_DISABLE) to `true`.
- Consider setting [`PAPERLESS_OCR_PAGES`](configuration.md#PAPERLESS_OCR_PAGES) to 1, so that Paperless - Consider setting [`PAPERLESS_OCR_PAGES`](configuration.md#PAPERLESS_OCR_PAGES) to 1, so that Paperless
OCRs only the first page of your documents. In most cases, this page OCRs only the first page of your documents. In most cases, this page
contains enough information to be able to find it. contains enough information to be able to find it.
- [`PAPERLESS_TASK_WORKERS`](configuration.md#PAPERLESS_TASK_WORKERS) and [`PAPERLESS_THREADS_PER_WORKER`](configuration.md#PAPERLESS_THREADS_PER_WORKER) are - [`PAPERLESS_TASK_WORKERS`](configuration.md#PAPERLESS_TASK_WORKERS) and [`PAPERLESS_THREADS_PER_WORKER`](configuration.md#PAPERLESS_THREADS_PER_WORKER) are
configured to use all cores. The Raspberry Pi models 3 and up have 4 configured to use all cores. The Raspberry Pi models 3 and up have 4
cores, meaning that Paperless will use 2 workers and 2 threads per cores, meaning that Paperless will use 2 workers and 2 threads per
worker. This may result in sluggish response times during worker. This may result in sluggish response times during
consumption, so you might want to lower these settings (example: 2 consumption, so you might want to lower these settings (example: 2
workers and 1 thread to always have some computing power left for workers and 1 thread to always have some computing power left for
other tasks). other tasks).
- Keep [`PAPERLESS_OCR_MODE`](configuration.md#PAPERLESS_OCR_MODE) at its default value `skip` and consider - Keep [`PAPERLESS_OCR_MODE`](configuration.md#PAPERLESS_OCR_MODE) at its default value `skip` and consider
OCRing your documents before feeding them into Paperless. Some OCRing your documents before feeding them into Paperless. Some
scanners are able to do this! scanners are able to do this!
- Set [`PAPERLESS_OCR_SKIP_ARCHIVE_FILE`](configuration.md#PAPERLESS_OCR_SKIP_ARCHIVE_FILE) to `with_text` to skip archive - Set [`PAPERLESS_OCR_SKIP_ARCHIVE_FILE`](configuration.md#PAPERLESS_OCR_SKIP_ARCHIVE_FILE) to `with_text` to skip archive
file generation for already OCRed documents, or `always` to skip it file generation for already OCRed documents, or `always` to skip it
for all documents. for all documents.
- If you want to perform OCR on the device, consider using - If you want to perform OCR on the device, consider using
`PAPERLESS_OCR_CLEAN=none`. This will speed up OCR times and use `PAPERLESS_OCR_CLEAN=none`. This will speed up OCR times and use
less memory at the expense of slightly worse OCR results. less memory at the expense of slightly worse OCR results.
- If using Docker, consider setting [`PAPERLESS_WEBSERVER_WORKERS`](configuration.md#PAPERLESS_WEBSERVER_WORKERS) to 1. This will save some memory. - If using Docker, consider setting [`PAPERLESS_WEBSERVER_WORKERS`](configuration.md#PAPERLESS_WEBSERVER_WORKERS) to 1. This will save some memory.
- Consider setting [`PAPERLESS_ENABLE_NLTK`](configuration.md#PAPERLESS_ENABLE_NLTK) to false, to disable the - Consider setting [`PAPERLESS_ENABLE_NLTK`](configuration.md#PAPERLESS_ENABLE_NLTK) to false, to disable the
more advanced language processing, which can take more memory and more advanced language processing, which can take more memory and
processing time. processing time.
For details, refer to [configuration](configuration.md). For details, refer to [configuration](configuration.md).

View File

@@ -4,27 +4,27 @@
Check for the following issues: Check for the following issues:
- Ensure that the directory you're putting your documents in is the - Ensure that the directory you're putting your documents in is the
folder paperless is watching. With docker, this setting is performed folder paperless is watching. With docker, this setting is performed
in the `docker-compose.yml` file. Without Docker, look at the in the `docker-compose.yml` file. Without Docker, look at the
`CONSUMPTION_DIR` setting. Don't adjust this setting if you're `CONSUMPTION_DIR` setting. Don't adjust this setting if you're
using docker. using docker.
- Ensure that redis is up and running. Paperless does its task - Ensure that redis is up and running. Paperless does its task
processing asynchronously, and for documents to arrive at the task processing asynchronously, and for documents to arrive at the task
processor, it needs redis to run. processor, it needs redis to run.
- Ensure that the task processor is running. Docker does this - Ensure that the task processor is running. Docker does this
automatically. Manually invoke the task processor by executing automatically. Manually invoke the task processor by executing
```shell-session ```shell-session
celery --app paperless worker celery --app paperless worker
``` ```
- Look at the output of paperless and inspect it for any errors. - Look at the output of paperless and inspect it for any errors.
- Go to the admin interface, and check if there are failed tasks. If - Go to the admin interface, and check if there are failed tasks. If
so, the tasks will contain an error message. so, the tasks will contain an error message.
## Consumer warns `OCR for XX failed` ## Consumer warns `OCR for XX failed`
@@ -78,12 +78,12 @@ Ensure that `chown` is possible on these directories.
This indicates that the Auto matching algorithm found no documents to This indicates that the Auto matching algorithm found no documents to
learn from. This may have two reasons: learn from. This may have two reasons:
- You don't use the Auto matching algorithm: The error can be safely - You don't use the Auto matching algorithm: The error can be safely
ignored in this case. ignored in this case.
- You are using the Auto matching algorithm: The classifier explicitly - You are using the Auto matching algorithm: The classifier explicitly
excludes documents with Inbox tags. Verify that there are documents excludes documents with Inbox tags. Verify that there are documents
in your archive without inbox tags. The algorithm will only learn in your archive without inbox tags. The algorithm will only learn
from documents not in your inbox. from documents not in your inbox.
## UserWarning in sklearn on every single document ## UserWarning in sklearn on every single document
@@ -127,10 +127,10 @@ change in the `docker-compose.yml` file:
# 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.
command: command:
- 'gotenberg' - 'gotenberg'
- '--chromium-disable-javascript=true' - '--chromium-disable-javascript=true'
- '--chromium-allow-list=file:///tmp/.*' - '--chromium-allow-list=file:///tmp/.*'
- '--api-timeout=60s' - '--api-timeout=60s'
``` ```
## Permission denied errors in the consumption directory ## Permission denied errors in the consumption directory

View File

@@ -14,42 +14,42 @@ for finding and managing your documents.
Paperless essentially consists of two different parts for managing your Paperless essentially consists of two different parts for managing your
documents: documents:
- The _consumer_ watches a specified folder and adds all documents in - The _consumer_ watches a specified folder and adds all documents in
that folder to paperless. that folder to paperless.
- The _web server_ (web UI) provides a UI that you use to manage and - The _web server_ (web UI) provides a UI that you use to manage and
search documents. search documents.
Each document has data fields that you can assign to them: Each document has data fields that you can assign to them:
- A _Document_ is a piece of paper that sometimes contains valuable - A _Document_ is a piece of paper that sometimes contains valuable
information. information.
- The _correspondent_ of a document is the person, institution or - The _correspondent_ of a document is the person, institution or
company that a document either originates from, or is sent to. company that a document either originates from, or is sent to.
- A _tag_ is a label that you can assign to documents. Think of labels - A _tag_ is a label that you can assign to documents. Think of labels
as more powerful folders: Multiple documents can be grouped together as more powerful folders: Multiple documents can be grouped together
with a single tag, however, a single document can also have multiple with a single tag, however, a single document can also have multiple
tags. This is not possible with folders. The reason folders are not tags. This is not possible with folders. The reason folders are not
implemented in paperless is simply that tags are much more versatile implemented in paperless is simply that tags are much more versatile
than folders. than folders.
- A _document type_ is used to demarcate the type of a document such - A _document type_ is used to demarcate the type of a document such
as letter, bank statement, invoice, contract, etc. It is used to as letter, bank statement, invoice, contract, etc. It is used to
identify what a document is about. identify what a document is about.
- The document _storage path_ is the location where the document files - The document _storage path_ is the location where the document files
are stored. See [Storage Paths](advanced_usage.md#storage-paths) for are stored. See [Storage Paths](advanced_usage.md#storage-paths) for
more information. more information.
- The _date added_ of a document is the date the document was scanned - The _date added_ of a document is the date the document was scanned
into paperless. You cannot and should not change this date. into paperless. You cannot and should not change this date.
- The _date created_ of a document is the date the document was - The _date created_ of a document is the date the document was
initially issued. This can be the date you bought a product, the initially issued. This can be the date you bought a product, the
date you signed a contract, or the date a letter was sent to you. date you signed a contract, or the date a letter was sent to you.
- The _archive serial number_ (short: ASN) of a document is the - The _archive serial number_ (short: ASN) of a document is the
identifier of the document in your physical document binders. See identifier of the document in your physical document binders. See
[recommended workflow](#usage-recommended-workflow) below. [recommended workflow](#usage-recommended-workflow) below.
- The _content_ of a document is the text that was OCR'ed from the - The _content_ of a document is the text that was OCR'ed from the
document. This text is fed into the search engine and is used for document. This text is fed into the search engine and is used for
matching tags, correspondents and document types. matching tags, correspondents and document types.
- Paperless-ngx also supports _custom fields_ which can be used to - Paperless-ngx also supports _custom fields_ which can be used to
store additional metadata about a document. store additional metadata about a document.
## The Web UI ## The Web UI
@@ -93,12 +93,12 @@ download the document or share it via a share link.
Think of versions as **file history** for a document. Think of versions as **file history** for a document.
- Versions track the underlying file and extracted text content (OCR/text). - Versions track the underlying file and extracted text content (OCR/text).
- Metadata such as tags, correspondent, document type, storage path and custom fields stay on the "root" document. - Metadata such as tags, correspondent, document type, storage path and custom fields stay on the "root" document.
- Version files follow normal filename formatting (including storage paths) and add a `_vN` suffix (for example `_v1`, `_v2`). - Version files follow normal filename formatting (including storage paths) and add a `_vN` suffix (for example `_v1`, `_v2`).
- By default, search and document content use the latest version. - By default, search and document content use the latest version.
- In document detail, selecting a version switches the preview, file metadata and content (and download etc buttons) to that version. - In document detail, selecting a version switches the preview, file metadata and content (and download etc buttons) to that version.
- Deleting a non-root version keeps metadata and falls back to the latest remaining version. - Deleting a non-root version keeps metadata and falls back to the latest remaining version.
### Management Lists ### Management Lists
@@ -218,20 +218,21 @@ patterns can include wildcards and multiple patterns separated by a comma.
The actions all ensure that the same mail is not consumed twice by The actions all ensure that the same mail is not consumed twice by
different means. These are as follows: different means. These are as follows:
- **Delete:** Immediately deletes mail that paperless has consumed - **Delete:** Immediately deletes mail that paperless has consumed
documents from. Use with caution. documents from. Use with caution.
- **Mark as read:** Mark consumed mail as read. Paperless will not - **Mark as read:** Mark consumed mail as read. Paperless will not
consume documents from already read mails. If you read a mail before consume documents from already read mails. If you read a mail before
paperless sees it, it will be ignored. paperless sees it, it will be ignored.
- **Flag:** Sets the 'important' flag on mails with consumed - **Flag:** Sets the 'important' flag on mails with consumed
documents. Paperless will not consume flagged mails. documents. Paperless will not consume flagged mails.
- **Move to folder:** Moves consumed mails out of the way so that - **Move to folder:** Moves consumed mails out of the way so that
paperless won't consume them again. paperless won't consume them again.
- **Add custom Tag:** Adds a custom tag to mails with consumed - **Add custom Tag:** Adds a custom tag to mails with consumed
documents (the IMAP standard calls these "keywords"). Paperless documents (the IMAP standard calls these "keywords"). Paperless
will not consume mails already tagged. Not all mail servers support will not consume mails already tagged. Not all mail servers support
this feature! this feature!
- **Apple Mail support:** Apple Mail clients allow differently colored tags. For this to work use `apple:<color>` (e.g. _apple:green_) as a custom tag. Available colors are _red_, _orange_, _yellow_, _blue_, _green_, _violet_ and _grey_.
- **Apple Mail support:** Apple Mail clients allow differently colored tags. For this to work use `apple:<color>` (e.g. _apple:green_) as a custom tag. Available colors are _red_, _orange_, _yellow_, _blue_, _green_, _violet_ and _grey_.
!!! warning !!! warning
@@ -324,12 +325,12 @@ or using [email](#workflow-action-email) or [webhook](#workflow-action-webhook)
"Share links" are public links to files (or an archive of files) and can be created and managed under the 'Send' button on the document detail screen or from the bulk editor. "Share links" are public links to files (or an archive of files) and can be created and managed under the 'Send' button on the document detail screen or from the bulk editor.
- Share links do not require a user to login and thus link directly to a file or bundled download. - Share links do not require a user to login and thus link directly to a file or bundled download.
- Links are unique and are of the form `{paperless-url}/share/{randomly-generated-slug}`. - Links are unique and are of the form `{paperless-url}/share/{randomly-generated-slug}`.
- Links can optionally have an expiration time set. - Links can optionally have an expiration time set.
- After a link expires or is deleted users will be redirected to the regular paperless-ngx login. - After a link expires or is deleted users will be redirected to the regular paperless-ngx login.
- From the document detail screen you can create a share link for that single document. - From the document detail screen you can create a share link for that single document.
- From the bulk editor you can create a **share link bundle** for any selection. Paperless-ngx prepares a ZIP archive in the background and exposes a single share link. You can revisit the "Manage share link bundles" dialog to monitor progress, retry failed bundles, or delete links. - From the bulk editor you can create a **share link bundle** for any selection. Paperless-ngx prepares a ZIP archive in the background and exposes a single share link. You can revisit the "Manage share link bundles" dialog to monitor progress, retry failed bundles, or delete links.
!!! tip !!! tip
@@ -513,25 +514,25 @@ flowchart TD
Workflows allow you to filter by: Workflows allow you to filter by:
- Source, e.g. documents uploaded via consume folder, API (& the web UI) and mail fetch - Source, e.g. documents uploaded via consume folder, API (& the web UI) and mail fetch
- File name, including wildcards e.g. \*.pdf will apply to all pdfs. - File name, including wildcards e.g. \*.pdf will apply to all pdfs.
- File path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for - File path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for
example, automatically assigning documents to different owners based on the upload directory. example, automatically assigning documents to different owners based on the upload directory.
- Mail rule. Choosing this option will force 'mail fetch' to be the workflow source. - Mail rule. Choosing this option will force 'mail fetch' to be the workflow source.
- Content matching (`Added`, `Updated` and `Scheduled` triggers only). Filter document content using the matching settings. - Content matching (`Added`, `Updated` and `Scheduled` triggers only). Filter document content using the matching settings.
There are also 'advanced' filters available for `Added`, `Updated` and `Scheduled` triggers: There are also 'advanced' filters available for `Added`, `Updated` and `Scheduled` triggers:
- Any Tags: Filter for documents with any of the specified tags. - Any Tags: Filter for documents with any of the specified tags.
- All Tags: Filter for documents with all of the specified tags. - All Tags: Filter for documents with all of the specified tags.
- No Tags: Filter for documents with none of the specified tags. - No Tags: Filter for documents with none of the specified tags.
- Document type: Filter documents with this document type. - Document type: Filter documents with this document type.
- Not Document types: Filter documents without any of these document types. - Not Document types: Filter documents without any of these document types.
- Correspondent: Filter documents with this correspondent. - Correspondent: Filter documents with this correspondent.
- Not Correspondents: Filter documents without any of these correspondents. - Not Correspondents: Filter documents without any of these correspondents.
- Storage path: Filter documents with this storage path. - Storage path: Filter documents with this storage path.
- Not Storage paths: Filter documents without any of these storage paths. - Not Storage paths: Filter documents without any of these storage paths.
- Custom field query: Filter documents with a custom field query (the same as used for the document list filters). - Custom field query: Filter documents with a custom field query (the same as used for the document list filters).
### Workflow Actions ### Workflow Actions
@@ -543,37 +544,37 @@ The following workflow action types are available:
"Assignment" actions can assign: "Assignment" actions can assign:
- Title, see [workflow placeholders](usage.md#workflow-placeholders) below - Title, see [workflow placeholders](usage.md#workflow-placeholders) below
- Tags, correspondent, document type and storage path - Tags, correspondent, document type and storage path
- Document owner - Document owner
- View and / or edit permissions to users or groups - View and / or edit permissions to users or groups
- Custom fields. Note that no value for the field will be set - Custom fields. Note that no value for the field will be set
##### Removal {#workflow-action-removal} ##### Removal {#workflow-action-removal}
"Removal" actions can remove either all of or specific sets of the following: "Removal" actions can remove either all of or specific sets of the following:
- Tags, correspondents, document types or storage paths - Tags, correspondents, document types or storage paths
- Document owner - Document owner
- View and / or edit permissions - View and / or edit permissions
- Custom fields - Custom fields
##### Email {#workflow-action-email} ##### Email {#workflow-action-email}
"Email" actions can send documents via email. This action requires a mail server to be [configured](configuration.md#email-sending). You can specify: "Email" actions can send documents via email. This action requires a mail server to be [configured](configuration.md#email-sending). You can specify:
- The recipient email address(es) separated by commas - The recipient email address(es) separated by commas
- The subject and body of the email, which can include placeholders, see [placeholders](usage.md#workflow-placeholders) below - The subject and body of the email, which can include placeholders, see [placeholders](usage.md#workflow-placeholders) below
- Whether to include the document as an attachment - Whether to include the document as an attachment
##### Webhook {#workflow-action-webhook} ##### Webhook {#workflow-action-webhook}
"Webhook" actions send a POST request to a specified URL. You can specify: "Webhook" actions send a POST request to a specified URL. You can specify:
- The URL to send the request to - The URL to send the request to
- The request body as text or as key-value pairs, which can include placeholders, see [placeholders](usage.md#workflow-placeholders) below. - The request body as text or as key-value pairs, which can include placeholders, see [placeholders](usage.md#workflow-placeholders) below.
- Encoding for the request body, either JSON or form data - Encoding for the request body, either JSON or form data
- The request headers as key-value pairs - The request headers as key-value pairs
For security reasons, webhooks can be limited to specific ports and disallowed from connecting to local URLs. See the relevant For security reasons, webhooks can be limited to specific ports and disallowed from connecting to local URLs. See the relevant
[configuration settings](configuration.md#workflow-webhooks) to change this behavior. If you are allowing non-admins to create workflows, [configuration settings](configuration.md#workflow-webhooks) to change this behavior. If you are allowing non-admins to create workflows,
@@ -604,33 +605,33 @@ The available inputs differ depending on the type of workflow trigger.
This is because at the time of consumption (when the text is to be set), no automatic tags etc. have been This is because at the time of consumption (when the text is to be set), no automatic tags etc. have been
applied. You can use the following placeholders in the template with any trigger type: applied. You can use the following placeholders in the template with any trigger type:
- `{{correspondent}}`: assigned correspondent name - `{{correspondent}}`: assigned correspondent name
- `{{document_type}}`: assigned document type name - `{{document_type}}`: assigned document type name
- `{{owner_username}}`: assigned owner username - `{{owner_username}}`: assigned owner username
- `{{added}}`: added datetime - `{{added}}`: added datetime
- `{{added_year}}`: added year - `{{added_year}}`: added year
- `{{added_year_short}}`: added year - `{{added_year_short}}`: added year
- `{{added_month}}`: added month - `{{added_month}}`: added month
- `{{added_month_name}}`: added month name - `{{added_month_name}}`: added month name
- `{{added_month_name_short}}`: added month short name - `{{added_month_name_short}}`: added month short name
- `{{added_day}}`: added day - `{{added_day}}`: added day
- `{{added_time}}`: added time in HH:MM format - `{{added_time}}`: added time in HH:MM format
- `{{original_filename}}`: original file name without extension - `{{original_filename}}`: original file name without extension
- `{{filename}}`: current file name without extension (for "added" workflows this may not be final yet, you can use `{{original_filename}}`) - `{{filename}}`: current file name without extension (for "added" workflows this may not be final yet, you can use `{{original_filename}}`)
- `{{doc_title}}`: current document title (cannot be used in title assignment) - `{{doc_title}}`: current document title (cannot be used in title assignment)
The following placeholders are only available for "added" or "updated" triggers The following placeholders are only available for "added" or "updated" triggers
- `{{created}}`: created datetime - `{{created}}`: created datetime
- `{{created_year}}`: created year - `{{created_year}}`: created year
- `{{created_year_short}}`: created year - `{{created_year_short}}`: created year
- `{{created_month}}`: created month - `{{created_month}}`: created month
- `{{created_month_name}}`: created month name - `{{created_month_name}}`: created month name
- `{{created_month_name_short}}`: created month short name - `{{created_month_name_short}}`: created month short name
- `{{created_day}}`: created day - `{{created_day}}`: created day
- `{{created_time}}`: created time in HH:MM format - `{{created_time}}`: created time in HH:MM format
- `{{doc_url}}`: URL to the document in the web UI. Requires the `PAPERLESS_URL` setting to be set. - `{{doc_url}}`: URL to the document in the web UI. Requires the `PAPERLESS_URL` setting to be set.
- `{{doc_id}}`: Document ID - `{{doc_id}}`: Document ID
##### Examples ##### Examples
@@ -675,26 +676,26 @@ Multiple fields may be attached to a document but the same field name cannot be
The following custom field types are supported: The following custom field types are supported:
- `Text`: any text - `Text`: any text
- `Boolean`: true / false (check / unchecked) field - `Boolean`: true / false (check / unchecked) field
- `Date`: date - `Date`: date
- `URL`: a valid url - `URL`: a valid url
- `Integer`: integer number e.g. 12 - `Integer`: integer number e.g. 12
- `Number`: float number e.g. 12.3456 - `Number`: float number e.g. 12.3456
- `Monetary`: [ISO 4217 currency code](https://en.wikipedia.org/wiki/ISO_4217#List_of_ISO_4217_currency_codes) and a number with exactly two decimals, e.g. USD12.30 - `Monetary`: [ISO 4217 currency code](https://en.wikipedia.org/wiki/ISO_4217#List_of_ISO_4217_currency_codes) and a number with exactly two decimals, e.g. USD12.30
- `Document Link`: reference(s) to other document(s) displayed as links, automatically creates a symmetrical link in reverse - `Document Link`: reference(s) to other document(s) displayed as links, automatically creates a symmetrical link in reverse
- `Select`: a pre-defined list of strings from which the user can choose - `Select`: a pre-defined list of strings from which the user can choose
## PDF Actions ## PDF Actions
Paperless-ngx supports basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files). When viewing an individual document you can Paperless-ngx supports basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files). When viewing an individual document you can
open the 'PDF Editor' to use a simple UI for re-arranging, rotating, deleting pages and splitting documents. open the 'PDF Editor' to use a simple UI for re-arranging, rotating, deleting pages and splitting documents.
- Merging documents: available when selecting multiple documents for 'bulk editing'. - Merging documents: available when selecting multiple documents for 'bulk editing'.
- Rotating documents: available when selecting multiple documents for 'bulk editing' and via the pdf editor on an individual document's details page. - Rotating documents: available when selecting multiple documents for 'bulk editing' and via the pdf editor on an individual document's details page.
- Splitting documents: via the pdf editor on an individual document's details page. - Splitting documents: via the pdf editor on an individual document's details page.
- Deleting pages: via the pdf editor on an individual document's details page. - Deleting pages: via the pdf editor on an individual document's details page.
- Re-arranging pages: via the pdf editor on an individual document's details page. - Re-arranging pages: via the pdf editor on an individual document's details page.
!!! important !!! important
@@ -772,18 +773,18 @@ the system.
Here are a couple examples of tags and types that you could use in your Here are a couple examples of tags and types that you could use in your
collection. collection.
- An `inbox` tag for newly added documents that you haven't manually - An `inbox` tag for newly added documents that you haven't manually
edited yet. edited yet.
- A tag `car` for everything car related (repairs, registration, - A tag `car` for everything car related (repairs, registration,
insurance, etc) insurance, etc)
- A tag `todo` for documents that you still need to do something with, - A tag `todo` for documents that you still need to do something with,
such as reply, or perform some task online. such as reply, or perform some task online.
- A tag `bank account x` for all bank statement related to that - A tag `bank account x` for all bank statement related to that
account. account.
- A tag `mail` for anything that you added to paperless via its mail - A tag `mail` for anything that you added to paperless via its mail
processing capabilities. processing capabilities.
- A tag `missing_metadata` when you still need to add some metadata to - A tag `missing_metadata` when you still need to add some metadata to
a document, but can't or don't want to do this right now. a document, but can't or don't want to do this right now.
## Searching {#basic-usage_searching} ## Searching {#basic-usage_searching}
@@ -872,8 +873,8 @@ The following diagram shows how easy it is to manage your documents.
### Preparations in paperless ### Preparations in paperless
- Create an inbox tag that gets assigned to all new documents. - Create an inbox tag that gets assigned to all new documents.
- Create a TODO tag. - Create a TODO tag.
### Processing of the physical documents ### Processing of the physical documents
@@ -947,15 +948,15 @@ Some documents require attention and require you to act on the document.
You may take two different approaches to handle these documents based on You may take two different approaches to handle these documents based on
how regularly you intend to scan documents and use paperless. how regularly you intend to scan documents and use paperless.
- If you scan and process your documents in paperless regularly, - If you scan and process your documents in paperless regularly,
assign a TODO tag to all scanned documents that you need to process. assign a TODO tag to all scanned documents that you need to process.
Create a saved view on the dashboard that shows all documents with Create a saved view on the dashboard that shows all documents with
this tag. this tag.
- If you do not scan documents regularly and use paperless solely for - If you do not scan documents regularly and use paperless solely for
archiving, create a physical todo box next to your physical inbox archiving, create a physical todo box next to your physical inbox
and put documents you need to process in the TODO box. When you and put documents you need to process in the TODO box. When you
performed the task associated with the document, move it to the performed the task associated with the document, move it to the
inbox. inbox.
## Remote OCR ## Remote OCR
@@ -976,63 +977,64 @@ or page limitations (e.g. with a free tier).
Paperless-ngx consists of the following components: Paperless-ngx consists of the following components:
- **The webserver:** This serves the administration pages, the API, - **The webserver:** This serves the administration pages, the API,
and the new frontend. This is the main tool you'll be using to interact and the new frontend. This is the main tool you'll be using to interact
with paperless. You may start the webserver directly with with paperless. You may start the webserver directly with
```shell-session ```shell-session
cd /path/to/paperless/src/ cd /path/to/paperless/src/
granian --interface asginl --ws "paperless.asgi:application" granian --interface asginl --ws "paperless.asgi:application"
``` ```
or by any other means such as Apache `mod_wsgi`. or by any other means such as Apache `mod_wsgi`.
- **The consumer:** This is what watches your consumption folder for - **The consumer:** This is what watches your consumption folder for
documents. However, the consumer itself does not really consume your documents. However, the consumer itself does not really consume your
documents. Now it notifies a task processor that a new file is ready documents. Now it notifies a task processor that a new file is ready
for consumption. I suppose it should be named differently. This was for consumption. I suppose it should be named differently. This was
also used to check your emails, but that's now done elsewhere as also used to check your emails, but that's now done elsewhere as
well. well.
Start the consumer with the management command `document_consumer`: Start the consumer with the management command `document_consumer`:
```shell-session ```shell-session
cd /path/to/paperless/src/ cd /path/to/paperless/src/
python3 manage.py document_consumer python3 manage.py document_consumer
``` ```
- **The task processor:** Paperless relies on [Celery - Distributed - **The task processor:** Paperless relies on [Celery - Distributed
Task Queue](https://docs.celeryq.dev/en/stable/index.html) for doing Task Queue](https://docs.celeryq.dev/en/stable/index.html) for doing
most of the heavy lifting. This is a task queue that accepts tasks most of the heavy lifting. This is a task queue that accepts tasks
from multiple sources and processes these in parallel. It also comes from multiple sources and processes these in parallel. It also comes
with a scheduler that executes certain commands periodically. with a scheduler that executes certain commands periodically.
This task processor is responsible for: This task processor is responsible for:
- Consuming documents. When the consumer finds new documents, it
notifies the task processor to start a consumption task.
- The task processor also performs the consumption of any
documents you upload through the web interface.
- Consuming emails. It periodically checks your configured
accounts for new emails and notifies the task processor to
consume the attachment of an email.
- Maintaining the search index and the automatic matching
algorithm. These are things that paperless needs to do from time
to time in order to operate properly.
This allows paperless to process multiple documents from your - Consuming documents. When the consumer finds new documents, it
consumption folder in parallel! On a modern multi core system, this notifies the task processor to start a consumption task.
makes the consumption process with full OCR blazingly fast. - The task processor also performs the consumption of any
documents you upload through the web interface.
- Consuming emails. It periodically checks your configured
accounts for new emails and notifies the task processor to
consume the attachment of an email.
- Maintaining the search index and the automatic matching
algorithm. These are things that paperless needs to do from time
to time in order to operate properly.
The task processor comes with a built-in admin interface that you This allows paperless to process multiple documents from your
can use to check whenever any of the tasks fail and inspect the consumption folder in parallel! On a modern multi core system, this
errors (i.e., wrong email credentials, errors during consuming a makes the consumption process with full OCR blazingly fast.
specific file, etc).
- A [redis](https://redis.io/) message broker: This is a really The task processor comes with a built-in admin interface that you
lightweight service that is responsible for getting the tasks from can use to check whenever any of the tasks fail and inspect the
the webserver and the consumer to the task scheduler. These run in a errors (i.e., wrong email credentials, errors during consuming a
different process (maybe even on different machines!), and specific file, etc).
therefore, this is necessary.
- Optional: A database server. Paperless supports PostgreSQL, MariaDB - A [redis](https://redis.io/) message broker: This is a really
and SQLite for storing its data. lightweight service that is responsible for getting the tasks from
the webserver and the consumer to the task scheduler. These run in a
different process (maybe even on different machines!), and
therefore, this is necessary.
- Optional: A database server. Paperless supports PostgreSQL, MariaDB
and SQLite for storing its data.

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "paperless-ngx" name = "paperless-ngx"
version = "2.20.13" version = "2.20.10"
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"
@@ -26,7 +26,7 @@ dependencies = [
# WARNING: django does not use semver. # WARNING: django does not use semver.
# Only patch versions are guaranteed to not introduce breaking changes. # Only patch versions are guaranteed to not introduce breaking changes.
"django~=5.2.10", "django~=5.2.10",
"django-allauth[mfa,socialaccount]~=65.15.0", "django-allauth[mfa,socialaccount]~=65.14.0",
"django-auditlog~=3.4.1", "django-auditlog~=3.4.1",
"django-cachalot~=2.9.0", "django-cachalot~=2.9.0",
"django-celery-results~=2.6.0", "django-celery-results~=2.6.0",
@@ -42,14 +42,13 @@ dependencies = [
"djangorestframework~=3.16", "djangorestframework~=3.16",
"djangorestframework-guardian~=0.4.0", "djangorestframework-guardian~=0.4.0",
"drf-spectacular~=0.28", "drf-spectacular~=0.28",
"drf-spectacular-sidecar~=2026.3.1", "drf-spectacular-sidecar~=2026.1.1",
"drf-writable-nested~=0.7.1", "drf-writable-nested~=0.7.1",
"faiss-cpu>=1.10", "faiss-cpu>=1.10",
"filelock~=3.25.2", "filelock~=3.24.3",
"flower~=2.0.1", "flower~=2.0.1",
"gotenberg-client~=0.13.1", "gotenberg-client~=0.13.1",
"httpx-oauth~=0.16", "httpx-oauth~=0.16",
"ijson>=3.2",
"imap-tools~=1.11.0", "imap-tools~=1.11.0",
"jinja2~=3.1.5", "jinja2~=3.1.5",
"langdetect~=1.0.9", "langdetect~=1.0.9",
@@ -60,7 +59,7 @@ dependencies = [
"llama-index-llms-openai>=0.6.13", "llama-index-llms-openai>=0.6.13",
"llama-index-vector-stores-faiss>=0.5.2", "llama-index-vector-stores-faiss>=0.5.2",
"nltk~=3.9.1", "nltk~=3.9.1",
"ocrmypdf~=17.3.0", "ocrmypdf~=16.13.0",
"openai>=1.76", "openai>=1.76",
"pathvalidate~=3.3.1", "pathvalidate~=3.3.1",
"pdf2image~=1.17.0", "pdf2image~=1.17.0",
@@ -72,7 +71,7 @@ dependencies = [
"rapidfuzz~=3.14.0", "rapidfuzz~=3.14.0",
"redis[hiredis]~=5.2.1", "redis[hiredis]~=5.2.1",
"regex>=2025.9.18", "regex>=2025.9.18",
"scikit-learn~=1.8.0", "scikit-learn~=1.7.0",
"sentence-transformers>=4.1", "sentence-transformers>=4.1",
"setproctitle~=1.3.4", "setproctitle~=1.3.4",
"tika-client~=0.10.0", "tika-client~=0.10.0",
@@ -111,7 +110,7 @@ docs = [
testing = [ testing = [
"daphne", "daphne",
"factory-boy~=3.3.1", "factory-boy~=3.3.1",
"faker~=40.8.0", "faker~=40.5.1",
"imagehash", "imagehash",
"pytest~=9.0.0", "pytest~=9.0.0",
"pytest-cov~=7.0.0", "pytest-cov~=7.0.0",
@@ -248,13 +247,15 @@ lint.per-file-ignores."docker/wait-for-redis.py" = [
lint.per-file-ignores."src/documents/models.py" = [ lint.per-file-ignores."src/documents/models.py" = [
"SIM115", "SIM115",
] ]
lint.per-file-ignores."src/paperless_tesseract/tests/test_parser.py" = [
"RUF001",
]
lint.isort.force-single-line = true lint.isort.force-single-line = true
[tool.codespell] [tool.codespell]
write-changes = true write-changes = true
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish" ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish"
skip = "src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/paperless/tests/samples/mail/*,src/documents/tests/samples/*,*.po,*.json" skip = "src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/documents/tests/samples/*,*.po,*.json"
[tool.pytest] [tool.pytest]
minversion = "9.0" minversion = "9.0"
@@ -269,6 +270,10 @@ testpaths = [
"src/documents/tests/", "src/documents/tests/",
"src/paperless/tests/", "src/paperless/tests/",
"src/paperless_mail/tests/", "src/paperless_mail/tests/",
"src/paperless_tesseract/tests/",
"src/paperless_tika/tests",
"src/paperless_text/tests/",
"src/paperless_remote/tests/",
"src/paperless_ai/tests", "src/paperless_ai/tests",
] ]

View File

@@ -19,4 +19,6 @@ following additional information about it:
* Correspondent: ${DOCUMENT_CORRESPONDENT} * Correspondent: ${DOCUMENT_CORRESPONDENT}
* Tags: ${DOCUMENT_TAGS} * Tags: ${DOCUMENT_TAGS}
It was consumed with the passphrase ${PASSPHRASE}
" "

View File

@@ -468,7 +468,7 @@
"time": 0.951, "time": 0.951,
"request": { "request": {
"method": "GET", "method": "GET",
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__in=9", "url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__in=9",
"httpVersion": "HTTP/1.1", "httpVersion": "HTTP/1.1",
"cookies": [], "cookies": [],
"headers": [ "headers": [

File diff suppressed because one or more lines are too long

View File

@@ -534,7 +534,7 @@
"time": 0.653, "time": 0.653,
"request": { "request": {
"method": "GET", "method": "GET",
"url": "http://localhost:8000/api/documents/?page=1&page_size=10&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=9", "url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=9",
"httpVersion": "HTTP/1.1", "httpVersion": "HTTP/1.1",
"cookies": [], "cookies": [],
"headers": [ "headers": [

View File

@@ -883,7 +883,7 @@
"time": 0.93, "time": 0.93,
"request": { "request": {
"method": "GET", "method": "GET",
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=4", "url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=4",
"httpVersion": "HTTP/1.1", "httpVersion": "HTTP/1.1",
"cookies": [], "cookies": [],
"headers": [ "headers": [
@@ -961,7 +961,7 @@
"time": -1, "time": -1,
"request": { "request": {
"method": "GET", "method": "GET",
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=4", "url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=4",
"httpVersion": "HTTP/1.1", "httpVersion": "HTTP/1.1",
"cookies": [], "cookies": [],
"headers": [ "headers": [

View File

@@ -16,7 +16,7 @@ test('basic filtering', async ({ page }) => {
await expect(page).toHaveURL(/tags__id__all=9/) await expect(page).toHaveURL(/tags__id__all=9/)
await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/) await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
await page.getByRole('button', { name: 'Document type' }).click() await page.getByRole('button', { name: 'Document type' }).click()
await page.getByRole('menuitem', { name: /^Invoice Test/ }).click() await page.getByRole('menuitem', { name: 'Invoice Test 3' }).click()
await expect(page).toHaveURL(/document_type__id__in=1/) await expect(page).toHaveURL(/document_type__id__in=1/)
await expect(page.locator('pngx-document-list')).toHaveText(/3 documents/) await expect(page.locator('pngx-document-list')).toHaveText(/3 documents/)
await page.getByRole('button', { name: 'Reset filters' }).first().click() await page.getByRole('button', { name: 'Reset filters' }).first().click()

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "paperless-ngx-ui", "name": "paperless-ngx-ui",
"version": "2.20.13", "version": "2.20.10",
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm", "preinstall": "npx only-allow pnpm",
"ng": "ng", "ng": "ng",
@@ -11,17 +11,17 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/cdk": "^21.2.2", "@angular/cdk": "^21.2.0",
"@angular/common": "~21.2.4", "@angular/common": "~21.2.0",
"@angular/compiler": "~21.2.4", "@angular/compiler": "~21.2.0",
"@angular/core": "~21.2.4", "@angular/core": "~21.2.0",
"@angular/forms": "~21.2.4", "@angular/forms": "~21.2.0",
"@angular/localize": "~21.2.4", "@angular/localize": "~21.2.0",
"@angular/platform-browser": "~21.2.4", "@angular/platform-browser": "~21.2.0",
"@angular/platform-browser-dynamic": "~21.2.4", "@angular/platform-browser-dynamic": "~21.2.0",
"@angular/router": "~21.2.4", "@angular/router": "~21.2.0",
"@ng-bootstrap/ng-bootstrap": "^20.0.0", "@ng-bootstrap/ng-bootstrap": "^20.0.0",
"@ng-select/ng-select": "^21.5.2", "@ng-select/ng-select": "^21.4.1",
"@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",
@@ -42,26 +42,26 @@
"devDependencies": { "devDependencies": {
"@angular-builders/custom-webpack": "^21.0.3", "@angular-builders/custom-webpack": "^21.0.3",
"@angular-builders/jest": "^21.0.3", "@angular-builders/jest": "^21.0.3",
"@angular-devkit/core": "^21.2.2", "@angular-devkit/core": "^21.2.0",
"@angular-devkit/schematics": "^21.2.2", "@angular-devkit/schematics": "^21.2.0",
"@angular-eslint/builder": "21.3.0", "@angular-eslint/builder": "21.3.0",
"@angular-eslint/eslint-plugin": "21.3.0", "@angular-eslint/eslint-plugin": "21.3.0",
"@angular-eslint/eslint-plugin-template": "21.3.0", "@angular-eslint/eslint-plugin-template": "21.3.0",
"@angular-eslint/schematics": "21.3.0", "@angular-eslint/schematics": "21.3.0",
"@angular-eslint/template-parser": "21.3.0", "@angular-eslint/template-parser": "21.3.0",
"@angular/build": "^21.2.2", "@angular/build": "^21.2.0",
"@angular/cli": "~21.2.2", "@angular/cli": "~21.2.0",
"@angular/compiler-cli": "~21.2.4", "@angular/compiler-cli": "~21.2.0",
"@codecov/webpack-plugin": "^1.9.1", "@codecov/webpack-plugin": "^1.9.1",
"@playwright/test": "^1.58.2", "@playwright/test": "^1.58.2",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^25.4.0", "@types/node": "^25.3.3",
"@typescript-eslint/eslint-plugin": "^8.57.0", "@typescript-eslint/eslint-plugin": "^8.54.0",
"@typescript-eslint/parser": "^8.57.0", "@typescript-eslint/parser": "^8.54.0",
"@typescript-eslint/utils": "^8.57.0", "@typescript-eslint/utils": "^8.54.0",
"eslint": "^10.0.3", "eslint": "^10.0.2",
"jest": "30.3.0", "jest": "30.2.0",
"jest-environment-jsdom": "^30.3.0", "jest-environment-jsdom": "^30.2.0",
"jest-junit": "^16.0.0", "jest-junit": "^16.0.0",
"jest-preset-angular": "^16.1.1", "jest-preset-angular": "^16.1.1",
"jest-websocket-mock": "^2.5.0", "jest-websocket-mock": "^2.5.0",

1826
src-ui/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -59,7 +59,7 @@
<div [ngbNavOutlet]="nav" class="border-start border-end border-bottom p-3 mb-3 shadow-sm"></div> <div [ngbNavOutlet]="nav" class="border-start border-end border-bottom p-3 mb-3 shadow-sm"></div>
<div class="btn-toolbar" role="toolbar"> <div class="btn-toolbar" role="toolbar">
<div class="btn-group me-2"> <div class="btn-group me-2">
<button type="button" (click)="discardChanges()" class="btn btn-outline-secondary" [disabled]="loading || (isDirty$ | async) === false" i18n>Cancel</button> <button type="button" (click)="discardChanges()" class="btn btn-outline-secondary" [disabled]="loading || (isDirty$ | async) === false" i18n>Discard</button>
</div> </div>
<div class="btn-group"> <div class="btn-group">
<button type="submit" class="btn btn-primary" [disabled]="loading || !configForm.valid || (isDirty$ | async) === false" i18n>Save</button> <button type="submit" class="btn btn-primary" [disabled]="loading || !configForm.valid || (isDirty$ | async) === false" i18n>Save</button>

View File

@@ -1,7 +1,7 @@
<nav class="navbar navbar-dark fixed-top bg-primary flex-md-nowrap p-0 shadow-sm"> <nav class="navbar navbar-dark fixed-top bg-primary flex-md-nowrap p-0 shadow-sm">
<button class="navbar-toggler d-md-none collapsed border-0" type="button" data-toggle="collapse" <button class="navbar-toggler d-md-none collapsed border-0" type="button" data-toggle="collapse"
data-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation" data-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation"
(click)="mobileSearchHidden = false; isMenuCollapsed = !isMenuCollapsed"> (click)="isMenuCollapsed = !isMenuCollapsed">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<a class="navbar-brand d-flex align-items-center me-0 px-3 py-3 order-sm-0" <a class="navbar-brand d-flex align-items-center me-0 px-3 py-3 order-sm-0"
@@ -24,8 +24,7 @@
} }
</div> </div>
</a> </a>
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1" <div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1">
[class.mobile-hidden]="mobileSearchHidden">
<div class="col-12 col-md-7"> <div class="col-12 col-md-7">
<pngx-global-search></pngx-global-search> <pngx-global-search></pngx-global-search>
</div> </div>
@@ -379,7 +378,7 @@
</div> </div>
</nav> </nav>
<main role="main" class="ms-sm-auto px-md-4" [class.mobile-search-hidden]="mobileSearchHidden" <main role="main" class="ms-sm-auto px-md-4"
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-md-9 col-lg-10 col-xxxl-11'"> [ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-md-9 col-lg-10 col-xxxl-11'">
<router-outlet></router-outlet> <router-outlet></router-outlet>
</main> </main>

View File

@@ -44,23 +44,6 @@
.sidebar { .sidebar {
top: 3.5rem; top: 3.5rem;
} }
.search-container {
max-height: 4.5rem;
overflow: hidden;
transition: max-height .2s ease, opacity .2s ease, padding-top .2s ease, padding-bottom .2s ease;
&.mobile-hidden {
max-height: 0;
opacity: 0;
padding-top: 0 !important;
padding-bottom: 0 !important;
}
}
main.mobile-search-hidden {
padding-top: 56px;
}
} }
main { main {

View File

@@ -293,59 +293,6 @@ describe('AppFrameComponent', () => {
expect(component.isMenuCollapsed).toBeTruthy() expect(component.isMenuCollapsed).toBeTruthy()
}) })
it('should hide mobile search when scrolling down and show it when scrolling up', () => {
Object.defineProperty(globalThis, 'innerWidth', {
value: 767,
})
component.ngOnInit()
Object.defineProperty(globalThis, 'scrollY', {
configurable: true,
value: 40,
})
component.onWindowScroll()
expect(component.mobileSearchHidden).toBe(true)
Object.defineProperty(globalThis, 'scrollY', {
configurable: true,
value: 0,
})
component.onWindowScroll()
expect(component.mobileSearchHidden).toBe(false)
})
it('should keep mobile search visible on desktop scroll or resize', () => {
Object.defineProperty(globalThis, 'innerWidth', {
value: 1024,
})
component.ngOnInit()
component.mobileSearchHidden = true
component.onWindowScroll()
expect(component.mobileSearchHidden).toBe(false)
component.mobileSearchHidden = true
component.onWindowResize()
})
it('should keep mobile search visible while the mobile menu is expanded', () => {
Object.defineProperty(globalThis, 'innerWidth', {
value: 767,
})
component.ngOnInit()
component.isMenuCollapsed = false
Object.defineProperty(globalThis, 'scrollY', {
configurable: true,
value: 40,
})
component.onWindowScroll()
expect(component.mobileSearchHidden).toBe(false)
})
it('should support close document & navigate on close current doc', () => { it('should support close document & navigate on close current doc', () => {
const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument') const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument')
closeSpy.mockReturnValue(of(true)) closeSpy.mockReturnValue(of(true))

View File

@@ -51,8 +51,6 @@ import { ComponentWithPermissions } from '../with-permissions/with-permissions.c
import { GlobalSearchComponent } from './global-search/global-search.component' import { GlobalSearchComponent } from './global-search/global-search.component'
import { ToastsDropdownComponent } from './toasts-dropdown/toasts-dropdown.component' import { ToastsDropdownComponent } from './toasts-dropdown/toasts-dropdown.component'
const SCROLL_THRESHOLD = 16
@Component({ @Component({
selector: 'pngx-app-frame', selector: 'pngx-app-frame',
templateUrl: './app-frame.component.html', templateUrl: './app-frame.component.html',
@@ -96,10 +94,6 @@ export class AppFrameComponent
slimSidebarAnimating: boolean = false slimSidebarAnimating: boolean = false
public mobileSearchHidden: boolean = false
private lastScrollY: number = 0
constructor() { constructor() {
super() super()
const permissionsService = this.permissionsService const permissionsService = this.permissionsService
@@ -117,8 +111,6 @@ export class AppFrameComponent
} }
ngOnInit(): void { ngOnInit(): void {
this.lastScrollY = window.scrollY
if (this.settingsService.get(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED)) { if (this.settingsService.get(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED)) {
this.checkForUpdates() this.checkForUpdates()
} }
@@ -271,38 +263,6 @@ export class AppFrameComponent
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED) return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
} }
@HostListener('window:resize')
onWindowResize(): void {
if (!this.isMobileViewport()) {
this.mobileSearchHidden = false
}
}
@HostListener('window:scroll')
onWindowScroll(): void {
const currentScrollY = window.scrollY
if (!this.isMobileViewport() || this.isMenuCollapsed === false) {
this.mobileSearchHidden = false
this.lastScrollY = currentScrollY
return
}
const delta = currentScrollY - this.lastScrollY
if (currentScrollY <= 0 || delta < -SCROLL_THRESHOLD) {
this.mobileSearchHidden = false
} else if (currentScrollY > SCROLL_THRESHOLD && delta > SCROLL_THRESHOLD) {
this.mobileSearchHidden = true
}
this.lastScrollY = currentScrollY
}
private isMobileViewport(): boolean {
return window.innerWidth < 768
}
closeMenu() { closeMenu() {
this.isMenuCollapsed = true this.isMenuCollapsed = true
} }

View File

@@ -31,8 +31,8 @@ export enum EditDialogMode {
@Directive() @Directive()
export abstract class EditDialogComponent< export abstract class EditDialogComponent<
T extends ObjectWithPermissions | ObjectWithId, T extends ObjectWithPermissions | ObjectWithId,
> >
extends LoadingComponentWithPermissions extends LoadingComponentWithPermissions
implements OnInit implements OnInit
{ {

View File

@@ -631,59 +631,6 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
]) ])
}) })
it('deselecting a parent clears selected descendants', () => {
const root: Tag = { id: 100, name: 'Root Tag' }
const child: Tag = { id: 101, name: 'Child Tag', parent: root.id }
const grandchild: Tag = {
id: 102,
name: 'Grandchild Tag',
parent: child.id,
}
const other: Tag = { id: 103, name: 'Other Tag' }
selectionModel.items = [root, child, grandchild, other]
selectionModel.set(root.id, ToggleableItemState.Selected, false)
selectionModel.set(child.id, ToggleableItemState.Selected, false)
selectionModel.set(grandchild.id, ToggleableItemState.Selected, false)
selectionModel.set(other.id, ToggleableItemState.Selected, false)
selectionModel.toggle(root.id, false)
expect(selectionModel.getSelectedItems()).toEqual([other])
})
it('un-excluding a parent clears excluded descendants', () => {
const root: Tag = { id: 110, name: 'Root Tag' }
const child: Tag = { id: 111, name: 'Child Tag', parent: root.id }
const other: Tag = { id: 112, name: 'Other Tag' }
selectionModel.items = [root, child, other]
selectionModel.set(root.id, ToggleableItemState.Excluded, false)
selectionModel.set(child.id, ToggleableItemState.Excluded, false)
selectionModel.set(other.id, ToggleableItemState.Excluded, false)
selectionModel.exclude(root.id, false)
expect(selectionModel.getExcludedItems()).toEqual([other])
})
it('excluding a selected parent clears selected descendants', () => {
const root: Tag = { id: 120, name: 'Root Tag' }
const child: Tag = { id: 121, name: 'Child Tag', parent: root.id }
const other: Tag = { id: 122, name: 'Other Tag' }
selectionModel.manyToOne = true
selectionModel.items = [root, child, other]
selectionModel.set(root.id, ToggleableItemState.Selected, false)
selectionModel.set(child.id, ToggleableItemState.Selected, false)
selectionModel.set(other.id, ToggleableItemState.Selected, false)
selectionModel.exclude(root.id, false)
expect(selectionModel.getExcludedItems()).toEqual([root])
expect(selectionModel.getSelectedItems()).toEqual([other])
})
it('resorts items immediately when document count sorting enabled', () => { it('resorts items immediately when document count sorting enabled', () => {
const apple: Tag = { id: 55, name: 'Apple' } const apple: Tag = { id: 55, name: 'Apple' }
const zebra: Tag = { id: 56, name: 'Zebra' } const zebra: Tag = { id: 56, name: 'Zebra' }

View File

@@ -20,9 +20,9 @@ import { Subject, filter, takeUntil } from 'rxjs'
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type' import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
import { MatchingModel } from 'src/app/data/matching-model' import { MatchingModel } from 'src/app/data/matching-model'
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions' import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
import { SelectionDataItem } from 'src/app/data/results'
import { FilterPipe } from 'src/app/pipes/filter.pipe' import { FilterPipe } from 'src/app/pipes/filter.pipe'
import { HotKeyService } from 'src/app/services/hot-key.service' import { HotKeyService } from 'src/app/services/hot-key.service'
import { SelectionDataItem } from 'src/app/services/rest/document.service'
import { pngxPopperOptions } from 'src/app/utils/popper-options' import { pngxPopperOptions } from 'src/app/utils/popper-options'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component' import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
@@ -235,7 +235,6 @@ export class FilterableDropdownSelectionModel {
state == ToggleableItemState.Excluded state == ToggleableItemState.Excluded
) { ) {
this.temporarySelectionStates.delete(id) this.temporarySelectionStates.delete(id)
this.clearDescendantSelections(id)
} }
if (!id) { if (!id) {
@@ -262,7 +261,6 @@ export class FilterableDropdownSelectionModel {
if (this.manyToOne || this.singleSelect) { if (this.manyToOne || this.singleSelect) {
this.temporarySelectionStates.set(id, ToggleableItemState.Excluded) this.temporarySelectionStates.set(id, ToggleableItemState.Excluded)
this.clearDescendantSelections(id)
if (this.singleSelect) { if (this.singleSelect) {
for (let key of this.temporarySelectionStates.keys()) { for (let key of this.temporarySelectionStates.keys()) {
@@ -283,15 +281,9 @@ export class FilterableDropdownSelectionModel {
newState = ToggleableItemState.NotSelected newState = ToggleableItemState.NotSelected
} }
this.temporarySelectionStates.set(id, newState) this.temporarySelectionStates.set(id, newState)
if (newState == ToggleableItemState.Excluded) {
this.clearDescendantSelections(id)
}
} }
} else if (!id || state == ToggleableItemState.Excluded) { } else if (!id || state == ToggleableItemState.Excluded) {
this.temporarySelectionStates.delete(id) this.temporarySelectionStates.delete(id)
if (id) {
this.clearDescendantSelections(id)
}
} }
if (fireEvent) { if (fireEvent) {
@@ -303,33 +295,6 @@ export class FilterableDropdownSelectionModel {
return this.selectionStates.get(id) || ToggleableItemState.NotSelected return this.selectionStates.get(id) || ToggleableItemState.NotSelected
} }
private clearDescendantSelections(id: number) {
for (const descendantID of this.getDescendantIDs(id)) {
this.temporarySelectionStates.delete(descendantID)
}
}
private getDescendantIDs(id: number): number[] {
const descendants: number[] = []
const queue: number[] = [id]
while (queue.length) {
const parentID = queue.shift()
for (const item of this._items) {
if (
typeof item?.id === 'number' &&
typeof (item as any)['parent'] === 'number' &&
(item as any)['parent'] === parentID
) {
descendants.push(item.id)
queue.push(item.id)
}
}
}
return descendants
}
get logicalOperator(): LogicalOperator { get logicalOperator(): LogicalOperator {
return this.temporaryLogicalOperator return this.temporaryLogicalOperator
} }

View File

@@ -950,8 +950,8 @@ describe('DocumentDetailComponent', () => {
it('should support reprocess, confirm and close modal after started', () => { it('should support reprocess, confirm and close modal after started', () => {
initNormally() initNormally()
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments') const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit')
reprocessSpy.mockReturnValue(of(true)) bulkEditSpy.mockReturnValue(of(true))
let openModal: NgbModalRef let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
const modalSpy = jest.spyOn(modalService, 'open') const modalSpy = jest.spyOn(modalService, 'open')
@@ -959,7 +959,7 @@ describe('DocumentDetailComponent', () => {
component.reprocess() component.reprocess()
const modalCloseSpy = jest.spyOn(openModal, 'close') const modalCloseSpy = jest.spyOn(openModal, 'close')
openModal.componentInstance.confirmClicked.next() openModal.componentInstance.confirmClicked.next()
expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] }) expect(bulkEditSpy).toHaveBeenCalledWith([doc.id], 'reprocess', {})
expect(modalSpy).toHaveBeenCalled() expect(modalSpy).toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalled() expect(toastSpy).toHaveBeenCalled()
expect(modalCloseSpy).toHaveBeenCalled() expect(modalCloseSpy).toHaveBeenCalled()
@@ -967,13 +967,13 @@ describe('DocumentDetailComponent', () => {
it('should show error if redo ocr call fails', () => { it('should show error if redo ocr call fails', () => {
initNormally() initNormally()
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments') const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit')
let openModal: NgbModalRef let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
const toastSpy = jest.spyOn(toastService, 'showError') const toastSpy = jest.spyOn(toastService, 'showError')
component.reprocess() component.reprocess()
const modalCloseSpy = jest.spyOn(openModal, 'close') const modalCloseSpy = jest.spyOn(openModal, 'close')
reprocessSpy.mockReturnValue(throwError(() => new Error('error occurred'))) bulkEditSpy.mockReturnValue(throwError(() => new Error('error occurred')))
openModal.componentInstance.confirmClicked.next() openModal.componentInstance.confirmClicked.next()
expect(toastSpy).toHaveBeenCalled() expect(toastSpy).toHaveBeenCalled()
expect(modalCloseSpy).not.toHaveBeenCalled() expect(modalCloseSpy).not.toHaveBeenCalled()
@@ -1644,9 +1644,9 @@ describe('DocumentDetailComponent', () => {
expect( expect(
fixture.debugElement.query(By.css('.preview-sticky img')) fixture.debugElement.query(By.css('.preview-sticky img'))
).not.toBeUndefined() ).not.toBeUndefined()
;((component.document.mime_type = ;(component.document.mime_type =
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'), 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'),
fixture.detectChanges()) fixture.detectChanges()
expect(component.archiveContentRenderType).toEqual( expect(component.archiveContentRenderType).toEqual(
component.ContentRenderType.Other component.ContentRenderType.Other
) )
@@ -1669,15 +1669,18 @@ describe('DocumentDetailComponent', () => {
modal.componentInstance.pages = [{ page: 1, rotate: 0, splitAfter: false }] modal.componentInstance.pages = [{ page: 1, rotate: 0, splitAfter: false }]
modal.componentInstance.confirm() modal.componentInstance.confirm()
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/edit_pdf/` `${environment.apiBaseUrl}documents/bulk_edit/`
) )
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: [10], documents: [10],
operations: [{ page: 1, rotate: 0, doc: 0 }], method: 'edit_pdf',
delete_original: false, parameters: {
update_document: false, operations: [{ page: 1, rotate: 0, doc: 0 }],
include_metadata: true, delete_original: false,
source_mode: 'explicit_selection', update_document: false,
include_metadata: true,
source_mode: 'explicit_selection',
},
}) })
req.error(new ErrorEvent('failed')) req.error(new ErrorEvent('failed'))
expect(errorSpy).toHaveBeenCalled() expect(errorSpy).toHaveBeenCalled()
@@ -1688,7 +1691,7 @@ describe('DocumentDetailComponent', () => {
modal.componentInstance.deleteOriginal = true modal.componentInstance.deleteOriginal = true
modal.componentInstance.confirm() modal.componentInstance.confirm()
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/edit_pdf/` `${environment.apiBaseUrl}documents/bulk_edit/`
) )
req.flush(true) req.flush(true)
expect(closeSpy).toHaveBeenCalled() expect(closeSpy).toHaveBeenCalled()
@@ -1708,15 +1711,18 @@ describe('DocumentDetailComponent', () => {
dialog.deleteOriginal = true dialog.deleteOriginal = true
dialog.confirm() dialog.confirm()
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/remove_password/` `${environment.apiBaseUrl}documents/bulk_edit/`
) )
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: [10], documents: [10],
password: 'secret', method: 'remove_password',
update_document: false, parameters: {
include_metadata: false, password: 'secret',
delete_original: true, update_document: false,
source_mode: 'explicit_selection', include_metadata: false,
delete_original: true,
source_mode: 'explicit_selection',
},
}) })
req.flush(true) req.flush(true)
}) })
@@ -1731,7 +1737,7 @@ describe('DocumentDetailComponent', () => {
expect(errorSpy).toHaveBeenCalled() expect(errorSpy).toHaveBeenCalled()
httpTestingController.expectNone( httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/remove_password/` `${environment.apiBaseUrl}documents/bulk_edit/`
) )
}) })
@@ -1747,7 +1753,7 @@ describe('DocumentDetailComponent', () => {
modal.componentInstance as PasswordRemovalConfirmDialogComponent modal.componentInstance as PasswordRemovalConfirmDialogComponent
dialog.confirm() dialog.confirm()
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/remove_password/` `${environment.apiBaseUrl}documents/bulk_edit/`
) )
req.error(new ErrorEvent('failed')) req.error(new ErrorEvent('failed'))
@@ -1768,7 +1774,7 @@ describe('DocumentDetailComponent', () => {
modal.componentInstance as PasswordRemovalConfirmDialogComponent modal.componentInstance as PasswordRemovalConfirmDialogComponent
dialog.confirm() dialog.confirm()
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/remove_password/` `${environment.apiBaseUrl}documents/bulk_edit/`
) )
req.flush(true) req.flush(true)

View File

@@ -1380,7 +1380,7 @@ export class DocumentDetailComponent
modal.componentInstance.confirmClicked.subscribe(() => { modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled = false modal.componentInstance.buttonsEnabled = false
this.documentsService this.documentsService
.reprocessDocuments({ documents: [this.document.id] }) .bulkEdit([this.document.id], 'reprocess', {})
.subscribe({ .subscribe({
next: () => { next: () => {
this.toastService.showInfo( this.toastService.showInfo(
@@ -1766,7 +1766,7 @@ export class DocumentDetailComponent
.subscribe(() => { .subscribe(() => {
modal.componentInstance.buttonsEnabled = false modal.componentInstance.buttonsEnabled = false
this.documentsService this.documentsService
.editPdfDocuments([sourceDocumentId], { .bulkEdit([sourceDocumentId], 'edit_pdf', {
operations: modal.componentInstance.getOperations(), operations: modal.componentInstance.getOperations(),
delete_original: modal.componentInstance.deleteOriginal, delete_original: modal.componentInstance.deleteOriginal,
update_document: update_document:
@@ -1824,7 +1824,7 @@ export class DocumentDetailComponent
dialog.buttonsEnabled = false dialog.buttonsEnabled = false
this.networkActive = true this.networkActive = true
this.documentsService this.documentsService
.removePasswordDocuments([sourceDocumentId], { .bulkEdit([sourceDocumentId], 'remove_password', {
password: this.password, password: this.password,
update_document: dialog.updateDocument, update_document: dialog.updateDocument,
include_metadata: dialog.includeMetadata, include_metadata: dialog.includeMetadata,

View File

@@ -92,7 +92,7 @@
<button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll && !userCanEditAll"> <button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll && !userCanEditAll">
<i-bs name="arrow-clockwise" class="me-1"></i-bs><ng-container i18n>Rotate</ng-container> <i-bs name="arrow-clockwise" class="me-1"></i-bs><ng-container i18n>Rotate</ng-container>
</button> </button>
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.allSelected || list.selectedCount < 2"> <button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.selected.size < 2">
<i-bs name="journals" class="me-1"></i-bs><ng-container i18n>Merge</ng-container> <i-bs name="journals" class="me-1"></i-bs><ng-container i18n>Merge</ng-container>
</button> </button>
</div> </div>
@@ -103,13 +103,13 @@
class="btn btn-sm btn-outline-primary" class="btn btn-sm btn-outline-primary"
id="dropdownSend" id="dropdownSend"
ngbDropdownToggle ngbDropdownToggle
[disabled]="disabled || !list.hasSelection || list.allSelected" [disabled]="disabled || list.selected.size === 0"
> >
<i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container> <i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container>
</div> </div>
</button> </button>
<div ngbDropdownMenu aria-labelledby="dropdownSend" class="shadow"> <div ngbDropdownMenu aria-labelledby="dropdownSend" class="shadow">
<button ngbDropdownItem (click)="createShareLinkBundle()" [disabled]="list.allSelected"> <button ngbDropdownItem (click)="createShareLinkBundle()">
<i-bs name="link" class="me-1"></i-bs><ng-container i18n>Create a share link bundle</ng-container> <i-bs name="link" class="me-1"></i-bs><ng-container i18n>Create a share link bundle</ng-container>
</button> </button>
<button ngbDropdownItem (click)="manageShareLinkBundles()"> <button ngbDropdownItem (click)="manageShareLinkBundles()">
@@ -117,7 +117,7 @@
</button> </button>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
@if (emailEnabled) { @if (emailEnabled) {
<button ngbDropdownItem (click)="emailSelected()" [disabled]="list.allSelected"> <button ngbDropdownItem (click)="emailSelected()">
<i-bs name="envelope" class="me-1"></i-bs><ng-container i18n>Email</ng-container> <i-bs name="envelope" class="me-1"></i-bs><ng-container i18n>Email</ng-container>
</button> </button>
} }

View File

@@ -1,4 +1,3 @@
import { DatePipe } from '@angular/common'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { import {
HttpTestingController, HttpTestingController,
@@ -13,7 +12,6 @@ import { of, throwError } from 'rxjs'
import { Correspondent } from 'src/app/data/correspondent' import { Correspondent } from 'src/app/data/correspondent'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { DocumentType } from 'src/app/data/document-type' import { DocumentType } from 'src/app/data/document-type'
import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
import { Results } from 'src/app/data/results' import { Results } from 'src/app/data/results'
import { StoragePath } from 'src/app/data/storage-path' import { StoragePath } from 'src/app/data/storage-path'
import { Tag } from 'src/app/data/tag' import { Tag } from 'src/app/data/tag'
@@ -140,7 +138,6 @@ describe('BulkEditorComponent', () => {
}, },
}, },
FilterPipe, FilterPipe,
DatePipe,
SettingsService, SettingsService,
{ {
provide: UserService, provide: UserService,
@@ -274,92 +271,6 @@ describe('BulkEditorComponent', () => {
expect(component.customFieldsSelectionModel.selectionSize()).toEqual(1) expect(component.customFieldsSelectionModel.selectionSize()).toEqual(1)
}) })
it('should apply list selection data to tags menu when all filtered documents are selected', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
fixture.detectChanges()
jest
.spyOn(documentListViewService, 'allSelected', 'get')
.mockReturnValue(true)
jest
.spyOn(documentListViewService, 'selectedCount', 'get')
.mockReturnValue(3)
documentListViewService.selectionData = selectionData
const getSelectionDataSpy = jest.spyOn(documentService, 'getSelectionData')
component.openTagsDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.tagSelectionModel.selectionSize()).toEqual(1)
})
it('should apply list selection data to document types menu when all filtered documents are selected', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
fixture.detectChanges()
jest
.spyOn(documentListViewService, 'allSelected', 'get')
.mockReturnValue(true)
documentListViewService.selectionData = selectionData
const getSelectionDataSpy = jest.spyOn(documentService, 'getSelectionData')
component.openDocumentTypeDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.documentTypeDocumentCounts).toEqual(
selectionData.selected_document_types
)
})
it('should apply list selection data to correspondents menu when all filtered documents are selected', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
fixture.detectChanges()
jest
.spyOn(documentListViewService, 'allSelected', 'get')
.mockReturnValue(true)
documentListViewService.selectionData = selectionData
const getSelectionDataSpy = jest.spyOn(documentService, 'getSelectionData')
component.openCorrespondentDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.correspondentDocumentCounts).toEqual(
selectionData.selected_correspondents
)
})
it('should apply list selection data to storage paths menu when all filtered documents are selected', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
fixture.detectChanges()
jest
.spyOn(documentListViewService, 'allSelected', 'get')
.mockReturnValue(true)
documentListViewService.selectionData = selectionData
const getSelectionDataSpy = jest.spyOn(documentService, 'getSelectionData')
component.openStoragePathDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.storagePathDocumentCounts).toEqual(
selectionData.selected_storage_paths
)
})
it('should apply list selection data to custom fields menu when all filtered documents are selected', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
fixture.detectChanges()
jest
.spyOn(documentListViewService, 'allSelected', 'get')
.mockReturnValue(true)
documentListViewService.selectionData = selectionData
const getSelectionDataSpy = jest.spyOn(documentService, 'getSelectionData')
component.openCustomFieldsDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.customFieldDocumentCounts).toEqual(
selectionData.selected_custom_fields
)
})
it('should execute modify tags bulk operation', () => { it('should execute modify tags bulk operation', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest jest
@@ -387,56 +298,13 @@ describe('BulkEditorComponent', () => {
parameters: { add_tags: [101], remove_tags: [] }, parameters: { add_tags: [101], remove_tags: [] },
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds ) // listAllFilteredIds
}) })
it('should execute modify tags bulk operation for all filtered documents', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
.spyOn(documentListViewService, 'documents', 'get')
.mockReturnValue([{ id: 3 }, { id: 4 }])
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 4]))
jest
.spyOn(documentListViewService, 'allSelected', 'get')
.mockReturnValue(true)
jest
.spyOn(documentListViewService, 'filterRules', 'get')
.mockReturnValue([{ rule_type: FILTER_TITLE, value: 'apple' }])
jest
.spyOn(documentListViewService, 'selectedCount', 'get')
.mockReturnValue(25)
jest
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
.mockReturnValue(true)
component.showConfirmationDialogs = false
fixture.detectChanges()
component.setTags({
itemsToAdd: [{ id: 101 }],
itemsToRemove: [],
})
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
expect(req.request.body).toEqual({
all: true,
filters: { title__icontains: 'apple' },
method: 'modify_tags',
parameters: { add_tags: [101], remove_tags: [] },
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
})
it('should execute modify tags bulk operation with confirmation dialog if enabled', () => { it('should execute modify tags bulk operation with confirmation dialog if enabled', () => {
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0])) modalService.activeInstances.subscribe((m) => (modal = m[0]))
@@ -462,7 +330,7 @@ describe('BulkEditorComponent', () => {
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`) .expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true) .flush(true)
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -553,7 +421,7 @@ describe('BulkEditorComponent', () => {
parameters: { correspondent: 101 }, parameters: { correspondent: 101 },
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -585,7 +453,7 @@ describe('BulkEditorComponent', () => {
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`) .expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true) .flush(true)
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -651,7 +519,7 @@ describe('BulkEditorComponent', () => {
parameters: { document_type: 101 }, parameters: { document_type: 101 },
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -683,7 +551,7 @@ describe('BulkEditorComponent', () => {
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`) .expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true) .flush(true)
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -749,7 +617,7 @@ describe('BulkEditorComponent', () => {
parameters: { storage_path: 101 }, parameters: { storage_path: 101 },
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -781,7 +649,7 @@ describe('BulkEditorComponent', () => {
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`) .expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true) .flush(true)
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -847,7 +715,7 @@ describe('BulkEditorComponent', () => {
parameters: { add_custom_fields: [101], remove_custom_fields: [102] }, parameters: { add_custom_fields: [101], remove_custom_fields: [102] },
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -879,7 +747,7 @@ describe('BulkEditorComponent', () => {
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`) .expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true) .flush(true)
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -981,14 +849,16 @@ describe('BulkEditorComponent', () => {
expect(modal).not.toBeUndefined() expect(modal).not.toBeUndefined()
modal.componentInstance.confirm() modal.componentInstance.confirm()
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/delete/` `${environment.apiBaseUrl}documents/bulk_edit/`
) )
req.flush(true) req.flush(true)
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: [3, 4], documents: [3, 4],
method: 'delete',
parameters: {},
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -998,7 +868,7 @@ describe('BulkEditorComponent', () => {
fixture.detectChanges() fixture.detectChanges()
component.applyDelete() component.applyDelete()
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/delete/` `${environment.apiBaseUrl}documents/bulk_edit/`
) )
}) })
@@ -1074,14 +944,16 @@ describe('BulkEditorComponent', () => {
expect(modal).not.toBeUndefined() expect(modal).not.toBeUndefined()
modal.componentInstance.confirm() modal.componentInstance.confirm()
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/reprocess/` `${environment.apiBaseUrl}documents/bulk_edit/`
) )
req.flush(true) req.flush(true)
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: [3, 4], documents: [3, 4],
method: 'reprocess',
parameters: {},
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -1107,16 +979,16 @@ describe('BulkEditorComponent', () => {
modal.componentInstance.rotate() modal.componentInstance.rotate()
modal.componentInstance.confirm() modal.componentInstance.confirm()
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/rotate/` `${environment.apiBaseUrl}documents/bulk_edit/`
) )
req.flush(true) req.flush(true)
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: [3, 4], documents: [3, 4],
degrees: 90, method: 'rotate',
source_mode: 'latest_version', parameters: { degrees: 90 },
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -1149,15 +1021,16 @@ describe('BulkEditorComponent', () => {
modal.componentInstance.metadataDocumentID = 3 modal.componentInstance.metadataDocumentID = 3
modal.componentInstance.confirm() modal.componentInstance.confirm()
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/merge/` `${environment.apiBaseUrl}documents/bulk_edit/`
) )
req.flush(true) req.flush(true)
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: [3, 4], documents: [3, 4],
metadata_document_id: 3, method: 'merge',
parameters: { metadata_document_id: 3 },
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -1167,16 +1040,16 @@ describe('BulkEditorComponent', () => {
modal.componentInstance.deleteOriginals = true modal.componentInstance.deleteOriginals = true
modal.componentInstance.confirm() modal.componentInstance.confirm()
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/merge/` `${environment.apiBaseUrl}documents/bulk_edit/`
) )
req.flush(true) req.flush(true)
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: [3, 4], documents: [3, 4],
metadata_document_id: 3, method: 'merge',
delete_originals: true, parameters: { metadata_document_id: 3, delete_originals: true },
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -1188,16 +1061,16 @@ describe('BulkEditorComponent', () => {
modal.componentInstance.archiveFallback = true modal.componentInstance.archiveFallback = true
modal.componentInstance.confirm() modal.componentInstance.confirm()
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/merge/` `${environment.apiBaseUrl}documents/bulk_edit/`
) )
req.flush(true) req.flush(true)
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: [3, 4], documents: [3, 4],
metadata_document_id: 3, method: 'merge',
archive_fallback: true, parameters: { metadata_document_id: 3, archive_fallback: true },
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -1219,39 +1092,22 @@ describe('BulkEditorComponent', () => {
component.downloadForm.get('downloadFileTypeArchive').patchValue(true) component.downloadForm.get('downloadFileTypeArchive').patchValue(true)
fixture.detectChanges() fixture.detectChanges()
let downloadSpy = jest.spyOn(documentService, 'bulkDownload') let downloadSpy = jest.spyOn(documentService, 'bulkDownload')
downloadSpy.mockReturnValue(of(new Blob()))
//archive //archive
component.downloadSelected() component.downloadSelected()
expect(downloadSpy).toHaveBeenCalledWith( expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'archive', false)
{ documents: [3, 4] },
'archive',
false
)
//originals //originals
component.downloadForm.get('downloadFileTypeArchive').patchValue(false) component.downloadForm.get('downloadFileTypeArchive').patchValue(false)
component.downloadForm.get('downloadFileTypeOriginals').patchValue(true) component.downloadForm.get('downloadFileTypeOriginals').patchValue(true)
component.downloadSelected() component.downloadSelected()
expect(downloadSpy).toHaveBeenCalledWith( expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'originals', false)
{ documents: [3, 4] },
'originals',
false
)
//both //both
component.downloadForm.get('downloadFileTypeArchive').patchValue(true) component.downloadForm.get('downloadFileTypeArchive').patchValue(true)
component.downloadSelected() component.downloadSelected()
expect(downloadSpy).toHaveBeenCalledWith( expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'both', false)
{ documents: [3, 4] },
'both',
false
)
//formatting //formatting
component.downloadForm.get('downloadUseFormatting').patchValue(true) component.downloadForm.get('downloadUseFormatting').patchValue(true)
component.downloadSelected() component.downloadSelected()
expect(downloadSpy).toHaveBeenCalledWith( expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'both', true)
{ documents: [3, 4] },
'both',
true
)
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/bulk_download/` `${environment.apiBaseUrl}documents/bulk_download/`
@@ -1300,7 +1156,7 @@ describe('BulkEditorComponent', () => {
}, },
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -1597,7 +1453,6 @@ describe('BulkEditorComponent', () => {
expect(modal.componentInstance.customFields.length).toEqual(2) expect(modal.componentInstance.customFields.length).toEqual(2)
expect(modal.componentInstance.fieldsToAddIds).toEqual([1, 2]) expect(modal.componentInstance.fieldsToAddIds).toEqual([1, 2])
expect(modal.componentInstance.selection).toEqual({ documents: [3, 4] })
expect(modal.componentInstance.documents).toEqual([3, 4]) expect(modal.componentInstance.documents).toEqual([3, 4])
modal.componentInstance.failed.emit() modal.componentInstance.failed.emit()
@@ -1608,7 +1463,7 @@ describe('BulkEditorComponent', () => {
expect(toastServiceShowInfoSpy).toHaveBeenCalled() expect(toastServiceShowInfoSpy).toHaveBeenCalled()
expect(listReloadSpy).toHaveBeenCalled() expect(listReloadSpy).toHaveBeenCalled()
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`

View File

@@ -12,11 +12,10 @@ import {
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { saveAs } from 'file-saver' import { saveAs } from 'file-saver'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { first, map, Observable, Subject, switchMap, takeUntil } from 'rxjs' import { first, map, Subject, switchMap, takeUntil } from 'rxjs'
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
import { CustomField } from 'src/app/data/custom-field' import { CustomField } from 'src/app/data/custom-field'
import { MatchingModel } from 'src/app/data/matching-model' import { MatchingModel } from 'src/app/data/matching-model'
import { SelectionDataItem } from 'src/app/data/results'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service'
@@ -30,10 +29,8 @@ import { CorrespondentService } from 'src/app/services/rest/correspondent.servic
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service' import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { import {
DocumentBulkEditMethod,
DocumentSelectionQuery,
DocumentService, DocumentService,
MergeDocumentsRequest, SelectionDataItem,
} from 'src/app/services/rest/document.service' } from 'src/app/services/rest/document.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service' import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service' import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
@@ -42,7 +39,6 @@ import { TagService } from 'src/app/services/rest/tag.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { flattenTags } from 'src/app/utils/flatten-tags' import { flattenTags } from 'src/app/utils/flatten-tags'
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component' import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component' import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component' import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
@@ -259,75 +255,56 @@ export class BulkEditorComponent
this.unsubscribeNotifier.complete() this.unsubscribeNotifier.complete()
} }
private executeBulkEditMethod( private executeBulkOperation(
modal: NgbModalRef, modal: NgbModalRef,
method: DocumentBulkEditMethod, method: string,
args: any, args: any,
overrideSelection?: DocumentSelectionQuery overrideDocumentIDs?: number[]
) { ) {
if (modal) { if (modal) {
modal.componentInstance.buttonsEnabled = false modal.componentInstance.buttonsEnabled = false
} }
this.documentService this.documentService
.bulkEdit(overrideSelection ?? this.getSelectionQuery(), method, args) .bulkEdit(
overrideDocumentIDs ?? Array.from(this.list.selected),
method,
args
)
.pipe(first()) .pipe(first())
.subscribe({ .subscribe({
next: () => this.handleOperationSuccess(modal), next: () => {
error: (error) => this.handleOperationError(modal, error), if (args['delete_originals']) {
this.list.selected.clear()
}
this.list.reload()
this.list.reduceSelectionToFilter()
this.list.selected.forEach((id) => {
this.openDocumentService.refreshDocument(id)
})
this.savedViewService.maybeRefreshDocumentCounts()
if (modal) {
modal.close()
}
},
error: (error) => {
if (modal) {
modal.componentInstance.buttonsEnabled = true
}
this.toastService.showError(
$localize`Error executing bulk operation`,
error
)
},
}) })
} }
private executeDocumentAction(
modal: NgbModalRef,
request: Observable<any>,
options: { deleteOriginals?: boolean } = {}
) {
if (modal) {
modal.componentInstance.buttonsEnabled = false
}
request.pipe(first()).subscribe({
next: () => {
this.handleOperationSuccess(modal, options.deleteOriginals ?? false)
},
error: (error) => this.handleOperationError(modal, error),
})
}
private handleOperationSuccess(
modal: NgbModalRef,
clearSelection: boolean = false
) {
if (clearSelection) {
this.list.selected.clear()
}
this.list.reload()
this.list.reduceSelectionToFilter()
this.list.selected.forEach((id) => {
this.openDocumentService.refreshDocument(id)
})
this.savedViewService.maybeRefreshDocumentCounts()
if (modal) {
modal.close()
}
}
private handleOperationError(modal: NgbModalRef, error: any) {
if (modal) {
modal.componentInstance.buttonsEnabled = true
}
this.toastService.showError(
$localize`Error executing bulk operation`,
error
)
}
private applySelectionData( private applySelectionData(
items: SelectionDataItem[], items: SelectionDataItem[],
selectionModel: FilterableDropdownSelectionModel selectionModel: FilterableDropdownSelectionModel
) { ) {
let selectionData = new Map<number, ToggleableItemState>() let selectionData = new Map<number, ToggleableItemState>()
items.forEach((i) => { items.forEach((i) => {
if (i.document_count == this.list.selectedCount) { if (i.document_count == this.list.selected.size) {
selectionData.set(i.id, ToggleableItemState.Selected) selectionData.set(i.id, ToggleableItemState.Selected)
} else if (i.document_count > 0) { } else if (i.document_count > 0) {
selectionData.set(i.id, ToggleableItemState.PartiallySelected) selectionData.set(i.id, ToggleableItemState.PartiallySelected)
@@ -336,31 +313,7 @@ export class BulkEditorComponent
selectionModel.init(selectionData) selectionModel.init(selectionData)
} }
private getSelectionQuery(): DocumentSelectionQuery {
if (this.list.allSelected) {
return {
all: true,
filters: queryParamsFromFilterRules(this.list.filterRules),
}
}
return {
documents: Array.from(this.list.selected),
}
}
private getSelectionSize(): number {
return this.list.selectedCount
}
openTagsDropdown() { openTagsDropdown() {
if (this.list.allSelected) {
const selectionData = this.list.selectionData
this.tagDocumentCounts = selectionData?.selected_tags ?? []
this.applySelectionData(this.tagDocumentCounts, this.tagSelectionModel)
return
}
this.documentService this.documentService
.getSelectionData(Array.from(this.list.selected)) .getSelectionData(Array.from(this.list.selected))
.pipe(first()) .pipe(first())
@@ -371,17 +324,6 @@ export class BulkEditorComponent
} }
openDocumentTypeDropdown() { openDocumentTypeDropdown() {
if (this.list.allSelected) {
const selectionData = this.list.selectionData
this.documentTypeDocumentCounts =
selectionData?.selected_document_types ?? []
this.applySelectionData(
this.documentTypeDocumentCounts,
this.documentTypeSelectionModel
)
return
}
this.documentService this.documentService
.getSelectionData(Array.from(this.list.selected)) .getSelectionData(Array.from(this.list.selected))
.pipe(first()) .pipe(first())
@@ -395,17 +337,6 @@ export class BulkEditorComponent
} }
openCorrespondentDropdown() { openCorrespondentDropdown() {
if (this.list.allSelected) {
const selectionData = this.list.selectionData
this.correspondentDocumentCounts =
selectionData?.selected_correspondents ?? []
this.applySelectionData(
this.correspondentDocumentCounts,
this.correspondentSelectionModel
)
return
}
this.documentService this.documentService
.getSelectionData(Array.from(this.list.selected)) .getSelectionData(Array.from(this.list.selected))
.pipe(first()) .pipe(first())
@@ -419,17 +350,6 @@ export class BulkEditorComponent
} }
openStoragePathDropdown() { openStoragePathDropdown() {
if (this.list.allSelected) {
const selectionData = this.list.selectionData
this.storagePathDocumentCounts =
selectionData?.selected_storage_paths ?? []
this.applySelectionData(
this.storagePathDocumentCounts,
this.storagePathsSelectionModel
)
return
}
this.documentService this.documentService
.getSelectionData(Array.from(this.list.selected)) .getSelectionData(Array.from(this.list.selected))
.pipe(first()) .pipe(first())
@@ -443,17 +363,6 @@ export class BulkEditorComponent
} }
openCustomFieldsDropdown() { openCustomFieldsDropdown() {
if (this.list.allSelected) {
const selectionData = this.list.selectionData
this.customFieldDocumentCounts =
selectionData?.selected_custom_fields ?? []
this.applySelectionData(
this.customFieldDocumentCounts,
this.customFieldsSelectionModel
)
return
}
this.documentService this.documentService
.getSelectionData(Array.from(this.list.selected)) .getSelectionData(Array.from(this.list.selected))
.pipe(first()) .pipe(first())
@@ -503,33 +412,33 @@ export class BulkEditorComponent
changedTags.itemsToRemove.length == 0 changedTags.itemsToRemove.length == 0
) { ) {
let tag = changedTags.itemsToAdd[0] let tag = changedTags.itemsToAdd[0]
modal.componentInstance.message = $localize`This operation will add the tag "${tag.name}" to ${this.getSelectionSize()} selected document(s).` modal.componentInstance.message = $localize`This operation will add the tag "${tag.name}" to ${this.list.selected.size} selected document(s).`
} else if ( } else if (
changedTags.itemsToAdd.length > 1 && changedTags.itemsToAdd.length > 1 &&
changedTags.itemsToRemove.length == 0 changedTags.itemsToRemove.length == 0
) { ) {
modal.componentInstance.message = $localize`This operation will add the tags ${this._localizeList( modal.componentInstance.message = $localize`This operation will add the tags ${this._localizeList(
changedTags.itemsToAdd changedTags.itemsToAdd
)} to ${this.getSelectionSize()} selected document(s).` )} to ${this.list.selected.size} selected document(s).`
} else if ( } else if (
changedTags.itemsToAdd.length == 0 && changedTags.itemsToAdd.length == 0 &&
changedTags.itemsToRemove.length == 1 changedTags.itemsToRemove.length == 1
) { ) {
let tag = changedTags.itemsToRemove[0] let tag = changedTags.itemsToRemove[0]
modal.componentInstance.message = $localize`This operation will remove the tag "${tag.name}" from ${this.getSelectionSize()} selected document(s).` modal.componentInstance.message = $localize`This operation will remove the tag "${tag.name}" from ${this.list.selected.size} selected document(s).`
} else if ( } else if (
changedTags.itemsToAdd.length == 0 && changedTags.itemsToAdd.length == 0 &&
changedTags.itemsToRemove.length > 1 changedTags.itemsToRemove.length > 1
) { ) {
modal.componentInstance.message = $localize`This operation will remove the tags ${this._localizeList( modal.componentInstance.message = $localize`This operation will remove the tags ${this._localizeList(
changedTags.itemsToRemove changedTags.itemsToRemove
)} from ${this.getSelectionSize()} selected document(s).` )} from ${this.list.selected.size} selected document(s).`
} else { } else {
modal.componentInstance.message = $localize`This operation will add the tags ${this._localizeList( modal.componentInstance.message = $localize`This operation will add the tags ${this._localizeList(
changedTags.itemsToAdd changedTags.itemsToAdd
)} and remove the tags ${this._localizeList( )} and remove the tags ${this._localizeList(
changedTags.itemsToRemove changedTags.itemsToRemove
)} on ${this.getSelectionSize()} selected document(s).` )} on ${this.list.selected.size} selected document(s).`
} }
modal.componentInstance.btnClass = 'btn-warning' modal.componentInstance.btnClass = 'btn-warning'
@@ -537,13 +446,13 @@ export class BulkEditorComponent
modal.componentInstance.confirmClicked modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => { .subscribe(() => {
this.executeBulkEditMethod(modal, 'modify_tags', { this.executeBulkOperation(modal, 'modify_tags', {
add_tags: changedTags.itemsToAdd.map((t) => t.id), add_tags: changedTags.itemsToAdd.map((t) => t.id),
remove_tags: changedTags.itemsToRemove.map((t) => t.id), remove_tags: changedTags.itemsToRemove.map((t) => t.id),
}) })
}) })
} else { } else {
this.executeBulkEditMethod(null, 'modify_tags', { this.executeBulkOperation(null, 'modify_tags', {
add_tags: changedTags.itemsToAdd.map((t) => t.id), add_tags: changedTags.itemsToAdd.map((t) => t.id),
remove_tags: changedTags.itemsToRemove.map((t) => t.id), remove_tags: changedTags.itemsToRemove.map((t) => t.id),
}) })
@@ -568,21 +477,21 @@ export class BulkEditorComponent
}) })
modal.componentInstance.title = $localize`Confirm correspondent assignment` modal.componentInstance.title = $localize`Confirm correspondent assignment`
if (correspondent) { if (correspondent) {
modal.componentInstance.message = $localize`This operation will assign the correspondent "${correspondent.name}" to ${this.getSelectionSize()} selected document(s).` modal.componentInstance.message = $localize`This operation will assign the correspondent "${correspondent.name}" to ${this.list.selected.size} selected document(s).`
} else { } else {
modal.componentInstance.message = $localize`This operation will remove the correspondent from ${this.getSelectionSize()} selected document(s).` modal.componentInstance.message = $localize`This operation will remove the correspondent from ${this.list.selected.size} selected document(s).`
} }
modal.componentInstance.btnClass = 'btn-warning' modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Confirm` modal.componentInstance.btnCaption = $localize`Confirm`
modal.componentInstance.confirmClicked modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => { .subscribe(() => {
this.executeBulkEditMethod(modal, 'set_correspondent', { this.executeBulkOperation(modal, 'set_correspondent', {
correspondent: correspondent ? correspondent.id : null, correspondent: correspondent ? correspondent.id : null,
}) })
}) })
} else { } else {
this.executeBulkEditMethod(null, 'set_correspondent', { this.executeBulkOperation(null, 'set_correspondent', {
correspondent: correspondent ? correspondent.id : null, correspondent: correspondent ? correspondent.id : null,
}) })
} }
@@ -606,21 +515,21 @@ export class BulkEditorComponent
}) })
modal.componentInstance.title = $localize`Confirm document type assignment` modal.componentInstance.title = $localize`Confirm document type assignment`
if (documentType) { if (documentType) {
modal.componentInstance.message = $localize`This operation will assign the document type "${documentType.name}" to ${this.getSelectionSize()} selected document(s).` modal.componentInstance.message = $localize`This operation will assign the document type "${documentType.name}" to ${this.list.selected.size} selected document(s).`
} else { } else {
modal.componentInstance.message = $localize`This operation will remove the document type from ${this.getSelectionSize()} selected document(s).` modal.componentInstance.message = $localize`This operation will remove the document type from ${this.list.selected.size} selected document(s).`
} }
modal.componentInstance.btnClass = 'btn-warning' modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Confirm` modal.componentInstance.btnCaption = $localize`Confirm`
modal.componentInstance.confirmClicked modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => { .subscribe(() => {
this.executeBulkEditMethod(modal, 'set_document_type', { this.executeBulkOperation(modal, 'set_document_type', {
document_type: documentType ? documentType.id : null, document_type: documentType ? documentType.id : null,
}) })
}) })
} else { } else {
this.executeBulkEditMethod(null, 'set_document_type', { this.executeBulkOperation(null, 'set_document_type', {
document_type: documentType ? documentType.id : null, document_type: documentType ? documentType.id : null,
}) })
} }
@@ -644,21 +553,21 @@ export class BulkEditorComponent
}) })
modal.componentInstance.title = $localize`Confirm storage path assignment` modal.componentInstance.title = $localize`Confirm storage path assignment`
if (storagePath) { if (storagePath) {
modal.componentInstance.message = $localize`This operation will assign the storage path "${storagePath.name}" to ${this.getSelectionSize()} selected document(s).` modal.componentInstance.message = $localize`This operation will assign the storage path "${storagePath.name}" to ${this.list.selected.size} selected document(s).`
} else { } else {
modal.componentInstance.message = $localize`This operation will remove the storage path from ${this.getSelectionSize()} selected document(s).` modal.componentInstance.message = $localize`This operation will remove the storage path from ${this.list.selected.size} selected document(s).`
} }
modal.componentInstance.btnClass = 'btn-warning' modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Confirm` modal.componentInstance.btnCaption = $localize`Confirm`
modal.componentInstance.confirmClicked modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => { .subscribe(() => {
this.executeBulkEditMethod(modal, 'set_storage_path', { this.executeBulkOperation(modal, 'set_storage_path', {
storage_path: storagePath ? storagePath.id : null, storage_path: storagePath ? storagePath.id : null,
}) })
}) })
} else { } else {
this.executeBulkEditMethod(null, 'set_storage_path', { this.executeBulkOperation(null, 'set_storage_path', {
storage_path: storagePath ? storagePath.id : null, storage_path: storagePath ? storagePath.id : null,
}) })
} }
@@ -681,33 +590,33 @@ export class BulkEditorComponent
changedCustomFields.itemsToRemove.length == 0 changedCustomFields.itemsToRemove.length == 0
) { ) {
let customField = changedCustomFields.itemsToAdd[0] let customField = changedCustomFields.itemsToAdd[0]
modal.componentInstance.message = $localize`This operation will assign the custom field "${customField.name}" to ${this.getSelectionSize()} selected document(s).` modal.componentInstance.message = $localize`This operation will assign the custom field "${customField.name}" to ${this.list.selected.size} selected document(s).`
} else if ( } else if (
changedCustomFields.itemsToAdd.length > 1 && changedCustomFields.itemsToAdd.length > 1 &&
changedCustomFields.itemsToRemove.length == 0 changedCustomFields.itemsToRemove.length == 0
) { ) {
modal.componentInstance.message = $localize`This operation will assign the custom fields ${this._localizeList( modal.componentInstance.message = $localize`This operation will assign the custom fields ${this._localizeList(
changedCustomFields.itemsToAdd changedCustomFields.itemsToAdd
)} to ${this.getSelectionSize()} selected document(s).` )} to ${this.list.selected.size} selected document(s).`
} else if ( } else if (
changedCustomFields.itemsToAdd.length == 0 && changedCustomFields.itemsToAdd.length == 0 &&
changedCustomFields.itemsToRemove.length == 1 changedCustomFields.itemsToRemove.length == 1
) { ) {
let customField = changedCustomFields.itemsToRemove[0] let customField = changedCustomFields.itemsToRemove[0]
modal.componentInstance.message = $localize`This operation will remove the custom field "${customField.name}" from ${this.getSelectionSize()} selected document(s).` modal.componentInstance.message = $localize`This operation will remove the custom field "${customField.name}" from ${this.list.selected.size} selected document(s).`
} else if ( } else if (
changedCustomFields.itemsToAdd.length == 0 && changedCustomFields.itemsToAdd.length == 0 &&
changedCustomFields.itemsToRemove.length > 1 changedCustomFields.itemsToRemove.length > 1
) { ) {
modal.componentInstance.message = $localize`This operation will remove the custom fields ${this._localizeList( modal.componentInstance.message = $localize`This operation will remove the custom fields ${this._localizeList(
changedCustomFields.itemsToRemove changedCustomFields.itemsToRemove
)} from ${this.getSelectionSize()} selected document(s).` )} from ${this.list.selected.size} selected document(s).`
} else { } else {
modal.componentInstance.message = $localize`This operation will assign the custom fields ${this._localizeList( modal.componentInstance.message = $localize`This operation will assign the custom fields ${this._localizeList(
changedCustomFields.itemsToAdd changedCustomFields.itemsToAdd
)} and remove the custom fields ${this._localizeList( )} and remove the custom fields ${this._localizeList(
changedCustomFields.itemsToRemove changedCustomFields.itemsToRemove
)} on ${this.getSelectionSize()} selected document(s).` )} on ${this.list.selected.size} selected document(s).`
} }
modal.componentInstance.btnClass = 'btn-warning' modal.componentInstance.btnClass = 'btn-warning'
@@ -715,7 +624,7 @@ export class BulkEditorComponent
modal.componentInstance.confirmClicked modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => { .subscribe(() => {
this.executeBulkEditMethod(modal, 'modify_custom_fields', { this.executeBulkOperation(modal, 'modify_custom_fields', {
add_custom_fields: changedCustomFields.itemsToAdd.map((f) => f.id), add_custom_fields: changedCustomFields.itemsToAdd.map((f) => f.id),
remove_custom_fields: changedCustomFields.itemsToRemove.map( remove_custom_fields: changedCustomFields.itemsToRemove.map(
(f) => f.id (f) => f.id
@@ -723,7 +632,7 @@ export class BulkEditorComponent
}) })
}) })
} else { } else {
this.executeBulkEditMethod(null, 'modify_custom_fields', { this.executeBulkOperation(null, 'modify_custom_fields', {
add_custom_fields: changedCustomFields.itemsToAdd.map((f) => f.id), add_custom_fields: changedCustomFields.itemsToAdd.map((f) => f.id),
remove_custom_fields: changedCustomFields.itemsToRemove.map( remove_custom_fields: changedCustomFields.itemsToRemove.map(
(f) => f.id (f) => f.id
@@ -845,7 +754,7 @@ export class BulkEditorComponent
backdrop: 'static', backdrop: 'static',
}) })
modal.componentInstance.title = $localize`Confirm` modal.componentInstance.title = $localize`Confirm`
modal.componentInstance.messageBold = $localize`Move ${this.getSelectionSize()} selected document(s) to the trash?` modal.componentInstance.messageBold = $localize`Move ${this.list.selected.size} selected document(s) to the trash?`
modal.componentInstance.message = $localize`Documents can be restored prior to permanent deletion.` modal.componentInstance.message = $localize`Documents can be restored prior to permanent deletion.`
modal.componentInstance.btnClass = 'btn-danger' modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Move to trash` modal.componentInstance.btnCaption = $localize`Move to trash`
@@ -853,16 +762,10 @@ export class BulkEditorComponent
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => { .subscribe(() => {
modal.componentInstance.buttonsEnabled = false modal.componentInstance.buttonsEnabled = false
this.executeDocumentAction( this.executeBulkOperation(modal, 'delete', {})
modal,
this.documentService.deleteDocuments(this.getSelectionQuery())
)
}) })
} else { } else {
this.executeDocumentAction( this.executeBulkOperation(null, 'delete', {})
null,
this.documentService.deleteDocuments(this.getSelectionQuery())
)
} }
} }
@@ -877,7 +780,7 @@ export class BulkEditorComponent
: 'originals' : 'originals'
this.documentService this.documentService
.bulkDownload( .bulkDownload(
this.getSelectionQuery(), Array.from(this.list.selected),
downloadFileType, downloadFileType,
this.downloadForm.get('downloadUseFormatting').value this.downloadForm.get('downloadUseFormatting').value
) )
@@ -893,7 +796,7 @@ export class BulkEditorComponent
backdrop: 'static', backdrop: 'static',
}) })
modal.componentInstance.title = $localize`Reprocess confirm` modal.componentInstance.title = $localize`Reprocess confirm`
modal.componentInstance.messageBold = $localize`This operation will permanently recreate the archive files for ${this.getSelectionSize()} selected document(s).` modal.componentInstance.messageBold = $localize`This operation will permanently recreate the archive files for ${this.list.selected.size} selected document(s).`
modal.componentInstance.message = $localize`The archive files will be re-generated with the current settings.` modal.componentInstance.message = $localize`The archive files will be re-generated with the current settings.`
modal.componentInstance.btnClass = 'btn-danger' modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed` modal.componentInstance.btnCaption = $localize`Proceed`
@@ -901,10 +804,7 @@ export class BulkEditorComponent
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => { .subscribe(() => {
modal.componentInstance.buttonsEnabled = false modal.componentInstance.buttonsEnabled = false
this.executeDocumentAction( this.executeBulkOperation(modal, 'reprocess', {})
modal,
this.documentService.reprocessDocuments(this.getSelectionQuery())
)
}) })
} }
@@ -915,7 +815,7 @@ export class BulkEditorComponent
modal.componentInstance.confirmClicked.subscribe( modal.componentInstance.confirmClicked.subscribe(
({ permissions, merge }) => { ({ permissions, merge }) => {
modal.componentInstance.buttonsEnabled = false modal.componentInstance.buttonsEnabled = false
this.executeBulkEditMethod(modal, 'set_permissions', { this.executeBulkOperation(modal, 'set_permissions', {
...permissions, ...permissions,
merge, merge,
}) })
@@ -930,7 +830,7 @@ export class BulkEditorComponent
}) })
const rotateDialog = modal.componentInstance as RotateConfirmDialogComponent const rotateDialog = modal.componentInstance as RotateConfirmDialogComponent
rotateDialog.title = $localize`Rotate confirm` rotateDialog.title = $localize`Rotate confirm`
rotateDialog.messageBold = $localize`This operation will add rotated versions of the ${this.getSelectionSize()} document(s).` rotateDialog.messageBold = $localize`This operation will add rotated versions of the ${this.list.selected.size} document(s).`
rotateDialog.btnClass = 'btn-danger' rotateDialog.btnClass = 'btn-danger'
rotateDialog.btnCaption = $localize`Proceed` rotateDialog.btnCaption = $localize`Proceed`
rotateDialog.documentID = Array.from(this.list.selected)[0] rotateDialog.documentID = Array.from(this.list.selected)[0]
@@ -938,13 +838,9 @@ export class BulkEditorComponent
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => { .subscribe(() => {
rotateDialog.buttonsEnabled = false rotateDialog.buttonsEnabled = false
this.executeDocumentAction( this.executeBulkOperation(modal, 'rotate', {
modal, degrees: rotateDialog.degrees,
this.documentService.rotateDocuments( })
this.getSelectionQuery(),
rotateDialog.degrees
)
)
}) })
} }
@@ -954,28 +850,24 @@ export class BulkEditorComponent
}) })
const mergeDialog = modal.componentInstance as MergeConfirmDialogComponent const mergeDialog = modal.componentInstance as MergeConfirmDialogComponent
mergeDialog.title = $localize`Merge confirm` mergeDialog.title = $localize`Merge confirm`
mergeDialog.messageBold = $localize`This operation will merge ${this.getSelectionSize()} selected documents into a new document.` mergeDialog.messageBold = $localize`This operation will merge ${this.list.selected.size} selected documents into a new document.`
mergeDialog.btnCaption = $localize`Proceed` mergeDialog.btnCaption = $localize`Proceed`
mergeDialog.documentIDs = Array.from(this.list.selected) mergeDialog.documentIDs = Array.from(this.list.selected)
mergeDialog.confirmClicked mergeDialog.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => { .subscribe(() => {
const args: MergeDocumentsRequest = {} const args = {}
if (mergeDialog.metadataDocumentID > -1) { if (mergeDialog.metadataDocumentID > -1) {
args.metadata_document_id = mergeDialog.metadataDocumentID args['metadata_document_id'] = mergeDialog.metadataDocumentID
} }
if (mergeDialog.deleteOriginals) { if (mergeDialog.deleteOriginals) {
args.delete_originals = true args['delete_originals'] = true
} }
if (mergeDialog.archiveFallback) { if (mergeDialog.archiveFallback) {
args.archive_fallback = true args['archive_fallback'] = true
} }
mergeDialog.buttonsEnabled = false mergeDialog.buttonsEnabled = false
this.executeDocumentAction( this.executeBulkOperation(modal, 'merge', args, mergeDialog.documentIDs)
modal,
this.documentService.mergeDocuments(mergeDialog.documentIDs, args),
{ deleteOriginals: !!args.delete_originals }
)
this.toastService.showInfo( this.toastService.showInfo(
$localize`Merged document will be queued for consumption.` $localize`Merged document will be queued for consumption.`
) )
@@ -999,7 +891,7 @@ export class BulkEditorComponent
(item) => item.id (item) => item.id
) )
dialog.selection = this.getSelectionQuery() dialog.documents = Array.from(this.list.selected)
dialog.succeeded.subscribe((result) => { dialog.succeeded.subscribe((result) => {
this.toastService.showInfo($localize`Custom fields updated.`) this.toastService.showInfo($localize`Custom fields updated.`)
this.list.reload() this.list.reload()

View File

@@ -48,7 +48,7 @@ describe('CustomFieldsBulkEditDialogComponent', () => {
.mockReturnValue(of('Success')) .mockReturnValue(of('Success'))
const successSpy = jest.spyOn(component.succeeded, 'emit') const successSpy = jest.spyOn(component.succeeded, 'emit')
component.selection = [1, 2] component.documents = [1, 2]
component.fieldsToAddIds = [1] component.fieldsToAddIds = [1]
component.form.controls['1'].setValue('Value 1') component.form.controls['1'].setValue('Value 1')
component.save() component.save()
@@ -63,7 +63,7 @@ describe('CustomFieldsBulkEditDialogComponent', () => {
.mockReturnValue(throwError(new Error('Error'))) .mockReturnValue(throwError(new Error('Error')))
const failSpy = jest.spyOn(component.failed, 'emit') const failSpy = jest.spyOn(component.failed, 'emit')
component.selection = [1, 2] component.documents = [1, 2]
component.fieldsToAddIds = [1] component.fieldsToAddIds = [1]
component.form.controls['1'].setValue('Value 1') component.form.controls['1'].setValue('Value 1')
component.save() component.save()

View File

@@ -17,10 +17,7 @@ import { SelectComponent } from 'src/app/components/common/input/select/select.c
import { TextComponent } from 'src/app/components/common/input/text/text.component' import { TextComponent } from 'src/app/components/common/input/text/text.component'
import { UrlComponent } from 'src/app/components/common/input/url/url.component' import { UrlComponent } from 'src/app/components/common/input/url/url.component'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { import { DocumentService } from 'src/app/services/rest/document.service'
DocumentSelectionQuery,
DocumentService,
} from 'src/app/services/rest/document.service'
import { TextAreaComponent } from '../../../common/input/textarea/textarea.component' import { TextAreaComponent } from '../../../common/input/textarea/textarea.component'
@Component({ @Component({
@@ -79,11 +76,7 @@ export class CustomFieldsBulkEditDialogComponent {
public form: FormGroup = new FormGroup({}) public form: FormGroup = new FormGroup({})
public selection: DocumentSelectionQuery = { documents: [] } public documents: number[] = []
public get documents(): number[] {
return this.selection.documents
}
initForm() { initForm() {
Object.keys(this.form.controls).forEach((key) => { Object.keys(this.form.controls).forEach((key) => {
@@ -98,7 +91,7 @@ export class CustomFieldsBulkEditDialogComponent {
public save() { public save() {
this.documentService this.documentService
.bulkEdit(this.selection, 'modify_custom_fields', { .bulkEdit(this.documents, 'modify_custom_fields', {
add_custom_fields: this.form.value, add_custom_fields: this.form.value,
remove_custom_fields: this.fieldsToRemoveIds, remove_custom_fields: this.fieldsToRemoveIds,
}) })

View File

@@ -15,7 +15,7 @@
} }
@if (document && displayFields?.includes(DisplayField.TAGS)) { @if (document && displayFields?.includes(DisplayField.TAGS)) {
<div class="tags d-flex flex-column text-end position-absolute me-1 fs-6" [class.tags-no-wrap]="document.tags.length > 3"> <div class="tags d-flex flex-column text-end position-absolute me-1 fs-6">
@for (tagID of tagIDs; track tagID) { @for (tagID of tagIDs; track tagID) {
<pngx-tag [tagID]="tagID" (click)="clickTag.emit(tagID);$event.stopPropagation()" [clickable]="true" linkTitle="Toggle tag filter" i18n-linkTitle></pngx-tag> <pngx-tag [tagID]="tagID" (click)="clickTag.emit(tagID);$event.stopPropagation()" [clickable]="true" linkTitle="Toggle tag filter" i18n-linkTitle></pngx-tag>
} }

View File

@@ -72,14 +72,4 @@ a {
max-width: 80%; max-width: 80%;
row-gap: .2rem; row-gap: .2rem;
line-height: 1; line-height: 1;
&.tags-no-wrap {
::ng-deep .badge {
display: inline-block;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
} }

View File

@@ -82,16 +82,6 @@ describe('DocumentCardSmallComponent', () => {
).toHaveLength(6) ).toHaveLength(6)
}) })
it('should clear hidden tag counter when tag count falls below the limit', () => {
expect(component.moreTags).toEqual(3)
component.document.tags = [1, 2, 3, 4, 5, 6]
fixture.detectChanges()
expect(component.moreTags).toBeNull()
expect(fixture.nativeElement.textContent).not.toContain('+ 3')
})
it('should try to close the preview on mouse leave', () => { it('should try to close the preview on mouse leave', () => {
component.popupPreview = { component.popupPreview = {
close: jest.fn(), close: jest.fn(),

View File

@@ -126,7 +126,6 @@ export class DocumentCardSmallComponent
this.moreTags = this.document.tags.length - (limit - 1) this.moreTags = this.document.tags.length - (limit - 1)
return this.document.tags.slice(0, limit - 1) return this.document.tags.slice(0, limit - 1)
} else { } else {
this.moreTags = null
return this.document.tags return this.document.tags
} }
} }

View File

@@ -2,8 +2,8 @@
<div ngbDropdown class="btn-group flex-fill d-sm-none"> <div ngbDropdown class="btn-group flex-fill d-sm-none">
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle> <button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
<i-bs name="text-indent-left"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Select</ng-container></div> <i-bs name="text-indent-left"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Select</ng-container></div>
@if (list.hasSelection) { @if (list.selected.size > 0) {
<pngx-clearable-badge [selected]="list.hasSelection" [number]="list.selectedCount" (cleared)="list.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span> <pngx-clearable-badge [selected]="list.selected.size > 0" [number]="list.selected.size" (cleared)="list.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
} }
</button> </button>
<div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow"> <div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow">
@@ -17,7 +17,7 @@
<span class="input-group-text border-0" i18n>Select:</span> <span class="input-group-text border-0" i18n>Select:</span>
</div> </div>
<div class="btn-group btn-group-sm flex-nowrap"> <div class="btn-group btn-group-sm flex-nowrap">
@if (list.hasSelection) { @if (list.selected.size > 0) {
<button class="btn btn-sm btn-outline-secondary" (click)="list.selectNone()"> <button class="btn btn-sm btn-outline-secondary" (click)="list.selectNone()">
<i-bs name="slash-circle" class="me-1"></i-bs><ng-container i18n>None</ng-container> <i-bs name="slash-circle" class="me-1"></i-bs><ng-container i18n>None</ng-container>
</button> </button>
@@ -127,11 +127,11 @@
<div class="spinner-border spinner-border-sm me-2" role="status"></div> <div class="spinner-border spinner-border-sm me-2" role="status"></div>
<ng-container i18n>Loading...</ng-container> <ng-container i18n>Loading...</ng-container>
} }
@if (list.hasSelection) { @if (list.selected.size > 0) {
<span i18n>{list.collectionSize, plural, =1 {Selected {{list.selectedCount}} of one document} other {Selected {{list.selectedCount}} of {{list.collectionSize || 0}} documents}}</span> <span i18n>{list.collectionSize, plural, =1 {Selected {{list.selected.size}} of one document} other {Selected {{list.selected.size}} of {{list.collectionSize || 0}} documents}}</span>
} }
@if (!list.isReloading) { @if (!list.isReloading) {
@if (!list.hasSelection) { @if (list.selected.size === 0) {
<span i18n>{list.collectionSize, plural, =1 {One document} other {{{list.collectionSize || 0}} documents}}</span> <span i18n>{list.collectionSize, plural, =1 {One document} other {{{list.collectionSize || 0}} documents}}</span>
}&nbsp;@if (isFiltered) { }&nbsp;@if (isFiltered) {
&nbsp;<span i18n>(filtered)</span> &nbsp;<span i18n>(filtered)</span>
@@ -142,7 +142,7 @@
<i-bs width="1em" height="1em" name="x"></i-bs><small i18n>Reset filters</small> <i-bs width="1em" height="1em" name="x"></i-bs><small i18n>Reset filters</small>
</button> </button>
} }
@if (!list.isReloading && list.hasSelection) { @if (!list.isReloading && list.selected.size > 0) {
<button class="btn btn-link py-0" (click)="list.selectNone()"> <button class="btn btn-link py-0" (click)="list.selectNone()">
<i-bs width="1em" height="1em" name="slash-circle" class="me-1"></i-bs><small i18n>Clear selection</small> <i-bs width="1em" height="1em" name="slash-circle" class="me-1"></i-bs><small i18n>Clear selection</small>
</button> </button>

View File

@@ -56,20 +56,13 @@ $paperless-card-breakpoints: (
.sticky-top { .sticky-top {
z-index: 990; // below main navbar z-index: 990; // below main navbar
top: calc(7rem - 2px); // height of navbar + search row (mobile) top: calc(7rem - 2px); // height of navbar (mobile)
transition: top 0.2s ease;
@media (min-width: 580px) { @media (min-width: 580px) {
top: 3.5rem; // height of navbar top: 3.5rem; // height of navbar
} }
} }
@media (max-width: 579.98px) {
:host-context(main.mobile-search-hidden) .sticky-top {
top: calc(3.5rem - 2px); // height of navbar only when search is hidden
}
}
.table .form-check { .table .form-check {
padding: 0.2rem; padding: 0.2rem;
min-height: 0; min-height: 0;

View File

@@ -388,8 +388,8 @@ describe('DocumentListComponent', () => {
it('should support select all, none, page & range', () => { it('should support select all, none, page & range', () => {
jest.spyOn(documentListService, 'documents', 'get').mockReturnValue(docs) jest.spyOn(documentListService, 'documents', 'get').mockReturnValue(docs)
jest jest
.spyOn(documentListService, 'collectionSize', 'get') .spyOn(documentService, 'listAllFilteredIds')
.mockReturnValue(docs.length) .mockReturnValue(of(docs.map((d) => d.id)))
fixture.detectChanges() fixture.detectChanges()
expect(documentListService.selected.size).toEqual(0) expect(documentListService.selected.size).toEqual(0)
const docCards = fixture.debugElement.queryAll( const docCards = fixture.debugElement.queryAll(
@@ -403,8 +403,7 @@ describe('DocumentListComponent', () => {
displayModeButtons[2].triggerEventHandler('click') displayModeButtons[2].triggerEventHandler('click')
expect(selectAllSpy).toHaveBeenCalled() expect(selectAllSpy).toHaveBeenCalled()
fixture.detectChanges() fixture.detectChanges()
expect(documentListService.allSelected).toBeTruthy() expect(documentListService.selected.size).toEqual(3)
expect(documentListService.selectedCount).toEqual(3)
docCards.forEach((card) => { docCards.forEach((card) => {
expect(card.context.selected).toBeTruthy() expect(card.context.selected).toBeTruthy()
}) })

View File

@@ -240,7 +240,7 @@ export class DocumentListComponent
} }
get isBulkEditing(): boolean { get isBulkEditing(): boolean {
return this.list.hasSelection return this.list.selected.size > 0
} }
toggleDisplayField(field: DisplayField) { toggleDisplayField(field: DisplayField) {
@@ -327,7 +327,7 @@ export class DocumentListComponent
}) })
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => { .subscribe(() => {
if (this.list.hasSelection) { if (this.list.selected.size > 0) {
this.list.selectNone() this.list.selectNone()
} else if (this.isFiltered) { } else if (this.isFiltered) {
this.resetFilters() this.resetFilters()
@@ -356,7 +356,7 @@ export class DocumentListComponent
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => { .subscribe(() => {
if (this.list.documents.length > 0) { if (this.list.documents.length > 0) {
if (this.list.hasSelection) { if (this.list.selected.size > 0) {
this.openDocumentDetail(Array.from(this.list.selected)[0]) this.openDocumentDetail(Array.from(this.list.selected)[0])
} else { } else {
this.openDocumentDetail(this.list.documents[0]) this.openDocumentDetail(this.list.documents[0])

View File

@@ -76,7 +76,6 @@ import {
FILTER_TITLE_CONTENT, FILTER_TITLE_CONTENT,
NEGATIVE_NULL_FILTER_VALUE, NEGATIVE_NULL_FILTER_VALUE,
} from 'src/app/data/filter-rule-type' } from 'src/app/data/filter-rule-type'
import { SelectionData, SelectionDataItem } from 'src/app/data/results'
import { import {
PermissionAction, PermissionAction,
PermissionType, PermissionType,
@@ -85,7 +84,11 @@ import {
import { CorrespondentService } from 'src/app/services/rest/correspondent.service' import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service' import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { DocumentService } from 'src/app/services/rest/document.service' import {
DocumentService,
SelectionData,
SelectionDataItem,
} from 'src/app/services/rest/document.service'
import { SearchService } from 'src/app/services/rest/search.service' import { SearchService } from 'src/app/services/rest/search.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { TagService } from 'src/app/services/rest/tag.service' import { TagService } from 'src/app/services/rest/tag.service'

View File

@@ -9,8 +9,8 @@
<div ngbDropdown class="btn-group flex-fill d-sm-none"> <div ngbDropdown class="btn-group flex-fill d-sm-none">
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle> <button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
<i-bs name="text-indent-left"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Select</ng-container></div> <i-bs name="text-indent-left"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Select</ng-container></div>
@if (activeManagementList.hasSelection) { @if (activeManagementList.selectedObjects.size > 0) {
<pngx-clearable-badge [selected]="activeManagementList.hasSelection" [number]="activeManagementList.selectedCount" (cleared)="activeManagementList.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span> <pngx-clearable-badge [selected]="activeManagementList.selectedObjects.size > 0" [number]="activeManagementList.selectedObjects.size" (cleared)="activeManagementList.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
} }
</button> </button>
<div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow"> <div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow">
@@ -25,7 +25,7 @@
<span class="input-group-text border-0" i18n>Select:</span> <span class="input-group-text border-0" i18n>Select:</span>
</div> </div>
<div class="btn-group btn-group-sm flex-nowrap"> <div class="btn-group btn-group-sm flex-nowrap">
@if (activeManagementList.hasSelection) { @if (activeManagementList.selectedObjects.size > 0) {
<button class="btn btn-sm btn-outline-secondary" (click)="activeManagementList.selectNone()"> <button class="btn btn-sm btn-outline-secondary" (click)="activeManagementList.selectNone()">
<i-bs name="slash-circle" class="me-1"></i-bs><ng-container i18n>None</ng-container> <i-bs name="slash-circle" class="me-1"></i-bs><ng-container i18n>None</ng-container>
</button> </button>
@@ -40,11 +40,11 @@
</div> </div>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="activeManagementList.setPermissions()" <button type="button" class="btn btn-sm btn-outline-primary" (click)="activeManagementList.setPermissions()"
[disabled]="!activeManagementList.userCanBulkEdit(PermissionAction.Change) || !activeManagementList.hasSelection"> [disabled]="!activeManagementList.userCanBulkEdit(PermissionAction.Change) || activeManagementList.selectedObjects.size === 0">
<i-bs name="person-fill-lock" class="me-1"></i-bs><ng-container i18n>Permissions</ng-container> <i-bs name="person-fill-lock" class="me-1"></i-bs><ng-container i18n>Permissions</ng-container>
</button> </button>
<button type="button" class="btn btn-sm btn-outline-danger" (click)="activeManagementList.delete()" <button type="button" class="btn btn-sm btn-outline-danger" (click)="activeManagementList.delete()"
[disabled]="!activeManagementList.userCanBulkEdit(PermissionAction.Delete) || !activeManagementList.hasSelection"> [disabled]="!activeManagementList.userCanBulkEdit(PermissionAction.Delete) || activeManagementList.selectedObjects.size === 0">
<i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container> <i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container>
</button> </button>
<button type="button" class="btn btn-sm btn-outline-primary ms-md-5" (click)="activeManagementList.openCreateDialog()" <button type="button" class="btn btn-sm btn-outline-primary ms-md-5" (click)="activeManagementList.openCreateDialog()"

View File

@@ -65,8 +65,8 @@
@if (displayCollectionSize > 0) { @if (displayCollectionSize > 0) {
<div> <div>
<ng-container i18n>{displayCollectionSize, plural, =1 {One {{typeName}}} other {{{displayCollectionSize || 0}} total {{typeNamePlural}}}}</ng-container> <ng-container i18n>{displayCollectionSize, plural, =1 {One {{typeName}}} other {{{displayCollectionSize || 0}} total {{typeNamePlural}}}}</ng-container>
@if (hasSelection) { @if (selectedObjects.size > 0) {
&nbsp;({{selectedCount}} selected) &nbsp;({{selectedObjects.size}} selected)
} }
</div> </div>
} }

View File

@@ -117,6 +117,7 @@ describe('ManagementListComponent', () => {
: tags : tags
return of({ return of({
count: results.length, count: results.length,
all: results.map((o) => o.id),
results, results,
}) })
} }
@@ -230,11 +231,11 @@ describe('ManagementListComponent', () => {
expect(reloadSpy).toHaveBeenCalled() expect(reloadSpy).toHaveBeenCalled()
}) })
it('should use API count for pagination and nested ids for displayed total', fakeAsync(() => { it('should use API count for pagination and all ids for displayed total', fakeAsync(() => {
jest.spyOn(tagService, 'listFiltered').mockReturnValueOnce( jest.spyOn(tagService, 'listFiltered').mockReturnValueOnce(
of({ of({
count: 1, count: 1,
display_count: 3, all: [1, 2, 3],
results: tags.slice(0, 1), results: tags.slice(0, 1),
}) })
) )
@@ -314,17 +315,13 @@ describe('ManagementListComponent', () => {
expect(component.togggleAll).toBe(false) expect(component.togggleAll).toBe(false)
}) })
it('selectAll should activate all-selection mode', () => { it('selectAll should use all IDs when collection size exists', () => {
;(tagService.listFiltered as jest.Mock).mockClear() ;(component as any).allIDs = [1, 2, 3, 4]
component.collectionSize = tags.length component.collectionSize = 4
component.selectAll() component.selectAll()
expect(tagService.listFiltered).not.toHaveBeenCalled() expect(component.selectedObjects).toEqual(new Set([1, 2, 3, 4]))
expect(component.selectedObjects).toEqual(new Set(tags.map((t) => t.id)))
expect((component as any).allSelectionActive).toBe(true)
expect(component.hasSelection).toBe(true)
expect(component.selectedCount).toBe(tags.length)
expect(component.togggleAll).toBe(true) expect(component.togggleAll).toBe(true)
}) })
@@ -398,33 +395,6 @@ describe('ManagementListComponent', () => {
expect(successToastSpy).toHaveBeenCalled() expect(successToastSpy).toHaveBeenCalled()
}) })
it('should support bulk edit permissions for all filtered items', () => {
const bulkEditPermsSpy = jest
.spyOn(tagService, 'bulk_edit_objects')
.mockReturnValue(of('OK'))
component.selectAll()
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
fixture.detectChanges()
component.setPermissions()
expect(modal).not.toBeUndefined()
modal.componentInstance.confirmClicked.emit({
permissions: {},
merge: true,
})
expect(bulkEditPermsSpy).toHaveBeenCalledWith(
[],
BulkEditObjectOperation.SetPermissions,
{},
true,
true,
{ is_root: true }
)
})
it('should support bulk delete objects', () => { it('should support bulk delete objects', () => {
const bulkEditSpy = jest.spyOn(tagService, 'bulk_edit_objects') const bulkEditSpy = jest.spyOn(tagService, 'bulk_edit_objects')
component.toggleSelected(tags[0]) component.toggleSelected(tags[0])
@@ -445,11 +415,7 @@ describe('ManagementListComponent', () => {
modal.componentInstance.confirmClicked.emit(null) modal.componentInstance.confirmClicked.emit(null)
expect(bulkEditSpy).toHaveBeenCalledWith( expect(bulkEditSpy).toHaveBeenCalledWith(
Array.from(selected), Array.from(selected),
BulkEditObjectOperation.Delete, BulkEditObjectOperation.Delete
null,
null,
false,
null
) )
expect(errorToastSpy).toHaveBeenCalled() expect(errorToastSpy).toHaveBeenCalled()
@@ -460,29 +426,6 @@ describe('ManagementListComponent', () => {
expect(successToastSpy).toHaveBeenCalled() expect(successToastSpy).toHaveBeenCalled()
}) })
it('should support bulk delete for all filtered items', () => {
const bulkEditSpy = jest
.spyOn(tagService, 'bulk_edit_objects')
.mockReturnValue(of('OK'))
component.selectAll()
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
fixture.detectChanges()
component.delete()
expect(modal).not.toBeUndefined()
modal.componentInstance.confirmClicked.emit(null)
expect(bulkEditSpy).toHaveBeenCalledWith(
[],
BulkEditObjectOperation.Delete,
null,
null,
true,
{ is_root: true }
)
})
it('should disallow bulk permissions or delete objects if no global perms', () => { it('should disallow bulk permissions or delete objects if no global perms', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(false) jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(false)
expect(component.userCanBulkEdit(PermissionAction.Delete)).toBeFalsy() expect(component.userCanBulkEdit(PermissionAction.Delete)).toBeFalsy()

View File

@@ -90,8 +90,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
public data: T[] = [] public data: T[] = []
private unfilteredData: T[] = [] private unfilteredData: T[] = []
private currentExtraParams: { [key: string]: any } = null private allIDs: number[] = []
private allSelectionActive = false
public page = 1 public page = 1
@@ -108,16 +107,6 @@ export abstract class ManagementListComponent<T extends MatchingModel>
public selectedObjects: Set<number> = new Set() public selectedObjects: Set<number> = new Set()
public togggleAll: boolean = false public togggleAll: boolean = false
public get hasSelection(): boolean {
return this.selectedObjects.size > 0 || this.allSelectionActive
}
public get selectedCount(): number {
return this.allSelectionActive
? this.displayCollectionSize
: this.selectedObjects.size
}
ngOnInit(): void { ngOnInit(): void {
this.reloadData() this.reloadData()
@@ -161,11 +150,11 @@ export abstract class ManagementListComponent<T extends MatchingModel>
} }
protected getCollectionSize(results: Results<T>): number { protected getCollectionSize(results: Results<T>): number {
return results.count return results.all?.length ?? results.count
} }
protected getDisplayCollectionSize(results: Results<T>): number { protected getDisplayCollectionSize(results: Results<T>): number {
return results.display_count ?? this.getCollectionSize(results) return this.getCollectionSize(results)
} }
getDocumentCount(object: MatchingModel): number { getDocumentCount(object: MatchingModel): number {
@@ -182,7 +171,6 @@ export abstract class ManagementListComponent<T extends MatchingModel>
reloadData(extraParams: { [key: string]: any } = null) { reloadData(extraParams: { [key: string]: any } = null) {
this.loading = true this.loading = true
this.currentExtraParams = extraParams
this.clearSelection() this.clearSelection()
this.service this.service
.listFiltered( .listFiltered(
@@ -201,6 +189,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
this.data = this.filterData(c.results) this.data = this.filterData(c.results)
this.collectionSize = this.getCollectionSize(c) this.collectionSize = this.getCollectionSize(c)
this.displayCollectionSize = this.getDisplayCollectionSize(c) this.displayCollectionSize = this.getDisplayCollectionSize(c)
this.allIDs = c.all
}), }),
delay(100) delay(100)
) )
@@ -357,16 +346,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
return objects.map((o) => o.id) return objects.map((o) => o.id)
} }
private getBulkEditFilters(): { [key: string]: any } {
const filters = { ...this.currentExtraParams }
if (this._nameFilter?.length) {
filters['name__icontains'] = this._nameFilter
}
return filters
}
clearSelection() { clearSelection() {
this.allSelectionActive = false
this.togggleAll = false this.togggleAll = false
this.selectedObjects.clear() this.selectedObjects.clear()
} }
@@ -376,7 +356,6 @@ export abstract class ManagementListComponent<T extends MatchingModel>
} }
selectPage() { selectPage() {
this.allSelectionActive = false
this.selectedObjects = new Set(this.getSelectableIDs(this.data)) this.selectedObjects = new Set(this.getSelectableIDs(this.data))
this.togggleAll = this.areAllPageItemsSelected() this.togggleAll = this.areAllPageItemsSelected()
} }
@@ -386,16 +365,11 @@ export abstract class ManagementListComponent<T extends MatchingModel>
this.clearSelection() this.clearSelection()
return return
} }
this.selectedObjects = new Set(this.allIDs)
this.allSelectionActive = true
this.selectedObjects = new Set(this.getSelectableIDs(this.data))
this.togggleAll = this.areAllPageItemsSelected() this.togggleAll = this.areAllPageItemsSelected()
} }
toggleSelected(object) { toggleSelected(object) {
if (this.allSelectionActive) {
this.allSelectionActive = false
}
this.selectedObjects.has(object.id) this.selectedObjects.has(object.id)
? this.selectedObjects.delete(object.id) ? this.selectedObjects.delete(object.id)
: this.selectedObjects.add(object.id) : this.selectedObjects.add(object.id)
@@ -403,9 +377,6 @@ export abstract class ManagementListComponent<T extends MatchingModel>
} }
protected areAllPageItemsSelected(): boolean { protected areAllPageItemsSelected(): boolean {
if (this.allSelectionActive) {
return this.data.length > 0
}
const ids = this.getSelectableIDs(this.data) const ids = this.getSelectableIDs(this.data)
return ids.length > 0 && ids.every((id) => this.selectedObjects.has(id)) return ids.length > 0 && ids.every((id) => this.selectedObjects.has(id))
} }
@@ -419,12 +390,10 @@ export abstract class ManagementListComponent<T extends MatchingModel>
modal.componentInstance.buttonsEnabled = false modal.componentInstance.buttonsEnabled = false
this.service this.service
.bulk_edit_objects( .bulk_edit_objects(
this.allSelectionActive ? [] : Array.from(this.selectedObjects), Array.from(this.selectedObjects),
BulkEditObjectOperation.SetPermissions, BulkEditObjectOperation.SetPermissions,
permissions, permissions,
merge, merge
this.allSelectionActive,
this.allSelectionActive ? this.getBulkEditFilters() : null
) )
.subscribe({ .subscribe({
next: () => { next: () => {
@@ -459,12 +428,8 @@ export abstract class ManagementListComponent<T extends MatchingModel>
modal.componentInstance.buttonsEnabled = false modal.componentInstance.buttonsEnabled = false
this.service this.service
.bulk_edit_objects( .bulk_edit_objects(
this.allSelectionActive ? [] : Array.from(this.selectedObjects), Array.from(this.selectedObjects),
BulkEditObjectOperation.Delete, BulkEditObjectOperation.Delete
null,
null,
this.allSelectionActive,
this.allSelectionActive ? this.getBulkEditFilters() : null
) )
.subscribe({ .subscribe({
next: () => { next: () => {

View File

@@ -41,6 +41,7 @@ describe('TagListComponent', () => {
listFilteredSpy = jest.spyOn(tagService, 'listFiltered').mockReturnValue( listFilteredSpy = jest.spyOn(tagService, 'listFiltered').mockReturnValue(
of({ of({
count: 3, count: 3,
all: [1, 2, 3],
results: [ results: [
{ {
id: 1, id: 1,

View File

@@ -9,6 +9,7 @@ import {
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { TagEditDialogComponent } from 'src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component' import { TagEditDialogComponent } from 'src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type' import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
import { Results } from 'src/app/data/results'
import { Tag } from 'src/app/data/tag' import { Tag } from 'src/app/data/tag'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SortableDirective } from 'src/app/directives/sortable.directive' import { SortableDirective } from 'src/app/directives/sortable.directive'
@@ -76,6 +77,16 @@ export class TagListComponent extends ManagementListComponent<Tag> {
return data.filter((tag) => !tag.parent || !availableIds.has(tag.parent)) return data.filter((tag) => !tag.parent || !availableIds.has(tag.parent))
} }
protected override getCollectionSize(results: Results<Tag>): number {
// Tag list pages are requested with is_root=true (when unfiltered), so
// pagination must follow root count even though `all` includes descendants
return results.count
}
protected override getDisplayCollectionSize(results: Results<Tag>): number {
return super.getCollectionSize(results)
}
protected override getSelectableIDs(tags: Tag[]): number[] { protected override getSelectableIDs(tags: Tag[]): number[] {
const ids: number[] = [] const ids: number[] = []
for (const tag of tags.filter(Boolean)) { for (const tag of tags.filter(Boolean)) {

View File

@@ -1,26 +1,7 @@
import { Document } from './document'
export interface Results<T> { export interface Results<T> {
count: number count: number
display_count?: number
results: T[] results: T[]
}
export interface SelectionDataItem { all: number[]
id: number
document_count: number
}
export interface SelectionData {
selected_storage_paths: SelectionDataItem[]
selected_correspondents: SelectionDataItem[]
selected_tags: SelectionDataItem[]
selected_document_types: SelectionDataItem[]
selected_custom_fields: SelectionDataItem[]
}
export interface DocumentResults extends Results<Document> {
selection_data?: SelectionData
} }

View File

@@ -1,122 +0,0 @@
import {
HttpErrorResponse,
HttpHandlerFn,
HttpRequest,
} from '@angular/common/http'
import { throwError } from 'rxjs'
import * as navUtils from '../utils/navigation'
import { createAuthExpiryInterceptor } from './auth-expiry.interceptor'
describe('withAuthExpiryInterceptor', () => {
let interceptor: ReturnType<typeof createAuthExpiryInterceptor>
let dateNowSpy: jest.SpiedFunction<typeof Date.now>
beforeEach(() => {
interceptor = createAuthExpiryInterceptor()
dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(1000)
})
afterEach(() => {
jest.restoreAllMocks()
})
it('reloads when an API request returns 401', () => {
const reloadSpy = jest
.spyOn(navUtils, 'locationReload')
.mockImplementation(() => {})
interceptor(
new HttpRequest('GET', '/api/documents/'),
failingHandler('/api/documents/', 401)
).subscribe({
error: () => undefined,
})
expect(reloadSpy).toHaveBeenCalledTimes(1)
})
it('does not reload for non-401 errors', () => {
const reloadSpy = jest
.spyOn(navUtils, 'locationReload')
.mockImplementation(() => {})
interceptor(
new HttpRequest('GET', '/api/documents/'),
failingHandler('/api/documents/', 500)
).subscribe({
error: () => undefined,
})
expect(reloadSpy).not.toHaveBeenCalled()
})
it('does not reload for non-api 401 responses', () => {
const reloadSpy = jest
.spyOn(navUtils, 'locationReload')
.mockImplementation(() => {})
interceptor(
new HttpRequest('GET', '/accounts/profile/'),
failingHandler('/accounts/profile/', 401)
).subscribe({
error: () => undefined,
})
expect(reloadSpy).not.toHaveBeenCalled()
})
it('reloads only once even with multiple API 401 responses', () => {
const reloadSpy = jest
.spyOn(navUtils, 'locationReload')
.mockImplementation(() => {})
const request = new HttpRequest('GET', '/api/documents/')
const handler = failingHandler('/api/documents/', 401)
interceptor(request, handler).subscribe({
error: () => undefined,
})
interceptor(request, handler).subscribe({
error: () => undefined,
})
expect(reloadSpy).toHaveBeenCalledTimes(1)
})
it('retries reload after cooldown for repeated API 401 responses', () => {
const reloadSpy = jest
.spyOn(navUtils, 'locationReload')
.mockImplementation(() => {})
dateNowSpy
.mockReturnValueOnce(1000)
.mockReturnValueOnce(2500)
.mockReturnValueOnce(3501)
const request = new HttpRequest('GET', '/api/documents/')
const handler = failingHandler('/api/documents/', 401)
interceptor(request, handler).subscribe({
error: () => undefined,
})
interceptor(request, handler).subscribe({
error: () => undefined,
})
interceptor(request, handler).subscribe({
error: () => undefined,
})
expect(reloadSpy).toHaveBeenCalledTimes(2)
})
})
function failingHandler(url: string, status: number): HttpHandlerFn {
return (_request) =>
throwError(
() =>
new HttpErrorResponse({
status,
url,
})
)
}

View File

@@ -1,37 +0,0 @@
import {
HttpErrorResponse,
HttpEvent,
HttpHandlerFn,
HttpInterceptorFn,
HttpRequest,
} from '@angular/common/http'
import { catchError, Observable, throwError } from 'rxjs'
import { locationReload } from '../utils/navigation'
export const createAuthExpiryInterceptor = (): HttpInterceptorFn => {
let lastReloadAttempt = Number.NEGATIVE_INFINITY
return (
request: HttpRequest<unknown>,
next: HttpHandlerFn
): Observable<HttpEvent<unknown>> =>
next(request).pipe(
catchError((error: unknown) => {
if (
error instanceof HttpErrorResponse &&
error.status === 401 &&
request.url.includes('/api/')
) {
const now = Date.now()
if (now - lastReloadAttempt >= 2000) {
lastReloadAttempt = now
locationReload()
}
}
return throwError(() => error)
})
)
}
export const withAuthExpiryInterceptor = createAuthExpiryInterceptor()

View File

@@ -21,7 +21,6 @@ import {
FILTER_HAS_TAGS_ANY, FILTER_HAS_TAGS_ANY,
} from '../data/filter-rule-type' } from '../data/filter-rule-type'
import { SavedView } from '../data/saved-view' import { SavedView } from '../data/saved-view'
import { DOCUMENT_LIST_SERVICE } from '../data/storage-keys'
import { SETTINGS_KEYS } from '../data/ui-settings' import { SETTINGS_KEYS } from '../data/ui-settings'
import { PermissionsGuard } from '../guards/permissions.guard' import { PermissionsGuard } from '../guards/permissions.guard'
import { DocumentListViewService } from './document-list-view.service' import { DocumentListViewService } from './document-list-view.service'
@@ -127,10 +126,13 @@ describe('DocumentListViewService', () => {
expect(documentListViewService.currentPage).toEqual(1) expect(documentListViewService.currentPage).toEqual(1)
documentListViewService.reload() documentListViewService.reload()
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.flush(full_results) req.flush(full_results)
httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/selection_data/`
)
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
expect(documentListViewService.isReloading).toBeFalsy() expect(documentListViewService.isReloading).toBeFalsy()
expect(documentListViewService.activeSavedViewId).toBeNull() expect(documentListViewService.activeSavedViewId).toBeNull()
@@ -142,12 +144,12 @@ describe('DocumentListViewService', () => {
it('should handle error on page request out of range', () => { it('should handle error on page request out of range', () => {
documentListViewService.currentPage = 50 documentListViewService.currentPage = 50
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=50&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=50&page_size=50&ordering=-created&truncate_content=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.flush([], { status: 404, statusText: 'Unexpected error' }) req.flush([], { status: 404, statusText: 'Unexpected error' })
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
expect(documentListViewService.currentPage).toEqual(1) expect(documentListViewService.currentPage).toEqual(1)
@@ -164,7 +166,7 @@ describe('DocumentListViewService', () => {
] ]
documentListViewService.setFilterRules(filterRulesAny) documentListViewService.setFilterRules(filterRulesAny)
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__in=${tags__id__in}` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__in=${tags__id__in}`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.flush( req.flush(
@@ -172,13 +174,13 @@ describe('DocumentListViewService', () => {
{ status: 404, statusText: 'Unexpected error' } { status: 404, statusText: 'Unexpected error' }
) )
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
// reset the list // reset the list
documentListViewService.setFilterRules([]) documentListViewService.setFilterRules([])
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) )
}) })
@@ -186,7 +188,7 @@ describe('DocumentListViewService', () => {
documentListViewService.currentPage = 1 documentListViewService.currentPage = 1
documentListViewService.sortField = 'custom_field_999' documentListViewService.sortField = 'custom_field_999'
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-custom_field_999&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-custom_field_999&truncate_content=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.flush( req.flush(
@@ -195,7 +197,7 @@ describe('DocumentListViewService', () => {
) )
// resets itself // resets itself
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) )
}) })
@@ -210,7 +212,7 @@ describe('DocumentListViewService', () => {
] ]
documentListViewService.setFilterRules(filterRulesAny) documentListViewService.setFilterRules(filterRulesAny)
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__in=${tags__id__in}` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__in=${tags__id__in}`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.flush('Generic error', { status: 404, statusText: 'Unexpected error' }) req.flush('Generic error', { status: 404, statusText: 'Unexpected error' })
@@ -218,7 +220,7 @@ describe('DocumentListViewService', () => {
// reset the list // reset the list
documentListViewService.setFilterRules([]) documentListViewService.setFilterRules([])
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) )
}) })
@@ -227,7 +229,7 @@ describe('DocumentListViewService', () => {
expect(documentListViewService.sortReverse).toBeTruthy() expect(documentListViewService.sortReverse).toBeTruthy()
documentListViewService.setSort('added', false) documentListViewService.setSort('added', false)
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=added&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=added&truncate_content=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
expect(documentListViewService.sortField).toEqual('added') expect(documentListViewService.sortField).toEqual('added')
@@ -235,40 +237,17 @@ describe('DocumentListViewService', () => {
documentListViewService.sortField = 'created' documentListViewService.sortField = 'created'
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=created&truncate_content=true`
) )
expect(documentListViewService.sortField).toEqual('created') expect(documentListViewService.sortField).toEqual('created')
documentListViewService.sortReverse = true documentListViewService.sortReverse = true
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
expect(documentListViewService.sortReverse).toBeTruthy() expect(documentListViewService.sortReverse).toBeTruthy()
}) })
it('restores only known list view state fields from local storage', () => {
try {
localStorage.setItem(
DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG,
'{"currentPage":3,"sortField":"title","sortReverse":false,"__proto__":{"polluted":true},"injected":"ignored"}'
)
const restoredService = TestBed.runInInjectionContext(
() => new DocumentListViewService()
)
expect(restoredService.currentPage).toEqual(3)
expect(restoredService.sortField).toEqual('title')
expect(restoredService.sortReverse).toBeFalsy()
expect(
(restoredService as any).activeListViewState.injected
).toBeUndefined()
expect(({} as any).polluted).toBeUndefined()
} finally {
localStorage.removeItem(DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG)
}
})
it('should load from query params', () => { it('should load from query params', () => {
expect(documentListViewService.currentPage).toEqual(1) expect(documentListViewService.currentPage).toEqual(1)
const page = 2 const page = 2
@@ -283,7 +262,7 @@ describe('DocumentListViewService', () => {
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=${page}&page_size=${ `${environment.apiBaseUrl}documents/?page=${page}&page_size=${
documentListViewService.pageSize documentListViewService.pageSize
}&ordering=${reverse ? '-' : ''}${sort}&truncate_content=true&include_selection_data=true` }&ordering=${reverse ? '-' : ''}${sort}&truncate_content=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
expect(documentListViewService.currentPage).toEqual(page) expect(documentListViewService.currentPage).toEqual(page)
@@ -300,7 +279,7 @@ describe('DocumentListViewService', () => {
} }
documentListViewService.loadFromQueryParams(convertToParamMap(params)) documentListViewService.loadFromQueryParams(convertToParamMap(params))
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}` `${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&tags__id__all=${tags__id__all}`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
expect(documentListViewService.filterRules).toEqual([ expect(documentListViewService.filterRules).toEqual([
@@ -310,12 +289,15 @@ describe('DocumentListViewService', () => {
}, },
]) ])
req.flush(full_results) req.flush(full_results)
httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/selection_data/`
)
}) })
it('should use filter rules to update query params', () => { it('should use filter rules to update query params', () => {
documentListViewService.setFilterRules(filterRules) documentListViewService.setFilterRules(filterRules)
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}` `${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&tags__id__all=${tags__id__all}`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
}) })
@@ -324,26 +306,34 @@ describe('DocumentListViewService', () => {
documentListViewService.currentPage = 2 documentListViewService.currentPage = 2
let req = httpTestingController.expectOne((request) => let req = httpTestingController.expectOne((request) =>
request.urlWithParams.startsWith( request.urlWithParams.startsWith(
`${environment.apiBaseUrl}documents/?page=2&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=2&page_size=50&ordering=-created&truncate_content=true`
) )
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.flush(full_results) req.flush(full_results)
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/selection_data/`
)
req.flush([])
documentListViewService.setFilterRules(filterRules, true) documentListViewService.setFilterRules(filterRules, true)
const filteredReqs = httpTestingController.match( const filteredReqs = httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=${tags__id__all}`
) )
expect(filteredReqs).toHaveLength(1) expect(filteredReqs).toHaveLength(1)
filteredReqs[0].flush(full_results) filteredReqs[0].flush(full_results)
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/selection_data/`
)
req.flush([])
expect(documentListViewService.currentPage).toEqual(1) expect(documentListViewService.currentPage).toEqual(1)
}) })
it('should support quick filter', () => { it('should support quick filter', () => {
documentListViewService.quickFilter(filterRules) documentListViewService.quickFilter(filterRules)
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}` `${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&tags__id__all=${tags__id__all}`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
}) })
@@ -366,21 +356,21 @@ describe('DocumentListViewService', () => {
convertToParamMap(params) convertToParamMap(params)
) )
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=${page}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}` `${environment.apiBaseUrl}documents/?page=${page}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&tags__id__all=${tags__id__all}`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
// reset the list // reset the list
documentListViewService.currentPage = 1 documentListViewService.currentPage = 1
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true&include_selection_data=true&tags__id__all=9` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true&tags__id__all=9`
) )
documentListViewService.setFilterRules([]) documentListViewService.setFilterRules([])
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true`
) )
documentListViewService.sortField = 'created' documentListViewService.sortField = 'created'
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) )
documentListViewService.activateSavedView(null) documentListViewService.activateSavedView(null)
}) })
@@ -388,18 +378,21 @@ describe('DocumentListViewService', () => {
it('should support navigating next / previous', () => { it('should support navigating next / previous', () => {
documentListViewService.setFilterRules([]) documentListViewService.setFilterRules([])
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) )
expect(documentListViewService.currentPage).toEqual(1) expect(documentListViewService.currentPage).toEqual(1)
documentListViewService.pageSize = 3 documentListViewService.pageSize = 3
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.flush({ req.flush({
count: 3, count: 3,
results: documents.slice(0, 3), results: documents.slice(0, 3),
}) })
httpTestingController
.expectOne(`${environment.apiBaseUrl}documents/selection_data/`)
.flush([])
expect(documentListViewService.hasNext(documents[0].id)).toBeTruthy() expect(documentListViewService.hasNext(documents[0].id)).toBeTruthy()
expect(documentListViewService.hasPrevious(documents[0].id)).toBeFalsy() expect(documentListViewService.hasPrevious(documents[0].id)).toBeFalsy()
documentListViewService.getNext(documents[0].id).subscribe((docId) => { documentListViewService.getNext(documents[0].id).subscribe((docId) => {
@@ -446,7 +439,7 @@ describe('DocumentListViewService', () => {
expect(documentListViewService.currentPage).toEqual(1) expect(documentListViewService.currentPage).toEqual(1)
documentListViewService.pageSize = 3 documentListViewService.pageSize = 3
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true`
) )
jest jest
.spyOn(documentListViewService, 'getLastPage') .spyOn(documentListViewService, 'getLastPage')
@@ -461,7 +454,7 @@ describe('DocumentListViewService', () => {
expect(reloadSpy).toHaveBeenCalled() expect(reloadSpy).toHaveBeenCalled()
expect(documentListViewService.currentPage).toEqual(2) expect(documentListViewService.currentPage).toEqual(2)
const reqs = httpTestingController.match( const reqs = httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true`
) )
expect(reqs.length).toBeGreaterThan(0) expect(reqs.length).toBeGreaterThan(0)
}) })
@@ -496,11 +489,11 @@ describe('DocumentListViewService', () => {
.mockReturnValue(documents) .mockReturnValue(documents)
documentListViewService.currentPage = 2 documentListViewService.currentPage = 2
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=2&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=2&page_size=50&ordering=-created&truncate_content=true`
) )
documentListViewService.pageSize = 3 documentListViewService.pageSize = 3
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true`
) )
const reloadSpy = jest.spyOn(documentListViewService, 'reload') const reloadSpy = jest.spyOn(documentListViewService, 'reload')
documentListViewService.getPrevious(1).subscribe({ documentListViewService.getPrevious(1).subscribe({
@@ -510,7 +503,7 @@ describe('DocumentListViewService', () => {
expect(reloadSpy).toHaveBeenCalled() expect(reloadSpy).toHaveBeenCalled()
expect(documentListViewService.currentPage).toEqual(1) expect(documentListViewService.currentPage).toEqual(1)
const reqs = httpTestingController.match( const reqs = httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true`
) )
expect(reqs.length).toBeGreaterThan(0) expect(reqs.length).toBeGreaterThan(0)
}) })
@@ -523,10 +516,13 @@ describe('DocumentListViewService', () => {
it('should support select a document', () => { it('should support select a document', () => {
documentListViewService.reload() documentListViewService.reload()
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.flush(full_results) req.flush(full_results)
httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/selection_data/`
)
documentListViewService.toggleSelected(documents[0]) documentListViewService.toggleSelected(documents[0])
expect(documentListViewService.isSelected(documents[0])).toBeTruthy() expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
documentListViewService.toggleSelected(documents[0]) documentListViewService.toggleSelected(documents[0])
@@ -534,16 +530,12 @@ describe('DocumentListViewService', () => {
}) })
it('should support select all', () => { it('should support select all', () => {
documentListViewService.reload()
const reloadReq = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
)
expect(reloadReq.request.method).toEqual('GET')
reloadReq.flush(full_results)
documentListViewService.selectAll() documentListViewService.selectAll()
expect(documentListViewService.allSelected).toBeTruthy() const req = httpTestingController.expectOne(
expect(documentListViewService.selectedCount).toEqual(documents.length) `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
)
expect(req.request.method).toEqual('GET')
req.flush(full_results)
expect(documentListViewService.selected.size).toEqual(documents.length) expect(documentListViewService.selected.size).toEqual(documents.length)
expect(documentListViewService.isSelected(documents[0])).toBeTruthy() expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
documentListViewService.selectNone() documentListViewService.selectNone()
@@ -552,13 +544,16 @@ describe('DocumentListViewService', () => {
it('should support select page', () => { it('should support select page', () => {
documentListViewService.pageSize = 3 documentListViewService.pageSize = 3
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.flush({ req.flush({
count: 3, count: 3,
results: documents.slice(0, 3), results: documents.slice(0, 3),
}) })
httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/selection_data/`
)
documentListViewService.selectPage() documentListViewService.selectPage()
expect(documentListViewService.selected.size).toEqual(3) expect(documentListViewService.selected.size).toEqual(3)
expect(documentListViewService.isSelected(documents[5])).toBeFalsy() expect(documentListViewService.isSelected(documents[5])).toBeFalsy()
@@ -567,10 +562,13 @@ describe('DocumentListViewService', () => {
it('should support select range', () => { it('should support select range', () => {
documentListViewService.reload() documentListViewService.reload()
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.flush(full_results) req.flush(full_results)
httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/selection_data/`
)
documentListViewService.toggleSelected(documents[0]) documentListViewService.toggleSelected(documents[0])
expect(documentListViewService.isSelected(documents[0])).toBeTruthy() expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
documentListViewService.selectRangeTo(documents[2]) documentListViewService.selectRangeTo(documents[2])
@@ -579,62 +577,26 @@ describe('DocumentListViewService', () => {
expect(documentListViewService.isSelected(documents[3])).toBeTruthy() expect(documentListViewService.isSelected(documents[3])).toBeTruthy()
}) })
it('should clear all-selected mode when toggling a single document', () => {
documentListViewService.reload()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
)
req.flush(full_results)
documentListViewService.selectAll()
expect(documentListViewService.allSelected).toBeTruthy()
documentListViewService.toggleSelected(documents[0])
expect(documentListViewService.allSelected).toBeFalsy()
expect(documentListViewService.isSelected(documents[0])).toBeFalsy()
})
it('should clear all-selected mode when selecting a range', () => {
documentListViewService.reload()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
)
req.flush(full_results)
documentListViewService.selectAll()
documentListViewService.toggleSelected(documents[1])
documentListViewService.selectAll()
expect(documentListViewService.allSelected).toBeTruthy()
documentListViewService.selectRangeTo(documents[3])
expect(documentListViewService.allSelected).toBeFalsy()
expect(documentListViewService.isSelected(documents[1])).toBeTruthy()
expect(documentListViewService.isSelected(documents[2])).toBeTruthy()
expect(documentListViewService.isSelected(documents[3])).toBeTruthy()
})
it('should support selection range reduction', () => { it('should support selection range reduction', () => {
documentListViewService.reload() documentListViewService.selectAll()
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.flush(full_results) req.flush(full_results)
documentListViewService.selectAll()
expect(documentListViewService.selected.size).toEqual(6) expect(documentListViewService.selected.size).toEqual(6)
documentListViewService.setFilterRules(filterRules) documentListViewService.setFilterRules(filterRules)
req = httpTestingController.expectOne( httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=9` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=9`
) )
req.flush({ const reqs = httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id&tags__id__all=9`
)
reqs[0].flush({
count: 3, count: 3,
results: documents.slice(0, 3), results: documents.slice(0, 3),
}) })
expect(documentListViewService.allSelected).toBeTruthy()
expect(documentListViewService.selected.size).toEqual(3) expect(documentListViewService.selected.size).toEqual(3)
}) })
@@ -642,7 +604,7 @@ describe('DocumentListViewService', () => {
const cancelSpy = jest.spyOn(documentListViewService, 'cancelPending') const cancelSpy = jest.spyOn(documentListViewService, 'cancelPending')
documentListViewService.reload() documentListViewService.reload()
httpTestingController.expectOne( httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=9` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=9`
) )
expect(cancelSpy).toHaveBeenCalled() expect(cancelSpy).toHaveBeenCalled()
}) })
@@ -661,7 +623,7 @@ describe('DocumentListViewService', () => {
documentListViewService.setFilterRules([]) documentListViewService.setFilterRules([])
expect(documentListViewService.sortField).toEqual('created') expect(documentListViewService.sortField).toEqual('created')
httpTestingController.expectOne( httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) )
}) })
@@ -688,11 +650,11 @@ describe('DocumentListViewService', () => {
expect(localStorageSpy).toHaveBeenCalled() expect(localStorageSpy).toHaveBeenCalled()
// reload triggered // reload triggered
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) )
documentListViewService.displayFields = null documentListViewService.displayFields = null
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) )
expect(documentListViewService.displayFields).toEqual( expect(documentListViewService.displayFields).toEqual(
DEFAULT_DISPLAY_FIELDS.filter((f) => f.id !== DisplayField.ADDED).map( DEFAULT_DISPLAY_FIELDS.filter((f) => f.id !== DisplayField.ADDED).map(
@@ -732,7 +694,7 @@ describe('DocumentListViewService', () => {
it('should generate quick filter URL preserving default state', () => { it('should generate quick filter URL preserving default state', () => {
documentListViewService.reload() documentListViewService.reload()
httpTestingController.expectOne( httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) )
const urlTree = documentListViewService.getQuickFilterUrl(filterRules) const urlTree = documentListViewService.getQuickFilterUrl(filterRules)
expect(urlTree).toBeDefined() expect(urlTree).toBeDefined()

View File

@@ -1,6 +1,6 @@
import { Injectable, inject } from '@angular/core' import { Injectable, inject } from '@angular/core'
import { ParamMap, Router, UrlTree } from '@angular/router' import { ParamMap, Router, UrlTree } from '@angular/router'
import { Observable, Subject, takeUntil } from 'rxjs' import { Observable, Subject, first, takeUntil } from 'rxjs'
import { import {
DEFAULT_DISPLAY_FIELDS, DEFAULT_DISPLAY_FIELDS,
DisplayField, DisplayField,
@@ -8,7 +8,6 @@ import {
Document, Document,
} from '../data/document' } from '../data/document'
import { FilterRule } from '../data/filter-rule' import { FilterRule } from '../data/filter-rule'
import { DocumentResults, SelectionData } from '../data/results'
import { SavedView } from '../data/saved-view' import { SavedView } from '../data/saved-view'
import { DOCUMENT_LIST_SERVICE } from '../data/storage-keys' import { DOCUMENT_LIST_SERVICE } from '../data/storage-keys'
import { SETTINGS_KEYS } from '../data/ui-settings' import { SETTINGS_KEYS } from '../data/ui-settings'
@@ -18,27 +17,13 @@ import {
isFullTextFilterRule, isFullTextFilterRule,
} from '../utils/filter-rules' } from '../utils/filter-rules'
import { paramsFromViewState, paramsToViewState } from '../utils/query-params' import { paramsFromViewState, paramsToViewState } from '../utils/query-params'
import { DocumentService } from './rest/document.service' import { DocumentService, SelectionData } from './rest/document.service'
import { SettingsService } from './settings.service' import { SettingsService } from './settings.service'
const LIST_DEFAULT_DISPLAY_FIELDS: DisplayField[] = DEFAULT_DISPLAY_FIELDS.map( const LIST_DEFAULT_DISPLAY_FIELDS: DisplayField[] = DEFAULT_DISPLAY_FIELDS.map(
(f) => f.id (f) => f.id
).filter((f) => f !== DisplayField.ADDED) ).filter((f) => f !== DisplayField.ADDED)
const RESTORABLE_LIST_VIEW_STATE_KEYS: (keyof ListViewState)[] = [
'title',
'documents',
'currentPage',
'collectionSize',
'sortField',
'sortReverse',
'filterRules',
'selected',
'pageSize',
'displayMode',
'displayFields',
]
/** /**
* Captures the current state of the list view. * Captures the current state of the list view.
*/ */
@@ -80,11 +65,6 @@ export interface ListViewState {
*/ */
selected?: Set<number> selected?: Set<number>
/**
* True if the full filtered result set is selected.
*/
allSelected?: boolean
/** /**
* The page size of the list view. * The page size of the list view.
*/ */
@@ -132,32 +112,6 @@ export class DocumentListViewService {
private displayFieldsInitialized: boolean = false private displayFieldsInitialized: boolean = false
private restoreListViewState(savedState: unknown): ListViewState {
const newState = this.defaultListViewState()
if (
!savedState ||
typeof savedState !== 'object' ||
Array.isArray(savedState)
) {
return newState
}
const parsedState = savedState as Partial<
Record<keyof ListViewState, unknown>
>
const mutableState = newState as Record<keyof ListViewState, unknown>
for (const key of RESTORABLE_LIST_VIEW_STATE_KEYS) {
const value = parsedState[key]
if (value != null) {
mutableState[key] = value
}
}
return newState
}
get activeSavedViewId() { get activeSavedViewId() {
return this._activeSavedViewId return this._activeSavedViewId
} }
@@ -173,7 +127,14 @@ export class DocumentListViewService {
if (documentListViewConfigJson) { if (documentListViewConfigJson) {
try { try {
let savedState: ListViewState = JSON.parse(documentListViewConfigJson) let savedState: ListViewState = JSON.parse(documentListViewConfigJson)
let newState = this.restoreListViewState(savedState) // Remove null elements from the restored state
Object.keys(savedState).forEach((k) => {
if (savedState[k] == null) {
delete savedState[k]
}
})
// only use restored state attributes instead of defaults if they are not null
let newState = Object.assign(this.defaultListViewState(), savedState)
this.listViewStates.set(null, newState) this.listViewStates.set(null, newState)
} catch (e) { } catch (e) {
localStorage.removeItem(DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG) localStorage.removeItem(DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG)
@@ -204,20 +165,6 @@ export class DocumentListViewService {
sortReverse: true, sortReverse: true,
filterRules: [], filterRules: [],
selected: new Set<number>(), selected: new Set<number>(),
allSelected: false,
}
}
private syncSelectedToCurrentPage() {
if (!this.allSelected) {
return
}
this.selected.clear()
this.documents?.forEach((doc) => this.selected.add(doc.id))
if (!this.collectionSize) {
this.selectNone()
} }
} }
@@ -313,18 +260,27 @@ export class DocumentListViewService {
activeListViewState.sortField, activeListViewState.sortField,
activeListViewState.sortReverse, activeListViewState.sortReverse,
activeListViewState.filterRules, activeListViewState.filterRules,
{ truncate_content: true, include_selection_data: true } { truncate_content: true }
) )
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: (result) => { next: (result) => {
const resultWithSelectionData = result as DocumentResults
this.initialized = true this.initialized = true
this.isReloading = false this.isReloading = false
activeListViewState.collectionSize = result.count activeListViewState.collectionSize = result.count
activeListViewState.documents = result.results activeListViewState.documents = result.results
this.selectionData = resultWithSelectionData.selection_data ?? null
this.syncSelectedToCurrentPage() this.documentService
.getSelectionData(result.all)
.pipe(first())
.subscribe({
next: (selectionData) => {
this.selectionData = selectionData
},
error: () => {
this.selectionData = null
},
})
if (updateQueryParams && !this._activeSavedViewId) { if (updateQueryParams && !this._activeSavedViewId) {
let base = ['/documents'] let base = ['/documents']
@@ -457,20 +413,6 @@ export class DocumentListViewService {
return this.activeListViewState.selected return this.activeListViewState.selected
} }
get allSelected(): boolean {
return this.activeListViewState.allSelected ?? false
}
get selectedCount(): number {
return this.allSelected
? (this.collectionSize ?? this.selected.size)
: this.selected.size
}
get hasSelection(): boolean {
return this.allSelected || this.selected.size > 0
}
setSort(field: string, reverse: boolean) { setSort(field: string, reverse: boolean) {
this.activeListViewState.sortField = field this.activeListViewState.sortField = field
this.activeListViewState.sortReverse = reverse this.activeListViewState.sortReverse = reverse
@@ -625,16 +567,11 @@ export class DocumentListViewService {
} }
selectNone() { selectNone() {
this.activeListViewState.allSelected = false
this.selected.clear() this.selected.clear()
this.rangeSelectionAnchorIndex = this.lastRangeSelectionToIndex = null this.rangeSelectionAnchorIndex = this.lastRangeSelectionToIndex = null
} }
reduceSelectionToFilter() { reduceSelectionToFilter() {
if (this.allSelected) {
return
}
if (this.selected.size > 0) { if (this.selected.size > 0) {
this.documentService this.documentService
.listAllFilteredIds(this.filterRules) .listAllFilteredIds(this.filterRules)
@@ -649,12 +586,12 @@ export class DocumentListViewService {
} }
selectAll() { selectAll() {
this.activeListViewState.allSelected = true this.documentService
this.syncSelectedToCurrentPage() .listAllFilteredIds(this.filterRules)
.subscribe((ids) => ids.forEach((id) => this.selected.add(id)))
} }
selectPage() { selectPage() {
this.activeListViewState.allSelected = false
this.selected.clear() this.selected.clear()
this.documents.forEach((doc) => { this.documents.forEach((doc) => {
this.selected.add(doc.id) this.selected.add(doc.id)
@@ -662,13 +599,10 @@ export class DocumentListViewService {
} }
isSelected(d: Document) { isSelected(d: Document) {
return this.allSelected || this.selected.has(d.id) return this.selected.has(d.id)
} }
toggleSelected(d: Document): void { toggleSelected(d: Document): void {
if (this.allSelected) {
this.activeListViewState.allSelected = false
}
if (this.selected.has(d.id)) this.selected.delete(d.id) if (this.selected.has(d.id)) this.selected.delete(d.id)
else this.selected.add(d.id) else this.selected.add(d.id)
this.rangeSelectionAnchorIndex = this.documentIndexInCurrentView(d.id) this.rangeSelectionAnchorIndex = this.documentIndexInCurrentView(d.id)
@@ -676,10 +610,6 @@ export class DocumentListViewService {
} }
selectRangeTo(d: Document) { selectRangeTo(d: Document) {
if (this.allSelected) {
this.activeListViewState.allSelected = false
}
if (this.rangeSelectionAnchorIndex !== null) { if (this.rangeSelectionAnchorIndex !== null) {
const documentToIndex = this.documentIndexInCurrentView(d.id) const documentToIndex = this.documentIndexInCurrentView(d.id)
const fromIndex = Math.min( const fromIndex = Math.min(

View File

@@ -96,30 +96,6 @@ export const commonAbstractNameFilterPaperlessServiceTests = (
}) })
req.flush([]) req.flush([])
}) })
test('should call appropriate api endpoint for bulk delete on all filtered objects', () => {
subscription = service
.bulk_edit_objects(
[],
BulkEditObjectOperation.Delete,
null,
null,
true,
{ name__icontains: 'hello' }
)
.subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}bulk_edit_objects/`
)
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({
object_type: endpoint,
operation: BulkEditObjectOperation.Delete,
all: true,
filters: { name__icontains: 'hello' },
})
req.flush([])
})
}) })
beforeEach(() => { beforeEach(() => {

View File

@@ -37,22 +37,13 @@ export abstract class AbstractNameFilterService<
objects: Array<number>, objects: Array<number>,
operation: BulkEditObjectOperation, operation: BulkEditObjectOperation,
permissions: { owner: number; set_permissions: PermissionsObject } = null, permissions: { owner: number; set_permissions: PermissionsObject } = null,
merge: boolean = null, merge: boolean = null
all: boolean = false,
filters: { [key: string]: any } = null
): Observable<string> { ): Observable<string> {
const params: any = { const params = {
objects,
object_type: this.resourceName, object_type: this.resourceName,
operation, operation,
} }
if (all) {
params['all'] = true
if (filters) {
params['filters'] = filters
}
} else {
params['objects'] = objects
}
if (operation === BulkEditObjectOperation.SetPermissions) { if (operation === BulkEditObjectOperation.SetPermissions) {
params['owner'] = permissions?.owner params['owner'] = permissions?.owner
params['permissions'] = permissions?.set_permissions params['permissions'] = permissions?.set_permissions

View File

@@ -198,7 +198,7 @@ describe(`DocumentService`, () => {
const content = 'both' const content = 'both'
const useFilenameFormatting = false const useFilenameFormatting = false
subscription = service subscription = service
.bulkDownload({ documents: ids }, content, useFilenameFormatting) .bulkDownload(ids, content, useFilenameFormatting)
.subscribe() .subscribe()
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/bulk_download/` `${environment.apiBaseUrl}${endpoint}/bulk_download/`
@@ -218,9 +218,7 @@ describe(`DocumentService`, () => {
add_tags: [15], add_tags: [15],
remove_tags: [6], remove_tags: [6],
} }
subscription = service subscription = service.bulkEdit(ids, method, parameters).subscribe()
.bulkEdit({ documents: ids }, method, parameters)
.subscribe()
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/bulk_edit/` `${environment.apiBaseUrl}${endpoint}/bulk_edit/`
) )
@@ -232,111 +230,6 @@ describe(`DocumentService`, () => {
}) })
}) })
it('should call appropriate api endpoint for bulk edit with all and filters', () => {
const method = 'modify_tags'
const parameters = {
add_tags: [15],
remove_tags: [6],
}
const selection = {
all: true,
filters: { title__icontains: 'apple' },
}
subscription = service.bulkEdit(selection, method, parameters).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/bulk_edit/`
)
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({
all: true,
filters: { title__icontains: 'apple' },
method,
parameters,
})
})
it('should call appropriate api endpoint for delete documents', () => {
const ids = [1, 2, 3]
subscription = service.deleteDocuments({ documents: ids }).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/delete/`
)
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({
documents: ids,
})
})
it('should call appropriate api endpoint for reprocess documents', () => {
const ids = [1, 2, 3]
subscription = service.reprocessDocuments({ documents: ids }).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/reprocess/`
)
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({
documents: ids,
})
})
it('should call appropriate api endpoint for rotate documents', () => {
const ids = [1, 2, 3]
subscription = service.rotateDocuments({ documents: ids }, 90).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/rotate/`
)
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({
documents: ids,
degrees: 90,
source_mode: 'latest_version',
})
})
it('should call appropriate api endpoint for merge documents', () => {
const ids = [1, 2, 3]
const args = { metadata_document_id: 1, delete_originals: true }
subscription = service.mergeDocuments(ids, args).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/merge/`
)
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({
documents: ids,
metadata_document_id: 1,
delete_originals: true,
})
})
it('should call appropriate api endpoint for edit pdf', () => {
const ids = [1]
const args = { operations: [{ page: 1, rotate: 90, doc: 0 }] }
subscription = service.editPdfDocuments(ids, args).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/edit_pdf/`
)
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({
documents: ids,
operations: [{ page: 1, rotate: 90, doc: 0 }],
})
})
it('should call appropriate api endpoint for remove password', () => {
const ids = [1]
const args = { password: 'secret', update_document: true }
subscription = service.removePasswordDocuments(ids, args).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/remove_password/`
)
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({
documents: ids,
password: 'secret',
update_document: true,
})
})
it('should return the correct preview URL for a single document', () => { it('should return the correct preview URL for a single document', () => {
let url = service.getPreviewUrl(documents[0].id) let url = service.getPreviewUrl(documents[0].id)
expect(url).toEqual( expect(url).toEqual(

View File

@@ -12,7 +12,7 @@ import {
import { DocumentMetadata } from 'src/app/data/document-metadata' import { DocumentMetadata } from 'src/app/data/document-metadata'
import { DocumentSuggestions } from 'src/app/data/document-suggestions' import { DocumentSuggestions } from 'src/app/data/document-suggestions'
import { FilterRule } from 'src/app/data/filter-rule' import { FilterRule } from 'src/app/data/filter-rule'
import { Results, SelectionData } from 'src/app/data/results' import { Results } from 'src/app/data/results'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { queryParamsFromFilterRules } from '../../utils/query-params' import { queryParamsFromFilterRules } from '../../utils/query-params'
import { import {
@@ -24,56 +24,24 @@ import { SettingsService } from '../settings.service'
import { AbstractPaperlessService } from './abstract-paperless-service' import { AbstractPaperlessService } from './abstract-paperless-service'
import { CustomFieldsService } from './custom-fields.service' import { CustomFieldsService } from './custom-fields.service'
export interface SelectionDataItem {
id: number
document_count: number
}
export interface SelectionData {
selected_storage_paths: SelectionDataItem[]
selected_correspondents: SelectionDataItem[]
selected_tags: SelectionDataItem[]
selected_document_types: SelectionDataItem[]
selected_custom_fields: SelectionDataItem[]
}
export enum BulkEditSourceMode { export enum BulkEditSourceMode {
LATEST_VERSION = 'latest_version', LATEST_VERSION = 'latest_version',
EXPLICIT_SELECTION = 'explicit_selection', EXPLICIT_SELECTION = 'explicit_selection',
} }
export type DocumentBulkEditMethod =
| 'set_correspondent'
| 'set_document_type'
| 'set_storage_path'
| 'add_tag'
| 'remove_tag'
| 'modify_tags'
| 'modify_custom_fields'
| 'set_permissions'
export interface MergeDocumentsRequest {
metadata_document_id?: number
delete_originals?: boolean
archive_fallback?: boolean
source_mode?: BulkEditSourceMode
}
export interface EditPdfOperation {
page: number
rotate?: number
doc?: number
}
export interface EditPdfDocumentsRequest {
operations: EditPdfOperation[]
delete_original?: boolean
update_document?: boolean
include_metadata?: boolean
source_mode?: BulkEditSourceMode
}
export interface RemovePasswordDocumentsRequest {
password: string
update_document?: boolean
delete_original?: boolean
include_metadata?: boolean
source_mode?: BulkEditSourceMode
}
export interface DocumentSelectionQuery {
documents?: number[]
all?: boolean
filters?: { [key: string]: any }
}
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
@@ -331,66 +299,14 @@ export class DocumentService extends AbstractPaperlessService<Document> {
return this.http.get<DocumentMetadata>(url.toString()) return this.http.get<DocumentMetadata>(url.toString())
} }
bulkEdit( bulkEdit(ids: number[], method: string, args: any) {
selection: DocumentSelectionQuery,
method: DocumentBulkEditMethod,
args: any
) {
return this.http.post(this.getResourceUrl(null, 'bulk_edit'), { return this.http.post(this.getResourceUrl(null, 'bulk_edit'), {
...selection, documents: ids,
method: method, method: method,
parameters: args, parameters: args,
}) })
} }
deleteDocuments(selection: DocumentSelectionQuery) {
return this.http.post(this.getResourceUrl(null, 'delete'), {
...selection,
})
}
reprocessDocuments(selection: DocumentSelectionQuery) {
return this.http.post(this.getResourceUrl(null, 'reprocess'), {
...selection,
})
}
rotateDocuments(
selection: DocumentSelectionQuery,
degrees: number,
sourceMode: BulkEditSourceMode = BulkEditSourceMode.LATEST_VERSION
) {
return this.http.post(this.getResourceUrl(null, 'rotate'), {
...selection,
degrees,
source_mode: sourceMode,
})
}
mergeDocuments(ids: number[], request: MergeDocumentsRequest = {}) {
return this.http.post(this.getResourceUrl(null, 'merge'), {
documents: ids,
...request,
})
}
editPdfDocuments(ids: number[], request: EditPdfDocumentsRequest) {
return this.http.post(this.getResourceUrl(null, 'edit_pdf'), {
documents: ids,
...request,
})
}
removePasswordDocuments(
ids: number[],
request: RemovePasswordDocumentsRequest
) {
return this.http.post(this.getResourceUrl(null, 'remove_password'), {
documents: ids,
...request,
})
}
getSelectionData(ids: number[]): Observable<SelectionData> { getSelectionData(ids: number[]): Observable<SelectionData> {
return this.http.post<SelectionData>( return this.http.post<SelectionData>(
this.getResourceUrl(null, 'selection_data'), this.getResourceUrl(null, 'selection_data'),
@@ -409,14 +325,14 @@ export class DocumentService extends AbstractPaperlessService<Document> {
} }
bulkDownload( bulkDownload(
selection: DocumentSelectionQuery, ids: number[],
content = 'both', content = 'both',
useFilenameFormatting: boolean = false useFilenameFormatting: boolean = false
) { ) {
return this.http.post( return this.http.post(
this.getResourceUrl(null, 'bulk_download'), this.getResourceUrl(null, 'bulk_download'),
{ {
...selection, documents: ids,
content: content, content: content,
follow_formatting: useFilenameFormatting, follow_formatting: useFilenameFormatting,
}, },

View File

@@ -166,23 +166,6 @@ describe('SettingsService', () => {
expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('#9fbf2f') expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('#9fbf2f')
}) })
it('ignores unsafe top-level keys from loaded settings', () => {
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}ui_settings/`
)
const payload = JSON.parse(
JSON.stringify(ui_settings).replace(
'"settings":{',
'"settings":{"__proto__":{"polluted":"yes"},'
)
)
payload.settings.app_title = 'Safe Title'
req.flush(payload)
expect(settingsService.get(SETTINGS_KEYS.APP_TITLE)).toEqual('Safe Title')
expect(({} as any).polluted).toBeUndefined()
})
it('correctly allows updating settings of various types', () => { it('correctly allows updating settings of various types', () => {
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}ui_settings/` `${environment.apiBaseUrl}ui_settings/`

View File

@@ -276,8 +276,6 @@ const ISO_LANGUAGE_OPTION: LanguageOption = {
dateInputFormat: 'yyyy-mm-dd', dateInputFormat: 'yyyy-mm-dd',
} }
const UNSAFE_OBJECT_KEYS = new Set(['__proto__', 'prototype', 'constructor'])
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
@@ -293,7 +291,7 @@ export class SettingsService {
protected baseUrl: string = environment.apiBaseUrl + 'ui_settings/' protected baseUrl: string = environment.apiBaseUrl + 'ui_settings/'
private settings: Record<string, any> = {} private settings: Object = {}
currentUser: User currentUser: User
public settingsSaved: EventEmitter<any> = new EventEmitter() public settingsSaved: EventEmitter<any> = new EventEmitter()
@@ -322,21 +320,6 @@ export class SettingsService {
this._renderer = rendererFactory.createRenderer(null, null) this._renderer = rendererFactory.createRenderer(null, null)
} }
private isSafeObjectKey(key: string): boolean {
return !UNSAFE_OBJECT_KEYS.has(key)
}
private assignSafeSettings(source: Record<string, any>) {
if (!source || typeof source !== 'object' || Array.isArray(source)) {
return
}
for (const key of Object.keys(source)) {
if (!this.isSafeObjectKey(key)) continue
this.settings[key] = source[key]
}
}
// this is called by the app initializer in app.module // this is called by the app initializer in app.module
public initializeSettings(): Observable<UiSettings> { public initializeSettings(): Observable<UiSettings> {
return this.http.get<UiSettings>(this.baseUrl).pipe( return this.http.get<UiSettings>(this.baseUrl).pipe(
@@ -355,7 +338,7 @@ export class SettingsService {
}) })
}), }),
tap((uisettings) => { tap((uisettings) => {
this.assignSafeSettings(uisettings.settings) Object.assign(this.settings, uisettings.settings)
if (this.get(SETTINGS_KEYS.APP_TITLE)?.length) { if (this.get(SETTINGS_KEYS.APP_TITLE)?.length) {
environment.appTitle = this.get(SETTINGS_KEYS.APP_TITLE) environment.appTitle = this.get(SETTINGS_KEYS.APP_TITLE)
} }
@@ -550,11 +533,7 @@ export class SettingsService {
let settingObj = this.settings let settingObj = this.settings
keys.forEach((keyPart, index) => { keys.forEach((keyPart, index) => {
keyPart = keyPart.replace(/-/g, '_') keyPart = keyPart.replace(/-/g, '_')
if ( if (!settingObj.hasOwnProperty(keyPart)) return
!this.isSafeObjectKey(keyPart) ||
!Object.prototype.hasOwnProperty.call(settingObj, keyPart)
)
return
if (index == keys.length - 1) value = settingObj[keyPart] if (index == keys.length - 1) value = settingObj[keyPart]
else settingObj = settingObj[keyPart] else settingObj = settingObj[keyPart]
}) })
@@ -600,9 +579,7 @@ export class SettingsService {
const keys = key.replace('general-settings:', '').split(':') const keys = key.replace('general-settings:', '').split(':')
keys.forEach((keyPart, index) => { keys.forEach((keyPart, index) => {
keyPart = keyPart.replace(/-/g, '_') keyPart = keyPart.replace(/-/g, '_')
if (!this.isSafeObjectKey(keyPart)) return if (!settingObj.hasOwnProperty(keyPart)) settingObj[keyPart] = {}
if (!Object.prototype.hasOwnProperty.call(settingObj, keyPart))
settingObj[keyPart] = {}
if (index == keys.length - 1) settingObj[keyPart] = value if (index == keys.length - 1) settingObj[keyPart] = value
else settingObj = settingObj[keyPart] else settingObj = settingObj[keyPart]
}) })
@@ -625,10 +602,7 @@ export class SettingsService {
maybeMigrateSettings() { maybeMigrateSettings() {
if ( if (
!Object.prototype.hasOwnProperty.call( !this.settings.hasOwnProperty('documentListSize') &&
this.settings,
'documentListSize'
) &&
localStorage.getItem(SETTINGS_KEYS.DOCUMENT_LIST_SIZE) localStorage.getItem(SETTINGS_KEYS.DOCUMENT_LIST_SIZE)
) { ) {
// lets migrate // lets migrate
@@ -636,7 +610,8 @@ export class SettingsService {
const errorMessage = $localize`Unable to migrate settings to the database, please try saving manually.` const errorMessage = $localize`Unable to migrate settings to the database, please try saving manually.`
try { try {
for (const key of Object.values(SETTINGS_KEYS)) { for (const setting in SETTINGS_KEYS) {
const key = SETTINGS_KEYS[setting]
const value = localStorage.getItem(key) const value = localStorage.getItem(key)
this.set(key, value) this.set(key, value)
} }

View File

@@ -62,7 +62,7 @@ export function hslToRgb(h, s, l) {
* @return Array The HSL representation * @return Array The HSL representation
*/ */
export function rgbToHsl(r, g, b) { export function rgbToHsl(r, g, b) {
;((r /= 255), (g /= 255), (b /= 255)) ;(r /= 255), (g /= 255), (b /= 255)
var max = Math.max(r, g, b), var max = Math.max(r, g, b),
min = Math.min(r, g, b) min = Math.min(r, g, b)
var h, var h,

View File

@@ -6,7 +6,7 @@ export const environment = {
apiVersion: '10', // match src/paperless/settings.py apiVersion: '10', // match src/paperless/settings.py
appTitle: 'Paperless-ngx', appTitle: 'Paperless-ngx',
tag: 'prod', tag: 'prod',
version: '2.20.13', version: '2.20.10',
webSocketHost: window.location.host, webSocketHost: window.location.host,
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:', webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
webSocketBaseUrl: base_url.pathname + 'ws/', webSocketBaseUrl: base_url.pathname + 'ws/',

View File

@@ -5,7 +5,7 @@
export const environment = { export const environment = {
production: false, production: false,
apiBaseUrl: 'http://localhost:8000/api/', apiBaseUrl: 'http://localhost:8000/api/',
apiVersion: '10', apiVersion: '9',
appTitle: 'Paperless-ngx', appTitle: 'Paperless-ngx',
tag: 'dev', tag: 'dev',
version: 'DEVELOPMENT', version: 'DEVELOPMENT',

View File

@@ -154,7 +154,6 @@ import { DirtyDocGuard } from './app/guards/dirty-doc.guard'
import { DirtySavedViewGuard } from './app/guards/dirty-saved-view.guard' import { DirtySavedViewGuard } from './app/guards/dirty-saved-view.guard'
import { PermissionsGuard } from './app/guards/permissions.guard' import { PermissionsGuard } from './app/guards/permissions.guard'
import { withApiVersionInterceptor } from './app/interceptors/api-version.interceptor' import { withApiVersionInterceptor } from './app/interceptors/api-version.interceptor'
import { withAuthExpiryInterceptor } from './app/interceptors/auth-expiry.interceptor'
import { withCsrfInterceptor } from './app/interceptors/csrf.interceptor' import { withCsrfInterceptor } from './app/interceptors/csrf.interceptor'
import { DocumentTitlePipe } from './app/pipes/document-title.pipe' import { DocumentTitlePipe } from './app/pipes/document-title.pipe'
import { FilterPipe } from './app/pipes/filter.pipe' import { FilterPipe } from './app/pipes/filter.pipe'
@@ -400,11 +399,7 @@ bootstrapApplication(AppComponent, {
StoragePathNamePipe, StoragePathNamePipe,
provideHttpClient( provideHttpClient(
withInterceptorsFromDi(), withInterceptorsFromDi(),
withInterceptors([ withInterceptors([withCsrfInterceptor, withApiVersionInterceptor]),
withCsrfInterceptor,
withApiVersionInterceptor,
withAuthExpiryInterceptor,
]),
withFetch() withFetch()
), ),
provideUiTour({ provideUiTour({

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