Compare commits

..

5 Commits

Author SHA1 Message Date
shamoon
ddce646c7e Move migration again 2026-03-13 07:41:31 -07:00
shamoon
27bc96806f Add to diagram 2026-03-13 07:19:52 -07:00
shamoon
dde52a6bc2 Move migration 2026-03-13 07:19:51 -07:00
shamoon
a5355010b0 make it a separate one 2026-03-13 07:19:51 -07:00
shamoon
8d3bb2ebea Save this 2026-03-13 07:19:51 -07:00
300 changed files with 8011 additions and 17127 deletions

View File

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

View File

@@ -157,9 +157,6 @@ updates:
postgres:
patterns:
- "docker.io/library/postgres*"
greenmail:
patterns:
- "docker.io/greenmail*"
- package-ecosystem: "pre-commit" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:

View File

@@ -21,7 +21,7 @@ jobs:
backend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.backend == 'true' }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Decide run mode
@@ -49,7 +49,7 @@ jobs:
- name: Detect changes
id: filter
if: steps.force.outputs.run_all != 'true'
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
uses: dorny/paths-filter@v3.0.2
with:
base: ${{ steps.range.outputs.base }}
ref: ${{ steps.range.outputs.ref }}
@@ -71,18 +71,18 @@ jobs:
fail-fast: false
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@v6.0.2
- name: Start containers
run: |
docker compose --file docker/compose/docker-compose.ci-test.yml pull --quiet
docker compose --file docker/compose/docker-compose.ci-test.yml up --detach
- name: Set up Python
id: setup-python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@v6.2.0
with:
python-version: "${{ matrix.python-version }}"
- name: Install uv
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
uses: astral-sh/setup-uv@v7.3.1
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
@@ -119,13 +119,13 @@ jobs:
pytest
- name: Upload test results to Codecov
if: always()
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
uses: codecov/codecov-action@v5.5.2
with:
flags: backend-python-${{ matrix.python-version }}
files: junit.xml
report_type: test_results
- name: Upload coverage to Codecov
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
uses: codecov/codecov-action@v5.5.2
with:
flags: backend-python-${{ matrix.python-version }}
files: coverage.xml
@@ -144,14 +144,14 @@ jobs:
DEFAULT_PYTHON: "3.12"
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@v6.0.2
- name: Set up Python
id: setup-python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@v6.2.0
with:
python-version: "${{ env.DEFAULT_PYTHON }}"
- name: Install uv
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
uses: astral-sh/setup-uv@v7.3.1
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
@@ -173,7 +173,7 @@ jobs:
check \
src/
- name: Cache Mypy
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@v5.0.3
with:
path: .mypy_cache
# Keyed by OS, Python version, and dependency hashes

View File

@@ -41,7 +41,7 @@ jobs:
ref-name: ${{ steps.ref.outputs.name }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@v6.0.2
- name: Determine ref name
id: ref
run: |
@@ -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@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@v4.0.0
- name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@v4.0.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -119,7 +119,7 @@ jobs:
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
- name: Docker metadata
id: docker-meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@v5.10.0
with:
images: |
${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}
@@ -130,7 +130,7 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
- name: Build and push by digest
id: build
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@v6.19.2
with:
context: .
file: ./Dockerfile
@@ -152,7 +152,7 @@ jobs:
echo "${digest}" > "/tmp/digests/digest-${{ matrix.arch }}.txt"
- name: Upload digest
if: steps.check-push.outputs.should-push == 'true'
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@v7.0.0
with:
name: digests-${{ matrix.arch }}
path: /tmp/digests/digest-${{ matrix.arch }}.txt
@@ -169,7 +169,7 @@ jobs:
packages: write
steps:
- name: Download digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@v8.0.0
with:
path: /tmp/digests
pattern: digest-*.txt
@@ -179,29 +179,29 @@ jobs:
echo "Downloaded digests:"
ls -la /tmp/digests/
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@v4.0.0
- name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.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@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.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@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@v4.0.0
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
password: ${{ secrets.QUAY_ROBOT_TOKEN }}
- name: Docker metadata
id: docker-meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@v5.10.0
with:
images: |
${{ env.REGISTRY }}/${{ needs.build-arch.outputs.repository }}

View File

@@ -23,7 +23,7 @@ jobs:
docs_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.docs == 'true' }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Decide run mode
@@ -51,7 +51,7 @@ jobs:
- name: Detect changes
id: filter
if: steps.force.outputs.run_all != 'true'
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
uses: dorny/paths-filter@v3.0.2
with:
base: ${{ steps.range.outputs.base }}
ref: ${{ steps.range.outputs.ref }}
@@ -68,16 +68,16 @@ jobs:
name: Build Documentation
runs-on: ubuntu-24.04
steps:
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- uses: actions/configure-pages@v5.0.0
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@v6.0.2
- name: Set up Python
id: setup-python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@v6.2.0
with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
uses: astral-sh/setup-uv@v7.3.1
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
@@ -93,7 +93,7 @@ jobs:
--frozen \
zensical build --clean
- name: Upload GitHub Pages artifact
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0
uses: actions/upload-pages-artifact@v4.0.0
with:
path: site
name: github-pages-${{ github.run_id }}-${{ github.run_attempt }}
@@ -107,7 +107,7 @@ jobs:
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy GitHub Pages
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
uses: actions/deploy-pages@v4.0.5
id: deployment
with:
artifact_name: github-pages-${{ github.run_id }}-${{ github.run_attempt }}

View File

@@ -18,7 +18,7 @@ jobs:
frontend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.frontend == 'true' }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Decide run mode
@@ -46,7 +46,7 @@ jobs:
- name: Detect changes
id: filter
if: steps.force.outputs.run_all != 'true'
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
uses: dorny/paths-filter@v3.0.2
with:
base: ${{ steps.range.outputs.base }}
ref: ${{ steps.range.outputs.ref }}
@@ -61,20 +61,20 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@v6.0.2
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
uses: pnpm/action-setup@v4.2.0
with:
version: 10
- name: Use Node.js 24
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@v6.3.0
with:
node-version: 24.x
cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies
id: cache-frontend-deps
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@v5.0.3
with:
path: |
~/.pnpm-store
@@ -89,19 +89,19 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@v6.0.2
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
uses: pnpm/action-setup@v4.2.0
with:
version: 10
- name: Use Node.js 24
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@v6.3.0
with:
node-version: 24.x
cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@v5.0.3
with:
path: |
~/.pnpm-store
@@ -124,19 +124,19 @@ jobs:
shard-count: [4]
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@v6.0.2
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
uses: pnpm/action-setup@v4.2.0
with:
version: 10
- name: Use Node.js 24
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@v6.3.0
with:
node-version: 24.x
cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@v5.0.3
with:
path: |
~/.pnpm-store
@@ -148,13 +148,13 @@ jobs:
run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
- name: Upload test results to Codecov
if: always()
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
uses: codecov/codecov-action@v5.5.2
with:
flags: frontend-node-${{ matrix.node-version }}
directory: src-ui/
report_type: test_results
- name: Upload coverage to Codecov
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
uses: codecov/codecov-action@v5.5.2
with:
flags: frontend-node-${{ matrix.node-version }}
directory: src-ui/coverage/
@@ -175,19 +175,19 @@ jobs:
shard-count: [2]
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@v6.0.2
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
uses: pnpm/action-setup@v4.2.0
with:
version: 10
- name: Use Node.js 24
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@v6.3.0
with:
node-version: 24.x
cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@v5.0.3
with:
path: |
~/.pnpm-store
@@ -206,21 +206,21 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@v6.0.2
with:
fetch-depth: 2
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
uses: pnpm/action-setup@v4.2.0
with:
version: 10
- name: Use Node.js 24
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@v6.3.0
with:
node-version: 24.x
cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@v5.0.3
with:
path: |
~/.pnpm-store

View File

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

View File

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

View File

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

View File

@@ -34,10 +34,10 @@ jobs:
# Learn more about CodeQL language support at https://git.io/codeql-language-support
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@v6.0.2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
uses: github/codeql-action/init@v4.32.5
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -45,4 +45,4 @@ jobs:
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
uses: github/codeql-action/analyze@v4.32.5

View File

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

View File

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

View File

@@ -19,6 +19,6 @@ jobs:
if: github.event_name == 'pull_request_target' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login != 'dependabot'
steps:
- name: Label PR with release-drafter
uses: release-drafter/release-drafter@139054aeaa9adc52ab36ddf67437541f039b88e2 # v7.1.1
uses: release-drafter/release-drafter@v6.2.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

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

View File

@@ -11,7 +11,7 @@ jobs:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@v6.0.2
env:
GH_REF: ${{ github.ref }} # sonar rule:githubactions:S7630 - avoid injection
with:
@@ -19,13 +19,13 @@ jobs:
ref: ${{ env.GH_REF }}
- name: Set up Python
id: setup-python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@v6.2.0
- name: Install system dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -qq --no-install-recommends gettext
- name: Install uv
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
uses: astral-sh/setup-uv@v7.3.1
with:
enable-cache: true
- name: Install backend python dependencies
@@ -36,18 +36,18 @@ jobs:
- name: Generate backend translation strings
run: cd src/ && uv run manage.py makemessages -l en_US -i "samples*"
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
uses: pnpm/action-setup@v4.2.0
with:
version: 10
- name: Use Node.js 24
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@v6.3.0
with:
node-version: 24.x
cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies
id: cache-frontend-deps
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@v5.0.3
with:
path: |
~/.pnpm-store
@@ -63,7 +63,7 @@ jobs:
cd src-ui
pnpm run ng extract-i18n
- name: Commit changes
uses: stefanzweifel/git-auto-commit-action@04702edda442b2e678b25b537cec683a1493fcb9 # v7.1.0
uses: stefanzweifel/git-auto-commit-action@v7.1.0
with:
file_pattern: 'src-ui/messages.xlf src/locale/en_US/LC_MESSAGES/django.po'
commit_message: "Auto translate strings"

View File

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

View File

@@ -50,12 +50,12 @@ repos:
- 'prettier-plugin-organize-imports@4.3.0'
# Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.8
rev: v0.15.5
hooks:
- id: ruff-check
- id: ruff-format
- repo: https://github.com/tox-dev/pyproject-fmt
rev: "v2.21.0"
rev: "v2.12.1"
hooks:
- id: pyproject-fmt
# Dockerfile hooks

View File

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

View File

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

View File

@@ -3,10 +3,26 @@
declare -r log_prefix="[init-index]"
echo "${log_prefix} Checking search index..."
cd "${PAPERLESS_SRC_DIR}"
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py document_index reindex --if-needed --no-progress-bar
else
s6-setuidgid paperless python3 manage.py document_index reindex --if-needed --no-progress-bar
declare -r index_version=9
declare -r data_dir="${PAPERLESS_DATA_DIR:-/usr/src/paperless/data}"
declare -r index_version_file="${data_dir}/.index_version"
update_index () {
echo "${log_prefix} Search index out of date. Updating..."
cd "${PAPERLESS_SRC_DIR}"
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py document_index reindex --no-progress-bar
echo ${index_version} | tee "${index_version_file}" > /dev/null
else
s6-setuidgid paperless python3 manage.py document_index reindex --no-progress-bar
echo ${index_version} | s6-setuidgid paperless tee "${index_version_file}" > /dev/null
fi
}
if [[ (! -f "${index_version_file}") ]]; then
echo "${log_prefix} No index version file found"
update_index
elif [[ $(<"${index_version_file}") != "$index_version" ]]; then
echo "${log_prefix} index version updated"
update_index
fi

View File

@@ -180,16 +180,6 @@ following:
This might not actually do anything. Not every new paperless version
comes with new database migrations.
4. Rebuild the search index if needed.
```shell-session
cd src
python3 manage.py document_index reindex --if-needed
```
This is a no-op if the index is already up to date, so it is safe to
run on every upgrade.
### Database Upgrades
Paperless-ngx is compatible with Django-supported versions of PostgreSQL and MariaDB and it is generally
@@ -463,42 +453,17 @@ the search yields non-existing documents or won't find anything, you
may need to recreate the index manually.
```
document_index {reindex,optimize} [--recreate] [--if-needed]
document_index {reindex,optimize}
```
Specify `reindex` to rebuild the index from all documents in the database. This
may take some time.
Specify `reindex` to have the index created from scratch. This may take
some time.
Pass `--recreate` to wipe the existing index before rebuilding. Use this when the
index is corrupted or you want a fully clean rebuild.
Pass `--if-needed` to skip the rebuild if the index is already up to date (schema
version and search language match). Safe to run on every startup or upgrade.
Specify `optimize` to optimize the index. This command is regularly invoked by the
Specify `optimize` to optimize the index. This updates certain aspects
of the index and usually makes queries faster and also ensures that the
autocompletion works properly. This command is regularly invoked by the
task scheduler.
!!! note
The `optimize` subcommand is deprecated and is now a no-op. Tantivy manages
segment merging automatically; no manual optimization step is needed.
!!! note
**Docker users:** On every startup, the container runs
`document_index reindex --if-needed` automatically. Schema changes, language
changes, and missing indexes are all detected and rebuilt before the webserver
starts. No manual step is required.
**Bare metal users:** Run the following command after each upgrade (and after
changing `PAPERLESS_SEARCH_LANGUAGE`). It is a no-op if the index is already
up to date:
```shell-session
cd src
python3 manage.py document_index reindex --if-needed
```
### Clearing the database read cache
If the database read cache is enabled, **you must run this command** after making any changes to the database outside the application context.

View File

