mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-03-13 04:31:23 +00:00
Compare commits
69 Commits
chore/upda
...
feature-dr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17c628b686 | ||
|
|
f15394fa5c | ||
|
|
773eb25f7d | ||
|
|
e2947ccff2 | ||
|
|
61841a767b | ||
|
|
15db023caa | ||
|
|
45b363659e | ||
|
|
7494161c95 | ||
|
|
5331312699 | ||
|
|
b5a002b8ed | ||
|
|
dd8573242d | ||
|
|
86fa74c115 | ||
|
|
b7b9e83f37 | ||
|
|
217b5df591 | ||
|
|
3efc9a5733 | ||
|
|
e19f341974 | ||
|
|
2b4ea570ef | ||
|
|
86573fc1a0 | ||
|
|
3856ec19c0 | ||
|
|
1221e7f21c | ||
|
|
3e32e90355 | ||
|
|
63cb75564e | ||
|
|
6955d6c07f | ||
|
|
d85ee29976 | ||
|
|
0c7d56c5e7 | ||
|
|
0bcf904e3a | ||
|
|
bcc2f11152 | ||
|
|
e18b1fd99d | ||
|
|
e30676f889 | ||
|
|
2a28549c5a | ||
|
|
4badf0e7c2 | ||
|
|
bc26d94593 | ||
|
|
93cbbf34b7 | ||
|
|
1e8622494d | ||
|
|
0c3298f030 | ||
|
|
2b288c094d | ||
|
|
2cdb1424ef | ||
|
|
f5c0c21922 | ||
|
|
91ddda9256 | ||
|
|
9d5e618de8 | ||
|
|
50ae49c7da | ||
|
|
ba023ef332 | ||
|
|
7345f2e81c | ||
|
|
731448a8f9 | ||
|
|
1c2d5483c2 | ||
|
|
815e598218 | ||
|
|
a5a267fe49 | ||
|
|
24a2cfd957 | ||
|
|
7cf2ef6398 | ||
|
|
df03207eef | ||
|
|
fa998ecd49 | ||
|
|
1e21bcd26e | ||
|
|
a9cb89c633 | ||
|
|
a37e24c1ad | ||
|
|
85a18e5911 | ||
|
|
ae182c459b | ||
|
|
d51a118aac | ||
|
|
d6a316b1df | ||
|
|
8f311c4b6b | ||
|
|
f25322600d | ||
|
|
615f27e6fb | ||
|
|
190fc70288 | ||
|
|
5b809122b5 | ||
|
|
c623234769 | ||
|
|
299dac21ee | ||
|
|
5498503d60 | ||
|
|
8b8307571a | ||
|
|
16b58c2de5 | ||
|
|
c724fbb5d9 |
18
.codecov.yml
18
.codecov.yml
@@ -14,10 +14,6 @@ component_management:
|
||||
# https://docs.codecov.com/docs/carryforward-flags
|
||||
flags:
|
||||
# Backend Python versions
|
||||
backend-python-3.10:
|
||||
paths:
|
||||
- src/**
|
||||
carryforward: true
|
||||
backend-python-3.11:
|
||||
paths:
|
||||
- src/**
|
||||
@@ -26,6 +22,14 @@ flags:
|
||||
paths:
|
||||
- src/**
|
||||
carryforward: true
|
||||
backend-python-3.13:
|
||||
paths:
|
||||
- src/**
|
||||
carryforward: true
|
||||
backend-python-3.14:
|
||||
paths:
|
||||
- src/**
|
||||
carryforward: true
|
||||
# Frontend (shards merge into single flag)
|
||||
frontend-node-24.x:
|
||||
paths:
|
||||
@@ -41,9 +45,10 @@ coverage:
|
||||
project:
|
||||
backend:
|
||||
flags:
|
||||
- backend-python-3.10
|
||||
- backend-python-3.11
|
||||
- backend-python-3.12
|
||||
- backend-python-3.13
|
||||
- backend-python-3.14
|
||||
paths:
|
||||
- src/**
|
||||
# https://docs.codecov.com/docs/commit-status#threshold
|
||||
@@ -59,9 +64,10 @@ coverage:
|
||||
patch:
|
||||
backend:
|
||||
flags:
|
||||
- backend-python-3.10
|
||||
- backend-python-3.11
|
||||
- backend-python-3.12
|
||||
- backend-python-3.13
|
||||
- backend-python-3.14
|
||||
paths:
|
||||
- src/**
|
||||
target: 100%
|
||||
|
||||
24
.github/dependabot.yml
vendored
24
.github/dependabot.yml
vendored
@@ -12,6 +12,8 @@ updates:
|
||||
open-pull-requests-limit: 10
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
labels:
|
||||
- "frontend"
|
||||
- "dependencies"
|
||||
@@ -36,7 +38,9 @@ updates:
|
||||
directory: "/"
|
||||
# Check for updates once a week
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
interval: "monthly"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
labels:
|
||||
- "backend"
|
||||
- "dependencies"
|
||||
@@ -97,6 +101,8 @@ updates:
|
||||
schedule:
|
||||
# Check for updates to GitHub Actions every month
|
||||
interval: "monthly"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
labels:
|
||||
- "ci-cd"
|
||||
- "dependencies"
|
||||
@@ -112,7 +118,9 @@ updates:
|
||||
- "/"
|
||||
- "/.devcontainer/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
interval: "monthly"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
@@ -123,7 +131,9 @@ updates:
|
||||
- package-ecosystem: "docker-compose"
|
||||
directory: "/docker/compose/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
interval: "monthly"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
@@ -147,3 +157,11 @@ updates:
|
||||
postgres:
|
||||
patterns:
|
||||
- "docker.io/library/postgres*"
|
||||
- package-ecosystem: "pre-commit" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
groups:
|
||||
pre-commit-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
88
.github/workflows/ci-backend.yml
vendored
88
.github/workflows/ci-backend.yml
vendored
@@ -3,21 +3,9 @@ on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- 'translations**'
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
- 'docker/compose/docker-compose.ci-test.yml'
|
||||
- '.github/workflows/ci-backend.yml'
|
||||
pull_request:
|
||||
branches-ignore:
|
||||
- 'translations**'
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
- 'docker/compose/docker-compose.ci-test.yml'
|
||||
- '.github/workflows/ci-backend.yml'
|
||||
workflow_dispatch:
|
||||
concurrency:
|
||||
group: backend-${{ github.event.pull_request.number || github.ref }}
|
||||
@@ -26,12 +14,60 @@ env:
|
||||
DEFAULT_UV_VERSION: "0.10.x"
|
||||
NLTK_DATA: "/usr/share/nltk_data"
|
||||
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@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@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:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.backend_changed == 'true'
|
||||
name: "Python ${{ matrix.python-version }}"
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.10', '3.11', '3.12']
|
||||
python-version: ['3.11', '3.12', '3.13', '3.14']
|
||||
fail-fast: false
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -100,6 +136,8 @@ jobs:
|
||||
docker compose --file docker/compose/docker-compose.ci-test.yml logs
|
||||
docker compose --file docker/compose/docker-compose.ci-test.yml down
|
||||
typing:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.backend_changed == 'true'
|
||||
name: Check project typing
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
@@ -150,3 +188,27 @@ jobs:
|
||||
--show-error-codes \
|
||||
--warn-unused-configs \
|
||||
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."
|
||||
|
||||
24
.github/workflows/ci-docker.yml
vendored
24
.github/workflows/ci-docker.yml
vendored
@@ -104,9 +104,9 @@ jobs:
|
||||
echo "repository=${repo_name}"
|
||||
echo "name=${repo_name}" >> $GITHUB_OUTPUT
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.12.0
|
||||
uses: docker/setup-buildx-action@v4.0.0
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3.7.0
|
||||
uses: docker/login-action@v4.0.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -149,15 +149,16 @@ jobs:
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
echo "digest=${digest}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
echo "${digest}" > "/tmp/digests/digest-${{ matrix.arch }}.txt"
|
||||
- name: Upload digest
|
||||
if: steps.check-push.outputs.should-push == 'true'
|
||||
uses: actions/upload-artifact@v7.0.0
|
||||
with:
|
||||
name: digests-${{ matrix.arch }}
|
||||
path: /tmp/digests/*
|
||||
path: /tmp/digests/digest-${{ matrix.arch }}.txt
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
archive: false
|
||||
merge-and-push:
|
||||
name: Merge and Push Manifest
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -171,29 +172,29 @@ jobs:
|
||||
uses: actions/download-artifact@v8.0.0
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
pattern: digest-*.txt
|
||||
merge-multiple: true
|
||||
- name: List digests
|
||||
run: |
|
||||
echo "Downloaded digests:"
|
||||
ls -la /tmp/digests/
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.12.0
|
||||
uses: docker/setup-buildx-action@v4.0.0
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3.7.0
|
||||
uses: docker/login-action@v4.0.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Login to Docker Hub
|
||||
if: needs.build-arch.outputs.push-external == 'true'
|
||||
uses: docker/login-action@v3.7.0
|
||||
uses: docker/login-action@v4.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Login to Quay.io
|
||||
if: needs.build-arch.outputs.push-external == 'true'
|
||||
uses: docker/login-action@v3.7.0
|
||||
uses: docker/login-action@v4.0.0
|
||||
with:
|
||||
registry: quay.io
|
||||
username: ${{ secrets.QUAY_USERNAME }}
|
||||
@@ -217,8 +218,9 @@ jobs:
|
||||
tags=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "${DOCKER_METADATA_OUTPUT_JSON}")
|
||||
|
||||
digests=""
|
||||
for digest in *; do
|
||||
digests+="${{ env.REGISTRY }}/${REPOSITORY}@sha256:${digest} "
|
||||
for digest_file in digest-*.txt; do
|
||||
digest=$(cat "${digest_file}")
|
||||
digests+="${{ env.REGISTRY }}/${REPOSITORY}@${digest} "
|
||||
done
|
||||
|
||||
echo "Creating manifest with tags: ${tags}"
|
||||
|
||||
88
.github/workflows/ci-docs.yml
vendored
88
.github/workflows/ci-docs.yml
vendored
@@ -1,22 +1,9 @@
|
||||
name: Documentation
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'zensical.toml'
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
- '.github/workflows/ci-docs.yml'
|
||||
branches-ignore:
|
||||
- 'translations**'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'zensical.toml'
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
- '.github/workflows/ci-docs.yml'
|
||||
workflow_dispatch:
|
||||
concurrency:
|
||||
group: docs-${{ github.event.pull_request.number || github.ref }}
|
||||
@@ -29,7 +16,55 @@ env:
|
||||
DEFAULT_UV_VERSION: "0.10.x"
|
||||
DEFAULT_PYTHON_VERSION: "3.12"
|
||||
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@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@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:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.docs_changed == 'true'
|
||||
name: Build Documentation
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
@@ -64,8 +99,8 @@ jobs:
|
||||
name: github-pages-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
deploy:
|
||||
name: Deploy Documentation
|
||||
needs: build
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
needs: [changes, build]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changes.outputs.docs_changed == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
environment:
|
||||
name: github-pages
|
||||
@@ -76,3 +111,22 @@ jobs:
|
||||
id: deployment
|
||||
with:
|
||||
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."
|
||||
|
||||
114
.github/workflows/ci-frontend.yml
vendored
114
.github/workflows/ci-frontend.yml
vendored
@@ -3,21 +3,60 @@ on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- 'translations**'
|
||||
paths:
|
||||
- 'src-ui/**'
|
||||
- '.github/workflows/ci-frontend.yml'
|
||||
pull_request:
|
||||
branches-ignore:
|
||||
- 'translations**'
|
||||
paths:
|
||||
- 'src-ui/**'
|
||||
- '.github/workflows/ci-frontend.yml'
|
||||
workflow_dispatch:
|
||||
concurrency:
|
||||
group: frontend-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
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@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@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:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend_changed == 'true'
|
||||
name: Install Dependencies
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
@@ -28,7 +67,7 @@ jobs:
|
||||
with:
|
||||
version: 10
|
||||
- name: Use Node.js 24
|
||||
uses: actions/setup-node@v6.2.0
|
||||
uses: actions/setup-node@v6.3.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
cache: 'pnpm'
|
||||
@@ -45,7 +84,8 @@ jobs:
|
||||
run: cd src-ui && pnpm install
|
||||
lint:
|
||||
name: Lint
|
||||
needs: install-dependencies
|
||||
needs: [changes, install-dependencies]
|
||||
if: needs.changes.outputs.frontend_changed == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -55,7 +95,7 @@ jobs:
|
||||
with:
|
||||
version: 10
|
||||
- name: Use Node.js 24
|
||||
uses: actions/setup-node@v6.2.0
|
||||
uses: actions/setup-node@v6.3.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
cache: 'pnpm'
|
||||
@@ -73,7 +113,8 @@ jobs:
|
||||
run: cd src-ui && pnpm run lint
|
||||
unit-tests:
|
||||
name: "Unit Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})"
|
||||
needs: install-dependencies
|
||||
needs: [changes, install-dependencies]
|
||||
if: needs.changes.outputs.frontend_changed == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -89,7 +130,7 @@ jobs:
|
||||
with:
|
||||
version: 10
|
||||
- name: Use Node.js 24
|
||||
uses: actions/setup-node@v6.2.0
|
||||
uses: actions/setup-node@v6.3.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
cache: 'pnpm'
|
||||
@@ -119,7 +160,8 @@ jobs:
|
||||
directory: src-ui/coverage/
|
||||
e2e-tests:
|
||||
name: "E2E Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})"
|
||||
needs: install-dependencies
|
||||
needs: [changes, install-dependencies]
|
||||
if: needs.changes.outputs.frontend_changed == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
container: mcr.microsoft.com/playwright:v1.58.2-noble
|
||||
env:
|
||||
@@ -139,7 +181,7 @@ jobs:
|
||||
with:
|
||||
version: 10
|
||||
- name: Use Node.js 24
|
||||
uses: actions/setup-node@v6.2.0
|
||||
uses: actions/setup-node@v6.3.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
cache: 'pnpm'
|
||||
@@ -159,17 +201,20 @@ jobs:
|
||||
run: cd src-ui && pnpm exec playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
||||
bundle-analysis:
|
||||
name: Bundle Analysis
|
||||
needs: [unit-tests, e2e-tests]
|
||||
needs: [changes, unit-tests, e2e-tests]
|
||||
if: needs.changes.outputs.frontend_changed == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
- name: Use Node.js 24
|
||||
uses: actions/setup-node@v6.2.0
|
||||
uses: actions/setup-node@v6.3.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
cache: 'pnpm'
|
||||
@@ -187,3 +232,42 @@ jobs:
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
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."
|
||||
|
||||
2
.github/workflows/ci-release.yml
vendored
2
.github/workflows/ci-release.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
with:
|
||||
version: 10
|
||||
- name: Use Node.js 24
|
||||
uses: actions/setup-node@v6.2.0
|
||||
uses: actions/setup-node@v6.3.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
cache: 'pnpm'
|
||||
|
||||
17
.github/workflows/pr-bot.yml
vendored
17
.github/workflows/pr-bot.yml
vendored
@@ -2,13 +2,24 @@ name: PR Bot
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
jobs:
|
||||
anti-slop:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: peakoss/anti-slop@v0.2.1
|
||||
with:
|
||||
max-failures: 4
|
||||
failure-add-pr-labels: 'ai'
|
||||
pr-bot:
|
||||
name: Automated PR Bot
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Label PR by file path or branch name
|
||||
# see .github/labeler.yml for the labeler config
|
||||
|
||||
2
.github/workflows/translate-strings.yml
vendored
2
.github/workflows/translate-strings.yml
vendored
@@ -40,7 +40,7 @@ jobs:
|
||||
with:
|
||||
version: 10
|
||||
- name: Use Node.js 24
|
||||
uses: actions/setup-node@v6.2.0
|
||||
uses: actions/setup-node@v6.3.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
cache: 'pnpm'
|
||||
|
||||
@@ -264,7 +264,6 @@ src/documents/management/commands/document_exporter.py:0: error: Function is mis
|
||||
src/documents/management/commands/document_exporter.py:0: error: Incompatible types in assignment (expression has type "str", variable has type "Path") [assignment]
|
||||
src/documents/management/commands/document_exporter.py:0: error: Invalid index type "str" for "str"; expected type "SupportsIndex | slice[Any, Any, Any]" [index]
|
||||
src/documents/management/commands/document_exporter.py:0: error: Invalid index type "str" for "str"; expected type "SupportsIndex | slice[Any, Any, Any]" [index]
|
||||
src/documents/management/commands/document_exporter.py:0: error: Library stubs not installed for "tqdm" [import-untyped]
|
||||
src/documents/management/commands/document_exporter.py:0: error: Missing type parameters for generic type "QuerySet" [type-arg]
|
||||
src/documents/management/commands/document_exporter.py:0: error: Missing type parameters for generic type "dict" [type-arg]
|
||||
src/documents/management/commands/document_exporter.py:0: error: Missing type parameters for generic type "dict" [type-arg]
|
||||
@@ -283,18 +282,23 @@ src/documents/management/commands/document_importer.py:0: error: Function is mis
|
||||
src/documents/management/commands/document_importer.py:0: error: Incompatible types in assignment (expression has type "str | None", base class "CryptMixin" defined the type as "str") [assignment]
|
||||
src/documents/management/commands/document_importer.py:0: error: Invalid index type "str" for "str"; expected type "SupportsIndex | slice[Any, Any, Any]" [index]
|
||||
src/documents/management/commands/document_importer.py:0: error: Invalid index type "str" for "str"; expected type "SupportsIndex | slice[Any, Any, Any]" [index]
|
||||
src/documents/management/commands/document_importer.py:0: error: Library stubs not installed for "tqdm" [import-untyped]
|
||||
src/documents/management/commands/document_importer.py:0: error: Missing type parameters for generic type "Generator" [type-arg]
|
||||
src/documents/management/commands/document_importer.py:0: error: Missing type parameters for generic type "dict" [type-arg]
|
||||
src/documents/management/commands/document_importer.py:0: error: Need type annotation for "manifest_paths" (hint: "manifest_paths: list[<type>] = ...") [var-annotated]
|
||||
src/documents/management/commands/document_importer.py:0: error: Skipping analyzing "auditlog.registry": module is installed, but missing library stubs or py.typed marker [import-untyped]
|
||||
src/documents/management/commands/document_index.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/management/commands/document_index.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/management/commands/document_llmindex.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/management/commands/document_llmindex.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/management/commands/document_renamer.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/management/commands/document_retagger.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/management/commands/document_retagger.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/management/commands/document_renamer.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/management/commands/document_retagger.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/management/commands/document_retagger.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/management/commands/document_sanity_checker.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/management/commands/document_sanity_checker.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/management/commands/document_thumbnails.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/management/commands/document_thumbnails.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/management/commands/document_thumbnails.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/management/commands/loaddata_stdin.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/management/commands/loaddata_stdin.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/management/commands/loaddata_stdin.py:0: error: Incompatible types in assignment (expression has type "tuple[Callable[[Any, Any], TextIO | Any], None]", target has type "tuple[Callable[[str, Literal['r', 'rb']], BufferedReader[_BufferedReaderStream]]]") [assignment]
|
||||
@@ -304,8 +308,11 @@ src/documents/management/commands/mixins.py:0: error: Attribute "kdf_algorithm"
|
||||
src/documents/management/commands/mixins.py:0: error: Attribute "key_iterations" already defined on line 0 [no-redef]
|
||||
src/documents/management/commands/mixins.py:0: error: Attribute "key_size" already defined on line 0 [no-redef]
|
||||
src/documents/management/commands/mixins.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/management/commands/mixins.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/management/commands/mixins.py:0: error: Incompatible types in assignment (expression has type "list[dict[str, Sequence[str]]]", variable has type "CryptFields") [assignment]
|
||||
src/documents/management/commands/mixins.py:0: error: Missing type parameters for generic type "dict" [type-arg]
|
||||
src/documents/management/commands/mixins.py:0: error: Unsupported operand types for // ("None" and "int") [operator]
|
||||
src/documents/management/commands/prune_audit_logs.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/management/commands/prune_audit_logs.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/management/commands/prune_audit_logs.py:0: error: Skipping analyzing "auditlog.models": module is installed, but missing library stubs or py.typed marker [import-untyped]
|
||||
src/documents/matching.py:0: error: Argument 1 to "existing_document_matches_workflow" has incompatible type "ConsumableDocument | Document"; expected "Document" [arg-type]
|
||||
@@ -334,6 +341,9 @@ src/documents/migrations/0001_initial.py:0: error: Skipping analyzing "multisele
|
||||
src/documents/migrations/0001_initial.py:0: error: Skipping analyzing "multiselectfield.db.fields": module is installed, but missing library stubs or py.typed marker [import-untyped]
|
||||
src/documents/migrations/0008_sharelinkbundle.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/migrations/0008_sharelinkbundle.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/migrations/0012_savedview_visibility_to_ui_settings.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/migrations/0012_savedview_visibility_to_ui_settings.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/migrations/0012_savedview_visibility_to_ui_settings.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/models.py:0: error: Argument 1 to "Path" has incompatible type "Path | None"; expected "str | PathLike[str]" [arg-type]
|
||||
src/documents/models.py:0: error: Couldn't resolve related manager 'documents' for relation 'documents.models.Document.correspondent'. [django-manager-missing]
|
||||
src/documents/models.py:0: error: Couldn't resolve related manager 'documents' for relation 'documents.models.Document.document_type'. [django-manager-missing]
|
||||
@@ -433,15 +443,23 @@ src/documents/permissions.py:0: error: Item "list[str]" of "Any | list[str] | Qu
|
||||
src/documents/permissions.py:0: error: Item "list[str]" of "Any | list[str] | QuerySet[User, User]" has no attribute "exists" [union-attr]
|
||||
src/documents/permissions.py:0: error: Missing type parameters for generic type "QuerySet" [type-arg]
|
||||
src/documents/permissions.py:0: error: Missing type parameters for generic type "dict" [type-arg]
|
||||
src/documents/plugins/helpers.py:0: error: "Collection[str]" has no attribute "update" [attr-defined]
|
||||
src/documents/plugins/helpers.py:0: error: Argument 1 to "send" of "BaseStatusManager" has incompatible type "dict[str, Collection[str]]"; expected "dict[str, str | int | None]" [arg-type]
|
||||
src/documents/plugins/helpers.py:0: error: Argument 1 to "send" of "BaseStatusManager" has incompatible type "dict[str, Collection[str]]"; expected "dict[str, str | int | None]" [arg-type]
|
||||
src/documents/plugins/helpers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/plugins/helpers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/plugins/helpers.py:0: error: Skipping analyzing "channels_redis.pubsub": module is installed, but missing library stubs or py.typed marker [import-untyped]
|
||||
src/documents/regex.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/regex.py:0: error: Library stubs not installed for "regex" [import-untyped]
|
||||
src/documents/sanity_checker.py:0: error: Argument 1 to "Path" has incompatible type "Path | None"; expected "str | PathLike[str]" [arg-type]
|
||||
src/documents/sanity_checker.py:0: error: Cannot use Final inside a loop [misc]
|
||||
src/documents/sanity_checker.py:0: error: Cannot use Final inside a loop [misc]
|
||||
src/documents/sanity_checker.py:0: error: Cannot use Final inside a loop [misc]
|
||||
src/documents/sanity_checker.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/sanity_checker.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/sanity_checker.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/sanity_checker.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/sanity_checker.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/sanity_checker.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/sanity_checker.py:0: error: Incompatible type for "task_id" of "PaperlessTask" (got "UUID", expected "str | int | Combinable") [misc]
|
||||
src/documents/sanity_checker.py:0: error: Missing type parameters for generic type "dict" [type-arg]
|
||||
src/documents/schema.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/schema.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/schema.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
@@ -533,7 +551,6 @@ src/documents/serialisers.py:0: error: Function is missing a type annotation [n
|
||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
@@ -649,7 +666,9 @@ src/documents/signals/handlers.py:0: error: Argument 2 to "match_storage_paths"
|
||||
src/documents/signals/handlers.py:0: error: Argument 2 to "match_tags" has incompatible type "DocumentClassifier | None"; expected "DocumentClassifier" [arg-type]
|
||||
src/documents/signals/handlers.py:0: error: Argument 2 to "validate_move" has incompatible type "Path | Any | None"; expected "Path" [arg-type]
|
||||
src/documents/signals/handlers.py:0: error: Argument 3 to "validate_move" has incompatible type "Path | Any | None"; expected "Path" [arg-type]
|
||||
src/documents/signals/handlers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/signals/handlers.py:0: error: Argument 5 to "_suggestion_printer" has incompatible type "Any | None"; expected "MatchingModel" [arg-type]
|
||||
src/documents/signals/handlers.py:0: error: Argument 5 to "_suggestion_printer" has incompatible type "Any | None"; expected "MatchingModel" [arg-type]
|
||||
src/documents/signals/handlers.py:0: error: Argument 5 to "_suggestion_printer" has incompatible type "Any | None"; expected "MatchingModel" [arg-type]
|
||||
src/documents/signals/handlers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/signals/handlers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/signals/handlers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
@@ -668,6 +687,12 @@ src/documents/signals/handlers.py:0: error: Function is missing a type annotatio
|
||||
src/documents/signals/handlers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/signals/handlers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/signals/handlers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/signals/handlers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/signals/handlers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/signals/handlers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/signals/handlers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/signals/handlers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/signals/handlers.py:0: error: Incompatible types in assignment (expression has type "list[Tag]", variable has type "set[Tag]") [assignment]
|
||||
src/documents/signals/handlers.py:0: error: Incompatible types in assignment (expression has type "tuple[Any, Any, Any]", variable has type "tuple[Any, Any]") [assignment]
|
||||
src/documents/signals/handlers.py:0: error: Item "ConsumableDocument" of "Document | ConsumableDocument" has no attribute "save" [union-attr]
|
||||
src/documents/signals/handlers.py:0: error: Item "ConsumableDocument" of "Document | ConsumableDocument" has no attribute "source_path" [union-attr]
|
||||
@@ -695,6 +720,7 @@ src/documents/tasks.py:0: error: Function is missing a type annotation for one o
|
||||
src/documents/tasks.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/tasks.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/tasks.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/tasks.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/tasks.py:0: error: Incompatible type for "task_id" of "PaperlessTask" (got "UUID", expected "str | int | Combinable") [misc]
|
||||
src/documents/tasks.py:0: error: Incompatible type for "task_id" of "PaperlessTask" (got "UUID", expected "str | int | Combinable") [misc]
|
||||
src/documents/tasks.py:0: error: Incompatible types in assignment (expression has type "Path", variable has type "str") [assignment]
|
||||
@@ -721,80 +747,8 @@ src/documents/templating/utils.py:0: error: Function is missing a type annotatio
|
||||
src/documents/templating/workflows.py:0: error: Incompatible return value type (got "None", expected "str") [return-value]
|
||||
src/documents/tests/conftest.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/conftest.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/conftest.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/conftest.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/conftest.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/tests/conftest.py:0: error: Incompatible return value type (got "DocumentFactory", expected "Document") [return-value]
|
||||
src/documents/tests/factories.py:0: error: Missing type parameters for generic type "DjangoModelFactory" [type-arg]
|
||||
src/documents/tests/factories.py:0: error: Missing type parameters for generic type "DjangoModelFactory" [type-arg]
|
||||
src/documents/tests/factories.py:0: error: Missing type parameters for generic type "DjangoModelFactory" [type-arg]
|
||||
src/documents/tests/factories.py:0: error: Missing type parameters for generic type "DjangoModelFactory" [type-arg]
|
||||
src/documents/tests/factories.py:0: error: Missing type parameters for generic type "DjangoModelFactory" [type-arg]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Incompatible types in assignment (expression has type "StringIO", variable has type "OutputWrapper") [assignment]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Incompatible types in assignment (expression has type "StringIO", variable has type "OutputWrapper") [assignment]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Incompatible types in assignment (expression has type "StringIO", variable has type "OutputWrapper") [assignment]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Incompatible types in assignment (expression has type "StringIO", variable has type "OutputWrapper") [assignment]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Incompatible types in assignment (expression has type "StringIO", variable has type "OutputWrapper") [assignment]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Incompatible types in assignment (expression has type "StringIO", variable has type "OutputWrapper") [assignment]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Incompatible types in assignment (expression has type "StringIO", variable has type "OutputWrapper") [assignment]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Incompatible types in assignment (expression has type "StringIO", variable has type "OutputWrapper") [assignment]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Incompatible types in assignment (expression has type "StringIO", variable has type "OutputWrapper") [assignment]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Incompatible types in assignment (expression has type "StringIO", variable has type "OutputWrapper") [assignment]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Incompatible types in assignment (expression has type "StringIO", variable has type "OutputWrapper") [assignment]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Incompatible types in assignment (expression has type "StringIO", variable has type "OutputWrapper") [assignment]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Incompatible types in assignment (expression has type "StringIO", variable has type "OutputWrapper") [assignment]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Incompatible types in assignment (expression has type "StringIO", variable has type "OutputWrapper") [assignment]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Missing type parameters for generic type "QuerySet" [type-arg]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Missing type parameters for generic type "dict" [type-arg]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Missing type parameters for generic type "dict" [type-arg]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Missing type parameters for generic type "dict" [type-arg]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Missing type parameters for generic type "dict" [type-arg]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Missing type parameters for generic type "dict" [type-arg]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Missing type parameters for generic type "dict" [type-arg]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Missing type parameters for generic type "list" [type-arg]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Need type annotation for "result" [var-annotated]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Need type annotation for "result" (hint: "result: list[<type>] = ...") [var-annotated]
|
||||
src/documents/tests/management/test_management_base_cmd.py:0: error: Need type annotation for "results" [var-annotated]
|
||||
src/documents/tests/management/test_management_sanity_checker.py:0: error: "DocumentFactory" has no attribute "source_path"; maybe "storage_path"? [attr-defined]
|
||||
src/documents/tests/management/test_management_sanity_checker.py:0: error: "DocumentFactory" has no attribute "thumbnail_path" [attr-defined]
|
||||
src/documents/tests/test_admin.py:0: error: "PaperlessUserForm" has no attribute "request" [attr-defined]
|
||||
src/documents/tests/test_admin.py:0: error: "PaperlessUserForm" has no attribute "request" [attr-defined]
|
||||
src/documents/tests/test_admin.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
@@ -822,7 +776,6 @@ src/documents/tests/test_api_app_config.py:0: error: Item "None" of "Application
|
||||
src/documents/tests/test_api_bulk_download.py:0: error: Value of type variable "_StrPathT" of "copy" cannot be "Path | None" [type-var]
|
||||
src/documents/tests/test_api_bulk_edit.py:0: error: "type[MatchingModel]" has no attribute "objects" [attr-defined]
|
||||
src/documents/tests/test_api_bulk_edit.py:0: error: "type[MatchingModel]" has no attribute "objects" [attr-defined]
|
||||
src/documents/tests/test_api_bulk_edit.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/test_api_bulk_edit.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/tests/test_api_bulk_edit.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/tests/test_api_bulk_edit.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
@@ -875,10 +828,8 @@ src/documents/tests/test_api_custom_fields.py:0: error: Value of type "Any | Non
|
||||
src/documents/tests/test_api_documents.py:0: error: "None" object is not iterable [misc]
|
||||
src/documents/tests/test_api_documents.py:0: error: "object" has no attribute "get" [attr-defined]
|
||||
src/documents/tests/test_api_documents.py:0: error: Argument 1 to "Path" has incompatible type "Path | None"; expected "str | PathLike[str]" [arg-type]
|
||||
src/documents/tests/test_api_documents.py:0: error: Argument 1 to "Path" has incompatible type "Path | None"; expected "str | PathLike[str]" [arg-type]
|
||||
src/documents/tests/test_api_documents.py:0: error: Argument 1 to "assertCountEqual" of "TestCase" has incompatible type "list[int] | None"; expected "Iterable[Any]" [arg-type]
|
||||
src/documents/tests/test_api_documents.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/test_api_documents.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/test_api_documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/tests/test_api_documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/tests/test_api_documents.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
@@ -1223,21 +1174,16 @@ src/documents/tests/test_management_exporter.py:0: error: Function is missing a
|
||||
src/documents/tests/test_management_exporter.py:0: error: Item "None" of "MailAccount | None" has no attribute "password" [union-attr]
|
||||
src/documents/tests/test_management_exporter.py:0: error: Skipping analyzing "allauth.socialaccount.models": module is installed, but missing library stubs or py.typed marker [import-untyped]
|
||||
src/documents/tests/test_management_fuzzy.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/tests/test_management_retagger.py:0: error: "DocumentFactory" has no attribute "refresh_from_db" [attr-defined]
|
||||
src/documents/tests/test_management_retagger.py:0: error: "DocumentFactory" has no attribute "refresh_from_db" [attr-defined]
|
||||
src/documents/tests/test_management_retagger.py:0: error: "DocumentFactory" has no attribute "refresh_from_db" [attr-defined]
|
||||
src/documents/tests/test_management_retagger.py:0: error: "DocumentFactory" has no attribute "tags" [attr-defined]
|
||||
src/documents/tests/test_management_retagger.py:0: error: "DocumentFactory" has no attribute "tags" [attr-defined]
|
||||
src/documents/tests/test_management_retagger.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/tests/test_management_retagger.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/tests/test_management_retagger.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/tests/test_management_retagger.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/tests/test_management_retagger.py:0: error: Incompatible return value type (got "tuple[CorrespondentFactory, CorrespondentFactory]", expected "tuple[Correspondent, Correspondent]") [return-value]
|
||||
src/documents/tests/test_management_retagger.py:0: error: Incompatible return value type (got "tuple[DocumentFactory, DocumentFactory, DocumentFactory, DocumentFactory]", expected "tuple[Document, Document, Document, Document]") [return-value]
|
||||
src/documents/tests/test_management_retagger.py:0: error: Incompatible return value type (got "tuple[DocumentTypeFactory, DocumentTypeFactory]", expected "tuple[DocumentType, DocumentType]") [return-value]
|
||||
src/documents/tests/test_management_retagger.py:0: error: Incompatible return value type (got "tuple[StoragePathFactory, StoragePathFactory, StoragePathFactory]", expected "tuple[StoragePath, StoragePath, StoragePath]") [return-value]
|
||||
src/documents/tests/test_management_retagger.py:0: error: Incompatible return value type (got "tuple[TagFactory, TagFactory, TagFactory, TagFactory, TagFactory]", expected "tuple[Tag, Tag, Tag, Tag, Tag]") [return-value]
|
||||
src/documents/tests/test_management_retagger.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/test_management_superuser.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/tests/test_migration_saved_view_visibility.py:0: error: Cannot determine type of "apps" [has-type]
|
||||
src/documents/tests/test_migration_saved_view_visibility.py:0: error: Cannot determine type of "apps" [has-type]
|
||||
src/documents/tests/test_migration_saved_view_visibility.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/tests/test_migration_saved_view_visibility.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/tests/test_migration_saved_view_visibility.py:0: error: Incompatible types in assignment (expression has type "str", base class "TestMigrations" defined the type as "None") [assignment]
|
||||
src/documents/tests/test_migration_saved_view_visibility.py:0: error: Incompatible types in assignment (expression has type "str", base class "TestMigrations" defined the type as "None") [assignment]
|
||||
src/documents/tests/test_migration_saved_view_visibility.py:0: error: Incompatible types in assignment (expression has type "str", base class "TestMigrations" defined the type as "None") [assignment]
|
||||
src/documents/tests/test_migration_saved_view_visibility.py:0: error: Incompatible types in assignment (expression has type "str", base class "TestMigrations" defined the type as "None") [assignment]
|
||||
src/documents/tests/test_migration_share_link_bundle.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/tests/test_migration_share_link_bundle.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/tests/test_migration_share_link_bundle.py:0: error: Incompatible types in assignment (expression has type "str", base class "TestMigrations" defined the type as "None") [assignment]
|
||||
@@ -1253,10 +1199,9 @@ src/documents/tests/test_parsers.py:0: error: Function is missing a type annotat
|
||||
src/documents/tests/test_parsers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/tests/test_parsers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/tests/test_parsers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/tests/test_sanity_check.py:0: error: Argument 1 to "Path" has incompatible type "Path | None"; expected "str | PathLike[str]" [arg-type]
|
||||
src/documents/tests/test_sanity_check.py:0: error: Argument 1 to "Path" has incompatible type "Path | None"; expected "str | PathLike[str]" [arg-type]
|
||||
src/documents/tests/test_sanity_check.py:0: error: Argument 1 to "Path" has incompatible type "Path | None"; expected "str | PathLike[str]" [arg-type]
|
||||
src/documents/tests/test_sanity_check.py:0: error: Unsupported right operand type for in ("str | None") [operator]
|
||||
src/documents/tests/test_sanity_check.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/test_sanity_check.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/tests/test_sanity_check.py:0: error: Invalid index type "None" for "dict[int, list[dict[Any, Any]]]"; expected type "int" [index]
|
||||
src/documents/tests/test_share_link_bundles.py:0: error: "_MonkeyPatchedResponse" has no attribute "streaming_content" [attr-defined]
|
||||
src/documents/tests/test_share_link_bundles.py:0: error: Argument 1 to "ZipFile" has incompatible type "Path | None"; expected "str | PathLike[str] | IO[bytes]" [arg-type]
|
||||
src/documents/tests/test_share_link_bundles.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
@@ -1287,6 +1232,10 @@ src/documents/tests/test_tasks.py:0: error: Function is missing a type annotatio
|
||||
src/documents/tests/test_tasks.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/tests/test_tasks.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/tests/test_tasks.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/tests/test_tasks.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/tests/test_tasks.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/tests/test_tasks.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/tests/test_tasks.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/tests/test_views.py:0: error: "_MonkeyPatchedWSGIResponse" has no attribute "render" [attr-defined]
|
||||
src/documents/tests/test_views.py:0: error: "_MonkeyPatchedWSGIResponse" has no attribute "render" [attr-defined]
|
||||
src/documents/tests/test_views.py:0: error: "_MonkeyPatchedWSGIResponse" has no attribute "url" [attr-defined]
|
||||
@@ -1611,7 +1560,6 @@ src/documents/views.py:0: error: Function is missing a return type annotation [
|
||||
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
@@ -1670,7 +1618,6 @@ src/documents/views.py:0: error: Function is missing a type annotation for one o
|
||||
src/documents/views.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/views.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/views.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/views.py:0: error: Incompatible type for lookup 'owner': (got "User | AnonymousUser", expected "User | int | None") [misc]
|
||||
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "Any | None", variable has type "dict[Any, Any]") [assignment]
|
||||
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "QuerySet[Any, Any]", variable has type "list[Any]") [assignment]
|
||||
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "QuerySet[Any, Any]", variable has type "list[Document]") [assignment]
|
||||
@@ -1736,11 +1683,11 @@ src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable
|
||||
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
|
||||
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
|
||||
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
|
||||
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
|
||||
src/documents/views.py:0: error: Value of type "Iterable[CustomField]" is not indexable [index]
|
||||
src/documents/views.py:0: error: Value of type "Iterable[Group]" is not indexable [index]
|
||||
src/documents/views.py:0: error: Value of type "Iterable[MailAccount]" is not indexable [index]
|
||||
src/documents/views.py:0: error: Value of type "Iterable[MailRule]" is not indexable [index]
|
||||
src/documents/views.py:0: error: Value of type "Iterable[SavedView]" is not indexable [index]
|
||||
src/documents/views.py:0: error: Value of type "Iterable[User]" is not indexable [index]
|
||||
src/documents/views.py:0: error: Value of type "Iterable[Workflow]" is not indexable [index]
|
||||
src/documents/views.py:0: error: Value of type "dict[str, _PingReply] | None" is not indexable [index]
|
||||
@@ -1925,54 +1872,39 @@ src/paperless/serialisers.py:0: error: Skipping analyzing "allauth.mfa.adapter":
|
||||
src/paperless/serialisers.py:0: error: Skipping analyzing "allauth.mfa.models": module is installed, but missing library stubs or py.typed marker [import-untyped]
|
||||
src/paperless/serialisers.py:0: error: Skipping analyzing "allauth.mfa.totp.internal.auth": module is installed, but missing library stubs or py.typed marker [import-untyped]
|
||||
src/paperless/serialisers.py:0: error: Skipping analyzing "allauth.socialaccount.models": module is installed, but missing library stubs or py.typed marker [import-untyped]
|
||||
src/paperless/settings/__init__.py:0: error: "Sequence[str]" has no attribute "append" [attr-defined]
|
||||
src/paperless/settings/__init__.py:0: error: "Sequence[str]" has no attribute "insert" [attr-defined]
|
||||
src/paperless/settings/__init__.py:0: error: Argument 1 to "_parse_ignore_dates" has incompatible type "str | None"; expected "str" [arg-type]
|
||||
src/paperless/settings/__init__.py:0: error: Argument 1 to "append" of "list" has incompatible type "str | None"; expected "str" [arg-type]
|
||||
src/paperless/settings/__init__.py:0: error: Argument 2 to "__get_path" has incompatible type "str | None"; expected "PathLike[Any] | str" [arg-type]
|
||||
src/paperless/settings/__init__.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/paperless/settings/__init__.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/paperless/settings/__init__.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/paperless/settings/__init__.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/paperless/settings/__init__.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/paperless/settings/__init__.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/paperless/settings/__init__.py:0: error: Incompatible return value type (got "set[date]", expected "set[datetime]") [return-value]
|
||||
src/paperless/settings/__init__.py:0: error: Incompatible return value type (got "tuple[str | None, str, str, str, str]", expected "tuple[str, str, str, str, str]") [return-value]
|
||||
src/paperless/settings/__init__.py:0: error: Incompatible types in assignment (expression has type "bool", variable has type "str") [assignment]
|
||||
src/paperless/settings/__init__.py:0: error: Incompatible types in assignment (expression has type "set[datetime]", variable has type "set[date]") [assignment]
|
||||
src/paperless/settings/__init__.py:0: error: Missing type parameters for generic type "PathLike" [type-arg]
|
||||
src/paperless/settings/__init__.py:0: error: Missing type parameters for generic type "dict" [type-arg]
|
||||
src/paperless/settings/__init__.py:0: error: No overload variant of "getenv" matches argument types "Collection[str]", "Collection[str]" [call-overload]
|
||||
src/paperless/settings/__init__.py:0: error: Skipping analyzing "compression_middleware.middleware": module is installed, but missing library stubs or py.typed marker [import-untyped]
|
||||
src/paperless/settings/parsers.py:0: error: Incompatible types in assignment (expression has type "Path", variable has type "bool") [assignment]
|
||||
src/paperless/settings/parsers.py:0: error: Missing type parameters for generic type "dict" [type-arg]
|
||||
src/paperless/settings/parsers.py:0: error: Missing type parameters for generic type "dict" [type-arg]
|
||||
src/paperless/settings/parsers.py:0: error: Missing type parameters for generic type "dict" [type-arg]
|
||||
src/paperless/settings.py:0: error: "Sequence[str]" has no attribute "append" [attr-defined]
|
||||
src/paperless/settings.py:0: error: "Sequence[str]" has no attribute "insert" [attr-defined]
|
||||
src/paperless/settings.py:0: error: "object" has no attribute "update" [attr-defined]
|
||||
src/paperless/settings.py:0: error: "object" has no attribute "update" [attr-defined]
|
||||
src/paperless/settings.py:0: error: "object" has no attribute "update" [attr-defined]
|
||||
src/paperless/settings.py:0: error: "object" has no attribute "update" [attr-defined]
|
||||
src/paperless/settings.py:0: error: Argument 1 to "_parse_ignore_dates" has incompatible type "str | None"; expected "str" [arg-type]
|
||||
src/paperless/settings.py:0: error: Argument 1 to "append" of "list" has incompatible type "str | None"; expected "str" [arg-type]
|
||||
src/paperless/settings.py:0: error: Argument 1 to "int" has incompatible type "str | None"; expected "str | Buffer | SupportsInt | SupportsIndex | SupportsTrunc" [arg-type]
|
||||
src/paperless/settings.py:0: error: Argument 1 to "int" has incompatible type "str | None"; expected "str | Buffer | SupportsInt | SupportsIndex | SupportsTrunc" [arg-type]
|
||||
src/paperless/settings.py:0: error: Argument 1 to "int" has incompatible type "str | None"; expected "str | Buffer | SupportsInt | SupportsIndex | SupportsTrunc" [arg-type]
|
||||
src/paperless/settings.py:0: error: Argument 1 to "int" has incompatible type "str | None"; expected "str | Buffer | SupportsInt | SupportsIndex | SupportsTrunc" [arg-type]
|
||||
src/paperless/settings.py:0: error: Argument 2 to "__get_path" has incompatible type "str | None"; expected "PathLike[Any] | str" [arg-type]
|
||||
src/paperless/settings.py:0: error: Argument 2 to "getenv" has incompatible type "None"; expected "Collection[str]" [arg-type]
|
||||
src/paperless/settings.py:0: error: Argument 2 to "getenv" has incompatible type "None"; expected "Collection[str]" [arg-type]
|
||||
src/paperless/settings.py:0: error: Argument 2 to "getenv" has incompatible type "None"; expected "Collection[str]" [arg-type]
|
||||
src/paperless/settings.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/paperless/settings.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/paperless/settings.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/paperless/settings.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/paperless/settings.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/paperless/settings.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/paperless/settings.py:0: error: Incompatible return value type (got "set[date]", expected "set[datetime]") [return-value]
|
||||
src/paperless/settings.py:0: error: Incompatible return value type (got "tuple[str | None, str, str, str, str]", expected "tuple[str, str, str, str, str]") [return-value]
|
||||
src/paperless/settings.py:0: error: Incompatible types in assignment (expression has type "bool", variable has type "str") [assignment]
|
||||
src/paperless/settings.py:0: error: Incompatible types in assignment (expression has type "set[datetime]", variable has type "set[date]") [assignment]
|
||||
src/paperless/settings.py:0: error: Missing type parameters for generic type "PathLike" [type-arg]
|
||||
src/paperless/settings.py:0: error: Missing type parameters for generic type "dict" [type-arg]
|
||||
src/paperless/settings.py:0: error: Missing type parameters for generic type "dict" [type-arg]
|
||||
src/paperless/settings.py:0: error: No overload variant of "getenv" matches argument types "Collection[str]", "Collection[str]" [call-overload]
|
||||
src/paperless/settings.py:0: error: Skipping analyzing "compression_middleware.middleware": module is installed, but missing library stubs or py.typed marker [import-untyped]
|
||||
src/paperless/signals.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/paperless/signals.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/paperless/tests/settings/test_custom_parsers.py:0: error: Missing type parameters for generic type "dict" [type-arg]
|
||||
src/paperless/tests/settings/test_environment_parsers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/paperless/tests/settings/test_environment_parsers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/paperless/tests/settings/test_environment_parsers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/paperless/tests/settings/test_environment_parsers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/paperless/tests/settings/test_environment_parsers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/paperless/tests/settings/test_environment_parsers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/paperless/tests/settings/test_environment_parsers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/paperless/tests/settings/test_environment_parsers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/paperless/tests/settings/test_environment_parsers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/paperless/tests/settings/test_environment_parsers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/paperless/tests/settings/test_environment_parsers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/paperless/tests/settings/test_environment_parsers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/paperless/tests/settings/test_environment_parsers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/paperless/tests/settings/test_environment_parsers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/paperless/tests/settings/test_environment_parsers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/paperless/tests/settings/test_environment_parsers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/paperless/tests/settings/test_environment_parsers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/paperless/tests/settings/test_environment_parsers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/paperless/tests/settings/test_environment_parsers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/paperless/tests/settings/test_environment_parsers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/paperless/tests/settings/test_environment_parsers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/paperless/tests/settings/test_environment_parsers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/paperless/tests/test_adapter.py:0: error: Cannot assign to a method [method-assign]
|
||||
src/paperless/tests/test_adapter.py:0: error: Cannot assign to a method [method-assign]
|
||||
src/paperless/tests/test_adapter.py:0: error: Cannot assign to a method [method-assign]
|
||||
@@ -1980,12 +1912,6 @@ src/paperless/tests/test_adapter.py:0: error: Function is missing a type annotat
|
||||
src/paperless/tests/test_adapter.py:0: error: Skipping analyzing "allauth.account.adapter": module is installed, but missing library stubs or py.typed marker [import-untyped]
|
||||
src/paperless/tests/test_adapter.py:0: error: Skipping analyzing "allauth.core": module is installed, but missing library stubs or py.typed marker [import-untyped]
|
||||
src/paperless/tests/test_adapter.py:0: error: Skipping analyzing "allauth.socialaccount.adapter": module is installed, but missing library stubs or py.typed marker [import-untyped]
|
||||
src/paperless/tests/test_checks.py:0: error: Generator has incompatible item type "str | None"; expected "str" [misc]
|
||||
src/paperless/tests/test_checks.py:0: error: Unsupported right operand type for in ("str | None") [operator]
|
||||
src/paperless/tests/test_checks.py:0: error: Unsupported right operand type for in ("str | None") [operator]
|
||||
src/paperless/tests/test_checks.py:0: error: Unsupported right operand type for in ("str | None") [operator]
|
||||
src/paperless/tests/test_checks.py:0: error: Unsupported right operand type for in ("str | None") [operator]
|
||||
src/paperless/tests/test_checks.py:0: error: Unsupported right operand type for in ("str | None") [operator]
|
||||
src/paperless/tests/test_db_cache.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/paperless/tests/test_db_cache.py:0: error: Skipping analyzing "cachalot.settings": module is installed, but missing library stubs or py.typed marker [import-untyped]
|
||||
src/paperless/tests/test_settings.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
@@ -2010,6 +1936,7 @@ src/paperless/tests/test_websockets.py:0: error: Item "None" of "BaseChannelLaye
|
||||
src/paperless/tests/test_websockets.py:0: error: Item "None" of "BaseChannelLayer | None" has no attribute "group_send" [union-attr]
|
||||
src/paperless/tests/test_websockets.py:0: error: Item "None" of "BaseChannelLayer | None" has no attribute "group_send" [union-attr]
|
||||
src/paperless/tests/test_websockets.py:0: error: Item "None" of "BaseChannelLayer | None" has no attribute "group_send" [union-attr]
|
||||
src/paperless/tests/test_websockets.py:0: error: Item "None" of "BaseChannelLayer | None" has no attribute "group_send" [union-attr]
|
||||
src/paperless/tests/test_websockets.py:0: error: TypedDict "_WebsocketTestScope" has no key "user" [typeddict-item]
|
||||
src/paperless/tests/test_websockets.py:0: error: TypedDict "_WebsocketTestScope" has no key "user" [typeddict-item]
|
||||
src/paperless/tests/test_websockets.py:0: error: TypedDict "_WebsocketTestScope" has no key "user" [typeddict-item]
|
||||
|
||||
@@ -29,7 +29,7 @@ repos:
|
||||
- id: check-case-conflict
|
||||
- id: detect-private-key
|
||||
- repo: https://github.com/codespell-project/codespell
|
||||
rev: v2.4.1
|
||||
rev: v2.4.2
|
||||
hooks:
|
||||
- id: codespell
|
||||
additional_dependencies: [tomli]
|
||||
@@ -46,11 +46,11 @@ repos:
|
||||
- ts
|
||||
- markdown
|
||||
additional_dependencies:
|
||||
- prettier@3.3.3
|
||||
- 'prettier-plugin-organize-imports@4.1.0'
|
||||
- prettier@3.8.1
|
||||
- 'prettier-plugin-organize-imports@4.3.0'
|
||||
# Python hooks
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.0
|
||||
rev: v0.15.5
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
- id: ruff-format
|
||||
@@ -65,7 +65,7 @@ repos:
|
||||
- id: hadolint
|
||||
# Shell script hooks
|
||||
- repo: https://github.com/lovesegfault/beautysh
|
||||
rev: v6.4.2
|
||||
rev: v6.4.3
|
||||
hooks:
|
||||
- id: beautysh
|
||||
types: [file]
|
||||
|
||||
@@ -5,14 +5,6 @@ const config = {
|
||||
singleQuote: true,
|
||||
// https://prettier.io/docs/en/options.html#trailing-commas
|
||||
trailingComma: 'es5',
|
||||
overrides: [
|
||||
{
|
||||
files: ['docs/*.md'],
|
||||
options: {
|
||||
tabWidth: 4,
|
||||
},
|
||||
},
|
||||
],
|
||||
plugins: [require('prettier-plugin-organize-imports')],
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,9 @@ If you want to implement something big:
|
||||
|
||||
## Python
|
||||
|
||||
Paperless supports python 3.10 - 3.12 at this time. We format Python code with [ruff](https://docs.astral.sh/ruff/formatter/).
|
||||
Paperless-ngx currently supports Python 3.11, 3.12, 3.13, and 3.14. As a policy, we aim to support at least the three most recent Python versions, and drop support for versions as they reach end-of-life. Older versions may be supported if dependencies permit, but this is not guaranteed.
|
||||
|
||||
We format Python code with [ruff](https://docs.astral.sh/ruff/formatter/).
|
||||
|
||||
## Branches
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ RUN set -eux \
|
||||
# Purpose: Installs s6-overlay and rootfs
|
||||
# Comments:
|
||||
# - Don't leave anything extra in here either
|
||||
FROM ghcr.io/astral-sh/uv:0.10.7-python3.12-trixie-slim AS s6-overlay-base
|
||||
FROM ghcr.io/astral-sh/uv:0.10.9-python3.12-trixie-slim AS s6-overlay-base
|
||||
|
||||
WORKDIR /usr/src/s6
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ services:
|
||||
environment:
|
||||
PAPERLESS_REDIS: redis://broker:6379
|
||||
PAPERLESS_DBHOST: db
|
||||
PAPERLESS_DBENGINE: postgres
|
||||
env_file:
|
||||
- stack.env
|
||||
volumes:
|
||||
|
||||
@@ -62,6 +62,7 @@ services:
|
||||
environment:
|
||||
PAPERLESS_REDIS: redis://broker:6379
|
||||
PAPERLESS_DBHOST: db
|
||||
PAPERLESS_DBENGINE: postgresql
|
||||
PAPERLESS_TIKA_ENABLED: 1
|
||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||
|
||||
@@ -56,6 +56,7 @@ services:
|
||||
environment:
|
||||
PAPERLESS_REDIS: redis://broker:6379
|
||||
PAPERLESS_DBHOST: db
|
||||
PAPERLESS_DBENGINE: postgresql
|
||||
volumes:
|
||||
data:
|
||||
media:
|
||||
|
||||
@@ -51,6 +51,7 @@ services:
|
||||
env_file: docker-compose.env
|
||||
environment:
|
||||
PAPERLESS_REDIS: redis://broker:6379
|
||||
PAPERLESS_DBENGINE: sqlite
|
||||
PAPERLESS_TIKA_ENABLED: 1
|
||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||
|
||||
@@ -42,6 +42,7 @@ services:
|
||||
env_file: docker-compose.env
|
||||
environment:
|
||||
PAPERLESS_REDIS: redis://broker:6379
|
||||
PAPERLESS_DBENGINE: sqlite
|
||||
volumes:
|
||||
data:
|
||||
media:
|
||||
|
||||
@@ -10,8 +10,10 @@ cd "${PAPERLESS_SRC_DIR}"
|
||||
|
||||
# The whole migrate, with flock, needs to run as the right user
|
||||
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
|
||||
else
|
||||
s6-setuidgid paperless python3 manage.py check --tag compatibility paperless || exit 1
|
||||
exec s6-setuidgid paperless \
|
||||
s6-setlock -n "${data_dir}/migration_lock" \
|
||||
python3 manage.py migrate --skip-checks --no-input
|
||||
|
||||
@@ -10,16 +10,16 @@ consuming documents at that time.
|
||||
|
||||
Options available to any installation of paperless:
|
||||
|
||||
- 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
|
||||
documents and settings into a fresh instance of paperless again or store your
|
||||
documents in another DMS with this export.
|
||||
- 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
|
||||
documents and settings into a fresh instance of paperless again or store your
|
||||
documents in another DMS with this export.
|
||||
|
||||
The document exporter is also able to update an already existing
|
||||
export. Therefore, incremental backups with `rsync` are entirely
|
||||
possible.
|
||||
The document exporter is also able to update an already existing
|
||||
export. Therefore, incremental backups with `rsync` are entirely
|
||||
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
|
||||
|
||||
@@ -29,28 +29,27 @@ Options available to any installation of paperless:
|
||||
|
||||
Options available to docker installations:
|
||||
|
||||
- Backup the docker volumes. These usually reside within
|
||||
`/var/lib/docker/volumes` on the host and you need to be root in
|
||||
order to access them.
|
||||
- Backup the docker volumes. These usually reside within
|
||||
`/var/lib/docker/volumes` on the host and you need to be root in
|
||||
order to access them.
|
||||
|
||||
Paperless uses 4 volumes:
|
||||
|
||||
- `paperless_media`: This is where your documents are stored.
|
||||
- `paperless_data`: This is where auxiliary data is stored. This
|
||||
folder also contains the SQLite database, if you use it.
|
||||
- `paperless_pgdata`: Exists only if you use PostgreSQL and
|
||||
contains the database.
|
||||
- `paperless_dbdata`: Exists only if you use MariaDB and contains
|
||||
the database.
|
||||
Paperless uses 4 volumes:
|
||||
- `paperless_media`: This is where your documents are stored.
|
||||
- `paperless_data`: This is where auxiliary data is stored. This
|
||||
folder also contains the SQLite database, if you use it.
|
||||
- `paperless_pgdata`: Exists only if you use PostgreSQL and
|
||||
contains the database.
|
||||
- `paperless_dbdata`: Exists only if you use MariaDB and contains
|
||||
the database.
|
||||
|
||||
Options available to bare-metal and non-docker installations:
|
||||
|
||||
- Backup the entire paperless folder. This ensures that if your
|
||||
paperless instance crashes at some point or your disk fails, you can
|
||||
simply copy the folder back into place and it works.
|
||||
- Backup the entire paperless folder. This ensures that if your
|
||||
paperless instance crashes at some point or your disk fails, you can
|
||||
simply copy the folder back into place and it works.
|
||||
|
||||
When using PostgreSQL or MariaDB, you'll also have to backup the
|
||||
database.
|
||||
When using PostgreSQL or MariaDB, you'll also have to backup the
|
||||
database.
|
||||
|
||||
### Restoring {#migrating-restoring}
|
||||
|
||||
@@ -509,19 +508,19 @@ collection for issues.
|
||||
|
||||
The issues detected by the sanity checker are as follows:
|
||||
|
||||
- Missing original files.
|
||||
- Missing archive files.
|
||||
- Inaccessible original files due to improper permissions.
|
||||
- Inaccessible archive files due to improper permissions.
|
||||
- Corrupted original documents by comparing their checksum against
|
||||
what is stored in the database.
|
||||
- Corrupted archive documents by comparing their checksum against what
|
||||
is stored in the database.
|
||||
- Missing thumbnails.
|
||||
- Inaccessible thumbnails due to improper permissions.
|
||||
- Documents without any content (warning).
|
||||
- Orphaned files in the media directory (warning). These are files
|
||||
that are not referenced by any document in paperless.
|
||||
- Missing original files.
|
||||
- Missing archive files.
|
||||
- Inaccessible original files due to improper permissions.
|
||||
- Inaccessible archive files due to improper permissions.
|
||||
- Corrupted original documents by comparing their checksum against
|
||||
what is stored in the database.
|
||||
- Corrupted archive documents by comparing their checksum against what
|
||||
is stored in the database.
|
||||
- Missing thumbnails.
|
||||
- Inaccessible thumbnails due to improper permissions.
|
||||
- Documents without any content (warning).
|
||||
- Orphaned files in the media directory (warning). These are files
|
||||
that are not referenced by any document in paperless.
|
||||
|
||||
```
|
||||
document_sanity_checker
|
||||
|
||||
@@ -25,20 +25,20 @@ documents.
|
||||
|
||||
The following algorithms are available:
|
||||
|
||||
- **None:** No matching will be performed.
|
||||
- **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
|
||||
documents containing either of these terms.
|
||||
- **All:** Requires that every word provided appears in the PDF,
|
||||
albeit not in the order provided.
|
||||
- **Exact:** Matches only if the match appears exactly as provided
|
||||
(i.e. preserve ordering) in the PDF.
|
||||
- **Regular expression:** Parses the match as a regular expression and
|
||||
tries to find a match within the document.
|
||||
- **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)
|
||||
- **Auto:** Tries to automatically match new documents. This does not
|
||||
require you to set a match. See the [notes below](#automatic-matching).
|
||||
- **None:** No matching will be performed.
|
||||
- **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
|
||||
documents containing either of these terms.
|
||||
- **All:** Requires that every word provided appears in the PDF,
|
||||
albeit not in the order provided.
|
||||
- **Exact:** Matches only if the match appears exactly as provided
|
||||
(i.e. preserve ordering) in the PDF.
|
||||
- **Regular expression:** Parses the match as a regular expression and
|
||||
tries to find a match within the document.
|
||||
- **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)
|
||||
- **Auto:** Tries to automatically match new documents. This does not
|
||||
require you to set a match. See the [notes below](#automatic-matching).
|
||||
|
||||
When using the _any_ or _all_ matching algorithms, you can search for
|
||||
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
|
||||
when using this feature:
|
||||
|
||||
- Changes to your documents are not immediately reflected by the
|
||||
matching algorithm. The neural network needs to be _trained_ on your
|
||||
documents after changes. Paperless periodically (default: once each
|
||||
hour) checks for changes and does this automatically for you.
|
||||
- The Auto matching algorithm only takes documents into account which
|
||||
are NOT placed in your inbox (i.e. have any inbox tags assigned to
|
||||
them). This ensures that the neural network only learns from
|
||||
documents which you have correctly tagged before.
|
||||
- The matching algorithm can only work if there is a correlation
|
||||
between the tag, correspondent, document type, or storage path and
|
||||
the document itself. Your bank statements usually contain your bank
|
||||
account number and the name of the bank, so this works reasonably
|
||||
well, However, tags such as "TODO" cannot be automatically
|
||||
assigned.
|
||||
- The matching algorithm needs a reasonable number of documents to
|
||||
identify when to assign tags, correspondents, storage paths, and
|
||||
types. If one out of a thousand documents has the correspondent
|
||||
"Very obscure web shop I bought something five years ago", it will
|
||||
probably not assign this correspondent automatically if you buy
|
||||
something from them again. The more documents, the better.
|
||||
- Paperless also needs a reasonable amount of negative examples to
|
||||
decide when not to assign a certain tag, correspondent, document
|
||||
type, or storage path. This will usually be the case as you start
|
||||
filling up paperless with documents. Example: If all your documents
|
||||
are either from "Webshop" or "Bank", paperless will assign one
|
||||
of these correspondents to ANY new document, if both are set to
|
||||
automatic matching.
|
||||
- Changes to your documents are not immediately reflected by the
|
||||
matching algorithm. The neural network needs to be _trained_ on your
|
||||
documents after changes. Paperless periodically (default: once each
|
||||
hour) checks for changes and does this automatically for you.
|
||||
- The Auto matching algorithm only takes documents into account which
|
||||
are NOT placed in your inbox (i.e. have any inbox tags assigned to
|
||||
them). This ensures that the neural network only learns from
|
||||
documents which you have correctly tagged before.
|
||||
- The matching algorithm can only work if there is a correlation
|
||||
between the tag, correspondent, document type, or storage path and
|
||||
the document itself. Your bank statements usually contain your bank
|
||||
account number and the name of the bank, so this works reasonably
|
||||
well, However, tags such as "TODO" cannot be automatically
|
||||
assigned.
|
||||
- The matching algorithm needs a reasonable number of documents to
|
||||
identify when to assign tags, correspondents, storage paths, and
|
||||
types. If one out of a thousand documents has the correspondent
|
||||
"Very obscure web shop I bought something five years ago", it will
|
||||
probably not assign this correspondent automatically if you buy
|
||||
something from them again. The more documents, the better.
|
||||
- Paperless also needs a reasonable amount of negative examples to
|
||||
decide when not to assign a certain tag, correspondent, document
|
||||
type, or storage path. This will usually be the case as you start
|
||||
filling up paperless with documents. Example: If all your documents
|
||||
are either from "Webshop" or "Bank", paperless will assign one
|
||||
of these correspondents to ANY new document, if both are set to
|
||||
automatic matching.
|
||||
|
||||
## Hooking into the consumption process {#consume-hooks}
|
||||
|
||||
@@ -243,12 +243,12 @@ webserver:
|
||||
|
||||
Troubleshooting:
|
||||
|
||||
- Monitor the Docker Compose log
|
||||
`cd ~/paperless-ngx; docker compose logs -f`
|
||||
- Check your script's permission e.g. in case of permission error
|
||||
`sudo chmod 755 post-consumption-example.sh`
|
||||
- Pipe your scripts's output to a log file e.g.
|
||||
`echo "${DOCUMENT_ID}" | tee --append /usr/src/paperless/scripts/post-consumption-example.log`
|
||||
- Monitor the Docker Compose log
|
||||
`cd ~/paperless-ngx; docker compose logs -f`
|
||||
- Check your script's permission e.g. in case of permission error
|
||||
`sudo chmod 755 post-consumption-example.sh`
|
||||
- Pipe your scripts's output to a log file e.g.
|
||||
`echo "${DOCUMENT_ID}" | tee --append /usr/src/paperless/scripts/post-consumption-example.log`
|
||||
|
||||
## File name handling {#file-name-handling}
|
||||
|
||||
@@ -262,6 +262,10 @@ your files differently, you can do that by adjusting the
|
||||
or using [storage paths (see below)](#storage-paths). Paperless adds the
|
||||
correct file extension e.g. `.pdf`, `.jpg` automatically.
|
||||
|
||||
When a document has file versions, each version uses the same naming rules and
|
||||
storage path resolution as any other document file, with an added version suffix
|
||||
such as `_v1`, `_v2`, etc.
|
||||
|
||||
This variable allows you to configure the filename (folders are allowed)
|
||||
using placeholders. For example, configuring this to
|
||||
|
||||
@@ -303,35 +307,35 @@ will create a directory structure as follows:
|
||||
|
||||
Paperless provides the following variables for use within filenames:
|
||||
|
||||
- `{{ asn }}`: The archive serial number of the document, or "none".
|
||||
- `{{ correspondent }}`: The name of the correspondent, or "none".
|
||||
- `{{ document_type }}`: The name of the document type, or "none".
|
||||
- `{{ tag_list }}`: A comma separated list of all tags assigned to 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_year }}`: Year created only, formatted as the year with
|
||||
century.
|
||||
- `{{ created_year_short }}`: Year created only, formatted as the year
|
||||
without century, zero padded.
|
||||
- `{{ created_month }}`: Month created only (number 01-12).
|
||||
- `{{ created_month_name }}`: Month created name, as per locale
|
||||
- `{{ created_month_name_short }}`: Month created abbreviated name, as per
|
||||
locale
|
||||
- `{{ created_day }}`: Day created only (number 01-31).
|
||||
- `{{ added }}`: The full date (ISO format) the document was added to
|
||||
paperless.
|
||||
- `{{ added_year }}`: Year added only.
|
||||
- `{{ added_year_short }}`: Year added only, formatted as the year without
|
||||
century, zero padded.
|
||||
- `{{ added_month }}`: Month added only (number 01-12).
|
||||
- `{{ added_month_name }}`: Month added name, as per locale
|
||||
- `{{ added_month_name_short }}`: Month added abbreviated name, as per
|
||||
locale
|
||||
- `{{ added_day }}`: Day added only (number 01-31).
|
||||
- `{{ owner_username }}`: Username of document owner, 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.
|
||||
- `{{ asn }}`: The archive serial number of the document, or "none".
|
||||
- `{{ correspondent }}`: The name of the correspondent, or "none".
|
||||
- `{{ document_type }}`: The name of the document type, or "none".
|
||||
- `{{ tag_list }}`: A comma separated list of all tags assigned to 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_year }}`: Year created only, formatted as the year with
|
||||
century.
|
||||
- `{{ created_year_short }}`: Year created only, formatted as the year
|
||||
without century, zero padded.
|
||||
- `{{ created_month }}`: Month created only (number 01-12).
|
||||
- `{{ created_month_name }}`: Month created name, as per locale
|
||||
- `{{ created_month_name_short }}`: Month created abbreviated name, as per
|
||||
locale
|
||||
- `{{ created_day }}`: Day created only (number 01-31).
|
||||
- `{{ added }}`: The full date (ISO format) the document was added to
|
||||
paperless.
|
||||
- `{{ added_year }}`: Year added only.
|
||||
- `{{ added_year_short }}`: Year added only, formatted as the year without
|
||||
century, zero padded.
|
||||
- `{{ added_month }}`: Month added only (number 01-12).
|
||||
- `{{ added_month_name }}`: Month added name, as per locale
|
||||
- `{{ added_month_name_short }}`: Month added abbreviated name, as per
|
||||
locale
|
||||
- `{{ added_day }}`: Day added only (number 01-31).
|
||||
- `{{ owner_username }}`: Username of document owner, 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.
|
||||
|
||||
!!! warning
|
||||
|
||||
@@ -353,6 +357,8 @@ If paperless detects that two documents share the same filename,
|
||||
paperless will automatically append `_01`, `_02`, etc to the filename.
|
||||
This happens if all the placeholders in a filename evaluate to the same
|
||||
value.
|
||||
For versioned files, this counter is appended after the version suffix
|
||||
(for example `statement_v2_01.pdf`).
|
||||
|
||||
If there are any errors in the placeholders included in `PAPERLESS_FILENAME_FORMAT`,
|
||||
paperless will fall back to using the default naming scheme instead.
|
||||
@@ -382,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
|
||||
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
|
||||
follows the rules described above
|
||||
- Each document is assigned a storage path using the matching algorithms described above, but can be
|
||||
overwritten at any time
|
||||
- Each storage path is a [`PAPERLESS_FILENAME_FORMAT`](configuration.md#PAPERLESS_FILENAME_FORMAT) and
|
||||
follows the rules described above
|
||||
- Each document is assigned a storage path using the matching algorithms described above, but can be
|
||||
overwritten at any time
|
||||
|
||||
For example, you could define the following two storage paths:
|
||||
|
||||
@@ -451,13 +457,13 @@ The `get_cf_value` filter retrieves a value from custom field data with optional
|
||||
|
||||
###### Parameters
|
||||
|
||||
- `custom_fields`: This _must_ be the provided custom field data
|
||||
- `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
|
||||
- `custom_fields`: This _must_ be the provided custom field data
|
||||
- `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
|
||||
|
||||
###### 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
|
||||
|
||||
@@ -481,12 +487,12 @@ The `datetime` filter formats a datetime string or datetime object using Python'
|
||||
|
||||
###### Parameters
|
||||
|
||||
- `value` (str | datetime): Date/time value to format (strings will be parsed automatically)
|
||||
- `format` (str): Python strftime format string
|
||||
- `value` (str | datetime): Date/time value to format (strings will be parsed automatically)
|
||||
- `format` (str): Python strftime format string
|
||||
|
||||
###### Returns
|
||||
|
||||
- `str`: Formatted datetime string
|
||||
- `str`: Formatted datetime string
|
||||
|
||||
###### Examples
|
||||
|
||||
@@ -519,13 +525,13 @@ An ISO string can also be provided to control the output format.
|
||||
|
||||
###### Parameters
|
||||
|
||||
- `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
|
||||
- `locale` (str): Locale code for localization (e.g., 'en_US', 'fr_FR', 'de_DE')
|
||||
- `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
|
||||
- `locale` (str): Locale code for localization (e.g., 'en_US', 'fr_FR', 'de_DE')
|
||||
|
||||
###### Returns
|
||||
|
||||
- `str`: Localized, formatted date string
|
||||
- `str`: Localized, formatted date string
|
||||
|
||||
###### Examples
|
||||
|
||||
@@ -559,15 +565,15 @@ See the [supported format codes](https://unicode.org/reports/tr35/tr35-dates.htm
|
||||
|
||||
### Format Presets
|
||||
|
||||
- **short**: Abbreviated format (e.g., "1/15/24")
|
||||
- **medium**: Medium-length format (e.g., "Jan 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")
|
||||
- **short**: Abbreviated format (e.g., "1/15/24")
|
||||
- **medium**: Medium-length format (e.g., "Jan 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")
|
||||
|
||||
#### 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
|
||||
- `{{ 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.
|
||||
- `{{ 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.
|
||||
|
||||
!!! tip
|
||||
|
||||
@@ -669,15 +675,15 @@ installation, you can use volumes to accomplish this:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
# ...
|
||||
webserver:
|
||||
environment:
|
||||
- PAPERLESS_ENABLE_FLOWER
|
||||
ports:
|
||||
- 5555:5555 # (2)!
|
||||
# ...
|
||||
webserver:
|
||||
environment:
|
||||
- PAPERLESS_ENABLE_FLOWER
|
||||
ports:
|
||||
- 5555:5555 # (2)!
|
||||
# ...
|
||||
volumes:
|
||||
- /path/to/my/flowerconfig.py:/usr/src/paperless/src/paperless/flowerconfig.py:ro # (1)!
|
||||
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.
|
||||
@@ -708,11 +714,11 @@ For example, using Docker Compose:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
# ...
|
||||
webserver:
|
||||
# ...
|
||||
webserver:
|
||||
# ...
|
||||
volumes:
|
||||
- /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
|
||||
@@ -765,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:
|
||||
|
||||
- AN-13/UPC-A
|
||||
- UPC-E
|
||||
- EAN-8
|
||||
- Code 128
|
||||
- Code 93
|
||||
- Code 39
|
||||
- Codabar
|
||||
- Interleaved 2 of 5
|
||||
- QR Code
|
||||
- SQ Code
|
||||
- AN-13/UPC-A
|
||||
- UPC-E
|
||||
- EAN-8
|
||||
- Code 128
|
||||
- Code 93
|
||||
- Code 39
|
||||
- Codabar
|
||||
- Interleaved 2 of 5
|
||||
- QR Code
|
||||
- SQ Code
|
||||
|
||||
For usage in Paperless, the type of barcode does not matter, only the contents of it.
|
||||
|
||||
@@ -787,8 +793,8 @@ below.
|
||||
If document splitting is enabled, Paperless splits _after_ a separator barcode by default.
|
||||
This means:
|
||||
|
||||
- any page containing the configured separator barcode starts a new document, starting with the **next** page
|
||||
- pages containing the separator barcode are discarded
|
||||
- any page containing the configured separator barcode starts a new document, starting with the **next** page
|
||||
- pages containing the separator barcode are discarded
|
||||
|
||||
This is intended for dedicated separator sheets such as PATCH-T pages.
|
||||
|
||||
@@ -825,10 +831,10 @@ to `true`.
|
||||
When enabled, documents will be split at pages containing tag barcodes, similar to how
|
||||
ASN barcodes work. Key features:
|
||||
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
|
||||
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.
|
||||
@@ -990,9 +996,9 @@ If using docker, you'll need to add the following volume mounts to your `docker-
|
||||
|
||||
```yaml
|
||||
webserver:
|
||||
volumes:
|
||||
- /home/user/.gnupg/pubring.gpg:/usr/src/paperless/.gnupg/pubring.gpg
|
||||
- <path to gpg-agent socket>:/usr/src/paperless/.gnupg/S.gpg-agent
|
||||
volumes:
|
||||
- /home/user/.gnupg/pubring.gpg:/usr/src/paperless/.gnupg/pubring.gpg
|
||||
- <path to gpg-agent socket>:/usr/src/paperless/.gnupg/S.gpg-agent
|
||||
```
|
||||
|
||||
For a 'bare-metal' installation no further configuration is necessary. If you
|
||||
@@ -1000,9 +1006,9 @@ want to use a separate `GNUPG_HOME`, you can do so by configuring the [PAPERLESS
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
- 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.
|
||||
- Check that all files in `/usr/src/paperless/.gnupg` have correct permissions
|
||||
- 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.
|
||||
- Check that all files in `/usr/src/paperless/.gnupg` have correct permissions
|
||||
|
||||
```shell
|
||||
paperless@9da1865df327:~/.gnupg$ ls -al
|
||||
|
||||
271
docs/api.md
271
docs/api.md
@@ -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
|
||||
results:
|
||||
|
||||
- `/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).
|
||||
- `/api/documents/?more_like_id=1234`: Search for documents similar to
|
||||
the document with id 1234.
|
||||
- `/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).
|
||||
- `/api/documents/?more_like_id=1234`: Search for documents similar to
|
||||
the document with id 1234.
|
||||
|
||||
Pagination works exactly the same as it does for normal requests on this
|
||||
endpoint.
|
||||
@@ -106,12 +106,12 @@ attribute with various information about the search results:
|
||||
}
|
||||
```
|
||||
|
||||
- `score` is an indication how well this document matches the query
|
||||
relative to the other search results.
|
||||
- `highlights` is an excerpt from the document content and highlights
|
||||
the search terms with `<span>` tags as shown above.
|
||||
- `rank` is the index of the search results. The first result will
|
||||
have rank 0.
|
||||
- `score` is an indication how well this document matches the query
|
||||
relative to the other search results.
|
||||
- `highlights` is an excerpt from the document content and highlights
|
||||
the search terms with `<span>` tags as shown above.
|
||||
- `rank` is the index of the search results. The first result will
|
||||
have rank 0.
|
||||
|
||||
### Filtering by custom fields
|
||||
|
||||
@@ -122,33 +122,33 @@ use cases:
|
||||
1. Documents with a custom field "due" (date) between Aug 1, 2024 and
|
||||
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"
|
||||
(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`:
|
||||
|
||||
`?custom_field_query=["answered", "exact", true]`
|
||||
`?custom_field_query=["answered", "exact", true]`
|
||||
|
||||
4. Documents with a custom field "favorite animal" (select) set to either
|
||||
"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:
|
||||
|
||||
`?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":
|
||||
|
||||
`?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:
|
||||
|
||||
`?custom_field_query=["references", "contains", [3, 7]]`
|
||||
`?custom_field_query=["references", "contains", [3, 7]]`
|
||||
|
||||
All field types support basic operations including `exact`, `in`, `isnull`,
|
||||
and `exists`. String, URL, and monetary fields support case-insensitive
|
||||
@@ -164,8 +164,8 @@ Get auto completions for a partial search term.
|
||||
|
||||
Query parameters:
|
||||
|
||||
- `term`: The incomplete term.
|
||||
- `limit`: Amount of results. Defaults to 10.
|
||||
- `term`: The incomplete term.
|
||||
- `limit`: Amount of results. Defaults to 10.
|
||||
|
||||
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
|
||||
@@ -189,19 +189,19 @@ from there.
|
||||
|
||||
The endpoint supports the following optional form fields:
|
||||
|
||||
- `title`: Specify a title that the consumer should use for the
|
||||
document.
|
||||
- `created`: Specify a DateTime where the document was created (e.g.
|
||||
"2016-04-19" or "2016-04-19 06:15:00+02:00").
|
||||
- `correspondent`: Specify the ID of a correspondent that the consumer
|
||||
should use for the document.
|
||||
- `document_type`: Similar to correspondent.
|
||||
- `storage_path`: Similar to correspondent.
|
||||
- `tags`: Similar to correspondent. Specify this multiple times to
|
||||
have multiple tags added to the document.
|
||||
- `archive_serial_number`: An optional archive serial number to set.
|
||||
- `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.
|
||||
- `title`: Specify a title that the consumer should use for the
|
||||
document.
|
||||
- `created`: Specify a DateTime where the document was created (e.g.
|
||||
"2016-04-19" or "2016-04-19 06:15:00+02:00").
|
||||
- `correspondent`: Specify the ID of a correspondent that the consumer
|
||||
should use for the document.
|
||||
- `document_type`: Similar to correspondent.
|
||||
- `storage_path`: Similar to correspondent.
|
||||
- `tags`: Similar to correspondent. Specify this multiple times to
|
||||
have multiple tags added to the document.
|
||||
- `archive_serial_number`: An optional archive serial number to set.
|
||||
- `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.
|
||||
|
||||
The endpoint will immediately return HTTP 200 if the document consumption
|
||||
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.
|
||||
|
||||
- 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.
|
||||
- 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-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.
|
||||
- `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}`.
|
||||
- `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.
|
||||
- `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.
|
||||
- `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`.
|
||||
- `DELETE /api/documents/{root_id}/versions/{version_id}/`: deletes a non-root version.
|
||||
|
||||
## Permissions
|
||||
|
||||
@@ -282,74 +282,38 @@ a json payload of the format:
|
||||
|
||||
The following methods are supported:
|
||||
|
||||
- `set_correspondent`
|
||||
- Requires `parameters`: `{ "correspondent": CORRESPONDENT_ID }`
|
||||
- `set_document_type`
|
||||
- Requires `parameters`: `{ "document_type": DOCUMENT_TYPE_ID }`
|
||||
- `set_storage_path`
|
||||
- Requires `parameters`: `{ "storage_path": STORAGE_PATH_ID }`
|
||||
- `add_tag`
|
||||
- Requires `parameters`: `{ "tag": TAG_ID }`
|
||||
- `remove_tag`
|
||||
- Requires `parameters`: `{ "tag": TAG_ID }`
|
||||
- `modify_tags`
|
||||
- Requires `parameters`: `{ "add_tags": [LIST_OF_TAG_IDS] }` and `{ "remove_tags": [LIST_OF_TAG_IDS] }`
|
||||
- `delete`
|
||||
- No `parameters` required
|
||||
- `reprocess`
|
||||
- No `parameters` required
|
||||
- `set_permissions`
|
||||
- Requires `parameters`:
|
||||
- `"set_permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or
|
||||
- `"owner": OWNER_ID or null`
|
||||
- `"merge": true or false` (defaults to false)
|
||||
- The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including
|
||||
removing them) or be merged with existing permissions.
|
||||
- `edit_pdf`
|
||||
- Requires `parameters`:
|
||||
- `"doc_ids": [DOCUMENT_ID]` A list of a single document ID to edit.
|
||||
- `"operations": [OPERATION, ...]` A list of operations to perform on the documents. Each operation is a dictionary
|
||||
with the following keys:
|
||||
- `"page": PAGE_NUMBER` The page number to edit (1-based).
|
||||
- `"rotate": DEGREES` Optional rotation in degrees (90, 180, 270).
|
||||
- `"doc": OUTPUT_DOCUMENT_INDEX` Optional index of the output document for split operations.
|
||||
- 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.
|
||||
- `set_correspondent`
|
||||
- Requires `parameters`: `{ "correspondent": CORRESPONDENT_ID }`
|
||||
- `set_document_type`
|
||||
- Requires `parameters`: `{ "document_type": DOCUMENT_TYPE_ID }`
|
||||
- `set_storage_path`
|
||||
- Requires `parameters`: `{ "storage_path": STORAGE_PATH_ID }`
|
||||
- `add_tag`
|
||||
- Requires `parameters`: `{ "tag": TAG_ID }`
|
||||
- `remove_tag`
|
||||
- Requires `parameters`: `{ "tag": TAG_ID }`
|
||||
- `modify_tags`
|
||||
- Requires `parameters`: `{ "add_tags": [LIST_OF_TAG_IDS] }` and `{ "remove_tags": [LIST_OF_TAG_IDS] }`
|
||||
- `delete`
|
||||
- No `parameters` required
|
||||
- `reprocess`
|
||||
- No `parameters` required
|
||||
- `set_permissions`
|
||||
- Requires `parameters`:
|
||||
- `"set_permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or
|
||||
- `"owner": OWNER_ID or null`
|
||||
- `"merge": true or false` (defaults to false)
|
||||
- The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including
|
||||
removing them) or be merged with existing permissions.
|
||||
- `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.
|
||||
|
||||
#### Document-editing 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.
|
||||
|
||||
### Objects
|
||||
|
||||
@@ -369,41 +333,38 @@ operations, using the endpoint: `/api/bulk_edit_objects/`, which requires a json
|
||||
|
||||
## API Versioning
|
||||
|
||||
The REST API is versioned since Paperless-ngx 1.3.0.
|
||||
The REST API is versioned.
|
||||
|
||||
- Versioning ensures that changes to the API don't break older
|
||||
clients.
|
||||
- Clients specify the specific version of the API they wish to use
|
||||
with every request and Paperless will handle the request using the
|
||||
specified API version.
|
||||
- Even if the underlying data model changes, older API versions will
|
||||
always serve compatible data.
|
||||
- If no version is specified, Paperless will serve version 1 to ensure
|
||||
compatibility with older clients that do not request a specific API
|
||||
version.
|
||||
- Versioning ensures that changes to the API don't break older
|
||||
clients.
|
||||
- Clients specify the specific version of the API they wish to use
|
||||
with every request and Paperless will handle the request using the
|
||||
specified API version.
|
||||
- Even if the underlying data model changes, supported older API
|
||||
versions continue to serve compatible data.
|
||||
- If no version is specified, Paperless serves the configured default
|
||||
API version (currently `10`).
|
||||
- Supported API versions are currently `9` and `10`.
|
||||
|
||||
API versions are specified by submitting an additional HTTP `Accept`
|
||||
header with every request:
|
||||
|
||||
```
|
||||
Accept: application/json; version=6
|
||||
Accept: application/json; version=10
|
||||
```
|
||||
|
||||
If an invalid version is specified, Paperless 1.3.0 will respond with
|
||||
"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 an invalid version is specified, Paperless responds with
|
||||
`406 Not Acceptable` and an error message in the body.
|
||||
|
||||
If a client wishes to verify whether it is compatible with any given
|
||||
server, the following procedure should be performed:
|
||||
|
||||
1. Perform an _authenticated_ request against any API endpoint. If the
|
||||
server is on version 1.3.0 or newer, the server will add two custom
|
||||
headers to the response:
|
||||
1. Perform an _authenticated_ request against any API endpoint. The
|
||||
server will add two custom headers to the response:
|
||||
|
||||
```
|
||||
X-Api-Version: 2
|
||||
X-Version: 1.3.0
|
||||
X-Api-Version: 10
|
||||
X-Version: <server-version>
|
||||
```
|
||||
|
||||
2. Determine whether the client is compatible with this server based on
|
||||
@@ -423,46 +384,56 @@ Initial API version.
|
||||
|
||||
#### Version 2
|
||||
|
||||
- Added field `Tag.color`. This read/write string field contains a hex
|
||||
color such as `#a6cee3`.
|
||||
- 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
|
||||
depending on the brightness of `Tag.color`.
|
||||
- Removed field `Tag.colour`.
|
||||
- Added field `Tag.color`. This read/write string field contains a hex
|
||||
color such as `#a6cee3`.
|
||||
- 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
|
||||
depending on the brightness of `Tag.color`.
|
||||
- Removed field `Tag.colour`.
|
||||
|
||||
#### Version 3
|
||||
|
||||
- Permissions endpoints have been added.
|
||||
- The format of the `/api/ui_settings/` has changed.
|
||||
- Permissions endpoints have been added.
|
||||
- The format of the `/api/ui_settings/` has changed.
|
||||
|
||||
#### Version 4
|
||||
|
||||
- Consumption templates were refactored to workflows and API endpoints
|
||||
changed as such.
|
||||
- Consumption templates were refactored to workflows and API endpoints
|
||||
changed as such.
|
||||
|
||||
#### Version 5
|
||||
|
||||
- Added bulk deletion methods for documents and objects.
|
||||
- Added bulk deletion methods for documents and objects.
|
||||
|
||||
#### Version 6
|
||||
|
||||
- Moved acknowledge tasks endpoint to be under `/api/tasks/acknowledge/`.
|
||||
- Moved acknowledge tasks endpoint to be under `/api/tasks/acknowledge/`.
|
||||
|
||||
#### Version 7
|
||||
|
||||
- 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
|
||||
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
|
||||
the option whereas previously was the index of the option.
|
||||
- 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
|
||||
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
|
||||
the option whereas previously was the index of the option.
|
||||
|
||||
#### Version 8
|
||||
|
||||
- The user field of document notes now returns a simplified user object
|
||||
rather than just the user ID.
|
||||
- The user field of document notes now returns a simplified user object
|
||||
rather than just the user ID.
|
||||
|
||||
#### Version 9
|
||||
|
||||
- The document `created` field is now a date, not a datetime. The
|
||||
`created_date` field is considered deprecated and will be removed in a
|
||||
future version.
|
||||
- The document `created` field is now a date, not a datetime. The
|
||||
`created_date` field is considered deprecated and will be removed in a
|
||||
future version.
|
||||
|
||||
#### Version 10
|
||||
|
||||
- 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
|
||||
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.
|
||||
|
||||
9982
docs/changelog.md
9982
docs/changelog.md
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
or applicable default will be utilized instead.
|
||||
|
||||
- If you run paperless on docker, `paperless.conf` is not used.
|
||||
Rather, configure paperless by copying necessary options to
|
||||
`docker-compose.env`.
|
||||
- If you run paperless on docker, `paperless.conf` is not used.
|
||||
Rather, configure paperless by copying necessary options to
|
||||
`docker-compose.env`.
|
||||
|
||||
- If you are running paperless on anything else, paperless will search
|
||||
for the configuration file in these locations and use the first one
|
||||
it finds:
|
||||
- The environment variable `PAPERLESS_CONFIGURATION_PATH`
|
||||
- `/path/to/paperless/paperless.conf`
|
||||
- `/etc/paperless.conf`
|
||||
- `/usr/local/etc/paperless.conf`
|
||||
- If you are running paperless on anything else, paperless will search
|
||||
for the configuration file in these locations and use the first one
|
||||
it finds:
|
||||
- The environment variable `PAPERLESS_CONFIGURATION_PATH`
|
||||
- `/path/to/paperless/paperless.conf`
|
||||
- `/etc/paperless.conf`
|
||||
- `/usr/local/etc/paperless.conf`
|
||||
|
||||
## Required services
|
||||
|
||||
|
||||
@@ -6,23 +6,23 @@ on Paperless-ngx.
|
||||
Check out the source from GitHub. The repository is organized in the
|
||||
following way:
|
||||
|
||||
- `main` always represents the latest release and will only see
|
||||
changes when a new release is made.
|
||||
- `dev` contains the code that will be in the next release.
|
||||
- `feature-X` contains bigger changes that will be in some release, but
|
||||
not necessarily the next one.
|
||||
- `main` always represents the latest release and will only see
|
||||
changes when a new release is made.
|
||||
- `dev` contains the code that will be in the next release.
|
||||
- `feature-X` contains bigger changes that will be in some release, but
|
||||
not necessarily the next one.
|
||||
|
||||
When making functional changes to Paperless-ngx, _always_ make your changes
|
||||
on the `dev` branch.
|
||||
|
||||
Apart from that, the folder structure is as follows:
|
||||
|
||||
- `docs/` - Documentation.
|
||||
- `src-ui/` - Code of the front end.
|
||||
- `src/` - Code of the back end.
|
||||
- `scripts/` - Various scripts that help with different parts of
|
||||
development.
|
||||
- `docker/` - Files required to build the docker image.
|
||||
- `docs/` - Documentation.
|
||||
- `src-ui/` - Code of the front end.
|
||||
- `src/` - Code of the back end.
|
||||
- `scripts/` - Various scripts that help with different parts of
|
||||
development.
|
||||
- `docker/` - Files required to build the docker image.
|
||||
|
||||
## Contributing to Paperless-ngx
|
||||
|
||||
@@ -75,13 +75,13 @@ first-time setup.
|
||||
4. Install the Python dependencies:
|
||||
|
||||
```bash
|
||||
$ uv sync --group dev
|
||||
uv sync --group dev
|
||||
```
|
||||
|
||||
5. Install pre-commit hooks:
|
||||
|
||||
```bash
|
||||
$ uv run prek install
|
||||
uv run prek install
|
||||
```
|
||||
|
||||
6. Apply migrations and create a superuser (also can be done via the web UI) for your development instance:
|
||||
@@ -89,23 +89,22 @@ first-time setup.
|
||||
```bash
|
||||
# src/
|
||||
|
||||
$ uv run manage.py migrate
|
||||
$ uv run manage.py createsuperuser
|
||||
uv run manage.py migrate
|
||||
uv run manage.py createsuperuser
|
||||
```
|
||||
|
||||
7. You can now either ...
|
||||
- install Redis or
|
||||
|
||||
- install Redis or
|
||||
- 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
|
||||
|
||||
- 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
|
||||
- spin up a bare Redis container
|
||||
|
||||
- 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 :-).
|
||||
|
||||
@@ -118,18 +117,18 @@ 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 following launch configurations in your IDE:
|
||||
|
||||
- `python3 manage.py runserver`
|
||||
- `python3 manage.py document_consumer`
|
||||
- `celery --app paperless worker -l DEBUG` (or any other log level)
|
||||
- `uv run manage.py runserver`
|
||||
- `uv run manage.py document_consumer`
|
||||
- `uv run celery --app paperless worker -l DEBUG` (or any other log level)
|
||||
|
||||
To start them all:
|
||||
|
||||
```bash
|
||||
# src/
|
||||
|
||||
$ python3 manage.py runserver & \
|
||||
python3 manage.py document_consumer & \
|
||||
celery --app paperless worker -l DEBUG
|
||||
uv run manage.py runserver & \
|
||||
uv run manage.py document_consumer & \
|
||||
uv run celery --app paperless worker -l DEBUG
|
||||
```
|
||||
|
||||
You might need the front end to test your back end code.
|
||||
@@ -140,17 +139,17 @@ To build the front end once use this command:
|
||||
```bash
|
||||
# src-ui/
|
||||
|
||||
$ pnpm install
|
||||
$ ng build --configuration production
|
||||
pnpm install
|
||||
pnpm ng build --configuration production
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
- Run `pytest` in the `src/` directory to execute all tests. This also
|
||||
generates a HTML coverage report. When running tests, `paperless.conf`
|
||||
is loaded as well. However, the tests rely on the default
|
||||
configuration. This is not ideal. But for now, make sure no settings
|
||||
except for DEBUG are overridden when testing.
|
||||
- Run `pytest` in the `src/` directory to execute all tests. This also
|
||||
generates a HTML coverage report. When running tests, `paperless.conf`
|
||||
is loaded as well. However, the tests rely on the default
|
||||
configuration. This is not ideal. But for now, make sure no settings
|
||||
except for DEBUG are overridden when testing.
|
||||
|
||||
!!! note
|
||||
|
||||
@@ -199,7 +198,7 @@ The front end is built using AngularJS. In order to get started, you need Node.j
|
||||
4. You can launch a development server by running:
|
||||
|
||||
```bash
|
||||
ng serve
|
||||
pnpm ng serve
|
||||
```
|
||||
|
||||
This will automatically update whenever you save. However, in-place
|
||||
@@ -217,21 +216,21 @@ commit. See [above](#code-formatting-with-pre-commit-hooks) for installation ins
|
||||
command such as
|
||||
|
||||
```bash
|
||||
$ git ls-files -- '*.ts' | xargs prek run prettier --files
|
||||
git ls-files -- '*.ts' | xargs uv run prek run prettier --files
|
||||
```
|
||||
|
||||
Front end testing uses Jest and Playwright. Unit tests and e2e tests,
|
||||
respectively, can be run non-interactively with:
|
||||
|
||||
```bash
|
||||
$ ng test
|
||||
$ npx playwright test
|
||||
pnpm ng test
|
||||
pnpm playwright test
|
||||
```
|
||||
|
||||
Playwright also includes a UI which can be run with:
|
||||
|
||||
```bash
|
||||
$ npx playwright test --ui
|
||||
pnpm playwright test --ui
|
||||
```
|
||||
|
||||
### Building the frontend
|
||||
@@ -239,7 +238,7 @@ $ npx playwright test --ui
|
||||
In order to build the front end and serve it as part of Django, execute:
|
||||
|
||||
```bash
|
||||
$ ng build --configuration production
|
||||
pnpm ng build --configuration production
|
||||
```
|
||||
|
||||
This will build the front end and put it in a location from which the
|
||||
@@ -254,14 +253,14 @@ these parts have to be translated separately.
|
||||
|
||||
### Front end localization
|
||||
|
||||
- The AngularJS front end does localization according to the [Angular
|
||||
documentation](https://angular.io/guide/i18n).
|
||||
- The source language of the project is "en_US".
|
||||
- The source strings end up in the file `src-ui/messages.xlf`.
|
||||
- The translated strings need to be placed in the
|
||||
`src-ui/src/locale/` folder.
|
||||
- In order to extract added or changed strings from the source files,
|
||||
call `ng extract-i18n`.
|
||||
- The AngularJS front end does localization according to the [Angular
|
||||
documentation](https://angular.io/guide/i18n).
|
||||
- The source language of the project is "en_US".
|
||||
- The source strings end up in the file `src-ui/messages.xlf`.
|
||||
- The translated strings need to be placed in the
|
||||
`src-ui/src/locale/` folder.
|
||||
- In order to extract added or changed strings from the source files,
|
||||
call `ng extract-i18n`.
|
||||
|
||||
Adding new languages requires adding the translated files in the
|
||||
`src-ui/src/locale/` folder and adjusting a couple files.
|
||||
@@ -307,18 +306,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
|
||||
end (such as error messages).
|
||||
|
||||
- The django application does localization according to the [Django
|
||||
documentation](https://docs.djangoproject.com/en/3.1/topics/i18n/translation/).
|
||||
- The source language of the project is "en_US".
|
||||
- Localization files end up in the folder `src/locale/`.
|
||||
- In order to extract strings from the application, call
|
||||
`python3 manage.py makemessages -l en_US`. This is important after
|
||||
making changes to translatable strings.
|
||||
- The message files need to be compiled for them to show up in the
|
||||
application. Call `python3 manage.py compilemessages` to do this.
|
||||
The generated files don't get committed into git, since these are
|
||||
derived artifacts. The build pipeline takes care of executing this
|
||||
command.
|
||||
- The django application does localization according to the [Django
|
||||
documentation](https://docs.djangoproject.com/en/3.1/topics/i18n/translation/).
|
||||
- The source language of the project is "en_US".
|
||||
- Localization files end up in the folder `src/locale/`.
|
||||
- In order to extract strings from the application, call
|
||||
`uv run manage.py makemessages -l en_US`. This is important after
|
||||
making changes to translatable strings.
|
||||
- The message files need to be compiled for them to show up in the
|
||||
application. Call `uv run manage.py compilemessages` to do this.
|
||||
The generated files don't get committed into git, since these are
|
||||
derived artifacts. The build pipeline takes care of executing this
|
||||
command.
|
||||
|
||||
Adding new languages requires adding the translated files in the
|
||||
`src/locale/`-folder and adjusting the file
|
||||
@@ -381,10 +380,10 @@ base code.
|
||||
Paperless-ngx uses parsers to add documents. A parser is
|
||||
responsible for:
|
||||
|
||||
- Retrieving the content from the original
|
||||
- Creating a thumbnail
|
||||
- _optional:_ Retrieving a created date from the original
|
||||
- _optional:_ Creating an archived document from the original
|
||||
- Retrieving the content from the original
|
||||
- Creating a thumbnail
|
||||
- _optional:_ Retrieving a created date from the original
|
||||
- _optional:_ Creating an archived document from the original
|
||||
|
||||
Custom parsers can be added to Paperless-ngx to support more file types. In
|
||||
order to do that, you need to write the parser itself and announce its
|
||||
@@ -442,17 +441,17 @@ def myparser_consumer_declaration(sender, **kwargs):
|
||||
}
|
||||
```
|
||||
|
||||
- `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.
|
||||
- `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.
|
||||
|
||||
## Using Visual Studio Code devcontainer
|
||||
|
||||
@@ -471,9 +470,8 @@ To get started:
|
||||
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.
|
||||
- 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
|
||||
@@ -538,12 +536,12 @@ class MyDateParserPlugin(DateParserPluginBase):
|
||||
|
||||
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
|
||||
- `timezone_str: str` - Timezone string for date localization
|
||||
- `ignore_dates: set[datetime.date]` - Dates that should be filtered out
|
||||
- `reference_time: datetime.datetime` - Current time for filtering future dates
|
||||
- `filename_date_order: str | None` - Date order preference for filenames (e.g., "DMY", "MDY")
|
||||
- `content_date_order: str` - Date order preference for content
|
||||
- `languages: list[str]` - List of language codes for date parsing
|
||||
- `timezone_str: str` - Timezone string for date localization
|
||||
- `ignore_dates: set[datetime.date]` - Dates that should be filtered out
|
||||
- `reference_time: datetime.datetime` - Current time for filtering future dates
|
||||
- `filename_date_order: str | None` - Date order preference for filenames (e.g., "DMY", "MDY")
|
||||
- `content_date_order: str` - Date order preference for content
|
||||
|
||||
The base class provides two helper methods you can use:
|
||||
|
||||
|
||||
34
docs/faq.md
34
docs/faq.md
@@ -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
|
||||
elsewhere. Here are a couple notes about that.
|
||||
|
||||
- Paperless-ngx never modifies your original documents. It keeps
|
||||
checksums of all documents and uses a scheduled sanity checker to
|
||||
check that they remain the same.
|
||||
- By default, paperless uses the internal ID of each document as its
|
||||
filename. This might not be very convenient for export. However, you
|
||||
can adjust the way files are stored in paperless by
|
||||
[configuring the filename format](advanced_usage.md#file-name-handling).
|
||||
- [The exporter](administration.md#exporter) is
|
||||
another easy way to get your files out of paperless with reasonable
|
||||
file names.
|
||||
- Paperless-ngx never modifies your original documents. It keeps
|
||||
checksums of all documents and uses a scheduled sanity checker to
|
||||
check that they remain the same.
|
||||
- By default, paperless uses the internal ID of each document as its
|
||||
filename. This might not be very convenient for export. However, you
|
||||
can adjust the way files are stored in paperless by
|
||||
[configuring the filename format](advanced_usage.md#file-name-handling).
|
||||
- [The exporter](administration.md#exporter) is
|
||||
another easy way to get your files out of paperless with reasonable
|
||||
file names.
|
||||
|
||||
## _What file types does paperless-ngx support?_
|
||||
|
||||
**A:** Currently, the following files are supported:
|
||||
|
||||
- PDF documents, PNG images, JPEG images, TIFF images, GIF images and
|
||||
WebP images are processed with OCR and converted into PDF documents.
|
||||
- Plain text documents are supported as well and are added verbatim to
|
||||
paperless.
|
||||
- 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,
|
||||
.ppt, .pptx, .odp, .xls, .xlsx, .ods).
|
||||
- PDF documents, PNG images, JPEG images, TIFF images, GIF images and
|
||||
WebP images are processed with OCR and converted into PDF documents.
|
||||
- Plain text documents are supported as well and are added verbatim to
|
||||
paperless.
|
||||
- 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,
|
||||
.ppt, .pptx, .odp, .xls, .xlsx, .ods).
|
||||
|
||||
Paperless-ngx determines the type of a file by inspecting its content
|
||||
rather than its file extensions. However, files processed via the
|
||||
|
||||
@@ -28,36 +28,36 @@ physical documents into a searchable online archive so you can keep, well, _less
|
||||
|
||||
## Features
|
||||
|
||||
- **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.
|
||||
- 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.
|
||||
- _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.
|
||||
- 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).
|
||||
- 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.
|
||||
- **Beautiful, modern web application** that features:
|
||||
- Customizable dashboard with statistics.
|
||||
- Filtering by tags, correspondents, types, and more.
|
||||
- Bulk editing of tags, correspondents, types and more.
|
||||
- Drag-and-drop uploading of documents throughout the app.
|
||||
- Customizable views can be saved and displayed on the dashboard and / or sidebar.
|
||||
- Support for custom fields of various data types.
|
||||
- Shareable public links with optional expiration.
|
||||
- **Full text search** helps you find what you need:
|
||||
- Auto completion suggests relevant words from your documents.
|
||||
- Results are sorted by relevance to your search query.
|
||||
- Highlighting shows you which parts of the document matched the query.
|
||||
- Searching for similar documents ("More like this")
|
||||
- **Email processing**[^1]: import documents from your email accounts:
|
||||
- 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.
|
||||
- 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.
|
||||
- **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.
|
||||
- **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.
|
||||
- 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.
|
||||
- _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.
|
||||
- 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).
|
||||
- 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.
|
||||
- **Beautiful, modern web application** that features:
|
||||
- Customizable dashboard with statistics.
|
||||
- Filtering by tags, correspondents, types, and more.
|
||||
- Bulk editing of tags, correspondents, types and more.
|
||||
- Drag-and-drop uploading of documents throughout the app.
|
||||
- Customizable views can be saved and displayed on the dashboard and / or sidebar.
|
||||
- Support for custom fields of various data types.
|
||||
- Shareable public links with optional expiration.
|
||||
- **Full text search** helps you find what you need:
|
||||
- Auto completion suggests relevant words from your documents.
|
||||
- Results are sorted by relevance to your search query.
|
||||
- Highlighting shows you which parts of the document matched the query.
|
||||
- Searching for similar documents ("More like this")
|
||||
- **Email processing**[^1]: import documents from your email accounts:
|
||||
- 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.
|
||||
- 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.
|
||||
- **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.
|
||||
|
||||
[^1]: Office document and email consumption support is optional and provided by Apache Tika (see [configuration](https://docs.paperless-ngx.com/configuration/#tika))
|
||||
|
||||
|
||||
@@ -42,12 +42,12 @@ The `CONSUMER_BARCODE_SCANNER` setting has been removed. zxing-cpp is now the on
|
||||
|
||||
### Action Required
|
||||
|
||||
- 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
|
||||
removing the setting. zxing-cpp supports all the same barcode formats and you should see improved detection
|
||||
reliability.
|
||||
- The `libzbar0` / `libzbar-dev` system packages are no longer required and can be removed from any custom Docker
|
||||
images or host installations.
|
||||
- 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
|
||||
removing the setting. zxing-cpp supports all the same barcode formats and you should see improved detection
|
||||
reliability.
|
||||
- The `libzbar0` / `libzbar-dev` system packages are no longer required and can be removed from any custom Docker
|
||||
images or host installations.
|
||||
|
||||
## Database Engine
|
||||
|
||||
@@ -72,6 +72,30 @@ PAPERLESS_DBHOST: postgres
|
||||
|
||||
See [`PAPERLESS_DBENGINE`](configuration.md#PAPERLESS_DBENGINE) for accepted values.
|
||||
|
||||
## Management Command: `--skip-checks` Removed
|
||||
|
||||
The `--skip-checks` flag has been removed from all Paperless-ngx management commands
|
||||
(`document_exporter`, `document_importer`, `document_retagger`, `document_archiver`,
|
||||
`document_thumbnails`, `document_index`, `document_renamer`, `document_sanity_checker`,
|
||||
`document_fuzzy_match`, and others).
|
||||
|
||||
These commands now set `requires_system_checks = []` internally, which both skips
|
||||
redundant checks at runtime (they are already run as a dedicated step during Docker
|
||||
startup via `init-system-checks`) and removes `--skip-checks` from the argument parser.
|
||||
|
||||
#### Action Required
|
||||
|
||||
Remove `--skip-checks` from any scripts, cron jobs, or automation that invokes
|
||||
these commands:
|
||||
|
||||
```bash
|
||||
# v2
|
||||
document_exporter /backup --skip-checks
|
||||
|
||||
# v3
|
||||
document_exporter /backup
|
||||
```
|
||||
|
||||
## Database Advanced Options
|
||||
|
||||
The individual SSL, timeout, and pooling variables have been removed in favor of a
|
||||
|
||||
235
docs/setup.md
235
docs/setup.md
@@ -44,8 +44,8 @@ account. In short, it automates the [Docker Compose setup](#docker) described be
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
- 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).
|
||||
- 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).
|
||||
|
||||
#### Run the installation script
|
||||
|
||||
@@ -63,7 +63,7 @@ credentials you provided during the installation script.
|
||||
|
||||
#### 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
|
||||
|
||||
@@ -101,7 +101,7 @@ credentials you provided during the installation script.
|
||||
|
||||
```yaml
|
||||
ports:
|
||||
- 8010:8000
|
||||
- 8010:8000
|
||||
```
|
||||
|
||||
3. Modify `docker-compose.env` with any configuration options you need.
|
||||
@@ -145,11 +145,11 @@ a [superuser](usage.md#superusers) account.
|
||||
If you want to run Paperless as a rootless container, make this
|
||||
change in `docker-compose.yml`:
|
||||
|
||||
- Set the `user` running the container to map to the `paperless`
|
||||
user in the container. This value (`user_id` below) should be
|
||||
the same ID that `USERMAP_UID` and `USERMAP_GID` are set to in
|
||||
`docker-compose.env`. See `USERMAP_UID` and `USERMAP_GID`
|
||||
[here](configuration.md#docker).
|
||||
- Set the `user` running the container to map to the `paperless`
|
||||
user in the container. This value (`user_id` below) should be
|
||||
the same ID that `USERMAP_UID` and `USERMAP_GID` are set to in
|
||||
`docker-compose.env`. See `USERMAP_UID` and `USERMAP_GID`
|
||||
[here](configuration.md#docker).
|
||||
|
||||
Your entry for Paperless should contain something like:
|
||||
|
||||
@@ -171,26 +171,25 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
- Paperless runs on Linux only, Windows is not supported.
|
||||
- Python 3 is required with versions 3.10 - 3.12 currently supported. Newer versions may work, but some dependencies may not be fully compatible.
|
||||
- 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.
|
||||
|
||||
#### Installation
|
||||
|
||||
1. Install dependencies. Paperless requires the following packages:
|
||||
|
||||
- `python3`
|
||||
- `python3-pip`
|
||||
- `python3-dev`
|
||||
- `default-libmysqlclient-dev` for MariaDB
|
||||
- `pkg-config` for mysqlclient (python dependency)
|
||||
- `fonts-liberation` for generating thumbnails for plain text
|
||||
files
|
||||
- `imagemagick` >= 6 for PDF conversion
|
||||
- `gnupg` for handling encrypted documents
|
||||
- `libpq-dev` for PostgreSQL
|
||||
- `libmagic-dev` for mime type detection
|
||||
- `mariadb-client` for MariaDB compile time
|
||||
- `poppler-utils` for barcode detection
|
||||
- `python3`
|
||||
- `python3-pip`
|
||||
- `python3-dev`
|
||||
- `default-libmysqlclient-dev` for MariaDB
|
||||
- `pkg-config` for mysqlclient (python dependency)
|
||||
- `fonts-liberation` for generating thumbnails for plain text
|
||||
files
|
||||
- `imagemagick` >= 6 for PDF conversion
|
||||
- `gnupg` for handling encrypted documents
|
||||
- `libpq-dev` for PostgreSQL
|
||||
- `libmagic-dev` for mime type detection
|
||||
- `mariadb-client` for MariaDB compile time
|
||||
- `poppler-utils` for barcode detection
|
||||
|
||||
Use this list for your preferred package management:
|
||||
|
||||
@@ -200,18 +199,17 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
|
||||
|
||||
These dependencies are required for OCRmyPDF, which is used for text
|
||||
recognition.
|
||||
|
||||
- `unpaper`
|
||||
- `ghostscript`
|
||||
- `icc-profiles-free`
|
||||
- `qpdf`
|
||||
- `liblept5`
|
||||
- `libxml2`
|
||||
- `pngquant` (suggested for certain PDF image optimizations)
|
||||
- `zlib1g`
|
||||
- `tesseract-ocr` >= 4.0.0 for OCR
|
||||
- `tesseract-ocr` language packs (`tesseract-ocr-eng`,
|
||||
`tesseract-ocr-deu`, etc)
|
||||
- `unpaper`
|
||||
- `ghostscript`
|
||||
- `icc-profiles-free`
|
||||
- `qpdf`
|
||||
- `liblept5`
|
||||
- `libxml2`
|
||||
- `pngquant` (suggested for certain PDF image optimizations)
|
||||
- `zlib1g`
|
||||
- `tesseract-ocr` >= 4.0.0 for OCR
|
||||
- `tesseract-ocr` language packs (`tesseract-ocr-eng`,
|
||||
`tesseract-ocr-deu`, etc)
|
||||
|
||||
Use this list for your preferred package management:
|
||||
|
||||
@@ -220,16 +218,14 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
|
||||
```
|
||||
|
||||
On Raspberry Pi, these libraries are required as well:
|
||||
|
||||
- `libatlas-base-dev`
|
||||
- `libxslt1-dev`
|
||||
- `mime-support`
|
||||
- `libatlas-base-dev`
|
||||
- `libxslt1-dev`
|
||||
- `mime-support`
|
||||
|
||||
You will also need these for installing some of the python dependencies:
|
||||
|
||||
- `build-essential`
|
||||
- `python3-setuptools`
|
||||
- `python3-wheel`
|
||||
- `build-essential`
|
||||
- `python3-setuptools`
|
||||
- `python3-wheel`
|
||||
|
||||
Use this list for your preferred package management:
|
||||
|
||||
@@ -279,44 +275,41 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
|
||||
6. Configure Paperless-ngx. See [configuration](configuration.md) for details.
|
||||
Edit the included `paperless.conf` and adjust the settings to your
|
||||
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_DBENGINE`](configuration.md#PAPERLESS_DBENGINE) is optional, and should be one of `postgres`,
|
||||
`mariadb`, or `sqlite`
|
||||
- [`PAPERLESS_DBHOST`](configuration.md#PAPERLESS_DBHOST) should be the hostname on which your
|
||||
PostgreSQL server is running. Do not configure this to use
|
||||
SQLite instead. Also configure port, database name, user and
|
||||
password as necessary.
|
||||
- [`PAPERLESS_CONSUMPTION_DIR`](configuration.md#PAPERLESS_CONSUMPTION_DIR) should point to the folder
|
||||
that Paperless-ngx should watch for incoming documents.
|
||||
Likewise, [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) and
|
||||
[`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) define where Paperless-ngx stores its data.
|
||||
If needed, these can point to the same directory.
|
||||
- [`PAPERLESS_SECRET_KEY`](configuration.md#PAPERLESS_SECRET_KEY) should be a random sequence of
|
||||
characters. It's used for authentication. Failure to do so
|
||||
allows third parties to forge authentication credentials.
|
||||
- Set [`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) if you are behind a reverse proxy. This should
|
||||
point to your domain. Please see
|
||||
[configuration](configuration.md) for more
|
||||
information.
|
||||
- [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) should point to your Redis server, such as
|
||||
`redis://localhost:6379`.
|
||||
- [`PAPERLESS_DBENGINE`](configuration.md#PAPERLESS_DBENGINE) is optional, and should be one of `postgres`,
|
||||
`mariadb`, or `sqlite`
|
||||
- [`PAPERLESS_DBHOST`](configuration.md#PAPERLESS_DBHOST) should be the hostname on which your
|
||||
PostgreSQL server is running. Do not configure this to use
|
||||
SQLite instead. Also configure port, database name, user and
|
||||
password as necessary.
|
||||
- [`PAPERLESS_CONSUMPTION_DIR`](configuration.md#PAPERLESS_CONSUMPTION_DIR) should point to the folder
|
||||
that Paperless-ngx should watch for incoming documents.
|
||||
Likewise, [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) and
|
||||
[`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) define where Paperless-ngx stores its data.
|
||||
If needed, these can point to the same directory.
|
||||
- [`PAPERLESS_SECRET_KEY`](configuration.md#PAPERLESS_SECRET_KEY) should be a random sequence of
|
||||
characters. It's used for authentication. Failure to do so
|
||||
allows third parties to forge authentication credentials.
|
||||
- Set [`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) if you are behind a reverse proxy. This should
|
||||
point to your domain. Please see
|
||||
[configuration](configuration.md) for more
|
||||
information.
|
||||
|
||||
You can make many more adjustments, especially for OCR.
|
||||
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_TIME_ZONE`](configuration.md#PAPERLESS_TIME_ZONE) to your local time zone.
|
||||
- Set [`PAPERLESS_OCR_LANGUAGE`](configuration.md#PAPERLESS_OCR_LANGUAGE) to the language most of your
|
||||
documents are written in.
|
||||
- Set [`PAPERLESS_TIME_ZONE`](configuration.md#PAPERLESS_TIME_ZONE) to your local time zone.
|
||||
|
||||
!!! warning
|
||||
|
||||
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:
|
||||
|
||||
- `/opt/paperless/media`
|
||||
- `/opt/paperless/data`
|
||||
- `/opt/paperless/consume`
|
||||
- `/opt/paperless/media`
|
||||
- `/opt/paperless/data`
|
||||
- `/opt/paperless/consume`
|
||||
|
||||
Adjust these paths if you configured different folders.
|
||||
Then verify that the `paperless` user has write permissions:
|
||||
@@ -391,11 +384,10 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
|
||||
starting point.
|
||||
|
||||
Paperless needs:
|
||||
|
||||
- The `webserver` script to run the webserver.
|
||||
- The `consumer` script to watch the input folder.
|
||||
- The `taskqueue` script for background workers (document consumption, etc.).
|
||||
- The `scheduler` script for periodic tasks such as email checking.
|
||||
- The `webserver` script to run the webserver.
|
||||
- The `consumer` script to watch the input folder.
|
||||
- The `taskqueue` script for background workers (document consumption, etc.).
|
||||
- The `scheduler` script for periodic tasks such as email checking.
|
||||
|
||||
!!! note
|
||||
|
||||
@@ -501,19 +493,19 @@ your setup depending on how you installed Paperless.
|
||||
This section describes how to update an existing Paperless Docker
|
||||
installation. Keep these points in mind:
|
||||
|
||||
- Read the [changelog](changelog.md) and
|
||||
take note of breaking changes.
|
||||
- Decide whether to stay on SQLite or migrate to PostgreSQL.
|
||||
Both work fine with Paperless-ngx.
|
||||
However, if you already have a database server running
|
||||
for other services, you might as well use it for Paperless as well.
|
||||
- The task scheduler of Paperless, which is used to execute periodic
|
||||
tasks such as email checking and maintenance, requires a
|
||||
[Redis](https://redis.io/) message broker instance. The
|
||||
Docker Compose route takes care of that.
|
||||
- The layout of the folder structure for your documents and data
|
||||
remains the same, so you can plug your old Docker volumes into
|
||||
paperless-ngx and expect it to find everything where it should be.
|
||||
- Read the [changelog](changelog.md) and
|
||||
take note of breaking changes.
|
||||
- Decide whether to stay on SQLite or migrate to PostgreSQL.
|
||||
Both work fine with Paperless-ngx.
|
||||
However, if you already have a database server running
|
||||
for other services, you might as well use it for Paperless as well.
|
||||
- The task scheduler of Paperless, which is used to execute periodic
|
||||
tasks such as email checking and maintenance, requires a
|
||||
[Redis](https://redis.io/) message broker instance. The
|
||||
Docker Compose route takes care of that.
|
||||
- The layout of the folder structure for your documents and data
|
||||
remains the same, so you can plug your old Docker volumes into
|
||||
paperless-ngx and expect it to find everything where it should be.
|
||||
|
||||
Migration to Paperless-ngx is then performed in a few simple steps:
|
||||
|
||||
@@ -598,7 +590,6 @@ commands as well.
|
||||
1. Stop and remove the Paperless container.
|
||||
2. If using an external database, stop that container.
|
||||
3. Update Redis configuration.
|
||||
|
||||
1. If `REDIS_URL` is already set, change it to [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS)
|
||||
and continue to step 4.
|
||||
|
||||
@@ -610,22 +601,18 @@ commands as well.
|
||||
the new Redis container.
|
||||
|
||||
4. Update user mapping.
|
||||
|
||||
1. If set, change the environment variable `PUID` to `USERMAP_UID`.
|
||||
|
||||
1. If set, change the environment variable `PGID` to `USERMAP_GID`.
|
||||
|
||||
5. Update configuration paths.
|
||||
|
||||
1. Set the environment variable [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) to `/config`.
|
||||
|
||||
6. Update media paths.
|
||||
|
||||
1. Set the environment variable [`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) to
|
||||
`/data/media`.
|
||||
|
||||
7. Update timezone.
|
||||
|
||||
1. Set the environment variable [`PAPERLESS_TIME_ZONE`](configuration.md#PAPERLESS_TIME_ZONE) to the same
|
||||
value as `TZ`.
|
||||
|
||||
@@ -639,33 +626,33 @@ commands as well.
|
||||
Paperless runs on Raspberry Pi. Some tasks can be slow on lower-powered
|
||||
hardware, but a few settings can improve performance:
|
||||
|
||||
- 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 do not need the filesystem-based consumer, consider disabling it
|
||||
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
|
||||
OCRs only the first page of your documents. In most cases, this page
|
||||
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
|
||||
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
|
||||
worker. This may result in sluggish response times during
|
||||
consumption, so you might want to lower these settings (example: 2
|
||||
workers and 1 thread to always have some computing power left for
|
||||
other tasks).
|
||||
- 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
|
||||
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
|
||||
file generation for already OCRed documents, or `always` to skip it
|
||||
for all documents.
|
||||
- If you want to perform OCR on the device, consider using
|
||||
`PAPERLESS_OCR_CLEAN=none`. This will speed up OCR times and use
|
||||
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.
|
||||
- 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
|
||||
processing time.
|
||||
- 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 do not need the filesystem-based consumer, consider disabling it
|
||||
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
|
||||
OCRs only the first page of your documents. In most cases, this page
|
||||
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
|
||||
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
|
||||
worker. This may result in sluggish response times during
|
||||
consumption, so you might want to lower these settings (example: 2
|
||||
workers and 1 thread to always have some computing power left for
|
||||
other tasks).
|
||||
- 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
|
||||
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
|
||||
file generation for already OCRed documents, or `always` to skip it
|
||||
for all documents.
|
||||
- If you want to perform OCR on the device, consider using
|
||||
`PAPERLESS_OCR_CLEAN=none`. This will speed up OCR times and use
|
||||
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.
|
||||
- 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
|
||||
processing time.
|
||||
|
||||
For details, refer to [configuration](configuration.md).
|
||||
|
||||
|
||||
@@ -4,27 +4,27 @@
|
||||
|
||||
Check for the following issues:
|
||||
|
||||
- Ensure that the directory you're putting your documents in is the
|
||||
folder paperless is watching. With docker, this setting is performed
|
||||
in the `docker-compose.yml` file. Without Docker, look at the
|
||||
`CONSUMPTION_DIR` setting. Don't adjust this setting if you're
|
||||
using docker.
|
||||
- Ensure that the directory you're putting your documents in is the
|
||||
folder paperless is watching. With docker, this setting is performed
|
||||
in the `docker-compose.yml` file. Without Docker, look at the
|
||||
`CONSUMPTION_DIR` setting. Don't adjust this setting if you're
|
||||
using docker.
|
||||
|
||||
- Ensure that redis is up and running. Paperless does its task
|
||||
processing asynchronously, and for documents to arrive at the task
|
||||
processor, it needs redis to run.
|
||||
- Ensure that redis is up and running. Paperless does its task
|
||||
processing asynchronously, and for documents to arrive at the task
|
||||
processor, it needs redis to run.
|
||||
|
||||
- Ensure that the task processor is running. Docker does this
|
||||
automatically. Manually invoke the task processor by executing
|
||||
- Ensure that the task processor is running. Docker does this
|
||||
automatically. Manually invoke the task processor by executing
|
||||
|
||||
```shell-session
|
||||
celery --app paperless worker
|
||||
```
|
||||
```shell-session
|
||||
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
|
||||
so, the tasks will contain an error message.
|
||||
- Go to the admin interface, and check if there are failed tasks. If
|
||||
so, the tasks will contain an error message.
|
||||
|
||||
## 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
|
||||
learn from. This may have two reasons:
|
||||
|
||||
- You don't use the Auto matching algorithm: The error can be safely
|
||||
ignored in this case.
|
||||
- You are using the Auto matching algorithm: The classifier explicitly
|
||||
excludes documents with Inbox tags. Verify that there are documents
|
||||
in your archive without inbox tags. The algorithm will only learn
|
||||
from documents not in your inbox.
|
||||
- You don't use the Auto matching algorithm: The error can be safely
|
||||
ignored in this case.
|
||||
- You are using the Auto matching algorithm: The classifier explicitly
|
||||
excludes documents with Inbox tags. Verify that there are documents
|
||||
in your archive without inbox tags. The algorithm will only learn
|
||||
from documents not in your inbox.
|
||||
|
||||
## 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
|
||||
# want to allow external content like tracking pixels or even javascript.
|
||||
command:
|
||||
- 'gotenberg'
|
||||
- '--chromium-disable-javascript=true'
|
||||
- '--chromium-allow-list=file:///tmp/.*'
|
||||
- '--api-timeout=60s'
|
||||
- 'gotenberg'
|
||||
- '--chromium-disable-javascript=true'
|
||||
- '--chromium-allow-list=file:///tmp/.*'
|
||||
- '--api-timeout=60s'
|
||||
```
|
||||
|
||||
## Permission denied errors in the consumption directory
|
||||
|
||||
403
docs/usage.md
403
docs/usage.md
@@ -14,42 +14,42 @@ for finding and managing your documents.
|
||||
Paperless essentially consists of two different parts for managing your
|
||||
documents:
|
||||
|
||||
- The _consumer_ watches a specified folder and adds all documents in
|
||||
that folder to paperless.
|
||||
- The _web server_ (web UI) provides a UI that you use to manage and
|
||||
search documents.
|
||||
- The _consumer_ watches a specified folder and adds all documents in
|
||||
that folder to paperless.
|
||||
- The _web server_ (web UI) provides a UI that you use to manage and
|
||||
search documents.
|
||||
|
||||
Each document has data fields that you can assign to them:
|
||||
|
||||
- A _Document_ is a piece of paper that sometimes contains valuable
|
||||
information.
|
||||
- The _correspondent_ of a document is the person, institution or
|
||||
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
|
||||
as more powerful folders: Multiple documents can be grouped together
|
||||
with a single tag, however, a single document can also have multiple
|
||||
tags. This is not possible with folders. The reason folders are not
|
||||
implemented in paperless is simply that tags are much more versatile
|
||||
than folders.
|
||||
- A _document type_ is used to demarcate the type of a document such
|
||||
as letter, bank statement, invoice, contract, etc. It is used to
|
||||
identify what a document is about.
|
||||
- The document _storage path_ is the location where the document files
|
||||
are stored. See [Storage Paths](advanced_usage.md#storage-paths) for
|
||||
more information.
|
||||
- The _date added_ of a document is the date the document was scanned
|
||||
into paperless. You cannot and should not change this date.
|
||||
- The _date created_ of a document is the date the document was
|
||||
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.
|
||||
- The _archive serial number_ (short: ASN) of a document is the
|
||||
identifier of the document in your physical document binders. See
|
||||
[recommended workflow](#usage-recommended-workflow) below.
|
||||
- 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
|
||||
matching tags, correspondents and document types.
|
||||
- Paperless-ngx also supports _custom fields_ which can be used to
|
||||
store additional metadata about a document.
|
||||
- A _Document_ is a piece of paper that sometimes contains valuable
|
||||
information.
|
||||
- The _correspondent_ of a document is the person, institution or
|
||||
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
|
||||
as more powerful folders: Multiple documents can be grouped together
|
||||
with a single tag, however, a single document can also have multiple
|
||||
tags. This is not possible with folders. The reason folders are not
|
||||
implemented in paperless is simply that tags are much more versatile
|
||||
than folders.
|
||||
- A _document type_ is used to demarcate the type of a document such
|
||||
as letter, bank statement, invoice, contract, etc. It is used to
|
||||
identify what a document is about.
|
||||
- The document _storage path_ is the location where the document files
|
||||
are stored. See [Storage Paths](advanced_usage.md#storage-paths) for
|
||||
more information.
|
||||
- The _date added_ of a document is the date the document was scanned
|
||||
into paperless. You cannot and should not change this date.
|
||||
- The _date created_ of a document is the date the document was
|
||||
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.
|
||||
- The _archive serial number_ (short: ASN) of a document is the
|
||||
identifier of the document in your physical document binders. See
|
||||
[recommended workflow](#usage-recommended-workflow) below.
|
||||
- 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
|
||||
matching tags, correspondents and document types.
|
||||
- Paperless-ngx also supports _custom fields_ which can be used to
|
||||
store additional metadata about a document.
|
||||
|
||||
## The Web UI
|
||||
|
||||
@@ -93,11 +93,12 @@ download the document or share it via a share link.
|
||||
|
||||
Think of versions as **file history** for a document.
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- Deleting a non-root version keeps metadata and falls back to the latest remaining version.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
### Management Lists
|
||||
|
||||
@@ -217,21 +218,20 @@ patterns can include wildcards and multiple patterns separated by a comma.
|
||||
The actions all ensure that the same mail is not consumed twice by
|
||||
different means. These are as follows:
|
||||
|
||||
- **Delete:** Immediately deletes mail that paperless has consumed
|
||||
documents from. Use with caution.
|
||||
- **Mark as read:** Mark consumed mail as read. Paperless will not
|
||||
consume documents from already read mails. If you read a mail before
|
||||
paperless sees it, it will be ignored.
|
||||
- **Flag:** Sets the 'important' flag on mails with consumed
|
||||
documents. Paperless will not consume flagged mails.
|
||||
- **Move to folder:** Moves consumed mails out of the way so that
|
||||
paperless won't consume them again.
|
||||
- **Add custom Tag:** Adds a custom tag to mails with consumed
|
||||
documents (the IMAP standard calls these "keywords"). Paperless
|
||||
will not consume mails already tagged. Not all mail servers support
|
||||
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_.
|
||||
- **Delete:** Immediately deletes mail that paperless has consumed
|
||||
documents from. Use with caution.
|
||||
- **Mark as read:** Mark consumed mail as read. Paperless will not
|
||||
consume documents from already read mails. If you read a mail before
|
||||
paperless sees it, it will be ignored.
|
||||
- **Flag:** Sets the 'important' flag on mails with consumed
|
||||
documents. Paperless will not consume flagged mails.
|
||||
- **Move to folder:** Moves consumed mails out of the way so that
|
||||
paperless won't consume them again.
|
||||
- **Add custom Tag:** Adds a custom tag to mails with consumed
|
||||
documents (the IMAP standard calls these "keywords"). Paperless
|
||||
will not consume mails already tagged. Not all mail servers support
|
||||
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_.
|
||||
|
||||
!!! warning
|
||||
|
||||
@@ -324,12 +324,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 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 can optionally have an expiration time set.
|
||||
- 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 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.
|
||||
- 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 can optionally have an expiration time set.
|
||||
- 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 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
|
||||
|
||||
@@ -513,25 +513,25 @@ flowchart TD
|
||||
|
||||
Workflows allow you to filter by:
|
||||
|
||||
- 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 path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for
|
||||
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.
|
||||
- Content matching (`Added`, `Updated` and `Scheduled` triggers only). Filter document content using the matching settings.
|
||||
- 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 path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for
|
||||
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.
|
||||
- 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:
|
||||
|
||||
- Any Tags: Filter for documents with any 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.
|
||||
- Document type: Filter documents with this document type.
|
||||
- Not Document types: Filter documents without any of these document types.
|
||||
- Correspondent: Filter documents with this correspondent.
|
||||
- Not Correspondents: Filter documents without any of these correspondents.
|
||||
- Storage path: Filter documents with this storage path.
|
||||
- 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).
|
||||
- Any Tags: Filter for documents with any 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.
|
||||
- Document type: Filter documents with this document type.
|
||||
- Not Document types: Filter documents without any of these document types.
|
||||
- Correspondent: Filter documents with this correspondent.
|
||||
- Not Correspondents: Filter documents without any of these correspondents.
|
||||
- Storage path: Filter documents with this storage path.
|
||||
- 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).
|
||||
|
||||
### Workflow Actions
|
||||
|
||||
@@ -543,37 +543,37 @@ The following workflow action types are available:
|
||||
|
||||
"Assignment" actions can assign:
|
||||
|
||||
- Title, see [workflow placeholders](usage.md#workflow-placeholders) below
|
||||
- Tags, correspondent, document type and storage path
|
||||
- Document owner
|
||||
- View and / or edit permissions to users or groups
|
||||
- Custom fields. Note that no value for the field will be set
|
||||
- Title, see [workflow placeholders](usage.md#workflow-placeholders) below
|
||||
- Tags, correspondent, document type and storage path
|
||||
- Document owner
|
||||
- View and / or edit permissions to users or groups
|
||||
- Custom fields. Note that no value for the field will be set
|
||||
|
||||
##### Removal {#workflow-action-removal}
|
||||
|
||||
"Removal" actions can remove either all of or specific sets of the following:
|
||||
|
||||
- Tags, correspondents, document types or storage paths
|
||||
- Document owner
|
||||
- View and / or edit permissions
|
||||
- Custom fields
|
||||
- Tags, correspondents, document types or storage paths
|
||||
- Document owner
|
||||
- View and / or edit permissions
|
||||
- Custom fields
|
||||
|
||||
##### 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:
|
||||
|
||||
- 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
|
||||
- Whether to include the document as an attachment
|
||||
- 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
|
||||
- Whether to include the document as an attachment
|
||||
|
||||
##### Webhook {#workflow-action-webhook}
|
||||
|
||||
"Webhook" actions send a POST request to a specified URL. You can specify:
|
||||
|
||||
- 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.
|
||||
- Encoding for the request body, either JSON or form data
|
||||
- The request headers as key-value pairs
|
||||
- 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.
|
||||
- Encoding for the request body, either JSON or form data
|
||||
- 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
|
||||
[configuration settings](configuration.md#workflow-webhooks) to change this behavior. If you are allowing non-admins to create workflows,
|
||||
@@ -604,33 +604,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
|
||||
applied. You can use the following placeholders in the template with any trigger type:
|
||||
|
||||
- `{{correspondent}}`: assigned correspondent name
|
||||
- `{{document_type}}`: assigned document type name
|
||||
- `{{owner_username}}`: assigned owner username
|
||||
- `{{added}}`: added datetime
|
||||
- `{{added_year}}`: added year
|
||||
- `{{added_year_short}}`: added year
|
||||
- `{{added_month}}`: added month
|
||||
- `{{added_month_name}}`: added month name
|
||||
- `{{added_month_name_short}}`: added month short name
|
||||
- `{{added_day}}`: added day
|
||||
- `{{added_time}}`: added time in HH:MM format
|
||||
- `{{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}}`)
|
||||
- `{{doc_title}}`: current document title (cannot be used in title assignment)
|
||||
- `{{correspondent}}`: assigned correspondent name
|
||||
- `{{document_type}}`: assigned document type name
|
||||
- `{{owner_username}}`: assigned owner username
|
||||
- `{{added}}`: added datetime
|
||||
- `{{added_year}}`: added year
|
||||
- `{{added_year_short}}`: added year
|
||||
- `{{added_month}}`: added month
|
||||
- `{{added_month_name}}`: added month name
|
||||
- `{{added_month_name_short}}`: added month short name
|
||||
- `{{added_day}}`: added day
|
||||
- `{{added_time}}`: added time in HH:MM format
|
||||
- `{{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}}`)
|
||||
- `{{doc_title}}`: current document title (cannot be used in title assignment)
|
||||
|
||||
The following placeholders are only available for "added" or "updated" triggers
|
||||
|
||||
- `{{created}}`: created datetime
|
||||
- `{{created_year}}`: created year
|
||||
- `{{created_year_short}}`: created year
|
||||
- `{{created_month}}`: created month
|
||||
- `{{created_month_name}}`: created month name
|
||||
- `{{created_month_name_short}}`: created month short name
|
||||
- `{{created_day}}`: created day
|
||||
- `{{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_id}}`: Document ID
|
||||
- `{{created}}`: created datetime
|
||||
- `{{created_year}}`: created year
|
||||
- `{{created_year_short}}`: created year
|
||||
- `{{created_month}}`: created month
|
||||
- `{{created_month_name}}`: created month name
|
||||
- `{{created_month_name_short}}`: created month short name
|
||||
- `{{created_day}}`: created day
|
||||
- `{{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_id}}`: Document ID
|
||||
|
||||
##### Examples
|
||||
|
||||
@@ -675,26 +675,26 @@ Multiple fields may be attached to a document but the same field name cannot be
|
||||
|
||||
The following custom field types are supported:
|
||||
|
||||
- `Text`: any text
|
||||
- `Boolean`: true / false (check / unchecked) field
|
||||
- `Date`: date
|
||||
- `URL`: a valid url
|
||||
- `Integer`: integer number e.g. 12
|
||||
- `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
|
||||
- `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
|
||||
- `Text`: any text
|
||||
- `Boolean`: true / false (check / unchecked) field
|
||||
- `Date`: date
|
||||
- `URL`: a valid url
|
||||
- `Integer`: integer number e.g. 12
|
||||
- `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
|
||||
- `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
|
||||
|
||||
## 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
|
||||
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'.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- Re-arranging pages: via the pdf editor on an individual document's details page.
|
||||
|
||||
!!! important
|
||||
|
||||
@@ -772,18 +772,18 @@ the system.
|
||||
Here are a couple examples of tags and types that you could use in your
|
||||
collection.
|
||||
|
||||
- An `inbox` tag for newly added documents that you haven't manually
|
||||
edited yet.
|
||||
- A tag `car` for everything car related (repairs, registration,
|
||||
insurance, etc)
|
||||
- A tag `todo` for documents that you still need to do something with,
|
||||
such as reply, or perform some task online.
|
||||
- A tag `bank account x` for all bank statement related to that
|
||||
account.
|
||||
- A tag `mail` for anything that you added to paperless via its mail
|
||||
processing capabilities.
|
||||
- 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.
|
||||
- An `inbox` tag for newly added documents that you haven't manually
|
||||
edited yet.
|
||||
- A tag `car` for everything car related (repairs, registration,
|
||||
insurance, etc)
|
||||
- A tag `todo` for documents that you still need to do something with,
|
||||
such as reply, or perform some task online.
|
||||
- A tag `bank account x` for all bank statement related to that
|
||||
account.
|
||||
- A tag `mail` for anything that you added to paperless via its mail
|
||||
processing capabilities.
|
||||
- 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.
|
||||
|
||||
## Searching {#basic-usage_searching}
|
||||
|
||||
@@ -872,8 +872,8 @@ The following diagram shows how easy it is to manage your documents.
|
||||
|
||||
### Preparations in paperless
|
||||
|
||||
- Create an inbox tag that gets assigned to all new documents.
|
||||
- Create a TODO tag.
|
||||
- Create an inbox tag that gets assigned to all new documents.
|
||||
- Create a TODO tag.
|
||||
|
||||
### Processing of the physical documents
|
||||
|
||||
@@ -947,15 +947,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
|
||||
how regularly you intend to scan documents and use paperless.
|
||||
|
||||
- If you scan and process your documents in paperless regularly,
|
||||
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
|
||||
this tag.
|
||||
- If you do not scan documents regularly and use paperless solely for
|
||||
archiving, create a physical todo box next to your physical inbox
|
||||
and put documents you need to process in the TODO box. When you
|
||||
performed the task associated with the document, move it to the
|
||||
inbox.
|
||||
- If you scan and process your documents in paperless regularly,
|
||||
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
|
||||
this tag.
|
||||
- If you do not scan documents regularly and use paperless solely for
|
||||
archiving, create a physical todo box next to your physical inbox
|
||||
and put documents you need to process in the TODO box. When you
|
||||
performed the task associated with the document, move it to the
|
||||
inbox.
|
||||
|
||||
## Remote OCR
|
||||
|
||||
@@ -976,64 +976,63 @@ or page limitations (e.g. with a free tier).
|
||||
|
||||
Paperless-ngx consists of the following components:
|
||||
|
||||
- **The webserver:** This serves the administration pages, the API,
|
||||
and the new frontend. This is the main tool you'll be using to interact
|
||||
with paperless. You may start the webserver directly with
|
||||
- **The webserver:** This serves the administration pages, the API,
|
||||
and the new frontend. This is the main tool you'll be using to interact
|
||||
with paperless. You may start the webserver directly with
|
||||
|
||||
```shell-session
|
||||
cd /path/to/paperless/src/
|
||||
granian --interface asginl --ws "paperless.asgi:application"
|
||||
```
|
||||
```shell-session
|
||||
cd /path/to/paperless/src/
|
||||
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
|
||||
documents. However, the consumer itself does not really consume your
|
||||
documents. Now it notifies a task processor that a new file is ready
|
||||
for consumption. I suppose it should be named differently. This was
|
||||
also used to check your emails, but that's now done elsewhere as
|
||||
well.
|
||||
- **The consumer:** This is what watches your consumption folder for
|
||||
documents. However, the consumer itself does not really consume your
|
||||
documents. Now it notifies a task processor that a new file is ready
|
||||
for consumption. I suppose it should be named differently. This was
|
||||
also used to check your emails, but that's now done elsewhere as
|
||||
well.
|
||||
|
||||
Start the consumer with the management command `document_consumer`:
|
||||
Start the consumer with the management command `document_consumer`:
|
||||
|
||||
```shell-session
|
||||
cd /path/to/paperless/src/
|
||||
python3 manage.py document_consumer
|
||||
```
|
||||
```shell-session
|
||||
cd /path/to/paperless/src/
|
||||
python3 manage.py document_consumer
|
||||
```
|
||||
|
||||
- **The task processor:** Paperless relies on [Celery - Distributed
|
||||
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
|
||||
from multiple sources and processes these in parallel. It also comes
|
||||
with a scheduler that executes certain commands periodically.
|
||||
- **The task processor:** Paperless relies on [Celery - Distributed
|
||||
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
|
||||
from multiple sources and processes these in parallel. It also comes
|
||||
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.
|
||||
|
||||
- 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
|
||||
consumption folder in parallel! On a modern multi core system, this
|
||||
makes the consumption process with full OCR blazingly fast.
|
||||
|
||||
This allows paperless to process multiple documents from your
|
||||
consumption folder in parallel! On a modern multi core system, this
|
||||
makes the consumption process with full OCR blazingly fast.
|
||||
The task processor comes with a built-in admin interface that you
|
||||
can use to check whenever any of the tasks fail and inspect the
|
||||
errors (i.e., wrong email credentials, errors during consuming a
|
||||
specific file, etc).
|
||||
|
||||
The task processor comes with a built-in admin interface that you
|
||||
can use to check whenever any of the tasks fail and inspect the
|
||||
errors (i.e., wrong email credentials, errors during consuming a
|
||||
specific file, etc).
|
||||
- A [redis](https://redis.io/) message broker: This is a really
|
||||
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.
|
||||
|
||||
- A [redis](https://redis.io/) message broker: This is a really
|
||||
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.
|
||||
- Optional: A database server. Paperless supports PostgreSQL, MariaDB
|
||||
and SQLite for storing its data.
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
[project]
|
||||
name = "paperless-ngx"
|
||||
version = "2.20.9"
|
||||
version = "2.20.10"
|
||||
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
requires-python = ">=3.11"
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
@@ -43,13 +42,14 @@ dependencies = [
|
||||
"djangorestframework~=3.16",
|
||||
"djangorestframework-guardian~=0.4.0",
|
||||
"drf-spectacular~=0.28",
|
||||
"drf-spectacular-sidecar~=2026.1.1",
|
||||
"drf-spectacular-sidecar~=2026.3.1",
|
||||
"drf-writable-nested~=0.7.1",
|
||||
"faiss-cpu>=1.10",
|
||||
"filelock~=3.24.3",
|
||||
"filelock~=3.25.2",
|
||||
"flower~=2.0.1",
|
||||
"gotenberg-client~=0.13.1",
|
||||
"httpx-oauth~=0.16",
|
||||
"ijson>=3.2",
|
||||
"imap-tools~=1.11.0",
|
||||
"jinja2~=3.1.5",
|
||||
"langdetect~=1.0.9",
|
||||
@@ -72,7 +72,7 @@ dependencies = [
|
||||
"rapidfuzz~=3.14.0",
|
||||
"redis[hiredis]~=5.2.1",
|
||||
"regex>=2025.9.18",
|
||||
"scikit-learn~=1.7.0",
|
||||
"scikit-learn~=1.8.0",
|
||||
"sentence-transformers>=4.1",
|
||||
"setproctitle~=1.3.4",
|
||||
"tika-client~=0.10.0",
|
||||
@@ -111,7 +111,7 @@ docs = [
|
||||
testing = [
|
||||
"daphne",
|
||||
"factory-boy~=3.3.1",
|
||||
"faker~=40.5.1",
|
||||
"faker~=40.8.0",
|
||||
"imagehash",
|
||||
"pytest~=9.0.0",
|
||||
"pytest-cov~=7.0.0",
|
||||
@@ -177,7 +177,7 @@ torch = [
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py310"
|
||||
target-version = "py311"
|
||||
line-length = 88
|
||||
src = [
|
||||
"src",
|
||||
@@ -310,6 +310,7 @@ markers = [
|
||||
[tool.pytest_env]
|
||||
PAPERLESS_DISABLE_DBHANDLER = "true"
|
||||
PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
|
||||
PAPERLESS_CHANNELS_BACKEND = "channels.layers.InMemoryChannelLayer"
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_also = [
|
||||
|
||||
@@ -19,6 +19,4 @@ following additional information about it:
|
||||
* Correspondent: ${DOCUMENT_CORRESPONDENT}
|
||||
* Tags: ${DOCUMENT_TAGS}
|
||||
|
||||
It was consumed with the passphrase ${PASSPHRASE}
|
||||
|
||||
"
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
{
|
||||
"root": true,
|
||||
"ignorePatterns": [
|
||||
"projects/**/*",
|
||||
"/src/app/components/common/pdf-viewer/**"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"*.ts"
|
||||
],
|
||||
"parserOptions": {
|
||||
"project": [
|
||||
"tsconfig.json"
|
||||
],
|
||||
"createDefaultProgram": true
|
||||
},
|
||||
"extends": [
|
||||
"plugin:@angular-eslint/recommended",
|
||||
"plugin:@angular-eslint/template/process-inline-templates"
|
||||
],
|
||||
"rules": {
|
||||
"@angular-eslint/directive-selector": [
|
||||
"error",
|
||||
{
|
||||
"type": "attribute",
|
||||
"prefix": "pngx",
|
||||
"style": "camelCase"
|
||||
}
|
||||
],
|
||||
"@angular-eslint/component-selector": [
|
||||
"error",
|
||||
{
|
||||
"type": "element",
|
||||
"prefix": "pngx",
|
||||
"style": "kebab-case"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"*.html"
|
||||
],
|
||||
"extends": [
|
||||
"plugin:@angular-eslint/template/recommended"
|
||||
],
|
||||
"rules": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -58,7 +58,7 @@
|
||||
"content": {
|
||||
"size": -1,
|
||||
"mimeType": "application/json",
|
||||
"text": "{\"user\":{\"id\":2,\"username\":\"testuser\",\"is_superuser\":false,\"groups\":[]},\"settings\":{\"language\":\"\",\"bulk_edit\":{\"confirmation_dialogs\":true,\"apply_on_close\":false},\"documentListSize\":50,\"dark_mode\":{\"use_system\":false,\"enabled\":\"false\",\"thumb_inverted\":\"true\"},\"theme\":{\"color\":\"#9fbf2f\"},\"document_details\":{\"native_pdf_viewer\":false},\"date_display\":{\"date_locale\":\"\",\"date_format\":\"mediumDate\"},\"notifications\":{\"consumer_new_documents\":true,\"consumer_success\":true,\"consumer_failed\":true,\"consumer_suppress_on_dashboard\":true},\"comments_enabled\":true,\"slim_sidebar\":false,\"update_checking\":{\"enabled\":false,\"backend_setting\":\"default\"},\"saved_views\":{\"warn_on_unsaved_change\":true},\"notes_enabled\":true,\"tour_complete\":true},\"permissions\":[\"delete_schedule\",\"view_note\",\"view_taskattributes\",\"delete_ormq\",\"add_ormq\",\"change_tag\",\"change_chordcounter\",\"delete_savedviewfilterrule\",\"delete_comment\",\"add_session\",\"add_mailrule\",\"add_tokenproxy\",\"delete_taskresult\",\"view_failure\",\"add_tag\",\"view_savedviewfilterrule\",\"view_paperlesstask\",\"change_mailaccount\",\"change_frontendsettings\",\"delete_group\",\"add_userobjectpermission\",\"add_failure\",\"delete_mailrule\",\"view_userobjectpermission\",\"change_schedule\",\"delete_note\",\"view_ormq\",\"add_note\",\"add_chordcounter\",\"delete_token\",\"change_failure\",\"add_savedviewfilterrule\",\"delete_user\",\"view_correspondent\",\"view_schedule\",\"change_tokenproxy\",\"view_user\",\"delete_task\",\"delete_correspondent\",\"delete_chordcounter\",\"delete_document\",\"view_taskresult\",\"change_document\",\"add_frontendsettings\",\"view_mailrule\",\"change_ormq\",\"delete_taskattributes\",\"view_logentry\",\"view_mailaccount\",\"view_log\",\"delete_success\",\"view_frontendsettings\",\"view_documenttype\",\"change_taskresult\",\"view_permission\",\"add_groupobjectpermission\",\"change_user\",\"view_document\",\"change_userobjectpermission\",\"add_user\",\"add_correspondent\",\"add_token\",\"add_mailaccount\",\"change_group\",\"add_group\",\"delete_processedmail\",\"delete_contenttype\",\"add_savedview\",\"view_chordcounter\",\"delete_tokenproxy\",\"change_groupresult\",\"delete_session\",\"view_savedview\",\"view_processedmail\",\"add_comment\",\"view_storagepath\",\"delete_documenttype\",\"add_processedmail\",\"view_group\",\"change_processedmail\",\"view_session\",\"delete_storagepath\",\"delete_paperlesstask\",\"add_groupresult\",\"delete_savedview\",\"delete_userobjectpermission\",\"view_tokenproxy\",\"add_task\",\"view_tag\",\"add_taskresult\",\"change_documenttype\",\"change_mailrule\",\"add_document\",\"change_comment\",\"view_task\",\"view_groupresult\",\"change_contenttype\",\"view_groupobjectpermission\",\"change_task\",\"add_log\",\"add_success\",\"change_savedview\",\"delete_frontendsettings\",\"view_success\",\"add_permission\",\"change_correspondent\",\"add_paperlesstask\",\"change_paperlesstask\",\"add_contenttype\",\"view_comment\",\"change_logentry\",\"delete_logentry\",\"delete_mailaccount\",\"change_session\",\"delete_groupresult\",\"add_logentry\",\"change_savedviewfilterrule\",\"change_success\",\"delete_tag\",\"add_taskattributes\",\"change_groupobjectpermission\",\"delete_failure\",\"add_uisettings\",\"view_token\",\"add_schedule\",\"delete_log\",\"delete_uisettings\",\"change_permission\",\"delete_groupobjectpermission\",\"change_token\",\"view_uisettings\",\"change_uisettings\",\"delete_permission\",\"add_storagepath\",\"change_storagepath\",\"view_contenttype\",\"change_note\",\"change_log\",\"change_taskattributes\",\"add_documenttype\"]}"
|
||||
"text": "{\"user\":{\"id\":2,\"username\":\"testuser\",\"is_superuser\":false,\"groups\":[]},\"settings\":{\"language\":\"\",\"bulk_edit\":{\"confirmation_dialogs\":true,\"apply_on_close\":false},\"documentListSize\":50,\"dark_mode\":{\"use_system\":false,\"enabled\":\"false\",\"thumb_inverted\":\"true\"},\"theme\":{\"color\":\"#9fbf2f\"},\"document_details\":{\"native_pdf_viewer\":false},\"date_display\":{\"date_locale\":\"\",\"date_format\":\"mediumDate\"},\"notifications\":{\"consumer_new_documents\":true,\"consumer_success\":true,\"consumer_failed\":true,\"consumer_suppress_on_dashboard\":true},\"comments_enabled\":true,\"slim_sidebar\":false,\"update_checking\":{\"enabled\":false,\"backend_setting\":\"default\"},\"saved_views\":{\"warn_on_unsaved_change\":true,\"dashboard_views_visible_ids\":[7,4],\"sidebar_views_visible_ids\":[7,4,11]},\"notes_enabled\":true,\"tour_complete\":true},\"permissions\":[\"delete_schedule\",\"view_note\",\"view_taskattributes\",\"delete_ormq\",\"add_ormq\",\"change_tag\",\"change_chordcounter\",\"delete_savedviewfilterrule\",\"delete_comment\",\"add_session\",\"add_mailrule\",\"add_tokenproxy\",\"delete_taskresult\",\"view_failure\",\"add_tag\",\"view_savedviewfilterrule\",\"view_paperlesstask\",\"change_mailaccount\",\"change_frontendsettings\",\"delete_group\",\"add_userobjectpermission\",\"add_failure\",\"delete_mailrule\",\"view_userobjectpermission\",\"change_schedule\",\"delete_note\",\"view_ormq\",\"add_note\",\"add_chordcounter\",\"delete_token\",\"change_failure\",\"add_savedviewfilterrule\",\"delete_user\",\"view_correspondent\",\"view_schedule\",\"change_tokenproxy\",\"view_user\",\"delete_task\",\"delete_correspondent\",\"delete_chordcounter\",\"delete_document\",\"view_taskresult\",\"change_document\",\"add_frontendsettings\",\"view_mailrule\",\"change_ormq\",\"delete_taskattributes\",\"view_logentry\",\"view_mailaccount\",\"view_log\",\"delete_success\",\"view_frontendsettings\",\"view_documenttype\",\"change_taskresult\",\"view_permission\",\"add_groupobjectpermission\",\"change_user\",\"view_document\",\"change_userobjectpermission\",\"add_user\",\"add_correspondent\",\"add_token\",\"add_mailaccount\",\"change_group\",\"add_group\",\"delete_processedmail\",\"delete_contenttype\",\"add_savedview\",\"view_chordcounter\",\"delete_tokenproxy\",\"change_groupresult\",\"delete_session\",\"view_savedview\",\"view_processedmail\",\"add_comment\",\"view_storagepath\",\"delete_documenttype\",\"add_processedmail\",\"view_group\",\"change_processedmail\",\"view_session\",\"delete_storagepath\",\"delete_paperlesstask\",\"add_groupresult\",\"delete_savedview\",\"delete_userobjectpermission\",\"view_tokenproxy\",\"add_task\",\"view_tag\",\"add_taskresult\",\"change_documenttype\",\"change_mailrule\",\"add_document\",\"change_comment\",\"view_task\",\"view_groupresult\",\"change_contenttype\",\"view_groupobjectpermission\",\"change_task\",\"add_log\",\"add_success\",\"change_savedview\",\"delete_frontendsettings\",\"view_success\",\"add_permission\",\"change_correspondent\",\"add_paperlesstask\",\"change_paperlesstask\",\"add_contenttype\",\"view_comment\",\"change_logentry\",\"delete_logentry\",\"delete_mailaccount\",\"change_session\",\"delete_groupresult\",\"add_logentry\",\"change_savedviewfilterrule\",\"change_success\",\"delete_tag\",\"add_taskattributes\",\"change_groupobjectpermission\",\"delete_failure\",\"add_uisettings\",\"view_token\",\"add_schedule\",\"delete_log\",\"delete_uisettings\",\"change_permission\",\"delete_groupobjectpermission\",\"change_token\",\"view_uisettings\",\"change_uisettings\",\"delete_permission\",\"add_storagepath\",\"change_storagepath\",\"view_contenttype\",\"change_note\",\"change_log\",\"change_taskattributes\",\"add_documenttype\"]}"
|
||||
},
|
||||
"headersSize": -1,
|
||||
"bodySize": -1,
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
"content": {
|
||||
"size": -1,
|
||||
"mimeType": "application/json",
|
||||
"text": "{\"user\":{\"id\":2,\"username\":\"testuser\",\"is_superuser\":false,\"groups\":[]},\"settings\":{\"language\":\"\",\"bulk_edit\":{\"confirmation_dialogs\":true,\"apply_on_close\":false},\"documentListSize\":50,\"dark_mode\":{\"use_system\":false,\"enabled\":\"false\",\"thumb_inverted\":\"true\"},\"theme\":{\"color\":\"#9fbf2f\"},\"document_details\":{\"native_pdf_viewer\":false},\"date_display\":{\"date_locale\":\"\",\"date_format\":\"mediumDate\"},\"notifications\":{\"consumer_new_documents\":true,\"consumer_success\":true,\"consumer_failed\":true,\"consumer_suppress_on_dashboard\":true},\"comments_enabled\":true,\"slim_sidebar\":false,\"update_checking\":{\"enabled\":false,\"backend_setting\":\"default\"},\"saved_views\":{\"warn_on_unsaved_change\":true},\"notes_enabled\":true,\"tour_complete\":true},\"permissions\":[\"delete_schedule\",\"view_note\",\"view_taskattributes\",\"delete_ormq\",\"add_ormq\",\"change_tag\",\"change_chordcounter\",\"delete_savedviewfilterrule\",\"delete_comment\",\"add_session\",\"add_mailrule\",\"add_tokenproxy\",\"delete_taskresult\",\"view_failure\",\"add_tag\",\"view_savedviewfilterrule\",\"view_paperlesstask\",\"change_mailaccount\",\"change_frontendsettings\",\"delete_group\",\"add_userobjectpermission\",\"add_failure\",\"delete_mailrule\",\"view_userobjectpermission\",\"change_schedule\",\"delete_note\",\"view_ormq\",\"add_note\",\"add_chordcounter\",\"delete_token\",\"change_failure\",\"add_savedviewfilterrule\",\"delete_user\",\"view_correspondent\",\"view_schedule\",\"change_tokenproxy\",\"view_user\",\"delete_task\",\"delete_correspondent\",\"delete_chordcounter\",\"delete_document\",\"view_taskresult\",\"change_document\",\"add_frontendsettings\",\"view_mailrule\",\"change_ormq\",\"delete_taskattributes\",\"view_logentry\",\"view_mailaccount\",\"view_log\",\"delete_success\",\"view_frontendsettings\",\"view_documenttype\",\"change_taskresult\",\"view_permission\",\"add_groupobjectpermission\",\"change_user\",\"view_document\",\"change_userobjectpermission\",\"add_user\",\"add_correspondent\",\"add_token\",\"add_mailaccount\",\"change_group\",\"add_group\",\"delete_processedmail\",\"delete_contenttype\",\"add_savedview\",\"view_chordcounter\",\"delete_tokenproxy\",\"change_groupresult\",\"delete_session\",\"view_savedview\",\"view_processedmail\",\"add_comment\",\"view_storagepath\",\"delete_documenttype\",\"add_processedmail\",\"view_group\",\"change_processedmail\",\"view_session\",\"delete_storagepath\",\"delete_paperlesstask\",\"add_groupresult\",\"delete_savedview\",\"delete_userobjectpermission\",\"view_tokenproxy\",\"add_task\",\"view_tag\",\"add_taskresult\",\"change_documenttype\",\"change_mailrule\",\"add_document\",\"change_comment\",\"view_task\",\"view_groupresult\",\"change_contenttype\",\"view_groupobjectpermission\",\"change_task\",\"add_log\",\"add_success\",\"change_savedview\",\"delete_frontendsettings\",\"view_success\",\"add_permission\",\"change_correspondent\",\"add_paperlesstask\",\"change_paperlesstask\",\"add_contenttype\",\"view_comment\",\"change_logentry\",\"delete_logentry\",\"delete_mailaccount\",\"change_session\",\"delete_groupresult\",\"add_logentry\",\"change_savedviewfilterrule\",\"change_success\",\"delete_tag\",\"add_taskattributes\",\"change_groupobjectpermission\",\"delete_failure\",\"add_uisettings\",\"view_token\",\"add_schedule\",\"delete_log\",\"delete_uisettings\",\"change_permission\",\"delete_groupobjectpermission\",\"change_token\",\"view_uisettings\",\"change_uisettings\",\"delete_permission\",\"add_storagepath\",\"change_storagepath\",\"view_contenttype\",\"change_note\",\"change_log\",\"change_taskattributes\",\"add_documenttype\"]}"
|
||||
"text": "{\"user\":{\"id\":2,\"username\":\"testuser\",\"is_superuser\":false,\"groups\":[]},\"settings\":{\"language\":\"\",\"bulk_edit\":{\"confirmation_dialogs\":true,\"apply_on_close\":false},\"documentListSize\":50,\"dark_mode\":{\"use_system\":false,\"enabled\":\"false\",\"thumb_inverted\":\"true\"},\"theme\":{\"color\":\"#9fbf2f\"},\"document_details\":{\"native_pdf_viewer\":false},\"date_display\":{\"date_locale\":\"\",\"date_format\":\"mediumDate\"},\"notifications\":{\"consumer_new_documents\":true,\"consumer_success\":true,\"consumer_failed\":true,\"consumer_suppress_on_dashboard\":true},\"comments_enabled\":true,\"slim_sidebar\":false,\"update_checking\":{\"enabled\":false,\"backend_setting\":\"default\"},\"saved_views\":{\"warn_on_unsaved_change\":true,\"dashboard_views_visible_ids\":[7,4],\"sidebar_views_visible_ids\":[7,4,11]},\"notes_enabled\":true,\"tour_complete\":true},\"permissions\":[\"delete_schedule\",\"view_note\",\"view_taskattributes\",\"delete_ormq\",\"add_ormq\",\"change_tag\",\"change_chordcounter\",\"delete_savedviewfilterrule\",\"delete_comment\",\"add_session\",\"add_mailrule\",\"add_tokenproxy\",\"delete_taskresult\",\"view_failure\",\"add_tag\",\"view_savedviewfilterrule\",\"view_paperlesstask\",\"change_mailaccount\",\"change_frontendsettings\",\"delete_group\",\"add_userobjectpermission\",\"add_failure\",\"delete_mailrule\",\"view_userobjectpermission\",\"change_schedule\",\"delete_note\",\"view_ormq\",\"add_note\",\"add_chordcounter\",\"delete_token\",\"change_failure\",\"add_savedviewfilterrule\",\"delete_user\",\"view_correspondent\",\"view_schedule\",\"change_tokenproxy\",\"view_user\",\"delete_task\",\"delete_correspondent\",\"delete_chordcounter\",\"delete_document\",\"view_taskresult\",\"change_document\",\"add_frontendsettings\",\"view_mailrule\",\"change_ormq\",\"delete_taskattributes\",\"view_logentry\",\"view_mailaccount\",\"view_log\",\"delete_success\",\"view_frontendsettings\",\"view_documenttype\",\"change_taskresult\",\"view_permission\",\"add_groupobjectpermission\",\"change_user\",\"view_document\",\"change_userobjectpermission\",\"add_user\",\"add_correspondent\",\"add_token\",\"add_mailaccount\",\"change_group\",\"add_group\",\"delete_processedmail\",\"delete_contenttype\",\"add_savedview\",\"view_chordcounter\",\"delete_tokenproxy\",\"change_groupresult\",\"delete_session\",\"view_savedview\",\"view_processedmail\",\"add_comment\",\"view_storagepath\",\"delete_documenttype\",\"add_processedmail\",\"view_group\",\"change_processedmail\",\"view_session\",\"delete_storagepath\",\"delete_paperlesstask\",\"add_groupresult\",\"delete_savedview\",\"delete_userobjectpermission\",\"view_tokenproxy\",\"add_task\",\"view_tag\",\"add_taskresult\",\"change_documenttype\",\"change_mailrule\",\"add_document\",\"change_comment\",\"view_task\",\"view_groupresult\",\"change_contenttype\",\"view_groupobjectpermission\",\"change_task\",\"add_log\",\"add_success\",\"change_savedview\",\"delete_frontendsettings\",\"view_success\",\"add_permission\",\"change_correspondent\",\"add_paperlesstask\",\"change_paperlesstask\",\"add_contenttype\",\"view_comment\",\"change_logentry\",\"delete_logentry\",\"delete_mailaccount\",\"change_session\",\"delete_groupresult\",\"add_logentry\",\"change_savedviewfilterrule\",\"change_success\",\"delete_tag\",\"add_taskattributes\",\"change_groupobjectpermission\",\"delete_failure\",\"add_uisettings\",\"view_token\",\"add_schedule\",\"delete_log\",\"delete_uisettings\",\"change_permission\",\"delete_groupobjectpermission\",\"change_token\",\"view_uisettings\",\"change_uisettings\",\"delete_permission\",\"add_storagepath\",\"change_storagepath\",\"view_contenttype\",\"change_note\",\"change_log\",\"change_taskattributes\",\"add_documenttype\"]}"
|
||||
},
|
||||
"headersSize": -1,
|
||||
"bodySize": -1,
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
"content": {
|
||||
"size": -1,
|
||||
"mimeType": "application/json",
|
||||
"text": "{\"user\":{\"id\":2,\"username\":\"testuser\",\"is_superuser\":false,\"groups\":[]},\"settings\":{\"language\":\"\",\"bulk_edit\":{\"confirmation_dialogs\":true,\"apply_on_close\":false},\"documentListSize\":50,\"dark_mode\":{\"use_system\":false,\"enabled\":\"false\",\"thumb_inverted\":\"true\"},\"theme\":{\"color\":\"#9fbf2f\"},\"document_details\":{\"native_pdf_viewer\":false},\"date_display\":{\"date_locale\":\"\",\"date_format\":\"mediumDate\"},\"notifications\":{\"consumer_new_documents\":true,\"consumer_success\":true,\"consumer_failed\":true,\"consumer_suppress_on_dashboard\":true},\"comments_enabled\":true,\"slim_sidebar\":false,\"update_checking\":{\"enabled\":false,\"backend_setting\":\"default\"},\"saved_views\":{\"warn_on_unsaved_change\":true},\"notes_enabled\":true,\"tour_complete\":true},\"permissions\":[\"delete_schedule\",\"view_note\",\"view_taskattributes\",\"delete_ormq\",\"add_ormq\",\"change_tag\",\"change_chordcounter\",\"delete_savedviewfilterrule\",\"delete_comment\",\"add_session\",\"add_mailrule\",\"add_tokenproxy\",\"delete_taskresult\",\"view_failure\",\"add_tag\",\"view_savedviewfilterrule\",\"view_paperlesstask\",\"change_mailaccount\",\"change_frontendsettings\",\"delete_group\",\"add_userobjectpermission\",\"add_failure\",\"delete_mailrule\",\"view_userobjectpermission\",\"change_schedule\",\"delete_note\",\"view_ormq\",\"add_note\",\"add_chordcounter\",\"delete_token\",\"change_failure\",\"add_savedviewfilterrule\",\"delete_user\",\"view_correspondent\",\"view_schedule\",\"change_tokenproxy\",\"view_user\",\"delete_task\",\"delete_correspondent\",\"delete_chordcounter\",\"delete_document\",\"view_taskresult\",\"change_document\",\"add_frontendsettings\",\"view_mailrule\",\"change_ormq\",\"delete_taskattributes\",\"view_logentry\",\"view_mailaccount\",\"view_log\",\"delete_success\",\"view_frontendsettings\",\"view_documenttype\",\"change_taskresult\",\"view_permission\",\"add_groupobjectpermission\",\"change_user\",\"view_document\",\"change_userobjectpermission\",\"add_user\",\"add_correspondent\",\"add_token\",\"add_mailaccount\",\"change_group\",\"add_group\",\"delete_processedmail\",\"delete_contenttype\",\"add_savedview\",\"view_chordcounter\",\"delete_tokenproxy\",\"change_groupresult\",\"delete_session\",\"view_savedview\",\"view_processedmail\",\"add_comment\",\"view_storagepath\",\"delete_documenttype\",\"add_processedmail\",\"view_group\",\"change_processedmail\",\"view_session\",\"delete_storagepath\",\"delete_paperlesstask\",\"add_groupresult\",\"delete_savedview\",\"delete_userobjectpermission\",\"view_tokenproxy\",\"add_task\",\"view_tag\",\"add_taskresult\",\"change_documenttype\",\"change_mailrule\",\"add_document\",\"change_comment\",\"view_task\",\"view_groupresult\",\"change_contenttype\",\"view_groupobjectpermission\",\"change_task\",\"add_log\",\"add_success\",\"change_savedview\",\"delete_frontendsettings\",\"view_success\",\"add_permission\",\"change_correspondent\",\"add_paperlesstask\",\"change_paperlesstask\",\"add_contenttype\",\"view_comment\",\"change_logentry\",\"delete_logentry\",\"delete_mailaccount\",\"change_session\",\"delete_groupresult\",\"add_logentry\",\"change_savedviewfilterrule\",\"change_success\",\"delete_tag\",\"add_taskattributes\",\"change_groupobjectpermission\",\"delete_failure\",\"add_uisettings\",\"view_token\",\"add_schedule\",\"delete_log\",\"delete_uisettings\",\"change_permission\",\"delete_groupobjectpermission\",\"change_token\",\"view_uisettings\",\"change_uisettings\",\"delete_permission\",\"add_storagepath\",\"change_storagepath\",\"view_contenttype\",\"change_note\",\"change_log\",\"change_taskattributes\",\"add_documenttype\"]}"
|
||||
"text": "{\"user\":{\"id\":2,\"username\":\"testuser\",\"is_superuser\":false,\"groups\":[]},\"settings\":{\"language\":\"\",\"bulk_edit\":{\"confirmation_dialogs\":true,\"apply_on_close\":false},\"documentListSize\":50,\"dark_mode\":{\"use_system\":false,\"enabled\":\"false\",\"thumb_inverted\":\"true\"},\"theme\":{\"color\":\"#9fbf2f\"},\"document_details\":{\"native_pdf_viewer\":false},\"date_display\":{\"date_locale\":\"\",\"date_format\":\"mediumDate\"},\"notifications\":{\"consumer_new_documents\":true,\"consumer_success\":true,\"consumer_failed\":true,\"consumer_suppress_on_dashboard\":true},\"comments_enabled\":true,\"slim_sidebar\":false,\"update_checking\":{\"enabled\":false,\"backend_setting\":\"default\"},\"saved_views\":{\"warn_on_unsaved_change\":true,\"dashboard_views_visible_ids\":[7,4],\"sidebar_views_visible_ids\":[7,4,11]},\"notes_enabled\":true,\"tour_complete\":true},\"permissions\":[\"delete_schedule\",\"view_note\",\"view_taskattributes\",\"delete_ormq\",\"add_ormq\",\"change_tag\",\"change_chordcounter\",\"delete_savedviewfilterrule\",\"delete_comment\",\"add_session\",\"add_mailrule\",\"add_tokenproxy\",\"delete_taskresult\",\"view_failure\",\"add_tag\",\"view_savedviewfilterrule\",\"view_paperlesstask\",\"change_mailaccount\",\"change_frontendsettings\",\"delete_group\",\"add_userobjectpermission\",\"add_failure\",\"delete_mailrule\",\"view_userobjectpermission\",\"change_schedule\",\"delete_note\",\"view_ormq\",\"add_note\",\"add_chordcounter\",\"delete_token\",\"change_failure\",\"add_savedviewfilterrule\",\"delete_user\",\"view_correspondent\",\"view_schedule\",\"change_tokenproxy\",\"view_user\",\"delete_task\",\"delete_correspondent\",\"delete_chordcounter\",\"delete_document\",\"view_taskresult\",\"change_document\",\"add_frontendsettings\",\"view_mailrule\",\"change_ormq\",\"delete_taskattributes\",\"view_logentry\",\"view_mailaccount\",\"view_log\",\"delete_success\",\"view_frontendsettings\",\"view_documenttype\",\"change_taskresult\",\"view_permission\",\"add_groupobjectpermission\",\"change_user\",\"view_document\",\"change_userobjectpermission\",\"add_user\",\"add_correspondent\",\"add_token\",\"add_mailaccount\",\"change_group\",\"add_group\",\"delete_processedmail\",\"delete_contenttype\",\"add_savedview\",\"view_chordcounter\",\"delete_tokenproxy\",\"change_groupresult\",\"delete_session\",\"view_savedview\",\"view_processedmail\",\"add_comment\",\"view_storagepath\",\"delete_documenttype\",\"add_processedmail\",\"view_group\",\"change_processedmail\",\"view_session\",\"delete_storagepath\",\"delete_paperlesstask\",\"add_groupresult\",\"delete_savedview\",\"delete_userobjectpermission\",\"view_tokenproxy\",\"add_task\",\"view_tag\",\"add_taskresult\",\"change_documenttype\",\"change_mailrule\",\"add_document\",\"change_comment\",\"view_task\",\"view_groupresult\",\"change_contenttype\",\"view_groupobjectpermission\",\"change_task\",\"add_log\",\"add_success\",\"change_savedview\",\"delete_frontendsettings\",\"view_success\",\"add_permission\",\"change_correspondent\",\"add_paperlesstask\",\"change_paperlesstask\",\"add_contenttype\",\"view_comment\",\"change_logentry\",\"delete_logentry\",\"delete_mailaccount\",\"change_session\",\"delete_groupresult\",\"add_logentry\",\"change_savedviewfilterrule\",\"change_success\",\"delete_tag\",\"add_taskattributes\",\"change_groupobjectpermission\",\"delete_failure\",\"add_uisettings\",\"view_token\",\"add_schedule\",\"delete_log\",\"delete_uisettings\",\"change_permission\",\"delete_groupobjectpermission\",\"change_token\",\"view_uisettings\",\"change_uisettings\",\"delete_permission\",\"add_storagepath\",\"change_storagepath\",\"view_contenttype\",\"change_note\",\"change_log\",\"change_taskattributes\",\"add_documenttype\"]}"
|
||||
},
|
||||
"headersSize": -1,
|
||||
"bodySize": -1,
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
"content": {
|
||||
"size": -1,
|
||||
"mimeType": "application/json",
|
||||
"text": "{\"user\":{\"id\":2,\"username\":\"testuser\",\"is_superuser\":false,\"groups\":[]},\"settings\":{\"language\":\"\",\"bulk_edit\":{\"confirmation_dialogs\":true,\"apply_on_close\":false},\"documentListSize\":50,\"dark_mode\":{\"use_system\":false,\"enabled\":\"false\",\"thumb_inverted\":\"true\"},\"theme\":{\"color\":\"#9fbf2f\"},\"document_details\":{\"native_pdf_viewer\":false},\"date_display\":{\"date_locale\":\"\",\"date_format\":\"mediumDate\"},\"notifications\":{\"consumer_new_documents\":true,\"consumer_success\":true,\"consumer_failed\":true,\"consumer_suppress_on_dashboard\":true},\"comments_enabled\":true,\"slim_sidebar\":false,\"update_checking\":{\"enabled\":false,\"backend_setting\":\"default\"},\"saved_views\":{\"warn_on_unsaved_change\":true},\"notes_enabled\":true,\"tour_complete\":true},\"permissions\":[\"delete_schedule\",\"view_note\",\"view_taskattributes\",\"delete_ormq\",\"add_ormq\",\"change_tag\",\"change_chordcounter\",\"delete_savedviewfilterrule\",\"delete_comment\",\"add_session\",\"add_mailrule\",\"add_tokenproxy\",\"delete_taskresult\",\"view_failure\",\"add_tag\",\"view_savedviewfilterrule\",\"view_paperlesstask\",\"change_mailaccount\",\"change_frontendsettings\",\"delete_group\",\"add_userobjectpermission\",\"add_failure\",\"delete_mailrule\",\"view_userobjectpermission\",\"change_schedule\",\"delete_note\",\"view_ormq\",\"add_note\",\"add_chordcounter\",\"delete_token\",\"change_failure\",\"add_savedviewfilterrule\",\"delete_user\",\"view_correspondent\",\"view_schedule\",\"change_tokenproxy\",\"view_user\",\"delete_task\",\"delete_correspondent\",\"delete_chordcounter\",\"delete_document\",\"view_taskresult\",\"change_document\",\"add_frontendsettings\",\"view_mailrule\",\"change_ormq\",\"delete_taskattributes\",\"view_logentry\",\"view_mailaccount\",\"view_log\",\"delete_success\",\"view_frontendsettings\",\"view_documenttype\",\"change_taskresult\",\"view_permission\",\"add_groupobjectpermission\",\"change_user\",\"view_document\",\"change_userobjectpermission\",\"add_user\",\"add_correspondent\",\"add_token\",\"add_mailaccount\",\"change_group\",\"add_group\",\"delete_processedmail\",\"delete_contenttype\",\"add_savedview\",\"view_chordcounter\",\"delete_tokenproxy\",\"change_groupresult\",\"delete_session\",\"view_savedview\",\"view_processedmail\",\"add_comment\",\"view_storagepath\",\"delete_documenttype\",\"add_processedmail\",\"view_group\",\"change_processedmail\",\"view_session\",\"delete_storagepath\",\"delete_paperlesstask\",\"add_groupresult\",\"delete_savedview\",\"delete_userobjectpermission\",\"view_tokenproxy\",\"add_task\",\"view_tag\",\"add_taskresult\",\"change_documenttype\",\"change_mailrule\",\"add_document\",\"change_comment\",\"view_task\",\"view_groupresult\",\"change_contenttype\",\"view_groupobjectpermission\",\"change_task\",\"add_log\",\"add_success\",\"change_savedview\",\"delete_frontendsettings\",\"view_success\",\"add_permission\",\"change_correspondent\",\"add_paperlesstask\",\"change_paperlesstask\",\"add_contenttype\",\"view_comment\",\"change_logentry\",\"delete_logentry\",\"delete_mailaccount\",\"change_session\",\"delete_groupresult\",\"add_logentry\",\"change_savedviewfilterrule\",\"change_success\",\"delete_tag\",\"add_taskattributes\",\"change_groupobjectpermission\",\"delete_failure\",\"add_uisettings\",\"view_token\",\"add_schedule\",\"delete_log\",\"delete_uisettings\",\"change_permission\",\"delete_groupobjectpermission\",\"change_token\",\"view_uisettings\",\"change_uisettings\",\"delete_permission\",\"add_storagepath\",\"change_storagepath\",\"view_contenttype\",\"change_note\",\"change_log\",\"change_taskattributes\",\"add_documenttype\"]}"
|
||||
"text": "{\"user\":{\"id\":2,\"username\":\"testuser\",\"is_superuser\":false,\"groups\":[]},\"settings\":{\"language\":\"\",\"bulk_edit\":{\"confirmation_dialogs\":true,\"apply_on_close\":false},\"documentListSize\":50,\"dark_mode\":{\"use_system\":false,\"enabled\":\"false\",\"thumb_inverted\":\"true\"},\"theme\":{\"color\":\"#9fbf2f\"},\"document_details\":{\"native_pdf_viewer\":false},\"date_display\":{\"date_locale\":\"\",\"date_format\":\"mediumDate\"},\"notifications\":{\"consumer_new_documents\":true,\"consumer_success\":true,\"consumer_failed\":true,\"consumer_suppress_on_dashboard\":true},\"comments_enabled\":true,\"slim_sidebar\":false,\"update_checking\":{\"enabled\":false,\"backend_setting\":\"default\"},\"saved_views\":{\"warn_on_unsaved_change\":true,\"dashboard_views_visible_ids\":[7,4],\"sidebar_views_visible_ids\":[7,4,11]},\"notes_enabled\":true,\"tour_complete\":true},\"permissions\":[\"delete_schedule\",\"view_note\",\"view_taskattributes\",\"delete_ormq\",\"add_ormq\",\"change_tag\",\"change_chordcounter\",\"delete_savedviewfilterrule\",\"delete_comment\",\"add_session\",\"add_mailrule\",\"add_tokenproxy\",\"delete_taskresult\",\"view_failure\",\"add_tag\",\"view_savedviewfilterrule\",\"view_paperlesstask\",\"change_mailaccount\",\"change_frontendsettings\",\"delete_group\",\"add_userobjectpermission\",\"add_failure\",\"delete_mailrule\",\"view_userobjectpermission\",\"change_schedule\",\"delete_note\",\"view_ormq\",\"add_note\",\"add_chordcounter\",\"delete_token\",\"change_failure\",\"add_savedviewfilterrule\",\"delete_user\",\"view_correspondent\",\"view_schedule\",\"change_tokenproxy\",\"view_user\",\"delete_task\",\"delete_correspondent\",\"delete_chordcounter\",\"delete_document\",\"view_taskresult\",\"change_document\",\"add_frontendsettings\",\"view_mailrule\",\"change_ormq\",\"delete_taskattributes\",\"view_logentry\",\"view_mailaccount\",\"view_log\",\"delete_success\",\"view_frontendsettings\",\"view_documenttype\",\"change_taskresult\",\"view_permission\",\"add_groupobjectpermission\",\"change_user\",\"view_document\",\"change_userobjectpermission\",\"add_user\",\"add_correspondent\",\"add_token\",\"add_mailaccount\",\"change_group\",\"add_group\",\"delete_processedmail\",\"delete_contenttype\",\"add_savedview\",\"view_chordcounter\",\"delete_tokenproxy\",\"change_groupresult\",\"delete_session\",\"view_savedview\",\"view_processedmail\",\"add_comment\",\"view_storagepath\",\"delete_documenttype\",\"add_processedmail\",\"view_group\",\"change_processedmail\",\"view_session\",\"delete_storagepath\",\"delete_paperlesstask\",\"add_groupresult\",\"delete_savedview\",\"delete_userobjectpermission\",\"view_tokenproxy\",\"add_task\",\"view_tag\",\"add_taskresult\",\"change_documenttype\",\"change_mailrule\",\"add_document\",\"change_comment\",\"view_task\",\"view_groupresult\",\"change_contenttype\",\"view_groupobjectpermission\",\"change_task\",\"add_log\",\"add_success\",\"change_savedview\",\"delete_frontendsettings\",\"view_success\",\"add_permission\",\"change_correspondent\",\"add_paperlesstask\",\"change_paperlesstask\",\"add_contenttype\",\"view_comment\",\"change_logentry\",\"delete_logentry\",\"delete_mailaccount\",\"change_session\",\"delete_groupresult\",\"add_logentry\",\"change_savedviewfilterrule\",\"change_success\",\"delete_tag\",\"add_taskattributes\",\"change_groupobjectpermission\",\"delete_failure\",\"add_uisettings\",\"view_token\",\"add_schedule\",\"delete_log\",\"delete_uisettings\",\"change_permission\",\"delete_groupobjectpermission\",\"change_token\",\"view_uisettings\",\"change_uisettings\",\"delete_permission\",\"add_storagepath\",\"change_storagepath\",\"view_contenttype\",\"change_note\",\"change_log\",\"change_taskattributes\",\"add_documenttype\"]}"
|
||||
},
|
||||
"headersSize": -1,
|
||||
"bodySize": -1,
|
||||
|
||||
58
src-ui/eslint.config.js
Normal file
58
src-ui/eslint.config.js
Normal file
@@ -0,0 +1,58 @@
|
||||
const angularEslintPlugin = require('@angular-eslint/eslint-plugin')
|
||||
const angularTemplatePlugin = require('@angular-eslint/eslint-plugin-template')
|
||||
const angularTemplateParser = require('@angular-eslint/template-parser')
|
||||
const tsParser = require('@typescript-eslint/parser')
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
ignores: ['projects/**/*', 'src/app/components/common/pdf-viewer/**'],
|
||||
},
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
project: ['tsconfig.json'],
|
||||
createDefaultProgram: true,
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'@angular-eslint': angularEslintPlugin,
|
||||
'@angular-eslint/template': angularTemplatePlugin,
|
||||
},
|
||||
processor: '@angular-eslint/template/extract-inline-html',
|
||||
rules: {
|
||||
...angularEslintPlugin.configs.recommended.rules,
|
||||
'@angular-eslint/directive-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'attribute',
|
||||
prefix: 'pngx',
|
||||
style: 'camelCase',
|
||||
},
|
||||
],
|
||||
'@angular-eslint/component-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'element',
|
||||
prefix: 'pngx',
|
||||
style: 'kebab-case',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.html'],
|
||||
languageOptions: {
|
||||
parser: angularTemplateParser,
|
||||
},
|
||||
plugins: {
|
||||
'@angular-eslint/template': angularTemplatePlugin,
|
||||
},
|
||||
rules: {
|
||||
...angularTemplatePlugin.configs.recommended.rules,
|
||||
},
|
||||
},
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paperless-ngx-ui",
|
||||
"version": "2.20.9",
|
||||
"version": "2.20.10",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"ng": "ng",
|
||||
@@ -44,11 +44,11 @@
|
||||
"@angular-builders/jest": "^21.0.3",
|
||||
"@angular-devkit/core": "^21.2.0",
|
||||
"@angular-devkit/schematics": "^21.2.0",
|
||||
"@angular-eslint/builder": "21.2.0",
|
||||
"@angular-eslint/eslint-plugin": "21.2.0",
|
||||
"@angular-eslint/eslint-plugin-template": "21.2.0",
|
||||
"@angular-eslint/schematics": "21.2.0",
|
||||
"@angular-eslint/template-parser": "21.2.0",
|
||||
"@angular-eslint/builder": "21.3.0",
|
||||
"@angular-eslint/eslint-plugin": "21.3.0",
|
||||
"@angular-eslint/eslint-plugin-template": "21.3.0",
|
||||
"@angular-eslint/schematics": "21.3.0",
|
||||
"@angular-eslint/template-parser": "21.3.0",
|
||||
"@angular/build": "^21.2.0",
|
||||
"@angular/cli": "~21.2.0",
|
||||
"@angular/compiler-cli": "~21.2.0",
|
||||
@@ -59,7 +59,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
||||
"@typescript-eslint/parser": "^8.54.0",
|
||||
"@typescript-eslint/utils": "^8.54.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint": "^10.0.2",
|
||||
"jest": "30.2.0",
|
||||
"jest-environment-jsdom": "^30.2.0",
|
||||
"jest-junit": "^16.0.0",
|
||||
|
||||
315
src-ui/pnpm-lock.yaml
generated
315
src-ui/pnpm-lock.yaml
generated
@@ -103,20 +103,20 @@ importers:
|
||||
specifier: ^21.2.0
|
||||
version: 21.2.0(chokidar@5.0.0)
|
||||
'@angular-eslint/builder':
|
||||
specifier: 21.2.0
|
||||
version: 21.2.0(@angular/cli@21.2.0(@types/node@25.3.3)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
specifier: 21.3.0
|
||||
version: 21.3.0(@angular/cli@21.2.0(@types/node@25.3.3)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular-eslint/eslint-plugin':
|
||||
specifier: 21.2.0
|
||||
version: 21.2.0(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
specifier: 21.3.0
|
||||
version: 21.3.0(@typescript-eslint/utils@8.54.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular-eslint/eslint-plugin-template':
|
||||
specifier: 21.2.0
|
||||
version: 21.2.0(@angular-eslint/template-parser@21.2.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/types@8.54.0)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
specifier: 21.3.0
|
||||
version: 21.3.0(@angular-eslint/template-parser@21.3.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/types@8.54.0)(@typescript-eslint/utils@8.54.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular-eslint/schematics':
|
||||
specifier: 21.2.0
|
||||
version: 21.2.0(@angular-eslint/template-parser@21.2.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@angular/cli@21.2.0(@types/node@25.3.3)(chokidar@5.0.0))(@typescript-eslint/types@8.54.0)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
specifier: 21.3.0
|
||||
version: 21.3.0(@angular-eslint/template-parser@21.3.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(@angular/cli@21.2.0(@types/node@25.3.3)(chokidar@5.0.0))(@typescript-eslint/types@8.54.0)(@typescript-eslint/utils@8.54.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(chokidar@5.0.0)(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular-eslint/template-parser':
|
||||
specifier: 21.2.0
|
||||
version: 21.2.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
specifier: 21.3.0
|
||||
version: 21.3.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular/build':
|
||||
specifier: ^21.2.0
|
||||
version: 21.2.0(@angular/compiler-cli@21.2.0(@angular/compiler@21.2.0)(typescript@5.9.3))(@angular/compiler@21.2.0)(@angular/core@21.2.0(@angular/compiler@21.2.0)(rxjs@7.8.2)(zone.js@0.16.1))(@angular/localize@21.2.0(@angular/compiler-cli@21.2.0(@angular/compiler@21.2.0)(typescript@5.9.3))(@angular/compiler@21.2.0))(@angular/platform-browser@21.2.0(@angular/common@21.2.0(@angular/core@21.2.0(@angular/compiler@21.2.0)(rxjs@7.8.2)(zone.js@0.16.1))(rxjs@7.8.2))(@angular/core@21.2.0(@angular/compiler@21.2.0)(rxjs@7.8.2)(zone.js@0.16.1)))(@types/node@25.3.3)(chokidar@5.0.0)(jiti@2.6.1)(less@4.4.2)(postcss@8.5.6)(terser@5.44.1)(tslib@2.8.1)(typescript@5.9.3)(yaml@2.7.0)
|
||||
@@ -140,16 +140,16 @@ importers:
|
||||
version: 25.3.3
|
||||
'@typescript-eslint/eslint-plugin':
|
||||
specifier: ^8.54.0
|
||||
version: 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
version: 8.54.0(@typescript-eslint/parser@8.54.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/parser':
|
||||
specifier: ^8.54.0
|
||||
version: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
version: 8.54.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/utils':
|
||||
specifier: ^8.54.0
|
||||
version: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
version: 8.54.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint:
|
||||
specifier: ^9.39.2
|
||||
version: 9.39.2(jiti@2.6.1)
|
||||
specifier: ^10.0.2
|
||||
version: 10.0.2(jiti@2.6.1)
|
||||
jest:
|
||||
specifier: 30.2.0
|
||||
version: 30.2.0(@types/node@25.3.3)(ts-node@10.9.2(@types/node@25.3.3)(typescript@5.9.3))
|
||||
@@ -364,48 +364,48 @@ packages:
|
||||
resolution: {integrity: sha512-3kn3FI5v7BQ7Zct6raek+WgvyDwOJ8wElbyC903GxMQCDBRGGcevhHvTAIHhknihEsrgplzPhTlWeMbk1JfdFg==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
|
||||
|
||||
'@angular-eslint/builder@21.2.0':
|
||||
resolution: {integrity: sha512-wcp3J9cbrDwSeI/o1D/DSvMQa8zpKjc5WhRGTx33omhWijCfiVNEAiBLWiEx5Sb/dWcoX8yFNWY5jSgFVy9Sjw==}
|
||||
'@angular-eslint/builder@21.3.0':
|
||||
resolution: {integrity: sha512-26QUUouei52biUFAlJSrWNAU9tuF2miKwd8uHdxWwCF31xz+OxC5+NfudWvt1AFaYow7gWueX1QX3rNNtSPDrg==}
|
||||
peerDependencies:
|
||||
'@angular/cli': '>= 21.0.0 < 22.0.0'
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '*'
|
||||
|
||||
'@angular-eslint/bundled-angular-compiler@21.2.0':
|
||||
resolution: {integrity: sha512-J0DWL+j6t9ItFIyIADvzHGqwDA1qfVJ9bx+oTmJ/Hlo7cUpIRoXpcTXpug0CEEABFH0RfDu6PDG2b0FoZ1+7bg==}
|
||||
'@angular-eslint/bundled-angular-compiler@21.3.0':
|
||||
resolution: {integrity: sha512-l521I24J9gJxyMbRkrM24Tc7W8J8BP+TDAmVs2nT8+lXbS3kg8QpWBRtd+hNUgq6o+vt+lKBkytnEfu8OiqeRg==}
|
||||
|
||||
'@angular-eslint/eslint-plugin-template@21.2.0':
|
||||
resolution: {integrity: sha512-lJ13Dj0DjR6YiceQR0sRbyWzSzOQ6uZPwK9CJUF3wuZjYAUvL1D61zaU9QrVLtf89NVOxv+dYZHDdu3IDeIqbA==}
|
||||
'@angular-eslint/eslint-plugin-template@21.3.0':
|
||||
resolution: {integrity: sha512-lVixd/KypPWgA/5/pUOhJV9MTcaHjYZEqyOi+IiLk+h+maGxn6/s6Ot+20n+XGS85zAgOY+qUw6EEQ11hoojIQ==}
|
||||
peerDependencies:
|
||||
'@angular-eslint/template-parser': 21.2.0
|
||||
'@angular-eslint/template-parser': 21.3.0
|
||||
'@typescript-eslint/types': ^7.11.0 || ^8.0.0
|
||||
'@typescript-eslint/utils': ^7.11.0 || ^8.0.0
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '*'
|
||||
|
||||
'@angular-eslint/eslint-plugin@21.2.0':
|
||||
resolution: {integrity: sha512-X2Qn2viDsjm91CEMxNrxDH3qkKpp6un0C1F1BW2p/m9J4AUVfOcXwWz9UpHFSHTRQ+YlTJbiH1ZwwAPeKhFaxA==}
|
||||
'@angular-eslint/eslint-plugin@21.3.0':
|
||||
resolution: {integrity: sha512-Whf/AUUBekOlfSJRS78m76YGrBQAZ3waXE7oOdlW5xEQvn8jBDN9EGuNnjg/syZzvzjK4ZpYC4g1XYXrc+fQIg==}
|
||||
peerDependencies:
|
||||
'@typescript-eslint/utils': ^7.11.0 || ^8.0.0
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '*'
|
||||
|
||||
'@angular-eslint/schematics@21.2.0':
|
||||
resolution: {integrity: sha512-WtT4fPKIUQ/hswy+l2GF/rKOdD+42L3fUzzcwRzNutQbe2tU9SimoSOAsay/ylWEuhIOQTs7ysPB8fUgFQoLpA==}
|
||||
'@angular-eslint/schematics@21.3.0':
|
||||
resolution: {integrity: sha512-8deU/zVY9f8k8kAQQ9PL130ox2VlrZw3fMxgsPNAY5tjQ0xk0J2YVSszYHhcqdMGG1J01IsxIjvQaJ4pFfEmMw==}
|
||||
peerDependencies:
|
||||
'@angular/cli': '>= 21.0.0 < 22.0.0'
|
||||
|
||||
'@angular-eslint/template-parser@21.2.0':
|
||||
resolution: {integrity: sha512-TCb3qYOC/uXKZCo56cJ6N9sHeWdFhyVqrbbYfFjTi09081T6jllgHDZL5Ms7gOMNY8KywWGGbhxwvzeA0RwTgA==}
|
||||
'@angular-eslint/template-parser@21.3.0':
|
||||
resolution: {integrity: sha512-ysyou1zAY6M6rSZNdIcYKGd4nk6TCapamyFNB3ivmTlVZ0O35TS9o/rJ0aUttuHgDp+Ysgs3ql+LA746PXgCyQ==}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '*'
|
||||
|
||||
'@angular-eslint/utils@21.2.0':
|
||||
resolution: {integrity: sha512-E19/hkuvHoNFvctBkmEiGWpy2bbC6cgbr3GNVrn2nGtbI4jnwnDFCGHv50I4LBfvj0PA9E6TWe73ejJ5qoMJWQ==}
|
||||
'@angular-eslint/utils@21.3.0':
|
||||
resolution: {integrity: sha512-oNigH6w3l+owTMboj/uFG0tHOy43uH8BpQRtBOQL1/s2+5in/BJ2Fjobv3SyizxTgeJ1FhRefbkT8GmVjK7jAA==}
|
||||
peerDependencies:
|
||||
'@typescript-eslint/utils': ^7.11.0 || ^8.0.0
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '*'
|
||||
|
||||
'@angular/build@21.1.2':
|
||||
@@ -1579,33 +1579,25 @@ packages:
|
||||
resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
|
||||
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
|
||||
|
||||
'@eslint/config-array@0.21.1':
|
||||
resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
'@eslint/config-array@0.23.2':
|
||||
resolution: {integrity: sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||
|
||||
'@eslint/config-helpers@0.4.2':
|
||||
resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
'@eslint/config-helpers@0.5.2':
|
||||
resolution: {integrity: sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||
|
||||
'@eslint/core@0.17.0':
|
||||
resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
'@eslint/core@1.1.0':
|
||||
resolution: {integrity: sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||
|
||||
'@eslint/eslintrc@3.3.3':
|
||||
resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
'@eslint/object-schema@3.0.2':
|
||||
resolution: {integrity: sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||
|
||||
'@eslint/js@9.39.2':
|
||||
resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@eslint/object-schema@2.1.7':
|
||||
resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@eslint/plugin-kit@0.4.1':
|
||||
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
'@eslint/plugin-kit@0.6.0':
|
||||
resolution: {integrity: sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||
|
||||
'@fastify/busboy@2.1.1':
|
||||
resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==}
|
||||
@@ -3324,8 +3316,8 @@ packages:
|
||||
peerDependencies:
|
||||
ajv: ^8.8.2
|
||||
|
||||
ajv@6.12.6:
|
||||
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
|
||||
ajv@6.14.0:
|
||||
resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==}
|
||||
|
||||
ajv@8.17.1:
|
||||
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
|
||||
@@ -4024,12 +4016,8 @@ packages:
|
||||
resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
eslint-scope@8.4.0:
|
||||
resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
eslint-scope@9.1.0:
|
||||
resolution: {integrity: sha512-CkWE42hOJsNj9FJRaoMX9waUFYhqY4jmyLFdAdzZr6VaCg3ynLYx4WnOdkaIifGfH4gsUcBTn4OZbHXkpLD0FQ==}
|
||||
eslint-scope@9.1.1:
|
||||
resolution: {integrity: sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||
|
||||
eslint-visitor-keys@3.4.3:
|
||||
@@ -4040,9 +4028,13 @@ packages:
|
||||
resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
eslint@9.39.2:
|
||||
resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
eslint-visitor-keys@5.0.1:
|
||||
resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||
|
||||
eslint@10.0.2:
|
||||
resolution: {integrity: sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
jiti: '*'
|
||||
@@ -4050,9 +4042,9 @@ packages:
|
||||
jiti:
|
||||
optional: true
|
||||
|
||||
espree@10.4.0:
|
||||
resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
espree@11.1.1:
|
||||
resolution: {integrity: sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||
|
||||
esprima@4.0.1:
|
||||
resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
|
||||
@@ -4316,10 +4308,6 @@ packages:
|
||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
globals@14.0.0:
|
||||
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
gopd@1.2.0:
|
||||
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -4974,9 +4962,6 @@ packages:
|
||||
lodash.memoize@4.1.2:
|
||||
resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
|
||||
|
||||
lodash.merge@4.6.2:
|
||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||
|
||||
lodash@4.17.23:
|
||||
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
||||
|
||||
@@ -5110,9 +5095,6 @@ packages:
|
||||
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
minimatch@3.1.2:
|
||||
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
|
||||
|
||||
minimatch@3.1.5:
|
||||
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
|
||||
|
||||
@@ -7123,48 +7105,48 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- chokidar
|
||||
|
||||
'@angular-eslint/builder@21.2.0(@angular/cli@21.2.0(@types/node@25.3.3)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
'@angular-eslint/builder@21.3.0(@angular/cli@21.2.0(@types/node@25.3.3)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@angular-devkit/architect': 0.2101.2(chokidar@5.0.0)
|
||||
'@angular-devkit/architect': 0.2102.0(chokidar@5.0.0)
|
||||
'@angular-devkit/core': 21.2.0(chokidar@5.0.0)
|
||||
'@angular/cli': 21.2.0(@types/node@25.3.3)(chokidar@5.0.0)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint: 10.0.2(jiti@2.6.1)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- chokidar
|
||||
|
||||
'@angular-eslint/bundled-angular-compiler@21.2.0': {}
|
||||
'@angular-eslint/bundled-angular-compiler@21.3.0': {}
|
||||
|
||||
'@angular-eslint/eslint-plugin-template@21.2.0(@angular-eslint/template-parser@21.2.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/types@8.54.0)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
'@angular-eslint/eslint-plugin-template@21.3.0(@angular-eslint/template-parser@21.3.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/types@8.54.0)(@typescript-eslint/utils@8.54.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@angular-eslint/bundled-angular-compiler': 21.2.0
|
||||
'@angular-eslint/template-parser': 21.2.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular-eslint/utils': 21.2.0(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular-eslint/bundled-angular-compiler': 21.3.0
|
||||
'@angular-eslint/template-parser': 21.3.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular-eslint/utils': 21.3.0(@typescript-eslint/utils@8.54.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.54.0
|
||||
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.54.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
aria-query: 5.3.2
|
||||
axobject-query: 4.1.0
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint: 10.0.2(jiti@2.6.1)
|
||||
typescript: 5.9.3
|
||||
|
||||
'@angular-eslint/eslint-plugin@21.2.0(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
'@angular-eslint/eslint-plugin@21.3.0(@typescript-eslint/utils@8.54.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@angular-eslint/bundled-angular-compiler': 21.2.0
|
||||
'@angular-eslint/utils': 21.2.0(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
'@angular-eslint/bundled-angular-compiler': 21.3.0
|
||||
'@angular-eslint/utils': 21.3.0(@typescript-eslint/utils@8.54.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.54.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint: 10.0.2(jiti@2.6.1)
|
||||
ts-api-utils: 2.4.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
|
||||
'@angular-eslint/schematics@21.2.0(@angular-eslint/template-parser@21.2.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@angular/cli@21.2.0(@types/node@25.3.3)(chokidar@5.0.0))(@typescript-eslint/types@8.54.0)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
'@angular-eslint/schematics@21.3.0(@angular-eslint/template-parser@21.3.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(@angular/cli@21.2.0(@types/node@25.3.3)(chokidar@5.0.0))(@typescript-eslint/types@8.54.0)(@typescript-eslint/utils@8.54.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(chokidar@5.0.0)(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@angular-devkit/core': 21.2.0(chokidar@5.0.0)
|
||||
'@angular-devkit/schematics': 21.2.0(chokidar@5.0.0)
|
||||
'@angular-eslint/eslint-plugin': 21.2.0(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular-eslint/eslint-plugin-template': 21.2.0(@angular-eslint/template-parser@21.2.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/types@8.54.0)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular-eslint/eslint-plugin': 21.3.0(@typescript-eslint/utils@8.54.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular-eslint/eslint-plugin-template': 21.3.0(@angular-eslint/template-parser@21.3.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/types@8.54.0)(@typescript-eslint/utils@8.54.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@angular/cli': 21.2.0(@types/node@25.3.3)(chokidar@5.0.0)
|
||||
ignore: 7.0.5
|
||||
semver: 7.7.3
|
||||
semver: 7.7.4
|
||||
strip-json-comments: 3.1.1
|
||||
transitivePeerDependencies:
|
||||
- '@angular-eslint/template-parser'
|
||||
@@ -7174,18 +7156,18 @@ snapshots:
|
||||
- eslint
|
||||
- typescript
|
||||
|
||||
'@angular-eslint/template-parser@21.2.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
'@angular-eslint/template-parser@21.3.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@angular-eslint/bundled-angular-compiler': 21.2.0
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint-scope: 9.1.0
|
||||
'@angular-eslint/bundled-angular-compiler': 21.3.0
|
||||
eslint: 10.0.2(jiti@2.6.1)
|
||||
eslint-scope: 9.1.1
|
||||
typescript: 5.9.3
|
||||
|
||||
'@angular-eslint/utils@21.2.0(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
'@angular-eslint/utils@21.3.0(@typescript-eslint/utils@8.54.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@angular-eslint/bundled-angular-compiler': 21.2.0
|
||||
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
'@angular-eslint/bundled-angular-compiler': 21.3.0
|
||||
'@typescript-eslint/utils': 8.54.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint: 10.0.2(jiti@2.6.1)
|
||||
typescript: 5.9.3
|
||||
|
||||
'@angular/build@21.1.2(@angular/compiler-cli@21.2.0(@angular/compiler@21.2.0)(typescript@5.9.3))(@angular/compiler@21.2.0)(@angular/core@21.2.0(@angular/compiler@21.2.0)(rxjs@7.8.2)(zone.js@0.16.1))(@angular/localize@21.2.0(@angular/compiler-cli@21.2.0(@angular/compiler@21.2.0)(typescript@5.9.3))(@angular/compiler@21.2.0))(@angular/platform-browser@21.2.0(@angular/common@21.2.0(@angular/core@21.2.0(@angular/compiler@21.2.0)(rxjs@7.8.2)(zone.js@0.16.1))(rxjs@7.8.2))(@angular/core@21.2.0(@angular/compiler@21.2.0)(rxjs@7.8.2)(zone.js@0.16.1)))(@types/node@25.3.3)(chokidar@5.0.0)(jiti@2.6.1)(less@4.4.2)(postcss@8.5.6)(terser@5.44.1)(tslib@2.8.1)(typescript@5.9.3)(yaml@2.7.0)':
|
||||
@@ -8422,50 +8404,34 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))':
|
||||
'@eslint-community/eslint-utils@4.9.1(eslint@10.0.2(jiti@2.6.1))':
|
||||
dependencies:
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint: 10.0.2(jiti@2.6.1)
|
||||
eslint-visitor-keys: 3.4.3
|
||||
|
||||
'@eslint-community/regexpp@4.12.2': {}
|
||||
|
||||
'@eslint/config-array@0.21.1':
|
||||
'@eslint/config-array@0.23.2':
|
||||
dependencies:
|
||||
'@eslint/object-schema': 2.1.7
|
||||
'@eslint/object-schema': 3.0.2
|
||||
debug: 4.4.3
|
||||
minimatch: 3.1.2
|
||||
minimatch: 10.2.4
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@eslint/config-helpers@0.4.2':
|
||||
'@eslint/config-helpers@0.5.2':
|
||||
dependencies:
|
||||
'@eslint/core': 0.17.0
|
||||
'@eslint/core': 1.1.0
|
||||
|
||||
'@eslint/core@0.17.0':
|
||||
'@eslint/core@1.1.0':
|
||||
dependencies:
|
||||
'@types/json-schema': 7.0.15
|
||||
|
||||
'@eslint/eslintrc@3.3.3':
|
||||
'@eslint/object-schema@3.0.2': {}
|
||||
|
||||
'@eslint/plugin-kit@0.6.0':
|
||||
dependencies:
|
||||
ajv: 6.12.6
|
||||
debug: 4.4.3
|
||||
espree: 10.4.0
|
||||
globals: 14.0.0
|
||||
ignore: 5.3.2
|
||||
import-fresh: 3.3.1
|
||||
js-yaml: 4.1.1
|
||||
minimatch: 3.1.2
|
||||
strip-json-comments: 3.1.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@eslint/js@9.39.2': {}
|
||||
|
||||
'@eslint/object-schema@2.1.7': {}
|
||||
|
||||
'@eslint/plugin-kit@0.4.1':
|
||||
dependencies:
|
||||
'@eslint/core': 0.17.0
|
||||
'@eslint/core': 1.1.0
|
||||
levn: 0.4.1
|
||||
|
||||
'@fastify/busboy@2.1.1': {}
|
||||
@@ -9844,15 +9810,15 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/yargs-parser': 21.0.3
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
'@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/parser': 8.54.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/scope-manager': 8.54.0
|
||||
'@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/type-utils': 8.54.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.54.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.54.0
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint: 10.0.2(jiti@2.6.1)
|
||||
ignore: 7.0.5
|
||||
natural-compare: 1.4.0
|
||||
ts-api-utils: 2.4.0(typescript@5.9.3)
|
||||
@@ -9860,14 +9826,14 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
'@typescript-eslint/parser@8.54.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 8.54.0
|
||||
'@typescript-eslint/types': 8.54.0
|
||||
'@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.54.0
|
||||
debug: 4.4.3
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint: 10.0.2(jiti@2.6.1)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -9890,13 +9856,13 @@ snapshots:
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
'@typescript-eslint/type-utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
'@typescript-eslint/type-utils@8.54.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.54.0
|
||||
'@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.54.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
debug: 4.4.3
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint: 10.0.2(jiti@2.6.1)
|
||||
ts-api-utils: 2.4.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
@@ -9919,13 +9885,13 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
'@typescript-eslint/utils@8.54.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2(jiti@2.6.1))
|
||||
'@typescript-eslint/scope-manager': 8.54.0
|
||||
'@typescript-eslint/types': 8.54.0
|
||||
'@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint: 10.0.2(jiti@2.6.1)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -10136,7 +10102,7 @@ snapshots:
|
||||
ajv: 8.18.0
|
||||
fast-deep-equal: 3.1.3
|
||||
|
||||
ajv@6.12.6:
|
||||
ajv@6.14.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-json-stable-stringify: 2.1.0
|
||||
@@ -10905,12 +10871,7 @@ snapshots:
|
||||
esrecurse: 4.3.0
|
||||
estraverse: 4.3.0
|
||||
|
||||
eslint-scope@8.4.0:
|
||||
dependencies:
|
||||
esrecurse: 4.3.0
|
||||
estraverse: 5.3.0
|
||||
|
||||
eslint-scope@9.1.0:
|
||||
eslint-scope@9.1.1:
|
||||
dependencies:
|
||||
'@types/esrecurse': 4.3.1
|
||||
'@types/estree': 1.0.8
|
||||
@@ -10921,28 +10882,27 @@ snapshots:
|
||||
|
||||
eslint-visitor-keys@4.2.1: {}
|
||||
|
||||
eslint@9.39.2(jiti@2.6.1):
|
||||
eslint-visitor-keys@5.0.1: {}
|
||||
|
||||
eslint@10.0.2(jiti@2.6.1):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2(jiti@2.6.1))
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@eslint/config-array': 0.21.1
|
||||
'@eslint/config-helpers': 0.4.2
|
||||
'@eslint/core': 0.17.0
|
||||
'@eslint/eslintrc': 3.3.3
|
||||
'@eslint/js': 9.39.2
|
||||
'@eslint/plugin-kit': 0.4.1
|
||||
'@eslint/config-array': 0.23.2
|
||||
'@eslint/config-helpers': 0.5.2
|
||||
'@eslint/core': 1.1.0
|
||||
'@eslint/plugin-kit': 0.6.0
|
||||
'@humanfs/node': 0.16.7
|
||||
'@humanwhocodes/module-importer': 1.0.1
|
||||
'@humanwhocodes/retry': 0.4.3
|
||||
'@types/estree': 1.0.8
|
||||
ajv: 6.12.6
|
||||
chalk: 4.1.2
|
||||
ajv: 6.14.0
|
||||
cross-spawn: 7.0.6
|
||||
debug: 4.4.3
|
||||
escape-string-regexp: 4.0.0
|
||||
eslint-scope: 8.4.0
|
||||
eslint-visitor-keys: 4.2.1
|
||||
espree: 10.4.0
|
||||
eslint-scope: 9.1.1
|
||||
eslint-visitor-keys: 5.0.1
|
||||
espree: 11.1.1
|
||||
esquery: 1.7.0
|
||||
esutils: 2.0.3
|
||||
fast-deep-equal: 3.1.3
|
||||
@@ -10953,8 +10913,7 @@ snapshots:
|
||||
imurmurhash: 0.1.4
|
||||
is-glob: 4.0.3
|
||||
json-stable-stringify-without-jsonify: 1.0.1
|
||||
lodash.merge: 4.6.2
|
||||
minimatch: 3.1.2
|
||||
minimatch: 10.2.4
|
||||
natural-compare: 1.4.0
|
||||
optionator: 0.9.4
|
||||
optionalDependencies:
|
||||
@@ -10962,11 +10921,11 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
espree@10.4.0:
|
||||
espree@11.1.1:
|
||||
dependencies:
|
||||
acorn: 8.16.0
|
||||
acorn-jsx: 5.3.2(acorn@8.16.0)
|
||||
eslint-visitor-keys: 4.2.1
|
||||
eslint-visitor-keys: 5.0.1
|
||||
|
||||
esprima@4.0.1: {}
|
||||
|
||||
@@ -11284,8 +11243,6 @@ snapshots:
|
||||
once: 1.4.0
|
||||
path-is-absolute: 1.0.1
|
||||
|
||||
globals@14.0.0: {}
|
||||
|
||||
gopd@1.2.0: {}
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
@@ -12177,8 +12134,6 @@ snapshots:
|
||||
|
||||
lodash.memoize@4.1.2: {}
|
||||
|
||||
lodash.merge@4.6.2: {}
|
||||
|
||||
lodash@4.17.23: {}
|
||||
|
||||
log-symbols@7.0.1:
|
||||
@@ -12314,10 +12269,6 @@ snapshots:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.4
|
||||
|
||||
minimatch@3.1.2:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.12
|
||||
|
||||
minimatch@3.1.5:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.12
|
||||
|
||||
@@ -277,7 +277,7 @@
|
||||
<div class="col">
|
||||
<select class="form-select" formControlName="pdfEditorDefaultEditMode">
|
||||
<option [ngValue]="PdfEditorEditMode.Create" i18n>Create new document(s)</option>
|
||||
<option [ngValue]="PdfEditorEditMode.Update" i18n>Update existing document</option>
|
||||
<option [ngValue]="PdfEditorEditMode.Update" i18n>Add document version</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -99,12 +99,7 @@
|
||||
</ul>
|
||||
|
||||
<div class="nav-group mt-3 mb-1" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
||||
@if (savedViewService.loading) {
|
||||
<h6 class="sidebar-heading px-3 text-muted">
|
||||
<span i18n>Saved views</span>
|
||||
<div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
|
||||
</h6>
|
||||
} @else if (savedViewService.sidebarViews?.length > 0) {
|
||||
@if (savedViewService.sidebarViews?.length > 0) {
|
||||
<h6 class="sidebar-heading px-3 text-muted">
|
||||
<span i18n>Saved views</span>
|
||||
</h6>
|
||||
@@ -134,6 +129,11 @@
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else if (savedViewService.loading) {
|
||||
<h6 class="sidebar-heading px-3 text-muted">
|
||||
<span i18n>Saved views</span>
|
||||
<div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
|
||||
</h6>
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,10 +10,22 @@
|
||||
<ul class="list-group"
|
||||
cdkDropList
|
||||
(cdkDropListDropped)="onDrop($event)">
|
||||
@for (documentID of documentIDs; track documentID) {
|
||||
<li class="list-group-item" cdkDrag>
|
||||
@for (document of documents; track document.id) {
|
||||
<li class="list-group-item d-flex align-items-center" cdkDrag>
|
||||
<i-bs name="grip-vertical" class="me-2"></i-bs>
|
||||
{{getDocument(documentID)?.title}}
|
||||
<div class="d-flex flex-column">
|
||||
<div>
|
||||
@if (document.correspondent) {
|
||||
<b>{{document.correspondent | correspondentName | async}}: </b>
|
||||
}{{document.title}}
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
{{document.created | customDate:'mediumDate'}}
|
||||
@if (document.page_count) {
|
||||
| {document.page_count, plural, =1 {One page} other {{{document.page_count}} pages}}
|
||||
}
|
||||
</small>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
@@ -3,11 +3,14 @@ import {
|
||||
DragDropModule,
|
||||
moveItemInArray,
|
||||
} from '@angular/cdk/drag-drop'
|
||||
import { AsyncPipe } from '@angular/common'
|
||||
import { Component, OnInit, inject } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { takeUntil } from 'rxjs'
|
||||
import { Document } from 'src/app/data/document'
|
||||
import { CorrespondentNamePipe } from 'src/app/pipes/correspondent-name.pipe'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
||||
@@ -17,6 +20,9 @@ import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
||||
templateUrl: './merge-confirm-dialog.component.html',
|
||||
styleUrl: './merge-confirm-dialog.component.scss',
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
CorrespondentNamePipe,
|
||||
CustomDatePipe,
|
||||
DragDropModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
|
||||
@@ -31,8 +31,8 @@ export enum EditDialogMode {
|
||||
|
||||
@Directive()
|
||||
export abstract class EditDialogComponent<
|
||||
T extends ObjectWithPermissions | ObjectWithId,
|
||||
>
|
||||
T extends ObjectWithPermissions | ObjectWithId,
|
||||
>
|
||||
extends LoadingComponentWithPermissions
|
||||
implements OnInit
|
||||
{
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
<input type="radio" class="btn-check" [(ngModel)]="editMode" [value]="PdfEditorEditMode.Update" id="editModeUpdate" name="editmode" [disabled]="hasSplit()">
|
||||
<label for="editModeUpdate" class="btn btn-outline-primary btn-sm">
|
||||
<i-bs name="pencil"></i-bs>
|
||||
<span class="form-check-label ms-2" i18n>Update existing document</span>
|
||||
<span class="form-check-label ms-2" i18n>Add document version</span>
|
||||
</label>
|
||||
</div>
|
||||
@if (editMode === PdfEditorEditMode.Create) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { PDFEditorComponent } from './pdf-editor.component'
|
||||
|
||||
describe('PDFEditorComponent', () => {
|
||||
@@ -139,4 +140,16 @@ describe('PDFEditorComponent', () => {
|
||||
expect(component.pages[1].page).toBe(2)
|
||||
expect(component.pages[2].page).toBe(3)
|
||||
})
|
||||
|
||||
it('should include selected version in preview source when provided', () => {
|
||||
const documentService = TestBed.inject(DocumentService)
|
||||
const previewSpy = jest
|
||||
.spyOn(documentService, 'getPreviewUrl')
|
||||
.mockReturnValue('preview-version')
|
||||
component.documentID = 3
|
||||
component.versionID = 10
|
||||
|
||||
expect(component.pdfSrc).toBe('preview-version')
|
||||
expect(previewSpy).toHaveBeenCalledWith(3, false, 10)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -46,6 +46,7 @@ export class PDFEditorComponent extends ConfirmDialogComponent {
|
||||
activeModal: NgbActiveModal = inject(NgbActiveModal)
|
||||
|
||||
documentID: number
|
||||
versionID?: number
|
||||
pages: PageOperation[] = []
|
||||
totalPages = 0
|
||||
editMode: PdfEditorEditMode = this.settingsService.get(
|
||||
@@ -55,7 +56,11 @@ export class PDFEditorComponent extends ConfirmDialogComponent {
|
||||
includeMetadata: boolean = true
|
||||
|
||||
get pdfSrc(): string {
|
||||
return this.documentService.getPreviewUrl(this.documentID)
|
||||
return this.documentService.getPreviewUrl(
|
||||
this.documentID,
|
||||
false,
|
||||
this.versionID
|
||||
)
|
||||
}
|
||||
|
||||
pdfLoaded(pdf: PngxPdfDocumentProxy) {
|
||||
|
||||
@@ -16,6 +16,12 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@if (note) {
|
||||
<div class="small text-muted fst-italic mt-2">
|
||||
{{ note }}
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@if (!buttonsEnabled) {
|
||||
|
||||
@@ -40,6 +40,9 @@ export class PermissionsDialogComponent {
|
||||
@Input()
|
||||
title = $localize`Set permissions`
|
||||
|
||||
@Input()
|
||||
note: string = null
|
||||
|
||||
@Input()
|
||||
set object(o: ObjectWithPermissions) {
|
||||
this.o = o
|
||||
|
||||
@@ -65,6 +65,7 @@ import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
||||
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
|
||||
@@ -83,9 +84,9 @@ const doc: Document = {
|
||||
storage_path: 31,
|
||||
tags: [41, 42, 43],
|
||||
content: 'text content',
|
||||
added: new Date('May 4, 2014 03:24:00'),
|
||||
created: new Date('May 4, 2014 03:24:00'),
|
||||
modified: new Date('May 4, 2014 03:24:00'),
|
||||
added: new Date('May 4, 2014 03:24:00').toISOString(),
|
||||
created: new Date('May 4, 2014 03:24:00').toISOString(),
|
||||
modified: new Date('May 4, 2014 03:24:00').toISOString(),
|
||||
archive_serial_number: null,
|
||||
original_file_name: 'file.pdf',
|
||||
owner: null,
|
||||
@@ -327,6 +328,29 @@ describe('DocumentDetailComponent', () => {
|
||||
expect(component.activeNavID).toEqual(component.DocumentDetailNavIDs.Notes)
|
||||
})
|
||||
|
||||
it('should switch from preview to details when pdf preview enters the DOM', fakeAsync(() => {
|
||||
component.nav = {
|
||||
activeId: component.DocumentDetailNavIDs.Preview,
|
||||
select: jest.fn(),
|
||||
} as any
|
||||
;(component as any).pdfPreview = {
|
||||
nativeElement: { offsetParent: {} },
|
||||
}
|
||||
|
||||
tick()
|
||||
expect(component.nav.select).toHaveBeenCalledWith(
|
||||
component.DocumentDetailNavIDs.Details
|
||||
)
|
||||
}))
|
||||
|
||||
it('should forward title key up value to titleSubject', () => {
|
||||
const subjectSpy = jest.spyOn(component.titleSubject, 'next')
|
||||
|
||||
component.titleKeyUp({ target: { value: 'Updated title' } })
|
||||
|
||||
expect(subjectSpy).toHaveBeenCalledWith('Updated title')
|
||||
})
|
||||
|
||||
it('should change url on tab switch', () => {
|
||||
initNormally()
|
||||
const navigateSpy = jest.spyOn(router, 'navigate')
|
||||
@@ -524,7 +548,7 @@ describe('DocumentDetailComponent', () => {
|
||||
jest.spyOn(documentService, 'get').mockReturnValue(
|
||||
of({
|
||||
...doc,
|
||||
modified: new Date('2024-01-02T00:00:00Z'),
|
||||
modified: '2024-01-02T00:00:00Z',
|
||||
duplicate_documents: updatedDuplicates,
|
||||
})
|
||||
)
|
||||
@@ -926,8 +950,8 @@ describe('DocumentDetailComponent', () => {
|
||||
|
||||
it('should support reprocess, confirm and close modal after started', () => {
|
||||
initNormally()
|
||||
const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit')
|
||||
bulkEditSpy.mockReturnValue(of(true))
|
||||
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
|
||||
reprocessSpy.mockReturnValue(of(true))
|
||||
let openModal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
||||
const modalSpy = jest.spyOn(modalService, 'open')
|
||||
@@ -935,7 +959,7 @@ describe('DocumentDetailComponent', () => {
|
||||
component.reprocess()
|
||||
const modalCloseSpy = jest.spyOn(openModal, 'close')
|
||||
openModal.componentInstance.confirmClicked.next()
|
||||
expect(bulkEditSpy).toHaveBeenCalledWith([doc.id], 'reprocess', {})
|
||||
expect(reprocessSpy).toHaveBeenCalledWith([doc.id])
|
||||
expect(modalSpy).toHaveBeenCalled()
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
expect(modalCloseSpy).toHaveBeenCalled()
|
||||
@@ -943,13 +967,13 @@ describe('DocumentDetailComponent', () => {
|
||||
|
||||
it('should show error if redo ocr call fails', () => {
|
||||
initNormally()
|
||||
const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit')
|
||||
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
|
||||
let openModal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||
component.reprocess()
|
||||
const modalCloseSpy = jest.spyOn(openModal, 'close')
|
||||
bulkEditSpy.mockReturnValue(throwError(() => new Error('error occurred')))
|
||||
reprocessSpy.mockReturnValue(throwError(() => new Error('error occurred')))
|
||||
openModal.componentInstance.confirmClicked.next()
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
expect(modalCloseSpy).not.toHaveBeenCalled()
|
||||
@@ -1386,17 +1410,21 @@ describe('DocumentDetailComponent', () => {
|
||||
expect(errorSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should warn when open document does not match doc retrieved from backend on init', () => {
|
||||
it('should show incoming update modal when open local draft is older than backend on init', () => {
|
||||
let openModal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((modals) => (openModal = modals[0]))
|
||||
const modalSpy = jest.spyOn(modalService, 'open')
|
||||
const openDoc = Object.assign({}, doc)
|
||||
const openDoc = Object.assign({}, doc, {
|
||||
__changedFields: ['title'],
|
||||
})
|
||||
// simulate a document being modified elsewhere and db updated
|
||||
doc.modified = new Date()
|
||||
const remoteDoc = Object.assign({}, doc, {
|
||||
modified: new Date(new Date(doc.modified).getTime() + 1000).toISOString(),
|
||||
})
|
||||
jest
|
||||
.spyOn(activatedRoute, 'paramMap', 'get')
|
||||
.mockReturnValue(of(convertToParamMap({ id: 3, section: 'details' })))
|
||||
jest.spyOn(documentService, 'get').mockReturnValueOnce(of(doc))
|
||||
jest.spyOn(documentService, 'get').mockReturnValueOnce(of(remoteDoc))
|
||||
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
|
||||
jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
|
||||
of({
|
||||
@@ -1406,11 +1434,185 @@ describe('DocumentDetailComponent', () => {
|
||||
})
|
||||
)
|
||||
fixture.detectChanges() // calls ngOnInit
|
||||
expect(modalSpy).toHaveBeenCalledWith(ConfirmDialogComponent)
|
||||
const closeSpy = jest.spyOn(openModal, 'close')
|
||||
expect(modalSpy).toHaveBeenCalledWith(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
const confirmDialog = openModal.componentInstance as ConfirmDialogComponent
|
||||
confirmDialog.confirmClicked.next(confirmDialog)
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
expect(confirmDialog.messageBold).toContain('Document was updated at')
|
||||
})
|
||||
|
||||
it('should react to websocket document updated notifications', () => {
|
||||
initNormally()
|
||||
const updateMessage = {
|
||||
document_id: component.documentId,
|
||||
modified: '2026-02-17T00:00:00Z',
|
||||
owner_id: 1,
|
||||
}
|
||||
const handleSpy = jest
|
||||
.spyOn(component as any, 'handleIncomingDocumentUpdated')
|
||||
.mockImplementation(() => {})
|
||||
const websocketStatusService = TestBed.inject(WebsocketStatusService)
|
||||
|
||||
websocketStatusService.handleDocumentUpdated(updateMessage)
|
||||
|
||||
expect(handleSpy).toHaveBeenCalledWith(updateMessage)
|
||||
})
|
||||
|
||||
it('should queue incoming update while network is active and flush after', () => {
|
||||
initNormally()
|
||||
const loadSpy = jest.spyOn(component as any, 'loadDocument')
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
|
||||
component.networkActive = true
|
||||
;(component as any).handleIncomingDocumentUpdated({
|
||||
document_id: component.documentId,
|
||||
modified: '2026-02-17T00:00:00Z',
|
||||
})
|
||||
|
||||
expect(loadSpy).not.toHaveBeenCalled()
|
||||
|
||||
component.networkActive = false
|
||||
;(component as any).flushPendingIncomingUpdate()
|
||||
|
||||
expect(loadSpy).toHaveBeenCalledWith(component.documentId, true)
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
'Document reloaded with latest changes.'
|
||||
)
|
||||
})
|
||||
|
||||
it('should ignore queued incoming update matching local save modified', () => {
|
||||
initNormally()
|
||||
const loadSpy = jest.spyOn(component as any, 'loadDocument')
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
|
||||
component.networkActive = true
|
||||
;(component as any).lastLocalSaveModified = '2026-02-17T00:00:00+00:00'
|
||||
;(component as any).handleIncomingDocumentUpdated({
|
||||
document_id: component.documentId,
|
||||
modified: '2026-02-17T00:00:00+00:00',
|
||||
})
|
||||
|
||||
component.networkActive = false
|
||||
;(component as any).flushPendingIncomingUpdate()
|
||||
|
||||
expect(loadSpy).not.toHaveBeenCalled()
|
||||
expect(toastSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should clear pdf source if preview URL is empty', () => {
|
||||
component.pdfSource = { url: '/preview', password: 'secret' } as any
|
||||
component.previewUrl = null
|
||||
;(component as any).updatePdfSource()
|
||||
|
||||
expect(component.pdfSource).toEqual({ url: null, password: undefined })
|
||||
})
|
||||
|
||||
it('should close incoming update modal if one is open', () => {
|
||||
const modalRef = { close: jest.fn() } as unknown as NgbModalRef
|
||||
;(component as any).incomingUpdateModal = modalRef
|
||||
;(component as any).closeIncomingUpdateModal()
|
||||
|
||||
expect(modalRef.close).toHaveBeenCalled()
|
||||
expect((component as any).incomingUpdateModal).toBeNull()
|
||||
})
|
||||
|
||||
it('should reload remote version when incoming update modal is confirmed', async () => {
|
||||
let openModal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((modals) => (openModal = modals[0]))
|
||||
const reloadSpy = jest
|
||||
.spyOn(component as any, 'reloadRemoteVersion')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
;(component as any).showIncomingUpdateModal('2026-02-17T00:00:00Z')
|
||||
|
||||
const dialog = openModal.componentInstance as ConfirmDialogComponent
|
||||
dialog.confirmClicked.next()
|
||||
await openModal.result
|
||||
|
||||
expect(dialog.buttonsEnabled).toBe(false)
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
expect((component as any).incomingUpdateModal).toBeNull()
|
||||
})
|
||||
|
||||
it('should overwrite open document state when loading remote version with force', () => {
|
||||
const openDoc = Object.assign({}, doc, {
|
||||
title: 'Locally edited title',
|
||||
__changedFields: ['title'],
|
||||
})
|
||||
const remoteDoc = Object.assign({}, doc, {
|
||||
title: 'Remote title',
|
||||
modified: '2026-02-17T00:00:00Z',
|
||||
})
|
||||
jest.spyOn(documentService, 'get').mockReturnValue(of(remoteDoc))
|
||||
jest.spyOn(documentService, 'getMetadata').mockReturnValue(
|
||||
of({
|
||||
has_archive_version: false,
|
||||
original_mime_type: 'application/pdf',
|
||||
})
|
||||
)
|
||||
jest.spyOn(documentService, 'getSuggestions').mockReturnValue(
|
||||
of({
|
||||
suggested_tags: [],
|
||||
suggested_document_types: [],
|
||||
suggested_correspondents: [],
|
||||
})
|
||||
)
|
||||
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
|
||||
const setDirtySpy = jest.spyOn(openDocumentsService, 'setDirty')
|
||||
const saveSpy = jest.spyOn(openDocumentsService, 'save')
|
||||
|
||||
;(component as any).loadDocument(doc.id, true)
|
||||
|
||||
expect(openDoc.title).toEqual('Remote title')
|
||||
expect(openDoc.__changedFields).toEqual([])
|
||||
expect(setDirtySpy).toHaveBeenCalledWith(openDoc, false)
|
||||
expect(saveSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should ignore incoming update for a different document id', () => {
|
||||
initNormally()
|
||||
const loadSpy = jest.spyOn(component as any, 'loadDocument')
|
||||
|
||||
;(component as any).handleIncomingDocumentUpdated({
|
||||
document_id: component.documentId + 1,
|
||||
modified: '2026-02-17T00:00:00Z',
|
||||
})
|
||||
|
||||
expect(loadSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show incoming update modal when local document has unsaved edits', () => {
|
||||
initNormally()
|
||||
jest.spyOn(openDocumentsService, 'isDirty').mockReturnValue(true)
|
||||
const modalSpy = jest
|
||||
.spyOn(component as any, 'showIncomingUpdateModal')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
;(component as any).handleIncomingDocumentUpdated({
|
||||
document_id: component.documentId,
|
||||
modified: '2026-02-17T00:00:00Z',
|
||||
})
|
||||
|
||||
expect(modalSpy).toHaveBeenCalledWith('2026-02-17T00:00:00Z')
|
||||
})
|
||||
|
||||
it('should reload current document and show toast when reloading remote version', () => {
|
||||
component.documentId = doc.id
|
||||
const closeModalSpy = jest
|
||||
.spyOn(component as any, 'closeIncomingUpdateModal')
|
||||
.mockImplementation(() => {})
|
||||
const loadSpy = jest
|
||||
.spyOn(component as any, 'loadDocument')
|
||||
.mockImplementation(() => {})
|
||||
const notifySpy = jest.spyOn(component.docChangeNotifier, 'next')
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
|
||||
;(component as any).reloadRemoteVersion()
|
||||
|
||||
expect(closeModalSpy).toHaveBeenCalled()
|
||||
expect(notifySpy).toHaveBeenCalledWith(doc.id)
|
||||
expect(loadSpy).toHaveBeenCalledWith(doc.id, true)
|
||||
expect(toastSpy).toHaveBeenCalledWith('Document reloaded.')
|
||||
})
|
||||
|
||||
it('should change preview element by render type', () => {
|
||||
@@ -1442,9 +1644,9 @@ describe('DocumentDetailComponent', () => {
|
||||
expect(
|
||||
fixture.debugElement.query(By.css('.preview-sticky img'))
|
||||
).not.toBeUndefined()
|
||||
;(component.document.mime_type =
|
||||
;((component.document.mime_type =
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'),
|
||||
fixture.detectChanges()
|
||||
fixture.detectChanges())
|
||||
expect(component.archiveContentRenderType).toEqual(
|
||||
component.ContentRenderType.Other
|
||||
)
|
||||
@@ -1459,23 +1661,23 @@ describe('DocumentDetailComponent', () => {
|
||||
const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument')
|
||||
const errorSpy = jest.spyOn(toastService, 'showError')
|
||||
initNormally()
|
||||
component.selectedVersionId = 10
|
||||
component.editPdf()
|
||||
expect(modal).not.toBeUndefined()
|
||||
modal.componentInstance.documentID = doc.id
|
||||
expect(modal.componentInstance.versionID).toBe(10)
|
||||
modal.componentInstance.pages = [{ page: 1, rotate: 0, splitAfter: false }]
|
||||
modal.componentInstance.confirm()
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
`${environment.apiBaseUrl}documents/edit_pdf/`
|
||||
)
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [doc.id],
|
||||
method: 'edit_pdf',
|
||||
parameters: {
|
||||
operations: [{ page: 1, rotate: 0, doc: 0 }],
|
||||
delete_original: false,
|
||||
update_document: false,
|
||||
include_metadata: true,
|
||||
},
|
||||
documents: [10],
|
||||
operations: [{ page: 1, rotate: 0, doc: 0 }],
|
||||
delete_original: false,
|
||||
update_document: false,
|
||||
include_metadata: true,
|
||||
source_mode: 'explicit_selection',
|
||||
})
|
||||
req.error(new ErrorEvent('failed'))
|
||||
expect(errorSpy).toHaveBeenCalled()
|
||||
@@ -1486,7 +1688,7 @@ describe('DocumentDetailComponent', () => {
|
||||
modal.componentInstance.deleteOriginal = true
|
||||
modal.componentInstance.confirm()
|
||||
req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
`${environment.apiBaseUrl}documents/edit_pdf/`
|
||||
)
|
||||
req.flush(true)
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
@@ -1496,6 +1698,7 @@ describe('DocumentDetailComponent', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||
initNormally()
|
||||
component.selectedVersionId = 10
|
||||
component.password = 'secret'
|
||||
component.removePassword()
|
||||
const dialog =
|
||||
@@ -1505,17 +1708,15 @@ describe('DocumentDetailComponent', () => {
|
||||
dialog.deleteOriginal = true
|
||||
dialog.confirm()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
`${environment.apiBaseUrl}documents/remove_password/`
|
||||
)
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [doc.id],
|
||||
method: 'remove_password',
|
||||
parameters: {
|
||||
password: 'secret',
|
||||
update_document: false,
|
||||
include_metadata: false,
|
||||
delete_original: true,
|
||||
},
|
||||
documents: [10],
|
||||
password: 'secret',
|
||||
update_document: false,
|
||||
include_metadata: false,
|
||||
delete_original: true,
|
||||
source_mode: 'explicit_selection',
|
||||
})
|
||||
req.flush(true)
|
||||
})
|
||||
@@ -1530,7 +1731,7 @@ describe('DocumentDetailComponent', () => {
|
||||
|
||||
expect(errorSpy).toHaveBeenCalled()
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
`${environment.apiBaseUrl}documents/remove_password/`
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1546,7 +1747,7 @@ describe('DocumentDetailComponent', () => {
|
||||
modal.componentInstance as PasswordRemovalConfirmDialogComponent
|
||||
dialog.confirm()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
`${environment.apiBaseUrl}documents/remove_password/`
|
||||
)
|
||||
req.error(new ErrorEvent('failed'))
|
||||
|
||||
@@ -1567,7 +1768,7 @@ describe('DocumentDetailComponent', () => {
|
||||
modal.componentInstance as PasswordRemovalConfirmDialogComponent
|
||||
dialog.confirm()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
`${environment.apiBaseUrl}documents/remove_password/`
|
||||
)
|
||||
req.flush(true)
|
||||
|
||||
@@ -1721,6 +1922,14 @@ describe('DocumentDetailComponent', () => {
|
||||
expect(component.createDisabled(DataType.Tag)).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should expose add permission via userCanAdd getter', () => {
|
||||
currentUserCan = true
|
||||
expect(component.userCanAdd).toBeTruthy()
|
||||
|
||||
currentUserCan = false
|
||||
expect(component.userCanAdd).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should call tryRenderTiff when no archive and file is tiff', () => {
|
||||
initNormally()
|
||||
const tiffRenderSpy = jest.spyOn(
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
NgbDateStruct,
|
||||
NgbDropdownModule,
|
||||
NgbModal,
|
||||
NgbModalRef,
|
||||
NgbNav,
|
||||
NgbNavChangeEvent,
|
||||
NgbNavModule,
|
||||
@@ -73,13 +74,17 @@ import {
|
||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import {
|
||||
BulkEditSourceMode,
|
||||
DocumentService,
|
||||
} from 'src/app/services/rest/document.service'
|
||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
|
||||
import { getFilenameFromContentDisposition } from 'src/app/utils/http'
|
||||
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
|
||||
import * as UTIF from 'utif'
|
||||
@@ -143,6 +148,11 @@ enum ContentRenderType {
|
||||
TIFF = 'tiff',
|
||||
}
|
||||
|
||||
interface IncomingDocumentUpdate {
|
||||
document_id: number
|
||||
modified: string
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-document-detail',
|
||||
templateUrl: './document-detail.component.html',
|
||||
@@ -208,6 +218,7 @@ export class DocumentDetailComponent
|
||||
private componentRouterService = inject(ComponentRouterService)
|
||||
private deviceDetectorService = inject(DeviceDetectorService)
|
||||
private savedViewService = inject(SavedViewService)
|
||||
private readonly websocketStatusService = inject(WebsocketStatusService)
|
||||
|
||||
@ViewChild('inputTitle')
|
||||
titleInput: TextComponent
|
||||
@@ -267,6 +278,9 @@ export class DocumentDetailComponent
|
||||
isDirty$: Observable<boolean>
|
||||
unsubscribeNotifier: Subject<any> = new Subject()
|
||||
docChangeNotifier: Subject<any> = new Subject()
|
||||
private incomingUpdateModal: NgbModalRef
|
||||
private pendingIncomingUpdate: IncomingDocumentUpdate
|
||||
private lastLocalSaveModified: string | null = null
|
||||
|
||||
requiresPassword: boolean = false
|
||||
password: string
|
||||
@@ -475,9 +489,12 @@ export class DocumentDetailComponent
|
||||
)
|
||||
}
|
||||
|
||||
private loadDocument(documentId: number): void {
|
||||
private loadDocument(documentId: number, forceRemote: boolean = false): void {
|
||||
let redirectedToRoot = false
|
||||
this.closeIncomingUpdateModal()
|
||||
this.pendingIncomingUpdate = null
|
||||
this.selectedVersionId = documentId
|
||||
this.lastLocalSaveModified = null
|
||||
this.previewUrl = this.documentsService.getPreviewUrl(
|
||||
this.selectedVersionId
|
||||
)
|
||||
@@ -545,21 +562,25 @@ export class DocumentDetailComponent
|
||||
openDocument.duplicate_documents = doc.duplicate_documents
|
||||
this.openDocumentService.save()
|
||||
}
|
||||
const useDoc = openDocument || doc
|
||||
if (openDocument) {
|
||||
if (
|
||||
new Date(doc.modified) > new Date(openDocument.modified) &&
|
||||
!this.modalService.hasOpenModals()
|
||||
) {
|
||||
const modal = this.modalService.open(ConfirmDialogComponent)
|
||||
modal.componentInstance.title = $localize`Document changes detected`
|
||||
modal.componentInstance.messageBold = $localize`The version of this document in your browser session appears older than the existing version.`
|
||||
modal.componentInstance.message = $localize`Saving the document here may overwrite other changes that were made. To restore the existing version, discard your changes or close the document.`
|
||||
modal.componentInstance.cancelBtnClass = 'visually-hidden'
|
||||
modal.componentInstance.btnCaption = $localize`Ok`
|
||||
modal.componentInstance.confirmClicked.subscribe(() =>
|
||||
modal.close()
|
||||
)
|
||||
let useDoc = openDocument || doc
|
||||
if (openDocument && forceRemote) {
|
||||
Object.assign(openDocument, doc)
|
||||
openDocument.__changedFields = []
|
||||
this.openDocumentService.setDirty(openDocument, false)
|
||||
this.openDocumentService.save()
|
||||
useDoc = openDocument
|
||||
} else if (openDocument) {
|
||||
if (new Date(doc.modified) > new Date(openDocument.modified)) {
|
||||
if (this.hasLocalEdits(openDocument)) {
|
||||
this.showIncomingUpdateModal(doc.modified)
|
||||
} else {
|
||||
// No local edits to preserve, so keep the tab in sync automatically.
|
||||
Object.assign(openDocument, doc)
|
||||
openDocument.__changedFields = []
|
||||
this.openDocumentService.setDirty(openDocument, false)
|
||||
this.openDocumentService.save()
|
||||
useDoc = openDocument
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.openDocumentService
|
||||
@@ -590,6 +611,98 @@ export class DocumentDetailComponent
|
||||
})
|
||||
}
|
||||
|
||||
private hasLocalEdits(doc: Document): boolean {
|
||||
return (
|
||||
this.openDocumentService.isDirty(doc) || !!doc.__changedFields?.length
|
||||
)
|
||||
}
|
||||
|
||||
private showIncomingUpdateModal(modified: string): void {
|
||||
if (this.incomingUpdateModal) return
|
||||
|
||||
const modal = this.modalService.open(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
this.incomingUpdateModal = modal
|
||||
|
||||
let formattedModified = null
|
||||
const parsed = new Date(modified)
|
||||
formattedModified = parsed.toLocaleString()
|
||||
|
||||
modal.componentInstance.title = $localize`Document was updated`
|
||||
modal.componentInstance.messageBold = $localize`Document was updated at ${formattedModified}.`
|
||||
modal.componentInstance.message = $localize`Reload to discard your local unsaved edits and load the latest remote version.`
|
||||
modal.componentInstance.btnClass = 'btn-warning'
|
||||
modal.componentInstance.btnCaption = $localize`Reload`
|
||||
modal.componentInstance.cancelBtnCaption = $localize`Dismiss`
|
||||
|
||||
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
modal.close()
|
||||
this.reloadRemoteVersion()
|
||||
})
|
||||
modal.result.finally(() => {
|
||||
this.incomingUpdateModal = null
|
||||
})
|
||||
}
|
||||
|
||||
private closeIncomingUpdateModal() {
|
||||
if (!this.incomingUpdateModal) return
|
||||
this.incomingUpdateModal.close()
|
||||
this.incomingUpdateModal = null
|
||||
}
|
||||
|
||||
private flushPendingIncomingUpdate() {
|
||||
if (!this.pendingIncomingUpdate || this.networkActive) return
|
||||
const pendingUpdate = this.pendingIncomingUpdate
|
||||
this.pendingIncomingUpdate = null
|
||||
this.handleIncomingDocumentUpdated(pendingUpdate)
|
||||
}
|
||||
|
||||
private handleIncomingDocumentUpdated(data: IncomingDocumentUpdate): void {
|
||||
if (
|
||||
!this.documentId ||
|
||||
!this.document ||
|
||||
data.document_id !== this.documentId
|
||||
)
|
||||
return
|
||||
if (this.networkActive) {
|
||||
this.pendingIncomingUpdate = data
|
||||
return
|
||||
}
|
||||
// If modified timestamp of the incoming update is the same as the last local save,
|
||||
// we assume this update is from our own save and dont notify
|
||||
const incomingModified = data.modified
|
||||
if (
|
||||
incomingModified &&
|
||||
this.lastLocalSaveModified &&
|
||||
incomingModified === this.lastLocalSaveModified
|
||||
) {
|
||||
this.lastLocalSaveModified = null
|
||||
return
|
||||
}
|
||||
this.lastLocalSaveModified = null
|
||||
|
||||
if (this.openDocumentService.isDirty(this.document)) {
|
||||
this.showIncomingUpdateModal(data.modified)
|
||||
} else {
|
||||
this.docChangeNotifier.next(this.documentId)
|
||||
this.loadDocument(this.documentId, true)
|
||||
this.toastService.showInfo(
|
||||
$localize`Document reloaded with latest changes.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private reloadRemoteVersion() {
|
||||
if (!this.documentId) return
|
||||
|
||||
this.closeIncomingUpdateModal()
|
||||
this.docChangeNotifier.next(this.documentId)
|
||||
this.loadDocument(this.documentId, true)
|
||||
this.toastService.showInfo($localize`Document reloaded.`)
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.setZoom(
|
||||
this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING) as PdfZoomScale
|
||||
@@ -648,6 +761,11 @@ export class DocumentDetailComponent
|
||||
|
||||
this.getCustomFields()
|
||||
|
||||
this.websocketStatusService
|
||||
.onDocumentUpdated()
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((data) => this.handleIncomingDocumentUpdated(data))
|
||||
|
||||
this.route.paramMap
|
||||
.pipe(
|
||||
filter(
|
||||
@@ -1033,6 +1151,7 @@ export class DocumentDetailComponent
|
||||
)
|
||||
.subscribe({
|
||||
next: (doc) => {
|
||||
this.closeIncomingUpdateModal()
|
||||
Object.assign(this.document, doc)
|
||||
doc['permissions_form'] = {
|
||||
owner: doc.owner,
|
||||
@@ -1079,6 +1198,8 @@ export class DocumentDetailComponent
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: (docValues) => {
|
||||
this.closeIncomingUpdateModal()
|
||||
this.lastLocalSaveModified = docValues.modified ?? null
|
||||
// in case data changed while saving eg removing inbox_tags
|
||||
this.documentForm.patchValue(docValues)
|
||||
const newValues = Object.assign({}, this.documentForm.value)
|
||||
@@ -1093,16 +1214,19 @@ export class DocumentDetailComponent
|
||||
this.networkActive = false
|
||||
this.error = null
|
||||
if (close) {
|
||||
this.pendingIncomingUpdate = null
|
||||
this.close(() =>
|
||||
this.openDocumentService.refreshDocument(this.documentId)
|
||||
)
|
||||
} else {
|
||||
this.openDocumentService.refreshDocument(this.documentId)
|
||||
this.flushPendingIncomingUpdate()
|
||||
}
|
||||
this.savedViewService.maybeRefreshDocumentCounts()
|
||||
},
|
||||
error: (error) => {
|
||||
this.networkActive = false
|
||||
this.lastLocalSaveModified = null
|
||||
const canEdit =
|
||||
this.permissionsService.currentUserHasObjectPermissions(
|
||||
PermissionAction.Change,
|
||||
@@ -1122,6 +1246,7 @@ export class DocumentDetailComponent
|
||||
error
|
||||
)
|
||||
}
|
||||
this.flushPendingIncomingUpdate()
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1158,8 +1283,11 @@ export class DocumentDetailComponent
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: ({ updateResult, nextDocId, closeResult }) => {
|
||||
this.closeIncomingUpdateModal()
|
||||
this.error = null
|
||||
this.networkActive = false
|
||||
this.pendingIncomingUpdate = null
|
||||
this.lastLocalSaveModified = null
|
||||
if (closeResult && updateResult && nextDocId) {
|
||||
this.router.navigate(['documents', nextDocId])
|
||||
this.titleInput?.focus()
|
||||
@@ -1167,8 +1295,10 @@ export class DocumentDetailComponent
|
||||
},
|
||||
error: (error) => {
|
||||
this.networkActive = false
|
||||
this.lastLocalSaveModified = null
|
||||
this.error = error.error
|
||||
this.toastService.showError($localize`Error saving document`, error)
|
||||
this.flushPendingIncomingUpdate()
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1249,27 +1379,25 @@ export class DocumentDetailComponent
|
||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
this.documentsService
|
||||
.bulkEdit([this.document.id], 'reprocess', {})
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastService.showInfo(
|
||||
$localize`Reprocess operation for "${this.document.title}" will begin in the background. Close and re-open or reload this document after the operation has completed to see new content.`
|
||||
)
|
||||
if (modal) {
|
||||
modal.close()
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
if (modal) {
|
||||
modal.componentInstance.buttonsEnabled = true
|
||||
}
|
||||
this.toastService.showError(
|
||||
$localize`Error executing operation`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
this.documentsService.reprocessDocuments([this.document.id]).subscribe({
|
||||
next: () => {
|
||||
this.toastService.showInfo(
|
||||
$localize`Reprocess operation for "${this.document.title}" will begin in the background.`
|
||||
)
|
||||
if (modal) {
|
||||
modal.close()
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
if (modal) {
|
||||
modal.componentInstance.buttonsEnabled = true
|
||||
}
|
||||
this.toastService.showError(
|
||||
$localize`Error executing operation`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1626,20 +1754,23 @@ export class DocumentDetailComponent
|
||||
size: 'xl',
|
||||
scrollable: true,
|
||||
})
|
||||
const sourceDocumentId = this.selectedVersionId ?? this.document.id
|
||||
modal.componentInstance.title = $localize`PDF Editor`
|
||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||
modal.componentInstance.documentID = this.document.id
|
||||
modal.componentInstance.versionID = sourceDocumentId
|
||||
modal.componentInstance.confirmClicked
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
this.documentsService
|
||||
.bulkEdit([this.document.id], 'edit_pdf', {
|
||||
.editPdfDocuments([sourceDocumentId], {
|
||||
operations: modal.componentInstance.getOperations(),
|
||||
delete_original: modal.componentInstance.deleteOriginal,
|
||||
update_document:
|
||||
modal.componentInstance.editMode == PdfEditorEditMode.Update,
|
||||
include_metadata: modal.componentInstance.includeMetadata,
|
||||
source_mode: BulkEditSourceMode.EXPLICIT_SELECTION,
|
||||
})
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
@@ -1685,16 +1816,18 @@ export class DocumentDetailComponent
|
||||
modal.componentInstance.confirmClicked
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
const sourceDocumentId = this.selectedVersionId ?? this.document.id
|
||||
const dialog =
|
||||
modal.componentInstance as PasswordRemovalConfirmDialogComponent
|
||||
dialog.buttonsEnabled = false
|
||||
this.networkActive = true
|
||||
this.documentsService
|
||||
.bulkEdit([this.document.id], 'remove_password', {
|
||||
.removePasswordDocuments([sourceDocumentId], {
|
||||
password: this.password,
|
||||
update_document: dialog.updateDocument,
|
||||
include_metadata: dialog.includeMetadata,
|
||||
delete_original: dialog.deleteOriginal,
|
||||
source_mode: BulkEditSourceMode.EXPLICIT_SELECTION,
|
||||
})
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DatePipe } from '@angular/common'
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import {
|
||||
HttpTestingController,
|
||||
@@ -138,6 +139,7 @@ describe('BulkEditorComponent', () => {
|
||||
},
|
||||
},
|
||||
FilterPipe,
|
||||
DatePipe,
|
||||
SettingsService,
|
||||
{
|
||||
provide: UserService,
|
||||
@@ -849,13 +851,11 @@ describe('BulkEditorComponent', () => {
|
||||
expect(modal).not.toBeUndefined()
|
||||
modal.componentInstance.confirm()
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
`${environment.apiBaseUrl}documents/delete/`
|
||||
)
|
||||
req.flush(true)
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [3, 4],
|
||||
method: 'delete',
|
||||
parameters: {},
|
||||
})
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||
@@ -868,7 +868,7 @@ describe('BulkEditorComponent', () => {
|
||||
fixture.detectChanges()
|
||||
component.applyDelete()
|
||||
req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
`${environment.apiBaseUrl}documents/delete/`
|
||||
)
|
||||
})
|
||||
|
||||
@@ -944,13 +944,11 @@ describe('BulkEditorComponent', () => {
|
||||
expect(modal).not.toBeUndefined()
|
||||
modal.componentInstance.confirm()
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
`${environment.apiBaseUrl}documents/reprocess/`
|
||||
)
|
||||
req.flush(true)
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [3, 4],
|
||||
method: 'reprocess',
|
||||
parameters: {},
|
||||
})
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||
@@ -979,13 +977,13 @@ describe('BulkEditorComponent', () => {
|
||||
modal.componentInstance.rotate()
|
||||
modal.componentInstance.confirm()
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
`${environment.apiBaseUrl}documents/rotate/`
|
||||
)
|
||||
req.flush(true)
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [3, 4],
|
||||
method: 'rotate',
|
||||
parameters: { degrees: 90 },
|
||||
degrees: 90,
|
||||
source_mode: 'latest_version',
|
||||
})
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||
@@ -1021,13 +1019,12 @@ describe('BulkEditorComponent', () => {
|
||||
modal.componentInstance.metadataDocumentID = 3
|
||||
modal.componentInstance.confirm()
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
`${environment.apiBaseUrl}documents/merge/`
|
||||
)
|
||||
req.flush(true)
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [3, 4],
|
||||
method: 'merge',
|
||||
parameters: { metadata_document_id: 3 },
|
||||
metadata_document_id: 3,
|
||||
})
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||
@@ -1040,13 +1037,13 @@ describe('BulkEditorComponent', () => {
|
||||
modal.componentInstance.deleteOriginals = true
|
||||
modal.componentInstance.confirm()
|
||||
req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
`${environment.apiBaseUrl}documents/merge/`
|
||||
)
|
||||
req.flush(true)
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [3, 4],
|
||||
method: 'merge',
|
||||
parameters: { metadata_document_id: 3, delete_originals: true },
|
||||
metadata_document_id: 3,
|
||||
delete_originals: true,
|
||||
})
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||
@@ -1061,13 +1058,13 @@ describe('BulkEditorComponent', () => {
|
||||
modal.componentInstance.archiveFallback = true
|
||||
modal.componentInstance.confirm()
|
||||
req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
`${environment.apiBaseUrl}documents/merge/`
|
||||
)
|
||||
req.flush(true)
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [3, 4],
|
||||
method: 'merge',
|
||||
parameters: { metadata_document_id: 3, archive_fallback: true },
|
||||
metadata_document_id: 3,
|
||||
archive_fallback: true,
|
||||
})
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { saveAs } from 'file-saver'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { first, map, Subject, switchMap, takeUntil } from 'rxjs'
|
||||
import { first, map, Observable, Subject, switchMap, takeUntil } from 'rxjs'
|
||||
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
|
||||
import { CustomField } from 'src/app/data/custom-field'
|
||||
import { MatchingModel } from 'src/app/data/matching-model'
|
||||
@@ -29,7 +29,9 @@ import { CorrespondentService } from 'src/app/services/rest/correspondent.servic
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||
import {
|
||||
DocumentBulkEditMethod,
|
||||
DocumentService,
|
||||
MergeDocumentsRequest,
|
||||
SelectionDataItem,
|
||||
} from 'src/app/services/rest/document.service'
|
||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||
@@ -255,9 +257,9 @@ export class BulkEditorComponent
|
||||
this.unsubscribeNotifier.complete()
|
||||
}
|
||||
|
||||
private executeBulkOperation(
|
||||
private executeBulkEditMethod(
|
||||
modal: NgbModalRef,
|
||||
method: string,
|
||||
method: DocumentBulkEditMethod,
|
||||
args: any,
|
||||
overrideDocumentIDs?: number[]
|
||||
) {
|
||||
@@ -272,32 +274,55 @@ export class BulkEditorComponent
|
||||
)
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: () => {
|
||||
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
|
||||
)
|
||||
},
|
||||
next: () => this.handleOperationSuccess(modal),
|
||||
error: (error) => this.handleOperationError(modal, 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(
|
||||
items: SelectionDataItem[],
|
||||
selectionModel: FilterableDropdownSelectionModel
|
||||
@@ -446,13 +471,13 @@ export class BulkEditorComponent
|
||||
modal.componentInstance.confirmClicked
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
this.executeBulkOperation(modal, 'modify_tags', {
|
||||
this.executeBulkEditMethod(modal, 'modify_tags', {
|
||||
add_tags: changedTags.itemsToAdd.map((t) => t.id),
|
||||
remove_tags: changedTags.itemsToRemove.map((t) => t.id),
|
||||
})
|
||||
})
|
||||
} else {
|
||||
this.executeBulkOperation(null, 'modify_tags', {
|
||||
this.executeBulkEditMethod(null, 'modify_tags', {
|
||||
add_tags: changedTags.itemsToAdd.map((t) => t.id),
|
||||
remove_tags: changedTags.itemsToRemove.map((t) => t.id),
|
||||
})
|
||||
@@ -486,12 +511,12 @@ export class BulkEditorComponent
|
||||
modal.componentInstance.confirmClicked
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
this.executeBulkOperation(modal, 'set_correspondent', {
|
||||
this.executeBulkEditMethod(modal, 'set_correspondent', {
|
||||
correspondent: correspondent ? correspondent.id : null,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
this.executeBulkOperation(null, 'set_correspondent', {
|
||||
this.executeBulkEditMethod(null, 'set_correspondent', {
|
||||
correspondent: correspondent ? correspondent.id : null,
|
||||
})
|
||||
}
|
||||
@@ -524,12 +549,12 @@ export class BulkEditorComponent
|
||||
modal.componentInstance.confirmClicked
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
this.executeBulkOperation(modal, 'set_document_type', {
|
||||
this.executeBulkEditMethod(modal, 'set_document_type', {
|
||||
document_type: documentType ? documentType.id : null,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
this.executeBulkOperation(null, 'set_document_type', {
|
||||
this.executeBulkEditMethod(null, 'set_document_type', {
|
||||
document_type: documentType ? documentType.id : null,
|
||||
})
|
||||
}
|
||||
@@ -562,12 +587,12 @@ export class BulkEditorComponent
|
||||
modal.componentInstance.confirmClicked
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
this.executeBulkOperation(modal, 'set_storage_path', {
|
||||
this.executeBulkEditMethod(modal, 'set_storage_path', {
|
||||
storage_path: storagePath ? storagePath.id : null,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
this.executeBulkOperation(null, 'set_storage_path', {
|
||||
this.executeBulkEditMethod(null, 'set_storage_path', {
|
||||
storage_path: storagePath ? storagePath.id : null,
|
||||
})
|
||||
}
|
||||
@@ -624,7 +649,7 @@ export class BulkEditorComponent
|
||||
modal.componentInstance.confirmClicked
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
this.executeBulkOperation(modal, 'modify_custom_fields', {
|
||||
this.executeBulkEditMethod(modal, 'modify_custom_fields', {
|
||||
add_custom_fields: changedCustomFields.itemsToAdd.map((f) => f.id),
|
||||
remove_custom_fields: changedCustomFields.itemsToRemove.map(
|
||||
(f) => f.id
|
||||
@@ -632,7 +657,7 @@ export class BulkEditorComponent
|
||||
})
|
||||
})
|
||||
} else {
|
||||
this.executeBulkOperation(null, 'modify_custom_fields', {
|
||||
this.executeBulkEditMethod(null, 'modify_custom_fields', {
|
||||
add_custom_fields: changedCustomFields.itemsToAdd.map((f) => f.id),
|
||||
remove_custom_fields: changedCustomFields.itemsToRemove.map(
|
||||
(f) => f.id
|
||||
@@ -762,10 +787,16 @@ export class BulkEditorComponent
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
this.executeBulkOperation(modal, 'delete', {})
|
||||
this.executeDocumentAction(
|
||||
modal,
|
||||
this.documentService.deleteDocuments(Array.from(this.list.selected))
|
||||
)
|
||||
})
|
||||
} else {
|
||||
this.executeBulkOperation(null, 'delete', {})
|
||||
this.executeDocumentAction(
|
||||
null,
|
||||
this.documentService.deleteDocuments(Array.from(this.list.selected))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -804,7 +835,12 @@ export class BulkEditorComponent
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
this.executeBulkOperation(modal, 'reprocess', {})
|
||||
this.executeDocumentAction(
|
||||
modal,
|
||||
this.documentService.reprocessDocuments(
|
||||
Array.from(this.list.selected)
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -815,7 +851,7 @@ export class BulkEditorComponent
|
||||
modal.componentInstance.confirmClicked.subscribe(
|
||||
({ permissions, merge }) => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
this.executeBulkOperation(modal, 'set_permissions', {
|
||||
this.executeBulkEditMethod(modal, 'set_permissions', {
|
||||
...permissions,
|
||||
merge,
|
||||
})
|
||||
@@ -830,7 +866,7 @@ export class BulkEditorComponent
|
||||
})
|
||||
const rotateDialog = modal.componentInstance as RotateConfirmDialogComponent
|
||||
rotateDialog.title = $localize`Rotate confirm`
|
||||
rotateDialog.messageBold = $localize`This operation will permanently rotate the original version of ${this.list.selected.size} document(s).`
|
||||
rotateDialog.messageBold = $localize`This operation will add rotated versions of the ${this.list.selected.size} document(s).`
|
||||
rotateDialog.btnClass = 'btn-danger'
|
||||
rotateDialog.btnCaption = $localize`Proceed`
|
||||
rotateDialog.documentID = Array.from(this.list.selected)[0]
|
||||
@@ -838,9 +874,13 @@ export class BulkEditorComponent
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
rotateDialog.buttonsEnabled = false
|
||||
this.executeBulkOperation(modal, 'rotate', {
|
||||
degrees: rotateDialog.degrees,
|
||||
})
|
||||
this.executeDocumentAction(
|
||||
modal,
|
||||
this.documentService.rotateDocuments(
|
||||
Array.from(this.list.selected),
|
||||
rotateDialog.degrees
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -856,18 +896,22 @@ export class BulkEditorComponent
|
||||
mergeDialog.confirmClicked
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
const args = {}
|
||||
const args: MergeDocumentsRequest = {}
|
||||
if (mergeDialog.metadataDocumentID > -1) {
|
||||
args['metadata_document_id'] = mergeDialog.metadataDocumentID
|
||||
args.metadata_document_id = mergeDialog.metadataDocumentID
|
||||
}
|
||||
if (mergeDialog.deleteOriginals) {
|
||||
args['delete_originals'] = true
|
||||
args.delete_originals = true
|
||||
}
|
||||
if (mergeDialog.archiveFallback) {
|
||||
args['archive_fallback'] = true
|
||||
args.archive_fallback = true
|
||||
}
|
||||
mergeDialog.buttonsEnabled = false
|
||||
this.executeBulkOperation(modal, 'merge', args, mergeDialog.documentIDs)
|
||||
this.executeDocumentAction(
|
||||
modal,
|
||||
this.documentService.mergeDocuments(mergeDialog.documentIDs, args),
|
||||
{ deleteOriginals: !!args.delete_originals }
|
||||
)
|
||||
this.toastService.showInfo(
|
||||
$localize`Merged document will be queued for consumption.`
|
||||
)
|
||||
|
||||
@@ -104,11 +104,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
<div *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.SavedView }">
|
||||
@if (list.activeSavedViewId) {
|
||||
<button ngbDropdownItem (click)="saveViewConfig()" [disabled]="!savedViewIsModified" i18n>Save "{{list.activeSavedViewTitle}}"</button>
|
||||
}
|
||||
</div>
|
||||
@if (list.activeSavedViewId && activeSavedViewCanChange) {
|
||||
<button ngbDropdownItem (click)="saveViewConfig()" [disabled]="!savedViewIsModified" i18n>Save "{{list.activeSavedViewTitle}}"</button>
|
||||
}
|
||||
<button ngbDropdownItem (click)="saveViewConfigAs()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.SavedView }" i18n>Save as...</button>
|
||||
<a ngbDropdownItem routerLink="/savedviews" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }" i18n>All saved views</a>
|
||||
</div>
|
||||
|
||||
@@ -168,6 +168,10 @@ describe('DocumentListComponent', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should not allow changing a saved view when none is active', () => {
|
||||
expect(component.activeSavedViewCanChange).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should determine if filtered, support reset', () => {
|
||||
fixture.detectChanges()
|
||||
documentListService.setFilterRules([
|
||||
@@ -299,6 +303,19 @@ describe('DocumentListComponent', () => {
|
||||
expect(setCountSpy).toHaveBeenCalledWith(expect.any(Object), 3)
|
||||
})
|
||||
|
||||
it('should reset active saved view when loading unknown view config', () => {
|
||||
component['activeSavedView'] = { id: 1 } as SavedView
|
||||
const activateSpy = jest.spyOn(documentListService, 'activateSavedView')
|
||||
const reloadSpy = jest.spyOn(documentListService, 'reload')
|
||||
jest.spyOn(savedViewService, 'getCached').mockReturnValue(of(null))
|
||||
|
||||
component.loadViewConfig(10)
|
||||
|
||||
expect(component['activeSavedView']).toBeNull()
|
||||
expect(activateSpy).not.toHaveBeenCalled()
|
||||
expect(reloadSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support 3 different display modes', () => {
|
||||
jest.spyOn(documentListService, 'documents', 'get').mockReturnValue(docs)
|
||||
fixture.detectChanges()
|
||||
@@ -466,7 +483,7 @@ describe('DocumentListComponent', () => {
|
||||
})
|
||||
|
||||
it('should handle error on view saving', () => {
|
||||
component.list.activateSavedView({
|
||||
const view: SavedView = {
|
||||
id: 10,
|
||||
name: 'Saved View 10',
|
||||
sort_field: 'added',
|
||||
@@ -477,7 +494,16 @@ describe('DocumentListComponent', () => {
|
||||
value: '20',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
jest.spyOn(savedViewService, 'getCached').mockReturnValue(of(view))
|
||||
const queryParams = { view: view.id.toString() }
|
||||
jest
|
||||
.spyOn(activatedRoute, 'queryParamMap', 'get')
|
||||
.mockReturnValue(of(convertToParamMap(queryParams)))
|
||||
activatedRoute.snapshot.queryParams = queryParams
|
||||
router.routerState.snapshot.url = '/view/10/'
|
||||
fixture.detectChanges()
|
||||
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
jest
|
||||
.spyOn(savedViewService, 'patch')
|
||||
@@ -489,6 +515,40 @@ describe('DocumentListComponent', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should not save a view without object change permissions', () => {
|
||||
const view: SavedView = {
|
||||
id: 10,
|
||||
name: 'Saved View 10',
|
||||
sort_field: 'added',
|
||||
sort_reverse: true,
|
||||
filter_rules: [
|
||||
{
|
||||
rule_type: FILTER_HAS_TAGS_ANY,
|
||||
value: '20',
|
||||
},
|
||||
],
|
||||
owner: 999,
|
||||
user_can_change: false,
|
||||
}
|
||||
jest.spyOn(savedViewService, 'getCached').mockReturnValue(of(view))
|
||||
jest
|
||||
.spyOn(permissionService, 'currentUserHasObjectPermissions')
|
||||
.mockReturnValue(false)
|
||||
const queryParams = { view: view.id.toString() }
|
||||
jest
|
||||
.spyOn(activatedRoute, 'queryParamMap', 'get')
|
||||
.mockReturnValue(of(convertToParamMap(queryParams)))
|
||||
activatedRoute.snapshot.queryParams = queryParams
|
||||
router.routerState.snapshot.url = '/view/10/'
|
||||
fixture.detectChanges()
|
||||
|
||||
const patchSpy = jest.spyOn(savedViewService, 'patch')
|
||||
|
||||
component.saveViewConfig()
|
||||
|
||||
expect(patchSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support edited view saving as', () => {
|
||||
const view: SavedView = {
|
||||
id: 10,
|
||||
@@ -520,19 +580,105 @@ describe('DocumentListComponent', () => {
|
||||
const modalSpy = jest.spyOn(modalService, 'open')
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const savedViewServiceCreate = jest.spyOn(savedViewService, 'create')
|
||||
jest
|
||||
.spyOn(savedViewService, 'dashboardViews', 'get')
|
||||
.mockReturnValue([{ id: 77 } as SavedView])
|
||||
jest
|
||||
.spyOn(savedViewService, 'sidebarViews', 'get')
|
||||
.mockReturnValue([{ id: 88 } as SavedView])
|
||||
const updateVisibilitySpy = jest
|
||||
.spyOn(settingsService, 'updateSavedViewsVisibility')
|
||||
.mockReturnValue(of({ success: true }))
|
||||
savedViewServiceCreate.mockReturnValueOnce(of(modifiedView))
|
||||
component.saveViewConfigAs()
|
||||
|
||||
const modalCloseSpy = jest.spyOn(openModal, 'close')
|
||||
const permissions = {
|
||||
owner: 5,
|
||||
set_permissions: {
|
||||
view: {
|
||||
users: [4],
|
||||
groups: [3],
|
||||
},
|
||||
change: {
|
||||
users: [2],
|
||||
groups: [1],
|
||||
},
|
||||
},
|
||||
}
|
||||
openModal.componentInstance.saveClicked.next({
|
||||
name: 'Foo Bar',
|
||||
showOnDashboard: true,
|
||||
showInSideBar: true,
|
||||
permissions_form: permissions,
|
||||
})
|
||||
expect(savedViewServiceCreate).toHaveBeenCalled()
|
||||
expect(savedViewServiceCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Foo Bar',
|
||||
owner: permissions.owner,
|
||||
set_permissions: permissions.set_permissions,
|
||||
})
|
||||
)
|
||||
expect(updateVisibilitySpy).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([77, modifiedView.id]),
|
||||
expect.arrayContaining([88, modifiedView.id])
|
||||
)
|
||||
expect(modalSpy).toHaveBeenCalled()
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
expect(modalCloseSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show error when visibility update fails after creating a view', () => {
|
||||
const view: SavedView = {
|
||||
id: 10,
|
||||
name: 'Saved View 10',
|
||||
sort_field: 'added',
|
||||
sort_reverse: true,
|
||||
filter_rules: [
|
||||
{
|
||||
rule_type: FILTER_HAS_TAGS_ANY,
|
||||
value: '20',
|
||||
},
|
||||
],
|
||||
}
|
||||
jest.spyOn(savedViewService, 'getCached').mockReturnValue(of(view))
|
||||
const queryParams = { view: view.id.toString() }
|
||||
jest
|
||||
.spyOn(activatedRoute, 'queryParamMap', 'get')
|
||||
.mockReturnValue(of(convertToParamMap(queryParams)))
|
||||
activatedRoute.snapshot.queryParams = queryParams
|
||||
router.routerState.snapshot.url = '/view/10/'
|
||||
fixture.detectChanges()
|
||||
|
||||
let openModal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
||||
jest
|
||||
.spyOn(savedViewService, 'create')
|
||||
.mockReturnValueOnce(of({ ...view, id: 42, name: 'Foo Bar' }))
|
||||
jest.spyOn(savedViewService, 'dashboardViews', 'get').mockReturnValue([])
|
||||
jest.spyOn(savedViewService, 'sidebarViews', 'get').mockReturnValue([])
|
||||
jest
|
||||
.spyOn(settingsService, 'updateSavedViewsVisibility')
|
||||
.mockReturnValueOnce(
|
||||
throwError(() => new Error('unable to save visibility settings'))
|
||||
)
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
|
||||
component.saveViewConfigAs()
|
||||
|
||||
const modalCloseSpy = jest.spyOn(openModal, 'close')
|
||||
openModal.componentInstance.saveClicked.next({
|
||||
name: 'Foo Bar',
|
||||
show_on_dashboard: true,
|
||||
show_in_sidebar: true,
|
||||
showOnDashboard: true,
|
||||
showInSideBar: false,
|
||||
})
|
||||
expect(savedViewServiceCreate).toHaveBeenCalled()
|
||||
expect(modalSpy).toHaveBeenCalled()
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
|
||||
expect(modalCloseSpy).toHaveBeenCalled()
|
||||
expect(toastErrorSpy).toHaveBeenCalledWith(
|
||||
'View "Foo Bar" created successfully, but could not update visibility settings.',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle error on edited view saving as', () => {
|
||||
@@ -563,6 +709,10 @@ describe('DocumentListComponent', () => {
|
||||
|
||||
let openModal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
||||
const updateVisibilitySpy = jest.spyOn(
|
||||
settingsService,
|
||||
'updateSavedViewsVisibility'
|
||||
)
|
||||
jest.spyOn(savedViewService, 'create').mockReturnValueOnce(
|
||||
throwError(
|
||||
() =>
|
||||
@@ -575,9 +725,10 @@ describe('DocumentListComponent', () => {
|
||||
|
||||
openModal.componentInstance.saveClicked.next({
|
||||
name: 'Foo Bar',
|
||||
show_on_dashboard: true,
|
||||
show_in_sidebar: true,
|
||||
showOnDashboard: true,
|
||||
showInSideBar: true,
|
||||
})
|
||||
expect(updateVisibilitySpy).not.toHaveBeenCalled()
|
||||
expect(openModal.componentInstance.error).toEqual({ filter_rules: ['11'] })
|
||||
})
|
||||
|
||||
|
||||
@@ -47,7 +47,10 @@ import { UsernamePipe } from 'src/app/pipes/username.pipe'
|
||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||
import { HotKeyService } from 'src/app/services/hot-key.service'
|
||||
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import {
|
||||
PermissionAction,
|
||||
PermissionsService,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
@@ -148,12 +151,18 @@ export class DocumentListComponent
|
||||
|
||||
unmodifiedFilterRules: FilterRule[] = []
|
||||
private unmodifiedSavedView: SavedView
|
||||
private activeSavedView: SavedView | null = null
|
||||
|
||||
private unsubscribeNotifier: Subject<any> = new Subject()
|
||||
|
||||
get savedViewIsModified(): boolean {
|
||||
if (!this.list.activeSavedViewId || !this.unmodifiedSavedView) return false
|
||||
else {
|
||||
if (
|
||||
!this.list.activeSavedViewId ||
|
||||
!this.unmodifiedSavedView ||
|
||||
!this.activeSavedViewCanChange
|
||||
) {
|
||||
return false
|
||||
} else {
|
||||
return (
|
||||
this.unmodifiedSavedView.sort_field !== this.list.sortField ||
|
||||
this.unmodifiedSavedView.sort_reverse !== this.list.sortReverse ||
|
||||
@@ -180,6 +189,16 @@ export class DocumentListComponent
|
||||
}
|
||||
}
|
||||
|
||||
get activeSavedViewCanChange(): boolean {
|
||||
if (!this.activeSavedView) {
|
||||
return false
|
||||
}
|
||||
return this.permissionService.currentUserHasObjectPermissions(
|
||||
PermissionAction.Change,
|
||||
this.activeSavedView
|
||||
)
|
||||
}
|
||||
|
||||
get isFiltered() {
|
||||
return !!this.filterEditor?.rulesModified
|
||||
}
|
||||
@@ -264,11 +283,13 @@ export class DocumentListComponent
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(({ view }) => {
|
||||
if (!view) {
|
||||
this.activeSavedView = null
|
||||
this.router.navigate(['404'], {
|
||||
replaceUrl: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
this.activeSavedView = view
|
||||
this.unmodifiedSavedView = view
|
||||
this.list.activateSavedViewWithQueryParams(
|
||||
view,
|
||||
@@ -292,6 +313,7 @@ export class DocumentListComponent
|
||||
// loading a saved view on /documents
|
||||
this.loadViewConfig(parseInt(queryParams.get('view')))
|
||||
} else {
|
||||
this.activeSavedView = null
|
||||
this.list.activateSavedView(null)
|
||||
this.list.loadFromQueryParams(queryParams)
|
||||
this.unmodifiedFilterRules = []
|
||||
@@ -374,7 +396,7 @@ export class DocumentListComponent
|
||||
}
|
||||
|
||||
saveViewConfig() {
|
||||
if (this.list.activeSavedViewId != null) {
|
||||
if (this.list.activeSavedViewId != null && this.activeSavedViewCanChange) {
|
||||
let savedView: SavedView = {
|
||||
id: this.list.activeSavedViewId,
|
||||
filter_rules: this.list.filterRules,
|
||||
@@ -388,6 +410,7 @@ export class DocumentListComponent
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: (view) => {
|
||||
this.activeSavedView = view
|
||||
this.unmodifiedSavedView = view
|
||||
this.toastService.showInfo(
|
||||
$localize`View "${this.list.activeSavedViewTitle}" saved successfully.`
|
||||
@@ -409,6 +432,11 @@ export class DocumentListComponent
|
||||
.getCached(viewID)
|
||||
.pipe(first())
|
||||
.subscribe((view) => {
|
||||
if (!view) {
|
||||
this.activeSavedView = null
|
||||
return
|
||||
}
|
||||
this.activeSavedView = view
|
||||
this.unmodifiedSavedView = view
|
||||
this.list.activateSavedView(view)
|
||||
this.list.reload(() => {
|
||||
@@ -426,24 +454,48 @@ export class DocumentListComponent
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
let savedView: SavedView = {
|
||||
name: formValue.name,
|
||||
show_on_dashboard: formValue.showOnDashboard,
|
||||
show_in_sidebar: formValue.showInSideBar,
|
||||
filter_rules: this.list.filterRules,
|
||||
sort_reverse: this.list.sortReverse,
|
||||
sort_field: this.list.sortField,
|
||||
display_mode: this.list.displayMode,
|
||||
display_fields: this.activeDisplayFields,
|
||||
}
|
||||
const permissions = formValue.permissions_form
|
||||
if (permissions) {
|
||||
if (permissions.owner !== null && permissions.owner !== undefined) {
|
||||
savedView.owner = permissions.owner
|
||||
}
|
||||
if (permissions.set_permissions) {
|
||||
savedView['set_permissions'] = permissions.set_permissions
|
||||
}
|
||||
}
|
||||
|
||||
this.savedViewService
|
||||
.create(savedView)
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: () => {
|
||||
modal.close()
|
||||
this.toastService.showInfo(
|
||||
$localize`View "${savedView.name}" created successfully.`
|
||||
next: (createdView) => {
|
||||
this.saveCreatedViewVisibility(
|
||||
createdView,
|
||||
formValue.showOnDashboard,
|
||||
formValue.showInSideBar
|
||||
)
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: () => {
|
||||
modal.close()
|
||||
this.toastService.showInfo(
|
||||
$localize`View "${savedView.name}" created successfully.`
|
||||
)
|
||||
},
|
||||
error: (error) => {
|
||||
modal.close()
|
||||
this.toastService.showError(
|
||||
$localize`View "${savedView.name}" created successfully, but could not update visibility settings.`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
},
|
||||
error: (httpError) => {
|
||||
let error = httpError.error
|
||||
@@ -457,6 +509,28 @@ export class DocumentListComponent
|
||||
})
|
||||
}
|
||||
|
||||
private saveCreatedViewVisibility(
|
||||
createdView: SavedView,
|
||||
showOnDashboard: boolean,
|
||||
showInSideBar: boolean
|
||||
) {
|
||||
const dashboardViewIds = this.savedViewService.dashboardViews.map(
|
||||
(v) => v.id
|
||||
)
|
||||
const sidebarViewIds = this.savedViewService.sidebarViews.map((v) => v.id)
|
||||
if (showOnDashboard) {
|
||||
dashboardViewIds.push(createdView.id)
|
||||
}
|
||||
if (showInSideBar) {
|
||||
sidebarViewIds.push(createdView.id)
|
||||
}
|
||||
|
||||
return this.settingsService.updateSavedViewsVisibility(
|
||||
dashboardViewIds,
|
||||
sidebarViewIds
|
||||
)
|
||||
}
|
||||
|
||||
openDocumentDetail(document: Document | number) {
|
||||
this.router.navigate([
|
||||
'documents',
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<pngx-input-text i18n-title title="Name" formControlName="name" [error]="error?.name" autocomplete="off"></pngx-input-text>
|
||||
<pngx-input-check i18n-title title="Show in sidebar" formControlName="showInSideBar"></pngx-input-check>
|
||||
<pngx-input-check i18n-title title="Show on dashboard" formControlName="showOnDashboard"></pngx-input-check>
|
||||
<pngx-permissions-form accordion="true" formControlName="permissions_form"></pngx-permissions-form>
|
||||
@if (error?.filter_rules) {
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<h6 class="alert-heading" i18n>Filter rules error occurred while saving this view</h6>
|
||||
|
||||
@@ -7,7 +7,13 @@ import {
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { NgbActiveModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { of } from 'rxjs'
|
||||
import { GroupService } from 'src/app/services/rest/group.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { CheckComponent } from '../../common/input/check/check.component'
|
||||
import { PermissionsFormComponent } from '../../common/input/permissions/permissions-form/permissions-form.component'
|
||||
import { PermissionsGroupComponent } from '../../common/input/permissions/permissions-group/permissions-group.component'
|
||||
import { PermissionsUserComponent } from '../../common/input/permissions/permissions-user/permissions-user.component'
|
||||
import { TextComponent } from '../../common/input/text/text.component'
|
||||
import { SaveViewConfigDialogComponent } from './save-view-config-dialog.component'
|
||||
|
||||
@@ -18,7 +24,21 @@ describe('SaveViewConfigDialogComponent', () => {
|
||||
|
||||
beforeEach(fakeAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [NgbActiveModal],
|
||||
providers: [
|
||||
NgbActiveModal,
|
||||
{
|
||||
provide: UserService,
|
||||
useValue: {
|
||||
listAll: () => of({ results: [] }),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: GroupService,
|
||||
useValue: {
|
||||
listAll: () => of({ results: [] }),
|
||||
},
|
||||
},
|
||||
],
|
||||
imports: [
|
||||
NgbModalModule,
|
||||
FormsModule,
|
||||
@@ -26,6 +46,9 @@ describe('SaveViewConfigDialogComponent', () => {
|
||||
SaveViewConfigDialogComponent,
|
||||
TextComponent,
|
||||
CheckComponent,
|
||||
PermissionsFormComponent,
|
||||
PermissionsUserComponent,
|
||||
PermissionsGroupComponent,
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
@@ -81,6 +104,26 @@ describe('SaveViewConfigDialogComponent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should support permissions input', () => {
|
||||
const permissions = {
|
||||
owner: 10,
|
||||
set_permissions: {
|
||||
view: { users: [2], groups: [3] },
|
||||
change: { users: [4], groups: [5] },
|
||||
},
|
||||
}
|
||||
let result
|
||||
component.saveClicked.subscribe((saveResult) => (result = saveResult))
|
||||
component.saveViewConfigForm.get('permissions_form').patchValue(permissions)
|
||||
component.save()
|
||||
expect(result).toEqual({
|
||||
name: '',
|
||||
showInSideBar: false,
|
||||
showOnDashboard: false,
|
||||
permissions_form: permissions,
|
||||
})
|
||||
})
|
||||
|
||||
it('should support default name', () => {
|
||||
const saveClickedSpy = jest.spyOn(component.saveClicked, 'emit')
|
||||
const modalCloseSpy = jest.spyOn(modal, 'close')
|
||||
|
||||
@@ -13,14 +13,22 @@ import {
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { User } from 'src/app/data/user'
|
||||
import { CheckComponent } from '../../common/input/check/check.component'
|
||||
import { PermissionsFormComponent } from '../../common/input/permissions/permissions-form/permissions-form.component'
|
||||
import { TextComponent } from '../../common/input/text/text.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-save-view-config-dialog',
|
||||
templateUrl: './save-view-config-dialog.component.html',
|
||||
styleUrls: ['./save-view-config-dialog.component.scss'],
|
||||
imports: [CheckComponent, TextComponent, FormsModule, ReactiveFormsModule],
|
||||
imports: [
|
||||
CheckComponent,
|
||||
TextComponent,
|
||||
PermissionsFormComponent,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
],
|
||||
})
|
||||
export class SaveViewConfigDialogComponent implements OnInit {
|
||||
private modal = inject(NgbActiveModal)
|
||||
@@ -36,6 +44,8 @@ export class SaveViewConfigDialogComponent implements OnInit {
|
||||
|
||||
closeEnabled = false
|
||||
|
||||
users: User[]
|
||||
|
||||
_defaultName = ''
|
||||
|
||||
get defaultName() {
|
||||
@@ -52,6 +62,7 @@ export class SaveViewConfigDialogComponent implements OnInit {
|
||||
name: new FormControl(''),
|
||||
showInSideBar: new FormControl(false),
|
||||
showOnDashboard: new FormControl(false),
|
||||
permissions_form: new FormControl(null),
|
||||
})
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -62,7 +73,16 @@ export class SaveViewConfigDialogComponent implements OnInit {
|
||||
}
|
||||
|
||||
save() {
|
||||
this.saveClicked.emit(this.saveViewConfigForm.value)
|
||||
const formValue = this.saveViewConfigForm.value
|
||||
const saveViewConfig = {
|
||||
name: formValue.name,
|
||||
showInSideBar: formValue.showInSideBar,
|
||||
showOnDashboard: formValue.showOnDashboard,
|
||||
}
|
||||
if (formValue.permissions_form) {
|
||||
saveViewConfig['permissions_form'] = formValue.permissions_form
|
||||
}
|
||||
this.saveClicked.emit(saveViewConfig)
|
||||
}
|
||||
|
||||
cancel() {
|
||||
|
||||
@@ -25,15 +25,23 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label" for="name_{{view.id}}" i18n>Actions</label>
|
||||
<pngx-confirm-button
|
||||
label="Delete"
|
||||
i18n-label
|
||||
(confirm)="deleteSavedView(view)"
|
||||
*pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.SavedView }"
|
||||
buttonClasses="btn-sm btn-outline-danger form-control"
|
||||
iconName="trash">
|
||||
</pngx-confirm-button>
|
||||
@if (canDeleteSavedView(view)) {
|
||||
<label class="form-label" for="name_{{view.id}}" i18n>Actions</label>
|
||||
<button
|
||||
class="btn btn-sm btn-outline-secondary form-control mb-2"
|
||||
type="button"
|
||||
(click)="editPermissions(view)"
|
||||
*pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.SavedView }"
|
||||
i18n><i-bs class="me-1" name="person-fill-lock"></i-bs>Permissions</button>
|
||||
<pngx-confirm-button
|
||||
label="Delete"
|
||||
i18n-label
|
||||
(confirm)="deleteSavedView(view)"
|
||||
*pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.SavedView }"
|
||||
buttonClasses="btn-sm btn-outline-danger form-control"
|
||||
iconName="trash">
|
||||
</pngx-confirm-button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
|
||||
@@ -3,16 +3,16 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgbModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { Subject, of, throwError } from 'rxjs'
|
||||
import { SavedView } from 'src/app/data/saved-view'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
|
||||
import { CheckComponent } from '../../common/input/check/check.component'
|
||||
@@ -32,7 +32,9 @@ describe('SavedViewsComponent', () => {
|
||||
let component: SavedViewsComponent
|
||||
let fixture: ComponentFixture<SavedViewsComponent>
|
||||
let savedViewService: SavedViewService
|
||||
let settingsService: SettingsService
|
||||
let toastService: ToastService
|
||||
let modalService: NgbModal
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
@@ -57,6 +59,8 @@ describe('SavedViewsComponent', () => {
|
||||
provide: PermissionsService,
|
||||
useValue: {
|
||||
currentUserCan: () => true,
|
||||
currentUserHasObjectPermissions: () => true,
|
||||
currentUserOwnsObject: () => true,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -77,11 +81,13 @@ describe('SavedViewsComponent', () => {
|
||||
}).compileComponents()
|
||||
|
||||
savedViewService = TestBed.inject(SavedViewService)
|
||||
settingsService = TestBed.inject(SettingsService)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
modalService = TestBed.inject(NgbModal)
|
||||
fixture = TestBed.createComponent(SavedViewsComponent)
|
||||
component = fixture.componentInstance
|
||||
|
||||
jest.spyOn(savedViewService, 'listAll').mockReturnValue(
|
||||
jest.spyOn(savedViewService, 'list').mockReturnValue(
|
||||
of({
|
||||
all: savedViews.map((v) => v.id),
|
||||
count: savedViews.length,
|
||||
@@ -94,14 +100,13 @@ describe('SavedViewsComponent', () => {
|
||||
|
||||
it('should support save saved views, show error', () => {
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const toastSpy = jest.spyOn(toastService, 'show')
|
||||
const savedViewPatchSpy = jest.spyOn(savedViewService, 'patchMany')
|
||||
|
||||
const toggle = fixture.debugElement.query(
|
||||
By.css('.form-check.form-switch input')
|
||||
)
|
||||
toggle.nativeElement.checked = true
|
||||
toggle.nativeElement.dispatchEvent(new Event('change'))
|
||||
const control = component.savedViewsForm
|
||||
.get('savedViews')
|
||||
.get(savedViews[0].id.toString())
|
||||
.get('name')
|
||||
control.setValue(`${savedViews[0].name}-changed`)
|
||||
control.markAsDirty()
|
||||
|
||||
// saved views error first
|
||||
savedViewPatchSpy.mockReturnValueOnce(
|
||||
@@ -110,12 +115,13 @@ describe('SavedViewsComponent', () => {
|
||||
component.save()
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(savedViewPatchSpy).toHaveBeenCalled()
|
||||
toastSpy.mockClear()
|
||||
toastErrorSpy.mockClear()
|
||||
savedViewPatchSpy.mockClear()
|
||||
|
||||
// succeed saved views
|
||||
savedViewPatchSpy.mockReturnValueOnce(of(savedViews as SavedView[]))
|
||||
control.setValue(savedViews[0].name)
|
||||
control.markAsDirty()
|
||||
component.save()
|
||||
expect(toastErrorSpy).not.toHaveBeenCalled()
|
||||
expect(savedViewPatchSpy).toHaveBeenCalled()
|
||||
@@ -127,26 +133,65 @@ describe('SavedViewsComponent', () => {
|
||||
expect(patchSpy).not.toHaveBeenCalled()
|
||||
|
||||
const view = savedViews[0]
|
||||
const toggle = fixture.debugElement.query(
|
||||
By.css('.form-check.form-switch input')
|
||||
)
|
||||
toggle.nativeElement.checked = true
|
||||
toggle.nativeElement.dispatchEvent(new Event('change'))
|
||||
// register change
|
||||
component.savedViewsForm.get('savedViews').get(view.id.toString()).value[
|
||||
'show_on_dashboard'
|
||||
] = !view.show_on_dashboard
|
||||
component.savedViewsForm
|
||||
.get('savedViews')
|
||||
.get(view.id.toString())
|
||||
.get('name')
|
||||
.setValue('changed-view-name')
|
||||
component.savedViewsForm
|
||||
.get('savedViews')
|
||||
.get(view.id.toString())
|
||||
.get('name')
|
||||
.markAsDirty()
|
||||
fixture.detectChanges()
|
||||
|
||||
component.save()
|
||||
expect(patchSpy).toHaveBeenCalledWith([
|
||||
{
|
||||
id: view.id,
|
||||
name: view.name,
|
||||
show_in_sidebar: view.show_in_sidebar,
|
||||
show_on_dashboard: !view.show_on_dashboard,
|
||||
},
|
||||
])
|
||||
expect(patchSpy).toHaveBeenCalled()
|
||||
const patchBody = patchSpy.mock.calls[0][0][0]
|
||||
expect(patchBody).toMatchObject({
|
||||
id: view.id,
|
||||
name: 'changed-view-name',
|
||||
})
|
||||
expect(patchBody.show_on_dashboard).toBeUndefined()
|
||||
expect(patchBody.show_in_sidebar).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should persist visibility changes to user settings', () => {
|
||||
const patchSpy = jest.spyOn(savedViewService, 'patchMany')
|
||||
const updateVisibilitySpy = jest
|
||||
.spyOn(settingsService, 'updateSavedViewsVisibility')
|
||||
.mockReturnValue(of({ success: true }))
|
||||
|
||||
const dashboardControl = component.savedViewsForm
|
||||
.get('savedViews')
|
||||
.get(savedViews[0].id.toString())
|
||||
.get('show_on_dashboard')
|
||||
dashboardControl.setValue(false)
|
||||
dashboardControl.markAsDirty()
|
||||
|
||||
component.save()
|
||||
|
||||
expect(patchSpy).not.toHaveBeenCalled()
|
||||
expect(updateVisibilitySpy).toHaveBeenCalledWith([], [savedViews[0].id])
|
||||
})
|
||||
|
||||
it('should skip model updates for views that cannot be edited', () => {
|
||||
const patchSpy = jest.spyOn(savedViewService, 'patchMany')
|
||||
const updateVisibilitySpy = jest.spyOn(
|
||||
settingsService,
|
||||
'updateSavedViewsVisibility'
|
||||
)
|
||||
const nameControl = component.savedViewsForm
|
||||
.get('savedViews')
|
||||
.get(savedViews[0].id.toString())
|
||||
.get('name')
|
||||
|
||||
nameControl.disable()
|
||||
|
||||
component.save()
|
||||
|
||||
expect(patchSpy).not.toHaveBeenCalled()
|
||||
expect(updateVisibilitySpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support delete saved view', () => {
|
||||
@@ -162,14 +207,55 @@ describe('SavedViewsComponent', () => {
|
||||
|
||||
it('should support reset', () => {
|
||||
const view = savedViews[0]
|
||||
component.savedViewsForm.get('savedViews').get(view.id.toString()).value[
|
||||
'show_on_dashboard'
|
||||
] = !view.show_on_dashboard
|
||||
component.savedViewsForm
|
||||
.get('savedViews')
|
||||
.get(view.id.toString())
|
||||
.get('show_on_dashboard')
|
||||
.setValue(!view.show_on_dashboard)
|
||||
component.reset()
|
||||
expect(
|
||||
component.savedViewsForm.get('savedViews').get(view.id.toString()).value[
|
||||
'show_on_dashboard'
|
||||
]
|
||||
component.savedViewsForm
|
||||
.get('savedViews')
|
||||
.get(view.id.toString())
|
||||
.get('show_on_dashboard').value
|
||||
).toEqual(view.show_on_dashboard)
|
||||
})
|
||||
|
||||
it('should support editing permissions', () => {
|
||||
const confirmClicked = new Subject<any>()
|
||||
const modalRef = {
|
||||
componentInstance: {
|
||||
confirmClicked,
|
||||
buttonsEnabled: true,
|
||||
},
|
||||
close: jest.fn(),
|
||||
} as any
|
||||
jest.spyOn(modalService, 'open').mockReturnValue(modalRef)
|
||||
const patchSpy = jest.spyOn(savedViewService, 'patch')
|
||||
patchSpy.mockReturnValue(of(savedViews[0] as SavedView))
|
||||
|
||||
component.editPermissions(savedViews[0] as SavedView)
|
||||
confirmClicked.next({
|
||||
permissions: {
|
||||
owner: 1,
|
||||
set_permissions: {
|
||||
view: { users: [2], groups: [] },
|
||||
change: { users: [], groups: [3] },
|
||||
},
|
||||
},
|
||||
merge: true,
|
||||
})
|
||||
|
||||
expect(patchSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: savedViews[0].id,
|
||||
owner: 1,
|
||||
set_permissions: {
|
||||
view: { users: [2], groups: [] },
|
||||
change: { users: [], groups: [3] },
|
||||
},
|
||||
})
|
||||
)
|
||||
expect(modalRef.close).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,11 +6,18 @@ import {
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { dirtyCheck } from '@ngneat/dirty-check-forms'
|
||||
import { BehaviorSubject, Observable, takeUntil } from 'rxjs'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { BehaviorSubject, Observable, of, switchMap, takeUntil } from 'rxjs'
|
||||
import { PermissionsDialogComponent } from 'src/app/components/common/permissions-dialog/permissions-dialog.component'
|
||||
import { DisplayMode } from 'src/app/data/document'
|
||||
import { SavedView } from 'src/app/data/saved-view'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import {
|
||||
PermissionAction,
|
||||
PermissionsService,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
@@ -34,15 +41,18 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
AsyncPipe,
|
||||
NgxBootstrapIconsModule,
|
||||
],
|
||||
})
|
||||
export class SavedViewsComponent
|
||||
extends LoadingComponentWithPermissions
|
||||
implements OnInit, OnDestroy
|
||||
{
|
||||
private savedViewService = inject(SavedViewService)
|
||||
private settings = inject(SettingsService)
|
||||
private toastService = inject(ToastService)
|
||||
private readonly savedViewService = inject(SavedViewService)
|
||||
private readonly permissionsService = inject(PermissionsService)
|
||||
private readonly settings = inject(SettingsService)
|
||||
private readonly toastService = inject(ToastService)
|
||||
private readonly modalService = inject(NgbModal)
|
||||
|
||||
DisplayMode = DisplayMode
|
||||
|
||||
@@ -65,11 +75,17 @@ export class SavedViewsComponent
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.reloadViews()
|
||||
}
|
||||
|
||||
private reloadViews(): void {
|
||||
this.loading = true
|
||||
this.savedViewService.listAll().subscribe((r) => {
|
||||
this.savedViews = r.results
|
||||
this.initialize()
|
||||
})
|
||||
this.savedViewService
|
||||
.list(null, null, null, false, { full_perms: true })
|
||||
.subscribe((r) => {
|
||||
this.savedViews = r.results
|
||||
this.initialize()
|
||||
})
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
@@ -95,16 +111,20 @@ export class SavedViewsComponent
|
||||
display_mode: view.display_mode,
|
||||
display_fields: view.display_fields,
|
||||
}
|
||||
const canEdit = this.canEditSavedView(view)
|
||||
this.savedViewsGroup.addControl(
|
||||
view.id.toString(),
|
||||
new FormGroup({
|
||||
id: new FormControl(null),
|
||||
name: new FormControl(null),
|
||||
show_on_dashboard: new FormControl(null),
|
||||
show_in_sidebar: new FormControl(null),
|
||||
page_size: new FormControl(null),
|
||||
display_mode: new FormControl(null),
|
||||
display_fields: new FormControl([]),
|
||||
id: new FormControl({ value: null, disabled: !canEdit }),
|
||||
name: new FormControl({ value: null, disabled: !canEdit }),
|
||||
show_on_dashboard: new FormControl({
|
||||
value: null,
|
||||
disabled: false,
|
||||
}),
|
||||
show_in_sidebar: new FormControl({ value: null, disabled: false }),
|
||||
page_size: new FormControl({ value: null, disabled: !canEdit }),
|
||||
display_mode: new FormControl({ value: null, disabled: !canEdit }),
|
||||
display_fields: new FormControl({ value: [], disabled: !canEdit }),
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -133,10 +153,7 @@ export class SavedViewsComponent
|
||||
$localize`Saved view "${savedView.name}" deleted.`
|
||||
)
|
||||
this.savedViewService.clearCache()
|
||||
this.savedViewService.listAll().subscribe((r) => {
|
||||
this.savedViews = r.results
|
||||
this.initialize()
|
||||
})
|
||||
this.reloadViews()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -145,26 +162,120 @@ export class SavedViewsComponent
|
||||
}
|
||||
|
||||
public save() {
|
||||
// only patch views that have actually changed
|
||||
// Save only changed views, then save the visibility changes into user settings.
|
||||
const groups = Object.values(this.savedViewsGroup.controls) as FormGroup[]
|
||||
const visibilityChanged = groups.some(
|
||||
(group) =>
|
||||
group.get('show_on_dashboard')?.dirty ||
|
||||
group.get('show_in_sidebar')?.dirty
|
||||
)
|
||||
|
||||
const changed: SavedView[] = []
|
||||
Object.values(this.savedViewsGroup.controls)
|
||||
.filter((g: FormGroup) => !g.pristine)
|
||||
.forEach((group: FormGroup) => {
|
||||
changed.push(group.value)
|
||||
})
|
||||
const dashboardVisibleIds: number[] = []
|
||||
const sidebarVisibleIds: number[] = []
|
||||
|
||||
groups.forEach((group) => {
|
||||
const value = group.getRawValue()
|
||||
if (value.show_on_dashboard) {
|
||||
dashboardVisibleIds.push(value.id)
|
||||
}
|
||||
if (value.show_in_sidebar) {
|
||||
sidebarVisibleIds.push(value.id)
|
||||
}
|
||||
// Would be fine to send, but no longer stored on the model
|
||||
delete value.show_on_dashboard
|
||||
delete value.show_in_sidebar
|
||||
|
||||
if (!group.get('name')?.enabled) {
|
||||
// Quick check for user doesn't have permissions, then bail
|
||||
return
|
||||
}
|
||||
|
||||
const modelFieldsChanged =
|
||||
group.get('name')?.dirty ||
|
||||
group.get('page_size')?.dirty ||
|
||||
group.get('display_mode')?.dirty ||
|
||||
group.get('display_fields')?.dirty
|
||||
|
||||
if (!modelFieldsChanged) {
|
||||
return
|
||||
}
|
||||
|
||||
changed.push(value)
|
||||
})
|
||||
|
||||
if (!changed.length && !visibilityChanged) {
|
||||
return
|
||||
}
|
||||
|
||||
let saveOperation = of([])
|
||||
if (changed.length) {
|
||||
this.savedViewService.patchMany(changed).subscribe({
|
||||
saveOperation = saveOperation.pipe(
|
||||
switchMap(() => this.savedViewService.patchMany(changed))
|
||||
)
|
||||
}
|
||||
if (visibilityChanged) {
|
||||
saveOperation = saveOperation.pipe(
|
||||
switchMap(() =>
|
||||
this.settings.updateSavedViewsVisibility(
|
||||
dashboardVisibleIds,
|
||||
sidebarVisibleIds
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
saveOperation.subscribe({
|
||||
next: () => {
|
||||
this.toastService.showInfo($localize`Views saved successfully.`)
|
||||
this.savedViewService.clearCache()
|
||||
this.reloadViews()
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError($localize`Error while saving views.`, error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
public canEditSavedView(view: SavedView): boolean {
|
||||
return this.permissionsService.currentUserHasObjectPermissions(
|
||||
PermissionAction.Change,
|
||||
view
|
||||
)
|
||||
}
|
||||
|
||||
public canDeleteSavedView(view: SavedView): boolean {
|
||||
return this.permissionsService.currentUserOwnsObject(view)
|
||||
}
|
||||
|
||||
public editPermissions(savedView: SavedView): void {
|
||||
const modal = this.modalService.open(PermissionsDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
const dialog = modal.componentInstance as PermissionsDialogComponent
|
||||
dialog.object = savedView
|
||||
dialog.note = $localize`Note: Sharing saved views does not share the underlying documents.`
|
||||
|
||||
modal.componentInstance.confirmClicked.subscribe(({ permissions }) => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
const view = {
|
||||
id: savedView.id,
|
||||
owner: permissions.owner,
|
||||
}
|
||||
view['set_permissions'] = permissions.set_permissions
|
||||
this.savedViewService.patch(view as SavedView).subscribe({
|
||||
next: () => {
|
||||
this.toastService.showInfo($localize`Views saved successfully.`)
|
||||
this.store.next(this.savedViewsForm.value)
|
||||
this.toastService.showInfo($localize`Permissions updated`)
|
||||
modal.close()
|
||||
this.reloadViews()
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError(
|
||||
$localize`Error while saving views.`,
|
||||
$localize`Error updating permissions`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,15 +128,15 @@ export interface Document extends ObjectWithPermissions {
|
||||
checksum?: string
|
||||
|
||||
// UTC
|
||||
created?: Date
|
||||
created?: string // ISO string
|
||||
|
||||
modified?: Date
|
||||
modified?: string // ISO string
|
||||
|
||||
added?: Date
|
||||
added?: string // ISO string
|
||||
|
||||
mime_type?: string
|
||||
|
||||
deleted_at?: Date
|
||||
deleted_at?: string // ISO string
|
||||
|
||||
original_file_name?: string
|
||||
|
||||
|
||||
@@ -62,6 +62,10 @@ export const SETTINGS_KEYS = {
|
||||
'general-settings:update-checking:backend-setting',
|
||||
SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE:
|
||||
'general-settings:saved-views:warn-on-unsaved-change',
|
||||
DASHBOARD_VIEWS_VISIBLE_IDS:
|
||||
'general-settings:saved-views:dashboard-views-visible-ids',
|
||||
SIDEBAR_VIEWS_VISIBLE_IDS:
|
||||
'general-settings:saved-views:sidebar-views-visible-ids',
|
||||
DASHBOARD_VIEWS_SORT_ORDER:
|
||||
'general-settings:saved-views:dashboard-views-sort-order',
|
||||
SIDEBAR_VIEWS_SORT_ORDER:
|
||||
@@ -248,6 +252,16 @@ export const SETTINGS: UiSetting[] = [
|
||||
type: 'array',
|
||||
default: [],
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DASHBOARD_VIEWS_VISIBLE_IDS,
|
||||
type: 'array',
|
||||
default: [],
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.SIDEBAR_VIEWS_VISIBLE_IDS,
|
||||
type: 'array',
|
||||
default: [],
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER,
|
||||
type: 'array',
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface WebsocketDocumentUpdatedMessage {
|
||||
document_id: number
|
||||
modified: string
|
||||
owner_id?: number
|
||||
users_can_view?: number[]
|
||||
groups_can_view?: number[]
|
||||
}
|
||||
@@ -230,6 +230,88 @@ describe(`DocumentService`, () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should call appropriate api endpoint for delete documents', () => {
|
||||
const ids = [1, 2, 3]
|
||||
subscription = service.deleteDocuments(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(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(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', () => {
|
||||
let url = service.getPreviewUrl(documents[0].id)
|
||||
expect(url).toEqual(
|
||||
|
||||
@@ -37,6 +37,50 @@ export interface SelectionData {
|
||||
selected_custom_fields: SelectionDataItem[]
|
||||
}
|
||||
|
||||
export enum BulkEditSourceMode {
|
||||
LATEST_VERSION = 'latest_version',
|
||||
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
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
@@ -294,7 +338,7 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
||||
return this.http.get<DocumentMetadata>(url.toString())
|
||||
}
|
||||
|
||||
bulkEdit(ids: number[], method: string, args: any) {
|
||||
bulkEdit(ids: number[], method: DocumentBulkEditMethod, args: any) {
|
||||
return this.http.post(this.getResourceUrl(null, 'bulk_edit'), {
|
||||
documents: ids,
|
||||
method: method,
|
||||
@@ -302,6 +346,54 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
||||
})
|
||||
}
|
||||
|
||||
deleteDocuments(ids: number[]) {
|
||||
return this.http.post(this.getResourceUrl(null, 'delete'), {
|
||||
documents: ids,
|
||||
})
|
||||
}
|
||||
|
||||
reprocessDocuments(ids: number[]) {
|
||||
return this.http.post(this.getResourceUrl(null, 'reprocess'), {
|
||||
documents: ids,
|
||||
})
|
||||
}
|
||||
|
||||
rotateDocuments(
|
||||
ids: number[],
|
||||
degrees: number,
|
||||
sourceMode: BulkEditSourceMode = BulkEditSourceMode.LATEST_VERSION
|
||||
) {
|
||||
return this.http.post(this.getResourceUrl(null, 'rotate'), {
|
||||
documents: ids,
|
||||
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> {
|
||||
return this.http.post<SelectionData>(
|
||||
this.getResourceUrl(null, 'selection_data'),
|
||||
|
||||
@@ -57,6 +57,11 @@ describe(`Additional service tests for SavedViewService`, () => {
|
||||
let settingsService
|
||||
|
||||
it('should retrieve saved views and sort them', () => {
|
||||
jest.spyOn(settingsService, 'get').mockImplementation((key) => {
|
||||
if (key === SETTINGS_KEYS.DASHBOARD_VIEWS_VISIBLE_IDS) return [1, 2, 3]
|
||||
if (key === SETTINGS_KEYS.SIDEBAR_VIEWS_VISIBLE_IDS) return [1, 2, 3]
|
||||
return []
|
||||
})
|
||||
service.reload()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
|
||||
@@ -93,7 +98,9 @@ describe(`Additional service tests for SavedViewService`, () => {
|
||||
it('should sort dashboard views', () => {
|
||||
service['savedViews'] = saved_views
|
||||
jest.spyOn(settingsService, 'get').mockImplementation((key) => {
|
||||
if (key === SETTINGS_KEYS.DASHBOARD_VIEWS_VISIBLE_IDS) return [1, 2, 3]
|
||||
if (key === SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER) return [3, 1, 2]
|
||||
return []
|
||||
})
|
||||
expect(service.dashboardViews).toEqual([
|
||||
saved_views[2],
|
||||
@@ -102,10 +109,21 @@ describe(`Additional service tests for SavedViewService`, () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('should use user-specific dashboard visibility when configured', () => {
|
||||
service['savedViews'] = saved_views
|
||||
jest.spyOn(settingsService, 'get').mockImplementation((key) => {
|
||||
if (key === SETTINGS_KEYS.DASHBOARD_VIEWS_VISIBLE_IDS) return [4, 2]
|
||||
if (key === SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER) return []
|
||||
})
|
||||
expect(service.dashboardViews).toEqual([saved_views[1], saved_views[3]])
|
||||
})
|
||||
|
||||
it('should sort sidebar views', () => {
|
||||
service['savedViews'] = saved_views
|
||||
jest.spyOn(settingsService, 'get').mockImplementation((key) => {
|
||||
if (key === SETTINGS_KEYS.SIDEBAR_VIEWS_VISIBLE_IDS) return [1, 2, 3]
|
||||
if (key === SETTINGS_KEYS.SIDEBAR_VIEWS_SORT_ORDER) return [3, 1, 2]
|
||||
return []
|
||||
})
|
||||
expect(service.sidebarViews).toEqual([
|
||||
saved_views[2],
|
||||
@@ -114,6 +132,15 @@ describe(`Additional service tests for SavedViewService`, () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('should use user-specific sidebar visibility when configured', () => {
|
||||
service['savedViews'] = saved_views
|
||||
jest.spyOn(settingsService, 'get').mockImplementation((key) => {
|
||||
if (key === SETTINGS_KEYS.SIDEBAR_VIEWS_VISIBLE_IDS) return [4, 2]
|
||||
if (key === SETTINGS_KEYS.SIDEBAR_VIEWS_SORT_ORDER) return []
|
||||
})
|
||||
expect(service.sidebarViews).toEqual([saved_views[1], saved_views[3]])
|
||||
})
|
||||
|
||||
it('should treat empty display_fields as null', () => {
|
||||
subscription = service
|
||||
.patch({
|
||||
|
||||
@@ -36,7 +36,9 @@ export class SavedViewService extends AbstractPaperlessService<SavedView> {
|
||||
return super.list(page, pageSize, sortField, sortReverse, extraParams).pipe(
|
||||
tap({
|
||||
next: (r) => {
|
||||
this.savedViews = r.results
|
||||
const views = r.results.map((view) => this.withUserVisibility(view))
|
||||
this.savedViews = views
|
||||
r.results = views
|
||||
this._loading = false
|
||||
this.settingsService.dashboardIsEmpty =
|
||||
this.dashboardViews.length === 0
|
||||
@@ -65,8 +67,35 @@ export class SavedViewService extends AbstractPaperlessService<SavedView> {
|
||||
return this.savedViews
|
||||
}
|
||||
|
||||
private getVisibleViewIds(setting: string): number[] {
|
||||
const configured = this.settingsService.get(setting)
|
||||
return Array.isArray(configured) ? configured : []
|
||||
}
|
||||
|
||||
private withUserVisibility(view: SavedView): SavedView {
|
||||
return {
|
||||
...view,
|
||||
show_on_dashboard: this.isDashboardVisible(view),
|
||||
show_in_sidebar: this.isSidebarVisible(view),
|
||||
}
|
||||
}
|
||||
|
||||
private isDashboardVisible(view: SavedView): boolean {
|
||||
const visibleIds = this.getVisibleViewIds(
|
||||
SETTINGS_KEYS.DASHBOARD_VIEWS_VISIBLE_IDS
|
||||
)
|
||||
return visibleIds.includes(view.id)
|
||||
}
|
||||
|
||||
private isSidebarVisible(view: SavedView): boolean {
|
||||
const visibleIds = this.getVisibleViewIds(
|
||||
SETTINGS_KEYS.SIDEBAR_VIEWS_VISIBLE_IDS
|
||||
)
|
||||
return visibleIds.includes(view.id)
|
||||
}
|
||||
|
||||
get sidebarViews(): SavedView[] {
|
||||
const sidebarViews = this.savedViews.filter((v) => v.show_in_sidebar)
|
||||
const sidebarViews = this.savedViews.filter((v) => this.isSidebarVisible(v))
|
||||
|
||||
const sorted: number[] = this.settingsService.get(
|
||||
SETTINGS_KEYS.SIDEBAR_VIEWS_SORT_ORDER
|
||||
@@ -81,7 +110,9 @@ export class SavedViewService extends AbstractPaperlessService<SavedView> {
|
||||
}
|
||||
|
||||
get dashboardViews(): SavedView[] {
|
||||
const dashboardViews = this.savedViews.filter((v) => v.show_on_dashboard)
|
||||
const dashboardViews = this.savedViews.filter((v) =>
|
||||
this.isDashboardVisible(v)
|
||||
)
|
||||
|
||||
const sorted: number[] = this.settingsService.get(
|
||||
SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER
|
||||
|
||||
@@ -320,7 +320,7 @@ describe('SettingsService', () => {
|
||||
expect(req.request.method).toEqual('POST')
|
||||
})
|
||||
|
||||
it('should update saved view sorting', () => {
|
||||
it('should update saved view sorting and visibility', () => {
|
||||
httpTestingController
|
||||
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
|
||||
.flush(ui_settings)
|
||||
@@ -341,6 +341,15 @@ describe('SettingsService', () => {
|
||||
SETTINGS_KEYS.SIDEBAR_VIEWS_SORT_ORDER,
|
||||
[1, 4]
|
||||
)
|
||||
settingsService.updateSavedViewsVisibility([1, 4], [4, 1])
|
||||
expect(setSpy).toHaveBeenCalledWith(
|
||||
SETTINGS_KEYS.DASHBOARD_VIEWS_VISIBLE_IDS,
|
||||
[1, 4]
|
||||
)
|
||||
expect(setSpy).toHaveBeenCalledWith(
|
||||
SETTINGS_KEYS.SIDEBAR_VIEWS_VISIBLE_IDS,
|
||||
[4, 1]
|
||||
)
|
||||
httpTestingController
|
||||
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
|
||||
.flush(ui_settings)
|
||||
|
||||
@@ -699,4 +699,17 @@ export class SettingsService {
|
||||
])
|
||||
return this.storeSettings()
|
||||
}
|
||||
|
||||
updateSavedViewsVisibility(
|
||||
dashboardVisibleViewIds: number[],
|
||||
sidebarVisibleViewIds: number[]
|
||||
): Observable<any> {
|
||||
this.set(SETTINGS_KEYS.DASHBOARD_VIEWS_VISIBLE_IDS, [
|
||||
...new Set(dashboardVisibleViewIds),
|
||||
])
|
||||
this.set(SETTINGS_KEYS.SIDEBAR_VIEWS_VISIBLE_IDS, [
|
||||
...new Set(sidebarVisibleViewIds),
|
||||
])
|
||||
return this.storeSettings()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,4 +416,42 @@ describe('ConsumerStatusService', () => {
|
||||
websocketStatusService.disconnect()
|
||||
expect(deleted).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should trigger updated subject on document updated', () => {
|
||||
let updated = false
|
||||
websocketStatusService.onDocumentUpdated().subscribe((data) => {
|
||||
updated = true
|
||||
expect(data.document_id).toEqual(12)
|
||||
})
|
||||
|
||||
websocketStatusService.connect()
|
||||
server.send({
|
||||
type: WebsocketStatusType.DOCUMENT_UPDATED,
|
||||
data: {
|
||||
document_id: 12,
|
||||
modified: '2026-02-17T00:00:00Z',
|
||||
owner_id: 1,
|
||||
},
|
||||
})
|
||||
|
||||
websocketStatusService.disconnect()
|
||||
expect(updated).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should ignore document updated events the user cannot view', () => {
|
||||
let updated = false
|
||||
websocketStatusService.onDocumentUpdated().subscribe(() => {
|
||||
updated = true
|
||||
})
|
||||
|
||||
websocketStatusService.handleDocumentUpdated({
|
||||
document_id: 12,
|
||||
modified: '2026-02-17T00:00:00Z',
|
||||
owner_id: 2,
|
||||
users_can_view: [],
|
||||
groups_can_view: [],
|
||||
})
|
||||
|
||||
expect(updated).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, inject } from '@angular/core'
|
||||
import { Subject } from 'rxjs'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { User } from '../data/user'
|
||||
import { WebsocketDocumentUpdatedMessage } from '../data/websocket-document-updated-message'
|
||||
import { WebsocketDocumentsDeletedMessage } from '../data/websocket-documents-deleted-message'
|
||||
import { WebsocketProgressMessage } from '../data/websocket-progress-message'
|
||||
import { SettingsService } from './settings.service'
|
||||
@@ -9,6 +10,7 @@ import { SettingsService } from './settings.service'
|
||||
export enum WebsocketStatusType {
|
||||
STATUS_UPDATE = 'status_update',
|
||||
DOCUMENTS_DELETED = 'documents_deleted',
|
||||
DOCUMENT_UPDATED = 'document_updated',
|
||||
}
|
||||
|
||||
// see ProgressStatusOptions in src/documents/plugins/helpers.py
|
||||
@@ -100,17 +102,20 @@ export enum UploadState {
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class WebsocketStatusService {
|
||||
private settingsService = inject(SettingsService)
|
||||
private readonly settingsService = inject(SettingsService)
|
||||
|
||||
private statusWebSocket: WebSocket
|
||||
|
||||
private consumerStatus: FileStatus[] = []
|
||||
|
||||
private documentDetectedSubject = new Subject<FileStatus>()
|
||||
private documentConsumptionFinishedSubject = new Subject<FileStatus>()
|
||||
private documentConsumptionFailedSubject = new Subject<FileStatus>()
|
||||
private documentDeletedSubject = new Subject<boolean>()
|
||||
private connectionStatusSubject = new Subject<boolean>()
|
||||
private readonly documentDetectedSubject = new Subject<FileStatus>()
|
||||
private readonly documentConsumptionFinishedSubject =
|
||||
new Subject<FileStatus>()
|
||||
private readonly documentConsumptionFailedSubject = new Subject<FileStatus>()
|
||||
private readonly documentDeletedSubject = new Subject<boolean>()
|
||||
private readonly documentUpdatedSubject =
|
||||
new Subject<WebsocketDocumentUpdatedMessage>()
|
||||
private readonly connectionStatusSubject = new Subject<boolean>()
|
||||
|
||||
private get(taskId: string, filename?: string) {
|
||||
let status =
|
||||
@@ -176,7 +181,10 @@ export class WebsocketStatusService {
|
||||
data: messageData,
|
||||
}: {
|
||||
type: WebsocketStatusType
|
||||
data: WebsocketProgressMessage | WebsocketDocumentsDeletedMessage
|
||||
data:
|
||||
| WebsocketProgressMessage
|
||||
| WebsocketDocumentsDeletedMessage
|
||||
| WebsocketDocumentUpdatedMessage
|
||||
} = JSON.parse(ev.data)
|
||||
|
||||
switch (type) {
|
||||
@@ -184,6 +192,12 @@ export class WebsocketStatusService {
|
||||
this.documentDeletedSubject.next(true)
|
||||
break
|
||||
|
||||
case WebsocketStatusType.DOCUMENT_UPDATED:
|
||||
this.handleDocumentUpdated(
|
||||
messageData as WebsocketDocumentUpdatedMessage
|
||||
)
|
||||
break
|
||||
|
||||
case WebsocketStatusType.STATUS_UPDATE:
|
||||
this.handleProgressUpdate(messageData as WebsocketProgressMessage)
|
||||
break
|
||||
@@ -191,7 +205,11 @@ export class WebsocketStatusService {
|
||||
}
|
||||
}
|
||||
|
||||
private canViewMessage(messageData: WebsocketProgressMessage): boolean {
|
||||
private canViewMessage(messageData: {
|
||||
owner_id?: number
|
||||
users_can_view?: number[]
|
||||
groups_can_view?: number[]
|
||||
}): boolean {
|
||||
// see paperless.consumers.StatusConsumer._can_view
|
||||
const user: User = this.settingsService.currentUser
|
||||
return (
|
||||
@@ -251,6 +269,15 @@ export class WebsocketStatusService {
|
||||
}
|
||||
}
|
||||
|
||||
handleDocumentUpdated(messageData: WebsocketDocumentUpdatedMessage) {
|
||||
// fallback if backend didn't restrict message
|
||||
if (!this.canViewMessage(messageData)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.documentUpdatedSubject.next(messageData)
|
||||
}
|
||||
|
||||
fail(status: FileStatus, message: string) {
|
||||
status.message = message
|
||||
status.phase = FileStatusPhase.FAILED
|
||||
@@ -304,6 +331,10 @@ export class WebsocketStatusService {
|
||||
return this.documentDeletedSubject
|
||||
}
|
||||
|
||||
onDocumentUpdated() {
|
||||
return this.documentUpdatedSubject
|
||||
}
|
||||
|
||||
onConnectionStatus() {
|
||||
return this.connectionStatusSubject.asObservable()
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export function hslToRgb(h, s, l) {
|
||||
* @return Array The HSL representation
|
||||
*/
|
||||
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),
|
||||
min = Math.min(r, g, b)
|
||||
var h,
|
||||
|
||||
@@ -3,10 +3,10 @@ const base_url = new URL(document.baseURI)
|
||||
export const environment = {
|
||||
production: true,
|
||||
apiBaseUrl: document.baseURI + 'api/',
|
||||
apiVersion: '9', // match src/paperless/settings.py
|
||||
apiVersion: '10', // match src/paperless/settings.py
|
||||
appTitle: 'Paperless-ngx',
|
||||
tag: 'prod',
|
||||
version: '2.20.9',
|
||||
version: '2.20.10',
|
||||
webSocketHost: window.location.host,
|
||||
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
||||
webSocketBaseUrl: base_url.pathname + 'ws/',
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
apiBaseUrl: 'http://localhost:8000/api/',
|
||||
apiVersion: '9',
|
||||
apiVersion: '10',
|
||||
appTitle: 'Paperless-ngx',
|
||||
tag: 'dev',
|
||||
version: 'DEVELOPMENT',
|
||||
|
||||
@@ -15,6 +15,7 @@ class DocumentsConfig(AppConfig):
|
||||
from documents.signals.handlers import add_to_index
|
||||
from documents.signals.handlers import run_workflows_added
|
||||
from documents.signals.handlers import run_workflows_updated
|
||||
from documents.signals.handlers import send_websocket_document_updated
|
||||
from documents.signals.handlers import set_correspondent
|
||||
from documents.signals.handlers import set_document_type
|
||||
from documents.signals.handlers import set_storage_path
|
||||
@@ -29,6 +30,7 @@ class DocumentsConfig(AppConfig):
|
||||
document_consumption_finished.connect(run_workflows_added)
|
||||
document_consumption_finished.connect(add_or_update_document_in_llm_index)
|
||||
document_updated.connect(run_workflows_updated)
|
||||
document_updated.connect(send_websocket_document_updated)
|
||||
|
||||
import documents.schema # noqa: F401
|
||||
|
||||
|
||||
@@ -29,12 +29,21 @@ from documents.plugins.helpers import DocumentsStatusManager
|
||||
from documents.tasks import bulk_update_documents
|
||||
from documents.tasks import consume_file
|
||||
from documents.tasks import update_document_content_maybe_archive_file
|
||||
from documents.versioning import get_latest_version_for_root
|
||||
from documents.versioning import get_root_document
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
logger: logging.Logger = logging.getLogger("paperless.bulk_edit")
|
||||
|
||||
SourceMode = Literal["latest_version", "explicit_selection"]
|
||||
|
||||
|
||||
class SourceModeChoices:
|
||||
LATEST_VERSION: SourceMode = "latest_version"
|
||||
EXPLICIT_SELECTION: SourceMode = "explicit_selection"
|
||||
|
||||
|
||||
@shared_task(bind=True)
|
||||
def restore_archive_serial_numbers_task(
|
||||
@@ -72,46 +81,21 @@ def restore_archive_serial_numbers(backup: dict[int, int | None]) -> None:
|
||||
logger.info(f"Restored archive serial numbers for documents {list(backup.keys())}")
|
||||
|
||||
|
||||
def _get_root_ids_by_doc_id(doc_ids: list[int]) -> dict[int, int]:
|
||||
"""
|
||||
Resolve each provided document id to its root document id.
|
||||
def _resolve_root_and_source_doc(
|
||||
doc: Document,
|
||||
*,
|
||||
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
|
||||
) -> tuple[Document, Document]:
|
||||
root_doc = get_root_document(doc)
|
||||
|
||||
- If the id is already a root document: root id is itself.
|
||||
- If the id is a version document: root id is its `root_document_id`.
|
||||
"""
|
||||
qs = Document.objects.filter(id__in=doc_ids).only("id", "root_document_id")
|
||||
return {doc.id: doc.root_document_id or doc.id for doc in qs}
|
||||
if source_mode == SourceModeChoices.EXPLICIT_SELECTION:
|
||||
return root_doc, doc
|
||||
|
||||
# Version IDs are explicit by default, only a selected root resolves to latest
|
||||
if doc.root_document_id is not None:
|
||||
return root_doc, doc
|
||||
|
||||
def _get_root_and_current_docs_by_root_id(
|
||||
root_ids: set[int],
|
||||
) -> tuple[dict[int, Document], dict[int, Document]]:
|
||||
"""
|
||||
Returns:
|
||||
- root_docs: root_id -> root Document
|
||||
- current_docs: root_id -> newest version Document (or root if none)
|
||||
"""
|
||||
root_docs = {
|
||||
doc.id: doc
|
||||
for doc in Document.objects.filter(id__in=root_ids).select_related(
|
||||
"owner",
|
||||
)
|
||||
}
|
||||
latest_versions_by_root_id: dict[int, Document] = {}
|
||||
for version_doc in Document.objects.filter(root_document_id__in=root_ids).order_by(
|
||||
"root_document_id",
|
||||
"-id",
|
||||
):
|
||||
root_id = version_doc.root_document_id
|
||||
if root_id is None:
|
||||
continue
|
||||
latest_versions_by_root_id.setdefault(root_id, version_doc)
|
||||
|
||||
current_docs: dict[int, Document] = {
|
||||
root_id: latest_versions_by_root_id.get(root_id, root_docs[root_id])
|
||||
for root_id in root_docs
|
||||
}
|
||||
return root_docs, current_docs
|
||||
return root_doc, get_latest_version_for_root(root_doc)
|
||||
|
||||
|
||||
def set_correspondent(
|
||||
@@ -421,21 +405,32 @@ def rotate(
|
||||
doc_ids: list[int],
|
||||
degrees: int,
|
||||
*,
|
||||
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
|
||||
user: User | None = None,
|
||||
) -> Literal["OK"]:
|
||||
logger.info(
|
||||
f"Attempting to rotate {len(doc_ids)} documents by {degrees} degrees.",
|
||||
)
|
||||
doc_to_root_id = _get_root_ids_by_doc_id(doc_ids)
|
||||
root_ids = set(doc_to_root_id.values())
|
||||
root_docs_by_id, current_docs_by_root_id = _get_root_and_current_docs_by_root_id(
|
||||
root_ids,
|
||||
)
|
||||
docs_by_id = {
|
||||
doc.id: doc
|
||||
for doc in Document.objects.select_related("root_document").filter(
|
||||
id__in=doc_ids,
|
||||
)
|
||||
}
|
||||
docs_by_root_id: dict[int, tuple[Document, Document]] = {}
|
||||
for doc_id in doc_ids:
|
||||
doc = docs_by_id.get(doc_id)
|
||||
if doc is None:
|
||||
continue
|
||||
root_doc, source_doc = _resolve_root_and_source_doc(
|
||||
doc,
|
||||
source_mode=source_mode,
|
||||
)
|
||||
docs_by_root_id.setdefault(root_doc.id, (root_doc, source_doc))
|
||||
|
||||
import pikepdf
|
||||
|
||||
for root_id in root_ids:
|
||||
root_doc = root_docs_by_id[root_id]
|
||||
source_doc = current_docs_by_root_id[root_id]
|
||||
for root_doc, source_doc in docs_by_root_id.values():
|
||||
if source_doc.mime_type != "application/pdf":
|
||||
logger.warning(
|
||||
f"Document {root_doc.id} is not a PDF, skipping rotation.",
|
||||
@@ -481,12 +476,14 @@ def merge(
|
||||
metadata_document_id: int | None = None,
|
||||
delete_originals: bool = False,
|
||||
archive_fallback: bool = False,
|
||||
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
|
||||
user: User | None = None,
|
||||
) -> Literal["OK"]:
|
||||
logger.info(
|
||||
f"Attempting to merge {len(doc_ids)} documents into a single document.",
|
||||
)
|
||||
qs = Document.objects.filter(id__in=doc_ids)
|
||||
qs = Document.objects.select_related("root_document").filter(id__in=doc_ids)
|
||||
docs_by_id = {doc.id: doc for doc in qs}
|
||||
affected_docs: list[int] = []
|
||||
import pikepdf
|
||||
|
||||
@@ -495,14 +492,20 @@ def merge(
|
||||
handoff_asn: int | None = None
|
||||
# use doc_ids to preserve order
|
||||
for doc_id in doc_ids:
|
||||
doc = qs.get(id=doc_id)
|
||||
doc = docs_by_id.get(doc_id)
|
||||
if doc is None:
|
||||
continue
|
||||
_, source_doc = _resolve_root_and_source_doc(
|
||||
doc,
|
||||
source_mode=source_mode,
|
||||
)
|
||||
try:
|
||||
doc_path = (
|
||||
doc.archive_path
|
||||
source_doc.archive_path
|
||||
if archive_fallback
|
||||
and doc.mime_type != "application/pdf"
|
||||
and doc.has_archive_version
|
||||
else doc.source_path
|
||||
and source_doc.mime_type != "application/pdf"
|
||||
and source_doc.has_archive_version
|
||||
else source_doc.source_path
|
||||
)
|
||||
with pikepdf.open(str(doc_path)) as pdf:
|
||||
version = max(version, pdf.pdf_version)
|
||||
@@ -584,18 +587,23 @@ def split(
|
||||
pages: list[list[int]],
|
||||
*,
|
||||
delete_originals: bool = False,
|
||||
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
|
||||
user: User | None = None,
|
||||
) -> Literal["OK"]:
|
||||
logger.info(
|
||||
f"Attempting to split document {doc_ids[0]} into {len(pages)} documents",
|
||||
)
|
||||
doc = Document.objects.get(id=doc_ids[0])
|
||||
doc = Document.objects.select_related("root_document").get(id=doc_ids[0])
|
||||
_, source_doc = _resolve_root_and_source_doc(
|
||||
doc,
|
||||
source_mode=source_mode,
|
||||
)
|
||||
import pikepdf
|
||||
|
||||
consume_tasks = []
|
||||
|
||||
try:
|
||||
with pikepdf.open(doc.source_path) as pdf:
|
||||
with pikepdf.open(source_doc.source_path) as pdf:
|
||||
for idx, split_doc in enumerate(pages):
|
||||
dst: pikepdf.Pdf = pikepdf.new()
|
||||
for page in split_doc:
|
||||
@@ -659,25 +667,17 @@ def delete_pages(
|
||||
doc_ids: list[int],
|
||||
pages: list[int],
|
||||
*,
|
||||
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
|
||||
user: User | None = None,
|
||||
) -> Literal["OK"]:
|
||||
logger.info(
|
||||
f"Attempting to delete pages {pages} from {len(doc_ids)} documents",
|
||||
)
|
||||
doc = Document.objects.select_related("root_document").get(id=doc_ids[0])
|
||||
root_doc: Document
|
||||
if doc.root_document_id is None or doc.root_document is None:
|
||||
root_doc = doc
|
||||
else:
|
||||
root_doc = doc.root_document
|
||||
|
||||
source_doc = (
|
||||
Document.objects.filter(Q(id=root_doc.id) | Q(root_document=root_doc))
|
||||
.order_by("-id")
|
||||
.first()
|
||||
root_doc, source_doc = _resolve_root_and_source_doc(
|
||||
doc,
|
||||
source_mode=source_mode,
|
||||
)
|
||||
if source_doc is None:
|
||||
source_doc = root_doc
|
||||
pages = sorted(pages) # sort pages to avoid index issues
|
||||
import pikepdf
|
||||
|
||||
@@ -722,6 +722,7 @@ def edit_pdf(
|
||||
delete_original: bool = False,
|
||||
update_document: bool = False,
|
||||
include_metadata: bool = True,
|
||||
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
|
||||
user: User | None = None,
|
||||
) -> Literal["OK"]:
|
||||
"""
|
||||
@@ -736,19 +737,10 @@ def edit_pdf(
|
||||
f"Editing PDF of document {doc_ids[0]} with {len(operations)} operations",
|
||||
)
|
||||
doc = Document.objects.select_related("root_document").get(id=doc_ids[0])
|
||||
root_doc: Document
|
||||
if doc.root_document_id is None or doc.root_document is None:
|
||||
root_doc = doc
|
||||
else:
|
||||
root_doc = doc.root_document
|
||||
|
||||
source_doc = (
|
||||
Document.objects.filter(Q(id=root_doc.id) | Q(root_document=root_doc))
|
||||
.order_by("-id")
|
||||
.first()
|
||||
root_doc, source_doc = _resolve_root_and_source_doc(
|
||||
doc,
|
||||
source_mode=source_mode,
|
||||
)
|
||||
if source_doc is None:
|
||||
source_doc = root_doc
|
||||
import pikepdf
|
||||
|
||||
pdf_docs: list[pikepdf.Pdf] = []
|
||||
@@ -859,6 +851,7 @@ def remove_password(
|
||||
update_document: bool = False,
|
||||
delete_original: bool = False,
|
||||
include_metadata: bool = True,
|
||||
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
|
||||
user: User | None = None,
|
||||
) -> Literal["OK"]:
|
||||
"""
|
||||
@@ -868,19 +861,10 @@ def remove_password(
|
||||
|
||||
for doc_id in doc_ids:
|
||||
doc = Document.objects.select_related("root_document").get(id=doc_id)
|
||||
root_doc: Document
|
||||
if doc.root_document_id is None or doc.root_document is None:
|
||||
root_doc = doc
|
||||
else:
|
||||
root_doc = doc.root_document
|
||||
|
||||
source_doc = (
|
||||
Document.objects.filter(Q(id=root_doc.id) | Q(root_document=root_doc))
|
||||
.order_by("-id")
|
||||
.first()
|
||||
root_doc, source_doc = _resolve_root_and_source_doc(
|
||||
doc,
|
||||
source_mode=source_mode,
|
||||
)
|
||||
if source_doc is None:
|
||||
source_doc = root_doc
|
||||
try:
|
||||
logger.info(
|
||||
f"Attempting password removal from document {doc_ids[0]}",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from datetime import UTC
|
||||
from datetime import datetime
|
||||
from datetime import timezone
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
@@ -139,7 +139,7 @@ def thumbnail_last_modified(request: Any, pk: int) -> datetime | None:
|
||||
# No cache, get the timestamp and cache the datetime
|
||||
last_modified = datetime.fromtimestamp(
|
||||
doc.thumbnail_path.stat().st_mtime,
|
||||
tz=timezone.utc,
|
||||
tz=UTC,
|
||||
)
|
||||
cache.set(doc_key, last_modified, CACHE_50_MINUTES)
|
||||
return last_modified
|
||||
|
||||
@@ -2,7 +2,7 @@ import datetime
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
from enum import Enum
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Final
|
||||
@@ -11,6 +11,7 @@ import magic
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import transaction
|
||||
from django.db.models import Max
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from filelock import FileLock
|
||||
@@ -20,6 +21,7 @@ from documents.classifier import load_classifier
|
||||
from documents.data_models import ConsumableDocument
|
||||
from documents.data_models import DocumentMetadataOverrides
|
||||
from documents.file_handling import create_source_path_directory
|
||||
from documents.file_handling import generate_filename
|
||||
from documents.file_handling import generate_unique_filename
|
||||
from documents.loggers import LoggingMixin
|
||||
from documents.models import Correspondent
|
||||
@@ -81,7 +83,7 @@ class ConsumerError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ConsumerStatusShortMessage(str, Enum):
|
||||
class ConsumerStatusShortMessage(StrEnum):
|
||||
DOCUMENT_ALREADY_EXISTS = "document_already_exists"
|
||||
DOCUMENT_ALREADY_EXISTS_IN_TRASH = "document_already_exists_in_trash"
|
||||
ASN_ALREADY_EXISTS = "asn_already_exists"
|
||||
@@ -123,22 +125,6 @@ class ConsumerPluginMixin:
|
||||
|
||||
self.filename = self.metadata.filename or self.input_doc.original_file.name
|
||||
|
||||
if input_doc.root_document_id:
|
||||
self.log.debug(
|
||||
f"Document root document id: {input_doc.root_document_id}",
|
||||
)
|
||||
root_document = Document.objects.get(pk=input_doc.root_document_id)
|
||||
version_index = Document.objects.filter(root_document=root_document).count()
|
||||
filename_path = Path(self.filename)
|
||||
if filename_path.suffix:
|
||||
self.filename = str(
|
||||
filename_path.with_name(
|
||||
f"{filename_path.stem}_v{version_index}{filename_path.suffix}",
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.filename = f"{self.filename}_v{version_index}"
|
||||
|
||||
def _send_progress(
|
||||
self,
|
||||
current_progress: int,
|
||||
@@ -184,7 +170,7 @@ class ConsumerPlugin(
|
||||
):
|
||||
logging_name = LOGGING_NAME
|
||||
|
||||
def _clone_root_into_version(
|
||||
def _create_version_from_root(
|
||||
self,
|
||||
root_doc: Document,
|
||||
*,
|
||||
@@ -193,30 +179,38 @@ class ConsumerPlugin(
|
||||
mime_type: str,
|
||||
) -> Document:
|
||||
self.log.debug("Saving record for updated version to database")
|
||||
version_doc = Document.objects.get(pk=root_doc.pk)
|
||||
setattr(version_doc, "pk", None)
|
||||
version_doc.root_document = root_doc
|
||||
root_doc_frozen = Document.objects.select_for_update().get(pk=root_doc.pk)
|
||||
next_version_index = (
|
||||
Document.global_objects.filter(
|
||||
root_document_id=root_doc_frozen.pk,
|
||||
).aggregate(
|
||||
max_index=Max("version_index"),
|
||||
)["max_index"]
|
||||
or 0
|
||||
)
|
||||
file_for_checksum = (
|
||||
self.unmodified_original
|
||||
if self.unmodified_original is not None
|
||||
else self.working_copy
|
||||
)
|
||||
version_doc.checksum = hashlib.md5(
|
||||
file_for_checksum.read_bytes(),
|
||||
).hexdigest()
|
||||
version_doc.content = text or ""
|
||||
version_doc.page_count = page_count
|
||||
version_doc.mime_type = mime_type
|
||||
version_doc.original_filename = self.filename
|
||||
version_doc.storage_path = root_doc.storage_path
|
||||
# Clear unique file path fields so they can be generated uniquely later
|
||||
version_doc.filename = None
|
||||
version_doc.archive_filename = None
|
||||
version_doc.archive_checksum = None
|
||||
version_doc = Document(
|
||||
root_document=root_doc_frozen,
|
||||
version_index=next_version_index + 1,
|
||||
checksum=hashlib.md5(
|
||||
file_for_checksum.read_bytes(),
|
||||
).hexdigest(),
|
||||
content=text or "",
|
||||
page_count=page_count,
|
||||
mime_type=mime_type,
|
||||
original_filename=self.filename,
|
||||
owner_id=root_doc_frozen.owner_id,
|
||||
created=root_doc_frozen.created,
|
||||
title=root_doc_frozen.title,
|
||||
added=timezone.now(),
|
||||
modified=timezone.now(),
|
||||
)
|
||||
if self.metadata.version_label is not None:
|
||||
version_doc.version_label = self.metadata.version_label
|
||||
version_doc.added = timezone.now()
|
||||
version_doc.modified = timezone.now()
|
||||
return version_doc
|
||||
|
||||
def run_pre_consume_script(self) -> None:
|
||||
@@ -542,7 +536,7 @@ class ConsumerPlugin(
|
||||
root_doc = Document.objects.get(
|
||||
pk=self.input_doc.root_document_id,
|
||||
)
|
||||
original_document = self._clone_root_into_version(
|
||||
original_document = self._create_version_from_root(
|
||||
root_doc,
|
||||
text=text,
|
||||
page_count=page_count,
|
||||
@@ -611,7 +605,19 @@ class ConsumerPlugin(
|
||||
# After everything is in the database, copy the files into
|
||||
# place. If this fails, we'll also rollback the transaction.
|
||||
with FileLock(settings.MEDIA_LOCK):
|
||||
document.filename = generate_unique_filename(document)
|
||||
generated_filename = generate_unique_filename(document)
|
||||
if (
|
||||
len(str(generated_filename))
|
||||
> Document.MAX_STORED_FILENAME_LENGTH
|
||||
):
|
||||
self.log.warning(
|
||||
"Generated source filename exceeds db path limit, falling back to default naming",
|
||||
)
|
||||
generated_filename = generate_filename(
|
||||
document,
|
||||
use_format=False,
|
||||
)
|
||||
document.filename = generated_filename
|
||||
create_source_path_directory(document.source_path)
|
||||
|
||||
self._write(
|
||||
@@ -627,10 +633,23 @@ class ConsumerPlugin(
|
||||
)
|
||||
|
||||
if archive_path and Path(archive_path).is_file():
|
||||
document.archive_filename = generate_unique_filename(
|
||||
generated_archive_filename = generate_unique_filename(
|
||||
document,
|
||||
archive_filename=True,
|
||||
)
|
||||
if (
|
||||
len(str(generated_archive_filename))
|
||||
> Document.MAX_STORED_FILENAME_LENGTH
|
||||
):
|
||||
self.log.warning(
|
||||
"Generated archive filename exceeds db path limit, falling back to default naming",
|
||||
)
|
||||
generated_archive_filename = generate_filename(
|
||||
document,
|
||||
archive_filename=True,
|
||||
use_format=False,
|
||||
)
|
||||
document.archive_filename = generated_archive_filename
|
||||
create_source_path_directory(document.archive_path)
|
||||
self._write(
|
||||
archive_path,
|
||||
|
||||
@@ -127,23 +127,34 @@ def generate_filename(
|
||||
*,
|
||||
counter=0,
|
||||
archive_filename=False,
|
||||
use_format=True,
|
||||
) -> Path:
|
||||
# version docs use the root document for formatting, just with a suffix
|
||||
context_doc = doc if doc.root_document_id is None else doc.root_document
|
||||
version_suffix = (
|
||||
f"_v{doc.version_index}"
|
||||
if doc.root_document_id is not None and doc.version_index is not None
|
||||
else ""
|
||||
)
|
||||
base_path: Path | None = None
|
||||
|
||||
# Determine the source of the format string
|
||||
if doc.storage_path is not None:
|
||||
filename_format = doc.storage_path.path
|
||||
elif settings.FILENAME_FORMAT is not None:
|
||||
# Maybe convert old to new style
|
||||
filename_format = convert_format_str_to_template_format(
|
||||
settings.FILENAME_FORMAT,
|
||||
)
|
||||
if use_format:
|
||||
if context_doc.storage_path is not None:
|
||||
filename_format = context_doc.storage_path.path
|
||||
elif settings.FILENAME_FORMAT is not None:
|
||||
# Maybe convert old to new style
|
||||
filename_format = convert_format_str_to_template_format(
|
||||
settings.FILENAME_FORMAT,
|
||||
)
|
||||
else:
|
||||
filename_format = None
|
||||
else:
|
||||
filename_format = None
|
||||
|
||||
# If we have one, render it
|
||||
if filename_format is not None:
|
||||
rendered_path: str | None = format_filename(doc, filename_format)
|
||||
rendered_path: str | None = format_filename(context_doc, filename_format)
|
||||
if rendered_path:
|
||||
base_path = Path(rendered_path)
|
||||
|
||||
@@ -157,7 +168,7 @@ def generate_filename(
|
||||
base_filename = base_path.name
|
||||
|
||||
# Build the final filename with counter and filetype
|
||||
final_filename = f"{base_filename}{counter_str}{filetype_str}"
|
||||
final_filename = f"{base_filename}{version_suffix}{counter_str}{filetype_str}"
|
||||
|
||||
# If we have a directory component, include it
|
||||
if str(directory) != ".":
|
||||
@@ -166,7 +177,9 @@ def generate_filename(
|
||||
full_path = Path(final_filename)
|
||||
else:
|
||||
# No template, use document ID
|
||||
final_filename = f"{doc.pk:07}{counter_str}{filetype_str}"
|
||||
final_filename = (
|
||||
f"{context_doc.pk:07}{version_suffix}{counter_str}{filetype_str}"
|
||||
)
|
||||
full_path = Path(final_filename)
|
||||
|
||||
return full_path
|
||||
|
||||
@@ -5,10 +5,10 @@ import math
|
||||
import re
|
||||
from collections import Counter
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC
|
||||
from datetime import datetime
|
||||
from datetime import time
|
||||
from datetime import timedelta
|
||||
from datetime import timezone
|
||||
from shutil import rmtree
|
||||
from time import sleep
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -437,7 +437,7 @@ class ManualResults:
|
||||
class LocalDateParser(English):
|
||||
def reverse_timezone_offset(self, d):
|
||||
return (d.replace(tzinfo=django_timezone.get_current_timezone())).astimezone(
|
||||
timezone.utc,
|
||||
UTC,
|
||||
)
|
||||
|
||||
def date_from(self, *args, **kwargs):
|
||||
@@ -641,8 +641,8 @@ def rewrite_natural_date_keywords(query_string: str) -> str:
|
||||
end = datetime(local_now.year - 1, 12, 31, 23, 59, 59, tzinfo=tz)
|
||||
|
||||
# Convert to UTC and format
|
||||
start_str = start.astimezone(timezone.utc).strftime("%Y%m%d%H%M%S")
|
||||
end_str = end.astimezone(timezone.utc).strftime("%Y%m%d%H%M%S")
|
||||
start_str = start.astimezone(UTC).strftime("%Y%m%d%H%M%S")
|
||||
end_str = end.astimezone(UTC).strftime("%Y%m%d%H%M%S")
|
||||
return f"{field}:[{start_str} TO {end_str}]"
|
||||
|
||||
return re.sub(pattern, repl, query_string, flags=re.IGNORECASE)
|
||||
|
||||
@@ -151,6 +151,10 @@ class PaperlessCommand(RichCommand):
|
||||
supports_progress_bar: Adds --no-progress-bar argument (default: True)
|
||||
supports_multiprocessing: Adds --processes argument (default: False)
|
||||
|
||||
System checks are skipped by default (requires_system_checks = []) because
|
||||
these commands run post-startup where checks have already been performed by
|
||||
the application server. Subclasses that genuinely need checks can override.
|
||||
|
||||
Example usage:
|
||||
|
||||
class Command(PaperlessCommand):
|
||||
@@ -189,6 +193,8 @@ class PaperlessCommand(RichCommand):
|
||||
stats.imported += 1
|
||||
"""
|
||||
|
||||
requires_system_checks: ClassVar[list] = []
|
||||
|
||||
supports_progress_bar: ClassVar[bool] = True
|
||||
supports_multiprocessing: ClassVar[bool] = False
|
||||
|
||||
@@ -304,7 +310,7 @@ class PaperlessCommand(RichCommand):
|
||||
|
||||
Progress output is directed to stderr to match the convention that
|
||||
progress bars are transient UI feedback, not command output. This
|
||||
mirrors tqdm's default behavior and prevents progress bar rendering
|
||||
mirrors the convention that progress bars are transient UI feedback and prevents progress bar rendering
|
||||
from interfering with stdout-based assertions in tests or piped
|
||||
command output.
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ class Command(PaperlessCommand):
|
||||
"modified) after their initial import."
|
||||
)
|
||||
|
||||
supports_progress_bar = True
|
||||
supports_multiprocessing = True
|
||||
|
||||
def add_arguments(self, parser):
|
||||
|
||||
@@ -3,10 +3,10 @@ import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from itertools import islice
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import tqdm
|
||||
from allauth.mfa.models import Authenticator
|
||||
from allauth.socialaccount.models import SocialAccount
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
@@ -17,8 +17,8 @@ from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core import serializers
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core.management.base import CommandError
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from filelock import FileLock
|
||||
@@ -26,6 +26,8 @@ from guardian.models import GroupObjectPermission
|
||||
from guardian.models import UserObjectPermission
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
|
||||
from django.db.models import QuerySet
|
||||
|
||||
if settings.AUDIT_LOG_ENABLED:
|
||||
@@ -33,6 +35,7 @@ if settings.AUDIT_LOG_ENABLED:
|
||||
|
||||
from documents.file_handling import delete_empty_directories
|
||||
from documents.file_handling import generate_filename
|
||||
from documents.management.commands.base import PaperlessCommand
|
||||
from documents.management.commands.mixins import CryptMixin
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
@@ -60,14 +63,115 @@ from paperless_mail.models import MailAccount
|
||||
from paperless_mail.models import MailRule
|
||||
|
||||
|
||||
class Command(CryptMixin, BaseCommand):
|
||||
def serialize_queryset_batched(
|
||||
queryset: "QuerySet",
|
||||
*,
|
||||
batch_size: int = 500,
|
||||
) -> "Generator[list[dict], None, None]":
|
||||
"""Yield batches of serialized records from a QuerySet.
|
||||
|
||||
Each batch is a list of dicts in Django's Python serialization format.
|
||||
Uses QuerySet.iterator() to avoid loading the full queryset into memory,
|
||||
and islice to collect chunk-sized batches serialized in a single call.
|
||||
"""
|
||||
iterator = queryset.iterator(chunk_size=batch_size)
|
||||
while chunk := list(islice(iterator, batch_size)):
|
||||
yield serializers.serialize("python", chunk)
|
||||
|
||||
|
||||
class StreamingManifestWriter:
|
||||
"""Incrementally writes a JSON array to a file, one record at a time.
|
||||
|
||||
Writes to <target>.tmp first; on close(), optionally BLAKE2b-compares
|
||||
with the existing file (--compare-json) and renames or discards accordingly.
|
||||
On exception, discard() deletes the tmp file and leaves the original intact.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: Path,
|
||||
*,
|
||||
compare_json: bool = False,
|
||||
files_in_export_dir: "set[Path] | None" = None,
|
||||
) -> None:
|
||||
self._path = path.resolve()
|
||||
self._tmp_path = self._path.with_suffix(self._path.suffix + ".tmp")
|
||||
self._compare_json = compare_json
|
||||
self._files_in_export_dir: set[Path] = (
|
||||
files_in_export_dir if files_in_export_dir is not None else set()
|
||||
)
|
||||
self._file = None
|
||||
self._first = True
|
||||
|
||||
def open(self) -> None:
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._file = self._tmp_path.open("w", encoding="utf-8")
|
||||
self._file.write("[")
|
||||
self._first = True
|
||||
|
||||
def write_record(self, record: dict) -> None:
|
||||
if not self._first:
|
||||
self._file.write(",\n")
|
||||
else:
|
||||
self._first = False
|
||||
self._file.write(
|
||||
json.dumps(record, cls=DjangoJSONEncoder, indent=2, ensure_ascii=False),
|
||||
)
|
||||
|
||||
def write_batch(self, records: list[dict]) -> None:
|
||||
for record in records:
|
||||
self.write_record(record)
|
||||
|
||||
def close(self) -> None:
|
||||
if self._file is None:
|
||||
return
|
||||
self._file.write("\n]")
|
||||
self._file.close()
|
||||
self._file = None
|
||||
self._finalize()
|
||||
|
||||
def discard(self) -> None:
|
||||
if self._file is not None:
|
||||
self._file.close()
|
||||
self._file = None
|
||||
if self._tmp_path.exists():
|
||||
self._tmp_path.unlink()
|
||||
|
||||
def _finalize(self) -> None:
|
||||
"""Compare with existing file (if --compare-json) then rename or discard tmp."""
|
||||
if self._path in self._files_in_export_dir:
|
||||
self._files_in_export_dir.remove(self._path)
|
||||
if self._compare_json:
|
||||
existing_hash = hashlib.blake2b(self._path.read_bytes()).hexdigest()
|
||||
new_hash = hashlib.blake2b(self._tmp_path.read_bytes()).hexdigest()
|
||||
if existing_hash == new_hash:
|
||||
self._tmp_path.unlink()
|
||||
return
|
||||
self._tmp_path.rename(self._path)
|
||||
|
||||
def __enter__(self) -> "StreamingManifestWriter":
|
||||
self.open()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
if exc_type is not None:
|
||||
self.discard()
|
||||
else:
|
||||
self.close()
|
||||
|
||||
|
||||
class Command(CryptMixin, PaperlessCommand):
|
||||
help = (
|
||||
"Decrypt and rename all files in our collection into a given target "
|
||||
"directory. And include a manifest file containing document data for "
|
||||
"easy import."
|
||||
)
|
||||
|
||||
supports_progress_bar = True
|
||||
supports_multiprocessing = False
|
||||
|
||||
def add_arguments(self, parser) -> None:
|
||||
super().add_arguments(parser)
|
||||
parser.add_argument("target")
|
||||
|
||||
parser.add_argument(
|
||||
@@ -175,15 +279,19 @@ class Command(CryptMixin, BaseCommand):
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--no-progress-bar",
|
||||
default=False,
|
||||
action="store_true",
|
||||
help="If set, the progress bar will not be shown",
|
||||
"--passphrase",
|
||||
help="If provided, is used to encrypt sensitive data in the export",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--passphrase",
|
||||
help="If provided, is used to encrypt sensitive data in the export",
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=500,
|
||||
help=(
|
||||
"Number of records to process per batch during serialization. "
|
||||
"Lower values reduce peak memory usage; higher values improve "
|
||||
"throughput. Default: 500."
|
||||
),
|
||||
)
|
||||
|
||||
def handle(self, *args, **options) -> None:
|
||||
@@ -198,8 +306,8 @@ class Command(CryptMixin, BaseCommand):
|
||||
self.no_thumbnail: bool = options["no_thumbnail"]
|
||||
self.zip_export: bool = options["zip"]
|
||||
self.data_only: bool = options["data_only"]
|
||||
self.no_progress_bar: bool = options["no_progress_bar"]
|
||||
self.passphrase: str | None = options.get("passphrase")
|
||||
self.batch_size: int = options["batch_size"]
|
||||
|
||||
self.files_in_export_dir: set[Path] = set()
|
||||
self.exported_files: set[str] = set()
|
||||
@@ -289,90 +397,85 @@ class Command(CryptMixin, BaseCommand):
|
||||
if settings.AUDIT_LOG_ENABLED:
|
||||
manifest_key_to_object_query["log_entries"] = LogEntry.objects.all()
|
||||
|
||||
with transaction.atomic():
|
||||
manifest_dict = {}
|
||||
|
||||
# Build an overall manifest
|
||||
for key, object_query in manifest_key_to_object_query.items():
|
||||
manifest_dict[key] = json.loads(
|
||||
serializers.serialize("json", object_query),
|
||||
)
|
||||
|
||||
self.encrypt_secret_fields(manifest_dict)
|
||||
|
||||
# These are treated specially and included in the per-document manifest
|
||||
# if that setting is enabled. Otherwise, they are just exported to the bulk
|
||||
# manifest
|
||||
document_map: dict[int, Document] = {
|
||||
d.pk: d for d in manifest_key_to_object_query["documents"]
|
||||
}
|
||||
document_manifest = manifest_dict["documents"]
|
||||
|
||||
# 3. Export files from each document
|
||||
for index, document_dict in tqdm.tqdm(
|
||||
enumerate(document_manifest),
|
||||
total=len(document_manifest),
|
||||
disable=self.no_progress_bar,
|
||||
):
|
||||
document = document_map[document_dict["pk"]]
|
||||
|
||||
# 3.1. generate a unique filename
|
||||
base_name = self.generate_base_name(document)
|
||||
|
||||
# 3.2. write filenames into manifest
|
||||
original_target, thumbnail_target, archive_target = (
|
||||
self.generate_document_targets(document, base_name, document_dict)
|
||||
# Crypto setup before streaming begins
|
||||
if self.passphrase:
|
||||
self.setup_crypto(passphrase=self.passphrase)
|
||||
elif MailAccount.objects.count() > 0 or SocialToken.objects.count() > 0:
|
||||
self.stdout.write(
|
||||
self.style.NOTICE(
|
||||
"No passphrase was given, sensitive fields will be in plaintext",
|
||||
),
|
||||
)
|
||||
|
||||
# 3.3. write files to target folder
|
||||
if not self.data_only:
|
||||
self.copy_document_files(
|
||||
document,
|
||||
original_target,
|
||||
thumbnail_target,
|
||||
archive_target,
|
||||
)
|
||||
|
||||
if self.split_manifest:
|
||||
manifest_name = base_name.with_name(f"{base_name.stem}-manifest.json")
|
||||
if self.use_folder_prefix:
|
||||
manifest_name = Path("json") / manifest_name
|
||||
manifest_name = (self.target / manifest_name).resolve()
|
||||
manifest_name.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = [document_manifest[index]]
|
||||
content += list(
|
||||
filter(
|
||||
lambda d: d["fields"]["document"] == document_dict["pk"],
|
||||
manifest_dict["notes"],
|
||||
),
|
||||
)
|
||||
content += list(
|
||||
filter(
|
||||
lambda d: d["fields"]["document"] == document_dict["pk"],
|
||||
manifest_dict["custom_field_instances"],
|
||||
),
|
||||
)
|
||||
|
||||
self.check_and_write_json(
|
||||
content,
|
||||
manifest_name,
|
||||
)
|
||||
|
||||
# These were exported already
|
||||
if self.split_manifest:
|
||||
del manifest_dict["documents"]
|
||||
del manifest_dict["notes"]
|
||||
del manifest_dict["custom_field_instances"]
|
||||
|
||||
# 4.1 write primary manifest to target folder
|
||||
manifest = []
|
||||
for key, item in manifest_dict.items():
|
||||
manifest.extend(item)
|
||||
document_manifest: list[dict] = []
|
||||
manifest_path = (self.target / "manifest.json").resolve()
|
||||
self.check_and_write_json(
|
||||
manifest,
|
||||
|
||||
with StreamingManifestWriter(
|
||||
manifest_path,
|
||||
)
|
||||
compare_json=self.compare_json,
|
||||
files_in_export_dir=self.files_in_export_dir,
|
||||
) as writer:
|
||||
with transaction.atomic():
|
||||
for key, qs in manifest_key_to_object_query.items():
|
||||
if key == "documents":
|
||||
# Accumulate for file-copy loop; written to manifest after
|
||||
for batch in serialize_queryset_batched(
|
||||
qs,
|
||||
batch_size=self.batch_size,
|
||||
):
|
||||
for record in batch:
|
||||
self._encrypt_record_inline(record)
|
||||
document_manifest.extend(batch)
|
||||
elif self.split_manifest and key in (
|
||||
"notes",
|
||||
"custom_field_instances",
|
||||
):
|
||||
# Written per-document in _write_split_manifest
|
||||
pass
|
||||
else:
|
||||
for batch in serialize_queryset_batched(
|
||||
qs,
|
||||
batch_size=self.batch_size,
|
||||
):
|
||||
for record in batch:
|
||||
self._encrypt_record_inline(record)
|
||||
writer.write_batch(batch)
|
||||
|
||||
document_map: dict[int, Document] = {
|
||||
d.pk: d for d in Document.objects.order_by("id")
|
||||
}
|
||||
|
||||
# 3. Export files from each document
|
||||
for index, document_dict in enumerate(
|
||||
self.track(
|
||||
document_manifest,
|
||||
description="Exporting documents...",
|
||||
total=len(document_manifest),
|
||||
),
|
||||
):
|
||||
document = document_map[document_dict["pk"]]
|
||||
|
||||
# 3.1. generate a unique filename
|
||||
base_name = self.generate_base_name(document)
|
||||
|
||||
# 3.2. write filenames into manifest
|
||||
original_target, thumbnail_target, archive_target = (
|
||||
self.generate_document_targets(document, base_name, document_dict)
|
||||
)
|
||||
|
||||
# 3.3. write files to target folder
|
||||
if not self.data_only:
|
||||
self.copy_document_files(
|
||||
document,
|
||||
original_target,
|
||||
thumbnail_target,
|
||||
archive_target,
|
||||
)
|
||||
|
||||
if self.split_manifest:
|
||||
self._write_split_manifest(document_dict, document, base_name)
|
||||
else:
|
||||
writer.write_record(document_dict)
|
||||
|
||||
# 4.2 write version information to target folder
|
||||
extra_metadata_path = (self.target / "metadata.json").resolve()
|
||||
@@ -494,6 +597,42 @@ class Command(CryptMixin, BaseCommand):
|
||||
archive_target,
|
||||
)
|
||||
|
||||
def _encrypt_record_inline(self, record: dict) -> None:
|
||||
"""Encrypt sensitive fields in a single record, if passphrase is set."""
|
||||
if not self.passphrase:
|
||||
return
|
||||
fields = self.CRYPT_FIELDS_BY_MODEL.get(record.get("model", ""))
|
||||
if fields:
|
||||
for field in fields:
|
||||
if record["fields"].get(field):
|
||||
record["fields"][field] = self.encrypt_string(
|
||||
value=record["fields"][field],
|
||||
)
|
||||
|
||||
def _write_split_manifest(
|
||||
self,
|
||||
document_dict: dict,
|
||||
document: Document,
|
||||
base_name: Path,
|
||||
) -> None:
|
||||
"""Write per-document manifest file for --split-manifest mode."""
|
||||
content = [document_dict]
|
||||
content.extend(
|
||||
serializers.serialize("python", Note.objects.filter(document=document)),
|
||||
)
|
||||
content.extend(
|
||||
serializers.serialize(
|
||||
"python",
|
||||
CustomFieldInstance.objects.filter(document=document),
|
||||
),
|
||||
)
|
||||
manifest_name = base_name.with_name(f"{base_name.stem}-manifest.json")
|
||||
if self.use_folder_prefix:
|
||||
manifest_name = Path("json") / manifest_name
|
||||
manifest_name = (self.target / manifest_name).resolve()
|
||||
manifest_name.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.check_and_write_json(content, manifest_name)
|
||||
|
||||
def check_and_write_json(
|
||||
self,
|
||||
content: list[dict] | dict,
|
||||
@@ -511,15 +650,25 @@ class Command(CryptMixin, BaseCommand):
|
||||
if target in self.files_in_export_dir:
|
||||
self.files_in_export_dir.remove(target)
|
||||
if self.compare_json:
|
||||
target_checksum = hashlib.md5(target.read_bytes()).hexdigest()
|
||||
src_str = json.dumps(content, indent=2, ensure_ascii=False)
|
||||
src_checksum = hashlib.md5(src_str.encode("utf-8")).hexdigest()
|
||||
target_checksum = hashlib.blake2b(target.read_bytes()).hexdigest()
|
||||
src_str = json.dumps(
|
||||
content,
|
||||
cls=DjangoJSONEncoder,
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
src_checksum = hashlib.blake2b(src_str.encode("utf-8")).hexdigest()
|
||||
if src_checksum == target_checksum:
|
||||
perform_write = False
|
||||
|
||||
if perform_write:
|
||||
target.write_text(
|
||||
json.dumps(content, indent=2, ensure_ascii=False),
|
||||
json.dumps(
|
||||
content,
|
||||
cls=DjangoJSONEncoder,
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
@@ -558,28 +707,3 @@ class Command(CryptMixin, BaseCommand):
|
||||
if perform_copy:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
copy_file_with_basic_stats(source, target)
|
||||
|
||||
def encrypt_secret_fields(self, manifest: dict) -> None:
|
||||
"""
|
||||
Encrypts certain fields in the export. Currently limited to the mail account password
|
||||
"""
|
||||
|
||||
if self.passphrase:
|
||||
self.setup_crypto(passphrase=self.passphrase)
|
||||
|
||||
for crypt_config in self.CRYPT_FIELDS:
|
||||
exporter_key = crypt_config["exporter_key"]
|
||||
crypt_fields = crypt_config["fields"]
|
||||
for manifest_record in manifest[exporter_key]:
|
||||
for field in crypt_fields:
|
||||
if manifest_record["fields"][field]:
|
||||
manifest_record["fields"][field] = self.encrypt_string(
|
||||
value=manifest_record["fields"][field],
|
||||
)
|
||||
|
||||
elif MailAccount.objects.count() > 0 or SocialToken.objects.count() > 0:
|
||||
self.stdout.write(
|
||||
self.style.NOTICE(
|
||||
"No passphrase was given, sensitive fields will be in plaintext",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -40,6 +40,7 @@ def _process_and_match(work: _WorkPackage) -> _WorkResult:
|
||||
class Command(PaperlessCommand):
|
||||
help = "Searches for documents where the content almost matches"
|
||||
|
||||
supports_progress_bar = True
|
||||
supports_multiprocessing = True
|
||||
|
||||
def add_arguments(self, parser):
|
||||
|
||||
@@ -8,14 +8,13 @@ from pathlib import Path
|
||||
from zipfile import ZipFile
|
||||
from zipfile import is_zipfile
|
||||
|
||||
import tqdm
|
||||
import ijson
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import FieldDoesNotExist
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core.management.base import CommandError
|
||||
from django.core.serializers.base import DeserializationError
|
||||
from django.db import IntegrityError
|
||||
@@ -25,6 +24,7 @@ from django.db.models.signals import post_save
|
||||
from filelock import FileLock
|
||||
|
||||
from documents.file_handling import create_source_path_directory
|
||||
from documents.management.commands.base import PaperlessCommand
|
||||
from documents.management.commands.mixins import CryptMixin
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
@@ -33,7 +33,6 @@ from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import Note
|
||||
from documents.models import Tag
|
||||
from documents.parsers import run_convert
|
||||
from documents.settings import EXPORTER_ARCHIVE_NAME
|
||||
from documents.settings import EXPORTER_CRYPTO_SETTINGS_NAME
|
||||
from documents.settings import EXPORTER_FILE_NAME
|
||||
@@ -47,6 +46,15 @@ if settings.AUDIT_LOG_ENABLED:
|
||||
from auditlog.registry import auditlog
|
||||
|
||||
|
||||
def iter_manifest_records(path: Path) -> Generator[dict, None, None]:
|
||||
"""Yield records one at a time from a manifest JSON array via ijson."""
|
||||
try:
|
||||
with path.open("rb") as f:
|
||||
yield from ijson.items(f, "item")
|
||||
except ijson.JSONError as e:
|
||||
raise CommandError(f"Failed to parse manifest file {path}: {e}") from e
|
||||
|
||||
|
||||
@contextmanager
|
||||
def disable_signal(sig, receiver, sender, *, weak: bool | None = None) -> Generator:
|
||||
try:
|
||||
@@ -57,21 +65,18 @@ def disable_signal(sig, receiver, sender, *, weak: bool | None = None) -> Genera
|
||||
sig.connect(receiver=receiver, sender=sender, **kwargs)
|
||||
|
||||
|
||||
class Command(CryptMixin, BaseCommand):
|
||||
class Command(CryptMixin, PaperlessCommand):
|
||||
help = (
|
||||
"Using a manifest.json file, load the data from there, and import the "
|
||||
"documents it refers to."
|
||||
)
|
||||
|
||||
def add_arguments(self, parser) -> None:
|
||||
parser.add_argument("source")
|
||||
supports_progress_bar = True
|
||||
supports_multiprocessing = False
|
||||
|
||||
parser.add_argument(
|
||||
"--no-progress-bar",
|
||||
default=False,
|
||||
action="store_true",
|
||||
help="If set, the progress bar will not be shown",
|
||||
)
|
||||
def add_arguments(self, parser) -> None:
|
||||
super().add_arguments(parser)
|
||||
parser.add_argument("source")
|
||||
|
||||
parser.add_argument(
|
||||
"--data-only",
|
||||
@@ -147,14 +152,9 @@ class Command(CryptMixin, BaseCommand):
|
||||
Loads manifest data from the various JSON files for parsing and loading the database
|
||||
"""
|
||||
main_manifest_path: Path = self.source / "manifest.json"
|
||||
|
||||
with main_manifest_path.open() as infile:
|
||||
self.manifest = json.load(infile)
|
||||
self.manifest_paths.append(main_manifest_path)
|
||||
|
||||
for file in Path(self.source).glob("**/*-manifest.json"):
|
||||
with file.open() as infile:
|
||||
self.manifest += json.load(infile)
|
||||
self.manifest_paths.append(file)
|
||||
|
||||
def load_metadata(self) -> None:
|
||||
@@ -205,7 +205,7 @@ class Command(CryptMixin, BaseCommand):
|
||||
ContentType.objects.all().delete()
|
||||
Permission.objects.all().delete()
|
||||
for manifest_path in self.manifest_paths:
|
||||
call_command("loaddata", manifest_path)
|
||||
call_command("loaddata", manifest_path, skip_checks=True)
|
||||
except (FieldDoesNotExist, DeserializationError, IntegrityError) as e:
|
||||
self.stdout.write(self.style.ERROR("Database import failed"))
|
||||
if (
|
||||
@@ -231,12 +231,10 @@ class Command(CryptMixin, BaseCommand):
|
||||
|
||||
self.source = Path(options["source"]).resolve()
|
||||
self.data_only: bool = options["data_only"]
|
||||
self.no_progress_bar: bool = options["no_progress_bar"]
|
||||
self.passphrase: str | None = options.get("passphrase")
|
||||
self.version: str | None = None
|
||||
self.salt: str | None = None
|
||||
self.manifest_paths = []
|
||||
self.manifest = []
|
||||
|
||||
# Create a temporary directory for extracting a zip file into it, even if supplied source is no zip file to keep code cleaner.
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
@@ -296,6 +294,9 @@ class Command(CryptMixin, BaseCommand):
|
||||
else:
|
||||
self.stdout.write(self.style.NOTICE("Data only import completed"))
|
||||
|
||||
for tmp in getattr(self, "_decrypted_tmp_paths", []):
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
self.stdout.write("Updating search index...")
|
||||
call_command(
|
||||
"document_index",
|
||||
@@ -348,11 +349,12 @@ class Command(CryptMixin, BaseCommand):
|
||||
) from e
|
||||
|
||||
self.stdout.write("Checking the manifest")
|
||||
for record in self.manifest:
|
||||
# Only check if the document files exist if this is not data only
|
||||
# We don't care about documents for a data only import
|
||||
if not self.data_only and record["model"] == "documents.document":
|
||||
check_document_validity(record)
|
||||
for manifest_path in self.manifest_paths:
|
||||
for record in iter_manifest_records(manifest_path):
|
||||
# Only check if the document files exist if this is not data only
|
||||
# We don't care about documents for a data only import
|
||||
if not self.data_only and record["model"] == "documents.document":
|
||||
check_document_validity(record)
|
||||
|
||||
def _import_files_from_manifest(self) -> None:
|
||||
settings.ORIGINALS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
@@ -361,23 +363,31 @@ class Command(CryptMixin, BaseCommand):
|
||||
|
||||
self.stdout.write("Copy files into paperless...")
|
||||
|
||||
manifest_documents = list(
|
||||
filter(lambda r: r["model"] == "documents.document", self.manifest),
|
||||
)
|
||||
document_records = [
|
||||
{
|
||||
"pk": record["pk"],
|
||||
EXPORTER_FILE_NAME: record[EXPORTER_FILE_NAME],
|
||||
EXPORTER_THUMBNAIL_NAME: record.get(EXPORTER_THUMBNAIL_NAME),
|
||||
EXPORTER_ARCHIVE_NAME: record.get(EXPORTER_ARCHIVE_NAME),
|
||||
}
|
||||
for manifest_path in self.manifest_paths
|
||||
for record in iter_manifest_records(manifest_path)
|
||||
if record["model"] == "documents.document"
|
||||
]
|
||||
|
||||
for record in tqdm.tqdm(manifest_documents, disable=self.no_progress_bar):
|
||||
for record in self.track(document_records, description="Copying files..."):
|
||||
document = Document.objects.get(pk=record["pk"])
|
||||
|
||||
doc_file = record[EXPORTER_FILE_NAME]
|
||||
document_path = self.source / doc_file
|
||||
|
||||
if EXPORTER_THUMBNAIL_NAME in record:
|
||||
if record[EXPORTER_THUMBNAIL_NAME]:
|
||||
thumb_file = record[EXPORTER_THUMBNAIL_NAME]
|
||||
thumbnail_path = (self.source / thumb_file).resolve()
|
||||
else:
|
||||
thumbnail_path = None
|
||||
|
||||
if EXPORTER_ARCHIVE_NAME in record:
|
||||
if record[EXPORTER_ARCHIVE_NAME]:
|
||||
archive_file = record[EXPORTER_ARCHIVE_NAME]
|
||||
archive_path = self.source / archive_file
|
||||
else:
|
||||
@@ -392,22 +402,10 @@ class Command(CryptMixin, BaseCommand):
|
||||
copy_file_with_basic_stats(document_path, document.source_path)
|
||||
|
||||
if thumbnail_path:
|
||||
if thumbnail_path.suffix in {".png", ".PNG"}:
|
||||
run_convert(
|
||||
density=300,
|
||||
scale="500x5000>",
|
||||
alpha="remove",
|
||||
strip=True,
|
||||
trim=False,
|
||||
auto_orient=True,
|
||||
input_file=f"{thumbnail_path}[0]",
|
||||
output_file=str(document.thumbnail_path),
|
||||
)
|
||||
else:
|
||||
copy_file_with_basic_stats(
|
||||
thumbnail_path,
|
||||
document.thumbnail_path,
|
||||
)
|
||||
copy_file_with_basic_stats(
|
||||
thumbnail_path,
|
||||
document.thumbnail_path,
|
||||
)
|
||||
|
||||
if archive_path:
|
||||
create_source_path_directory(document.archive_path)
|
||||
@@ -418,33 +416,43 @@ class Command(CryptMixin, BaseCommand):
|
||||
|
||||
document.save()
|
||||
|
||||
def _decrypt_record_if_needed(self, record: dict) -> dict:
|
||||
fields = self.CRYPT_FIELDS_BY_MODEL.get(record.get("model", ""))
|
||||
if fields:
|
||||
for field in fields:
|
||||
if record["fields"].get(field):
|
||||
record["fields"][field] = self.decrypt_string(
|
||||
value=record["fields"][field],
|
||||
)
|
||||
return record
|
||||
|
||||
def decrypt_secret_fields(self) -> None:
|
||||
"""
|
||||
The converse decryption of some fields out of the export before importing to database
|
||||
The converse decryption of some fields out of the export before importing to database.
|
||||
Streams records from each manifest path and writes decrypted content to a temp file.
|
||||
"""
|
||||
if self.passphrase:
|
||||
# Salt has been loaded from metadata.json at this point, so it cannot be None
|
||||
self.setup_crypto(passphrase=self.passphrase, salt=self.salt)
|
||||
|
||||
had_at_least_one_record = False
|
||||
|
||||
for crypt_config in self.CRYPT_FIELDS:
|
||||
importer_model: str = crypt_config["model_name"]
|
||||
crypt_fields: str = crypt_config["fields"]
|
||||
for record in filter(
|
||||
lambda x: x["model"] == importer_model,
|
||||
self.manifest,
|
||||
):
|
||||
had_at_least_one_record = True
|
||||
for field in crypt_fields:
|
||||
if record["fields"][field]:
|
||||
record["fields"][field] = self.decrypt_string(
|
||||
value=record["fields"][field],
|
||||
)
|
||||
|
||||
if had_at_least_one_record:
|
||||
# It's annoying, but the DB is loaded from the JSON directly
|
||||
# Maybe could change that in the future?
|
||||
(self.source / "manifest.json").write_text(
|
||||
json.dumps(self.manifest, indent=2, ensure_ascii=False),
|
||||
)
|
||||
if not self.passphrase:
|
||||
return
|
||||
# Salt has been loaded from metadata.json at this point, so it cannot be None
|
||||
self.setup_crypto(passphrase=self.passphrase, salt=self.salt)
|
||||
self._decrypted_tmp_paths: list[Path] = []
|
||||
new_paths: list[Path] = []
|
||||
for manifest_path in self.manifest_paths:
|
||||
tmp = manifest_path.with_name(manifest_path.stem + ".decrypted.json")
|
||||
with tmp.open("w", encoding="utf-8") as out:
|
||||
out.write("[\n")
|
||||
first = True
|
||||
for record in iter_manifest_records(manifest_path):
|
||||
if not first:
|
||||
out.write(",\n")
|
||||
json.dump(
|
||||
self._decrypt_record_if_needed(record),
|
||||
out,
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
first = False
|
||||
out.write("\n]\n")
|
||||
self._decrypted_tmp_paths.append(tmp)
|
||||
new_paths.append(tmp)
|
||||
self.manifest_paths = new_paths
|
||||
|
||||
@@ -8,6 +8,9 @@ from documents.tasks import index_reindex
|
||||
class Command(PaperlessCommand):
|
||||
help = "Manages the document index."
|
||||
|
||||
supports_progress_bar = True
|
||||
supports_multiprocessing = False
|
||||
|
||||
def add_arguments(self, parser):
|
||||
super().add_arguments(parser)
|
||||
parser.add_argument("command", choices=["reindex", "optimize"])
|
||||
|
||||
@@ -7,6 +7,9 @@ from documents.tasks import llmindex_index
|
||||
class Command(PaperlessCommand):
|
||||
help = "Manages the LLM-based vector index for Paperless."
|
||||
|
||||
supports_progress_bar = True
|
||||
supports_multiprocessing = False
|
||||
|
||||
def add_arguments(self, parser: Any) -> None:
|
||||
super().add_arguments(parser)
|
||||
parser.add_argument("command", choices=["rebuild", "update"])
|
||||
|
||||
@@ -7,6 +7,9 @@ from documents.models import Document
|
||||
class Command(PaperlessCommand):
|
||||
help = "Rename all documents"
|
||||
|
||||
supports_progress_bar = True
|
||||
supports_multiprocessing = False
|
||||
|
||||
def handle(self, *args, **options):
|
||||
for document in self.track(Document.objects.all(), description="Renaming..."):
|
||||
post_save.send(Document, instance=document, created=False)
|
||||
|
||||
@@ -180,6 +180,9 @@ class Command(PaperlessCommand):
|
||||
"modified) after their initial import."
|
||||
)
|
||||
|
||||
supports_progress_bar = True
|
||||
supports_multiprocessing = False
|
||||
|
||||
def add_arguments(self, parser) -> None:
|
||||
super().add_arguments(parser)
|
||||
parser.add_argument("-c", "--correspondent", default=False, action="store_true")
|
||||
|
||||
@@ -24,6 +24,9 @@ _LEVEL_STYLE: dict[int, tuple[str, str]] = {
|
||||
class Command(PaperlessCommand):
|
||||
help = "This command checks your document archive for issues."
|
||||
|
||||
supports_progress_bar = True
|
||||
supports_multiprocessing = False
|
||||
|
||||
def _render_results(self, messages: SanityCheckMessages) -> None:
|
||||
"""Render sanity check results as a Rich table."""
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ def _process_document(doc_id: int) -> None:
|
||||
class Command(PaperlessCommand):
|
||||
help = "This will regenerate the thumbnails for all documents."
|
||||
|
||||
supports_progress_bar = True
|
||||
supports_multiprocessing = True
|
||||
|
||||
def add_arguments(self, parser) -> None:
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import sys
|
||||
|
||||
from django.core.management.commands.loaddata import Command as LoadDataCommand
|
||||
|
||||
|
||||
# This class is used to migrate data between databases
|
||||
# That's difficult to test
|
||||
class Command(LoadDataCommand): # pragma: no cover
|
||||
"""
|
||||
Allow the loading of data from standard in. Sourced originally from:
|
||||
https://gist.github.com/bmispelon/ad5a2c333443b3a1d051 (MIT licensed)
|
||||
"""
|
||||
|
||||
def parse_name(self, fixture_name):
|
||||
self.compression_formats["stdin"] = (lambda x, y: sys.stdin, None)
|
||||
if fixture_name == "-":
|
||||
return "-", "json", "stdin"
|
||||
|
||||
def find_fixtures(self, fixture_label):
|
||||
if fixture_label == "-":
|
||||
return [("-", None, "-")]
|
||||
return super().find_fixtures(fixture_label)
|
||||
@@ -1,6 +1,5 @@
|
||||
import base64
|
||||
import os
|
||||
from argparse import ArgumentParser
|
||||
from typing import TypedDict
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
@@ -21,25 +20,6 @@ class CryptFields(TypedDict):
|
||||
fields: list[str]
|
||||
|
||||
|
||||
class ProgressBarMixin:
|
||||
"""
|
||||
Many commands use a progress bar, which can be disabled
|
||||
via this class
|
||||
"""
|
||||
|
||||
def add_argument_progress_bar_mixin(self, parser: ArgumentParser) -> None:
|
||||
parser.add_argument(
|
||||
"--no-progress-bar",
|
||||
default=False,
|
||||
action="store_true",
|
||||
help="If set, the progress bar will not be shown",
|
||||
)
|
||||
|
||||
def handle_progress_bar_mixin(self, *args, **options) -> None:
|
||||
self.no_progress_bar = options["no_progress_bar"]
|
||||
self.use_progress_bar = not self.no_progress_bar
|
||||
|
||||
|
||||
class CryptMixin:
|
||||
"""
|
||||
Fully based on:
|
||||
@@ -71,7 +51,7 @@ class CryptMixin:
|
||||
key_size = 32
|
||||
kdf_algorithm = "pbkdf2_sha256"
|
||||
|
||||
CRYPT_FIELDS: CryptFields = [
|
||||
CRYPT_FIELDS: list[CryptFields] = [
|
||||
{
|
||||
"exporter_key": "mail_accounts",
|
||||
"model_name": "paperless_mail.mailaccount",
|
||||
@@ -89,6 +69,10 @@ class CryptMixin:
|
||||
],
|
||||
},
|
||||
]
|
||||
# O(1) lookup for per-record encryption; derived from CRYPT_FIELDS at class definition time
|
||||
CRYPT_FIELDS_BY_MODEL: dict[str, list[str]] = {
|
||||
cfg["model_name"]: cfg["fields"] for cfg in CRYPT_FIELDS
|
||||
}
|
||||
|
||||
def get_crypt_params(self) -> dict[str, dict[str, str | int]]:
|
||||
return {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user