@@ -723,81 +723,6 @@ services:
1. Note the `:ro` tag means the folder will be mounted as read only. This is for extra security against changes
## Installing third-party parser plugins {#parser-plugins}
Third-party parser plugins extend Paperless-ngx to support additional file
formats. A plugin is a Python package that advertises itself under the
`paperless_ngx.parsers` entry point group. Refer to the
[developer documentation](development.md#making-custom-parsers) for how to
create one.
!!! warning "Third-party plugins are not officially supported"
The Paperless-ngx maintainers do not provide support for third-party
plugins. Issues caused by or requiring changes to a third-party plugin
will be closed without further investigation. Always reproduce problems
with all plugins removed before filing a bug report.
### Docker
Use a [custom container initialization script](#custom-container-initialization)
to install the package before the webserver starts. Create a shell script and
mount it into `/custom-cont-init.d`:
```bash
#!/bin/bash
# /path/to/my/scripts/install-parsers.sh
pip install my-paperless-parser-package
```
Mount it in your `docker-compose.yml`:
```yaml
services:
webserver:
# ...
volumes:
- /path/to/my/scripts:/custom-cont-init.d:ro
```
The script runs as `root` before the webserver starts, so the package will be
available when Paperless-ngx discovers plugins at startup.
### Bare metal
Install the package into the same Python environment that runs Paperless-ngx.
If you followed the standard bare-metal install guide, that is the `paperless`
user's environment:
```bash
sudo -Hu paperless pip3 install my-paperless-parser-package
```
If you are using `uv` or a virtual environment, activate it first and then run:
```bash
uv pip install my-paperless-parser-package
# or
pip install my-paperless-parser-package
```
Restart all Paperless-ngx services after installation so the new plugin is
discovered.
### Verifying installation
On the next startup, check the application logs for a line confirming
discovery:
```
Loaded third-party parser 'My Parser' v1.0.0 by Acme Corp (entrypoint: 'my_parser').
```
If this line does not appear, verify that the package is installed in the
correct environment and that its `pyproject.toml` declares the
`paperless_ngx.parsers` entry point.
## MySQL Caveats {#mysql-caveats}
### Case Sensitivity

View File

@@ -62,14 +62,10 @@ The REST api provides five different forms of authentication.
## Searching for documents
Full text searching is available on the `/api/documents/` endpoint. The
following query parameters cause the API to return Tantivy-backed search
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/?text=your%20search%20query`: Search title and content
using simple substring-style search.
- `/api/documents/?title_search=your%20search%20query`: Search title only
using simple substring-style search.
- `/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
@@ -171,8 +167,9 @@ Query parameters:
- `term`: The incomplete term.
- `limit`: Amount of results. Defaults to 10.
Results are ordered by how many of the user's visible documents contain
each matching word. The first result is the word that appears in the most documents.
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
[Tf/Idf](https://en.wikipedia.org/wiki/Tf%E2%80%93idf) score in the index.
```json
["term1", "term3", "term6", "term4"]
@@ -440,8 +437,3 @@ Initial API version.
moved from the bulk edit endpoint to their own individual endpoints. Using these methods via
the bulk edit endpoint is still supported for compatibility with versions < 10 until support
for API v9 is dropped.
- The `all` parameter of list endpoints is now deprecated and will be removed in a future version.
- The bulk edit objects endpoint now supports `all` and `filters` parameters to avoid having to send
large lists of object IDs for operations affecting many objects.
- The legacy `title_content` document search parameter is deprecated and will be removed in a future version.
Clients should use `text` for simple title-and-content search and `title_search` for title-only search.

View File

@@ -1,56 +1,5 @@
# Changelog
## paperless-ngx 2.20.12
### Security
- Resolve [GHSA-96jx-fj7m-qh6x](https://github.com/paperless-ngx/paperless-ngx/security/advisories/GHSA-96jx-fj7m-qh6x)
### Bug Fixes
- Fix: Scope the workflow saves to prevent clobbering filename/archive_filename [@stumpylog](https://github.com/stumpylog) ([#12390](https://github.com/paperless-ngx/paperless-ngx/pull/12390))
- Fix: don't try to usermod/groupmod when non-root + update docs (#<!---->12365) [@stumpylog](https://github.com/stumpylog) ([#12391](https://github.com/paperless-ngx/paperless-ngx/pull/12391))
- Fix: avoid moving files if already moved [@shamoon](https://github.com/shamoon) ([#12389](https://github.com/paperless-ngx/paperless-ngx/pull/12389))
- Fix: remove pagination from document notes api spec [@shamoon](https://github.com/shamoon) ([#12388](https://github.com/paperless-ngx/paperless-ngx/pull/12388))
- Fix: fix file button hover color in dark mode [@shamoon](https://github.com/shamoon) ([#12367](https://github.com/paperless-ngx/paperless-ngx/pull/12367))
- Fixhancement: only offer basic auth for appropriate requests [@shamoon](https://github.com/shamoon) ([#12362](https://github.com/paperless-ngx/paperless-ngx/pull/12362))
### All App Changes
<details>
<summary>5 changes</summary>
- Fix: Scope the workflow saves to prevent clobbering filename/archive_filename [@stumpylog](https://github.com/stumpylog) ([#12390](https://github.com/paperless-ngx/paperless-ngx/pull/12390))
- Fix: avoid moving files if already moved [@shamoon](https://github.com/shamoon) ([#12389](https://github.com/paperless-ngx/paperless-ngx/pull/12389))
- Fix: remove pagination from document notes api spec [@shamoon](https://github.com/shamoon) ([#12388](https://github.com/paperless-ngx/paperless-ngx/pull/12388))
- Fix: fix file button hover color in dark mode [@shamoon](https://github.com/shamoon) ([#12367](https://github.com/paperless-ngx/paperless-ngx/pull/12367))
- Fixhancement: only offer basic auth for appropriate requests [@shamoon](https://github.com/shamoon) ([#12362](https://github.com/paperless-ngx/paperless-ngx/pull/12362))
</details>
## paperless-ngx 2.20.11
### Security
- Resolve [GHSA-59xh-5vwx-4c4q](https://github.com/paperless-ngx/paperless-ngx/security/advisories/GHSA-59xh-5vwx-4c4q)
### Bug Fixes
- Fix: correct dropdown list active color in dark mode [@shamoon](https://github.com/shamoon) ([#12328](https://github.com/paperless-ngx/paperless-ngx/pull/12328))
- Fixhancement: clear descendant selections in dropdown when parent toggled [@shamoon](https://github.com/shamoon) ([#12326](https://github.com/paperless-ngx/paperless-ngx/pull/12326))
- Fix: prevent wrapping with larger amounts of tags on small cards, reset moreTags setting to correct count [@shamoon](https://github.com/shamoon) ([#12302](https://github.com/paperless-ngx/paperless-ngx/pull/12302))
- Fix: prevent stale db filename during workflow actions [@shamoon](https://github.com/shamoon) ([#12289](https://github.com/paperless-ngx/paperless-ngx/pull/12289))
### All App Changes
<details>
<summary>4 changes</summary>
- Fix: correct dropdown list active color in dark mode [@shamoon](https://github.com/shamoon) ([#12328](https://github.com/paperless-ngx/paperless-ngx/pull/12328))
- Fixhancement: clear descendant selections in dropdown when parent toggled [@shamoon](https://github.com/shamoon) ([#12326](https://github.com/paperless-ngx/paperless-ngx/pull/12326))
- Fix: prevent wrapping with larger amounts of tags on small cards, reset moreTags setting to correct count [@shamoon](https://github.com/shamoon) ([#12302](https://github.com/paperless-ngx/paperless-ngx/pull/12302))
- Fix: prevent stale db filename during workflow actions [@shamoon](https://github.com/shamoon) ([#12289](https://github.com/paperless-ngx/paperless-ngx/pull/12289))
</details>
## paperless-ngx 2.20.10
### Bug Fixes

View File

@@ -674,9 +674,6 @@ See the corresponding [django-allauth documentation](https://docs.allauth.org/en
for a list of provider configurations. You will also need to include the relevant Django 'application' inside the
[PAPERLESS_APPS](#PAPERLESS_APPS) setting to activate that specific authentication provider (e.g. `allauth.socialaccount.providers.openid_connect` for the [OIDC Connect provider](https://docs.allauth.org/en/latest/socialaccount/providers/openid_connect.html)).
: For OpenID Connect providers, set `settings.token_auth_method` if your identity provider
requires a specific token endpoint authentication method.
Defaults to None, which does not enable any third party authentication systems.
#### [`PAPERLESS_SOCIAL_AUTO_SIGNUP=<bool>`](#PAPERLESS_SOCIAL_AUTO_SIGNUP) {#PAPERLESS_SOCIAL_AUTO_SIGNUP}
@@ -1103,32 +1100,6 @@ should be a valid crontab(5) expression describing when to run.
Defaults to `0 0 * * *` or daily at midnight.
#### [`PAPERLESS_SEARCH_LANGUAGE=<language>`](#PAPERLESS_SEARCH_LANGUAGE) {#PAPERLESS_SEARCH_LANGUAGE}
: Sets the stemmer language for the full-text search index.
Stemming improves recall by matching word variants (e.g. "running" matches "run").
Changing this setting causes the index to be rebuilt automatically on next startup.
An invalid value raises an error at startup.
: Use the ISO 639-1 two-letter code (e.g. `en`, `de`, `fr`). Lowercase full names
(e.g. `english`, `german`, `french`) are also accepted. The capitalized names shown
in the [Tantivy Language enum](https://docs.rs/tantivy/latest/tantivy/tokenizer/enum.Language.html)
documentation are **not** valid — use the lowercase equivalent.
: If not set, paperless infers the language from
[`PAPERLESS_OCR_LANGUAGE`](#PAPERLESS_OCR_LANGUAGE). If the OCR language has no
Tantivy stemmer equivalent, stemming is disabled.
Defaults to unset (inferred from `PAPERLESS_OCR_LANGUAGE`).
#### [`PAPERLESS_ADVANCED_FUZZY_SEARCH_THRESHOLD=<float>`](#PAPERLESS_ADVANCED_FUZZY_SEARCH_THRESHOLD) {#PAPERLESS_ADVANCED_FUZZY_SEARCH_THRESHOLD}
: When set to a float value, approximate/fuzzy matching is applied alongside exact
matching. Fuzzy results rank below exact matches. A value of `0.5` is a reasonable
starting point. Leave unset to disable fuzzy matching entirely.
Defaults to unset (disabled).
#### [`PAPERLESS_SANITY_TASK_CRON=<cron expression>`](#PAPERLESS_SANITY_TASK_CRON) {#PAPERLESS_SANITY_TASK_CRON}
: Configures the scheduled sanity checker frequency. The value should be a
@@ -1976,12 +1947,6 @@ current backend. If not supplied, defaults to "gpt-3.5-turbo" for OpenAI and "ll
Defaults to None.
#### [`PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS=<bool>`](#PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS) {#PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS}
: If set to false, Paperless blocks AI endpoint URLs that resolve to non-public addresses (e.g., localhost, etc).
Defaults to true, which allows internal endpoints.
#### [`PAPERLESS_AI_LLM_INDEX_TASK_CRON=<cron expression>`](#PAPERLESS_AI_LLM_INDEX_TASK_CRON) {#PAPERLESS_AI_LLM_INDEX_TASK_CRON}
: Configures the schedule to update the AI embeddings of text content and metadata for all documents. Only performed if

View File

@@ -370,367 +370,121 @@ docker build --file Dockerfile --tag paperless:local .
## Extending Paperless-ngx
Paperless-ngx supports third-party document parsers via a Python entry point
plugin system. Plugins are distributed as ordinary Python packages and
discovered automatically at startup — no changes to the Paperless-ngx source
are required.
!!! warning "Third-party plugins are not officially supported"
The Paperless-ngx maintainers do not provide support for third-party
plugins. Issues that are caused by or require changes to a third-party
plugin will be closed without further investigation. If you believe you
have found a bug in Paperless-ngx itself (not in a plugin), please
reproduce it with all third-party plugins removed before filing an issue.
Paperless-ngx does not have any fancy plugin systems and will probably never
have. However, some parts of the application have been designed to allow
easy integration of additional features without any modification to the
base code.
### Making custom parsers
Paperless-ngx uses parsers to add documents. A parser is responsible for:
Paperless-ngx uses parsers to add documents. A parser is
responsible for:
- Extracting plain-text content from the document
- Generating a thumbnail image
- _optional:_ Detecting the document's creation date
- _optional:_ Producing a searchable PDF archive copy
- 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 are distributed as ordinary Python packages and registered
via a [setuptools entry point](https://setuptools.pypa.io/en/latest/userguide/entry_point.html).
No changes to the Paperless-ngx source are required.
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
existence to Paperless-ngx.
#### 1. Implementing the parser class
Your parser must satisfy the `ParserProtocol` structural interface defined in
`paperless.parsers`. The simplest approach is to write a plain class — no base
class is required, only the right attributes and methods.
**Class-level identity attributes**
The registry reads these before instantiating the parser, so they must be
plain class attributes (not instance attributes or properties):
The parser itself must extend `documents.parsers.DocumentParser` and
must implement the methods `parse` and `get_thumbnail`. You can provide
your own implementation to `get_date` if you don't want to rely on
Paperless-ngx' default date guessing mechanisms.
```python
class MyCustomParser:
name = "My Format Parser" # human-readable name shown in logs
version = "1.0.0" # semantic version string
author = "Acme Corp" # author / organisation
url = "https://example.com/my-parser" # docs or issue tracker
class MyCustomParser(DocumentParser):
def parse(self, document_path, mime_type):
# This method does not return anything. Rather, you should assign
# whatever you got from the document to the following fields:
# The content of the document.
self.text = "content"
# Optional: path to a PDF document that you created from the original.
self.archive_path = os.path.join(self.tempdir, "archived.pdf")
# Optional: "created" date of the document.
self.date = get_created_from_metadata(document_path)
def get_thumbnail(self, document_path, mime_type):
# This should return the path to a thumbnail you created for this
# document.
return os.path.join(self.tempdir, "thumb.webp")
```
**Declaring supported MIME types**
If you encounter any issues during parsing, raise a
`documents.parsers.ParseError`.
Return a `dict` mapping MIME type strings to preferred file extensions
(including the leading dot). Paperless-ngx uses the extension when storing
archive copies and serving files for download.
The `self.tempdir` directory is a temporary directory that is guaranteed
to be empty and removed after consumption finished. You can use that
directory to store any intermediate files and also use it to store the
thumbnail / archived document.
After that, you need to announce your parser to Paperless-ngx. You need to
connect a handler to the `document_consumer_declaration` signal. Have a
look in the file `src/paperless_tesseract/apps.py` on how that's done.
The handler is a method that returns information about your parser:
```python
@classmethod
def supported_mime_types(cls) -> dict[str, str]:
def myparser_consumer_declaration(sender, **kwargs):
return {
"application/x-my-format": ".myf",
"application/x-my-format-alt": ".myf",
"parser": MyCustomParser,
"weight": 0,
"mime_types": {
"application/pdf": ".pdf",
"image/jpeg": ".jpg",
}
}
```
**Scoring**
When more than one parser can handle a file, the registry calls `score()` on
each candidate and picks the one with the highest result and equal scores favor third-party parsers over built-ins. Return `None` to
decline handling a file even though the MIME type is listed as supported (for
example, when a required external service is not configured).
| Score | Meaning |
| ------ | --------------------------------------------------------------------------------- |
| `None` | Decline — do not handle this file |
| `10` | Default priority used by all built-in parsers |
| `20` | Priority used by the remote OCR built-in parser, allowing it to replace Tesseract |
| `> 10` | Override a built-in parser for the same MIME type |
```python
@classmethod
def score(
cls,
mime_type: str,
filename: str,
path: "Path | None" = None,
) -> int | None:
# Inspect filename or file bytes here if needed.
return 10
```
**Archive and rendition flags**
```python
@property
def can_produce_archive(self) -> bool:
"""True if parse() can produce a searchable PDF archive copy."""
return True # or False if your parser doesn't produce PDFs
@property
def requires_pdf_rendition(self) -> bool:
"""True if the original format cannot be displayed by a browser
(e.g. DOCX, ODT) and the PDF output must always be kept."""
return False
```
**Context manager — temp directory lifecycle**
Paperless-ngx always uses parsers as context managers. Create a temporary
working directory in `__enter__` (or `__init__`) and remove it in `__exit__`
regardless of whether an exception occurred. Store intermediate files,
thumbnails, and archive PDFs inside this directory.
```python
import shutil
import tempfile
from pathlib import Path
from typing import Self
from types import TracebackType
from django.conf import settings
class MyCustomParser:
...
def __init__(self, logging_group: object = None) -> None:
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
self._tempdir = Path(
tempfile.mkdtemp(prefix="paperless-", dir=settings.SCRATCH_DIR)
)
self._text: str | None = None
self._archive_path: Path | None = None
def __enter__(self) -> Self:
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
shutil.rmtree(self._tempdir, ignore_errors=True)
```
**Optional context — `configure()`**
The consumer calls `configure()` with a `ParserContext` after instantiation
and before `parse()`. If your parser doesn't need context, a no-op
implementation is fine:
```python
from paperless.parsers import ParserContext
def configure(self, context: ParserContext) -> None:
pass # override if you need context.mailrule_id, etc.
```
**Parsing**
`parse()` is the core method. It must not return a value; instead, store
results in instance attributes and expose them via the accessor methods below.
Raise `documents.parsers.ParseError` on any unrecoverable failure.
```python
from documents.parsers import ParseError
def parse(
self,
document_path: Path,
mime_type: str,
*,
produce_archive: bool = True,
) -> None:
try:
self._text = extract_text_from_my_format(document_path)
except Exception as e:
raise ParseError(f"Failed to parse {document_path}: {e}") from e
if produce_archive and self.can_produce_archive:
archive = self._tempdir / "archived.pdf"
convert_to_pdf(document_path, archive)
self._archive_path = archive
```
**Result accessors**
```python
def get_text(self) -> str | None:
return self._text
def get_date(self) -> "datetime.datetime | None":
# Return a datetime extracted from the document, or None to let
# Paperless-ngx use its default date-guessing logic.
return None
def get_archive_path(self) -> Path | None:
return self._archive_path
def get_page_count(self, document_path: Path, mime_type: str) -> int | None:
# If the format doesn't have the concept of pages, return None
return count_pages(document_path)
```
**Thumbnail**
`get_thumbnail()` may be called independently of `parse()`. Return the path
to a WebP image inside `self._tempdir`. The image should be roughly 500 × 700
pixels.
```python
def get_thumbnail(self, document_path: Path, mime_type: str) -> Path:
thumb = self._tempdir / "thumb.webp"
render_thumbnail(document_path, thumb)
return thumb
```
**Optional methods**
These are called by the API on demand, not during the consumption pipeline.
Implement them if your format supports the information; otherwise return
`None` / `[]`.
```python
def extract_metadata(
self,
document_path: Path,
mime_type: str,
) -> "list[MetadataEntry]":
# Must never raise. Return [] if metadata cannot be read.
from paperless.parsers import MetadataEntry
return [
MetadataEntry(
namespace="https://example.com/ns/",
prefix="ex",
key="Author",
value="Alice",
)
]
```
#### 2. Registering via entry point
Add the following to your package's `pyproject.toml`. The key (left of `=`)
is an arbitrary name used only in log output; the value is the
`module:ClassName` import path.
```toml
[project.entry-points."paperless_ngx.parsers"]
my_parser = "my_package.parsers:MyCustomParser"
```
Install your package into the same Python environment as Paperless-ngx (or
add it to the Docker image), and the parser will be discovered automatically
on the next startup. No configuration changes are needed.
To verify discovery, check the application logs at startup for a line like:
```
Loaded third-party parser 'My Format Parser' v1.0.0 by Acme Corp (entrypoint: 'my_parser').
```
#### 3. Utilities
`paperless.parsers.utils` provides helpers you can import directly:
| Function | Description |
| --------------------------------------- | ---------------------------------------------------------------- |
| `read_file_handle_unicode_errors(path)` | Read a file as UTF-8, replacing invalid bytes instead of raising |
| `get_page_count_for_pdf(path)` | Count pages in a PDF using pikepdf |
| `extract_pdf_metadata(path)` | Extract XMP metadata from a PDF as a `list[MetadataEntry]` |
#### Minimal example
A complete, working parser for a hypothetical plain-XML format:
```python
from __future__ import annotations
import shutil
import tempfile
from pathlib import Path
from typing import Self
from types import TracebackType
import xml.etree.ElementTree as ET
from django.conf import settings
from documents.parsers import ParseError
from paperless.parsers import ParserContext
class XmlDocumentParser:
name = "XML Parser"
version = "1.0.0"
author = "Acme Corp"
url = "https://example.com/xml-parser"
@classmethod
def supported_mime_types(cls) -> dict[str, str]:
return {"application/xml": ".xml", "text/xml": ".xml"}
@classmethod
def score(cls, mime_type: str, filename: str, path: Path | None = None) -> int | None:
return 10
@property
def can_produce_archive(self) -> bool:
return False
@property
def requires_pdf_rendition(self) -> bool:
return False
def __init__(self, logging_group: object = None) -> None:
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
self._tempdir = Path(tempfile.mkdtemp(prefix="paperless-", dir=settings.SCRATCH_DIR))
self._text: str | None = None
def __enter__(self) -> Self:
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
shutil.rmtree(self._tempdir, ignore_errors=True)
def configure(self, context: ParserContext) -> None:
pass
def parse(self, document_path: Path, mime_type: str, *, produce_archive: bool = True) -> None:
try:
tree = ET.parse(document_path)
self._text = " ".join(tree.getroot().itertext())
except ET.ParseError as e:
raise ParseError(f"XML parse error: {e}") from e
def get_text(self) -> str | None:
return self._text
def get_date(self):
return None
def get_archive_path(self) -> Path | None:
return None
def get_thumbnail(self, document_path: Path, mime_type: str) -> Path:
from PIL import Image, ImageDraw
img = Image.new("RGB", (500, 700), color="white")
ImageDraw.Draw(img).text((10, 10), "XML Document", fill="black")
out = self._tempdir / "thumb.webp"
img.save(out, format="WEBP")
return out
def get_page_count(self, document_path: Path, mime_type: str) -> int | None:
return None
def extract_metadata(self, document_path: Path, mime_type: str) -> list:
return []
```
### Developing date parser plugins
- `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
Another easy way to get started with development is to use Visual Studio
Code devcontainers. This approach will create a preconfigured development
environment with all of the required tools and dependencies.
[Learn more about devcontainers](https://code.visualstudio.com/docs/devcontainers/containers).
The .devcontainer/vscode/tasks.json and .devcontainer/vscode/launch.json files
contain more information about the specific tasks and launch configurations (see the
non-standard "description" field).
To get started:
1. Clone the repository on your machine and open the Paperless-ngx folder in VS Code.
2. VS Code will prompt you with "Reopen in container". Do so and wait for the environment to start.
3. In case your host operating system is Windows:
- The Source Control view in Visual Studio Code might show: "The detected Git repository is potentially unsafe as the folder is owned by someone other than the current user." Use "Manage Unsafe Repositories" to fix this.
- Git might have detecteded modifications for all files, because Windows is using CRLF line endings. Run `git checkout .` in the containers terminal to fix this issue.
4. Initialize the project by running the task **Project Setup: Run all Init Tasks**. This
will initialize the database tables and create a superuser. Then you can compile the front end
for production or run the frontend in debug mode.
5. The project is ready for debugging, start either run the fullstack debug or individual debug
processes. Yo spin up the project without debugging run the task **Project Start: Run all Services**
## Developing Date Parser Plugins
Paperless-ngx uses a plugin system for date parsing, allowing you to extend or replace the default date parsing behavior. Plugins are discovered using [Python entry points](https://setuptools.pypa.io/en/latest/userguide/entry_point.html).
#### Creating a Date Parser Plugin
### Creating a Date Parser Plugin
To create a custom date parser plugin, you need to:
@@ -738,7 +492,7 @@ To create a custom date parser plugin, you need to:
2. Implement the required abstract method
3. Register your plugin via an entry point
##### 1. Implementing the Parser Class
#### 1. Implementing the Parser Class
Your parser must extend `documents.plugins.date_parsing.DateParserPluginBase` and implement the `parse` method:
@@ -778,7 +532,7 @@ class MyDateParserPlugin(DateParserPluginBase):
yield another_datetime
```
##### 2. Configuration and Helper Methods
#### 2. Configuration and Helper Methods
Your parser instance is initialized with a `DateParserConfig` object accessible via `self.config`. This provides:
@@ -811,11 +565,11 @@ def _filter_date(
"""
```
##### 3. Resource Management (Optional)
#### 3. Resource Management (Optional)
If your plugin needs to acquire or release resources (database connections, API clients, etc.), override the context manager methods. Paperless-ngx will always use plugins as context managers, ensuring resources can be released even in the event of errors.
##### 4. Registering Your Plugin
#### 4. Registering Your Plugin
Register your plugin using a setuptools entry point in your package's `pyproject.toml`:
@@ -826,7 +580,7 @@ my_parser = "my_package.parsers:MyDateParserPlugin"
The entry point name (e.g., `"my_parser"`) is used for sorting when multiple plugins are found. Paperless-ngx will use the first plugin alphabetically by name if multiple plugins are discovered.
#### Plugin Discovery
### Plugin Discovery
Paperless-ngx automatically discovers and loads date parser plugins at runtime. The discovery process:
@@ -837,7 +591,7 @@ Paperless-ngx automatically discovers and loads date parser plugins at runtime.
If multiple plugins are installed, a warning is logged indicating which plugin was selected.
#### Example: Simple Date Parser
### Example: Simple Date Parser
Here's a minimal example that only looks for ISO 8601 dates:
@@ -869,30 +623,3 @@ class ISODateParserPlugin(DateParserPluginBase):
if filtered_date is not None:
yield filtered_date
```
## Using Visual Studio Code devcontainer
Another easy way to get started with development is to use Visual Studio
Code devcontainers. This approach will create a preconfigured development
environment with all of the required tools and dependencies.
[Learn more about devcontainers](https://code.visualstudio.com/docs/devcontainers/containers).
The .devcontainer/vscode/tasks.json and .devcontainer/vscode/launch.json files
contain more information about the specific tasks and launch configurations (see the
non-standard "description" field).
To get started:
1. Clone the repository on your machine and open the Paperless-ngx folder in VS Code.
2. VS Code will prompt you with "Reopen in container". Do so and wait for the environment to start.
3. In case your host operating system is Windows:
- The Source Control view in Visual Studio Code might show: "The detected Git repository is potentially unsafe as the folder is owned by someone other than the current user." Use "Manage Unsafe Repositories" to fix this.
- Git might have detecteded modifications for all files, because Windows is using CRLF line endings. Run `git checkout .` in the containers terminal to fix this issue.
4. Initialize the project by running the task **Project Setup: Run all Init Tasks**. This
will initialize the database tables and create a superuser. Then you can compile the front end
for production or run the frontend in debug mode.
5. The project is ready for debugging, start either run the fullstack debug or individual debug
processes. Yo spin up the project without debugging run the task **Project Start: Run all Services**

View File

@@ -103,61 +103,3 @@ Multiple options are combined in a single value:
```bash
PAPERLESS_DB_OPTIONS="sslmode=require;sslrootcert=/certs/ca.pem;pool.max_size=10"
```
## Search Index (Whoosh -> Tantivy)
The full-text search backend has been replaced with [Tantivy](https://github.com/quickwit-oss/tantivy).
The index format is incompatible with Whoosh, so **the search index is automatically rebuilt from
scratch on first startup after upgrading**. No manual action is required for the rebuild itself.
### Note and custom field search syntax
The old Whoosh index exposed `note` and `custom_field` as flat text fields that were included in
unqualified searches (e.g. just typing `invoice` would match note content). With Tantivy these are
now structured JSON fields accessed via dotted paths:
| Old syntax | New syntax |
| -------------------- | --------------------------- |
| `note:query` | `notes.note:query` |
| `custom_field:query` | `custom_fields.value:query` |
**Saved views are migrated automatically.** Any saved view filter rule that used an explicit
`note:` or `custom_field:` field prefix in a fulltext query is rewritten to the new syntax by a
data migration that runs on upgrade.
**Unqualified queries are not migrated.** If you had a saved view with a plain search term (e.g.
`invoice`) that happened to match note content or custom field values, it will no longer return
those matches. Update those queries to use the explicit prefix, for example:
```
invoice OR notes.note:invoice OR custom_fields.value:invoice
```
Custom field names can also be searched with `custom_fields.name:fieldname`.
## OpenID Connect Token Endpoint Authentication
Some existing OpenID Connect setups may require an explicit token endpoint authentication method after upgrading to v3.
#### Action Required
If OIDC login fails at the callback with an `invalid_client` error, add `token_auth_method` to the provider `settings` in
[`PAPERLESS_SOCIALACCOUNT_PROVIDERS`](configuration.md#PAPERLESS_SOCIALACCOUNT_PROVIDERS).
For example:
```json
{
"openid_connect": {
"APPS": [
{
...
"settings": {
"server_url": "https://login.example.com",
"token_auth_method": "client_secret_basic"
}
}
]
}
}
```

View File

@@ -140,17 +140,24 @@ a [superuser](usage.md#superusers) account.
!!! warning
It is not possible to run the container rootless if additional languages are specified via `PAPERLESS_OCR_LANGUAGES`.
It is currently not possible to run the container rootless if additional languages are specified via `PAPERLESS_OCR_LANGUAGES`.
If you want to run Paperless as a rootless container, set `user:` in `docker-compose.yml` to the UID and GID of your host user (use `id -u` and `id -g` to find these values). The container process starts directly as that user with no internal privilege remapping:
If you want to run Paperless as a rootless container, make this
change in `docker-compose.yml`:
```yaml
webserver:
image: ghcr.io/paperless-ngx/paperless-ngx:latest
user: '1000:1000'
```
- 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).
Do not combine this with `USERMAP_UID` or `USERMAP_GID`, which are intended for the non-rootless case described in step 3.
Your entry for Paperless should contain something like:
> ```
> webserver:
> image: ghcr.io/paperless-ngx/paperless-ngx:latest
> user: <user_id>
> ```
**File systems without inotify support (e.g. NFS)**

View File

@@ -457,7 +457,7 @@ fields and permissions, which will be merged.
#### Types {#workflow-trigger-types}
Currently, there are four events that correspond to workflow trigger 'types':
Currently, there are five events that correspond to workflow trigger 'types':
1. **Consumption Started**: _before_ a document is consumed, so events can include filters by source (mail, consumption
folder or API), file path, file name, mail rule
@@ -469,8 +469,10 @@ Currently, there are four events that correspond to workflow trigger 'types':
4. **Scheduled**: a scheduled trigger that can be used to run workflows at a specific time. The date used can be either the document
added, created, updated date or you can specify a (date) custom field. You can also specify a day offset from the date (positive
offsets will trigger after the date, negative offsets will trigger before).
5. **Version Added**: when a new version is added for an existing document. This trigger evaluates filters against the root document
and applies actions to the root document.
The following flow diagram illustrates the four document trigger types:
The following flow diagram illustrates the document trigger types:
```mermaid
flowchart TD
@@ -486,6 +488,10 @@ flowchart TD
'Updated'
trigger(s)"}
version{"Matching
'Version Added'
trigger(s)"}
scheduled{"Documents
matching
trigger(s)"}
@@ -502,11 +508,15 @@ flowchart TD
updated --> |Yes| J[Workflow Actions Run]
updated --> |No| K
J --> K[Document Saved]
L[Scheduled Task Check<br/>hourly at :05] --> M[Get All Scheduled Triggers]
M --> scheduled
scheduled --> |Yes| N[Workflow Actions Run]
scheduled --> |No| O[Document Saved]
N --> O
L[New Document Version Added] --> version
version --> |Yes| V[Workflow Actions Run]
version --> |No| W
V --> W[Document Saved]
X[Scheduled Task Check<br/>hourly at :05] --> Y[Get All Scheduled Triggers]
Y --> scheduled
scheduled --> |Yes| Z[Workflow Actions Run]
scheduled --> |No| AA[Document Saved]
Z --> AA
```
#### Filters {#workflow-trigger-filters}
@@ -804,20 +814,13 @@ contract you signed 8 years ago).
When you search paperless for a document, it tries to match this query
against your documents. Paperless will look for matching documents by
inspecting their content, title, correspondent, type, tags, notes, and
custom field values. Paperless returns a scored list of results, so that
documents matching your query better will appear further up in the search
results.
inspecting their content, title, correspondent, type and tags. Paperless
returns a scored list of results, so that documents matching your query
better will appear further up in the search results.
By default, paperless returns only documents which contain all words
typed in the search bar. A few things to know about how matching works:
- **Word-order-independent**: "invoice unpaid" and "unpaid invoice" return the same results.
- **Accent-insensitive**: searching `resume` also finds `résumé`, `cafe` finds `café`.
- **Separator-agnostic**: punctuation and separators are stripped during indexing, so
searching a partial number like `1312` finds documents containing `A-1312/B`.
Paperless also offers advanced search syntax if you want to drill down further.
typed in the search bar. However, paperless also offers advanced search
syntax if you want to drill down the results further.
Matching documents with logical expressions:
@@ -846,69 +849,18 @@ Matching inexact words:
produ*name
```
Matching natural date keywords:
```
added:today
modified:yesterday
created:this_week
added:last_month
modified:this_year
```
Supported date keywords: `today`, `yesterday`, `this_week`, `last_week`,
`this_month`, `last_month`, `this_year`, `last_year`.
#### Searching custom fields
Custom field values are included in the full-text index, so a plain search
already matches documents whose custom field values contain your search terms.
To narrow by field name or value specifically:
```
custom_fields.value:policy
custom_fields.name:"Contract Number"
custom_fields.name:Insurance custom_fields.value:policy
```
- `custom_fields.value` matches against the value of any custom field.
- `custom_fields.name` matches the name of the field (use quotes for multi-word names).
- Combine both to find documents where a specific named field contains a specific value.
Because separators are stripped during indexing, individual parts of formatted
codes are searchable on their own. A value stored as `A-1312/99.50` produces the
tokens `a`, `1312`, `99`, `50` — each searchable independently:
```
custom_fields.value:1312
custom_fields.name:"Contract Number" custom_fields.value:1312
```
!!! note
Custom date fields do not support relative date syntax (e.g. `[now to 2 weeks]`).
For date ranges on custom date fields, use the document list filters in the web UI.
#### Searching notes
Notes content is included in full-text search automatically. To search
by note author or content specifically:
```
notes.user:alice
notes.note:reminder
notes.user:alice notes.note:insurance
```
Inexact terms are hard for search indexes. These queries might take a
while to execute. That's why paperless offers auto complete and query
correction.
All of these constructs can be combined as you see fit. If you want to
learn more about the query language used by paperless, see the
[Tantivy query language documentation](https://docs.rs/tantivy/latest/tantivy/query/struct.QueryParser.html).
!!! note
Fuzzy (approximate) matching can be enabled by setting
[`PAPERLESS_ADVANCED_FUZZY_SEARCH_THRESHOLD`](configuration.md#PAPERLESS_ADVANCED_FUZZY_SEARCH_THRESHOLD).
When enabled, paperless will include near-miss results ranked below exact matches.
learn more about the query language used by paperless, paperless uses
Whoosh's default query language. Head over to [Whoosh query
language](https://whoosh.readthedocs.io/en/latest/querylang.html). For
details on what date parsing utilities are available, see [Date
parsing](https://whoosh.readthedocs.io/en/latest/dates.html#parsing-date-queries).
## Keyboard shortcuts / hotkeys

View File

@@ -1,6 +1,6 @@
[project]
name = "paperless-ngx"
version = "2.20.13"
version = "2.20.10"
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
readme = "README.md"
requires-python = ">=3.11"
@@ -13,6 +13,7 @@ classifiers = [
]
# TODO: Move certain things to groups and then utilize that further
# This will allow testing to not install a webserver, mysql, etc
dependencies = [
"azure-ai-documentintelligence>=1.0.2",
"babel>=2.17",
@@ -25,7 +26,7 @@ dependencies = [
# WARNING: django does not use semver.
# Only patch versions are guaranteed to not introduce breaking changes.
"django~=5.2.10",
"django-allauth[mfa,socialaccount]~=65.15.0",
"django-allauth[mfa,socialaccount]~=65.14.0",
"django-auditlog~=3.4.1",
"django-cachalot~=2.9.0",
"django-celery-results~=2.6.0",
@@ -59,7 +60,7 @@ dependencies = [
"llama-index-llms-openai>=0.6.13",
"llama-index-vector-stores-faiss>=0.5.2",
"nltk~=3.9.1",
"ocrmypdf~=17.3.0",
"ocrmypdf~=16.13.0",
"openai>=1.76",
"pathvalidate~=3.3.1",
"pdf2image~=1.17.0",
@@ -74,40 +75,39 @@ dependencies = [
"scikit-learn~=1.8.0",
"sentence-transformers>=4.1",
"setproctitle~=1.3.4",
"tantivy>=0.25.1",
"tika-client~=0.10.0",
"torch~=2.10.0",
"watchfiles>=1.1.1",
"whitenoise~=6.11",
"whoosh-reloaded>=2.7.5",
"zxing-cpp~=3.0.0",
]
[project.optional-dependencies]
mariadb = [
optional-dependencies.mariadb = [
"mysqlclient~=2.2.7",
]
postgres = [
optional-dependencies.postgres = [
"psycopg[c,pool]==3.3",
# Direct dependency for proper resolution of the pre-built wheels
"psycopg-c==3.3",
"psycopg-pool==3.3",
]
webserver = [
optional-dependencies.webserver = [
"granian[uvloop]~=2.7.0",
]
[dependency-groups]
dev = [
{ include-group = "docs" },
{ include-group = "lint" },
{ include-group = "testing" },
{ "include-group" = "docs" },
{ "include-group" = "testing" },
{ "include-group" = "lint" },
]
docs = [
"zensical>=0.0.21",
]
lint = [
"prek~=0.3.0",
"ruff~=0.15.0",
]
testing = [
"daphne",
"factory-boy~=3.3.1",
@@ -119,12 +119,17 @@ testing = [
"pytest-env~=1.5.0",
"pytest-httpx",
"pytest-mock~=3.15.1",
# "pytest-randomly~=4.0.1",
#"pytest-randomly~=4.0.1",
"pytest-rerunfailures~=16.1",
"pytest-sugar",
"pytest-xdist~=3.8.0",
"time-machine>=2.13",
]
lint = [
"prek~=0.3.0",
"ruff~=0.15.0",
]
typing = [
"celery-types",
"django-filter-stubs",
@@ -149,21 +154,24 @@ typing = [
[tool.uv]
required-version = ">=0.9.0"
package = false
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
]
package = false
[[tool.uv.index]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
explicit = true
[tool.uv.sources]
# Markers are chosen to select these almost exclusively when building the Docker image
psycopg-c = [
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.0/psycopg_c-3.3.0-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.0/psycopg_c-3.3.0-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
]
torch = [
{ index = "pytorch-cpu" },
]
@@ -178,10 +186,10 @@ respect-gitignore = true
# https://docs.astral.sh/ruff/settings/
fix = true
show-fixes = true
output-format = "grouped"
[tool.ruff.lint]
# https://docs.astral.sh/ruff/rules/
extend-select = [
lint.extend-select = [
"COM", # https://docs.astral.sh/ruff/rules/#flake8-commas-com
"DJ", # https://docs.astral.sh/ruff/rules/#flake8-django-dj
"EXE", # https://docs.astral.sh/ruff/rules/#flake8-executable-exe
@@ -206,52 +214,121 @@ extend-select = [
"UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up
"W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
]
ignore = [
lint.ignore = [
"DJ001",
"PLC0415",
"RUF012",
"SIM105",
]
# Migrations
per-file-ignores."*/migrations/*.py" = [
lint.per-file-ignores."*/migrations/*.py" = [
"E501",
"SIM",
"T201",
]
# Testing
per-file-ignores."*/tests/*.py" = [
lint.per-file-ignores."*/tests/*.py" = [
"E501",
"SIM117",
]
per-file-ignores.".github/scripts/*.py" = [
lint.per-file-ignores.".github/scripts/*.py" = [
"E501",
"INP001",
"SIM117",
]
# Docker specific
per-file-ignores."docker/rootfs/usr/local/bin/wait-for-redis.py" = [
lint.per-file-ignores."docker/rootfs/usr/local/bin/wait-for-redis.py" = [
"INP001",
"T201",
]
per-file-ignores."docker/wait-for-redis.py" = [
lint.per-file-ignores."docker/wait-for-redis.py" = [
"INP001",
"T201",
]
per-file-ignores."src/documents/models.py" = [
lint.per-file-ignores."src/documents/models.py" = [
"SIM115",
]
isort.force-single-line = true
lint.per-file-ignores."src/paperless_tesseract/tests/test_parser.py" = [
"RUF001",
]
lint.isort.force-single-line = true
[tool.codespell]
write-changes = true
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish"
skip = """\
src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/paperless/tests/samples\
/mail/*,src/documents/tests/samples/*,*.po,*.json\
"""
skip = "src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/documents/tests/samples/*,*.po,*.json"
[tool.pyproject-fmt]
table_format = "long"
[tool.pytest]
minversion = "9.0"
pythonpath = [ "src" ]
strict_config = true
strict_markers = true
strict_parametrization_ids = true
strict_xfail = true
testpaths = [
"src/documents/tests/",
"src/paperless/tests/",
"src/paperless_mail/tests/",
"src/paperless_tesseract/tests/",
"src/paperless_tika/tests",
"src/paperless_text/tests/",
"src/paperless_remote/tests/",
"src/paperless_ai/tests",
]
addopts = [
"--pythonwarnings=all",
"--cov",
"--cov-report=html",
"--cov-report=xml",
"--numprocesses=auto",
"--maxprocesses=16",
"--dist=loadscope",
"--durations=50",
"--durations-min=0.5",
"--junitxml=junit.xml",
"-o",
"junit_family=legacy",
]
norecursedirs = [ "src/locale/", ".venv/", "src-ui/" ]
DJANGO_SETTINGS_MODULE = "paperless.settings"
markers = [
"live: Integration tests requiring external services (Gotenberg, Tika, nginx, etc)",
"nginx: Tests that make HTTP requests to the local nginx service",
"gotenberg: Tests requiring Gotenberg service",
"tika: Tests requiring Tika service",
"greenmail: Tests requiring Greenmail service",
"date_parsing: Tests which cover date parsing from content or filename",
"management: Tests which cover management commands/functionality",
]
[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 = [
"if settings.AUDIT_LOG_ENABLED:",
"if AUDIT_LOG_ENABLED:",
"if TYPE_CHECKING:",
]
[tool.coverage.run]
source = [
"src/",
]
omit = [
"*/tests/*",
"manage.py",
"paperless/wsgi.py",
"paperless/auth.py",
]
[tool.mypy]
mypy_path = "src"
@@ -274,68 +351,6 @@ python-platform = "linux"
[tool.django-stubs]
django_settings_module = "paperless.settings"
[tool.pytest]
minversion = "9.0"
pythonpath = [ "src" ]
strict_config = true
strict_markers = true
strict_parametrization_ids = true
strict_xfail = true
testpaths = [
"src/documents/tests/",
"src/paperless/tests/",
"src/paperless_mail/tests/",
"src/paperless_ai/tests",
]
addopts = [
"--pythonwarnings=all",
"--cov",
"--cov-report=html",
"--cov-report=xml",
"--numprocesses=auto",
"--maxprocesses=16",
"--dist=loadscope",
"--durations=50",
"--durations-min=0.5",
"--junitxml=junit.xml",
"-o",
"junit_family=legacy",
]
norecursedirs = [ "src/locale/", ".venv/", "src-ui/" ]
DJANGO_SETTINGS_MODULE = "paperless.settings"
markers = [
"live: Integration tests requiring external services (Gotenberg, Tika, nginx, etc)",
"nginx: Tests that make HTTP requests to the local nginx service",
"gotenberg: Tests requiring Gotenberg service",
"tika: Tests requiring Tika service",
"greenmail: Tests requiring Greenmail service",
"date_parsing: Tests which cover date parsing from content or filename",
"management: Tests which cover management commands/functionality",
"search: Tests for the Tantivy search backend",
]
[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 = [
"if settings.AUDIT_LOG_ENABLED:",
"if AUDIT_LOG_ENABLED:",
"if TYPE_CHECKING:",
]
[tool.coverage.run]
source = [
"src/",
]
omit = [
"*/tests/*",
"manage.py",
"paperless/wsgi.py",
"paperless/auth.py",
]
[tool.mypy-baseline]
baseline_path = ".mypy-baseline.txt"
sort_baseline = true

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@@ -16,7 +16,7 @@ test('basic filtering', async ({ page }) => {
await expect(page).toHaveURL(/tags__id__all=9/)
await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
await page.getByRole('button', { name: 'Document type' }).click()
await page.getByRole('menuitem', { name: /^Invoice Test/ }).click()
await page.getByRole('menuitem', { name: 'Invoice Test 3' }).click()
await expect(page).toHaveURL(/document_type__id__in=1/)
await expect(page.locator('pngx-document-list')).toHaveText(/3 documents/)
await page.getByRole('button', { name: 'Reset filters' }).first().click()
@@ -49,11 +49,11 @@ test('text filtering', async ({ page }) => {
await page.getByRole('main').getByRole('combobox').click()
await page.getByRole('main').getByRole('combobox').fill('test')
await expect(page.locator('pngx-document-list')).toHaveText(/32 documents/)
await expect(page).toHaveURL(/text=test/)
await expect(page).toHaveURL(/title_content=test/)
await page.getByRole('button', { name: 'Title & content' }).click()
await page.getByRole('button', { name: 'Title', exact: true }).click()
await expect(page.locator('pngx-document-list')).toHaveText(/9 documents/)
await expect(page).toHaveURL(/title_search=test/)
await expect(page).toHaveURL(/title__icontains=test/)
await page.getByRole('button', { name: 'Title', exact: true }).click()
await page.getByRole('button', { name: 'Advanced search' }).click()
await expect(page).toHaveURL(/query=test/)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "paperless-ngx-ui",
"version": "2.20.13",
"version": "2.20.10",
"scripts": {
"preinstall": "npx only-allow pnpm",
"ng": "ng",
@@ -11,17 +11,17 @@
},
"private": true,
"dependencies": {
"@angular/cdk": "^21.2.4",
"@angular/common": "~21.2.6",
"@angular/compiler": "~21.2.6",
"@angular/core": "~21.2.6",
"@angular/forms": "~21.2.6",
"@angular/localize": "~21.2.6",
"@angular/platform-browser": "~21.2.6",
"@angular/platform-browser-dynamic": "~21.2.6",
"@angular/router": "~21.2.6",
"@angular/cdk": "^21.2.0",
"@angular/common": "~21.2.0",
"@angular/compiler": "~21.2.0",
"@angular/core": "~21.2.0",
"@angular/forms": "~21.2.0",
"@angular/localize": "~21.2.0",
"@angular/platform-browser": "~21.2.0",
"@angular/platform-browser-dynamic": "~21.2.0",
"@angular/router": "~21.2.0",
"@ng-bootstrap/ng-bootstrap": "^20.0.0",
"@ng-select/ng-select": "^21.5.2",
"@ng-select/ng-select": "^21.4.1",
"@ngneat/dirty-check-forms": "^3.0.3",
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
@@ -29,7 +29,7 @@
"mime-names": "^1.0.0",
"ngx-bootstrap-icons": "^1.9.3",
"ngx-color": "^10.1.0",
"ngx-cookie-service": "^21.3.1",
"ngx-cookie-service": "^21.1.0",
"ngx-device-detector": "^11.0.0",
"ngx-ui-tour-ng-bootstrap": "^18.0.0",
"pdfjs-dist": "^5.4.624",
@@ -42,26 +42,26 @@
"devDependencies": {
"@angular-builders/custom-webpack": "^21.0.3",
"@angular-builders/jest": "^21.0.3",
"@angular-devkit/core": "^21.2.3",
"@angular-devkit/schematics": "^21.2.3",
"@angular-eslint/builder": "21.3.1",
"@angular-eslint/eslint-plugin": "21.3.1",
"@angular-eslint/eslint-plugin-template": "21.3.1",
"@angular-eslint/schematics": "21.3.1",
"@angular-eslint/template-parser": "21.3.1",
"@angular/build": "^21.2.3",
"@angular/cli": "~21.2.3",
"@angular/compiler-cli": "~21.2.6",
"@angular-devkit/core": "^21.2.0",
"@angular-devkit/schematics": "^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",
"@codecov/webpack-plugin": "^1.9.1",
"@playwright/test": "^1.58.2",
"@types/jest": "^30.0.0",
"@types/node": "^25.5.0",
"@typescript-eslint/eslint-plugin": "^8.57.2",
"@typescript-eslint/parser": "^8.57.2",
"@typescript-eslint/utils": "^8.57.2",
"eslint": "^10.1.0",
"jest": "30.3.0",
"jest-environment-jsdom": "^30.3.0",
"@types/node": "^25.3.3",
"@typescript-eslint/eslint-plugin": "^8.54.0",
"@typescript-eslint/parser": "^8.54.0",
"@typescript-eslint/utils": "^8.54.0",
"eslint": "^10.0.2",
"jest": "30.2.0",
"jest-environment-jsdom": "^30.2.0",
"jest-junit": "^16.0.0",
"jest-preset-angular": "^16.1.1",
"jest-websocket-mock": "^2.5.0",

2555
src-ui/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,7 +24,7 @@ import {
FILTER_HAS_DOCUMENT_TYPE_ANY,
FILTER_HAS_STORAGE_PATH_ANY,
FILTER_HAS_TAGS_ALL,
FILTER_SIMPLE_TEXT,
FILTER_TITLE_CONTENT,
} from 'src/app/data/filter-rule-type'
import { GlobalSearchType, SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
@@ -545,7 +545,7 @@ describe('GlobalSearchComponent', () => {
component.query = 'test'
component.runFullSearch()
expect(qfSpy).toHaveBeenCalledWith([
{ rule_type: FILTER_SIMPLE_TEXT, value: 'test' },
{ rule_type: FILTER_TITLE_CONTENT, value: 'test' },
])
settingsService.set(

View File

@@ -25,7 +25,7 @@ import {
FILTER_HAS_DOCUMENT_TYPE_ANY,
FILTER_HAS_STORAGE_PATH_ANY,
FILTER_HAS_TAGS_ALL,
FILTER_SIMPLE_TEXT,
FILTER_TITLE_CONTENT,
} from 'src/app/data/filter-rule-type'
import { ObjectWithId } from 'src/app/data/object-with-id'
import { GlobalSearchType, SETTINGS_KEYS } from 'src/app/data/ui-settings'
@@ -410,7 +410,7 @@ export class GlobalSearchComponent implements OnInit {
public runFullSearch() {
const ruleType = this.useAdvancedForFullSearch
? FILTER_FULLTEXT_QUERY
: FILTER_SIMPLE_TEXT
: FILTER_TITLE_CONTENT
this.documentService.searchQuery = this.useAdvancedForFullSearch
? this.query
: ''

View File

@@ -4,7 +4,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { NgbAccordionButton, NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { of, throwError } from 'rxjs'
import { FILTER_SIMPLE_TITLE } from 'src/app/data/filter-rule-type'
import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
import { DocumentService } from 'src/app/services/rest/document.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { SettingsService } from 'src/app/services/settings.service'
@@ -105,7 +105,7 @@ describe('StoragePathEditDialogComponent', () => {
null,
'created',
true,
[{ rule_type: FILTER_SIMPLE_TITLE, value: 'bar' }],
[{ rule_type: FILTER_TITLE, value: 'bar' }],
{ truncate_content: true }
)
listSpy.mockReturnValueOnce(

View File

@@ -23,7 +23,7 @@ import {
} from 'rxjs'
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
import { Document } from 'src/app/data/document'
import { FILTER_SIMPLE_TITLE } from 'src/app/data/filter-rule-type'
import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model'
import { StoragePath } from 'src/app/data/storage-path'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
@@ -146,7 +146,7 @@ export class StoragePathEditDialogComponent
null,
'created',
true,
[{ rule_type: FILTER_SIMPLE_TITLE, value: title }],
[{ rule_type: FILTER_TITLE, value: title }],
{ truncate_content: true }
)
.pipe(

View File

@@ -164,7 +164,7 @@
<pngx-input-text i18n-title title="Filter path" formControlName="filter_path" horizontal="true" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case-normalized." [error]="error?.filter_path"></pngx-input-text>
<pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules" horizontal="true" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select>
}
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled || formGroup.get('type').value === WorkflowTriggerType.VersionAdded) {
<pngx-input-select i18n-title title="Content matching algorithm" horizontal="true" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
@if (matchingPatternRequired(formGroup)) {
<pngx-input-text i18n-title title="Content matching pattern" horizontal="true" formControlName="match" [error]="error?.match"></pngx-input-text>
@@ -175,7 +175,7 @@
}
</div>
</div>
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled || formGroup.get('type').value === WorkflowTriggerType.VersionAdded) {
<div class="row mt-3">
<div class="col">
<div class="trigger-filters mb-3">

View File

@@ -120,6 +120,10 @@ export const WORKFLOW_TYPE_OPTIONS = [
id: WorkflowTriggerType.Scheduled,
name: $localize`Scheduled`,
},
{
id: WorkflowTriggerType.VersionAdded,
name: $localize`Version Added`,
},
]
export const WORKFLOW_ACTION_OPTIONS = [

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NG_VALUE_ACCESSOR } from '@angular/forms'
import { of, throwError } from 'rxjs'
import { FILTER_SIMPLE_TITLE } from 'src/app/data/filter-rule-type'
import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
import { DocumentService } from 'src/app/services/rest/document.service'
import { DocumentLinkComponent } from './document-link.component'
@@ -99,7 +99,7 @@ describe('DocumentLinkComponent', () => {
null,
'created',
true,
[{ rule_type: FILTER_SIMPLE_TITLE, value: 'bar' }],
[{ rule_type: FILTER_TITLE, value: 'bar' }],
{ truncate_content: true }
)
listSpy.mockReturnValueOnce(throwError(() => new Error()))

View File

@@ -28,7 +28,7 @@ import {
tap,
} from 'rxjs'
import { Document } from 'src/app/data/document'
import { FILTER_SIMPLE_TITLE } from 'src/app/data/filter-rule-type'
import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DocumentService } from 'src/app/services/rest/document.service'
import { AbstractInputComponent } from '../abstract-input'
@@ -121,7 +121,7 @@ export class DocumentLinkComponent
null,
'created',
true,
[{ rule_type: FILTER_SIMPLE_TITLE, value: title }],
[{ rule_type: FILTER_TITLE, value: title }],
{ truncate_content: true }
)
.pipe(

View File

@@ -959,7 +959,7 @@ describe('DocumentDetailComponent', () => {
component.reprocess()
const modalCloseSpy = jest.spyOn(openModal, 'close')
openModal.componentInstance.confirmClicked.next()
expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] })
expect(reprocessSpy).toHaveBeenCalledWith([doc.id])
expect(modalSpy).toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalled()
expect(modalCloseSpy).toHaveBeenCalled()

View File

@@ -1379,27 +1379,25 @@ export class DocumentDetailComponent
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled = false
this.documentsService
.reprocessDocuments({ documents: [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
)
},
})
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
)
},
})
})
}

View File

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

View File

@@ -13,7 +13,6 @@ import { of, throwError } from 'rxjs'
import { Correspondent } from 'src/app/data/correspondent'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { DocumentType } from 'src/app/data/document-type'
import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
import { Results } from 'src/app/data/results'
import { StoragePath } from 'src/app/data/storage-path'
import { Tag } from 'src/app/data/tag'
@@ -274,92 +273,6 @@ describe('BulkEditorComponent', () => {
expect(component.customFieldsSelectionModel.selectionSize()).toEqual(1)
})
it('should apply list selection data to tags menu when all filtered documents are selected', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
fixture.detectChanges()
jest
.spyOn(documentListViewService, 'allSelected', 'get')
.mockReturnValue(true)
jest
.spyOn(documentListViewService, 'selectedCount', 'get')
.mockReturnValue(3)
documentListViewService.selectionData = selectionData
const getSelectionDataSpy = jest.spyOn(documentService, 'getSelectionData')
component.openTagsDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.tagSelectionModel.selectionSize()).toEqual(1)
})
it('should apply list selection data to document types menu when all filtered documents are selected', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
fixture.detectChanges()
jest
.spyOn(documentListViewService, 'allSelected', 'get')
.mockReturnValue(true)
documentListViewService.selectionData = selectionData
const getSelectionDataSpy = jest.spyOn(documentService, 'getSelectionData')
component.openDocumentTypeDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.documentTypeDocumentCounts).toEqual(
selectionData.selected_document_types
)
})
it('should apply list selection data to correspondents menu when all filtered documents are selected', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
fixture.detectChanges()
jest
.spyOn(documentListViewService, 'allSelected', 'get')
.mockReturnValue(true)
documentListViewService.selectionData = selectionData
const getSelectionDataSpy = jest.spyOn(documentService, 'getSelectionData')
component.openCorrespondentDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.correspondentDocumentCounts).toEqual(
selectionData.selected_correspondents
)
})
it('should apply list selection data to storage paths menu when all filtered documents are selected', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
fixture.detectChanges()
jest
.spyOn(documentListViewService, 'allSelected', 'get')
.mockReturnValue(true)
documentListViewService.selectionData = selectionData
const getSelectionDataSpy = jest.spyOn(documentService, 'getSelectionData')
component.openStoragePathDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.storagePathDocumentCounts).toEqual(
selectionData.selected_storage_paths
)
})
it('should apply list selection data to custom fields menu when all filtered documents are selected', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
fixture.detectChanges()
jest
.spyOn(documentListViewService, 'allSelected', 'get')
.mockReturnValue(true)
documentListViewService.selectionData = selectionData
const getSelectionDataSpy = jest.spyOn(documentService, 'getSelectionData')
component.openCustomFieldsDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.customFieldDocumentCounts).toEqual(
selectionData.selected_custom_fields
)
})
it('should execute modify tags bulk operation', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
@@ -387,56 +300,13 @@ describe('BulkEditorComponent', () => {
parameters: { add_tags: [101], remove_tags: [] },
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
})
it('should execute modify tags bulk operation for all filtered documents', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
.spyOn(documentListViewService, 'documents', 'get')
.mockReturnValue([{ id: 3 }, { id: 4 }])
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 4]))
jest
.spyOn(documentListViewService, 'allSelected', 'get')
.mockReturnValue(true)
jest
.spyOn(documentListViewService, 'filterRules', 'get')
.mockReturnValue([{ rule_type: FILTER_TITLE, value: 'apple' }])
jest
.spyOn(documentListViewService, 'selectedCount', 'get')
.mockReturnValue(25)
jest
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
.mockReturnValue(true)
component.showConfirmationDialogs = false
fixture.detectChanges()
component.setTags({
itemsToAdd: [{ id: 101 }],
itemsToRemove: [],
})
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
expect(req.request.body).toEqual({
all: true,
filters: { title_search: 'apple' },
method: 'modify_tags',
parameters: { add_tags: [101], remove_tags: [] },
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
})
it('should execute modify tags bulk operation with confirmation dialog if enabled', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0]))
@@ -462,7 +332,7 @@ describe('BulkEditorComponent', () => {
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true)
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -553,7 +423,7 @@ describe('BulkEditorComponent', () => {
parameters: { correspondent: 101 },
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -585,7 +455,7 @@ describe('BulkEditorComponent', () => {
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true)
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -651,7 +521,7 @@ describe('BulkEditorComponent', () => {
parameters: { document_type: 101 },
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -683,7 +553,7 @@ describe('BulkEditorComponent', () => {
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true)
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -749,7 +619,7 @@ describe('BulkEditorComponent', () => {
parameters: { storage_path: 101 },
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -781,7 +651,7 @@ describe('BulkEditorComponent', () => {
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true)
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -847,7 +717,7 @@ describe('BulkEditorComponent', () => {
parameters: { add_custom_fields: [101], remove_custom_fields: [102] },
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -879,7 +749,7 @@ describe('BulkEditorComponent', () => {
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true)
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -988,7 +858,7 @@ describe('BulkEditorComponent', () => {
documents: [3, 4],
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -1081,7 +951,7 @@ describe('BulkEditorComponent', () => {
documents: [3, 4],
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -1116,7 +986,7 @@ describe('BulkEditorComponent', () => {
source_mode: 'latest_version',
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -1157,7 +1027,7 @@ describe('BulkEditorComponent', () => {
metadata_document_id: 3,
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -1176,7 +1046,7 @@ describe('BulkEditorComponent', () => {
delete_originals: true,
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -1197,7 +1067,7 @@ describe('BulkEditorComponent', () => {
archive_fallback: true,
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -1219,39 +1089,22 @@ describe('BulkEditorComponent', () => {
component.downloadForm.get('downloadFileTypeArchive').patchValue(true)
fixture.detectChanges()
let downloadSpy = jest.spyOn(documentService, 'bulkDownload')
downloadSpy.mockReturnValue(of(new Blob()))
//archive
component.downloadSelected()
expect(downloadSpy).toHaveBeenCalledWith(
{ documents: [3, 4] },
'archive',
false
)
expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'archive', false)
//originals
component.downloadForm.get('downloadFileTypeArchive').patchValue(false)
component.downloadForm.get('downloadFileTypeOriginals').patchValue(true)
component.downloadSelected()
expect(downloadSpy).toHaveBeenCalledWith(
{ documents: [3, 4] },
'originals',
false
)
expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'originals', false)
//both
component.downloadForm.get('downloadFileTypeArchive').patchValue(true)
component.downloadSelected()
expect(downloadSpy).toHaveBeenCalledWith(
{ documents: [3, 4] },
'both',
false
)
expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'both', false)
//formatting
component.downloadForm.get('downloadUseFormatting').patchValue(true)
component.downloadSelected()
expect(downloadSpy).toHaveBeenCalledWith(
{ documents: [3, 4] },
'both',
true
)
expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'both', true)
httpTestingController.match(
`${environment.apiBaseUrl}documents/bulk_download/`
@@ -1300,7 +1153,7 @@ describe('BulkEditorComponent', () => {
},
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -1597,7 +1450,6 @@ describe('BulkEditorComponent', () => {
expect(modal.componentInstance.customFields.length).toEqual(2)
expect(modal.componentInstance.fieldsToAddIds).toEqual([1, 2])
expect(modal.componentInstance.selection).toEqual({ documents: [3, 4] })
expect(modal.componentInstance.documents).toEqual([3, 4])
modal.componentInstance.failed.emit()
@@ -1608,7 +1460,7 @@ describe('BulkEditorComponent', () => {
expect(toastServiceShowInfoSpy).toHaveBeenCalled()
expect(listReloadSpy).toHaveBeenCalled()
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`

View File

@@ -16,7 +16,6 @@ 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'
import { SelectionDataItem } from 'src/app/data/results'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
@@ -31,9 +30,9 @@ import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import {
DocumentBulkEditMethod,
DocumentSelectionQuery,
DocumentService,
MergeDocumentsRequest,
SelectionDataItem,
} from 'src/app/services/rest/document.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
@@ -42,7 +41,6 @@ import { TagService } from 'src/app/services/rest/tag.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { flattenTags } from 'src/app/utils/flatten-tags'
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
@@ -263,13 +261,17 @@ export class BulkEditorComponent
modal: NgbModalRef,
method: DocumentBulkEditMethod,
args: any,
overrideSelection?: DocumentSelectionQuery
overrideDocumentIDs?: number[]
) {
if (modal) {
modal.componentInstance.buttonsEnabled = false
}
this.documentService
.bulkEdit(overrideSelection ?? this.getSelectionQuery(), method, args)
.bulkEdit(
overrideDocumentIDs ?? Array.from(this.list.selected),
method,
args
)
.pipe(first())
.subscribe({
next: () => this.handleOperationSuccess(modal),
@@ -327,7 +329,7 @@ export class BulkEditorComponent
) {
let selectionData = new Map<number, ToggleableItemState>()
items.forEach((i) => {
if (i.document_count == this.list.selectedCount) {
if (i.document_count == this.list.selected.size) {
selectionData.set(i.id, ToggleableItemState.Selected)
} else if (i.document_count > 0) {
selectionData.set(i.id, ToggleableItemState.PartiallySelected)
@@ -336,31 +338,7 @@ export class BulkEditorComponent
selectionModel.init(selectionData)
}
private getSelectionQuery(): DocumentSelectionQuery {
if (this.list.allSelected) {
return {
all: true,
filters: queryParamsFromFilterRules(this.list.filterRules),
}
}
return {
documents: Array.from(this.list.selected),
}
}
private getSelectionSize(): number {
return this.list.selectedCount
}
openTagsDropdown() {
if (this.list.allSelected) {
const selectionData = this.list.selectionData
this.tagDocumentCounts = selectionData?.selected_tags ?? []
this.applySelectionData(this.tagDocumentCounts, this.tagSelectionModel)
return
}
this.documentService
.getSelectionData(Array.from(this.list.selected))
.pipe(first())
@@ -371,17 +349,6 @@ export class BulkEditorComponent
}
openDocumentTypeDropdown() {
if (this.list.allSelected) {
const selectionData = this.list.selectionData
this.documentTypeDocumentCounts =
selectionData?.selected_document_types ?? []
this.applySelectionData(
this.documentTypeDocumentCounts,
this.documentTypeSelectionModel
)
return
}
this.documentService
.getSelectionData(Array.from(this.list.selected))
.pipe(first())
@@ -395,17 +362,6 @@ export class BulkEditorComponent
}
openCorrespondentDropdown() {
if (this.list.allSelected) {
const selectionData = this.list.selectionData
this.correspondentDocumentCounts =
selectionData?.selected_correspondents ?? []
this.applySelectionData(
this.correspondentDocumentCounts,
this.correspondentSelectionModel
)
return
}
this.documentService
.getSelectionData(Array.from(this.list.selected))
.pipe(first())
@@ -419,17 +375,6 @@ export class BulkEditorComponent
}
openStoragePathDropdown() {
if (this.list.allSelected) {
const selectionData = this.list.selectionData
this.storagePathDocumentCounts =
selectionData?.selected_storage_paths ?? []
this.applySelectionData(
this.storagePathDocumentCounts,
this.storagePathsSelectionModel
)
return
}
this.documentService
.getSelectionData(Array.from(this.list.selected))
.pipe(first())
@@ -443,17 +388,6 @@ export class BulkEditorComponent
}
openCustomFieldsDropdown() {
if (this.list.allSelected) {
const selectionData = this.list.selectionData
this.customFieldDocumentCounts =
selectionData?.selected_custom_fields ?? []
this.applySelectionData(
this.customFieldDocumentCounts,
this.customFieldsSelectionModel
)
return
}
this.documentService
.getSelectionData(Array.from(this.list.selected))
.pipe(first())
@@ -503,33 +437,33 @@ export class BulkEditorComponent
changedTags.itemsToRemove.length == 0
) {
let tag = changedTags.itemsToAdd[0]
modal.componentInstance.message = $localize`This operation will add the tag "${tag.name}" to ${this.getSelectionSize()} selected document(s).`
modal.componentInstance.message = $localize`This operation will add the tag "${tag.name}" to ${this.list.selected.size} selected document(s).`
} else if (
changedTags.itemsToAdd.length > 1 &&
changedTags.itemsToRemove.length == 0
) {
modal.componentInstance.message = $localize`This operation will add the tags ${this._localizeList(
changedTags.itemsToAdd
)} to ${this.getSelectionSize()} selected document(s).`
)} to ${this.list.selected.size} selected document(s).`
} else if (
changedTags.itemsToAdd.length == 0 &&
changedTags.itemsToRemove.length == 1
) {
let tag = changedTags.itemsToRemove[0]
modal.componentInstance.message = $localize`This operation will remove the tag "${tag.name}" from ${this.getSelectionSize()} selected document(s).`
modal.componentInstance.message = $localize`This operation will remove the tag "${tag.name}" from ${this.list.selected.size} selected document(s).`
} else if (
changedTags.itemsToAdd.length == 0 &&
changedTags.itemsToRemove.length > 1
) {
modal.componentInstance.message = $localize`This operation will remove the tags ${this._localizeList(
changedTags.itemsToRemove
)} from ${this.getSelectionSize()} selected document(s).`
)} from ${this.list.selected.size} selected document(s).`
} else {
modal.componentInstance.message = $localize`This operation will add the tags ${this._localizeList(
changedTags.itemsToAdd
)} and remove the tags ${this._localizeList(
changedTags.itemsToRemove
)} on ${this.getSelectionSize()} selected document(s).`
)} on ${this.list.selected.size} selected document(s).`
}
modal.componentInstance.btnClass = 'btn-warning'
@@ -568,9 +502,9 @@ export class BulkEditorComponent
})
modal.componentInstance.title = $localize`Confirm correspondent assignment`
if (correspondent) {
modal.componentInstance.message = $localize`This operation will assign the correspondent "${correspondent.name}" to ${this.getSelectionSize()} selected document(s).`
modal.componentInstance.message = $localize`This operation will assign the correspondent "${correspondent.name}" to ${this.list.selected.size} selected document(s).`
} else {
modal.componentInstance.message = $localize`This operation will remove the correspondent from ${this.getSelectionSize()} selected document(s).`
modal.componentInstance.message = $localize`This operation will remove the correspondent from ${this.list.selected.size} selected document(s).`
}
modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Confirm`
@@ -606,9 +540,9 @@ export class BulkEditorComponent
})
modal.componentInstance.title = $localize`Confirm document type assignment`
if (documentType) {
modal.componentInstance.message = $localize`This operation will assign the document type "${documentType.name}" to ${this.getSelectionSize()} selected document(s).`
modal.componentInstance.message = $localize`This operation will assign the document type "${documentType.name}" to ${this.list.selected.size} selected document(s).`
} else {
modal.componentInstance.message = $localize`This operation will remove the document type from ${this.getSelectionSize()} selected document(s).`
modal.componentInstance.message = $localize`This operation will remove the document type from ${this.list.selected.size} selected document(s).`
}
modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Confirm`
@@ -644,9 +578,9 @@ export class BulkEditorComponent
})
modal.componentInstance.title = $localize`Confirm storage path assignment`
if (storagePath) {
modal.componentInstance.message = $localize`This operation will assign the storage path "${storagePath.name}" to ${this.getSelectionSize()} selected document(s).`
modal.componentInstance.message = $localize`This operation will assign the storage path "${storagePath.name}" to ${this.list.selected.size} selected document(s).`
} else {
modal.componentInstance.message = $localize`This operation will remove the storage path from ${this.getSelectionSize()} selected document(s).`
modal.componentInstance.message = $localize`This operation will remove the storage path from ${this.list.selected.size} selected document(s).`
}
modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Confirm`
@@ -681,33 +615,33 @@ export class BulkEditorComponent
changedCustomFields.itemsToRemove.length == 0
) {
let customField = changedCustomFields.itemsToAdd[0]
modal.componentInstance.message = $localize`This operation will assign the custom field "${customField.name}" to ${this.getSelectionSize()} selected document(s).`
modal.componentInstance.message = $localize`This operation will assign the custom field "${customField.name}" to ${this.list.selected.size} selected document(s).`
} else if (
changedCustomFields.itemsToAdd.length > 1 &&
changedCustomFields.itemsToRemove.length == 0
) {
modal.componentInstance.message = $localize`This operation will assign the custom fields ${this._localizeList(
changedCustomFields.itemsToAdd
)} to ${this.getSelectionSize()} selected document(s).`
)} to ${this.list.selected.size} selected document(s).`
} else if (
changedCustomFields.itemsToAdd.length == 0 &&
changedCustomFields.itemsToRemove.length == 1
) {
let customField = changedCustomFields.itemsToRemove[0]
modal.componentInstance.message = $localize`This operation will remove the custom field "${customField.name}" from ${this.getSelectionSize()} selected document(s).`
modal.componentInstance.message = $localize`This operation will remove the custom field "${customField.name}" from ${this.list.selected.size} selected document(s).`
} else if (
changedCustomFields.itemsToAdd.length == 0 &&
changedCustomFields.itemsToRemove.length > 1
) {
modal.componentInstance.message = $localize`This operation will remove the custom fields ${this._localizeList(
changedCustomFields.itemsToRemove
)} from ${this.getSelectionSize()} selected document(s).`
)} from ${this.list.selected.size} selected document(s).`
} else {
modal.componentInstance.message = $localize`This operation will assign the custom fields ${this._localizeList(
changedCustomFields.itemsToAdd
)} and remove the custom fields ${this._localizeList(
changedCustomFields.itemsToRemove
)} on ${this.getSelectionSize()} selected document(s).`
)} on ${this.list.selected.size} selected document(s).`
}
modal.componentInstance.btnClass = 'btn-warning'
@@ -845,7 +779,7 @@ export class BulkEditorComponent
backdrop: 'static',
})
modal.componentInstance.title = $localize`Confirm`
modal.componentInstance.messageBold = $localize`Move ${this.getSelectionSize()} selected document(s) to the trash?`
modal.componentInstance.messageBold = $localize`Move ${this.list.selected.size} selected document(s) to the trash?`
modal.componentInstance.message = $localize`Documents can be restored prior to permanent deletion.`
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Move to trash`
@@ -855,13 +789,13 @@ export class BulkEditorComponent
modal.componentInstance.buttonsEnabled = false
this.executeDocumentAction(
modal,
this.documentService.deleteDocuments(this.getSelectionQuery())
this.documentService.deleteDocuments(Array.from(this.list.selected))
)
})
} else {
this.executeDocumentAction(
null,
this.documentService.deleteDocuments(this.getSelectionQuery())
this.documentService.deleteDocuments(Array.from(this.list.selected))
)
}
}
@@ -877,7 +811,7 @@ export class BulkEditorComponent
: 'originals'
this.documentService
.bulkDownload(
this.getSelectionQuery(),
Array.from(this.list.selected),
downloadFileType,
this.downloadForm.get('downloadUseFormatting').value
)
@@ -893,7 +827,7 @@ export class BulkEditorComponent
backdrop: 'static',
})
modal.componentInstance.title = $localize`Reprocess confirm`
modal.componentInstance.messageBold = $localize`This operation will permanently recreate the archive files for ${this.getSelectionSize()} selected document(s).`
modal.componentInstance.messageBold = $localize`This operation will permanently recreate the archive files for ${this.list.selected.size} selected document(s).`
modal.componentInstance.message = $localize`The archive files will be re-generated with the current settings.`
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed`
@@ -903,7 +837,9 @@ export class BulkEditorComponent
modal.componentInstance.buttonsEnabled = false
this.executeDocumentAction(
modal,
this.documentService.reprocessDocuments(this.getSelectionQuery())
this.documentService.reprocessDocuments(
Array.from(this.list.selected)
)
)
})
}
@@ -930,7 +866,7 @@ export class BulkEditorComponent
})
const rotateDialog = modal.componentInstance as RotateConfirmDialogComponent
rotateDialog.title = $localize`Rotate confirm`
rotateDialog.messageBold = $localize`This operation will add rotated versions of the ${this.getSelectionSize()} document(s).`
rotateDialog.messageBold = $localize`This operation will add rotated versions of the ${this.list.selected.size} document(s).`
rotateDialog.btnClass = 'btn-danger'
rotateDialog.btnCaption = $localize`Proceed`
rotateDialog.documentID = Array.from(this.list.selected)[0]
@@ -941,7 +877,7 @@ export class BulkEditorComponent
this.executeDocumentAction(
modal,
this.documentService.rotateDocuments(
this.getSelectionQuery(),
Array.from(this.list.selected),
rotateDialog.degrees
)
)
@@ -954,7 +890,7 @@ export class BulkEditorComponent
})
const mergeDialog = modal.componentInstance as MergeConfirmDialogComponent
mergeDialog.title = $localize`Merge confirm`
mergeDialog.messageBold = $localize`This operation will merge ${this.getSelectionSize()} selected documents into a new document.`
mergeDialog.messageBold = $localize`This operation will merge ${this.list.selected.size} selected documents into a new document.`
mergeDialog.btnCaption = $localize`Proceed`
mergeDialog.documentIDs = Array.from(this.list.selected)
mergeDialog.confirmClicked
@@ -999,7 +935,7 @@ export class BulkEditorComponent
(item) => item.id
)
dialog.selection = this.getSelectionQuery()
dialog.documents = Array.from(this.list.selected)
dialog.succeeded.subscribe((result) => {
this.toastService.showInfo($localize`Custom fields updated.`)
this.list.reload()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -67,8 +67,6 @@ import {
FILTER_OWNER_DOES_NOT_INCLUDE,
FILTER_OWNER_ISNULL,
FILTER_SHARED_BY_USER,
FILTER_SIMPLE_TEXT,
FILTER_SIMPLE_TITLE,
FILTER_STORAGE_PATH,
FILTER_TITLE,
FILTER_TITLE_CONTENT,
@@ -314,7 +312,7 @@ describe('FilterEditorComponent', () => {
expect(component.textFilter).toEqual(null)
component.filterRules = [
{
rule_type: FILTER_SIMPLE_TEXT,
rule_type: FILTER_TITLE_CONTENT,
value: 'foo',
},
]
@@ -322,18 +320,6 @@ describe('FilterEditorComponent', () => {
expect(component.textFilterTarget).toEqual('title-content') // TEXT_FILTER_TARGET_TITLE_CONTENT
}))
it('should ingest legacy text filter rules for doc title + content', fakeAsync(() => {
expect(component.textFilter).toEqual(null)
component.filterRules = [
{
rule_type: FILTER_TITLE_CONTENT,
value: 'legacy foo',
},
]
expect(component.textFilter).toEqual('legacy foo')
expect(component.textFilterTarget).toEqual('title-content') // TEXT_FILTER_TARGET_TITLE_CONTENT
}))
it('should ingest text filter rules for doc asn', fakeAsync(() => {
expect(component.textFilter).toEqual(null)
component.filterRules = [
@@ -1131,7 +1117,7 @@ describe('FilterEditorComponent', () => {
expect(component.textFilter).toEqual('foo')
expect(component.filterRules).toEqual([
{
rule_type: FILTER_SIMPLE_TEXT,
rule_type: FILTER_TITLE_CONTENT,
value: 'foo',
},
])
@@ -1150,7 +1136,7 @@ describe('FilterEditorComponent', () => {
expect(component.textFilterTarget).toEqual('title')
expect(component.filterRules).toEqual([
{
rule_type: FILTER_SIMPLE_TITLE,
rule_type: FILTER_TITLE,
value: 'foo',
},
])
@@ -1264,12 +1250,30 @@ describe('FilterEditorComponent', () => {
])
}))
it('should convert user input to correct filter rules on custom fields query', fakeAsync(() => {
component.textFilterInput.nativeElement.value = 'foo'
component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
const textFieldTargetDropdown = fixture.debugElement.queryAll(
By.directive(NgbDropdownItem)
)[3]
textFieldTargetDropdown.triggerEventHandler('click') // TEXT_FILTER_TARGET_CUSTOM_FIELDS
fixture.detectChanges()
tick(400)
expect(component.textFilterTarget).toEqual('custom-fields')
expect(component.filterRules).toEqual([
{
rule_type: FILTER_CUSTOM_FIELDS_TEXT,
value: 'foo',
},
])
}))
it('should convert user input to correct filter rules on mime type', fakeAsync(() => {
component.textFilterInput.nativeElement.value = 'pdf'
component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
const textFieldTargetDropdown = fixture.debugElement.queryAll(
By.directive(NgbDropdownItem)
)[3]
)[4]
textFieldTargetDropdown.triggerEventHandler('click') // TEXT_FILTER_TARGET_MIME_TYPE
fixture.detectChanges()
tick(400)
@@ -1287,8 +1291,8 @@ describe('FilterEditorComponent', () => {
component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
const textFieldTargetDropdown = fixture.debugElement.queryAll(
By.directive(NgbDropdownItem)
)[4]
textFieldTargetDropdown.triggerEventHandler('click') // TEXT_FILTER_TARGET_FULLTEXT_QUERY
)[5]
textFieldTargetDropdown.triggerEventHandler('click') // TEXT_FILTER_TARGET_ASN
fixture.detectChanges()
tick(400)
expect(component.textFilterTarget).toEqual('fulltext-query')
@@ -1692,56 +1696,12 @@ describe('FilterEditorComponent', () => {
])
}))
it('should convert legacy title filters into full text query when adding a created relative date', fakeAsync(() => {
component.filterRules = [
{
rule_type: FILTER_TITLE,
value: 'foo',
},
]
const dateCreatedDropdown = fixture.debugElement.queryAll(
By.directive(DatesDropdownComponent)
)[0]
component.dateCreatedRelativeDate = RelativeDate.WITHIN_1_WEEK
dateCreatedDropdown.triggerEventHandler('datesSet')
fixture.detectChanges()
tick(400)
expect(component.filterRules).toEqual([
{
rule_type: FILTER_FULLTEXT_QUERY,
value: 'foo,created:[-1 week to now]',
},
])
}))
it('should convert simple title filters into full text query when adding a created relative date', fakeAsync(() => {
component.filterRules = [
{
rule_type: FILTER_SIMPLE_TITLE,
value: 'foo',
},
]
const dateCreatedDropdown = fixture.debugElement.queryAll(
By.directive(DatesDropdownComponent)
)[0]
component.dateCreatedRelativeDate = RelativeDate.WITHIN_1_WEEK
dateCreatedDropdown.triggerEventHandler('datesSet')
fixture.detectChanges()
tick(400)
expect(component.filterRules).toEqual([
{
rule_type: FILTER_FULLTEXT_QUERY,
value: 'foo,created:[-1 week to now]',
},
])
}))
it('should leave relative dates not in quick list intact', fakeAsync(() => {
component.textFilterInput.nativeElement.value = 'created:[-2 week to now]'
component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
const textFieldTargetDropdown = fixture.debugElement.queryAll(
By.directive(NgbDropdownItem)
)[4]
)[5]
textFieldTargetDropdown.triggerEventHandler('click')
fixture.detectChanges()
tick(400)
@@ -2071,30 +2031,12 @@ describe('FilterEditorComponent', () => {
component.filterRules = [
{
rule_type: FILTER_SIMPLE_TITLE,
rule_type: FILTER_TITLE,
value: 'foo',
},
]
expect(component.generateFilterName()).toEqual('Title: foo')
component.filterRules = [
{
rule_type: FILTER_TITLE_CONTENT,
value: 'legacy foo',
},
]
expect(component.generateFilterName()).toEqual(
'Title & content: legacy foo'
)
component.filterRules = [
{
rule_type: FILTER_SIMPLE_TEXT,
value: 'foo',
},
]
expect(component.generateFilterName()).toEqual('Title & content: foo')
component.filterRules = [
{
rule_type: FILTER_ASN,
@@ -2214,36 +2156,6 @@ describe('FilterEditorComponent', () => {
})
})
it('should hide deprecated custom fields target from default text filter targets', () => {
expect(component.textFilterTargets).not.toContainEqual({
id: 'custom-fields',
name: $localize`Custom fields (Deprecated)`,
})
})
it('should keep deprecated custom fields target available for legacy filters', fakeAsync(() => {
component.filterRules = [
{
rule_type: FILTER_CUSTOM_FIELDS_TEXT,
value: 'foo',
},
]
fixture.detectChanges()
tick()
expect(component.textFilterTarget).toEqual('custom-fields')
expect(component.textFilterTargets).toContainEqual({
id: 'custom-fields',
name: $localize`Custom fields (Deprecated)`,
})
expect(component.filterRules).toEqual([
{
rule_type: FILTER_CUSTOM_FIELDS_TEXT,
value: 'foo',
},
])
}))
it('should call autocomplete endpoint on input', fakeAsync(() => {
component.textFilterTarget = 'fulltext-query' // TEXT_FILTER_TARGET_FULLTEXT_QUERY
const autocompleteSpy = jest.spyOn(searchService, 'autocomplete')

View File

@@ -71,14 +71,11 @@ import {
FILTER_OWNER_DOES_NOT_INCLUDE,
FILTER_OWNER_ISNULL,
FILTER_SHARED_BY_USER,
FILTER_SIMPLE_TEXT,
FILTER_SIMPLE_TITLE,
FILTER_STORAGE_PATH,
FILTER_TITLE,
FILTER_TITLE_CONTENT,
NEGATIVE_NULL_FILTER_VALUE,
} from 'src/app/data/filter-rule-type'
import { SelectionData, SelectionDataItem } from 'src/app/data/results'
import {
PermissionAction,
PermissionType,
@@ -87,7 +84,11 @@ 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 {
DocumentService,
SelectionData,
SelectionDataItem,
} from 'src/app/services/rest/document.service'
import { SearchService } from 'src/app/services/rest/search.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { TagService } from 'src/app/services/rest/tag.service'
@@ -197,6 +198,10 @@ const DEFAULT_TEXT_FILTER_TARGET_OPTIONS = [
name: $localize`Title & content`,
},
{ id: TEXT_FILTER_TARGET_ASN, name: $localize`ASN` },
{
id: TEXT_FILTER_TARGET_CUSTOM_FIELDS,
name: $localize`Custom fields`,
},
{ id: TEXT_FILTER_TARGET_MIME_TYPE, name: $localize`File type` },
{
id: TEXT_FILTER_TARGET_FULLTEXT_QUERY,
@@ -204,12 +209,6 @@ const DEFAULT_TEXT_FILTER_TARGET_OPTIONS = [
},
]
const DEPRECATED_CUSTOM_FIELDS_TEXT_FILTER_TARGET_OPTION = {
// Kept only so legacy saved views can render and be edited away from, remove me eventually
id: TEXT_FILTER_TARGET_CUSTOM_FIELDS,
name: $localize`Custom fields (Deprecated)`,
}
const TEXT_FILTER_TARGET_MORELIKE_OPTION = {
id: TEXT_FILTER_TARGET_FULLTEXT_MORELIKE,
name: $localize`More like`,
@@ -322,13 +321,8 @@ export class FilterEditorComponent
return $localize`Custom fields query`
case FILTER_TITLE:
case FILTER_SIMPLE_TITLE:
return $localize`Title: ${rule.value}`
case FILTER_TITLE_CONTENT:
case FILTER_SIMPLE_TEXT:
return $localize`Title & content: ${rule.value}`
case FILTER_ASN:
return $localize`ASN: ${rule.value}`
@@ -362,16 +356,12 @@ export class FilterEditorComponent
_moreLikeDoc: Document
get textFilterTargets() {
let targets = DEFAULT_TEXT_FILTER_TARGET_OPTIONS
if (this.textFilterTarget == TEXT_FILTER_TARGET_FULLTEXT_MORELIKE) {
targets = targets.concat([TEXT_FILTER_TARGET_MORELIKE_OPTION])
}
if (this.textFilterTarget == TEXT_FILTER_TARGET_CUSTOM_FIELDS) {
targets = targets.concat([
DEPRECATED_CUSTOM_FIELDS_TEXT_FILTER_TARGET_OPTION,
return DEFAULT_TEXT_FILTER_TARGET_OPTIONS.concat([
TEXT_FILTER_TARGET_MORELIKE_OPTION,
])
}
return targets
return DEFAULT_TEXT_FILTER_TARGET_OPTIONS
}
textFilterTarget = TEXT_FILTER_TARGET_TITLE_CONTENT
@@ -450,12 +440,10 @@ export class FilterEditorComponent
value.forEach((rule) => {
switch (rule.rule_type) {
case FILTER_TITLE:
case FILTER_SIMPLE_TITLE:
this._textFilter = rule.value
this.textFilterTarget = TEXT_FILTER_TARGET_TITLE
break
case FILTER_TITLE_CONTENT:
case FILTER_SIMPLE_TEXT:
this._textFilter = rule.value
this.textFilterTarget = TEXT_FILTER_TARGET_TITLE_CONTENT
break
@@ -777,15 +765,12 @@ export class FilterEditorComponent
this.textFilterTarget == TEXT_FILTER_TARGET_TITLE_CONTENT
) {
filterRules.push({
rule_type: FILTER_SIMPLE_TEXT,
rule_type: FILTER_TITLE_CONTENT,
value: this._textFilter.trim(),
})
}
if (this._textFilter && this.textFilterTarget == TEXT_FILTER_TARGET_TITLE) {
filterRules.push({
rule_type: FILTER_SIMPLE_TITLE,
value: this._textFilter,
})
filterRules.push({ rule_type: FILTER_TITLE, value: this._textFilter })
}
if (this.textFilterTarget == TEXT_FILTER_TARGET_ASN) {
if (
@@ -1027,10 +1012,7 @@ export class FilterEditorComponent
) {
existingRule = filterRules.find(
(fr) =>
fr.rule_type == FILTER_TITLE_CONTENT ||
fr.rule_type == FILTER_SIMPLE_TEXT ||
fr.rule_type == FILTER_TITLE ||
fr.rule_type == FILTER_SIMPLE_TITLE
fr.rule_type == FILTER_TITLE_CONTENT || fr.rule_type == FILTER_TITLE
)
existingRule.rule_type = FILTER_FULLTEXT_QUERY
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import { DataType } from './datatype'
export const NEGATIVE_NULL_FILTER_VALUE = -1
// These correspond to src/documents/models.py and changes here require a DB migration (and vice versa)
export const FILTER_TITLE = 0 // Deprecated in favor of Tantivy-backed `title_search`. Keep for now for existing saved views
export const FILTER_TITLE = 0
export const FILTER_CONTENT = 1
export const FILTER_ASN = 2
@@ -46,9 +46,7 @@ export const FILTER_ADDED_FROM = 46
export const FILTER_MODIFIED_BEFORE = 15
export const FILTER_MODIFIED_AFTER = 16
export const FILTER_TITLE_CONTENT = 19 // Deprecated in favor of Tantivy-backed `text` filtervar. Keep for now for existing saved views
export const FILTER_SIMPLE_TITLE = 48
export const FILTER_SIMPLE_TEXT = 49
export const FILTER_TITLE_CONTENT = 19
export const FILTER_FULLTEXT_QUERY = 20
export const FILTER_FULLTEXT_MORELIKE = 21
@@ -58,7 +56,7 @@ export const FILTER_OWNER_ISNULL = 34
export const FILTER_OWNER_DOES_NOT_INCLUDE = 35
export const FILTER_SHARED_BY_USER = 37
export const FILTER_CUSTOM_FIELDS_TEXT = 36 // Deprecated. UI no longer includes CF text-search mode. Keep for now for existing saved views
export const FILTER_CUSTOM_FIELDS_TEXT = 36
export const FILTER_HAS_CUSTOM_FIELDS_ALL = 38
export const FILTER_HAS_CUSTOM_FIELDS_ANY = 39
export const FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS = 40
@@ -68,9 +66,6 @@ export const FILTER_CUSTOM_FIELDS_QUERY = 42
export const FILTER_MIME_TYPE = 47
export const SIMPLE_TEXT_PARAMETER = 'text'
export const SIMPLE_TITLE_PARAMETER = 'title_search'
export const FILTER_RULE_TYPES: FilterRuleType[] = [
{
id: FILTER_TITLE,
@@ -79,13 +74,6 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
multi: false,
default: '',
},
{
id: FILTER_SIMPLE_TITLE,
filtervar: SIMPLE_TITLE_PARAMETER,
datatype: 'string',
multi: false,
default: '',
},
{
id: FILTER_CONTENT,
filtervar: 'content__icontains',
@@ -291,12 +279,6 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
datatype: 'string',
multi: false,
},
{
id: FILTER_SIMPLE_TEXT,
filtervar: SIMPLE_TEXT_PARAMETER,
datatype: 'string',
multi: false,
},
{
id: FILTER_FULLTEXT_QUERY,
filtervar: 'query',

View File

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

View File

@@ -12,6 +12,7 @@ export enum WorkflowTriggerType {
DocumentAdded = 2,
DocumentUpdated = 3,
Scheduled = 4,
VersionAdded = 5,
}
export enum ScheduleDateField {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ import {
DOCUMENT_SORT_FIELDS,
DOCUMENT_SORT_FIELDS_FULLTEXT,
} from 'src/app/data/document'
import { FILTER_SIMPLE_TITLE } from 'src/app/data/filter-rule-type'
import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { environment } from 'src/environments/environment'
import { PermissionsService } from '../permissions.service'
@@ -138,13 +138,13 @@ describe(`DocumentService`, () => {
subscription = service
.listAllFilteredIds([
{
rule_type: FILTER_SIMPLE_TITLE,
rule_type: FILTER_TITLE,
value: 'apple',
},
])
.subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000&fields=id&title_search=apple`
`${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000&fields=id&title__icontains=apple`
)
expect(req.request.method).toEqual('GET')
})
@@ -198,7 +198,7 @@ describe(`DocumentService`, () => {
const content = 'both'
const useFilenameFormatting = false
subscription = service
.bulkDownload({ documents: ids }, content, useFilenameFormatting)
.bulkDownload(ids, content, useFilenameFormatting)
.subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/bulk_download/`
@@ -218,9 +218,7 @@ describe(`DocumentService`, () => {
add_tags: [15],
remove_tags: [6],
}
subscription = service
.bulkEdit({ documents: ids }, method, parameters)
.subscribe()
subscription = service.bulkEdit(ids, method, parameters).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/bulk_edit/`
)
@@ -232,32 +230,9 @@ describe(`DocumentService`, () => {
})
})
it('should call appropriate api endpoint for bulk edit with all and filters', () => {
const method = 'modify_tags'
const parameters = {
add_tags: [15],
remove_tags: [6],
}
const selection = {
all: true,
filters: { title__icontains: 'apple' },
}
subscription = service.bulkEdit(selection, method, parameters).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/bulk_edit/`
)
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({
all: true,
filters: { title__icontains: 'apple' },
method,
parameters,
})
})
it('should call appropriate api endpoint for delete documents', () => {
const ids = [1, 2, 3]
subscription = service.deleteDocuments({ documents: ids }).subscribe()
subscription = service.deleteDocuments(ids).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/delete/`
)
@@ -269,7 +244,7 @@ describe(`DocumentService`, () => {
it('should call appropriate api endpoint for reprocess documents', () => {
const ids = [1, 2, 3]
subscription = service.reprocessDocuments({ documents: ids }).subscribe()
subscription = service.reprocessDocuments(ids).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/reprocess/`
)
@@ -281,7 +256,7 @@ describe(`DocumentService`, () => {
it('should call appropriate api endpoint for rotate documents', () => {
const ids = [1, 2, 3]
subscription = service.rotateDocuments({ documents: ids }, 90).subscribe()
subscription = service.rotateDocuments(ids, 90).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/rotate/`
)

View File

@@ -12,7 +12,7 @@ import {
import { DocumentMetadata } from 'src/app/data/document-metadata'
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
import { FilterRule } from 'src/app/data/filter-rule'
import { Results, SelectionData } from 'src/app/data/results'
import { Results } from 'src/app/data/results'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { queryParamsFromFilterRules } from '../../utils/query-params'
import {
@@ -24,6 +24,19 @@ import { SettingsService } from '../settings.service'
import { AbstractPaperlessService } from './abstract-paperless-service'
import { CustomFieldsService } from './custom-fields.service'
export interface SelectionDataItem {
id: number
document_count: number
}
export interface SelectionData {
selected_storage_paths: SelectionDataItem[]
selected_correspondents: SelectionDataItem[]
selected_tags: SelectionDataItem[]
selected_document_types: SelectionDataItem[]
selected_custom_fields: SelectionDataItem[]
}
export enum BulkEditSourceMode {
LATEST_VERSION = 'latest_version',
EXPLICIT_SELECTION = 'explicit_selection',
@@ -68,12 +81,6 @@ export interface RemovePasswordDocumentsRequest {
source_mode?: BulkEditSourceMode
}
export interface DocumentSelectionQuery {
documents?: number[]
all?: boolean
filters?: { [key: string]: any }
}
@Injectable({
providedIn: 'root',
})
@@ -331,37 +338,33 @@ export class DocumentService extends AbstractPaperlessService<Document> {
return this.http.get<DocumentMetadata>(url.toString())
}
bulkEdit(
selection: DocumentSelectionQuery,
method: DocumentBulkEditMethod,
args: any
) {
bulkEdit(ids: number[], method: DocumentBulkEditMethod, args: any) {
return this.http.post(this.getResourceUrl(null, 'bulk_edit'), {
...selection,
documents: ids,
method: method,
parameters: args,
})
}
deleteDocuments(selection: DocumentSelectionQuery) {
deleteDocuments(ids: number[]) {
return this.http.post(this.getResourceUrl(null, 'delete'), {
...selection,
documents: ids,
})
}
reprocessDocuments(selection: DocumentSelectionQuery) {
reprocessDocuments(ids: number[]) {
return this.http.post(this.getResourceUrl(null, 'reprocess'), {
...selection,
documents: ids,
})
}
rotateDocuments(
selection: DocumentSelectionQuery,
ids: number[],
degrees: number,
sourceMode: BulkEditSourceMode = BulkEditSourceMode.LATEST_VERSION
) {
return this.http.post(this.getResourceUrl(null, 'rotate'), {
...selection,
documents: ids,
degrees,
source_mode: sourceMode,
})
@@ -409,14 +412,14 @@ export class DocumentService extends AbstractPaperlessService<Document> {
}
bulkDownload(
selection: DocumentSelectionQuery,
ids: number[],
content = 'both',
useFilenameFormatting: boolean = false
) {
return this.http.post(
this.getResourceUrl(null, 'bulk_download'),
{
...selection,
documents: ids,
content: content,
follow_formatting: useFilenameFormatting,
},

View File

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

View File

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

View File

@@ -8,10 +8,6 @@ import {
FILTER_HAS_CUSTOM_FIELDS_ALL,
FILTER_HAS_CUSTOM_FIELDS_ANY,
FILTER_HAS_TAGS_ALL,
FILTER_SIMPLE_TEXT,
FILTER_SIMPLE_TITLE,
FILTER_TITLE,
FILTER_TITLE_CONTENT,
NEGATIVE_NULL_FILTER_VALUE,
} from '../data/filter-rule-type'
import {
@@ -132,26 +128,6 @@ describe('QueryParams Utils', () => {
is_tagged: 0,
})
params = queryParamsFromFilterRules([
{
rule_type: FILTER_TITLE_CONTENT,
value: 'bank statement',
},
])
expect(params).toEqual({
text: 'bank statement',
})
params = queryParamsFromFilterRules([
{
rule_type: FILTER_TITLE,
value: 'invoice',
},
])
expect(params).toEqual({
title_search: 'invoice',
})
params = queryParamsFromFilterRules([
{
rule_type: FILTER_HAS_TAGS_ALL,
@@ -172,30 +148,6 @@ describe('QueryParams Utils', () => {
it('should convert filter rules to query params', () => {
let rules = filterRulesFromQueryParams(
convertToParamMap({
text: 'bank statement',
})
)
expect(rules).toEqual([
{
rule_type: FILTER_SIMPLE_TEXT,
value: 'bank statement',
},
])
rules = filterRulesFromQueryParams(
convertToParamMap({
title_search: 'invoice',
})
)
expect(rules).toEqual([
{
rule_type: FILTER_SIMPLE_TITLE,
value: 'invoice',
},
])
rules = filterRulesFromQueryParams(
convertToParamMap({
tags__id__all,
})

View File

@@ -9,14 +9,8 @@ import {
FILTER_HAS_CUSTOM_FIELDS_ALL,
FILTER_HAS_CUSTOM_FIELDS_ANY,
FILTER_RULE_TYPES,
FILTER_SIMPLE_TEXT,
FILTER_SIMPLE_TITLE,
FILTER_TITLE,
FILTER_TITLE_CONTENT,
FilterRuleType,
NEGATIVE_NULL_FILTER_VALUE,
SIMPLE_TEXT_PARAMETER,
SIMPLE_TITLE_PARAMETER,
} from '../data/filter-rule-type'
import { ListViewState } from '../services/document-list-view.service'
@@ -103,8 +97,6 @@ export function transformLegacyFilterRules(
export function filterRulesFromQueryParams(
queryParams: ParamMap
): FilterRule[] {
let filterRulesFromQueryParams: FilterRule[] = []
const allFilterRuleQueryParams: string[] = FILTER_RULE_TYPES.map(
(rt) => rt.filtervar
)
@@ -112,6 +104,7 @@ export function filterRulesFromQueryParams(
.filter((rt) => rt !== undefined)
// transform query params to filter rules
let filterRulesFromQueryParams: FilterRule[] = []
allFilterRuleQueryParams
.filter((frqp) => queryParams.has(frqp))
.forEach((filterQueryParamName) => {
@@ -153,17 +146,7 @@ export function queryParamsFromFilterRules(filterRules: FilterRule[]): Params {
let params = {}
for (let rule of filterRules) {
let ruleType = FILTER_RULE_TYPES.find((t) => t.id == rule.rule_type)
if (
rule.rule_type === FILTER_TITLE_CONTENT ||
rule.rule_type === FILTER_SIMPLE_TEXT
) {
params[SIMPLE_TEXT_PARAMETER] = rule.value
} else if (
rule.rule_type === FILTER_TITLE ||
rule.rule_type === FILTER_SIMPLE_TITLE
) {
params[SIMPLE_TITLE_PARAMETER] = rule.value
} else if (ruleType.isnull_filtervar && rule.value == null) {
if (ruleType.isnull_filtervar && rule.value == null) {
params[ruleType.isnull_filtervar] = 1
} else if (
ruleType.isnull_filtervar &&

View File

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

View File

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

View File

@@ -150,15 +150,6 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
background-color: var(--pngx-body-color-accent);
}
.list-group-item-action:not(.active):active {
--bs-list-group-action-active-color: var(--bs-body-color);
--bs-list-group-action-active-bg: var(--pngx-bg-darker);
}
.form-control:hover::file-selector-button {
background-color:var(--pngx-bg-dark) !important
}
.search-container {
input, input:focus, i-bs[name="search"] , ::placeholder {
color: var(--pngx-primary-text-contrast) !important;

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