Compare commits

..
Author SHA1 Message Date
shamoon f5a4026d4f Update advanced-search-query.ts 2026-09-19 21:04:52 -07:00
shamoon 339f9a4d30 Hook up to the filter bar 2026-09-19 21:04:52 -07:00
shamoon 856140c068 Advanced search dialog 2026-09-19 21:04:52 -07:00
shamoonandClaude Opus 5 62cf7c4429 feat(search): read a query back into the advanced search editor
The inverse of the serializer, deliberately narrow: it reads the forms
the serializer writes and nothing else, so a query it cannot show stays
text instead of being converted into something subtly different.

The guarantee is enforced rather than argued: a tree is only returned
when writing it out again reproduces the query character for character.
That turns every near miss into a plain "can't show this one" - a
boost, an implicit AND, a field alias, or AND and OR mixed at one level.
Aliases are refused for the same reason, since reading type: would mean
rewriting the query as it was read.

Per-word field prefixes are put back together into the single condition
they came from, but only when adjacent and on the same field, so the
order of what the user typed is never rearranged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 21:04:51 -07:00
shamoon 9a2c2b9331 Basic advanced search query foundations 2026-09-19 21:04:51 -07:00
234 changed files with 32404 additions and 23261 deletions
-2
View File
@@ -15,8 +15,6 @@
# Test related # Test related
**/.pytest_cache **/.pytest_cache
**/tests **/tests
src/paperless_testing
src/conftest.py
**/*.spec.ts **/*.spec.ts
**/htmlcov **/htmlcov
# Local folders # Local folders
-53
View File
@@ -1,53 +0,0 @@
name: Install apt packages
description: >
Installs apt packages with a per-attempt timeout and retries. The hosted runner's Azure apt mirror intermittently serves archives at a crawl, which apt's own timeout does not catch. A stuck attempt is killed and retried, which usually lands on a healthy mirror node.
inputs:
packages:
description: Space separated list of packages to install
required: true
attempts:
description: Maximum number of attempts
default: "3"
attempt-timeout:
description: Seconds before a single attempt is killed
default: "180"
runs:
using: composite
steps:
- name: Install ${{ inputs.packages }}
shell: bash
env:
PACKAGES: ${{ inputs.packages }}
ATTEMPTS: ${{ inputs.attempts }}
ATTEMPT_TIMEOUT: ${{ inputs.attempt-timeout }}
run: |
SUDO=""
if [ "$(id -u)" -ne 0 ]; then
SUDO="sudo"
fi
attempt_install() {
$SUDO apt-get update -q
# Intentionally unquoted, this is a list of package names
$SUDO apt-get install -q --yes --no-install-recommends $PACKAGES
}
export -f attempt_install
export SUDO PACKAGES
for attempt in $(seq 1 "${ATTEMPTS}"); do
echo "::group::apt install attempt ${attempt}/${ATTEMPTS}"
start=$(date +%s)
if timeout --kill-after=10 "${ATTEMPT_TIMEOUT}" bash -c attempt_install; then
echo "::endgroup::"
echo "Installed in $(( $(date +%s) - start ))s"
exit 0
fi
echo "::endgroup::"
echo "::warning::apt install attempt ${attempt}/${ATTEMPTS} failed or exceeded ${ATTEMPT_TIMEOUT}s"
# A killed attempt can leave dpkg half configured
$SUDO dpkg --configure -a || true
done
echo "::error::apt install failed after ${ATTEMPTS} attempts"
exit 1
+1
View File
@@ -59,6 +59,7 @@ updates:
- "drf-*" - "drf-*"
- "djangorestframework" - "djangorestframework"
- "whitenoise" - "whitenoise"
- "bleach"
- "jinja2" - "jinja2"
# Async, Task Queuing & Caching # Async, Task Queuing & Caching
async-tasks: async-tasks:
+9 -8
View File
@@ -102,16 +102,17 @@ jobs:
with: with:
python-version: "${{ matrix.python-version }}" python-version: "${{ matrix.python-version }}"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
python-version: ${{ steps.setup-python.outputs.python-version }} python-version: ${{ steps.setup-python.outputs.python-version }}
- name: Install system dependencies - name: Install system dependencies
timeout-minutes: 12 timeout-minutes: 10
uses: $/.github/actions/apt-install run: |
with: sudo apt-get update -qq
packages: unpaper tesseract-ocr imagemagick ghostscript poppler-utils sudo apt-get install -qq --no-install-recommends \
unpaper tesseract-ocr imagemagick ghostscript poppler-utils
- name: Configure ImageMagick - name: Configure ImageMagick
run: | run: |
sudo cp docker/rootfs/etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-6/policy.xml sudo cp docker/rootfs/etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-6/policy.xml
@@ -138,13 +139,13 @@ jobs:
pytest pytest
- name: Upload test results to Codecov - name: Upload test results to Codecov
if: always() if: always()
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with: with:
flags: backend-python-${{ matrix.python-version }} flags: backend-python-${{ matrix.python-version }}
files: junit.xml files: junit.xml
report_type: test_results report_type: test_results
- name: Upload coverage to Codecov - name: Upload coverage to Codecov
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with: with:
flags: backend-python-${{ matrix.python-version }} flags: backend-python-${{ matrix.python-version }}
files: coverage.xml files: coverage.xml
@@ -175,7 +176,7 @@ jobs:
with: with:
python-version: "${{ env.DEFAULT_PYTHON }}" python-version: "${{ env.DEFAULT_PYTHON }}"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
+3 -3
View File
@@ -106,7 +106,7 @@ jobs:
echo "repository=${repo_name}" echo "repository=${repo_name}"
echo "name=${repo_name}" >> $GITHUB_OUTPUT echo "name=${repo_name}" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with: with:
@@ -132,7 +132,7 @@ jobs:
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
- name: Build and push by digest - name: Build and push by digest
id: build id: build
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
@@ -182,7 +182,7 @@ jobs:
echo "Downloaded digests:" echo "Downloaded digests:"
ls -la /tmp/digests/ ls -la /tmp/digests/
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with: with:
+1 -1
View File
@@ -78,7 +78,7 @@ jobs:
with: with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }} python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
+6 -7
View File
@@ -174,13 +174,13 @@ jobs:
run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }} run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
- name: Upload test results to Codecov - name: Upload test results to Codecov
if: always() if: always()
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with: with:
flags: frontend-node-${{ matrix.node-version }} flags: frontend-node-${{ matrix.node-version }}
directory: src-ui/ directory: src-ui/
report_type: test_results report_type: test_results
- name: Upload coverage to Codecov - name: Upload coverage to Codecov
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with: with:
flags: frontend-node-${{ matrix.node-version }} flags: frontend-node-${{ matrix.node-version }}
directory: src-ui/coverage/ directory: src-ui/coverage/
@@ -216,7 +216,7 @@ jobs:
with: with:
python-version: '3.12' python-version: '3.12'
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with: with:
version: '0.12.x' version: '0.12.x'
enable-cache: false enable-cache: false
@@ -231,10 +231,9 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: cd src-ui && pnpm install --frozen-lockfile run: cd src-ui && pnpm install --frozen-lockfile
- name: Install backend system dependencies - name: Install backend system dependencies
timeout-minutes: 12 run: |
uses: $/.github/actions/apt-install apt-get update
with: apt-get install --yes --quiet --no-install-recommends libmagic1
packages: libmagic1
- name: Install backend dependencies - name: Install backend dependencies
env: env:
PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }} PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }}
+5 -6
View File
@@ -59,7 +59,7 @@ jobs:
with: with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }} python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: false enable-cache: false
@@ -70,10 +70,9 @@ jobs:
run: | run: |
uv sync --python "${PYTHON_VERSION}" --dev --frozen uv sync --python "${PYTHON_VERSION}" --dev --frozen
- name: Install system dependencies - name: Install system dependencies
timeout-minutes: 12 run: |
uses: $/.github/actions/apt-install sudo apt-get update -qq
with: sudo apt-get install -qq --no-install-recommends gettext liblept5
packages: gettext liblept5
# ---- Build Documentation ---- # ---- Build Documentation ----
- name: Build documentation - name: Build documentation
env: env:
@@ -213,7 +212,7 @@ jobs:
with: with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }} python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: false enable-cache: false
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
- name: Run Semgrep - name: Run Semgrep
run: semgrep scan --config auto --sarif-output results.sarif run: semgrep scan --config auto --sarif-output results.sarif
- name: Upload results to GitHub code scanning - name: Upload results to GitHub code scanning
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
if: always() if: always()
with: with:
sarif_file: results.sarif sarif_file: results.sarif
+2 -2
View File
@@ -29,7 +29,7 @@ jobs:
steps: steps:
- name: Clean temporary images - name: Clean temporary images
if: "${{ env.TOKEN != '' }}" if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/ephemeral@21f875bab2376314e0525c614e923c669434f0e4 # v0.13.0 uses: stumpylog/image-cleaner-action/ephemeral@4fe057d991d63b8f6d5d22c40f17c1bca2226537 # v0.12.0
with: with:
token: "${{ env.TOKEN }}" token: "${{ env.TOKEN }}"
owner: "${{ github.repository_owner }}" owner: "${{ github.repository_owner }}"
@@ -56,7 +56,7 @@ jobs:
steps: steps:
- name: Clean untagged images - name: Clean untagged images
if: "${{ env.TOKEN != '' }}" if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/untagged@21f875bab2376314e0525c614e923c669434f0e4 # v0.13.0 uses: stumpylog/image-cleaner-action/untagged@4fe057d991d63b8f6d5d22c40f17c1bca2226537 # v0.12.0
with: with:
token: "${{ env.TOKEN }}" token: "${{ env.TOKEN }}"
owner: "${{ github.repository_owner }}" owner: "${{ github.repository_owner }}"
+2 -2
View File
@@ -39,7 +39,7 @@ jobs:
persist-credentials: false persist-credentials: false
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # If you wish to specify custom queries, you can do so here or in a config file.
@@ -47,4 +47,4 @@ jobs:
# Prefix the list here with "+" to use these queries and those in the config file. # Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main # queries: ./path/to/local/query, your-org/your-repo/queries@main
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
token: ${{ secrets.PNGX_BOT_PAT }} token: ${{ secrets.PNGX_BOT_PAT }}
persist-credentials: false persist-credentials: false
- name: crowdin action - name: crowdin action
uses: crowdin/github-action@9c23991700c0ec5256fd41089b9d9d7d540e424e # v3.3.0 uses: crowdin/github-action@0d5670f539973aea2f01abce61a8989934df0025 # v3.0.2
with: with:
upload_translations: false upload_translations: false
download_translations: true download_translations: true
+4 -5
View File
@@ -25,12 +25,11 @@ jobs:
id: setup-python id: setup-python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- name: Install system dependencies - name: Install system dependencies
timeout-minutes: 12 run: |
uses: $/.github/actions/apt-install sudo apt-get update -qq
with: sudo apt-get install -qq --no-install-recommends gettext
packages: gettext
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
+758 -1008
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -38,7 +38,7 @@ repos:
- json - json
# See https://github.com/prettier/prettier/issues/15742 for the fork reason # See https://github.com/prettier/prettier/issues/15742 for the fork reason
- repo: https://github.com/rbubley/mirrors-prettier - repo: https://github.com/rbubley/mirrors-prettier
rev: 'v3.9.9' rev: 'v3.9.6'
hooks: hooks:
- id: prettier - id: prettier
types_or: types_or:
@@ -46,16 +46,16 @@ repos:
- ts - ts
- markdown - markdown
additional_dependencies: additional_dependencies:
- prettier@3.9.9 - prettier@3.9.6
- 'prettier-plugin-organize-imports@4.3.0' - 'prettier-plugin-organize-imports@4.3.0'
# Python hooks # Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.9 rev: v0.16.5
hooks: hooks:
- id: ruff-check - id: ruff-check
- id: ruff-format - id: ruff-format
- repo: https://github.com/tox-dev/pyproject-fmt - repo: https://github.com/tox-dev/pyproject-fmt
rev: "v2.29.4" rev: "v2.28.1"
hooks: hooks:
- id: pyproject-fmt - id: pyproject-fmt
additional_dependencies: [tomli] additional_dependencies: [tomli]
+14241 -6097
View File
File diff suppressed because one or more lines are too long
+2 -4
View File
@@ -30,7 +30,7 @@ RUN set -eux \
# Purpose: Installs s6-overlay and rootfs # Purpose: Installs s6-overlay and rootfs
# Comments: # Comments:
# - Don't leave anything extra in here either # - Don't leave anything extra in here either
FROM ghcr.io/astral-sh/uv:0.12.23-python3.14-trixie-slim AS s6-overlay-base FROM ghcr.io/astral-sh/uv:0.12.16-python3.14-trixie-slim AS s6-overlay-base
WORKDIR /usr/src/s6 WORKDIR /usr/src/s6
@@ -171,9 +171,7 @@ RUN set -eux \
&& cp /etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-6/policy.xml \ && cp /etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-6/policy.xml \
&& echo "Cleaning up image layer" \ && echo "Cleaning up image layer" \
&& rm --force --verbose *.deb \ && rm --force --verbose *.deb \
&& rm --recursive --force --verbose /var/lib/apt/lists/* \ && rm --recursive --force --verbose /var/lib/apt/lists/*
&& echo "Configuring interactive shells to source the s6 container environment" \
&& echo '. /etc/profile.d/contenv.sh' >> /etc/bash.bashrc
WORKDIR /usr/src/paperless/src/ WORKDIR /usr/src/paperless/src/
+1 -1
View File
@@ -24,7 +24,7 @@ services:
network_mode: host network_mode: host
restart: unless-stopped restart: unless-stopped
greenmail: greenmail:
image: docker.io/greenmail/standalone:2.1.14 image: docker.io/greenmail/standalone:2.1.13
hostname: greenmail hostname: greenmail
container_name: greenmail container_name: greenmail
environment: environment:
-18
View File
@@ -1,18 +0,0 @@
#!/bin/sh
# Source s6 container environment for interactive shells.
# Ensures variables resolved from *_FILE secret injection are visible
# when using 'docker exec bash'. Does not affect s6 services (those
# use with-contenv directly). Has no effect in non-container contexts
# because the directory will not exist.
# Note: sh/dash shells opened via 'docker exec sh' are not covered;
# only bash-based sessions benefit from this file.
_pngx_contenv="/run/s6/container_environment"
if [ -d "${_pngx_contenv}" ]; then
for _pngx_f in "${_pngx_contenv}"/*; do
[ -f "${_pngx_f}" ] || continue
_pngx_name=$(basename "${_pngx_f}")
_pngx_val=$(cat "${_pngx_f}")
export "${_pngx_name}=${_pngx_val}"
done
fi
unset _pngx_contenv _pngx_f _pngx_name _pngx_val
@@ -2,7 +2,6 @@
# shellcheck shell=bash # shellcheck shell=bash
declare -r log_prefix="[svc-flower]" declare -r log_prefix="[svc-flower]"
declare -r flower_config="${PAPERLESS_SRC_DIR}/paperless/flowerconfig.py"
echo "${log_prefix} Checking if we should start flower..." echo "${log_prefix} Checking if we should start flower..."
@@ -10,20 +9,12 @@ if [[ -n "${PAPERLESS_ENABLE_FLOWER}" ]]; then
# Small delay to allow celery to be up first # Small delay to allow celery to be up first
echo "${log_prefix} Starting flower in 5s" echo "${log_prefix} Starting flower in 5s"
sleep 5 sleep 5
cd "${PAPERLESS_SRC_DIR}" || exit 1 cd ${PAPERLESS_SRC_DIR}
# Only pass --conf if the file is actually there. The image does not ship one, and
# flower >= 2.1.0 exits with FileNotFoundError when an explicitly given --conf path
# does not exist (mher/flower#1391). Earlier versions silently ignored it.
declare -a conf_args=()
if [[ -f "${flower_config}" ]]; then
conf_args=(--conf="${flower_config}")
fi
if [[ -n "${USER_IS_NON_ROOT}" ]]; then if [[ -n "${USER_IS_NON_ROOT}" ]]; then
exec /usr/local/bin/celery --app paperless flower "${conf_args[@]}" exec /usr/local/bin/celery --app paperless flower --conf=${PAPERLESS_SRC_DIR}/paperless/flowerconfig.py
else else
exec s6-setuidgid paperless /usr/local/bin/celery --app paperless flower "${conf_args[@]}" exec s6-setuidgid paperless /usr/local/bin/celery --app paperless flower --conf=${PAPERLESS_SRC_DIR}/paperless/flowerconfig.py
fi fi
else else
+9 -14
View File
@@ -136,15 +136,13 @@ for suggested generation and embedding models.
### AI-assisted suggestions ### AI-assisted suggestions
With AI enabled, Paperless-ngx can suggest a title, tags, correspondent, document type, With AI enabled, Paperless-ngx can suggest a title, tags, correspondent, document type,
storage path and dates by sending the document to the LLM using "Suggest" button on the document storage path and dates by sending the document to the LLM. This is **opt-in per request**
detail page. You can choose which type of suggestions are requested by default under Settings > and surfaces through the "Suggest" control on the document detail page, alongside the
Documents, either ML (classifier-based) suggestions, AI suggestions, or both. When both are requested classic classifier-based suggestions — it does not disable them. Suggestions are requested
the results are combined. automatically when you open a document that carries an inbox tag unless "Automatically request
suggestions for inbox documents" under Settings > Documents is disabled. Suggestion output
Suggestions are requested automatically when you open a document that carries an inbox tag language can be steered with
unless "Automatically request suggestions for inbox documents" under Settings > Documents is disabled. [`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE`](configuration.md#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE)
Suggestion output language can be steered with [`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE`](configuration.md#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE)
(otherwise it follows the user's UI language). (otherwise it follows the user's UI language).
### The LLM index (RAG) and similar documents ### The LLM index (RAG) and similar documents
@@ -155,11 +153,8 @@ in similar existing documents, and the document chat can retrieve relevant conte
Enable it by setting Enable it by setting
[`PAPERLESS_AI_LLM_EMBEDDING_BACKEND`](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_BACKEND) [`PAPERLESS_AI_LLM_EMBEDDING_BACKEND`](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_BACKEND)
(`huggingface` for fully-local embeddings, or `ollama` / `openai-like`). By default, the main (`huggingface` for fully-local embeddings, or `ollama` / `openai-like`). The index is only
LLM API key and endpoint are used, but an optional embedding-specific[API key](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_API_KEY) built when AI is enabled **and** an embedding backend is set.
and [endpoint](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT) can be configured.
The index is only built when AI is enabled **and** an embedding backend is set.
The index is updated automatically on a schedule controlled by The index is updated automatically on a schedule controlled by
[`PAPERLESS_LLM_INDEX_TASK_CRON`](configuration.md#PAPERLESS_LLM_INDEX_TASK_CRON) (daily by [`PAPERLESS_LLM_INDEX_TASK_CRON`](configuration.md#PAPERLESS_LLM_INDEX_TASK_CRON) (daily by
-25
View File
@@ -1,30 +1,5 @@
# Changelog # Changelog
## paperless-ngx 3.2.1
### Bug Fixes
- Fix: only pass --conf to flower when flowerconfig.py exists [@bitfoo1](https://github.com/bitfoo1) ([#14182](https://github.com/paperless-ngx/paperless-ngx/pull/14182))
- Fix: replace stale mail-fetch overlap check with a self-expiring lock [@stumpylog](https://github.com/stumpylog) ([#14189](https://github.com/paperless-ngx/paperless-ngx/pull/14189))
- Fix: bump ocrmypdf to 17.12 to pick up the ligature text-layer fix [@stumpylog](https://github.com/stumpylog) ([#14190](https://github.com/paperless-ngx/paperless-ngx/pull/14190))
- Fix: rebuild the search index automatically when it is missing Tantivy files [@stumpylog](https://github.com/stumpylog) ([#14180](https://github.com/paperless-ngx/paperless-ngx/pull/14180))
### Dependencies
- Chore(deps): Bump anyio from 4.12.1 to 4.14.2 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#14175](https://github.com/paperless-ngx/paperless-ngx/pull/14175))
### All App Changes
<details>
<summary>4 changes</summary>
- Chore(deps): Bump anyio from 4.12.1 to 4.14.2 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#14175](https://github.com/paperless-ngx/paperless-ngx/pull/14175))
- Fix: replace stale mail-fetch overlap check with a self-expiring lock [@stumpylog](https://github.com/stumpylog) ([#14189](https://github.com/paperless-ngx/paperless-ngx/pull/14189))
- Fix: bump ocrmypdf to 17.12 to pick up the ligature text-layer fix [@stumpylog](https://github.com/stumpylog) ([#14190](https://github.com/paperless-ngx/paperless-ngx/pull/14190))
- Fix: rebuild the search index automatically when it is missing Tantivy files [@stumpylog](https://github.com/stumpylog) ([#14180](https://github.com/paperless-ngx/paperless-ngx/pull/14180))
</details>
## paperless-ngx 3.2.0 ## paperless-ngx 3.2.0
### Features / Enhancements ### Features / Enhancements
-20
View File
@@ -2133,13 +2133,6 @@ for language and resource considerations.
Defaults to None. Defaults to None.
#### [`PAPERLESS_AI_LLM_EMBEDDING_API_KEY=<str>`](#PAPERLESS_AI_LLM_EMBEDDING_API_KEY) {#PAPERLESS_AI_LLM_EMBEDDING_API_KEY}
: The API key to use for the embedding backend. If not supplied, embeddings use
`PAPERLESS_AI_LLM_API_KEY`.
Defaults to None.
#### [`PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT=<str>`](#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT) {#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT} #### [`PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT=<str>`](#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT) {#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT}
: The endpoint / url to use for the embedding backend. If not supplied, embeddings use : The endpoint / url to use for the embedding backend. If not supplied, embeddings use
@@ -2224,19 +2217,6 @@ used with the OpenAI-compatible backend to target a custom provider or local gat
Defaults to true, which allows internal endpoints. Defaults to true, which allows internal endpoints.
#### [`PAPERLESS_AI_LLM_EXTRA_PARAMS=<json>`](#PAPERLESS_AI_LLM_EXTRA_PARAMS) {#PAPERLESS_AI_LLM_EXTRA_PARAMS}
: A JSON object of extra parameters sent with every LLM request, for providers that require a parameter Paperless does not
set itself. Values here override Paperless' own, and no validation is performed. Whatever you put here is passed to the
backend as-is, so an invalid parameter will simply be rejected by your provider. For example, current OpenAI reasoning
models refuse tool calls on the chat completions API unless reasoning is off:
```
PAPERLESS_AI_LLM_EXTRA_PARAMS={"reasoning_effort": "none"}
```
Defaults to empty, which adds nothing to requests.
#### [`PAPERLESS_LLM_INDEX_TASK_CRON=<cron expression>`](#PAPERLESS_LLM_INDEX_TASK_CRON) {#PAPERLESS_LLM_INDEX_TASK_CRON} #### [`PAPERLESS_LLM_INDEX_TASK_CRON=<cron expression>`](#PAPERLESS_LLM_INDEX_TASK_CRON) {#PAPERLESS_LLM_INDEX_TASK_CRON}
: Configures the schedule to update the AI embeddings of text content and metadata for all documents. Only performed if : Configures the schedule to update the AI embeddings of text content and metadata for all documents. Only performed if
-1
View File
@@ -150,7 +150,6 @@ pnpm ng build --configuration production
is loaded as well. However, the tests rely on the default is loaded as well. However, the tests rely on the default
configuration. This is not ideal. But for now, make sure no settings configuration. This is not ideal. But for now, make sure no settings
except for DEBUG are overridden when testing. except for DEBUG are overridden when testing.
- Tests run in a random order each session, so that one test cannot quietly depend on another having run first. The seed is printed at the top of the run; pass `--randomly-seed=<seed>` to replay that exact order, or `--randomly-seed=last` to repeat the previous run.
!!! note !!! note
+13 -24
View File
@@ -1,9 +1,7 @@
[project] [project]
name = "paperless-ngx" name = "paperless-ngx"
version = "3.2.1" version = "3.2.0"
description = """\ description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
A community-supported supercharged document management system: scan, index and archive all your physical documents\
"""
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
classifiers = [ classifiers = [
@@ -12,13 +10,13 @@ classifiers = [
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.14",
"Programming Language :: Python :: 3.15",
] ]
# TODO: Move certain things to groups and then utilize that further # TODO: Move certain things to groups and then utilize that further
# This will allow testing to not install a webserver, mysql, etc # This will allow testing to not install a webserver, mysql, etc
dependencies = [ dependencies = [
"azure-ai-documentintelligence>=1.0.2", "azure-ai-documentintelligence>=1.0.2",
"babel>=2.17", "babel>=2.17",
"bleach~=6.4.0",
"celery[redis]~=5.6.2", "celery[redis]~=5.6.2",
"channels~=4.2", "channels~=4.2",
"channels-redis~=4.2", "channels-redis~=4.2",
@@ -34,7 +32,7 @@ dependencies = [
"django-cors-headers~=4.9.0", "django-cors-headers~=4.9.0",
"django-extensions~=4.1", "django-extensions~=4.1",
"django-filter~=25.1", "django-filter~=25.1",
"django-guardian>=3.3.3,<3.6", "django-guardian>=3.3.3,<3.5",
"django-multiselectfield~=1.0.1", "django-multiselectfield~=1.0.1",
"django-rich~=2.2.0", "django-rich~=2.2.0",
"django-soft-delete~=1.0.18", "django-soft-delete~=1.0.18",
@@ -44,7 +42,7 @@ dependencies = [
"drf-spectacular-sidecar>=2026.7.1,<2026.10", "drf-spectacular-sidecar>=2026.7.1,<2026.10",
"drf-writable-nested~=0.7.1", "drf-writable-nested~=0.7.1",
"filelock~=3.32.0", "filelock~=3.32.0",
"flower>=2.0.1,<2.3", "flower>=2.0.1,<2.2",
"gotenberg-client[httpx]~=1.0", "gotenberg-client[httpx]~=1.0",
"httpx-oauth~=0.17", "httpx-oauth~=0.17",
"ijson>=3.5.1", "ijson>=3.5.1",
@@ -76,7 +74,6 @@ dependencies = [
"tantivy~=0.26.0", "tantivy~=0.26.0",
"tika-client[httpx]~=1.0", "tika-client[httpx]~=1.0",
"torch>=2.13,<2.15", "torch>=2.13,<2.15",
"turbohtml~=1.10.0",
"watchfiles>=1.2", "watchfiles>=1.2",
"whitenoise~=6.11", "whitenoise~=6.11",
"whoosh-compat[tantivy]==0.3", "whoosh-compat[tantivy]==0.3",
@@ -84,13 +81,13 @@ dependencies = [
] ]
[project.optional-dependencies] [project.optional-dependencies]
mariadb = [ mariadb = [
"mysqlclient>=2.2.7,<2.4", "mysqlclient~=2.2.7",
] ]
postgres = [ postgres = [
"psycopg[c,pool]==3.3.4", "psycopg[c,pool]==3.3.4",
# Direct dependency for proper resolution of the pre-built wheels # Direct dependency for proper resolution of the pre-built wheels
"psycopg-c==3.3.4", "psycopg-c==3.3.4",
"psycopg-pool==3.3.2", "psycopg-pool==3.3.1",
] ]
webserver = [ webserver = [
"granian[uvloop]>=2.7,<2.9", "granian[uvloop]>=2.7,<2.9",
@@ -112,7 +109,7 @@ lint = [
testing = [ testing = [
"daphne", "daphne",
"factory-boy~=3.3.1", "factory-boy~=3.3.1",
"faker>=40.36,<40.40", "faker>=40.36,<40.39",
"imagehash", "imagehash",
"pytest~=9.1.1", "pytest~=9.1.1",
"pytest-cov~=7.1.0", "pytest-cov~=7.1.0",
@@ -120,7 +117,7 @@ testing = [
"pytest-env~=1.7.0", "pytest-env~=1.7.0",
"pytest-httpx", "pytest-httpx",
"pytest-mock~=3.15.1", "pytest-mock~=3.15.1",
"pytest-randomly~=5.0.0", # "pytest-randomly~=4.0.1",
"pytest-rerunfailures~=16.4", "pytest-rerunfailures~=16.4",
"pytest-sugar", "pytest-sugar",
"pytest-xdist~=3.8.0", "pytest-xdist~=3.8.0",
@@ -136,6 +133,7 @@ typing = [
"mypy", "mypy",
"mypy-baseline", "mypy-baseline",
"pyrefly", "pyrefly",
"types-bleach",
"types-channels", "types-channels",
"types-colorama", "types-colorama",
"types-dateparser", "types-dateparser",
@@ -150,7 +148,6 @@ typing = [
[tool.uv] [tool.uv]
required-version = ">=0.9.0" required-version = ">=0.9.0"
prerelease = "disallow"
environments = [ environments = [
"sys_platform == 'darwin'", "sys_platform == 'darwin'",
"sys_platform == 'linux'", "sys_platform == 'linux'",
@@ -247,10 +244,6 @@ per-file-ignores."docker/wait-for-redis.py" = [
per-file-ignores."src/documents/models.py" = [ per-file-ignores."src/documents/models.py" = [
"SIM115", "SIM115",
] ]
per-file-ignores."src/documents/tests/*.py" = [
"TID251",
]
flake8-tidy-imports.banned-api."documents.tests".msg = "Shared test infrastructure lives in src/paperless_testing/."
isort.force-single-line = true isort.force-single-line = true
[tool.codespell] [tool.codespell]
@@ -278,9 +271,9 @@ plugins = [
] ]
[tool.pyrefly] [tool.pyrefly]
baseline = ".pyrefly-baseline.json"
python-platform = "linux" python-platform = "linux"
search-path = [ "src" ] search-path = [ "src" ]
baseline = ".pyrefly-baseline.json"
[tool.django-stubs] [tool.django-stubs]
django_settings_module = "paperless.settings" django_settings_module = "paperless.settings"
@@ -333,8 +326,6 @@ PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
PAPERLESS_CHANNELS_BACKEND = "channels.layers.InMemoryChannelLayer" PAPERLESS_CHANNELS_BACKEND = "channels.layers.InMemoryChannelLayer"
# I don't think anything hits this, but just in case, basically infinite # I don't think anything hits this, but just in case, basically infinite
PAPERLESS_TOKEN_THROTTLE_RATE = "1000/min" PAPERLESS_TOKEN_THROTTLE_RATE = "1000/min"
# The 0.1s production default trips on a stalled CI runner, the date parsing tests then find no dates
PAPERLESS_MATCH_REGEX_TIMEOUT_SECONDS = "5"
[tool.coverage.run] [tool.coverage.run]
source = [ source = [
@@ -343,15 +334,13 @@ source = [
omit = [ omit = [
"*/tests/*", "*/tests/*",
"manage.py", "manage.py",
"paperless/auth.py",
"paperless/wsgi.py", "paperless/wsgi.py",
"src/conftest.py", "paperless/auth.py",
"src/paperless_testing/*",
] ]
[tool.coverage.report] [tool.coverage.report]
exclude_also = [ exclude_also = [
"if AUDIT_LOG_ENABLED:",
"if settings.AUDIT_LOG_ENABLED:", "if settings.AUDIT_LOG_ENABLED:",
"if AUDIT_LOG_ENABLED:",
"if TYPE_CHECKING:", "if TYPE_CHECKING:",
] ]
-5
View File
@@ -91,11 +91,6 @@
"glob": "**/*", "glob": "**/*",
"input": "node_modules/pdfjs-dist/iccs/", "input": "node_modules/pdfjs-dist/iccs/",
"output": "/assets/iccs/" "output": "/assets/iccs/"
},
{
"glob": "**/*",
"input": "node_modules/pdfjs-dist/cmaps/",
"output": "/assets/cmaps/"
} }
], ],
"styles": [ "styles": [
+238 -315
View File
File diff suppressed because it is too large Load Diff
+23 -23
View File
@@ -1,6 +1,6 @@
{ {
"name": "paperless-ngx-ui", "name": "paperless-ngx-ui",
"version": "3.2.1", "version": "3.2.0",
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm", "preinstall": "npx only-allow pnpm",
"ng": "ng", "ng": "ng",
@@ -15,16 +15,16 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/cdk": "^22.2.0", "@angular/cdk": "^22.1.6",
"@angular/common": "~22.2.0", "@angular/common": "~22.1.6",
"@angular/compiler": "~22.2.0", "@angular/compiler": "~22.1.6",
"@angular/core": "~22.2.0", "@angular/core": "~22.1.6",
"@angular/forms": "~22.2.0", "@angular/forms": "~22.1.6",
"@angular/localize": "~22.2.0", "@angular/localize": "~22.1.6",
"@angular/platform-browser": "~22.2.0", "@angular/platform-browser": "~22.1.6",
"@angular/router": "~22.2.0", "@angular/router": "~22.1.6",
"@ng-bootstrap/ng-bootstrap": "^21.0.0", "@ng-bootstrap/ng-bootstrap": "^21.0.0",
"@ng-select/ng-select": "~24.1.2", "@ng-select/ng-select": "~24.1.1",
"@ngneat/dirty-check-forms": "^3.0.3", "@ngneat/dirty-check-forms": "^3.0.3",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
@@ -45,29 +45,29 @@
}, },
"devDependencies": { "devDependencies": {
"@angular-builders/jest": "^22.0.1", "@angular-builders/jest": "^22.0.1",
"@angular-devkit/core": "^22.2.0", "@angular-devkit/core": "^22.1.8",
"@angular-devkit/schematics": "^22.2.0", "@angular-devkit/schematics": "^22.1.8",
"@angular-eslint/builder": "22.5.0", "@angular-eslint/builder": "22.5.0",
"@angular-eslint/eslint-plugin": "22.5.0", "@angular-eslint/eslint-plugin": "22.5.0",
"@angular-eslint/eslint-plugin-template": "22.5.0", "@angular-eslint/eslint-plugin-template": "22.5.0",
"@angular-eslint/schematics": "22.5.0", "@angular-eslint/schematics": "22.5.0",
"@angular-eslint/template-parser": "22.5.0", "@angular-eslint/template-parser": "22.5.0",
"@angular/build": "22.2.0", "@angular/build": "22.1.8",
"@angular/cli": "22.2.0", "@angular/cli": "22.1.8",
"@angular/compiler-cli": "~22.2.0", "@angular/compiler-cli": "~22.1.6",
"@playwright/test": "^1.62.1", "@playwright/test": "^1.62.1",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^26.6.2", "@types/node": "^26.5.0",
"@typescript-eslint/eslint-plugin": "^8.70.1", "@typescript-eslint/eslint-plugin": "^8.70.0",
"@typescript-eslint/parser": "^8.70.1", "@typescript-eslint/parser": "^8.70.0",
"@typescript-eslint/utils": "^8.70.1", "@typescript-eslint/utils": "^8.70.0",
"eslint": "^10.11.0", "eslint": "^10.10.0",
"jest": "30.5.2", "jest": "30.5.1",
"jest-environment-jsdom": "^30.5.2", "jest-environment-jsdom": "^30.5.1",
"jest-junit": "^17.0.0", "jest-junit": "^17.0.0",
"jest-preset-angular": "^17.0.0", "jest-preset-angular": "^17.0.0",
"jest-websocket-mock": "^2.5.0", "jest-websocket-mock": "^2.5.0",
"prettier": "^3.9.9", "prettier": "^3.9.6",
"prettier-plugin-organize-imports": "^4.3.0", "prettier-plugin-organize-imports": "^4.3.0",
"ts-node": "~10.9.2", "ts-node": "~10.9.2",
"typescript": "^6.0.3" "typescript": "^6.0.3"
+1959 -1311
View File
File diff suppressed because it is too large Load Diff
@@ -253,24 +253,6 @@
</div> </div>
</div> </div>
@if (aiEnabled) {
<div class="row mb-3">
<div class="col-md-3 col-form-label pt-0">
<span i18n>Suggestions default to</span>
</div>
<div class="col">
<fieldset class="btn-group btn-group-sm">
<input type="radio" class="btn-check" id="suggestionSourceBoth" [value]="SuggestionSource.Both" formControlName="documentEditingSuggestionSource">
<label class="btn btn-outline-primary" for="suggestionSourceBoth"><ng-container i18n>Both</ng-container></label>
<input type="radio" class="btn-check" id="suggestionSourceML" [value]="SuggestionSource.ML" formControlName="documentEditingSuggestionSource">
<label class="btn btn-outline-primary" for="suggestionSourceML"><i-bs class="me-1" name="cpu"></i-bs><ng-container i18n>ML only</ng-container></label>
<input type="radio" class="btn-check" id="suggestionSourceAI" [value]="SuggestionSource.AI" formControlName="documentEditingSuggestionSource">
<label class="btn btn-outline-primary" for="suggestionSourceAI"><i-bs class="me-1" name="stars"></i-bs><ng-container i18n>AI only</ng-container></label>
</fieldset>
</div>
</div>
}
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<pngx-input-check i18n-title title="Automatically request suggestions for inbox documents" i18n-hint hint="If un-checked, suggestions must be requested via the Suggest button." formControlName="documentEditingAutoSuggest"></pngx-input-check> <pngx-input-check i18n-title title="Automatically request suggestions for inbox documents" i18n-hint hint="If un-checked, suggestions must be requested via the Suggest button." formControlName="documentEditingAutoSuggest"></pngx-input-check>
@@ -307,7 +307,7 @@ describe('SettingsComponent', () => {
expect(toastErrorSpy).toHaveBeenCalled() expect(toastErrorSpy).toHaveBeenCalled()
expect(storeSpy).toHaveBeenCalled() expect(storeSpy).toHaveBeenCalled()
expect(appearanceSettingsSpy).not.toHaveBeenCalled() expect(appearanceSettingsSpy).not.toHaveBeenCalled()
expect(setSpy).toHaveBeenCalledTimes(35) expect(setSpy).toHaveBeenCalledTimes(34)
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [ expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
HideableSidebarItemID.Workflows, HideableSidebarItemID.Workflows,
]) ])
@@ -44,7 +44,6 @@ import {
HIDEABLE_SIDEBAR_ITEM_IDS, HIDEABLE_SIDEBAR_ITEM_IDS,
HideableSidebarItemID, HideableSidebarItemID,
SETTINGS_KEYS, SETTINGS_KEYS,
SuggestionSource,
} from 'src/app/data/ui-settings' } from 'src/app/data/ui-settings'
import { User } from 'src/app/data/user' import { User } from 'src/app/data/user'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
@@ -185,7 +184,6 @@ export class SettingsComponent
documentEditingRemoveInboxTags: new FormControl(null), documentEditingRemoveInboxTags: new FormControl(null),
documentEditingOverlayThumbnail: new FormControl(null), documentEditingOverlayThumbnail: new FormControl(null),
documentEditingAutoSuggest: new FormControl(null), documentEditingAutoSuggest: new FormControl(null),
documentEditingSuggestionSource: new FormControl(null),
documentDetailsHiddenFields: new FormControl([]), documentDetailsHiddenFields: new FormControl([]),
searchDbOnly: new FormControl(null), searchDbOnly: new FormControl(null),
searchLink: new FormControl(null), searchLink: new FormControl(null),
@@ -219,11 +217,6 @@ export class SettingsComponent
public readonly PdfZoomScale = PdfZoomScale public readonly PdfZoomScale = PdfZoomScale
public readonly PdfEditorEditMode = PdfEditorEditMode public readonly PdfEditorEditMode = PdfEditorEditMode
public readonly SuggestionSource = SuggestionSource
get aiEnabled(): boolean {
return this.settings.get(SETTINGS_KEYS.AI_ENABLED)
}
public readonly documentDetailFieldOptions = documentDetailFieldOptions public readonly documentDetailFieldOptions = documentDetailFieldOptions
public readonly sidebarItemOptions = HIDEABLE_SIDEBAR_ITEM_IDS.map((id) => ({ public readonly sidebarItemOptions = HIDEABLE_SIDEBAR_ITEM_IDS.map((id) => ({
@@ -411,9 +404,6 @@ export class SettingsComponent
documentEditingAutoSuggest: this.settings.get( documentEditingAutoSuggest: this.settings.get(
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST
), ),
documentEditingSuggestionSource: this.settings.get(
SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE
),
documentDetailsHiddenFields: this.settings.get( documentDetailsHiddenFields: this.settings.get(
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS
), ),
@@ -635,10 +625,6 @@ export class SettingsComponent
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST, SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST,
this.settingsForm.value.documentEditingAutoSuggest this.settingsForm.value.documentEditingAutoSuggest
) )
this.settings.set(
SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE,
this.settingsForm.value.documentEditingSuggestionSource
)
this.settings.set( this.settings.set(
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS, SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS,
this.settingsForm.value.documentDetailsHiddenFields this.settingsForm.value.documentDetailsHiddenFields
@@ -0,0 +1,347 @@
<div class="modal-header">
<h4 class="modal-title" id="advanced-search-dialog-title" i18n>
Advanced search
</h4>
<button
type="button"
class="btn-close"
aria-label="Close"
i18n-aria-label
(click)="cancel()"
></button>
</div>
<div class="modal-body">
@if (unreadable) {
<div class="alert alert-warning d-flex flex-wrap gap-2 align-items-center">
<div class="flex-grow-1">
<span i18n
>The current query uses syntax this editor can't show. It still works
as typed.</span
>
</div>
<button
type="button"
class="btn btn-sm btn-outline-secondary"
(click)="startOver()"
i18n
>
Start a new query
</button>
</div>
}
<ng-container
*ngTemplateOutlet="
groupTemplate;
context: { group: root, parent: null, depth: 0 }
"
></ng-container>
<div class="mt-4">
<label
class="form-label small text-muted"
for="advanced-search-preview"
i18n
>Query</label
>
<pre
id="advanced-search-preview"
class="query-preview mb-0 p-2 rounded border"
>@if (generatedQuery) {{{ generatedQuery }}} @else {<span class="text-muted fst-italic" i18n>Nothing to search for yet</span>}</pre>
</div>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-outline-secondary"
(click)="cancel()"
i18n
>
Cancel
</button>
<button
type="button"
class="btn btn-primary"
(click)="apply()"
[disabled]="!generatedQuery"
i18n
>
Apply
</button>
</div>
<ng-template
#groupTemplate
let-group="group"
let-parent="parent"
let-depth="depth"
>
<div class="d-flex w-100 gap-2">
<div class="d-flex flex-grow-1 flex-column">
<div class="d-flex align-items-center flex-wrap">
<div
class="btn-group btn-group-xs"
role="group"
aria-label="Match"
i18n-aria-label
>
<input
type="radio"
class="btn-check"
[(ngModel)]="group.operator"
[ngModelOptions]="{ standalone: true }"
[value]="LogicalOperator.Or"
id="advancedSearchAny_{{ idFor(group) }}"
name="advancedSearchAny_{{ idFor(group) }}"
/>
<label
class="btn btn-outline-primary"
for="advancedSearchAny_{{ idFor(group) }}"
i18n
>Any</label
>
<input
type="radio"
class="btn-check"
[(ngModel)]="group.operator"
[ngModelOptions]="{ standalone: true }"
[value]="LogicalOperator.And"
id="advancedSearchAll_{{ idFor(group) }}"
name="advancedSearchAll_{{ idFor(group) }}"
/>
<label
class="btn btn-outline-primary"
for="advancedSearchAll_{{ idFor(group) }}"
i18n
>All</label
>
<input
type="radio"
class="btn-check"
[(ngModel)]="group.operator"
[ngModelOptions]="{ standalone: true }"
[value]="LogicalOperator.Not"
id="advancedSearchNot_{{ idFor(group) }}"
name="advancedSearchNot_{{ idFor(group) }}"
/>
<label
class="btn btn-outline-secondary"
for="advancedSearchNot_{{ idFor(group) }}"
i18n
>Not</label
>
</div>
<span class="small text-muted ms-2">
@switch (group.operator) {
@case (LogicalOperator.And) {
<ng-container i18n>match all of these</ng-container>
}
@case (LogicalOperator.Or) {
<ng-container i18n>match any of these</ng-container>
}
@case (LogicalOperator.Not) {
<ng-container i18n>match none of these</ng-container>
}
}
</span>
</div>
<div class="list-group list-group-flush">
@for (element of group.children; track element) {
<div class="list-group-item px-0 d-flex flex-nowrap">
@if (element.type === ElementType.Group) {
<ng-container
*ngTemplateOutlet="
groupTemplate;
context: { group: element, parent: group, depth: depth + 1 }
"
></ng-container>
} @else {
<ng-container
*ngTemplateOutlet="
atomTemplate;
context: { atom: element, parent: group }
"
></ng-container>
}
</div>
}
</div>
</div>
<div
class="btn-group-vertical align-self-start ms-2 ps-2 border-start"
role="group"
aria-label="Group actions"
i18n-aria-label
>
<button
type="button"
class="btn btn-sm btn-outline-secondary text-primary"
title="Add condition"
i18n-title
(click)="addAtom(group)"
[disabled]="group.children.length >= maxAtoms"
>
<i-bs name="node-plus"></i-bs>
</button>
<button
type="button"
class="btn btn-sm btn-outline-secondary text-primary"
title="Add group"
i18n-title
(click)="addGroup(group)"
[disabled]="depth >= maxDepth"
>
<i-bs name="braces"></i-bs>
</button>
@if (parent) {
<button
type="button"
class="btn btn-sm btn-outline-secondary text-danger"
aria-label="Remove group"
i18n-aria-label
(click)="remove(parent, group)"
>
<i-bs name="x-circle"></i-bs>
</button>
}
</div>
</div>
</ng-template>
<ng-template #atomTemplate let-atom="atom" let-parent="parent">
<div class="d-flex align-items-center gap-1 w-100">
<div class="input-group input-group-sm flex-wrap">
<select
class="form-select flex-grow-0 w-auto"
[(ngModel)]="atom.field"
[ngModelOptions]="{ standalone: true }"
(ngModelChange)="onFieldChange(atom)"
aria-label="Field"
i18n-aria-label
>
@for (fieldGroup of fieldGroups; track fieldGroup.label) {
<optgroup [label]="fieldGroup.label">
@for (field of fieldGroup.fields; track field) {
<option [ngValue]="field">{{ fieldLabels[field] }}</option>
}
</optgroup>
}
</select>
<select
class="form-select flex-grow-0 w-auto"
[(ngModel)]="atom.operator"
[ngModelOptions]="{ standalone: true }"
(ngModelChange)="onOperatorChange(atom)"
aria-label="Condition"
i18n-aria-label
>
@for (operator of operatorsFor(atom); track operator) {
<option [ngValue]="operator">
{{ operatorLabel(atom, operator) }}
</option>
}
</select>
@switch (atom.operator) {
@case (Operator.DateKeyword) {
<select
class="form-select"
[(ngModel)]="atom.value"
[ngModelOptions]="{ standalone: true }"
aria-label="Period"
i18n-aria-label
>
<option [ngValue]="''" disabled i18n>Choose a period</option>
@for (keyword of dateKeywords; track keyword) {
<option [ngValue]="keyword">
{{ dateKeywordLabels[keyword] }}
</option>
}
</select>
}
@case (Operator.WithinLast) {
<input
class="form-control amount"
type="number"
min="1"
[(ngModel)]="atom.value"
[ngModelOptions]="{ standalone: true }"
aria-label="Amount"
i18n-aria-label
/>
<select
class="form-select"
[(ngModel)]="atom.unit"
[ngModelOptions]="{ standalone: true }"
aria-label="Unit"
i18n-aria-label
>
@for (unit of dateUnits; track unit) {
<option [ngValue]="unit">{{ dateUnitLabels[unit] }}</option>
}
</select>
}
@case (Operator.Between) {
<input
class="form-control"
[type]="kindOf(atom) === FieldKind.Date ? 'date' : 'number'"
[(ngModel)]="atom.value"
[ngModelOptions]="{ standalone: true }"
aria-label="From"
i18n-aria-label
/>
<span class="input-group-text" i18n>and</span>
<input
class="form-control"
[type]="kindOf(atom) === FieldKind.Date ? 'date' : 'number'"
[(ngModel)]="atom.valueTo"
[ngModelOptions]="{ standalone: true }"
aria-label="To"
i18n-aria-label
/>
}
@default {
@if (kindOf(atom) === FieldKind.Date) {
<input
class="form-control"
type="date"
[(ngModel)]="atom.value"
[ngModelOptions]="{ standalone: true }"
aria-label="Date"
i18n-aria-label
/>
} @else if (kindOf(atom) === FieldKind.Number) {
<input
class="form-control"
type="number"
min="0"
[(ngModel)]="atom.value"
[ngModelOptions]="{ standalone: true }"
aria-label="Number"
i18n-aria-label
/>
} @else {
<input
class="form-control"
type="text"
[(ngModel)]="atom.value"
[ngModelOptions]="{ standalone: true }"
[placeholder]="placeholderFor(atom)"
aria-label="Value"
i18n-aria-label
/>
}
}
}
</div>
<button
class="btn btn-link btn-sm text-danger px-1"
type="button"
(click)="remove(parent, atom)"
aria-label="Remove condition"
i18n-aria-label
>
<i-bs name="x-circle"></i-bs>
</button>
</div>
</ng-template>
@@ -0,0 +1,11 @@
.query-preview {
font-size: 0.8125rem;
white-space: pre-wrap;
word-break: break-word;
background-color: var(--pngx-bg-darker);
border-color: var(--bs-border-color) !important;
}
.input-group .amount {
flex: 0 1 5rem;
}
@@ -0,0 +1,186 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import {
AdvancedSearchDateUnit,
AdvancedSearchField,
AdvancedSearchLogicalOperator,
AdvancedSearchOperator,
AdvancedSearchQueryAtom,
AdvancedSearchQueryElementType,
AdvancedSearchQueryGroup,
} from 'src/app/data/advanced-search-query'
import { AdvancedSearchDialogComponent } from './advanced-search-dialog.component'
describe('AdvancedSearchDialogComponent', () => {
let component: AdvancedSearchDialogComponent
let fixture: ComponentFixture<AdvancedSearchDialogComponent>
let activeModal: NgbActiveModal
const firstAtom = (): AdvancedSearchQueryAtom =>
component.root.children[0] as AdvancedSearchQueryAtom
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
AdvancedSearchDialogComponent,
NgxBootstrapIconsModule.pick(allIcons),
],
providers: [NgbActiveModal],
}).compileComponents()
fixture = TestBed.createComponent(AdvancedSearchDialogComponent)
activeModal = TestBed.inject(NgbActiveModal)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should start with one empty condition and nothing to search for', () => {
expect(component.root.children).toHaveLength(1)
expect(component.generatedQuery).toBe('')
expect(component.unreadable).toBeFalsy()
})
it('should show an existing query as conditions', () => {
component.query = 'title:invoice AND NOT tag:paid'
expect(component.unreadable).toBeFalsy()
expect(component.root.children).toHaveLength(2)
expect(component.generatedQuery).toBe('title:invoice AND NOT tag:paid')
})
it('should flag a query it cannot show and start empty', () => {
component.query = 'title:invoice^2'
expect(component.unreadable).toBeTruthy()
expect(component.generatedQuery).toBe('')
})
it('should clear the warning when starting a new query', () => {
component.query = 'title:invoice^2'
component.startOver()
expect(component.unreadable).toBeFalsy()
expect(component.root.children).toHaveLength(1)
})
it('should treat an empty query as a fresh start', () => {
component.query = ' '
expect(component.unreadable).toBeFalsy()
expect(component.root.children).toHaveLength(1)
})
it('should write the query as conditions are filled in', () => {
const atom = firstAtom()
atom.field = AdvancedSearchField.Title
atom.value = 'unpaid invoice'
expect(component.generatedQuery).toBe('title:unpaid AND title:invoice')
})
it('should offer the conditions of the chosen field', () => {
const atom = firstAtom()
atom.field = AdvancedSearchField.Added
component.onFieldChange(atom)
expect(component.operatorsFor(atom)).toContain(
AdvancedSearchOperator.WithinLast
)
expect(component.operatorsFor(atom)).not.toContain(
AdvancedSearchOperator.Phrase
)
})
it('should keep a condition the new field still offers', () => {
const atom = firstAtom()
atom.operator = AdvancedSearchOperator.Phrase
atom.field = AdvancedSearchField.Correspondent
component.onFieldChange(atom)
expect(atom.operator).toBe(AdvancedSearchOperator.Phrase)
})
it('should replace a condition the new field does not offer, and clear the value', () => {
const atom = firstAtom()
atom.operator = AdvancedSearchOperator.Phrase
atom.value = 'invoice'
atom.field = AdvancedSearchField.ASN
component.onFieldChange(atom)
expect(atom.operator).toBe(AdvancedSearchOperator.Equals)
expect(atom.value).toBe('')
})
it('should give a within-the-last condition a unit to start from', () => {
const atom = firstAtom()
atom.field = AdvancedSearchField.Added
atom.operator = AdvancedSearchOperator.WithinLast
component.onOperatorChange(atom)
expect(atom.unit).toBe(AdvancedSearchDateUnit.Day)
atom.value = '3'
expect(component.generatedQuery).toBe('added:[-3 days to now]')
})
it('should label date comparisons as dates', () => {
const atom = firstAtom()
atom.field = AdvancedSearchField.Created
expect(
component.operatorLabel(atom, AdvancedSearchOperator.AtLeast)
).toEqual('is on or after')
atom.field = AdvancedSearchField.ASN
expect(
component.operatorLabel(atom, AdvancedSearchOperator.AtLeast)
).toEqual('is at least')
})
it('should add and remove conditions', () => {
component.addAtom(component.root)
expect(component.root.children).toHaveLength(2)
component.remove(component.root, component.root.children[1])
expect(component.root.children).toHaveLength(1)
})
it('should add a group, which starts as Any', () => {
component.addGroup(component.root)
const group = component.root.children[1] as AdvancedSearchQueryGroup
expect(group.type).toBe(AdvancedSearchQueryElementType.Group)
expect(group.operator).toBe(AdvancedSearchLogicalOperator.Or)
expect(group.children).toHaveLength(1)
})
it('should give every group its own id, once', () => {
component.addGroup(component.root)
const group = component.root.children[1] as AdvancedSearchQueryGroup
expect(component.idFor(component.root)).not.toEqual(component.idFor(group))
expect(component.idFor(group)).toEqual(component.idFor(group))
})
it('should apply the query and close', () => {
const emitSpy = jest.spyOn(component.queryApplied, 'emit')
const closeSpy = jest.spyOn(activeModal, 'close')
const atom = firstAtom()
atom.value = 'invoice'
component.apply()
expect(emitSpy).toHaveBeenCalledWith('content:invoice')
expect(closeSpy).toHaveBeenCalled()
})
it('should close without applying on cancel', () => {
const emitSpy = jest.spyOn(component.queryApplied, 'emit')
const closeSpy = jest.spyOn(activeModal, 'close')
component.cancel()
expect(emitSpy).not.toHaveBeenCalled()
expect(closeSpy).toHaveBeenCalled()
})
it('should show the query it will apply', () => {
component.query = 'content:invoice OR content:receipt'
fixture.detectChanges()
const preview = fixture.nativeElement.querySelector(
'#advanced-search-preview'
)
expect(preview.textContent).toContain('content:invoice OR content:receipt')
})
it('should not offer to apply an empty query', () => {
fixture.detectChanges()
const apply = Array.from(
fixture.nativeElement.querySelectorAll('.modal-footer button')
).pop() as HTMLButtonElement
expect(apply.disabled).toBeTruthy()
})
})
@@ -0,0 +1,197 @@
import { NgTemplateOutlet } from '@angular/common'
import { Component, EventEmitter, inject, Input, Output } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import {
ADVANCED_SEARCH_DATE_KEYWORD_LABELS,
ADVANCED_SEARCH_DATE_KEYWORDS,
ADVANCED_SEARCH_DATE_OPERATOR_LABELS,
ADVANCED_SEARCH_DATE_UNIT_LABELS,
ADVANCED_SEARCH_FIELD_GROUPS,
ADVANCED_SEARCH_FIELD_KINDS,
ADVANCED_SEARCH_FIELD_LABELS,
ADVANCED_SEARCH_MAX_ATOMS,
ADVANCED_SEARCH_MAX_DEPTH,
ADVANCED_SEARCH_OPERATOR_LABELS,
ADVANCED_SEARCH_OPERATORS_BY_KIND,
AdvancedSearchDateUnit,
AdvancedSearchField,
AdvancedSearchFieldKind,
AdvancedSearchLogicalOperator,
AdvancedSearchOperator,
AdvancedSearchQueryAtom,
AdvancedSearchQueryElement,
AdvancedSearchQueryElementType,
AdvancedSearchQueryGroup,
} from 'src/app/data/advanced-search-query'
import {
parseAdvancedSearchQuery,
serializeAdvancedSearchQuery,
} from 'src/app/utils/advanced-search-query'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
@Component({
selector: 'pngx-advanced-search-dialog',
templateUrl: './advanced-search-dialog.component.html',
styleUrl: './advanced-search-dialog.component.scss',
imports: [FormsModule, NgTemplateOutlet, NgxBootstrapIconsModule],
})
export class AdvancedSearchDialogComponent extends LoadingComponentWithPermissions {
private activeModal = inject(NgbActiveModal)
public readonly ElementType = AdvancedSearchQueryElementType
public readonly LogicalOperator = AdvancedSearchLogicalOperator
public readonly Operator = AdvancedSearchOperator
public readonly FieldKind = AdvancedSearchFieldKind
public readonly fieldGroups = ADVANCED_SEARCH_FIELD_GROUPS
public readonly fieldLabels = ADVANCED_SEARCH_FIELD_LABELS
public readonly dateKeywords = ADVANCED_SEARCH_DATE_KEYWORDS
public readonly dateKeywordLabels = ADVANCED_SEARCH_DATE_KEYWORD_LABELS
public readonly dateUnits = Object.values(AdvancedSearchDateUnit)
public readonly dateUnitLabels = ADVANCED_SEARCH_DATE_UNIT_LABELS
public readonly maxDepth = ADVANCED_SEARCH_MAX_DEPTH
public readonly maxAtoms = ADVANCED_SEARCH_MAX_ATOMS
@Output()
public queryApplied = new EventEmitter<string>()
public root: AdvancedSearchQueryGroup = this.emptyRoot()
// True when the query in the search box uses syntax the editor cannot show
public unreadable: boolean = false
private _query: string = ''
@Input()
set query(query: string) {
this._query = query ?? ''
const parsed = parseAdvancedSearchQuery(this._query)
this.unreadable = !!this._query.trim() && !parsed
this.root = parsed ?? this.emptyRoot()
}
get query(): string {
return this._query
}
constructor() {
super()
this.loading.set(false)
}
// Stable ids for the radio groups, without putting them in the query model
private ids = new WeakMap<object, number>()
private nextId = 0
public idFor(element: AdvancedSearchQueryElement): number {
if (!this.ids.has(element)) {
this.ids.set(element, this.nextId++)
}
return this.ids.get(element)
}
private emptyRoot(): AdvancedSearchQueryGroup {
return {
type: AdvancedSearchQueryElementType.Group,
operator: AdvancedSearchLogicalOperator.And,
children: [this.newAtom()],
}
}
private newAtom(): AdvancedSearchQueryAtom {
return {
type: AdvancedSearchQueryElementType.Atom,
field: AdvancedSearchField.Content,
operator: AdvancedSearchOperator.AllWords,
value: '',
}
}
public get generatedQuery(): string {
return serializeAdvancedSearchQuery(this.root)
}
public kindOf(atom: AdvancedSearchQueryAtom): AdvancedSearchFieldKind {
return ADVANCED_SEARCH_FIELD_KINDS[atom.field]
}
public operatorsFor(atom: AdvancedSearchQueryAtom): AdvancedSearchOperator[] {
return ADVANCED_SEARCH_OPERATORS_BY_KIND[this.kindOf(atom)]
}
public operatorLabel(
atom: AdvancedSearchQueryAtom,
operator: AdvancedSearchOperator
): string {
return this.kindOf(atom) === AdvancedSearchFieldKind.Date
? (ADVANCED_SEARCH_DATE_OPERATOR_LABELS[operator] ??
ADVANCED_SEARCH_OPERATOR_LABELS[operator])
: ADVANCED_SEARCH_OPERATOR_LABELS[operator]
}
public placeholderFor(atom: AdvancedSearchQueryAtom): string {
switch (atom.operator) {
case AdvancedSearchOperator.Phrase:
return $localize`phrase`
case AdvancedSearchOperator.StartsWith:
return $localize`beginning of a word`
default:
return $localize`words`
}
}
public onFieldChange(atom: AdvancedSearchQueryAtom) {
// Keep the condition only if the new field still offers it
if (!this.operatorsFor(atom).includes(atom.operator)) {
atom.operator = this.operatorsFor(atom)[0]
}
this.clearValues(atom)
}
public onOperatorChange(atom: AdvancedSearchQueryAtom) {
this.clearValues(atom)
}
private clearValues(atom: AdvancedSearchQueryAtom) {
atom.value = ''
atom.valueTo = undefined
atom.unit =
atom.operator === AdvancedSearchOperator.WithinLast
? AdvancedSearchDateUnit.Day
: undefined
}
public addAtom(group: AdvancedSearchQueryGroup) {
group.children.push(this.newAtom())
}
public addGroup(group: AdvancedSearchQueryGroup) {
group.children.push({
type: AdvancedSearchQueryElementType.Group,
operator: AdvancedSearchLogicalOperator.Or,
children: [this.newAtom()],
})
}
public remove(
parent: AdvancedSearchQueryGroup,
element: AdvancedSearchQueryElement
) {
parent.children = parent.children.filter((child) => child !== element)
}
public startOver() {
this.unreadable = false
this.root = this.emptyRoot()
}
public apply() {
this.queryApplied.emit(this.generatedQuery)
this.activeModal.close()
}
public cancel() {
this.activeModal.close()
}
}
@@ -154,29 +154,11 @@
& section { & section {
position: absolute; position: absolute;
text-align: initial; text-align: initial;
pointer-events: auto;
box-sizing: border-box; box-sizing: border-box;
transform-origin: 0 0; transform-origin: 0 0;
} }
& :is(.linkAnnotation, .buttonWidgetAnnotation.pushButton) > a {
position: absolute;
inset: 0;
font-size: 1em;
transition: none;
}
& :is(.linkAnnotation, .buttonWidgetAnnotation.pushButton):not(.hasBorder)
> a:hover {
opacity: 0.2;
background-color: rgb(255 255 0);
}
& .annotationTextContent { & .annotationTextContent {
opacity: 0; opacity: 0;
} }
} }
:host ::ng-deep .textLayer.selecting ~ .annotationLayer section {
pointer-events: none;
}
@@ -1,11 +1,7 @@
import { SimpleChange } from '@angular/core' import { SimpleChange } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import * as pdfjs from 'pdfjs-dist/legacy/build/pdf.mjs' import * as pdfjs from 'pdfjs-dist/legacy/build/pdf.mjs'
import { import { PDFSinglePageViewer, PDFViewer } from 'pdfjs-dist/web/pdf_viewer.mjs'
LinkTarget,
PDFSinglePageViewer,
PDFViewer,
} from 'pdfjs-dist/web/pdf_viewer.mjs'
import { PngxPdfViewerComponent } from './pdf-viewer.component' import { PngxPdfViewerComponent } from './pdf-viewer.component'
import { PdfRenderMode, PdfZoomLevel, PdfZoomScale } from './pdf-viewer.types' import { PdfRenderMode, PdfZoomLevel, PdfZoomScale } from './pdf-viewer.types'
@@ -62,16 +58,6 @@ describe('PngxPdfViewerComponent', () => {
expect((component as any).pdfViewer).toBeInstanceOf(PDFViewer) expect((component as any).pdfViewer).toBeInstanceOf(PDFViewer)
}) })
it('opens external links in a new tab', () => {
const linkService = (component as any).linkService
expect(linkService.options).toEqual(
expect.objectContaining({
externalLinkTarget: LinkTarget.BLANK,
externalLinkRel: 'noopener noreferrer nofollow',
})
)
})
it('resolves the worker source relative to the document base URI', async () => { it('resolves the worker source relative to the document base URI', async () => {
setBaseHref('/paperless/') setBaseHref('/paperless/')
const getDocumentSpy = jest.spyOn(pdfjs, 'getDocument') const getDocumentSpy = jest.spyOn(pdfjs, 'getDocument')
@@ -90,7 +76,6 @@ describe('PngxPdfViewerComponent', () => {
withCredentials: true, withCredentials: true,
wasmUrl: expect.stringContaining('/paperless/assets/wasm/'), wasmUrl: expect.stringContaining('/paperless/assets/wasm/'),
iccUrl: expect.stringContaining('/paperless/assets/iccs/'), iccUrl: expect.stringContaining('/paperless/assets/iccs/'),
cMapUrl: expect.stringContaining('/paperless/assets/cmaps/'),
}) })
}) })
@@ -21,7 +21,6 @@ import {
} from 'pdfjs-dist/legacy/build/pdf.mjs' } from 'pdfjs-dist/legacy/build/pdf.mjs'
import { import {
EventBus, EventBus,
LinkTarget,
PDFFindController, PDFFindController,
PDFLinkService, PDFLinkService,
PDFSinglePageViewer, PDFSinglePageViewer,
@@ -76,11 +75,7 @@ export class PngxPdfViewerComponent
private lastViewerPage?: number private lastViewerPage?: number
private readonly eventBus = new EventBus() private readonly eventBus = new EventBus()
private readonly linkService = new PDFLinkService({ private readonly linkService = new PDFLinkService({ eventBus: this.eventBus })
eventBus: this.eventBus,
externalLinkTarget: LinkTarget.BLANK,
externalLinkRel: 'noopener noreferrer nofollow',
})
private readonly findController = new PDFFindController({ private readonly findController = new PDFFindController({
eventBus: this.eventBus, eventBus: this.eventBus,
linkService: this.linkService, linkService: this.linkService,
@@ -190,7 +185,6 @@ export class PngxPdfViewerComponent
withCredentials: true, withCredentials: true,
wasmUrl: new URL('assets/wasm/', this.document.baseURI).toString(), wasmUrl: new URL('assets/wasm/', this.document.baseURI).toString(),
iccUrl: new URL('assets/iccs/', this.document.baseURI).toString(), iccUrl: new URL('assets/iccs/', this.document.baseURI).toString(),
cMapUrl: new URL('assets/cmaps/', this.document.baseURI).toString(),
} }
this.loadingTask = getDocument(initOptions) this.loadingTask = getDocument(initOptions)
try { try {
@@ -1,84 +1,58 @@
<div class="d-flex align-items-center"> <div class="btn-group">
<div class="btn-group"> <button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading() || (suggestions() && !aiEnabled())" [aria-label]="noSuggestions ? 'No suggestions' : 'Suggest'" i18n-aria-label>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading() || (suggestions() && !aiEnabled())" [aria-label]="noSuggestions ? 'No suggestions' : 'Suggest'" i18n-aria-label> @if (loading()) {
@if (loading()) { <div class="spinner-border spinner-border-sm" role="status"></div>
<div class="spinner-border spinner-border-sm" role="status"></div> } @else if (noSuggestions) {
} @else if (noSuggestions) { <i-bs width="1.2em" height="1.2em" name="check-circle"></i-bs>
<i-bs width="1.2em" height="1.2em" name="check-circle"></i-bs> } @else {
} @else { <i-bs width="1.2em" height="1.2em" name="stars"></i-bs>
<i-bs width="1.2em" height="1.2em" name="lightbulb"></i-bs>
}
@if (noSuggestions) {
<span class="d-none d-lg-inline ps-1" i18n>No suggestions</span>
} @else {
<span class="d-none d-lg-inline ps-1" i18n>Suggest</span>
}
@if (totalSuggestions > 0) {
<span class="badge bg-primary ms-2">{{ totalSuggestions }}</span>
}
</button>
@if (aiEnabled()) {
<div class="btn-group" ngbDropdown #dropdown="ngbDropdown" [popperOptions]="popperOptions">
<button type="button" class="btn btn-sm btn-outline-primary" ngbDropdownToggle [disabled]="disabled() || loading() || !suggestions()" aria-expanded="false" aria-controls="suggestionsDropdown" aria-label="Suggestions dropdown">
<span class="visually-hidden" i18n>Show suggestions</span>
</button>
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
<div class="list-group list-group-flush small pb-0">
@if (novelSuggestions === 0 && reusableSuggestions === 0) {
<div class="list-group-item text-muted fst-italic">
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
</div>
}
@if (suggestions()?.suggested_tags?.length > 0) {
<small class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="tags"></i-bs><ng-container i18n>Tags</ng-container></small>
@for (tag of suggestions().suggested_tags; track tag) {
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)">{{ tag }}</button>
}
}
@if (suggestions()?.suggested_document_types?.length > 0) {
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="hash"></i-bs><ng-container i18n>Document Types</ng-container></div>
@for (type of suggestions().suggested_document_types; track type) {
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)">{{ type }}</button>
}
}
@if (suggestions()?.suggested_correspondents?.length > 0) {
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="person"></i-bs><ng-container i18n>Correspondents</ng-container></div>
@for (correspondent of suggestions().suggested_correspondents; track correspondent) {
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button>
}
}
@if (reusableSuggestions > 0) {
<div class="list-group-item text-muted fst-italic">
<small class="text-muted small fst-italic" i18n>{reusableSuggestions, plural, =1 {1 existing value suggested below} other {{{reusableSuggestions}} existing values suggested below}}</small>
</div>
}
</div>
</div>
</div>
} }
</div> @if (noSuggestions) {
<span class="d-none d-lg-inline ps-1" i18n>No suggestions</span>
} @else {
<span class="d-none d-lg-inline ps-1" i18n>Suggest</span>
}
@if (totalSuggestions > 0) {
<span class="badge bg-primary ms-2">{{ totalSuggestions }}</span>
}
</button>
@if (aiEnabled()) { @if (aiEnabled()) {
<div ngbDropdown autoClose="outside" placement="bottom-end" [popperOptions]="popperOptions"> <div class="btn-group" ngbDropdown #dropdown="ngbDropdown" [popperOptions]="popperOptions">
<button type="button" class="btn btn-sm btn-link position-relative" ngbDropdownToggle [disabled]="disabled() || loading()" i18n-title title="Suggestion options"> <button type="button" class="btn btn-sm btn-outline-primary" ngbDropdownToggle [disabled]="disabled() || loading() || !suggestions()" aria-expanded="false" aria-controls="suggestionsDropdown" aria-label="Suggestions dropdown">
<i-bs name="three-dots"></i-bs> <span class="visually-hidden" i18n>Show suggestions</span>
@if (source() !== defaultSource()) {
<span class="position-absolute top-0 start-100 translate-middle p-1 bg-primary border border-light rounded-circle">
<span class="visually-hidden" i18n>Not using default</span>
</span>
}
</button> </button>
<div ngbDropdownMenu class="shadow p-3">
<div class="small text-muted mb-2" i18n>Suggest using:</div> <div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
<div class="form-check small"> <div class="list-group list-group-flush small pb-0">
<input class="form-check-input" type="checkbox" id="suggestionSourceML" [checked]="useML" [disabled]="useML && !useAI" (change)="setSources($event.target.checked, useAI)"> @if (novelSuggestions === 0 && reusableSuggestions === 0) {
<label class="form-check-label d-inline-flex align-items-center gap-1" for="suggestionSourceML"><i-bs name="cpu"></i-bs><ng-container i18n>ML</ng-container></label> <div class="list-group-item text-muted fst-italic">
</div> <small class="text-muted small fst-italic" i18n>No novel suggestions</small>
<div class="form-check small"> </div>
<input class="form-check-input" type="checkbox" id="suggestionSourceAI" [checked]="useAI" [disabled]="useAI && !useML" (change)="setSources(useML, $event.target.checked)"> }
<label class="form-check-label d-inline-flex align-items-center gap-1" for="suggestionSourceAI"><i-bs name="stars"></i-bs><ng-container i18n>AI</ng-container></label> @if (suggestions()?.suggested_tags?.length > 0) {
<small class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="tags"></i-bs><ng-container i18n>Tags</ng-container></small>
@for (tag of suggestions().suggested_tags; track tag) {
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)">{{ tag }}</button>
}
}
@if (suggestions()?.suggested_document_types?.length > 0) {
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="hash"></i-bs><ng-container i18n>Document Types</ng-container></div>
@for (type of suggestions().suggested_document_types; track type) {
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)">{{ type }}</button>
}
}
@if (suggestions()?.suggested_correspondents?.length > 0) {
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="person"></i-bs><ng-container i18n>Correspondents</ng-container></div>
@for (correspondent of suggestions().suggested_correspondents; track correspondent) {
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button>
}
}
@if (reusableSuggestions > 0) {
<div class="list-group-item text-muted fst-italic">
<small class="text-muted small fst-italic" i18n>{reusableSuggestions, plural, =1 {1 existing value suggested below} other {{{reusableSuggestions}} existing values suggested below}}</small>
</div>
}
</div> </div>
</div> </div>
</div> </div>
@@ -1,7 +1,3 @@
.suggestions-dropdown { .suggestions-dropdown {
min-width: 250px; min-width: 250px;
} }
.btn-link.dropdown-toggle::after {
display: none;
}
@@ -1,7 +1,6 @@
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { SuggestionSource } from 'src/app/data/ui-settings'
import { SuggestionsDropdownComponent } from './suggestions-dropdown.component' import { SuggestionsDropdownComponent } from './suggestions-dropdown.component'
describe('SuggestionsDropdownComponent', () => { describe('SuggestionsDropdownComponent', () => {
@@ -180,71 +179,14 @@ describe('SuggestionsDropdownComponent', () => {
it('should toggle dropdown when clickSuggest is called and suggestions are not null', () => { it('should toggle dropdown when clickSuggest is called and suggestions are not null', () => {
fixture.componentRef.setInput('aiEnabled', true) fixture.componentRef.setInput('aiEnabled', true)
fixture.componentRef.setInput('fetchedSources', [SuggestionSource.ML])
fixture.detectChanges() fixture.detectChanges()
fixture.componentRef.setInput('suggestions', { fixture.componentRef.setInput('suggestions', {
suggested_correspondents: [], suggested_correspondents: [],
suggested_tags: [], suggested_tags: [],
suggested_document_types: [], suggested_document_types: [],
}) })
fixture.detectChanges()
component.clickSuggest() component.clickSuggest()
expect(component.dropdown.isOpen()).toBeTruthy() expect(component.dropdown.open).toBeTruthy()
expect(fixture.nativeElement.textContent).toContain('No novel suggestions') expect(fixture.nativeElement.textContent).toContain('No novel suggestions')
}) })
it('should fetch unfetched sources and show existing suggestions', () => {
jest.spyOn(component.getSuggestions, 'emit')
fixture.componentRef.setInput('aiEnabled', true)
fixture.componentRef.setInput('source', SuggestionSource.Both)
fixture.componentRef.setInput('fetchedSources', [SuggestionSource.ML])
fixture.componentRef.setInput('suggestions', { tags: [1] })
fixture.detectChanges()
component.clickSuggest()
expect(component.getSuggestions.emit).toHaveBeenCalledWith(
SuggestionSource.Both
)
expect(component.dropdown.isOpen()).toBeTruthy()
})
it('should only show source options when AI is enabled', () => {
expect(
fixture.nativeElement.querySelector('#suggestionSourceML')
).toBeNull()
fixture.componentRef.setInput('aiEnabled', true)
fixture.detectChanges()
fixture.nativeElement
.querySelector('button[title="Suggestion options"]')
.click()
fixture.detectChanges()
expect(
fixture.nativeElement.querySelector('#suggestionSourceML')
).not.toBeNull()
})
it('should emit source changes and never allow no source', () => {
const emitSpy = jest.spyOn(component.sourceChange, 'emit')
component.setSources(true, true)
expect(emitSpy).toHaveBeenCalledWith(SuggestionSource.Both)
component.setSources(true, false)
expect(emitSpy).toHaveBeenCalledWith(SuggestionSource.ML)
component.setSources(false, true)
expect(emitSpy).toHaveBeenCalledWith(SuggestionSource.AI)
emitSpy.mockClear()
component.setSources(false, false)
expect(emitSpy).not.toHaveBeenCalled()
})
it('should indicate a non-default source', () => {
fixture.componentRef.setInput('aiEnabled', true)
fixture.componentRef.setInput('source', SuggestionSource.AI)
fixture.componentRef.setInput('defaultSource', SuggestionSource.AI)
fixture.detectChanges()
expect(fixture.nativeElement.textContent).not.toContain('Not using default')
fixture.componentRef.setInput('source', SuggestionSource.Both)
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Not using default')
})
}) })
@@ -8,7 +8,6 @@ import {
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { DocumentSuggestions } from 'src/app/data/document-suggestions' import { DocumentSuggestions } from 'src/app/data/document-suggestions'
import { SuggestionSource } from 'src/app/data/ui-settings'
import { pngxPopperOptions } from 'src/app/utils/popper-options' import { pngxPopperOptions } from 'src/app/utils/popper-options'
@Component({ @Component({
@@ -19,16 +18,12 @@ import { pngxPopperOptions } from 'src/app/utils/popper-options'
}) })
export class SuggestionsDropdownComponent { export class SuggestionsDropdownComponent {
public popperOptions = pngxPopperOptions public popperOptions = pngxPopperOptions
public readonly SuggestionSource = SuggestionSource
@ViewChild('dropdown') dropdown: NgbDropdown @ViewChild('dropdown') dropdown: NgbDropdown
readonly suggestions = input<DocumentSuggestions>(null) readonly suggestions = input<DocumentSuggestions>(null)
readonly aiEnabled = input(false) readonly aiEnabled = input(false)
readonly loading = input(false) readonly loading = input(false)
readonly disabled = input(false) readonly disabled = input(false)
readonly source = input<SuggestionSource>(SuggestionSource.ML)
readonly defaultSource = input<SuggestionSource>(SuggestionSource.ML)
readonly fetchedSources = input<SuggestionSource[]>([])
readonly appliedTags = input<number[]>([]) readonly appliedTags = input<number[]>([])
readonly appliedCorrespondent = input<number>(null) readonly appliedCorrespondent = input<number>(null)
@@ -36,10 +31,8 @@ export class SuggestionsDropdownComponent {
readonly appliedStoragePath = input<number>(null) readonly appliedStoragePath = input<number>(null)
@Output() @Output()
getSuggestions: EventEmitter<SuggestionSource> = new EventEmitter() getSuggestions: EventEmitter<SuggestionsDropdownComponent> =
new EventEmitter()
@Output()
sourceChange: EventEmitter<SuggestionSource> = new EventEmitter()
@Output() @Output()
addTag: EventEmitter<string> = new EventEmitter() addTag: EventEmitter<string> = new EventEmitter()
@@ -60,42 +53,12 @@ export class SuggestionsDropdownComponent {
} }
if (!this.suggestions()) { if (!this.suggestions()) {
this.getSuggestions.emit(this.source()) this.getSuggestions.emit(this)
} else if (this.hasUnfetchedSources) {
// sources changed, fetch the rest and show what we have meanwhile
this.getSuggestions.emit(this.source())
this.dropdown?.open()
} else { } else {
this.dropdown?.toggle() this.dropdown?.toggle()
} }
} }
get useML(): boolean {
return this.source() !== SuggestionSource.AI
}
get useAI(): boolean {
return this.source() !== SuggestionSource.ML
}
get hasUnfetchedSources(): boolean {
const fetched = this.fetchedSources()
return (
(this.useML && !fetched.includes(SuggestionSource.ML)) ||
(this.useAI && !fetched.includes(SuggestionSource.AI))
)
}
public setSources(ml: boolean, ai: boolean) {
if (ml && ai) {
this.sourceChange.emit(SuggestionSource.Both)
} else if (ml) {
this.sourceChange.emit(SuggestionSource.ML)
} else if (ai) {
this.sourceChange.emit(SuggestionSource.AI)
}
}
get novelSuggestions(): number { get novelSuggestions(): number {
return ( return (
(this.suggestions()?.suggested_correspondents?.length ?? 0) + (this.suggestions()?.suggested_correspondents?.length ?? 0) +
@@ -134,15 +134,11 @@
[loading]="suggestionsLoading()" [loading]="suggestionsLoading()"
[suggestions]="suggestions()" [suggestions]="suggestions()"
[aiEnabled]="aiEnabled" [aiEnabled]="aiEnabled"
[source]="suggestionSource"
[defaultSource]="defaultSuggestionSource"
[fetchedSources]="fetchedSuggestionSources()"
[appliedTags]="documentForm.value.tags" [appliedTags]="documentForm.value.tags"
[appliedCorrespondent]="documentForm.value.correspondent" [appliedCorrespondent]="documentForm.value.correspondent"
[appliedDocumentType]="documentForm.value.document_type" [appliedDocumentType]="documentForm.value.document_type"
[appliedStoragePath]="documentForm.value.storage_path" [appliedStoragePath]="documentForm.value.storage_path"
(getSuggestions)="getSuggestions($event)" (getSuggestions)="getSuggestions()"
(sourceChange)="suggestionSourceOverride.set($event)"
(addTag)="createTag($event)" (addTag)="createTag($event)"
(addDocumentType)="createDocumentType($event)" (addDocumentType)="createDocumentType($event)"
(addCorrespondent)="createCorrespondent($event)"> (addCorrespondent)="createCorrespondent($event)">
@@ -43,7 +43,7 @@ import {
} from 'src/app/data/filter-rule-type' } from 'src/app/data/filter-rule-type'
import { StoragePath } from 'src/app/data/storage-path' import { StoragePath } from 'src/app/data/storage-path'
import { Tag } from 'src/app/data/tag' import { Tag } from 'src/app/data/tag'
import { SETTINGS_KEYS, SuggestionSource } from 'src/app/data/ui-settings' import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { PermissionsGuard } from 'src/app/guards/permissions.guard' import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe' import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
@@ -1528,113 +1528,6 @@ describe('DocumentDetailComponent', () => {
expect(component.suggestionsLoading()).toBeFalsy() expect(component.suggestionsLoading()).toBeFalsy()
}) })
it('should get and merge ML and AI suggestions when source is both', () => {
settingsService.set(
SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE,
SuggestionSource.Both
)
const getSetting = settingsService.get.bind(settingsService)
jest
.spyOn(settingsService, 'get')
.mockImplementation((key) =>
key === SETTINGS_KEYS.AI_ENABLED ? true : getSetting(key)
)
const suggestionsSpy = jest
.spyOn(documentService, 'getSuggestions')
.mockReturnValue(of({ tags: [42], dates: ['2024-01-01'] }))
const aiSuggestionsSpy = jest
.spyOn(documentService, 'getAiSuggestions')
.mockReturnValue(
of({ title: 'AI title', tags: [42, 43], suggested_tags: ['New'] })
)
initNormally()
expect(suggestionsSpy).toHaveBeenCalled()
expect(aiSuggestionsSpy).toHaveBeenCalled()
expect(component.suggestions().title).toEqual('AI title')
expect(component.suggestions().tags).toEqual([42, 43])
expect(component.suggestions().suggested_tags).toEqual(['New'])
expect(component.suggestions().dates).toEqual(['2024-01-01'])
})
it('should only fetch sources not yet fetched for the document', () => {
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST, false)
settingsService.set(
SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE,
SuggestionSource.ML
)
const getSetting = settingsService.get.bind(settingsService)
jest
.spyOn(settingsService, 'get')
.mockImplementation((key) =>
key === SETTINGS_KEYS.AI_ENABLED ? true : getSetting(key)
)
const suggestionsSpy = jest
.spyOn(documentService, 'getSuggestions')
.mockReturnValue(of({ tags: [42] }))
const aiSuggestionsSpy = jest
.spyOn(documentService, 'getAiSuggestions')
.mockReturnValue(of({ tags: [43] }))
initNormally()
component.getSuggestions()
expect(suggestionsSpy).toHaveBeenCalledTimes(1)
expect(aiSuggestionsSpy).not.toHaveBeenCalled()
component.getSuggestions(SuggestionSource.Both)
expect(suggestionsSpy).toHaveBeenCalledTimes(1)
expect(aiSuggestionsSpy).toHaveBeenCalledTimes(1)
expect(component.suggestions().tags).toEqual([42, 43])
component.getSuggestions(SuggestionSource.Both)
expect(suggestionsSpy).toHaveBeenCalledTimes(1)
expect(aiSuggestionsSpy).toHaveBeenCalledTimes(1)
})
it('should use the per-document source override and reset it on document change', () => {
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST, false)
const getSetting = settingsService.get.bind(settingsService)
jest
.spyOn(settingsService, 'get')
.mockImplementation((key) =>
key === SETTINGS_KEYS.AI_ENABLED ? true : getSetting(key)
)
initNormally()
expect(component.suggestionSource).toEqual(SuggestionSource.AI)
component.suggestionSourceOverride.set(SuggestionSource.ML)
expect(component.suggestionSource).toEqual(SuggestionSource.ML)
jest
.spyOn(documentService, 'get')
.mockReturnValueOnce(of(Object.assign({}, doc)))
;(component as any).loadDocument(doc.id, true)
expect(component.suggestionSourceOverride()).toBeNull()
expect(component.fetchedSuggestionSources()).toEqual([])
})
it('should keep suggestions from one source if the other fails', () => {
settingsService.set(
SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE,
SuggestionSource.Both
)
const getSetting = settingsService.get.bind(settingsService)
jest
.spyOn(settingsService, 'get')
.mockImplementation((key) =>
key === SETTINGS_KEYS.AI_ENABLED ? true : getSetting(key)
)
const errorSpy = jest.spyOn(toastService, 'showError')
jest
.spyOn(documentService, 'getSuggestions')
.mockReturnValue(of({ tags: [42] }))
jest
.spyOn(documentService, 'getAiSuggestions')
.mockReturnValue(throwError(() => new Error('failed')))
initNormally()
expect(errorSpy).toHaveBeenCalled()
expect(component.suggestions().tags).toEqual([42])
expect(component.fetchedSuggestionSources()).toEqual([SuggestionSource.ML])
})
it('should show error if needed for get suggestions', () => { it('should show error if needed for get suggestions', () => {
const suggestionsSpy = jest.spyOn(documentService, 'getSuggestions') const suggestionsSpy = jest.spyOn(documentService, 'getSuggestions')
const errorSpy = jest.spyOn(toastService, 'showError') const errorSpy = jest.spyOn(toastService, 'showError')
@@ -28,7 +28,7 @@ import {
import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms' import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { DeviceDetectorService } from 'ngx-device-detector' import { DeviceDetectorService } from 'ngx-device-detector'
import { BehaviorSubject, merge, Observable, of, Subject, timer } from 'rxjs' import { BehaviorSubject, Observable, of, Subject, timer } from 'rxjs'
import { import {
catchError, catchError,
debounceTime, debounceTime,
@@ -48,10 +48,7 @@ import { DataType } from 'src/app/data/datatype'
import { Document, DocumentVersionInfo } from 'src/app/data/document' import { Document, DocumentVersionInfo } from 'src/app/data/document'
import { DocumentMetadata } from 'src/app/data/document-metadata' import { DocumentMetadata } from 'src/app/data/document-metadata'
import { DocumentNote } from 'src/app/data/document-note' import { DocumentNote } from 'src/app/data/document-note'
import { import { DocumentSuggestions } from 'src/app/data/document-suggestions'
DocumentSuggestions,
mergeSuggestions,
} from 'src/app/data/document-suggestions'
import { DocumentType } from 'src/app/data/document-type' import { DocumentType } from 'src/app/data/document-type'
import { FilterRule } from 'src/app/data/filter-rule' import { FilterRule } from 'src/app/data/filter-rule'
import { import {
@@ -66,7 +63,7 @@ import {
import { ObjectWithId } from 'src/app/data/object-with-id' import { ObjectWithId } from 'src/app/data/object-with-id'
import { StoragePath } from 'src/app/data/storage-path' import { StoragePath } from 'src/app/data/storage-path'
import { Tag } from 'src/app/data/tag' import { Tag } from 'src/app/data/tag'
import { SETTINGS_KEYS, SuggestionSource } from 'src/app/data/ui-settings' import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { User } from 'src/app/data/user' import { User } from 'src/app/data/user'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
@@ -243,10 +240,6 @@ export class DocumentDetailComponent
private readonly autoSuggestSetting = this.settings.getSignal<boolean>( private readonly autoSuggestSetting = this.settings.getSignal<boolean>(
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST
) )
private readonly suggestionSourceSetting =
this.settings.getSignal<SuggestionSource>(
SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE
)
private readonly hiddenFieldsSetting = this.settings.getSignal< private readonly hiddenFieldsSetting = this.settings.getSignal<
DocumentDetailFieldID[] DocumentDetailFieldID[]
>(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS) >(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS)
@@ -268,9 +261,6 @@ export class DocumentDetailComponent
readonly metadata = signal<DocumentMetadata>(undefined) readonly metadata = signal<DocumentMetadata>(undefined)
readonly suggestions = signal<DocumentSuggestions>(undefined) readonly suggestions = signal<DocumentSuggestions>(undefined)
readonly suggestionsLoading = signal(false) readonly suggestionsLoading = signal(false)
// per-document, resets on navigation
readonly suggestionSourceOverride = signal<SuggestionSource>(null)
readonly fetchedSuggestionSources = signal<SuggestionSource[]>([])
readonly users = signal<User[]>(undefined) readonly users = signal<User[]>(undefined)
readonly title = signal<string>(undefined) readonly title = signal<string>(undefined)
@@ -375,15 +365,6 @@ export class DocumentDetailComponent
return this.autoSuggestSetting() return this.autoSuggestSetting()
} }
get defaultSuggestionSource(): SuggestionSource {
return this.aiEnabled ? this.suggestionSourceSetting() : SuggestionSource.ML
}
get suggestionSource(): SuggestionSource {
if (!this.aiEnabled) return SuggestionSource.ML
return this.suggestionSourceOverride() ?? this.defaultSuggestionSource
}
get archiveContentRenderType(): ContentRenderType { get archiveContentRenderType(): ContentRenderType {
const hasArchiveVersion = const hasArchiveVersion =
this.metadata()?.has_archive_version ?? this.metadata()?.has_archive_version ??
@@ -609,8 +590,6 @@ export class DocumentDetailComponent
} }
this.documentId.set(doc.id) this.documentId.set(doc.id)
this.suggestions.set(null) this.suggestions.set(null)
this.suggestionSourceOverride.set(null)
this.fetchedSuggestionSources.set([])
const openDocument = this.openDocumentService.getOpenDocument( const openDocument = this.openDocumentService.getOpenDocument(
this.documentId() this.documentId()
) )
@@ -1098,44 +1077,29 @@ export class DocumentDetailComponent
return this.documentForm.get('custom_fields') as FormArray return this.documentForm.get('custom_fields') as FormArray
} }
getSuggestions(source: SuggestionSource = this.suggestionSource) { getSuggestions() {
const sources = (
source === SuggestionSource.Both
? [SuggestionSource.ML, SuggestionSource.AI]
: [source]
).filter((s) => !this.fetchedSuggestionSources().includes(s))
if (!sources.length) return
this.suggestionsLoading.set(true) this.suggestionsLoading.set(true)
merge( const suggestionsObservable = this.aiEnabled
...sources.map((s) => ? this.documentsService.getAiSuggestions(this.documentId())
(s === SuggestionSource.AI : this.documentsService.getSuggestions(this.documentId())
? this.documentsService.getAiSuggestions(this.documentId()) suggestionsObservable
: this.documentsService.getSuggestions(this.documentId())
).pipe(
first(),
map((result) => ({ source: s, result })),
catchError((error) => {
this.toastService.showError(
$localize`Error retrieving suggestions.`,
error
)
return of(null)
})
)
)
)
.pipe( .pipe(
first(),
takeUntil(this.unsubscribeNotifier), takeUntil(this.unsubscribeNotifier),
takeUntil(this.docChangeNotifier), takeUntil(this.docChangeNotifier),
finalize(() => this.suggestionsLoading.set(false)) finalize(() => this.suggestionsLoading.set(false))
) )
.subscribe((response) => { .subscribe({
if (!response) return next: (result) => {
this.fetchedSuggestionSources.update((f) => [...f, response.source]) this.suggestions.set(result)
this.suggestions.set( },
mergeSuggestions(this.suggestions(), response.result) error: (error) => {
) this.suggestions.set(null)
this.toastService.showError(
$localize`Error retrieving suggestions.`,
error
)
},
}) })
} }
@@ -146,19 +146,6 @@ describe('DocumentListComponent', () => {
expect(reloadSpy).toHaveBeenCalled() expect(reloadSpy).toHaveBeenCalled()
}) })
it('should stop reloading on document deleted after destroy', () => {
const reloadSpy = jest.spyOn(documentListService, 'reload')
const documentDeletedSubject = new Subject<boolean>()
jest
.spyOn(websocketStatusService, 'onDocumentDeleted')
.mockReturnValue(documentDeletedSubject)
fixture.detectChanges()
fixture.destroy()
reloadSpy.mockClear()
documentDeletedSubject.next(true)
expect(reloadSpy).not.toHaveBeenCalled()
})
it('should show score sort fields on fulltext queries', () => { it('should show score sort fields on fulltext queries', () => {
documentListService.setFilterRules([ documentListService.setFilterRules([
{ {
@@ -270,12 +270,9 @@ export class DocumentListComponent
this.list.reload() this.list.reload()
}) })
this.websocketStatusService this.websocketStatusService.onDocumentDeleted().subscribe(() => {
.onDocumentDeleted() this.list.reload()
.pipe(takeUntil(this.unsubscribeNotifier)) })
.subscribe(() => {
this.list.reload()
})
this.route.paramMap this.route.paramMap
.pipe( .pipe(
@@ -17,6 +17,12 @@
} }
</select> </select>
} }
@if (advancedSearchEditorAvailable) {
<button class="btn btn-sm btn-outline-primary" type="button" (click)="openAdvancedSearchEditor()"
title="Edit query" i18n-title [disabled]="disabled">
<i-bs name="sliders"></i-bs>
</button>
}
@if (_textFilter) { @if (_textFilter) {
<button class="btn btn-link btn-sm px-2 position-absolute top-0 end-0 z-10" (click)="resetTextField()" aria-label="Clear search" i18n-aria-label> <button class="btn btn-link btn-sm px-2 position-absolute top-0 end-0 z-10" (click)="resetTextField()" aria-label="Clear search" i18n-aria-label>
<i-bs width="1em" height="1em" name="x"></i-bs> <i-bs width="1em" height="1em" name="x"></i-bs>
@@ -12,6 +12,8 @@ import {
NgbDatepickerModule, NgbDatepickerModule,
NgbDropdownItem, NgbDropdownItem,
NgbDropdownModule, NgbDropdownModule,
NgbModal,
NgbModalRef,
NgbTypeaheadModule, NgbTypeaheadModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select' import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select'
@@ -2515,4 +2517,45 @@ describe('FilterEditorComponent', () => {
expect(component.textFilter).toEqual('help ') expect(component.textFilter).toEqual('help ')
}) })
it('should open the advanced search editor with the current query and apply the result', () => {
const modalService: NgbModal = TestBed.inject(NgbModal)
let modal: NgbModalRef
modalService.activeInstances.subscribe(
(instances) => (modal = instances[0])
)
component.textFilterTarget = 'fulltext-query'
component.updateTextFilter('title:invoice')
fixture.detectChanges()
const editorButton = fixture.debugElement.query(
By.css('button[title="Edit query"]')
)
expect(editorButton).not.toBeNull()
editorButton.triggerEventHandler('click')
fixture.detectChanges()
expect(modal.componentInstance.query).toEqual('title:invoice')
const rulesSpy = jest.spyOn(component.filterRulesChange, 'next')
modal.componentInstance.queryApplied.emit('title:invoice AND NOT tag:paid')
expect(component.textFilter).toEqual('title:invoice AND NOT tag:paid')
expect(documentService.searchQuery).toEqual(
'title:invoice AND NOT tag:paid'
)
expect(rulesSpy).toHaveBeenCalledWith([
{
rule_type: FILTER_FULLTEXT_QUERY,
value: 'title:invoice AND NOT tag:paid',
},
])
})
it('should not offer the advanced search editor for other targets', () => {
component.textFilterTarget = 'title-content'
fixture.detectChanges()
expect(
fixture.debugElement.query(By.css('button[title="Edit query"]'))
).toBeNull()
})
}) })
@@ -15,12 +15,13 @@ import {
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { import {
NgbDropdownModule, NgbDropdownModule,
NgbModal,
NgbTypeahead, NgbTypeahead,
NgbTypeaheadModule, NgbTypeaheadModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap' import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
import { Observable, Subject, from } from 'rxjs' import { first, Observable, Subject, from } from 'rxjs'
import { import {
catchError, catchError,
debounceTime, debounceTime,
@@ -121,6 +122,7 @@ import {
PermissionsFilterDropdownComponent, PermissionsFilterDropdownComponent,
PermissionsSelectionModel, PermissionsSelectionModel,
} from '../../common/permissions-filter-dropdown/permissions-filter-dropdown.component' } from '../../common/permissions-filter-dropdown/permissions-filter-dropdown.component'
import { AdvancedSearchDialogComponent } from '../../common/advanced-search-dialog/advanced-search-dialog.component'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
const TEXT_FILTER_TARGET_TITLE = 'title' const TEXT_FILTER_TARGET_TITLE = 'title'
@@ -286,6 +288,7 @@ export class FilterEditorComponent
permissionsService = inject(PermissionsService) permissionsService = inject(PermissionsService)
private customFieldService = inject(CustomFieldsService) private customFieldService = inject(CustomFieldsService)
private searchService = inject(SearchService) private searchService = inject(SearchService)
private modalService = inject(NgbModal)
generateFilterName() { generateFilterName() {
if (this.filterRules.length == 1) { if (this.filterRules.length == 1) {
@@ -1372,6 +1375,23 @@ export class FilterEditorComponent
} }
} }
get advancedSearchEditorAvailable(): boolean {
return this.textFilterTarget === TEXT_FILTER_TARGET_FULLTEXT_QUERY
}
openAdvancedSearchEditor() {
const modal = this.modalService.open(AdvancedSearchDialogComponent, {
backdrop: 'static',
size: 'lg',
})
modal.componentInstance.query = this._textFilter ?? ''
modal.componentInstance.queryApplied
.pipe(first())
.subscribe((query: string) => {
this.updateTextFilter(query)
})
}
textFilterKeydown(event: KeyboardEvent) { textFilterKeydown(event: KeyboardEvent) {
if (event.key == 'Enter') { if (event.key == 'Enter') {
if (event.defaultPrevented) { if (event.defaultPrevented) {
@@ -0,0 +1,262 @@
// Fields and forms documented in docs/usage.md > "Document searches"
export enum AdvancedSearchField {
Any = '',
Title = 'title',
Content = 'content',
OriginalFilename = 'original_filename',
NoteText = 'notes.note',
NoteAuthor = 'notes.user',
CustomFieldName = 'custom_fields.name',
CustomFieldValue = 'custom_fields.value',
Correspondent = 'correspondent',
DocumentType = 'document_type',
StoragePath = 'storage_path',
Tag = 'tag',
ASN = 'asn',
PageCount = 'page_count',
NumNotes = 'num_notes',
Created = 'created',
Added = 'added',
Modified = 'modified',
Checksum = 'checksum',
}
export enum AdvancedSearchFieldKind {
Text = 'text',
Number = 'number',
Date = 'date',
Checksum = 'checksum',
}
export const ADVANCED_SEARCH_FIELD_KINDS: Record<
AdvancedSearchField,
AdvancedSearchFieldKind
> = {
[AdvancedSearchField.Any]: AdvancedSearchFieldKind.Text,
[AdvancedSearchField.Title]: AdvancedSearchFieldKind.Text,
[AdvancedSearchField.Content]: AdvancedSearchFieldKind.Text,
[AdvancedSearchField.OriginalFilename]: AdvancedSearchFieldKind.Text,
[AdvancedSearchField.NoteText]: AdvancedSearchFieldKind.Text,
[AdvancedSearchField.NoteAuthor]: AdvancedSearchFieldKind.Text,
[AdvancedSearchField.CustomFieldName]: AdvancedSearchFieldKind.Text,
[AdvancedSearchField.CustomFieldValue]: AdvancedSearchFieldKind.Text,
[AdvancedSearchField.Correspondent]: AdvancedSearchFieldKind.Text,
[AdvancedSearchField.DocumentType]: AdvancedSearchFieldKind.Text,
[AdvancedSearchField.StoragePath]: AdvancedSearchFieldKind.Text,
[AdvancedSearchField.Tag]: AdvancedSearchFieldKind.Text,
[AdvancedSearchField.ASN]: AdvancedSearchFieldKind.Number,
[AdvancedSearchField.PageCount]: AdvancedSearchFieldKind.Number,
[AdvancedSearchField.NumNotes]: AdvancedSearchFieldKind.Number,
[AdvancedSearchField.Created]: AdvancedSearchFieldKind.Date,
[AdvancedSearchField.Added]: AdvancedSearchFieldKind.Date,
[AdvancedSearchField.Modified]: AdvancedSearchFieldKind.Date,
[AdvancedSearchField.Checksum]: AdvancedSearchFieldKind.Checksum,
}
export enum AdvancedSearchOperator {
AllWords = 'all',
AnyWord = 'any',
Phrase = 'phrase',
StartsWith = 'prefix',
Equals = 'eq',
AtLeast = 'gte',
AtMost = 'lte',
Between = 'between',
DateKeyword = 'keyword',
WithinLast = 'within',
}
export const ADVANCED_SEARCH_OPERATORS_BY_KIND: Record<
AdvancedSearchFieldKind,
AdvancedSearchOperator[]
> = {
[AdvancedSearchFieldKind.Text]: [
AdvancedSearchOperator.AllWords,
AdvancedSearchOperator.AnyWord,
AdvancedSearchOperator.Phrase,
AdvancedSearchOperator.StartsWith,
],
[AdvancedSearchFieldKind.Number]: [
AdvancedSearchOperator.Equals,
AdvancedSearchOperator.AtLeast,
AdvancedSearchOperator.AtMost,
AdvancedSearchOperator.Between,
],
[AdvancedSearchFieldKind.Date]: [
AdvancedSearchOperator.DateKeyword,
AdvancedSearchOperator.WithinLast,
AdvancedSearchOperator.AtLeast,
AdvancedSearchOperator.AtMost,
AdvancedSearchOperator.Between,
],
[AdvancedSearchFieldKind.Checksum]: [AdvancedSearchOperator.StartsWith],
}
export const ADVANCED_SEARCH_DATE_KEYWORDS = [
'today',
'yesterday',
'tomorrow',
'previous week',
'this month',
'previous month',
'previous quarter',
'this year',
'previous year',
] as const
export type AdvancedSearchDateKeyword =
(typeof ADVANCED_SEARCH_DATE_KEYWORDS)[number]
export enum AdvancedSearchDateUnit {
Day = 'day',
Week = 'week',
Month = 'month',
Year = 'year',
}
export enum AdvancedSearchLogicalOperator {
And = 'AND',
Or = 'OR',
Not = 'NOT',
}
export enum AdvancedSearchQueryElementType {
Atom = 'atom',
Group = 'group',
}
export interface AdvancedSearchQueryAtom {
type: AdvancedSearchQueryElementType.Atom
field: AdvancedSearchField
operator: AdvancedSearchOperator
value?: string
valueTo?: string
unit?: AdvancedSearchDateUnit // for WithinLast
}
export interface AdvancedSearchQueryGroup {
type: AdvancedSearchQueryElementType.Group
operator: AdvancedSearchLogicalOperator
children: AdvancedSearchQueryElement[]
}
export type AdvancedSearchQueryElement =
AdvancedSearchQueryAtom | AdvancedSearchQueryGroup
export const ADVANCED_SEARCH_MAX_DEPTH = 2
export const ADVANCED_SEARCH_MAX_ATOMS = 10
export const ADVANCED_SEARCH_FIELD_LABELS: Record<AdvancedSearchField, string> =
{
[AdvancedSearchField.Any]: $localize`Any field`,
[AdvancedSearchField.Title]: $localize`Title`,
[AdvancedSearchField.Content]: $localize`Content`,
[AdvancedSearchField.OriginalFilename]: $localize`File name`,
[AdvancedSearchField.NoteText]: $localize`Note text`,
[AdvancedSearchField.NoteAuthor]: $localize`Note author`,
[AdvancedSearchField.CustomFieldName]: $localize`Custom field name`,
[AdvancedSearchField.CustomFieldValue]: $localize`Custom field value`,
[AdvancedSearchField.Correspondent]: $localize`Correspondent name`,
[AdvancedSearchField.DocumentType]: $localize`Document type name`,
[AdvancedSearchField.StoragePath]: $localize`Storage path name`,
[AdvancedSearchField.Tag]: $localize`Tag name`,
[AdvancedSearchField.ASN]: $localize`ASN`,
[AdvancedSearchField.PageCount]: $localize`Pages`,
[AdvancedSearchField.NumNotes]: $localize`Number of notes`,
[AdvancedSearchField.Created]: $localize`Created`,
[AdvancedSearchField.Added]: $localize`Added`,
[AdvancedSearchField.Modified]: $localize`Modified`,
[AdvancedSearchField.Checksum]: $localize`Checksum`,
}
export const ADVANCED_SEARCH_FIELD_GROUPS: {
label: string
fields: AdvancedSearchField[]
}[] = [
{
label: $localize`Text`,
fields: [
AdvancedSearchField.Any,
AdvancedSearchField.Title,
AdvancedSearchField.Content,
AdvancedSearchField.OriginalFilename,
AdvancedSearchField.NoteText,
AdvancedSearchField.NoteAuthor,
AdvancedSearchField.CustomFieldName,
AdvancedSearchField.CustomFieldValue,
],
},
{
label: $localize`Names`,
fields: [
AdvancedSearchField.Correspondent,
AdvancedSearchField.DocumentType,
AdvancedSearchField.StoragePath,
AdvancedSearchField.Tag,
],
},
{
label: $localize`Numbers`,
fields: [
AdvancedSearchField.ASN,
AdvancedSearchField.PageCount,
AdvancedSearchField.NumNotes,
],
},
{
label: $localize`Dates`,
fields: [
AdvancedSearchField.Created,
AdvancedSearchField.Added,
AdvancedSearchField.Modified,
],
},
{ label: $localize`Other`, fields: [AdvancedSearchField.Checksum] },
]
export const ADVANCED_SEARCH_OPERATOR_LABELS: Record<
AdvancedSearchOperator,
string
> = {
[AdvancedSearchOperator.AllWords]: $localize`contains all words`,
[AdvancedSearchOperator.AnyWord]: $localize`contains any word`,
[AdvancedSearchOperator.Phrase]: $localize`contains the phrase`,
[AdvancedSearchOperator.StartsWith]: $localize`starts with`,
[AdvancedSearchOperator.Equals]: $localize`is`,
[AdvancedSearchOperator.AtLeast]: $localize`is at least`,
[AdvancedSearchOperator.AtMost]: $localize`is at most`,
[AdvancedSearchOperator.Between]: $localize`is between`,
[AdvancedSearchOperator.DateKeyword]: $localize`is`,
[AdvancedSearchOperator.WithinLast]: $localize`is within the last`,
}
// Comparing dates reads differently than comparing counts
export const ADVANCED_SEARCH_DATE_OPERATOR_LABELS: Partial<
Record<AdvancedSearchOperator, string>
> = {
[AdvancedSearchOperator.AtLeast]: $localize`is on or after`,
[AdvancedSearchOperator.AtMost]: $localize`is on or before`,
}
export const ADVANCED_SEARCH_DATE_KEYWORD_LABELS: Record<string, string> = {
today: $localize`today`,
yesterday: $localize`yesterday`,
tomorrow: $localize`tomorrow`,
'previous week': $localize`previous week`,
'this month': $localize`this month`,
'previous month': $localize`previous month`,
'previous quarter': $localize`previous quarter`,
'this year': $localize`this year`,
'previous year': $localize`previous year`,
}
export const ADVANCED_SEARCH_DATE_UNIT_LABELS: Record<
AdvancedSearchDateUnit,
string
> = {
[AdvancedSearchDateUnit.Day]: $localize`days`,
[AdvancedSearchDateUnit.Week]: $localize`weeks`,
[AdvancedSearchDateUnit.Month]: $localize`months`,
[AdvancedSearchDateUnit.Year]: $localize`years`,
}
@@ -15,33 +15,3 @@ export interface DocumentSuggestions {
dates?: string[] // ISO-formatted date string e.g. 2022-11-03 dates?: string[] // ISO-formatted date string e.g. 2022-11-03
} }
const union = <T>(a: T[] = [], b: T[] = []): T[] => [...new Set([...a, ...b])]
export function mergeSuggestions(
a: DocumentSuggestions,
b: DocumentSuggestions
): DocumentSuggestions {
if (!a) return b
return {
title: a.title || b.title,
tags: union(a.tags, b.tags),
suggested_tags: union(a.suggested_tags, b.suggested_tags),
correspondents: union(a.correspondents, b.correspondents),
suggested_correspondents: union(
a.suggested_correspondents,
b.suggested_correspondents
),
document_types: union(a.document_types, b.document_types),
suggested_document_types: union(
a.suggested_document_types,
b.suggested_document_types
),
storage_paths: union(a.storage_paths, b.storage_paths),
suggested_storage_paths: union(
a.suggested_storage_paths,
b.suggested_storage_paths
),
dates: union(a.dates, b.dates),
}
}
-9
View File
@@ -353,14 +353,6 @@ export const PaperlessConfigOptions: ConfigOption[] = [
config_key: 'PAPERLESS_AI_LLM_EMBEDDING_MODEL', config_key: 'PAPERLESS_AI_LLM_EMBEDDING_MODEL',
category: ConfigCategory.AI, category: ConfigCategory.AI,
}, },
{
key: 'llm_embedding_api_key',
title: $localize`LLM Embedding API Key`,
type: ConfigOptionType.Password,
note: $localize`Used for embeddings when set, otherwise LLM API key is used.`,
config_key: 'PAPERLESS_AI_LLM_EMBEDDING_API_KEY',
category: ConfigCategory.AI,
},
{ {
key: 'llm_embedding_endpoint', key: 'llm_embedding_endpoint',
title: $localize`LLM Embedding Endpoint`, title: $localize`LLM Embedding Endpoint`,
@@ -465,7 +457,6 @@ export interface PaperlessConfig extends ObjectWithId {
ai_enabled: boolean ai_enabled: boolean
llm_embedding_backend: string llm_embedding_backend: string
llm_embedding_model: string llm_embedding_model: string
llm_embedding_api_key: string
llm_embedding_endpoint: string llm_embedding_endpoint: string
llm_embedding_chunk_size: number llm_embedding_chunk_size: number
llm_context_size: number llm_context_size: number
-13
View File
@@ -20,12 +20,6 @@ export enum GlobalSearchType {
TITLE_CONTENT = 'title-content', TITLE_CONTENT = 'title-content',
} }
export enum SuggestionSource {
ML = 'ml',
AI = 'ai',
Both = 'both',
}
export enum CollapsibleSection { export enum CollapsibleSection {
ATTRIBUTES = 'attributes', ATTRIBUTES = 'attributes',
} }
@@ -104,8 +98,6 @@ export const SETTINGS_KEYS = {
'general-settings:document-editing:overlay-thumbnail', 'general-settings:document-editing:overlay-thumbnail',
DOCUMENT_EDITING_AUTO_SUGGEST: DOCUMENT_EDITING_AUTO_SUGGEST:
'general-settings:document-editing:auto-suggest', 'general-settings:document-editing:auto-suggest',
DOCUMENT_EDITING_SUGGESTION_SOURCE:
'general-settings:document-editing:suggestion-source',
DOCUMENT_DETAILS_HIDDEN_FIELDS: DOCUMENT_DETAILS_HIDDEN_FIELDS:
'general-settings:document-details:hidden-fields', 'general-settings:document-details:hidden-fields',
SEARCH_DB_ONLY: 'general-settings:search:db-only', SEARCH_DB_ONLY: 'general-settings:search:db-only',
@@ -334,11 +326,6 @@ export const SETTINGS: UiSetting[] = [
type: 'boolean', type: 'boolean',
default: true, default: true,
}, },
{
key: SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE,
type: 'string',
default: SuggestionSource.AI,
},
{ {
key: SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS, key: SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS,
type: 'array', type: 'array',
@@ -0,0 +1,515 @@
import {
AdvancedSearchDateUnit,
AdvancedSearchField,
AdvancedSearchLogicalOperator,
AdvancedSearchOperator,
AdvancedSearchQueryAtom,
AdvancedSearchQueryElement,
AdvancedSearchQueryElementType,
AdvancedSearchQueryGroup,
} from '../data/advanced-search-query'
import {
parseAdvancedSearchQuery,
serializeAdvancedSearchQuery,
} from './advanced-search-query'
const atom = (
field: AdvancedSearchField,
operator: AdvancedSearchOperator,
value?: string,
extra: Partial<AdvancedSearchQueryAtom> = {}
): AdvancedSearchQueryAtom => ({
type: AdvancedSearchQueryElementType.Atom,
field,
operator,
value,
...extra,
})
const group = (
operator: AdvancedSearchLogicalOperator,
...children: AdvancedSearchQueryElement[]
): AdvancedSearchQueryGroup => ({
type: AdvancedSearchQueryElementType.Group,
operator,
children,
})
const { And, Or, Not } = AdvancedSearchLogicalOperator
describe('serializeAdvancedSearchQuery', () => {
describe('text fields', () => {
it.each([
[AdvancedSearchOperator.AllWords, 'invoice', 'title:invoice'],
[
AdvancedSearchOperator.AllWords,
' invoice unpaid ',
'title:invoice AND title:unpaid',
],
[
AdvancedSearchOperator.AnyWord,
'invoice unpaid',
'title:invoice OR title:unpaid',
],
[
AdvancedSearchOperator.Phrase,
'quick brown fox',
'title:"quick brown fox"',
],
[AdvancedSearchOperator.Phrase, 'say "hi"', 'title:"say hi"'],
[AdvancedSearchOperator.StartsWith, 'invoi', 'title:invoi*'],
[AdvancedSearchOperator.StartsWith, 'in*v?oi', 'title:invoi*'],
])('%s %j writes %s', (operator, value, expected) => {
expect(
serializeAdvancedSearchQuery(
atom(AdvancedSearchField.Title, operator, value)
)
).toBe(expected)
})
it('writes bare words for the Any field', () => {
expect(
serializeAdvancedSearchQuery(
atom(AdvancedSearchField.Any, AdvancedSearchOperator.AllWords, 'a b')
)
).toBe('a AND b')
})
it.each([
['A-1312/99', 'custom_fields.value:A-1312/99'],
["O'Brien", "custom_fields.value:O'Brien"],
["'quoted'", `custom_fields.value:"'quoted'"`],
['foo:bar', 'custom_fields.value:"foo:bar"'],
['(x)', 'custom_fields.value:"(x)"'],
['2024*', 'custom_fields.value:"2024*"'],
['a,b', 'custom_fields.value:"a,b"'],
['OR', 'custom_fields.value:"OR"'],
['or', 'custom_fields.value:or'],
])(
'quotes %j only when the grammar would read it as syntax',
(value, expected) => {
expect(
serializeAdvancedSearchQuery(
atom(
AdvancedSearchField.CustomFieldValue,
AdvancedSearchOperator.AllWords,
value
)
)
).toBe(expected)
}
)
it('uses the dotted names for custom fields', () => {
expect(
serializeAdvancedSearchQuery(
group(
And,
atom(
AdvancedSearchField.CustomFieldName,
AdvancedSearchOperator.Phrase,
'status'
),
atom(
AdvancedSearchField.CustomFieldValue,
AdvancedSearchOperator.AllWords,
'paid'
)
)
)
).toBe('custom_fields.name:"status" AND custom_fields.value:paid')
})
it('uses the dotted names for notes', () => {
expect(
serializeAdvancedSearchQuery(
group(
And,
atom(
AdvancedSearchField.NoteText,
AdvancedSearchOperator.AllWords,
'call'
),
atom(
AdvancedSearchField.NoteAuthor,
AdvancedSearchOperator.AllWords,
'alice'
)
)
)
).toBe('notes.note:call AND notes.user:alice')
})
it.each([
[AdvancedSearchOperator.AllWords, ''],
[AdvancedSearchOperator.AllWords, ' '],
[AdvancedSearchOperator.AllWords, '!! --'],
[AdvancedSearchOperator.Phrase, '""'],
[AdvancedSearchOperator.StartsWith, 'two words'],
[AdvancedSearchOperator.StartsWith, '***'],
[AdvancedSearchOperator.StartsWith, undefined],
])('leaves out %s %j', (operator, value) => {
expect(
serializeAdvancedSearchQuery(
atom(AdvancedSearchField.Title, operator, value)
)
).toBe('')
})
})
describe('checksum', () => {
it('lowercases the prefix', () => {
expect(
serializeAdvancedSearchQuery(
atom(
AdvancedSearchField.Checksum,
AdvancedSearchOperator.StartsWith,
'9F86D081'
)
)
).toBe('checksum:9f86d081*')
})
})
describe('number fields', () => {
it.each([
[AdvancedSearchOperator.Equals, '42', undefined, 'asn:42'],
[AdvancedSearchOperator.AtLeast, '50', undefined, 'asn:[50 to]'],
[AdvancedSearchOperator.AtMost, '50', undefined, 'asn:[to 50]'],
[AdvancedSearchOperator.Between, '50', '150', 'asn:[50 to 150]'],
[AdvancedSearchOperator.Equals, '4.2', undefined, ''],
[AdvancedSearchOperator.Equals, '-1', undefined, ''],
[AdvancedSearchOperator.Equals, '2024-01-01', undefined, ''],
[AdvancedSearchOperator.Between, '50', '', ''],
])('%s %j %j writes %j', (operator, value, valueTo, expected) => {
expect(
serializeAdvancedSearchQuery(
atom(AdvancedSearchField.ASN, operator, value, { valueTo })
)
).toBe(expected)
})
})
describe('date fields', () => {
it.each([
['today', 'added:today'],
['previous month', 'added:"previous month"'],
['last tuesday', ''],
['', ''],
])('keyword %j writes %j', (value, expected) => {
expect(
serializeAdvancedSearchQuery(
atom(
AdvancedSearchField.Added,
AdvancedSearchOperator.DateKeyword,
value
)
)
).toBe(expected)
})
it.each([
['1', AdvancedSearchDateUnit.Day, 'added:[-1 day to now]'],
['3', AdvancedSearchDateUnit.Month, 'added:[-3 months to now]'],
['2', AdvancedSearchDateUnit.Week, 'added:[-2 weeks to now]'],
['0', AdvancedSearchDateUnit.Year, ''],
['1.5', AdvancedSearchDateUnit.Year, ''],
['3', undefined, ''],
['3', 'fortnight' as AdvancedSearchDateUnit, ''],
])('within the last %j %j writes %j', (value, unit, expected) => {
expect(
serializeAdvancedSearchQuery(
atom(
AdvancedSearchField.Added,
AdvancedSearchOperator.WithinLast,
value,
{
unit,
}
)
)
).toBe(expected)
})
it.each([
[
AdvancedSearchOperator.AtLeast,
'2024-01-01',
undefined,
'created:[2024-01-01 to]',
],
[
AdvancedSearchOperator.AtMost,
'2024-01-01',
undefined,
'created:[to 2024-01-01]',
],
[
AdvancedSearchOperator.Between,
'2024-01-01',
'2024-03-31',
'created:[2024-01-01 to 2024-03-31]',
],
[AdvancedSearchOperator.AtLeast, '2024', undefined, ''],
[AdvancedSearchOperator.Between, '2024-01-01', 'now', ''],
])('%s %j %j writes %j', (operator, value, valueTo, expected) => {
expect(
serializeAdvancedSearchQuery(
atom(AdvancedSearchField.Created, operator, value, { valueTo })
)
).toBe(expected)
})
})
describe('groups', () => {
const invoice = atom(
AdvancedSearchField.Content,
AdvancedSearchOperator.AllWords,
'invoice'
)
const letter = atom(
AdvancedSearchField.Title,
AdvancedSearchOperator.AllWords,
'letter'
)
const paid = atom(
AdvancedSearchField.Tag,
AdvancedSearchOperator.AllWords,
'paid'
)
const twoWords = atom(
AdvancedSearchField.Title,
AdvancedSearchOperator.AllWords,
'a b'
)
const anyWords = atom(
AdvancedSearchField.Title,
AdvancedSearchOperator.AnyWord,
'a b'
)
const empty = atom(
AdvancedSearchField.Title,
AdvancedSearchOperator.AllWords,
''
)
it.each([
['an empty group', group(And), ''],
['a group of empty atoms', group(Or, empty, group(And, empty)), ''],
[
'a single child without parentheses',
group(Or, invoice),
'content:invoice',
],
['All', group(And, invoice, letter), 'content:invoice AND title:letter'],
['Any', group(Or, invoice, letter), 'content:invoice OR title:letter'],
[
'skipped empty atoms',
group(And, empty, invoice, empty),
'content:invoice',
],
['Not with one child', group(Not, paid), 'NOT tag:paid'],
[
'Not as none of',
group(Not, paid, letter),
'NOT (tag:paid OR title:letter)',
],
[
'Not with a compound child',
group(Not, twoWords),
'NOT (title:a AND title:b)',
],
[
'Not inside All',
group(And, invoice, group(Not, paid)),
'content:invoice AND NOT tag:paid',
],
[
'Not inside Any',
group(Or, invoice, group(Not, paid)),
'content:invoice OR NOT tag:paid',
],
[
'Any inside All',
group(And, invoice, group(Or, letter, paid)),
'content:invoice AND (title:letter OR tag:paid)',
],
[
'All inside Any',
group(Or, invoice, group(And, letter, paid)),
'content:invoice OR (title:letter AND tag:paid)',
],
[
'All inside All flattened',
group(And, invoice, group(And, letter, paid)),
'content:invoice AND title:letter AND tag:paid',
],
[
'an all-words atom inside Any',
group(Or, invoice, twoWords),
'content:invoice OR (title:a AND title:b)',
],
[
'an any-word atom inside All',
group(And, invoice, anyWords),
'content:invoice AND (title:a OR title:b)',
],
[
'an all-words atom inside Not with siblings',
group(Not, paid, twoWords),
'NOT (tag:paid OR (title:a AND title:b))',
],
])('writes %s', (_, tree, expected) => {
expect(serializeAdvancedSearchQuery(tree)).toBe(expected)
})
it('writes the mockup example', () => {
expect(
serializeAdvancedSearchQuery(
group(
And,
invoice,
group(
Or,
atom(
AdvancedSearchField.Correspondent,
AdvancedSearchOperator.Phrase,
'acme corp'
),
atom(
AdvancedSearchField.Content,
AdvancedSearchOperator.Phrase,
'acme corporation'
)
),
atom(
AdvancedSearchField.Added,
AdvancedSearchOperator.WithinLast,
'3',
{
unit: AdvancedSearchDateUnit.Month,
}
),
group(Not, paid)
)
)
).toBe(
'content:invoice AND (correspondent:"acme corp" OR content:"acme corporation") AND added:[-3 months to now] AND NOT tag:paid'
)
})
})
})
describe('parseAdvancedSearchQuery', () => {
const canonical = [
'title:invoice',
'title:invoice AND title:unpaid',
'content:invoice OR content:receipt',
'a AND b',
'title:"quick brown fox"',
'title:invoi*',
"custom_fields.value:O'Brien",
'custom_fields.value:"foo:bar"',
'custom_fields.name:"status" AND custom_fields.value:paid',
'notes.note:call AND notes.user:alice',
'checksum:9f86d081*',
'asn:42',
'asn:[50 to 150]',
'asn:[50 to]',
'asn:[to 50]',
'page_count:[10 to]',
'added:today',
'added:"previous month"',
'added:[-1 day to now]',
'added:[-3 months to now]',
'created:[2024-01-01 to 2024-03-31]',
'created:[2024-01-01 to]',
'created:[to 2024-01-01]',
'NOT tag:paid',
'NOT (tag:paid OR title:letter)',
'NOT (title:a AND title:b)',
'content:invoice OR NOT tag:paid',
'content:invoice AND (title:letter OR tag:paid)',
'content:invoice OR (title:letter AND tag:paid)',
'content:invoice AND (correspondent:"acme corp" OR content:"acme corporation") AND added:[-3 months to now] AND NOT tag:paid',
]
it.each(canonical)('reads back %s unchanged', (query) => {
const tree = parseAdvancedSearchQuery(query)
expect(tree).not.toBeNull()
expect(serializeAdvancedSearchQuery(tree)).toBe(query)
})
it('reads surrounding whitespace', () => {
expect(
serializeAdvancedSearchQuery(
parseAdvancedSearchQuery(' title:invoice ')
)
).toBe('title:invoice')
})
it('puts the words of one condition back together', () => {
expect(parseAdvancedSearchQuery('title:invoice AND title:unpaid')).toEqual(
group(
And,
atom(
AdvancedSearchField.Title,
AdvancedSearchOperator.AllWords,
'invoice unpaid'
)
)
)
})
it('keeps words of different fields apart', () => {
expect(parseAdvancedSearchQuery('title:a OR content:b')).toEqual(
group(
Or,
atom(AdvancedSearchField.Title, AdvancedSearchOperator.AllWords, 'a'),
atom(AdvancedSearchField.Content, AdvancedSearchOperator.AllWords, 'b')
)
)
})
it('reads a range as its condition', () => {
expect(parseAdvancedSearchQuery('added:[-3 months to now]')).toEqual(
group(
And,
atom(
AdvancedSearchField.Added,
AdvancedSearchOperator.WithinLast,
'3',
{
unit: AdvancedSearchDateUnit.Month,
}
)
)
)
})
it.each([
['', 'nothing'],
[' ', 'whitespace'],
['type:invoice', 'a field alias'],
['notes:call', 'a bare notes prefix'],
['unknown:x', 'an unknown field'],
['title:a AND content:b OR title:c', 'AND and OR mixed at one level'],
['title:a b', 'an implicit AND'],
['title:"unterminated', 'an unterminated phrase'],
['asn:[50 to', 'an unterminated range'],
['title:a*b', 'a wildcard in the middle'],
['title:invoice^2', 'a boost'],
['asn:[50 to abc]', 'a non-numeric bound'],
['asn:invoice', 'a word on a number field'],
['created:[2024 to 2025]', 'a year-only date range'],
['added:"last tuesday"', 'a date keyword paperless does not have'],
['title:[a to b]', 'a range on a text field'],
['checksum:9f86d081', 'a checksum without a wildcard'],
['(title:invoice)', 'parentheses the editor would not write'],
['title:invoice AND', 'a trailing operator'],
['title:"a"b', 'a value running into the next'],
['ADDED:today', 'an uppercase field name'],
])('leaves %s alone, having %s', (query) => {
expect(parseAdvancedSearchQuery(query)).toBeNull()
})
})
@@ -0,0 +1,500 @@
import {
ADVANCED_SEARCH_DATE_KEYWORDS,
ADVANCED_SEARCH_FIELD_KINDS,
AdvancedSearchDateUnit,
AdvancedSearchField,
AdvancedSearchFieldKind,
AdvancedSearchLogicalOperator,
AdvancedSearchOperator,
AdvancedSearchQueryAtom,
AdvancedSearchQueryElement,
AdvancedSearchQueryElementType,
AdvancedSearchQueryGroup,
} from '../data/advanced-search-query'
// Anything the query grammar would read as syntax rather than as a word
const SYNTAX_CHARS = /[\s():"[\]*?,{}^~\\]/
const SYNTAX_CHARS_GLOBAL = new RegExp(SYNTAX_CHARS, 'g')
// A word wrapped in single quotes is also syntax, an apostrophe inside one is not
const EDGE_SINGLE_QUOTE = /^'|'$/
const RESERVED_WORDS = /^(AND|OR|NOT|TO)$/
const HAS_WORD_CHAR = /[\p{L}\p{N}]/u
const WHOLE_NUMBER = /^\d+$/
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
interface Serialized {
text: string
// The operator joining the top level of `text`, null when it is self-delimiting
join:
AdvancedSearchLogicalOperator.And | AdvancedSearchLogicalOperator.Or | null
}
const prefix = (field: AdvancedSearchField) => (field ? `${field}:` : '')
const quote = (text: string) => `"${text.replace(/"/g, '')}"`
const words = (value: string) =>
value
.trim()
.split(/\s+/)
.filter((word) => HAS_WORD_CHAR.test(word))
const word = (w: string) =>
SYNTAX_CHARS.test(w) || EDGE_SINGLE_QUOTE.test(w) || RESERVED_WORDS.test(w)
? quote(w)
: w
const atomic = (text: string): Serialized => ({ text, join: null })
function serializeWords(
atom: AdvancedSearchQueryAtom,
join: AdvancedSearchLogicalOperator.And | AdvancedSearchLogicalOperator.Or
): Serialized {
// A field applies only to the word right after it, so repeat it per word
const terms = words(atom.value ?? '').map(
(w) => `${prefix(atom.field)}${word(w)}`
)
if (terms.length === 0) return null
if (terms.length === 1) return atomic(terms[0])
return { text: terms.join(` ${join} `), join }
}
function serializeRange(
atom: AdvancedSearchQueryAtom,
isValid: (v: string) => boolean
): Serialized {
const lo = atom.value?.trim() ?? ''
const hi = atom.valueTo?.trim() ?? ''
const field = prefix(atom.field)
switch (atom.operator) {
case AdvancedSearchOperator.Equals:
return isValid(lo) ? atomic(`${field}${lo}`) : null
case AdvancedSearchOperator.AtLeast:
return isValid(lo) ? atomic(`${field}[${lo} to]`) : null
case AdvancedSearchOperator.AtMost:
return isValid(lo) ? atomic(`${field}[to ${lo}]`) : null
case AdvancedSearchOperator.Between:
return isValid(lo) && isValid(hi)
? atomic(`${field}[${lo} to ${hi}]`)
: null
}
return null
}
function serializeAtom(atom: AdvancedSearchQueryAtom): Serialized {
const field = prefix(atom.field)
const value = atom.value?.trim() ?? ''
switch (atom.operator) {
case AdvancedSearchOperator.AllWords:
return serializeWords(atom, AdvancedSearchLogicalOperator.And)
case AdvancedSearchOperator.AnyWord:
return serializeWords(atom, AdvancedSearchLogicalOperator.Or)
case AdvancedSearchOperator.Phrase:
return HAS_WORD_CHAR.test(value)
? atomic(`${field}${quote(value)}`)
: null
case AdvancedSearchOperator.StartsWith: {
if (/\s/.test(value)) return null
let stem = value.replace(SYNTAX_CHARS_GLOBAL, '')
if (!HAS_WORD_CHAR.test(stem)) return null
// checksum is indexed as-is, in lowercase
if (atom.field === AdvancedSearchField.Checksum) {
stem = stem.toLowerCase()
}
return atomic(`${field}${stem}*`)
}
case AdvancedSearchOperator.DateKeyword:
return (ADVANCED_SEARCH_DATE_KEYWORDS as readonly string[]).includes(
value
)
? atomic(`${field}${word(value)}`)
: null
case AdvancedSearchOperator.WithinLast: {
const amount = parseInt(value, 10)
const units = Object.values(AdvancedSearchDateUnit) as string[]
if (!WHOLE_NUMBER.test(value) || amount < 1) return null
if (!units.includes(atom.unit)) return null
const unit = amount === 1 ? atom.unit : `${atom.unit}s`
return atomic(`${field}[-${amount} ${unit} to now]`)
}
case AdvancedSearchOperator.Equals:
case AdvancedSearchOperator.AtLeast:
case AdvancedSearchOperator.AtMost:
case AdvancedSearchOperator.Between:
return serializeRange(
atom,
ADVANCED_SEARCH_FIELD_KINDS[atom.field] === AdvancedSearchFieldKind.Date
? (v) => ISO_DATE.test(v)
: (v) => WHOLE_NUMBER.test(v)
)
}
return null
}
function wrap(
child: Serialized,
parentJoin: AdvancedSearchLogicalOperator
): string {
return child.join === null || child.join === parentJoin
? child.text
: `(${child.text})`
}
function serializeGroup(group: AdvancedSearchQueryGroup): Serialized {
const children = group.children.map(serializeElement).filter(Boolean)
if (children.length === 0) return null
if (group.operator === AdvancedSearchLogicalOperator.Not) {
// A Not group matches documents matching none of its children
if (children.length === 1 && children[0].join === null) {
return atomic(`NOT ${children[0].text}`)
}
const inner =
children.length === 1
? children[0].text
: children
.map((c) => wrap(c, AdvancedSearchLogicalOperator.Or))
.join(' OR ')
return atomic(`NOT (${inner})`)
}
if (children.length === 1) return children[0]
return {
text: children
.map((c) => wrap(c, group.operator))
.join(` ${group.operator} `),
join: group.operator,
}
}
function serializeElement(element: AdvancedSearchQueryElement): Serialized {
return element.type === AdvancedSearchQueryElementType.Group
? serializeGroup(element)
: serializeAtom(element)
}
/**
* Writes an editor tree as a full-text query. Atoms that are not filled in
* (or not valid) are left out, and empty groups with them.
*/
export function serializeAdvancedSearchQuery(
element: AdvancedSearchQueryElement
): string {
return serializeElement(element)?.text ?? ''
}
// --- Reading a query back into the editor --------------------------------
//
// Deliberately narrow: this reads the forms serializeAdvancedSearchQuery
// writes, and nothing else. A query it cannot read is not a failure, it just
// stays text, so there is never a lossy or surprising conversion. The final
// round-trip check below is what holds that promise: a tree is only returned
// when writing it out again reproduces the query exactly.
const RELATIVE_BOUND = /^-(\d+) (day|week|month|year)s?$/
const FIELD_PREFIX = /^([a-z_]+(?:\.[a-z_]+)?):/
const KEYWORD_TOKEN = /^(AND|OR|NOT)(?=[\s(]|$)/
// Either bound may be missing: [50 to 150], [50 to], [to 50]
const RANGE_BOUNDS = /^(?:(.+?) )?to(?: (.+))?$/
const TRAILING_WILDCARD = /^([^*?]+)\*$/
class UnreadableQuery extends Error {}
interface Token {
type: 'term' | 'AND' | 'OR' | 'NOT' | '(' | ')'
field?: string
value?: string
quoted?: boolean
range?: boolean
}
// A parsed element, plus what it takes to merge the per-word terms the
// serializer writes for "contains all words" back into a single condition
interface Parsed {
element: AdvancedSearchQueryElement
word?: { field: AdvancedSearchField; text: string }
}
function tokenize(query: string): Token[] {
const tokens: Token[] = []
let i = 0
while (i < query.length) {
const rest = query.slice(i)
if (/^\s/.test(rest)) {
i++
continue
}
if (rest[0] === '(' || rest[0] === ')') {
tokens.push({ type: rest[0] as '(' | ')' })
i++
continue
}
const keyword = KEYWORD_TOKEN.exec(rest)
if (keyword) {
tokens.push({ type: keyword[1] as 'AND' | 'OR' | 'NOT' })
i += keyword[1].length
continue
}
const fieldMatch = FIELD_PREFIX.exec(rest)
const field = fieldMatch ? fieldMatch[1] : ''
i += fieldMatch ? fieldMatch[0].length : 0
const value = query.slice(i)
if (value.startsWith('"')) {
const end = query.indexOf('"', i + 1)
if (end < 0) throw new UnreadableQuery()
tokens.push({
type: 'term',
field,
value: query.slice(i + 1, end),
quoted: true,
})
i = end + 1
} else if (value.startsWith('[')) {
const end = query.indexOf(']', i + 1)
if (end < 0) throw new UnreadableQuery()
tokens.push({
type: 'term',
field,
value: query.slice(i + 1, end),
range: true,
})
i = end + 1
} else {
const bare = /^[^\s()]+/.exec(value)
if (!bare) throw new UnreadableQuery()
tokens.push({ type: 'term', field, value: bare[0] })
i += bare[0].length
}
// Nothing may run on directly after a value, e.g. title:"a"b
if (i < query.length && !/[\s)]/.test(query[i])) throw new UnreadableQuery()
}
return tokens
}
function resolveField(name: string): AdvancedSearchField {
const fields = Object.values(AdvancedSearchField) as string[]
// Aliases (type:, path:, notes:) are left to the text box on purpose:
// reading one would mean rewriting the user's query as it was read
if (!fields.includes(name)) throw new UnreadableQuery()
return name as AdvancedSearchField
}
function atomFrom(
field: AdvancedSearchField,
operator: AdvancedSearchOperator,
value: string,
extra: Partial<AdvancedSearchQueryAtom> = {}
): AdvancedSearchQueryAtom {
return {
type: AdvancedSearchQueryElementType.Atom,
field,
operator,
value,
...extra,
}
}
function parseRange(
field: AdvancedSearchField,
kind: AdvancedSearchFieldKind,
body: string
): AdvancedSearchQueryAtom {
const bounds = RANGE_BOUNDS.exec(body)
if (!bounds) throw new UnreadableQuery()
const lo = bounds[1] ?? ''
const hi = bounds[2] ?? ''
if (kind === AdvancedSearchFieldKind.Date) {
const relative = RELATIVE_BOUND.exec(lo)
if (relative && hi === 'now') {
return atomFrom(field, AdvancedSearchOperator.WithinLast, relative[1], {
unit: relative[2] as AdvancedSearchDateUnit,
})
}
} else if (kind !== AdvancedSearchFieldKind.Number) {
throw new UnreadableQuery()
}
const isValid =
kind === AdvancedSearchFieldKind.Date
? (v: string) => ISO_DATE.test(v)
: (v: string) => WHOLE_NUMBER.test(v)
if (lo && hi) {
if (!isValid(lo) || !isValid(hi)) throw new UnreadableQuery()
return atomFrom(field, AdvancedSearchOperator.Between, lo, { valueTo: hi })
}
if (lo && isValid(lo))
return atomFrom(field, AdvancedSearchOperator.AtLeast, lo)
if (hi && isValid(hi))
return atomFrom(field, AdvancedSearchOperator.AtMost, hi)
throw new UnreadableQuery()
}
function parseTerm(token: Token): Parsed {
const field = resolveField(token.field)
const kind = ADVANCED_SEARCH_FIELD_KINDS[field]
const value = token.value
const isKeyword = (
ADVANCED_SEARCH_DATE_KEYWORDS as readonly string[]
).includes(value)
if (token.range) {
return { element: parseRange(field, kind, value) }
}
if (kind === AdvancedSearchFieldKind.Date) {
if (!isKeyword) throw new UnreadableQuery()
return {
element: atomFrom(field, AdvancedSearchOperator.DateKeyword, value),
}
}
if (token.quoted) {
if (kind !== AdvancedSearchFieldKind.Text) throw new UnreadableQuery()
return { element: atomFrom(field, AdvancedSearchOperator.Phrase, value) }
}
const wildcard = TRAILING_WILDCARD.exec(value)
if (wildcard) {
if (kind === AdvancedSearchFieldKind.Number) throw new UnreadableQuery()
return {
element: atomFrom(field, AdvancedSearchOperator.StartsWith, wildcard[1]),
}
}
if (kind === AdvancedSearchFieldKind.Number) {
if (!WHOLE_NUMBER.test(value)) throw new UnreadableQuery()
return { element: atomFrom(field, AdvancedSearchOperator.Equals, value) }
}
// A checksum is only ever searched by its first characters
if (kind === AdvancedSearchFieldKind.Checksum) throw new UnreadableQuery()
return {
element: atomFrom(field, AdvancedSearchOperator.AllWords, value),
word: { field, text: value },
}
}
// The serializer repeats the field for every word, because a field applies
// only to the word after it. Put those back together into one condition.
function mergeWords(
parts: Parsed[],
operator: AdvancedSearchLogicalOperator.And | AdvancedSearchLogicalOperator.Or
): AdvancedSearchQueryElement[] {
const merged: AdvancedSearchQueryElement[] = []
for (let i = 0; i < parts.length; i++) {
const run = [parts[i]]
while (
parts[i].word &&
parts[i + 1]?.word &&
parts[i + 1].word.field === parts[i].word.field
) {
run.push(parts[++i])
}
if (run.length === 1) {
merged.push(run[0].element)
continue
}
merged.push(
atomFrom(
run[0].word.field,
operator === AdvancedSearchLogicalOperator.And
? AdvancedSearchOperator.AllWords
: AdvancedSearchOperator.AnyWord,
run.map((part) => part.word.text).join(' ')
)
)
}
return merged
}
interface Cursor {
tokens: Token[]
at: number
}
function parseExpression(cursor: Cursor): Parsed {
const parts: Parsed[] = [parseOperand(cursor)]
let operator:
AdvancedSearchLogicalOperator.And | AdvancedSearchLogicalOperator.Or
while (
cursor.tokens[cursor.at]?.type === 'AND' ||
cursor.tokens[cursor.at]?.type === 'OR'
) {
const next = cursor.tokens[cursor.at++].type as
AdvancedSearchLogicalOperator.And | AdvancedSearchLogicalOperator.Or
// One level mixing AND and OR is never something the editor wrote
if (operator && next !== operator) throw new UnreadableQuery()
operator = next
parts.push(parseOperand(cursor))
}
if (parts.length === 1) return parts[0]
const children = mergeWords(parts, operator)
if (children.length === 1) return { element: children[0] }
return {
element: {
type: AdvancedSearchQueryElementType.Group,
operator,
children,
},
}
}
function parseOperand(cursor: Cursor): Parsed {
const token = cursor.tokens[cursor.at++]
if (!token) throw new UnreadableQuery()
if (token.type === 'NOT') {
const child = parseOperand(cursor)
return {
element: {
type: AdvancedSearchQueryElementType.Group,
operator: AdvancedSearchLogicalOperator.Not,
children: [child.element],
},
}
}
if (token.type === '(') {
const inner = parseExpression(cursor)
if (cursor.tokens[cursor.at++]?.type !== ')') throw new UnreadableQuery()
return { element: inner.element }
}
if (token.type !== 'term') throw new UnreadableQuery()
return parseTerm(token)
}
/**
* Reads a query the editor could have written back into an editor tree, or
* returns null when the editor cannot show it, in which case the query stays
* text. Never returns a tree that would be written back differently.
*/
export function parseAdvancedSearchQuery(
query: string
): AdvancedSearchQueryGroup | null {
const trimmed = query?.trim() ?? ''
if (!trimmed) return null
let parsed: Parsed
try {
const cursor: Cursor = { tokens: tokenize(trimmed), at: 0 }
parsed = parseExpression(cursor)
if (cursor.at !== cursor.tokens.length) throw new UnreadableQuery()
} catch {
return null
}
const root =
parsed.element.type === AdvancedSearchQueryElementType.Group
? parsed.element
: {
type: AdvancedSearchQueryElementType.Group as const,
operator: AdvancedSearchLogicalOperator.And,
children: [parsed.element],
}
return serializeAdvancedSearchQuery(root) === trimmed ? root : null
}
+1 -1
View File
@@ -8,7 +8,7 @@ export const environment = {
apiVersion: '10', // match src/paperless/settings.py apiVersion: '10', // match src/paperless/settings.py
appTitle: DEFAULT_APP_TITLE, appTitle: DEFAULT_APP_TITLE,
tag: 'prod', tag: 'prod',
version: '3.2.1', version: '3.2.0',
webSocketHost: window.location.host, webSocketHost: window.location.host,
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:', webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
webSocketBaseUrl: base_url.pathname + 'ws/', webSocketBaseUrl: base_url.pathname + 'ws/',
+5 -5
View File
@@ -2855,7 +2855,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">104</context> <context context-type="linenumber">104</context>
</context-group> </context-group>
<target state="translated">KI-Vorschläge anwenden</target> <target state="needs-translation">Apply AI Suggestions</target>
</trans-unit> </trans-unit>
<trans-unit id="9172233176401579786" datatype="html" approved="yes"> <trans-unit id="9172233176401579786" datatype="html" approved="yes">
<source>Scheduled</source> <source>Scheduled</source>
@@ -3907,7 +3907,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
<context context-type="linenumber">2,3</context> <context context-type="linenumber">2,3</context>
</context-group> </context-group>
<target state="translated">Share Links</target> <target state="needs-translation">Share links</target>
</trans-unit> </trans-unit>
<trans-unit id="7844706011418789951" datatype="html" approved="yes"> <trans-unit id="7844706011418789951" datatype="html" approved="yes">
<source>Administration</source> <source>Administration</source>
@@ -5835,7 +5835,7 @@
<context context-type="sourcefile">src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts</context>
<context context-type="linenumber">182</context> <context context-type="linenumber">182</context>
</context-group> </context-group>
<target state="translated">Fehler beim Abrufen der Korrespondenten</target> <target state="needs-translation">Error retrieving correspondents</target>
</trans-unit> </trans-unit>
<trans-unit id="3407606224361082860" datatype="html"> <trans-unit id="3407606224361082860" datatype="html">
<source>Error retrieving document types</source> <source>Error retrieving document types</source>
@@ -9623,7 +9623,7 @@
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1051</context> <context context-type="linenumber">1051</context>
</context-group> </context-group>
<target state="translated">Sie haben ungespeicherte Änderungen am Inhalt dieser Version.</target> <target state="needs-translation">You have unsaved changes to the content of this version.</target>
</trans-unit> </trans-unit>
<trans-unit id="85184271222513014" datatype="html"> <trans-unit id="85184271222513014" datatype="html">
<source>Switching versions will discard them.</source> <source>Switching versions will discard them.</source>
@@ -12574,7 +12574,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
<context context-type="linenumber">4,5</context> <context context-type="linenumber">4,5</context>
</context-group> </context-group>
<target state="translated">Verwalten Sie öffentliche Links zu einzelnen Dokumenten und Dokumentenbündeln.</target> <target state="needs-translation">Manage public links to individual documents and document bundles.</target>
</trans-unit> </trans-unit>
<trans-unit id="3740311348735113099" datatype="html"> <trans-unit id="3740311348735113099" datatype="html">
<source>Document links</source> <source>Document links</source>
+14 -14
View File
@@ -3907,7 +3907,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
<context context-type="linenumber">2,3</context> <context context-type="linenumber">2,3</context>
</context-group> </context-group>
<target state="translated">Condividi link</target> <target state="needs-translation">Share links</target>
</trans-unit> </trans-unit>
<trans-unit id="7844706011418789951" datatype="html"> <trans-unit id="7844706011418789951" datatype="html">
<source>Administration</source> <source>Administration</source>
@@ -12411,7 +12411,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
<context context-type="linenumber">40,41</context> <context context-type="linenumber">40,41</context>
</context-group> </context-group>
<target state="translated">Scaduto</target> <target state="needs-translation">Expired</target>
</trans-unit> </trans-unit>
<trans-unit id="2057574872309338088" datatype="html"> <trans-unit id="2057574872309338088" datatype="html">
<source>Copy share link</source> <source>Copy share link</source>
@@ -12455,7 +12455,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-bundle-list/share-link-bundle-list.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-bundle-list/share-link-bundle-list.component.html</context>
<context context-type="linenumber">164,165</context> <context context-type="linenumber">164,165</context>
</context-group> </context-group>
<target state="translated">Paginazione dei pacchetti di link di condivisione</target> <target state="needs-translation">Share link bundles pagination</target>
</trans-unit> </trans-unit>
<trans-unit id="1541995860059283227" datatype="html"> <trans-unit id="1541995860059283227" datatype="html">
<source>Failed to load share link bundles.</source> <source>Failed to load share link bundles.</source>
@@ -12511,7 +12511,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
<context context-type="linenumber">7,8</context> <context context-type="linenumber">7,8</context>
</context-group> </context-group>
<target state="translated"> Attualmente non sono presenti link per la condivisione dei documenti. </target> <target state="needs-translation"> No document share links currently exist. </target>
</trans-unit> </trans-unit>
<trans-unit id="894965666917873756" datatype="html"> <trans-unit id="894965666917873756" datatype="html">
<source>Document</source> <source>Document</source>
@@ -12519,7 +12519,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
<context context-type="linenumber">15,16</context> <context context-type="linenumber">15,16</context>
</context-group> </context-group>
<target state="translated">Documento</target> <target state="needs-translation">Document</target>
</trans-unit> </trans-unit>
<trans-unit id="7420713064779772535" datatype="html"> <trans-unit id="7420713064779772535" datatype="html">
<source>Delete share link</source> <source>Delete share link</source>
@@ -12527,7 +12527,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
<context context-type="linenumber">75,76</context> <context context-type="linenumber">75,76</context>
</context-group> </context-group>
<target state="translated">Elimina link di condivisione</target> <target state="needs-translation">Delete share link</target>
</trans-unit> </trans-unit>
<trans-unit id="5198271726831144068" datatype="html"> <trans-unit id="5198271726831144068" datatype="html">
<source>Share links pagination</source> <source>Share links pagination</source>
@@ -12535,7 +12535,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
<context context-type="linenumber">104,105</context> <context context-type="linenumber">104,105</context>
</context-group> </context-group>
<target state="translated">Paginazione link condivisi</target> <target state="needs-translation">Share links pagination</target>
</trans-unit> </trans-unit>
<trans-unit id="2976350639944567244" datatype="html"> <trans-unit id="2976350639944567244" datatype="html">
<source>Failed to load share links.</source> <source>Failed to load share links.</source>
@@ -12543,7 +12543,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
<context context-type="linenumber">105</context> <context context-type="linenumber">105</context>
</context-group> </context-group>
<target state="translated">Impossibile caricare i link di condivisione.</target> <target state="needs-translation">Failed to load share links.</target>
</trans-unit> </trans-unit>
<trans-unit id="4100863040928608880" datatype="html"> <trans-unit id="4100863040928608880" datatype="html">
<source>Error retrieving share links.</source> <source>Error retrieving share links.</source>
@@ -12551,7 +12551,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
<context context-type="linenumber">107</context> <context context-type="linenumber">107</context>
</context-group> </context-group>
<target state="translated">Errore nel recuperare i link di condivisione.</target> <target state="needs-translation">Error retrieving share links.</target>
</trans-unit> </trans-unit>
<trans-unit id="326120283142363299" datatype="html"> <trans-unit id="326120283142363299" datatype="html">
<source>Share link deleted.</source> <source>Share link deleted.</source>
@@ -12559,7 +12559,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
<context context-type="linenumber">154</context> <context context-type="linenumber">154</context>
</context-group> </context-group>
<target state="translated">Link di condivisione eliminato.</target> <target state="needs-translation">Share link deleted.</target>
</trans-unit> </trans-unit>
<trans-unit id="4964931417181298145" datatype="html"> <trans-unit id="4964931417181298145" datatype="html">
<source>Error deleting share link.</source> <source>Error deleting share link.</source>
@@ -12567,7 +12567,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
<context context-type="linenumber">159</context> <context context-type="linenumber">159</context>
</context-group> </context-group>
<target state="translated">Errore nell'eliminare il link condivisione.</target> <target state="needs-translation">Error deleting share link.</target>
</trans-unit> </trans-unit>
<trans-unit id="3439104787814858626" datatype="html"> <trans-unit id="3439104787814858626" datatype="html">
<source>Manage public links to individual documents and document bundles.</source> <source>Manage public links to individual documents and document bundles.</source>
@@ -12575,7 +12575,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
<context context-type="linenumber">4,5</context> <context context-type="linenumber">4,5</context>
</context-group> </context-group>
<target state="translated">Gestisci i link pubblici ai singoli documenti e pacchetti di documenti.</target> <target state="needs-translation">Manage public links to individual documents and document bundles.</target>
</trans-unit> </trans-unit>
<trans-unit id="3740311348735113099" datatype="html"> <trans-unit id="3740311348735113099" datatype="html">
<source>Document links</source> <source>Document links</source>
@@ -12583,7 +12583,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
<context context-type="linenumber">18,19</context> <context context-type="linenumber">18,19</context>
</context-group> </context-group>
<target state="translated">Link al documento</target> <target state="needs-translation">Document links</target>
</trans-unit> </trans-unit>
<trans-unit id="8216780576869093031" datatype="html"> <trans-unit id="8216780576869093031" datatype="html">
<source>Bundles</source> <source>Bundles</source>
@@ -12591,7 +12591,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
<context context-type="linenumber">26,27</context> <context context-type="linenumber">26,27</context>
</context-group> </context-group>
<target state="translated">Pacchetti</target> <target state="needs-translation">Bundles</target>
</trans-unit> </trans-unit>
<trans-unit id="1229748338333965418" datatype="html"> <trans-unit id="1229748338333965418" datatype="html">
<source>Use workflows to customize the behavior of Paperless-ngx when events &apos;trigger&apos; a workflow.</source> <source>Use workflows to customize the behavior of Paperless-ngx when events &apos;trigger&apos; a workflow.</source>
+14 -14
View File
@@ -3907,7 +3907,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
<context context-type="linenumber">2,3</context> <context context-type="linenumber">2,3</context>
</context-group> </context-group>
<target state="translated">Ссылки для обмена</target> <target state="needs-translation">Share links</target>
</trans-unit> </trans-unit>
<trans-unit id="7844706011418789951" datatype="html"> <trans-unit id="7844706011418789951" datatype="html">
<source>Administration</source> <source>Administration</source>
@@ -12410,7 +12410,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
<context context-type="linenumber">40,41</context> <context context-type="linenumber">40,41</context>
</context-group> </context-group>
<target state="translated">Истёкшие</target> <target state="needs-translation">Expired</target>
</trans-unit> </trans-unit>
<trans-unit id="2057574872309338088" datatype="html"> <trans-unit id="2057574872309338088" datatype="html">
<source>Copy share link</source> <source>Copy share link</source>
@@ -12454,7 +12454,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-bundle-list/share-link-bundle-list.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-bundle-list/share-link-bundle-list.component.html</context>
<context context-type="linenumber">164,165</context> <context context-type="linenumber">164,165</context>
</context-group> </context-group>
<target state="translated">Пагинация ссылок</target> <target state="needs-translation">Share link bundles pagination</target>
</trans-unit> </trans-unit>
<trans-unit id="1541995860059283227" datatype="html"> <trans-unit id="1541995860059283227" datatype="html">
<source>Failed to load share link bundles.</source> <source>Failed to load share link bundles.</source>
@@ -12510,7 +12510,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
<context context-type="linenumber">7,8</context> <context context-type="linenumber">7,8</context>
</context-group> </context-group>
<target state="translated"> В настоящее время нет общих ссылок. </target> <target state="needs-translation"> No document share links currently exist. </target>
</trans-unit> </trans-unit>
<trans-unit id="894965666917873756" datatype="html"> <trans-unit id="894965666917873756" datatype="html">
<source>Document</source> <source>Document</source>
@@ -12518,7 +12518,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
<context context-type="linenumber">15,16</context> <context context-type="linenumber">15,16</context>
</context-group> </context-group>
<target state="translated">Документ</target> <target state="needs-translation">Document</target>
</trans-unit> </trans-unit>
<trans-unit id="7420713064779772535" datatype="html"> <trans-unit id="7420713064779772535" datatype="html">
<source>Delete share link</source> <source>Delete share link</source>
@@ -12526,7 +12526,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
<context context-type="linenumber">75,76</context> <context context-type="linenumber">75,76</context>
</context-group> </context-group>
<target state="translated">Удалить ссылку для обмена</target> <target state="needs-translation">Delete share link</target>
</trans-unit> </trans-unit>
<trans-unit id="5198271726831144068" datatype="html"> <trans-unit id="5198271726831144068" datatype="html">
<source>Share links pagination</source> <source>Share links pagination</source>
@@ -12534,7 +12534,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
<context context-type="linenumber">104,105</context> <context context-type="linenumber">104,105</context>
</context-group> </context-group>
<target state="translated">Пагинация ссылок</target> <target state="needs-translation">Share links pagination</target>
</trans-unit> </trans-unit>
<trans-unit id="2976350639944567244" datatype="html"> <trans-unit id="2976350639944567244" datatype="html">
<source>Failed to load share links.</source> <source>Failed to load share links.</source>
@@ -12542,7 +12542,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
<context context-type="linenumber">105</context> <context context-type="linenumber">105</context>
</context-group> </context-group>
<target state="translated">Не удалось загрузить ссылки.</target> <target state="needs-translation">Failed to load share links.</target>
</trans-unit> </trans-unit>
<trans-unit id="4100863040928608880" datatype="html"> <trans-unit id="4100863040928608880" datatype="html">
<source>Error retrieving share links.</source> <source>Error retrieving share links.</source>
@@ -12550,7 +12550,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
<context context-type="linenumber">107</context> <context context-type="linenumber">107</context>
</context-group> </context-group>
<target state="translated">Ошибка при получении ссылок.</target> <target state="needs-translation">Error retrieving share links.</target>
</trans-unit> </trans-unit>
<trans-unit id="326120283142363299" datatype="html"> <trans-unit id="326120283142363299" datatype="html">
<source>Share link deleted.</source> <source>Share link deleted.</source>
@@ -12558,7 +12558,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
<context context-type="linenumber">154</context> <context context-type="linenumber">154</context>
</context-group> </context-group>
<target state="translated">Ссылка для обмена удалена.</target> <target state="needs-translation">Share link deleted.</target>
</trans-unit> </trans-unit>
<trans-unit id="4964931417181298145" datatype="html"> <trans-unit id="4964931417181298145" datatype="html">
<source>Error deleting share link.</source> <source>Error deleting share link.</source>
@@ -12566,7 +12566,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
<context context-type="linenumber">159</context> <context context-type="linenumber">159</context>
</context-group> </context-group>
<target state="translated">Ошибка при удалении ссылки.</target> <target state="needs-translation">Error deleting share link.</target>
</trans-unit> </trans-unit>
<trans-unit id="3439104787814858626" datatype="html"> <trans-unit id="3439104787814858626" datatype="html">
<source>Manage public links to individual documents and document bundles.</source> <source>Manage public links to individual documents and document bundles.</source>
@@ -12574,7 +12574,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
<context context-type="linenumber">4,5</context> <context context-type="linenumber">4,5</context>
</context-group> </context-group>
<target state="translated">Управление публичными ссылками на отдельные документы и комплекты документов.</target> <target state="needs-translation">Manage public links to individual documents and document bundles.</target>
</trans-unit> </trans-unit>
<trans-unit id="3740311348735113099" datatype="html"> <trans-unit id="3740311348735113099" datatype="html">
<source>Document links</source> <source>Document links</source>
@@ -12582,7 +12582,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
<context context-type="linenumber">18,19</context> <context context-type="linenumber">18,19</context>
</context-group> </context-group>
<target state="translated">Ссылки на документы</target> <target state="needs-translation">Document links</target>
</trans-unit> </trans-unit>
<trans-unit id="8216780576869093031" datatype="html"> <trans-unit id="8216780576869093031" datatype="html">
<source>Bundles</source> <source>Bundles</source>
@@ -12590,7 +12590,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
<context context-type="linenumber">26,27</context> <context context-type="linenumber">26,27</context>
</context-group> </context-group>
<target state="translated">Комплекты</target> <target state="needs-translation">Bundles</target>
</trans-unit> </trans-unit>
<trans-unit id="1229748338333965418" datatype="html"> <trans-unit id="1229748338333965418" datatype="html">
<source>Use workflows to customize the behavior of Paperless-ngx when events &apos;trigger&apos; a workflow.</source> <source>Use workflows to customize the behavior of Paperless-ngx when events &apos;trigger&apos; a workflow.</source>
+15 -15
View File
@@ -2855,7 +2855,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">104</context> <context context-type="linenumber">104</context>
</context-group> </context-group>
<target state="translated">Uporabi predloge umetne inteligence</target> <target state="needs-translation">Apply AI Suggestions</target>
</trans-unit> </trans-unit>
<trans-unit id="9172233176401579786" datatype="html"> <trans-unit id="9172233176401579786" datatype="html">
<source>Scheduled</source> <source>Scheduled</source>
@@ -3907,7 +3907,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
<context context-type="linenumber">2,3</context> <context context-type="linenumber">2,3</context>
</context-group> </context-group>
<target state="translated">Delite povezave</target> <target state="needs-translation">Share links</target>
</trans-unit> </trans-unit>
<trans-unit id="7844706011418789951" datatype="html"> <trans-unit id="7844706011418789951" datatype="html">
<source>Administration</source> <source>Administration</source>
@@ -12411,7 +12411,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
<context context-type="linenumber">40,41</context> <context context-type="linenumber">40,41</context>
</context-group> </context-group>
<target state="translated">Poteklo</target> <target state="needs-translation">Expired</target>
</trans-unit> </trans-unit>
<trans-unit id="2057574872309338088" datatype="html"> <trans-unit id="2057574872309338088" datatype="html">
<source>Copy share link</source> <source>Copy share link</source>
@@ -12455,7 +12455,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-bundle-list/share-link-bundle-list.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-bundle-list/share-link-bundle-list.component.html</context>
<context context-type="linenumber">164,165</context> <context context-type="linenumber">164,165</context>
</context-group> </context-group>
<target state="translated">Deljenje povezav – strani v sklopih</target> <target state="needs-translation">Share link bundles pagination</target>
</trans-unit> </trans-unit>
<trans-unit id="1541995860059283227" datatype="html"> <trans-unit id="1541995860059283227" datatype="html">
<source>Failed to load share link bundles.</source> <source>Failed to load share link bundles.</source>
@@ -12511,7 +12511,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
<context context-type="linenumber">7,8</context> <context context-type="linenumber">7,8</context>
</context-group> </context-group>
<target state="translated"> Trenutno ni povezav za skupno rabo dokumentov. </target> <target state="needs-translation"> No document share links currently exist. </target>
</trans-unit> </trans-unit>
<trans-unit id="894965666917873756" datatype="html"> <trans-unit id="894965666917873756" datatype="html">
<source>Document</source> <source>Document</source>
@@ -12519,7 +12519,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
<context context-type="linenumber">15,16</context> <context context-type="linenumber">15,16</context>
</context-group> </context-group>
<target state="translated">Dokument</target> <target state="needs-translation">Document</target>
</trans-unit> </trans-unit>
<trans-unit id="7420713064779772535" datatype="html"> <trans-unit id="7420713064779772535" datatype="html">
<source>Delete share link</source> <source>Delete share link</source>
@@ -12527,7 +12527,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
<context context-type="linenumber">75,76</context> <context context-type="linenumber">75,76</context>
</context-group> </context-group>
<target state="translated">Izbriši povezavo za deljenje</target> <target state="needs-translation">Delete share link</target>
</trans-unit> </trans-unit>
<trans-unit id="5198271726831144068" datatype="html"> <trans-unit id="5198271726831144068" datatype="html">
<source>Share links pagination</source> <source>Share links pagination</source>
@@ -12535,7 +12535,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
<context context-type="linenumber">104,105</context> <context context-type="linenumber">104,105</context>
</context-group> </context-group>
<target state="translated">Deljenje povezav – strani v sklopih</target> <target state="needs-translation">Share links pagination</target>
</trans-unit> </trans-unit>
<trans-unit id="2976350639944567244" datatype="html"> <trans-unit id="2976350639944567244" datatype="html">
<source>Failed to load share links.</source> <source>Failed to load share links.</source>
@@ -12543,7 +12543,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
<context context-type="linenumber">105</context> <context context-type="linenumber">105</context>
</context-group> </context-group>
<target state="translated">Povezav za deljenje ni bilo mogoče naložiti.</target> <target state="needs-translation">Failed to load share links.</target>
</trans-unit> </trans-unit>
<trans-unit id="4100863040928608880" datatype="html"> <trans-unit id="4100863040928608880" datatype="html">
<source>Error retrieving share links.</source> <source>Error retrieving share links.</source>
@@ -12551,7 +12551,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
<context context-type="linenumber">107</context> <context context-type="linenumber">107</context>
</context-group> </context-group>
<target state="translated">Napaka pri pridobivanju povezav za deljenje.</target> <target state="needs-translation">Error retrieving share links.</target>
</trans-unit> </trans-unit>
<trans-unit id="326120283142363299" datatype="html"> <trans-unit id="326120283142363299" datatype="html">
<source>Share link deleted.</source> <source>Share link deleted.</source>
@@ -12559,7 +12559,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
<context context-type="linenumber">154</context> <context context-type="linenumber">154</context>
</context-group> </context-group>
<target state="translated">Povezava za deljenje je bila izbrisana.</target> <target state="needs-translation">Share link deleted.</target>
</trans-unit> </trans-unit>
<trans-unit id="4964931417181298145" datatype="html"> <trans-unit id="4964931417181298145" datatype="html">
<source>Error deleting share link.</source> <source>Error deleting share link.</source>
@@ -12567,7 +12567,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
<context context-type="linenumber">159</context> <context context-type="linenumber">159</context>
</context-group> </context-group>
<target state="translated">Napaka pri brisanju povezave za deljenje.</target> <target state="needs-translation">Error deleting share link.</target>
</trans-unit> </trans-unit>
<trans-unit id="3439104787814858626" datatype="html"> <trans-unit id="3439104787814858626" datatype="html">
<source>Manage public links to individual documents and document bundles.</source> <source>Manage public links to individual documents and document bundles.</source>
@@ -12575,7 +12575,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
<context context-type="linenumber">4,5</context> <context context-type="linenumber">4,5</context>
</context-group> </context-group>
<target state="translated">Upravljajte javne povezave do posameznih dokumentov in paketov dokumentov.</target> <target state="needs-translation">Manage public links to individual documents and document bundles.</target>
</trans-unit> </trans-unit>
<trans-unit id="3740311348735113099" datatype="html"> <trans-unit id="3740311348735113099" datatype="html">
<source>Document links</source> <source>Document links</source>
@@ -12583,7 +12583,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
<context context-type="linenumber">18,19</context> <context context-type="linenumber">18,19</context>
</context-group> </context-group>
<target state="translated">Povezave do dokumentov</target> <target state="needs-translation">Document links</target>
</trans-unit> </trans-unit>
<trans-unit id="8216780576869093031" datatype="html"> <trans-unit id="8216780576869093031" datatype="html">
<source>Bundles</source> <source>Bundles</source>
@@ -12591,7 +12591,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
<context context-type="linenumber">26,27</context> <context context-type="linenumber">26,27</context>
</context-group> </context-group>
<target state="translated">Paketi</target> <target state="needs-translation">Bundles</target>
</trans-unit> </trans-unit>
<trans-unit id="1229748338333965418" datatype="html"> <trans-unit id="1229748338333965418" datatype="html">
<source>Use workflows to customize the behavior of Paperless-ngx when events &apos;trigger&apos; a workflow.</source> <source>Use workflows to customize the behavior of Paperless-ngx when events &apos;trigger&apos; a workflow.</source>
+1 -1
View File
@@ -11518,7 +11518,7 @@
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context> <context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
<context context-type="linenumber">70,72</context> <context context-type="linenumber">70,72</context>
</context-group> </context-group>
<target state="translated">Inga fält har definierats.</target> <target state="translated"/>
</trans-unit> </trans-unit>
<trans-unit id="3032792139967609806" datatype="html"> <trans-unit id="3032792139967609806" datatype="html">
<source>Confirm delete field</source> <source>Confirm delete field</source>
+2 -4
View File
@@ -74,7 +74,6 @@ import {
clipboardCheckFill, clipboardCheckFill,
clipboardFill, clipboardFill,
clockHistory, clockHistory,
cpu,
creditCard, creditCard,
dash, dash,
dashCircle, dashCircle,
@@ -119,7 +118,6 @@ import {
infoCircle, infoCircle,
journalBookmarkFill, journalBookmarkFill,
journals, journals,
lightbulb,
link, link,
list, list,
listNested, listNested,
@@ -150,6 +148,7 @@ import {
send, send,
shop, shop,
slashCircle, slashCircle,
sliders,
sliders2Vertical, sliders2Vertical,
sortAlphaDown, sortAlphaDown,
sortAlphaUpAlt, sortAlphaUpAlt,
@@ -324,7 +323,6 @@ const icons = {
clipboardCheckFill, clipboardCheckFill,
clipboardFill, clipboardFill,
clockHistory, clockHistory,
cpu,
cash, cash,
creditCard, creditCard,
dash, dash,
@@ -370,7 +368,6 @@ const icons = {
infoCircle, infoCircle,
journalBookmarkFill, journalBookmarkFill,
journals, journals,
lightbulb,
link, link,
list, list,
listNested, listNested,
@@ -401,6 +398,7 @@ const icons = {
send, send,
slashCircle, slashCircle,
shop, shop,
sliders,
sliders2Vertical, sliders2Vertical,
sortAlphaDown, sortAlphaDown,
sortAlphaUpAlt, sortAlphaUpAlt,
-1
View File
@@ -292,7 +292,6 @@ a.btn-link:active,
a.btn-link:focus-visible, a.btn-link:focus-visible,
.btn-link:hover, .btn-link:hover,
.btn-link:active, .btn-link:active,
.btn-link.show,
.btn-link:focus-visible { .btn-link:focus-visible {
color: var(--pngx-primary-lighten-10) !important; color: var(--pngx-primary-lighten-10) !important;
.primary-light & { .primary-light & {
@@ -25,20 +25,10 @@ export class PDFFindController {
onIsPageVisible?: () => boolean onIsPageVisible?: () => boolean
} }
export const LinkTarget = {
NONE: 0,
SELF: 1,
BLANK: 2,
PARENT: 3,
TOP: 4,
}
export class PDFLinkService { export class PDFLinkService {
private document?: unknown private document?: unknown
private viewer?: unknown private viewer?: unknown
constructor(readonly options: Record<string, unknown> = {}) {}
setDocument(document: unknown): void { setDocument(document: unknown): void {
this.document = document this.document = document
} }
-151
View File
@@ -1,151 +0,0 @@
"""Fixtures available to every Paperless-ngx app.
Loaded automatically for every test path. Keep module-scope imports minimal:
this file is imported for every session, so anything heavy belongs inside
the fixture body that needs it.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
if TYPE_CHECKING:
from collections.abc import Generator
from pathlib import Path
from django.contrib.auth.models import User
from pytest_django.fixtures import Settings
from rest_framework.test import APIClient
from paperless_testing.dirs import PaperlessDirs
from paperless_testing.fakes.progress import FakeProgressManager
@pytest.fixture(scope="session", autouse=True)
def faker_session_locale() -> str:
"""Pin Faker's locale so generated data does not follow the host locale.
The seed itself is left to pytest-randomly, which derives one per run.
"""
return "en_US"
@pytest.fixture(autouse=True)
def _fast_password_hasher(settings: Settings) -> None:
"""Hash test passwords with MD5 instead of Django's default PBKDF2.
PBKDF2 is deliberately slow, and every ``admin_user`` or
``create_superuser`` call pays for it: about 600 ms each. No test depends
on the hash format, only on ``check_password`` and on the stored value
changing when the password does.
"""
settings.PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
@pytest.fixture(autouse=True)
def _clear_content_type_caches() -> None:
"""Clear Django's ContentType cache and guardian's lru_cache before each test.
Tests that delete and reinsert ContentType/Permission rows (e.g. the
importer) corrupt both caches. Without this fixture a subsequent test on
the same xdist worker sees stale ContentType objects and guardian raises
MixedContentTypeError.
"""
from django.contrib.contenttypes.models import ContentType
from guardian.shortcuts import clear_ct_cache
ContentType.objects.clear_cache()
clear_ct_cache()
@pytest.fixture(autouse=True)
def _clear_django_caches() -> None:
"""Clear every configured cache before each test.
Cached values outlive the test that wrote them: the classifier keys its
vectorized content on a hash of the content itself, so a second test
generating the same fixture data takes the cache-hit path and never calls
the code it is asserting against.
"""
from django.core.cache import caches
for cache in caches.all(initialized_only=False):
cache.clear()
@pytest.fixture
def paperless_dirs(
tmp_path: Path,
settings: Settings,
) -> Generator[PaperlessDirs, None, None]:
"""The standard temp directory layout, applied to Django settings."""
from documents.search import reset_backend
from paperless_testing.dirs import build_paperless_dirs
from paperless_testing.dirs import dirs_settings
dirs = build_paperless_dirs(tmp_path)
for name, value in dirs_settings(dirs).items():
setattr(settings, name, value)
# Not directory settings, but they are needed alongside the layout by the
# sanity checker tests.
settings.IGNORABLE_FILES = {".DS_Store", "Thumbs.db", "desktop.ini"}
settings.APP_LOGO = ""
reset_backend()
yield dirs
reset_backend()
@pytest.fixture
def rest_api_client() -> APIClient:
"""The basic DRF APIClient, unauthenticated."""
from rest_framework.test import APIClient
return APIClient()
@pytest.fixture
def regular_user(db: None) -> User:
"""Unprivileged user for permission boundary tests."""
from paperless_testing.factories import UserFactory
return UserFactory(username="regular")
@pytest.fixture
def admin_client(rest_api_client: APIClient, admin_user: User) -> APIClient:
"""Admin client pre-authenticated and sending the v10 Accept header."""
rest_api_client.force_authenticate(user=admin_user)
rest_api_client.credentials(HTTP_ACCEPT="application/json; version=10")
return rest_api_client
@pytest.fixture
def v9_client(rest_api_client: APIClient, admin_user: User) -> APIClient:
"""Admin client pre-authenticated and sending the v9 Accept header."""
rest_api_client.force_authenticate(user=admin_user)
rest_api_client.credentials(HTTP_ACCEPT="application/json; version=9")
return rest_api_client
@pytest.fixture
def user_client(rest_api_client: APIClient, regular_user: User) -> APIClient:
"""Regular-user client pre-authenticated and sending the v10 Accept header."""
rest_api_client.force_authenticate(user=regular_user)
rest_api_client.credentials(HTTP_ACCEPT="application/json; version=10")
return rest_api_client
@pytest.fixture
def fake_progress_manager(
monkeypatch: pytest.MonkeyPatch,
) -> type[FakeProgressManager]:
"""Replace documents.tasks.ProgressManager with the fake, so consuming a file
in a test never tries to reach a broker."""
from paperless_testing.fakes.progress import FakeProgressManager
monkeypatch.setattr("documents.tasks.ProgressManager", FakeProgressManager)
return FakeProgressManager
+1
View File
@@ -26,6 +26,7 @@ class DocumentsConfig(AppConfig):
document_consumption_finished.connect(set_document_type) document_consumption_finished.connect(set_document_type)
document_consumption_finished.connect(set_tags) document_consumption_finished.connect(set_tags)
document_consumption_finished.connect(set_storage_path) document_consumption_finished.connect(set_storage_path)
document_consumption_finished.connect(add_to_index)
document_consumption_finished.connect(run_workflows_added) document_consumption_finished.connect(run_workflows_added)
document_consumption_finished.connect(add_to_index) document_consumption_finished.connect(add_to_index)
document_consumption_finished.connect(add_or_update_document_in_llm_index) document_consumption_finished.connect(add_or_update_document_in_llm_index)
+2 -3
View File
@@ -857,9 +857,8 @@ class ConsumerPlugin(
self.log.debug(f"Creation date from parse_date: {create_date}") self.log.debug(f"Creation date from parse_date: {create_date}")
else: else:
stats = Path(self.input_doc.original_file).stat() stats = Path(self.input_doc.original_file).stat()
create_date = datetime.datetime.fromtimestamp( create_date = timezone.make_aware(
stats.st_mtime, datetime.datetime.fromtimestamp(stats.st_mtime),
tz=timezone.get_current_timezone(),
) )
self.log.debug(f"Creation date from st_mtime: {create_date}") self.log.debug(f"Creation date from st_mtime: {create_date}")
+63 -50
View File
@@ -196,49 +196,52 @@ class WriteBatch:
return self._raw_writer return self._raw_writer
def __enter__(self) -> Self: def __enter__(self) -> Self:
lock_path = self._backend._path / ".tantivy.lock" if self._backend._path is not None:
self._lock = filelock.FileLock(str(lock_path)) lock_path = self._backend._path / ".tantivy.lock"
for attempt in range(_LOCK_RETRY_ATTEMPTS): self._lock = filelock.FileLock(str(lock_path))
try: for attempt in range(_LOCK_RETRY_ATTEMPTS):
self._lock.acquire(timeout=self._lock_timeout) try:
break self._lock.acquire(timeout=self._lock_timeout)
except filelock.Timeout: break
if attempt == _LOCK_RETRY_ATTEMPTS - 1: except filelock.Timeout:
raise SearchIndexLockError( if attempt == _LOCK_RETRY_ATTEMPTS - 1:
f"Could not acquire index lock after {_LOCK_RETRY_ATTEMPTS} " raise SearchIndexLockError(
f"attempts (timeout={self._lock_timeout}s each)", f"Could not acquire index lock after {_LOCK_RETRY_ATTEMPTS} "
f"attempts (timeout={self._lock_timeout}s each)",
)
sleep_s = random.uniform(
0,
min(_LOCK_BACKOFF_CAP, _LOCK_BACKOFF_BASE * (2**attempt)),
) )
sleep_s = random.uniform( logger.debug(
0, "Index lock contention; retrying in %.2fs (attempt %d/%d)",
min(_LOCK_BACKOFF_CAP, _LOCK_BACKOFF_BASE * (2**attempt)), sleep_s,
) attempt + 1,
logger.debug( _LOCK_RETRY_ATTEMPTS,
"Index lock contention; retrying in %.2fs (attempt %d/%d)", )
sleep_s, time.sleep(sleep_s)
attempt + 1,
_LOCK_RETRY_ATTEMPTS,
)
time.sleep(sleep_s)
# Open a fresh Index (and thus a fresh Tantivy ManagedDirectory) # Open a fresh Index (and thus a fresh Tantivy ManagedDirectory)
# for the write, rather than reusing the process-local cached # for the write, rather than reusing the process-local cached
# index. ManagedDirectory loads its GC bookkeeping (.managed.json) # index. ManagedDirectory loads its GC bookkeeping (.managed.json)
# once, at construction, and never re-reads it; paperless runs # once, at construction, and never re-reads it; paperless runs
# several long-lived processes (Granian workers, Celery workers) # several long-lived processes (Granian workers, Celery workers)
# that take turns writing under the file lock above. A cached, # that take turns writing under the file lock above. A cached,
# long-lived writer index would carry a stale managed-files view # long-lived writer index would carry a stale managed-files view
# and, on commit, overwrite .managed.json with that stale view - # and, on commit, overwrite .managed.json with that stale view -
# permanently losing track of segment files other processes # permanently losing track of segment files other processes
# registered in the meantime, so they can never be garbage # registered in the meantime, so they can never be garbage
# collected. Reopening fresh here always picks up the current # collected. Reopening fresh here always picks up the current
# on-disk state. The long-lived self._backend._index is used for # on-disk state. The long-lived self._backend._index is used for
# reads only and is reloaded (not reopened) after commit below. # reads only and is reloaded (not reopened) after commit below.
write_index = tantivy.Index( write_index = tantivy.Index(
build_schema(), build_schema(),
path=str(self._backend._path), path=str(self._backend._path),
) )
register_tokenizers(write_index, settings.SEARCH_LANGUAGE) register_tokenizers(write_index, settings.SEARCH_LANGUAGE)
self._raw_writer = write_index.writer() self._raw_writer = write_index.writer()
else:
self._raw_writer = self._backend._index.writer()
return self return self
def __exit__(self, exc_type, exc_val, exc_tb): def __exit__(self, exc_type, exc_val, exc_tb):
@@ -369,8 +372,9 @@ class TantivyBackend:
Tantivy search backend with explicit lifecycle management. Tantivy search backend with explicit lifecycle management.
Provides full-text search capabilities using the Tantivy search engine. Provides full-text search capabilities using the Tantivy search engine.
Keeps a persistent on-disk index. Handles document indexing, search queries, Supports in-memory indexes (for testing) and persistent on-disk indexes
autocompletion, and "more like this" functionality. (for production use). Handles document indexing, search queries, autocompletion,
and "more like this" functionality.
The backend manages its own connection lifecycle and can be reset when The backend manages its own connection lifecycle and can be reset when
the underlying index directory changes (e.g., during test isolation). the underlying index directory changes (e.g., during test isolation).
@@ -404,7 +408,9 @@ class TantivyBackend:
}, },
) )
def __init__(self, path: Path): def __init__(self, path: Path | None = None):
# path=None → in-memory index (for tests)
# path=some_dir → on-disk index (for production)
self._path = path self._path = path
self._raw_index: tantivy.Index | None = None self._raw_index: tantivy.Index | None = None
self._raw_schema: tantivy.Schema | None = None self._raw_schema: tantivy.Schema | None = None
@@ -423,13 +429,16 @@ class TantivyBackend:
""" """
Open or rebuild the index as needed. Open or rebuild the index as needed.
Checks if rebuilding is needed due to schema version or language For disk-based indexes, checks if rebuilding is needed due to schema
changes. Registers custom tokenizers after opening. version or language changes. Registers custom tokenizers after opening.
Safe to call multiple times - subsequent calls are no-ops. Safe to call multiple times - subsequent calls are no-ops.
""" """
if self._raw_index is not None: if self._raw_index is not None:
return # pragma: no cover return # pragma: no cover
self._raw_index = open_or_rebuild_index(self._path) if self._path is not None:
self._raw_index = open_or_rebuild_index(self._path)
else:
self._raw_index = tantivy.Index(build_schema())
register_tokenizers(self._raw_index, settings.SEARCH_LANGUAGE) register_tokenizers(self._raw_index, settings.SEARCH_LANGUAGE)
self._raw_schema = self._raw_index.schema self._raw_schema = self._raw_index.schema
@@ -1093,9 +1102,13 @@ class TantivyBackend:
writer's threads). Larger values buffer more docs in RAM before writer's threads). Larger values buffer more docs in RAM before
flushing a segment, deferring merge work; they do not avoid it. flushing a segment, deferring merge work; they do not avoid it.
""" """
wipe_index(self._path) # Create new index (on-disk or in-memory)
new_index = tantivy.Index(build_schema(), path=str(self._path)) if self._path is not None:
_write_sentinels(self._path) wipe_index(self._path)
new_index = tantivy.Index(build_schema(), path=str(self._path))
_write_sentinels(self._path)
else:
new_index = tantivy.Index(build_schema())
register_tokenizers(new_index, settings.SEARCH_LANGUAGE) register_tokenizers(new_index, settings.SEARCH_LANGUAGE)
# Point instance at the new index so _build_tantivy_doc uses it # Point instance at the new index so _build_tantivy_doc uses it
File diff suppressed because it is too large Load Diff
-568
View File
@@ -1,568 +0,0 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from typing import Any
from typing import Literal
from django.contrib.auth.models import Group
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.utils.text import slugify
from django.utils.translation import gettext as _
from drf_spectacular.utils import extend_schema_field
from guardian.core import ObjectPermissionChecker
from guardian.shortcuts import get_users_with_perms
from guardian.utils import get_group_obj_perms_model
from guardian.utils import get_user_obj_perms_model
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from rest_framework.fields import SerializerMethodField
from rest_framework.utils import model_meta
from documents import bulk_edit
from documents.models import Document
from documents.models import MatchingModel
from documents.models import Note
from documents.permissions import get_groups_with_only_permission
from documents.permissions import set_permissions_for_object
from documents.regex import validate_regex_pattern
if TYPE_CHECKING:
from collections.abc import Iterable
logger = logging.getLogger("paperless.serializers")
# https://www.django-rest-framework.org/api-guide/serializers/#example
class DynamicFieldsModelSerializer(serializers.ModelSerializer[Any]):
"""
A ModelSerializer that takes an additional `fields` argument that
controls which fields should be displayed.
"""
def __init__(self, *args, **kwargs) -> None:
# Don't pass the 'fields' arg up to the superclass
fields = kwargs.pop("fields", None)
# Instantiate the superclass normally
super().__init__(*args, **kwargs)
if fields is not None:
# Drop any fields that are not specified in the `fields` argument.
allowed = set(fields)
existing = set(self.fields)
for field_name in existing - allowed:
self.fields.pop(field_name)
class DocumentUpdateFieldsModelSerializer(DynamicFieldsModelSerializer):
stale_update_excluded_fields = frozenset({"filename", "archive_filename"})
def _get_update_fields(self, validated_data) -> list[str]:
model_fields = {
field.name
for field in self.Meta.model._meta.concrete_fields
if field.name not in self.stale_update_excluded_fields
}
update_fields = [
field_name for field_name in validated_data if field_name in model_fields
]
if "modified" in model_fields and "modified" not in update_fields:
update_fields.append("modified")
return update_fields
def update(self, instance, validated_data):
serializers.raise_errors_on_nested_writes("update", self, validated_data)
info = model_meta.get_field_info(instance)
m2m_fields = []
for attr, value in validated_data.items():
if attr in info.relations and info.relations[attr].to_many:
m2m_fields.append((attr, value))
else:
setattr(instance, attr, value)
# File names are managed by post-save file handling. Saving only the
# serializer-updated fields prevents stale in-memory path values from
# overwriting a concurrent move.
instance.save(update_fields=self._get_update_fields(validated_data))
for attr, value in m2m_fields:
field = getattr(instance, attr)
field.set(value)
return instance
class MatchingModelSerializer(serializers.ModelSerializer[Any]):
document_count = serializers.IntegerField(read_only=True)
def get_slug(self, obj) -> str:
return slugify(obj.name)
slug = SerializerMethodField()
def validate(self, data):
# TODO: remove pending https://github.com/encode/django-rest-framework/issues/7173
name = data.get(
"name",
self.instance.name if hasattr(self.instance, "name") else None,
)
owner = (
data["owner"]
if "owner" in data
else self.user
if hasattr(self, "user")
else None
)
pk = self.instance.pk if hasattr(self.instance, "pk") else None
if ("name" in data or "owner" in data) and self.Meta.model.objects.filter(
name=name,
owner=owner,
).exclude(pk=pk).exists():
raise serializers.ValidationError(
{"error": "Object violates owner / name unique constraint"},
)
return data
def validate_match(self, match):
if (
"matching_algorithm" in self.initial_data
and self.initial_data["matching_algorithm"] == MatchingModel.MATCH_REGEX
):
try:
validate_regex_pattern(match)
except ValueError as e:
logger.debug(f"Invalid regular expression: {e!s}")
raise serializers.ValidationError(
"Invalid regular expression, see log for details.",
)
return match
PERMISSION_ACTIONS = ("view", "change")
class SetPermissionsMixin:
def _validate_user_ids(self, user_ids):
users = User.objects.none()
if user_ids is not None:
users = User.objects.filter(id__in=user_ids)
if not users.count() == len(user_ids):
raise serializers.ValidationError(
"Some users in don't exist or were specified twice.",
)
return users
def _validate_group_ids(self, group_ids):
groups = Group.objects.none()
if group_ids is not None:
groups = Group.objects.filter(id__in=group_ids)
if not groups.count() == len(group_ids):
raise serializers.ValidationError(
"Some groups in don't exist or were specified twice.",
)
return groups
def validate_set_permissions(self, set_permissions=None):
permissions_dict = {action: {} for action in PERMISSION_ACTIONS}
if set_permissions is not None:
for action in PERMISSION_ACTIONS:
if action in set_permissions:
if "users" in set_permissions[action]:
users = set_permissions[action]["users"]
permissions_dict[action]["users"] = self._validate_user_ids(
users,
)
if "groups" in set_permissions[action]:
groups = set_permissions[action]["groups"]
permissions_dict[action]["groups"] = self._validate_group_ids(
groups,
)
else:
del permissions_dict[action]
return permissions_dict
def _set_permissions(self, permissions, object) -> None:
set_permissions_for_object(permissions, object)
class SerializerWithPerms(serializers.Serializer[dict[str, Any]]):
def __init__(self, *args, **kwargs) -> None:
self.user = kwargs.pop("user", None)
self.full_perms = kwargs.pop("full_perms", False)
self.all_fields = kwargs.pop("all_fields", False)
super().__init__(*args, **kwargs)
class PermissionSetSerializer(serializers.Serializer[dict[str, Any]]):
users = serializers.ListField(
child=serializers.IntegerField(),
required=False,
allow_null=True,
)
groups = serializers.ListField(
child=serializers.IntegerField(),
required=False,
allow_null=True,
)
class SetPermissionsSerializer(serializers.Serializer[dict[str, Any]]):
view = PermissionSetSerializer(required=False)
change = PermissionSetSerializer(required=False)
def to_internal_value(self, data):
if isinstance(data, dict):
unknown_keys = set(data) - set(PERMISSION_ACTIONS)
if unknown_keys:
raise serializers.ValidationError(
{key: "Unknown permission action." for key in sorted(unknown_keys)},
)
return super().to_internal_value(data)
class OwnedObjectSerializer(
SerializerWithPerms,
serializers.ModelSerializer[Any],
SetPermissionsMixin,
):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
if not self.all_fields:
try:
if self.full_perms:
self.fields.pop("user_can_change")
self.fields.pop("is_shared_by_requester")
else:
self.fields.pop("permissions")
except KeyError:
pass
def _get_perms(self, obj, codename: str, target: Literal["users", "groups"]):
"""
Get the given permissions from context or from django-guardian.
:param codename: The permission codename, e.g. 'view' or 'change'
:param target: 'users' or 'groups'
"""
key = f"{target}_{codename}_perms"
cached = self.context.get(key, {}).get(obj.pk)
if cached is not None:
return list(cached)
# Permission not found in the context, get it from guardian
if target == "users":
return list(
get_users_with_perms(
obj,
only_with_perms_in=[f"{codename}_{obj.__class__.__name__.lower()}"],
with_group_users=False,
).values_list("id", flat=True),
)
else: # groups
return list(
get_groups_with_only_permission(
obj,
codename=f"{codename}_{obj.__class__.__name__.lower()}",
).values_list("id", flat=True),
)
@extend_schema_field(
field={
"type": "object",
"properties": {
"view": {
"type": "object",
"properties": {
"users": {
"type": "array",
"items": {"type": "integer"},
},
"groups": {
"type": "array",
"items": {"type": "integer"},
},
},
},
"change": {
"type": "object",
"properties": {
"users": {
"type": "array",
"items": {"type": "integer"},
},
"groups": {
"type": "array",
"items": {"type": "integer"},
},
},
},
},
},
)
def get_permissions(self, obj) -> dict:
return {
"view": {
"users": self._get_perms(obj, "view", "users"),
"groups": self._get_perms(obj, "view", "groups"),
},
"change": {
"users": self._get_perms(obj, "change", "users"),
"groups": self._get_perms(obj, "change", "groups"),
},
}
def get_user_can_change(self, obj) -> bool:
if obj.owner is None or obj.owner == self.user:
return True
if self.user is None:
return False
if self.user.is_active and self.user.is_superuser:
# Mirrors guardian's own ObjectPermissionChecker.has_perm() shortcut --
# superusers aren't necessarily granted explicit object permissions,
# so the batched context below would otherwise incorrectly say no.
return True
# Prefer the page-level batch computed by BulkPermissionMixin
# (get_serializer_context) over a fresh per-object guardian check,
# which would otherwise query the permission tables once per row.
users_change_perms = self.context.get("users_change_perms")
groups_change_perms = self.context.get("groups_change_perms")
if users_change_perms is not None and groups_change_perms is not None:
if self.user.pk in users_change_perms.get(obj.pk, []):
return True
user_group_ids = getattr(self, "_user_group_ids", None)
if user_group_ids is None:
user_group_ids = set(self.user.groups.values_list("id", flat=True))
self._user_group_ids = user_group_ids
return bool(
user_group_ids.intersection(groups_change_perms.get(obj.pk, [])),
)
checker = ObjectPermissionChecker(self.user)
return checker.has_perm(f"change_{obj.__class__.__name__.lower()}", obj)
@staticmethod
def get_shared_object_pks(objects: Iterable):
"""
Return the primary keys of the subset of objects that are shared.
"""
try:
first_obj = next(iter(objects))
except StopIteration:
return set()
ctype = ContentType.objects.get_for_model(first_obj)
object_pks = list(obj.pk for obj in objects)
pk_type = type(first_obj.pk)
def get_pks_for_permission_type(model):
return map(
pk_type, # coerce the pk to be the same type of the provided objects
model.objects.filter(
content_type=ctype,
object_pk__in=object_pks,
)
.values_list("object_pk", flat=True)
.distinct(),
)
UserObjectPermission = get_user_obj_perms_model()
GroupObjectPermission = get_group_obj_perms_model()
user_permission_pks = get_pks_for_permission_type(UserObjectPermission)
group_permission_pks = get_pks_for_permission_type(GroupObjectPermission)
return set(user_permission_pks) | set(group_permission_pks)
def get_is_shared_by_requester(self, obj: Document) -> bool:
# First check the context to see if `shared_object_pks` is set by the parent.
shared_object_pks = self.context.get("shared_object_pks")
# If not just check if the current object is shared.
if shared_object_pks is None:
shared_object_pks = self.get_shared_object_pks([obj])
return obj.owner == self.user and obj.id in shared_object_pks
permissions = SerializerMethodField(read_only=True, required=False)
user_can_change = SerializerMethodField(read_only=True, required=False)
is_shared_by_requester = SerializerMethodField(read_only=True, required=False)
set_permissions = SetPermissionsSerializer(
label="Set permissions",
required=False,
write_only=True,
)
# other methods in mixin
def validate_unique_together(self, validated_data, instance=None) -> None:
# workaround for https://github.com/encode/django-rest-framework/issues/9358
if "owner" in validated_data and "name" in self.Meta.fields:
name = validated_data.get("name", instance.name if instance else None)
objects = (
self.Meta.model.objects.exclude(pk=instance.pk)
if instance
else self.Meta.model.objects.all()
)
not_unique = objects.filter(
owner=validated_data["owner"],
name=name,
).exists()
if not_unique:
raise serializers.ValidationError(
{"error": "Object violates owner / name unique constraint"},
)
def create(self, validated_data):
# default to current user if not set
request = self.context.get("request")
if (
"owner" not in validated_data
or (request is not None and "owner" not in request.data)
) and self.user:
validated_data["owner"] = self.user
permissions = None
if "set_permissions" in validated_data:
permissions = validated_data.pop("set_permissions")
self.validate_unique_together(validated_data)
instance = super().create(validated_data)
if permissions is not None:
self._set_permissions(permissions, instance)
return instance
def update(self, instance, validated_data):
user = getattr(self, "user", None)
is_superuser = user.is_superuser if user is not None else False
is_owner = instance.owner == user if user is not None else False
is_unowned = instance.owner is None
if (
("owner" in validated_data and validated_data["owner"] != instance.owner)
or "set_permissions" in validated_data
) and not (is_superuser or is_owner or is_unowned):
raise PermissionDenied(
_("Insufficient permissions."),
)
if "set_permissions" in validated_data:
self._set_permissions(validated_data["set_permissions"], instance)
self.validate_unique_together(validated_data, instance)
return super().update(instance, validated_data)
class OwnedObjectListSerializer(serializers.ListSerializer[Any]):
def to_representation(self, documents):
self.child.context["shared_object_pks"] = self.child.get_shared_object_pks(
documents,
)
return super().to_representation(documents)
class ReadWriteSerializerMethodField(serializers.SerializerMethodField):
"""
Based on https://stackoverflow.com/a/62579804
"""
def __init__(self, method_name=None, *args, **kwargs) -> None:
self.method_name = method_name
kwargs["source"] = "*"
super(serializers.SerializerMethodField, self).__init__(*args, **kwargs)
def to_internal_value(self, data):
return {self.field_name: data}
class DocumentListSerializer(serializers.Serializer[dict[str, list[int]]]):
documents = serializers.ListField(
required=True,
label="Documents",
write_only=True,
child=serializers.IntegerField(),
)
def _validate_document_id_list(self, documents, name="documents") -> None:
if not isinstance(documents, list):
raise serializers.ValidationError(f"{name} must be a list")
if not all(isinstance(i, int) for i in documents):
raise serializers.ValidationError(f"{name} must be a list of integers")
count = Document.objects.filter(id__in=documents).count()
if not count == len(documents):
raise serializers.ValidationError(
f"Some documents in {name} don't exist or were specified twice.",
)
def validate_documents(self, documents):
self._validate_document_id_list(documents)
return documents
class DocumentSelectionSerializer(DocumentListSerializer):
documents = serializers.ListField(
required=False,
label="Documents",
write_only=True,
child=serializers.IntegerField(),
)
all = serializers.BooleanField(
default=False,
required=False,
write_only=True,
)
filters = serializers.DictField(
required=False,
allow_empty=True,
write_only=True,
)
excluded_documents = serializers.ListField(
required=False,
default=list,
write_only=True,
child=serializers.IntegerField(),
)
def validate(self, attrs):
if attrs.get("all", False):
attrs.setdefault("documents", [])
return attrs
if attrs["excluded_documents"]:
raise serializers.ValidationError(
"excluded_documents is only supported when all is true.",
)
if "documents" not in attrs:
raise serializers.ValidationError(
"documents is required unless all is true.",
)
documents = attrs["documents"]
self._validate_document_id_list(documents)
return attrs
class SourceModeValidationMixin:
def validate_source_mode(self, source_mode: str) -> str:
if source_mode not in bulk_edit.SourceModeChoices.__dict__.values():
raise serializers.ValidationError("Invalid source_mode")
return source_mode
class BasicUserSerializer(serializers.ModelSerializer[User]):
# Different than paperless.serializers.UserSerializer
class Meta:
model = User
fields = ["id", "username", "first_name", "last_name"]
class NotesSerializer(serializers.ModelSerializer[Note]):
user = BasicUserSerializer(read_only=True)
class Meta:
model = Note
fields = ["id", "note", "created", "user"]
ordering = ["-created"]
-750
View File
@@ -1,750 +0,0 @@
from __future__ import annotations
import logging
from django.contrib.auth.models import User
from rest_framework import serializers
from documents import bulk_edit
from documents.models import Correspondent
from documents.models import CustomField
from documents.models import Document
from documents.models import DocumentType
from documents.models import StoragePath
from documents.models import Tag
from .base import DocumentListSerializer
from .base import DocumentSelectionSerializer
from .base import SerializerWithPerms
from .base import SetPermissionsMixin
from .base import SetPermissionsSerializer
from .base import SourceModeValidationMixin
from .metadata import CustomFieldInstanceSerializer
logger = logging.getLogger("paperless.serializers")
def _validate_rotation_degrees(degrees: int, field: str = "degrees") -> int:
# QPDF refuses any other angle, which would otherwise fail inside the task
if degrees % 90 != 0:
raise serializers.ValidationError(f"{field} must be a multiple of 90")
return degrees
class RotateDocumentsSerializer(DocumentSelectionSerializer, SourceModeValidationMixin):
degrees = serializers.IntegerField(required=True)
source_mode = serializers.CharField(
required=False,
default=bulk_edit.SourceModeChoices.LATEST_VERSION,
)
from_webui = serializers.BooleanField(required=False, default=False)
def validate_degrees(self, value: int) -> int:
return _validate_rotation_degrees(value)
class MergeDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin):
metadata_document_id = serializers.IntegerField(
required=False,
allow_null=True,
)
delete_originals = serializers.BooleanField(required=False, default=False)
archive_fallback = serializers.BooleanField(required=False, default=False)
source_mode = serializers.CharField(
required=False,
default=bulk_edit.SourceModeChoices.LATEST_VERSION,
)
from_webui = serializers.BooleanField(required=False, default=False)
class MergeDocumentsAsVersionsSerializer(DocumentListSerializer):
root_document_id = serializers.IntegerField(required=True)
version_label = serializers.CharField(
required=False,
allow_blank=True,
allow_null=True,
max_length=64,
)
def validate_version_label(self, value):
if value is None:
return None
normalized = value.strip()
return normalized or None
def validate(self, attrs):
documents = attrs["documents"]
if len(documents) < 2:
raise serializers.ValidationError(
"At least two documents are required.",
)
if attrs.get("version_label") is not None and len(documents) != 2:
raise serializers.ValidationError(
"version_label can only be used when merging one source document.",
)
if attrs["root_document_id"] not in documents:
raise serializers.ValidationError(
"root_document_id must be one of the selected documents.",
)
selected_documents = Document.objects.filter(id__in=documents)
if selected_documents.filter(root_document__isnull=False).exists():
raise serializers.ValidationError(
"Only top-level documents can be merged as versions.",
)
source_document_ids = set(documents) - {attrs["root_document_id"]}
if Document.global_objects.filter(
root_document_id__in=source_document_ids,
).exists():
raise serializers.ValidationError(
"Documents with existing versions cannot be merged into another document.",
)
return attrs
class PdfEditOperationSerializer(serializers.Serializer[dict[str, int]]):
page = serializers.IntegerField(min_value=1)
rotate = serializers.IntegerField(required=False)
doc = serializers.IntegerField(required=False, min_value=0)
def validate_rotate(self, value: int) -> int:
return _validate_rotation_degrees(value, field="rotate")
class EditPdfDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin):
operations = serializers.ListField(
child=PdfEditOperationSerializer(),
required=True,
allow_empty=False,
)
delete_original = serializers.BooleanField(required=False, default=False)
update_document = serializers.BooleanField(required=False, default=False)
include_metadata = serializers.BooleanField(required=False, default=True)
source_mode = serializers.CharField(
required=False,
default=bulk_edit.SourceModeChoices.LATEST_VERSION,
)
from_webui = serializers.BooleanField(required=False, default=False)
def validate(self, attrs):
documents = attrs["documents"]
if len(documents) > 1:
raise serializers.ValidationError(
"Edit PDF method only supports one document",
)
operations = attrs["operations"]
if any(op.get("doc", 0) >= len(operations) for op in operations):
raise serializers.ValidationError("doc index is out of bounds")
if attrs["update_document"]:
max_idx = max(op.get("doc", 0) for op in operations)
if max_idx > 0:
raise serializers.ValidationError(
"update_document only allowed with a single output document",
)
doc = Document.objects.get(id=documents[0])
if doc.page_count:
for op in operations:
if op["page"] > doc.page_count:
raise serializers.ValidationError(
f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.",
)
return attrs
class RemovePasswordDocumentsSerializer(
DocumentListSerializer,
SourceModeValidationMixin,
):
password = serializers.CharField(required=True)
update_document = serializers.BooleanField(required=False, default=False)
delete_original = serializers.BooleanField(required=False, default=False)
include_metadata = serializers.BooleanField(required=False, default=True)
source_mode = serializers.CharField(
required=False,
default=bulk_edit.SourceModeChoices.LATEST_VERSION,
)
from_webui = serializers.BooleanField(required=False, default=False)
class DeleteDocumentsSerializer(DocumentSelectionSerializer):
pass
class ReprocessDocumentsSerializer(DocumentSelectionSerializer):
remote_ocr = serializers.BooleanField(required=False, default=False)
class BulkEditSerializer(
SerializerWithPerms,
DocumentSelectionSerializer,
SetPermissionsMixin,
SourceModeValidationMixin,
):
# TODO: remove this and related backwards compatibility code when API v9 is dropped
# split, delete_pages can be removed entirely
MOVED_DOCUMENT_ACTION_ENDPOINTS = {
"delete": "/api/documents/delete/",
"reprocess": "/api/documents/reprocess/",
"rotate": "/api/documents/rotate/",
"merge": "/api/documents/merge/",
"edit_pdf": "/api/documents/edit_pdf/",
"remove_password": "/api/documents/remove_password/",
"split": "/api/documents/edit_pdf/",
"delete_pages": "/api/documents/edit_pdf/",
}
LEGACY_DOCUMENT_ACTION_METHODS = tuple(MOVED_DOCUMENT_ACTION_ENDPOINTS.keys())
method = serializers.ChoiceField(
choices=[
"set_correspondent",
"set_document_type",
"set_storage_path",
"add_tag",
"remove_tag",
"modify_tags",
"modify_custom_fields",
"set_permissions",
*LEGACY_DOCUMENT_ACTION_METHODS,
],
label="Method",
write_only=True,
)
parameters = serializers.DictField(allow_empty=True, default={}, write_only=True)
from_webui = serializers.BooleanField(required=False, default=False)
def _validate_tag_id_list(self, tags, name="tags") -> None:
if not isinstance(tags, list):
raise serializers.ValidationError(f"{name} must be a list")
if not all(isinstance(i, int) for i in tags):
raise serializers.ValidationError(f"{name} must be a list of integers")
count = Tag.objects.filter(id__in=tags).count()
if not count == len(tags):
raise serializers.ValidationError(
f"Some tags in {name} don't exist or were specified twice.",
)
def _validate_custom_field_id_list_or_dict(
self,
custom_fields,
name="custom_fields",
) -> None:
ids = custom_fields
if isinstance(custom_fields, dict):
try:
ids = [int(i[0]) for i in custom_fields.items()]
except Exception as e:
logger.exception(f"Error validating custom fields: {e}")
raise serializers.ValidationError(
f"{name} must be a list of integers or a dict of id:value pairs, see the log for details",
)
elif not isinstance(custom_fields, list) or not all(
isinstance(i, int) for i in ids
):
raise serializers.ValidationError(
f"{name} must be a list of integers or a dict of id:value pairs",
)
count = CustomField.objects.filter(id__in=ids).count()
if not count == len(ids):
raise serializers.ValidationError(
f"Some custom fields in {name} don't exist or were specified twice.",
)
def _validate_custom_field_values(self, custom_fields, name):
if not isinstance(custom_fields, dict):
return custom_fields
validated = {}
errors = {}
for raw_field_id, value in custom_fields.items():
field_id = int(raw_field_id)
validator = CustomFieldInstanceSerializer(
data={"field": field_id, "value": value},
context=self.context,
)
if validator.is_valid():
validated[field_id] = validator.validated_data["value"]
else:
errors[str(field_id)] = validator.errors
if errors:
raise serializers.ValidationError({name: errors})
return validated
def validate_method(self, method):
if method == "set_correspondent":
return bulk_edit.set_correspondent
elif method == "set_document_type":
return bulk_edit.set_document_type
elif method == "set_storage_path":
return bulk_edit.set_storage_path
elif method == "add_tag":
return bulk_edit.add_tag
elif method == "remove_tag":
return bulk_edit.remove_tag
elif method == "modify_tags":
return bulk_edit.modify_tags
elif method == "modify_custom_fields":
return bulk_edit.modify_custom_fields
elif method == "delete":
return bulk_edit.delete
elif method == "redo_ocr" or method == "reprocess":
return bulk_edit.reprocess
elif method == "set_permissions":
return bulk_edit.set_permissions
elif method == "rotate":
return bulk_edit.rotate
elif method == "merge":
return bulk_edit.merge
elif method == "split":
return bulk_edit.split
elif method == "delete_pages":
return bulk_edit.delete_pages
elif method == "edit_pdf":
return bulk_edit.edit_pdf
elif method == "remove_password":
return bulk_edit.remove_password
else:
raise serializers.ValidationError("Unsupported method.")
def _validate_parameters_tags(self, parameters) -> None:
if "tag" in parameters:
tag_id = parameters["tag"]
try:
Tag.objects.get(id=tag_id)
except Tag.DoesNotExist:
raise serializers.ValidationError("Tag does not exist")
else:
raise serializers.ValidationError("tag not specified")
def _validate_parameters_document_type(self, parameters) -> None:
if "document_type" in parameters:
document_type_id = parameters["document_type"]
if document_type_id is None:
# None is ok
return
try:
DocumentType.objects.get(id=document_type_id)
except DocumentType.DoesNotExist:
raise serializers.ValidationError("Document type does not exist")
else:
raise serializers.ValidationError("document_type not specified")
def _validate_parameters_correspondent(self, parameters) -> None:
if "correspondent" in parameters:
correspondent_id = parameters["correspondent"]
if correspondent_id is None:
return
try:
Correspondent.objects.get(id=correspondent_id)
except Correspondent.DoesNotExist:
raise serializers.ValidationError("Correspondent does not exist")
else:
raise serializers.ValidationError("correspondent not specified")
def _validate_storage_path(self, parameters) -> None:
if "storage_path" in parameters:
storage_path_id = parameters["storage_path"]
if storage_path_id is None:
return
try:
StoragePath.objects.get(id=storage_path_id)
except StoragePath.DoesNotExist:
raise serializers.ValidationError(
"Storage path does not exist",
)
else:
raise serializers.ValidationError("storage path not specified")
def _validate_parameters_modify_tags(self, parameters) -> None:
if "add_tags" in parameters:
self._validate_tag_id_list(parameters["add_tags"], "add_tags")
else:
raise serializers.ValidationError("add_tags not specified")
if "remove_tags" in parameters:
self._validate_tag_id_list(parameters["remove_tags"], "remove_tags")
else:
raise serializers.ValidationError("remove_tags not specified")
def _validate_parameters_modify_custom_fields(self, parameters) -> None:
if "add_custom_fields" in parameters:
self._validate_custom_field_id_list_or_dict(
parameters["add_custom_fields"],
"add_custom_fields",
)
parameters["add_custom_fields"] = self._validate_custom_field_values(
parameters["add_custom_fields"],
"add_custom_fields",
)
else:
raise serializers.ValidationError("add_custom_fields not specified")
if "remove_custom_fields" in parameters:
self._validate_custom_field_id_list_or_dict(
parameters["remove_custom_fields"],
"remove_custom_fields",
)
else:
raise serializers.ValidationError("remove_custom_fields not specified")
def _validate_owner(self, owner) -> User:
owner_field = serializers.PrimaryKeyRelatedField(queryset=User.objects.all())
try:
return owner_field.run_validation(owner)
except serializers.ValidationError as e:
raise serializers.ValidationError(
"Specified owner cannot be found",
) from e
def _validate_parameters_set_permissions(self, parameters) -> None:
if "set_permissions" not in parameters:
raise serializers.ValidationError("set_permissions not specified")
set_permissions = parameters["set_permissions"]
if set_permissions is not None:
set_permissions = SetPermissionsSerializer().run_validation(
set_permissions,
)
parameters["set_permissions"] = self.validate_set_permissions(
set_permissions,
)
if "owner" in parameters and parameters["owner"] is not None:
parameters["owner"] = self._validate_owner(parameters["owner"]).pk
if "merge" not in parameters:
parameters["merge"] = False
def _validate_parameters_rotate(self, parameters) -> None:
if "degrees" not in parameters:
raise serializers.ValidationError("invalid rotation degrees")
try:
degrees = serializers.IntegerField().run_validation(parameters["degrees"])
except serializers.ValidationError as e:
raise serializers.ValidationError("invalid rotation degrees") from e
parameters["degrees"] = _validate_rotation_degrees(degrees)
def _validate_source_mode(self, parameters) -> None:
source_mode = parameters.get(
"source_mode",
bulk_edit.SourceModeChoices.LATEST_VERSION,
)
parameters["source_mode"] = self.validate_source_mode(source_mode)
def _validate_parameters_split(self, parameters, document_id) -> None:
if "pages" not in parameters:
raise serializers.ValidationError("pages not specified")
if not isinstance(parameters["pages"], str):
raise serializers.ValidationError("invalid pages specified")
page_count = Document.objects.get(id=document_id).page_count
if not page_count:
raise serializers.ValidationError("document page count is unknown")
pages = []
for group in parameters["pages"].split(","):
start, is_range, end = group.partition("-")
try:
first = int(start)
last = int(end) if is_range else first
except ValueError as e:
raise serializers.ValidationError("invalid pages specified") from e
# Bound the range before building it, a huge one would exhaust memory
if not 1 <= first <= last <= page_count:
raise serializers.ValidationError("invalid pages specified")
pages.append(list(range(first, last + 1)))
parameters["pages"] = pages
if "delete_originals" in parameters:
if not isinstance(parameters["delete_originals"], bool):
raise serializers.ValidationError("delete_originals must be a boolean")
else:
parameters["delete_originals"] = False
def _validate_parameters_delete_pages(self, parameters) -> None:
if "pages" not in parameters:
raise serializers.ValidationError("pages not specified")
if not isinstance(parameters["pages"], list):
raise serializers.ValidationError("pages must be a list")
if not all(isinstance(i, int) for i in parameters["pages"]):
raise serializers.ValidationError("pages must be a list of integers")
def _validate_parameters_merge(self, parameters) -> None:
if "delete_originals" in parameters:
if not isinstance(parameters["delete_originals"], bool):
raise serializers.ValidationError("delete_originals must be a boolean")
else:
parameters["delete_originals"] = False
if "archive_fallback" in parameters:
if not isinstance(parameters["archive_fallback"], bool):
raise serializers.ValidationError("archive_fallback must be a boolean")
else:
parameters["archive_fallback"] = False
def _validate_parameters_edit_pdf(self, parameters, document_id) -> None:
if "operations" not in parameters:
raise serializers.ValidationError("operations not specified")
operations_field = serializers.ListField(
child=PdfEditOperationSerializer(),
allow_empty=False,
)
try:
operations = operations_field.run_validation(parameters["operations"])
except serializers.ValidationError as e:
# Key the errors under "operations" so they match what the
# dedicated edit_pdf endpoint returns
raise serializers.ValidationError({"operations": e.detail}) from e
parameters["operations"] = operations
if "update_document" in parameters:
if not isinstance(parameters["update_document"], bool):
raise serializers.ValidationError("update_document must be a boolean")
else:
parameters["update_document"] = False
if "include_metadata" in parameters:
if not isinstance(parameters["include_metadata"], bool):
raise serializers.ValidationError("include_metadata must be a boolean")
else:
parameters["include_metadata"] = True
if any(op.get("doc", 0) >= len(operations) for op in operations):
raise serializers.ValidationError("doc index is out of bounds")
if parameters["update_document"]:
max_idx = max(op.get("doc", 0) for op in operations)
if max_idx > 0:
raise serializers.ValidationError(
"update_document only allowed with a single output document",
)
doc = Document.objects.get(id=document_id)
# doc existence is already validated
if doc.page_count:
for op in operations:
if op["page"] > doc.page_count:
raise serializers.ValidationError(
f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.",
)
def _validate_parameters_reprocess(self, parameters) -> None:
if "remote_ocr" in parameters:
if not isinstance(parameters["remote_ocr"], bool):
raise serializers.ValidationError("remote_ocr must be a boolean")
else:
parameters["remote_ocr"] = False
def validate_parameters_remove_password(self, parameters):
if "password" not in parameters:
raise serializers.ValidationError("password not specified")
if not isinstance(parameters["password"], str):
raise serializers.ValidationError("password must be a string")
def validate(self, attrs):
attrs = super().validate(attrs)
if attrs.get("all", False) and attrs["method"] in [
bulk_edit.merge,
bulk_edit.split,
bulk_edit.delete_pages,
bulk_edit.edit_pdf,
bulk_edit.remove_password,
]:
raise serializers.ValidationError(
"This method does not support all=true.",
)
method = attrs["method"]
parameters = attrs["parameters"]
if "source_mode" in parameters:
self._validate_source_mode(parameters)
if method == bulk_edit.set_correspondent:
self._validate_parameters_correspondent(parameters)
elif method == bulk_edit.set_document_type:
self._validate_parameters_document_type(parameters)
elif method == bulk_edit.add_tag or method == bulk_edit.remove_tag:
self._validate_parameters_tags(parameters)
elif method == bulk_edit.modify_tags:
self._validate_parameters_modify_tags(parameters)
elif method == bulk_edit.set_storage_path:
self._validate_storage_path(parameters)
elif method == bulk_edit.modify_custom_fields:
self._validate_parameters_modify_custom_fields(parameters)
elif method == bulk_edit.set_permissions:
self._validate_parameters_set_permissions(parameters)
elif method == bulk_edit.rotate:
self._validate_parameters_rotate(parameters)
elif method == bulk_edit.split:
if len(attrs["documents"]) > 1:
raise serializers.ValidationError(
"Split method only supports one document",
)
self._validate_parameters_split(parameters, attrs["documents"][0])
elif method == bulk_edit.delete_pages:
if len(attrs["documents"]) > 1:
raise serializers.ValidationError(
"Delete pages method only supports one document",
)
self._validate_parameters_delete_pages(parameters)
elif method == bulk_edit.merge:
self._validate_parameters_merge(parameters)
elif method == bulk_edit.edit_pdf:
if len(attrs["documents"]) > 1:
raise serializers.ValidationError(
"Edit PDF method only supports one document",
)
self._validate_parameters_edit_pdf(parameters, attrs["documents"][0])
elif method == bulk_edit.remove_password:
self.validate_parameters_remove_password(parameters)
elif method == bulk_edit.reprocess:
self._validate_parameters_reprocess(parameters)
return attrs
class BulkDownloadSerializer(DocumentSelectionSerializer):
content = serializers.ChoiceField(
choices=["archive", "originals", "both"],
default="archive",
)
compression = serializers.ChoiceField(
choices=["none", "deflated", "bzip2", "lzma"],
default="none",
)
follow_formatting = serializers.BooleanField(
default=False,
)
def validate_compression(self, compression):
import zipfile
return {
"none": zipfile.ZIP_STORED,
"deflated": zipfile.ZIP_DEFLATED,
"bzip2": zipfile.ZIP_BZIP2,
"lzma": zipfile.ZIP_LZMA,
}[compression]
class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
objects = serializers.ListField(
required=False,
allow_empty=True,
label="Objects",
write_only=True,
child=serializers.IntegerField(),
)
all = serializers.BooleanField(
default=False,
required=False,
write_only=True,
)
filters = serializers.DictField(
required=False,
allow_empty=True,
write_only=True,
)
object_type = serializers.ChoiceField(
choices=[
"tags",
"correspondents",
"document_types",
"storage_paths",
],
label="Object Type",
write_only=True,
)
operation = serializers.ChoiceField(
choices=[
"set_permissions",
"delete",
],
label="Operation",
required=True,
write_only=True,
)
owner = serializers.PrimaryKeyRelatedField(
queryset=User.objects.all(),
required=False,
allow_null=True,
)
permissions = SetPermissionsSerializer(
label="Set permissions",
required=False,
write_only=True,
)
merge = serializers.BooleanField(
default=False,
write_only=True,
required=False,
)
def get_object_class(self, object_type):
object_class = None
if object_type == "tags":
object_class = Tag
elif object_type == "correspondents":
object_class = Correspondent
elif object_type == "document_types":
object_class = DocumentType
elif object_type == "storage_paths":
object_class = StoragePath
return object_class
def _validate_objects(self, objects, object_type):
if not isinstance(objects, list):
raise serializers.ValidationError("objects must be a list")
if not all(isinstance(i, int) for i in objects):
raise serializers.ValidationError("objects must be a list of integers")
object_class = self.get_object_class(object_type)
count = object_class.objects.filter(id__in=objects).count()
if not count == len(objects):
raise serializers.ValidationError(
"Some ids in objects don't exist or were specified twice.",
)
return objects
def _validate_permissions(self, permissions) -> dict:
return self.validate_set_permissions(
permissions,
)
def validate(self, attrs):
object_type = attrs["object_type"]
objects = attrs.get("objects")
apply_to_all = attrs.get("all", False)
operation = attrs.get("operation")
if apply_to_all:
attrs.setdefault("objects", [])
else:
if objects is None:
raise serializers.ValidationError(
"objects is required unless all is true.",
)
if len(objects) == 0:
raise serializers.ValidationError("objects must not be empty")
self._validate_objects(objects, object_type)
if operation == "set_permissions":
permissions = attrs.get("permissions")
if permissions is not None:
if not permissions:
raise serializers.ValidationError(
"permissions must not be empty",
)
attrs["permissions"] = self._validate_permissions(permissions)
return attrs
-484
View File
@@ -1,484 +0,0 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from typing import Any
from typing import TypedDict
from django.conf import settings
from django.contrib.auth.models import User
from django.db.models import Q
from django.utils.dateparse import parse_datetime
from django.utils.timezone import get_current_timezone
from django.utils.timezone import is_naive
from django.utils.timezone import make_aware
from drf_spectacular.utils import extend_schema_field
from drf_spectacular.utils import extend_schema_serializer
from drf_writable_nested.serializers import NestedUpdateMixin
from rest_framework import serializers
from rest_framework.fields import SerializerMethodField
if settings.AUDIT_LOG_ENABLED:
from auditlog.context import set_actor
from documents import bulk_edit
from documents.models import CustomField
from documents.models import CustomFieldInstance
from documents.models import Document
from documents.models import Tag
from documents.permissions import permitted_document_ids
from documents.versioning import has_prefetched_effective_content
from documents.versioning import sort_versions_newest_first
from .base import DocumentUpdateFieldsModelSerializer
from .base import NotesSerializer
from .base import OwnedObjectListSerializer
from .base import OwnedObjectSerializer
from .metadata import CorrespondentField
from .metadata import CustomFieldInstanceSerializer
from .metadata import DocumentTypeField
from .metadata import StoragePathField
from .metadata import TagsField
from .upload import PostDocumentSerializer
if TYPE_CHECKING:
from datetime import datetime
from django.db.models.query import QuerySet
from rest_framework.relations import ManyRelatedField
from rest_framework.relations import RelatedField
logger = logging.getLogger("paperless.serializers")
def _get_viewable_duplicates(
document: Document,
user: User | None,
) -> QuerySet[Document]:
checksums = {document.checksum}
if document.archive_checksum:
checksums.add(document.archive_checksum)
duplicates = Document.global_objects.filter(
Q(checksum__in=checksums) | Q(archive_checksum__in=checksums),
).exclude(pk=document.pk)
duplicates = duplicates.filter(root_document__isnull=True)
duplicates = duplicates.order_by("-created")
allowed_ids = permitted_document_ids(user, include_deleted=True)
return duplicates.filter(id__in=allowed_ids)
class DuplicateDocumentSummarySerializer(serializers.Serializer[dict[str, Any]]):
id = serializers.IntegerField()
title = serializers.CharField()
deleted_at = serializers.DateTimeField(allow_null=True)
class _DocumentVersionInfo(TypedDict):
id: int
added: datetime
version_label: str | None
checksum: str | None
is_root: bool
class DocumentVersionInfoSerializer(serializers.Serializer[_DocumentVersionInfo]):
id = serializers.IntegerField()
added = serializers.DateTimeField()
version_label = serializers.CharField(required=False, allow_null=True)
checksum = serializers.CharField(required=False, allow_null=True)
is_root = serializers.BooleanField()
@extend_schema_serializer(
deprecate_fields=["created_date"],
)
class DocumentSerializer(
OwnedObjectSerializer,
NestedUpdateMixin,
DocumentUpdateFieldsModelSerializer,
):
correspondent = CorrespondentField(allow_null=True)
tags = TagsField(many=True)
document_type = DocumentTypeField(allow_null=True)
storage_path = StoragePathField(allow_null=True)
original_file_name = SerializerMethodField()
archived_file_name = SerializerMethodField()
created_date = serializers.DateField(required=False)
page_count = SerializerMethodField()
duplicate_documents = SerializerMethodField()
notes = NotesSerializer(many=True, required=False, read_only=True)
root_document: RelatedField[Document, Document, Any] | ManyRelatedField = (
serializers.PrimaryKeyRelatedField(read_only=True)
)
versions = SerializerMethodField()
custom_fields = CustomFieldInstanceSerializer(
many=True,
allow_null=False,
required=False,
)
owner = serializers.PrimaryKeyRelatedField(
queryset=User.objects.all(),
required=False,
allow_null=True,
)
remove_inbox_tags = serializers.BooleanField(
default=False,
write_only=True,
allow_null=True,
required=False,
)
def get_page_count(self, obj) -> int | None:
# Like content versions get their own page count from the newest version,
# use the prefetched versions cache to avoid an extra query
prefetched_cache = getattr(obj, "_prefetched_objects_cache", None)
prefetched_versions = (
prefetched_cache.get("versions")
if isinstance(prefetched_cache, dict)
else None
)
if obj.root_document_id is None and prefetched_versions:
return sort_versions_newest_first(prefetched_versions)[0].page_count
return obj.page_count
@extend_schema_field(DuplicateDocumentSummarySerializer(many=True))
def get_duplicate_documents(self, obj):
view = self.context.get("view")
if view and getattr(view, "action", None) != "retrieve":
return []
request = self.context.get("request")
user = request.user if request else None
duplicates = _get_viewable_duplicates(obj, user)
return list(duplicates.values("id", "title", "deleted_at"))
@extend_schema_field(DocumentVersionInfoSerializer(many=True))
def get_versions(self, obj):
root_doc = obj if obj.root_document_id is None else obj.root_document
if root_doc is None:
return []
prefetched_cache = getattr(obj, "_prefetched_objects_cache", None)
prefetched_versions = (
prefetched_cache.get("versions")
if isinstance(prefetched_cache, dict)
else None
)
versions: list[Document]
if prefetched_versions is not None:
versions = [*prefetched_versions, root_doc]
else:
versions_qs = Document.objects.filter(root_document=root_doc).only(
"id",
"added",
"checksum",
"version_label",
"root_document_id",
"version_index",
)
versions = [*versions_qs, root_doc]
versions = sort_versions_newest_first(versions)
def build_info(doc: Document) -> _DocumentVersionInfo:
return {
"id": doc.id,
"added": doc.added,
"version_label": doc.version_label,
"checksum": doc.checksum,
"is_root": doc.id == root_doc.id,
}
return [build_info(doc) for doc in versions]
def get_original_file_name(self, obj) -> str | None:
return obj.original_filename
def get_archived_file_name(self, obj) -> str | None:
if obj.has_archive_version:
return obj.get_public_filename(archive=True)
else:
return None
def to_representation(self, instance):
doc = super().to_representation(instance)
if "content" in self.fields and has_prefetched_effective_content(instance):
# Only resolve version-aware content when it's cheap: an SQL
# annotation or a versions prefetch is already on the instance.
# A caller that set up neither (e.g. TrashView, GlobalSearchView,
# which build their own querysets) gets the document's own,
# unresolved content instead of paying for an extra per-instance
# query -- same as before effective_content resolution existed.
doc["content"] = instance.get_effective_content() or ""
if self.truncate_content and "content" in self.fields:
doc["content"] = doc.get("content")[0:550]
return doc
def to_internal_value(self, data):
if (
"created" in data
and isinstance(data["created"], str)
and ":" in data["created"]
):
# Handle old format of isoformat datetime string
parsed = parse_datetime(data["created"])
if parsed:
if is_naive(parsed):
parsed = make_aware(parsed, get_current_timezone())
data["created"] = parsed.astimezone().date()
return super().to_internal_value(data)
def validate(self, attrs):
if (
"archive_serial_number" in attrs
and attrs["archive_serial_number"] is not None
and len(str(attrs["archive_serial_number"])) > 0
and Document.deleted_objects.filter(
archive_serial_number=attrs["archive_serial_number"],
).exists()
):
raise serializers.ValidationError(
{
"archive_serial_number": [
"Document with this Archive Serial Number already exists in the trash.",
],
},
)
return super().validate(attrs)
def update(self, instance: Document, validated_data):
if "created_date" in validated_data:
if "created" not in validated_data:
validated_data["created"] = validated_data["created_date"]
logger.warning(
"created_date is deprecated, use created instead",
)
validated_data.pop("created_date")
if instance.custom_fields.count() > 0 and "custom_fields" in validated_data:
incoming_custom_fields = [
field["field"] for field in validated_data["custom_fields"]
]
for custom_field_instance in instance.custom_fields.filter(
field__data_type=CustomField.FieldDataType.DOCUMENTLINK,
):
if (
custom_field_instance.field not in incoming_custom_fields
and custom_field_instance.value is not None
):
# Doc link field is being removed entirely
for doc_id in custom_field_instance.value:
bulk_edit.remove_doclink(
instance,
custom_field_instance.field,
doc_id,
)
if "tags" in validated_data:
# Respect tag hierarchy on updates:
# - Adding a child adds its ancestors
# - Removing a parent removes all its descendants
prev_tags = set(instance.tags.all())
requested_tags = set(validated_data["tags"])
# Tags newly added in this update and the ancestors they require
added_tags = requested_tags - prev_tags
required_by_add_tags = set(added_tags)
for t in added_tags:
required_by_add_tags.update(t.get_ancestors())
# Tags being removed in this update and all descendants, except
# those required by a tag that is being added in this same update
removed_tags = prev_tags - requested_tags
blocked_tags = set(removed_tags)
for t in removed_tags:
blocked_tags.update(t.get_descendants())
blocked_tags.difference_update(required_by_add_tags)
# Add all parent tags
final_tags = set(requested_tags)
for t in requested_tags:
final_tags.update(t.get_ancestors())
# Drop removed parents and their descendants
final_tags.difference_update(blocked_tags)
validated_data["tags"] = list(final_tags)
if validated_data.get("remove_inbox_tags"):
current_tag_ids = {t.pk for t in instance.tags.all()}
tags = (
validated_data["tags"]
if "tags" in validated_data
else list(instance.tags.all())
)
# Tags newly added in this update, plus their ancestors, are kept
keep_ids: set[int] = set()
for tag in tags:
if tag.pk not in current_tag_ids:
keep_ids.add(tag.pk)
keep_ids.update(int(pk) for pk in tag.get_ancestors_pks())
# Remove inbox tags and their descendants, except those being kept
remove_ids: set[int] = set()
for inbox_tag in (
Tag.objects.filter(is_inbox_tag=True)
.exclude(pk__in=keep_ids)
.only("pk", "tn_descendants_pks")
):
remove_ids.add(inbox_tag.pk)
remove_ids.update(int(pk) for pk in inbox_tag.get_descendants_pks())
validated_data["tags"] = [t for t in tags if t.pk not in remove_ids]
if settings.AUDIT_LOG_ENABLED:
with set_actor(self.user):
super().update(instance, validated_data)
else:
super().update(instance, validated_data)
# hard delete custom field instances that were soft deleted
CustomFieldInstance.deleted_objects.filter(document=instance).delete()
return instance
def __init__(self, *args, **kwargs) -> None:
self.truncate_content = kwargs.pop("truncate_content", False)
# return full permissions if we're doing a PATCH or PUT
context = kwargs.get("context")
if context is not None and (
context.get("request").method == "PATCH"
or context.get("request").method == "PUT"
):
kwargs["full_perms"] = True
super().__init__(*args, **kwargs)
class Meta:
model = Document
fields = (
"id",
"correspondent",
"document_type",
"storage_path",
"title",
"content",
"tags",
"created",
"created_date",
"modified",
"added",
"deleted_at",
"archive_serial_number",
"original_file_name",
"archived_file_name",
"duplicate_documents",
"owner",
"permissions",
"user_can_change",
"is_shared_by_requester",
"set_permissions",
"notes",
"custom_fields",
"remove_inbox_tags",
"page_count",
"mime_type",
"root_document",
"versions",
)
read_only_fields = ("deleted_at",)
list_serializer_class = OwnedObjectListSerializer
class SearchResultListSerializer(serializers.ListSerializer[Document]):
def to_representation(self, hits):
document_ids = [hit["id"] for hit in hits]
# Fetch all Document objects in the list in one SQL query.
documents = self.child.fetch_documents(document_ids)
self.child.context["documents"] = documents
# Also check if they are shared with other users / groups.
self.child.context["shared_object_pks"] = self.child.get_shared_object_pks(
documents.values(),
)
return super().to_representation(hits)
class SearchResultSerializer(DocumentSerializer):
@staticmethod
def fetch_documents(ids):
"""
Return a dict that maps given document IDs to Document objects.
"""
return {
document.id: document
for document in Document.objects.select_related(
"correspondent",
"storage_path",
"document_type",
"owner",
)
.prefetch_related("tags", "custom_fields", "notes")
.filter(id__in=ids)
}
def to_representation(self, hit):
# Again we first check if the parent has already fetched the documents.
documents = self.context.get("documents")
# Otherwise we fetch this document.
if documents is None: # pragma: no cover
# In practice we only serialize **lists** of SearchHit dicts.
# Keeping this check for completeness but marking it no cover for now.
documents = self.fetch_documents([hit["id"]])
document = documents[hit["id"]]
highlights = hit.get("highlights", {})
r = super().to_representation(document)
r["__search_hit__"] = {
"score": hit["score"],
"highlights": highlights.get("content", ""),
"note_highlights": highlights.get("notes") or None,
"rank": hit["rank"],
}
return r
class Meta(DocumentSerializer.Meta):
list_serializer_class = SearchResultListSerializer
class DocumentVersionSerializer(serializers.Serializer[dict[str, Any]]):
document = serializers.FileField(
label="Document",
write_only=True,
)
version_label = serializers.CharField(
label="Version label",
required=False,
allow_blank=True,
allow_null=True,
max_length=64,
)
validate_document = PostDocumentSerializer().validate_document
class DocumentVersionLabelSerializer(serializers.Serializer[dict[str, str | None]]):
version_label = serializers.CharField(
label="Version label",
required=True,
allow_blank=True,
allow_null=True,
max_length=64,
)
def validate_version_label(self, value):
if value is None:
return None
normalized = value.strip()
return normalized or None
-557
View File
@@ -1,557 +0,0 @@
from __future__ import annotations
import logging
import math
import re
from decimal import Decimal
from typing import Any
from django.core.exceptions import ValidationError
from django.core.validators import DecimalValidator
from django.core.validators import MaxLengthValidator
from django.core.validators import MaxValueValidator
from django.core.validators import MinValueValidator
from django.core.validators import RegexValidator
from django.core.validators import integer_validator
from django.db.models import Count
from django.db.models.functions import Lower
from django.utils.crypto import get_random_string
from django.utils.translation import gettext as _
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from rest_framework.filters import OrderingFilter
from documents import bulk_edit
from documents.models import Correspondent
from documents.models import CustomField
from documents.models import CustomFieldInstance
from documents.models import Document
from documents.models import DocumentType
from documents.models import PaperlessTask
from documents.models import StoragePath
from documents.models import Tag
from documents.permissions import get_document_count_filter_for_user
from documents.permissions import permitted_document_ids
from documents.permissions import restrict_queryset_to_visible
from documents.templating.filepath import validate_filepath_template_and_render
from documents.templating.utils import convert_format_str_to_template_format
from documents.validators import uri_validator
from .base import MatchingModelSerializer
from .base import OwnedObjectSerializer
from .base import ReadWriteSerializerMethodField
from .base import SerializerWithPerms
logger = logging.getLogger("paperless.serializers")
class CorrespondentSerializer(MatchingModelSerializer, OwnedObjectSerializer):
last_correspondence = serializers.DateField(read_only=True, required=False)
class Meta:
model = Correspondent
fields = (
"id",
"slug",
"name",
"match",
"matching_algorithm",
"is_insensitive",
"document_count",
"last_correspondence",
"owner",
"permissions",
"user_can_change",
"set_permissions",
)
class DocumentTypeSerializer(MatchingModelSerializer, OwnedObjectSerializer):
class Meta:
model = DocumentType
fields = (
"id",
"slug",
"name",
"match",
"matching_algorithm",
"is_insensitive",
"document_count",
"owner",
"permissions",
"user_can_change",
"set_permissions",
)
class DeprecatedColors:
COLOURS = (
(1, "#a6cee3"),
(2, "#1f78b4"),
(3, "#b2df8a"),
(4, "#33a02c"),
(5, "#fb9a99"),
(6, "#e31a1c"),
(7, "#fdbf6f"),
(8, "#ff7f00"),
(9, "#cab2d6"),
(10, "#6a3d9a"),
(11, "#b15928"),
(12, "#000000"),
(13, "#cccccc"),
)
@extend_schema_field(
serializers.ChoiceField(
choices=DeprecatedColors.COLOURS,
),
)
class ColorField(serializers.Field):
def to_internal_value(self, data):
for id, color in DeprecatedColors.COLOURS:
if id == data:
return color
raise serializers.ValidationError
def to_representation(self, value):
for id, color in DeprecatedColors.COLOURS:
if color == value:
return id
return 1
class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
def get_text_color(self, obj) -> str:
try:
h = obj.color.lstrip("#")
rgb = tuple(int(h[i : i + 2], 16) / 256 for i in (0, 2, 4))
luminance = math.sqrt(
0.299 * math.pow(rgb[0], 2)
+ 0.587 * math.pow(rgb[1], 2)
+ 0.114 * math.pow(rgb[2], 2),
)
return "#ffffff" if luminance < 0.53 else "#000000"
except ValueError:
return "#000000"
text_color = serializers.SerializerMethodField()
# map to treenode's tn_parent
parent = serializers.PrimaryKeyRelatedField(
queryset=Tag.objects.all(),
allow_null=True,
required=False,
source="tn_parent",
)
@extend_schema_field(
field=serializers.ListSerializer(
child=serializers.PrimaryKeyRelatedField(
queryset=Tag.objects.all(),
),
),
)
def get_children(self, obj):
children_map = self.context.get("children_map")
if children_map is not None:
children = children_map.get(obj.pk, [])
else:
filter_q = self.context.get("document_count_filter")
request = self.context.get("request")
if filter_q is None:
user = getattr(request, "user", None) if request else None
filter_q = get_document_count_filter_for_user(user)
self.context["document_count_filter"] = filter_q
children = (
obj.get_children_queryset()
.select_related("owner")
.annotate(document_count=Count("documents", filter=filter_q))
)
user = getattr(request, "user", None) if request else self.user
children = restrict_queryset_to_visible(children, user, "view_tag")
view = self.context.get("view")
ordering = (
OrderingFilter().get_ordering(request, children, view)
if request and view
else None
)
ordering = ordering or (Lower("name"),)
children = children.order_by(*ordering)
if not children:
return []
serializer = TagSerializer(
children,
many=True,
user=self.user,
full_perms=self.full_perms,
all_fields=self.all_fields,
context=self.context,
)
return serializer.data
# children as nested Tag objects
children = serializers.SerializerMethodField()
class Meta:
model = Tag
fields = (
"id",
"slug",
"name",
"color",
"text_color",
"match",
"matching_algorithm",
"is_insensitive",
"is_inbox_tag",
"document_count",
"owner",
"permissions",
"user_can_change",
"set_permissions",
"parent",
"children",
)
def validate_color(self, color):
regex = r"#[0-9a-fA-F]{6}"
if not re.match(regex, color):
raise serializers.ValidationError(_("Invalid color."))
return color
def validate(self, attrs):
# Validate when changing parent
parent = attrs.get(
"tn_parent",
self.instance.get_parent() if self.instance else None,
)
if self.instance:
# Temporarily set parent on the instance if updating and use model clean()
original_parent = self.instance.get_parent()
try:
# Temporarily set tn_parent in-memory to validate clean()
self.instance.tn_parent = parent
self.instance.clean()
except ValidationError as e:
logger.debug("Tag parent validation failed: %s", e)
raise e
finally:
self.instance.tn_parent = original_parent
else:
# For new instances, create a transient Tag and validate
temp = Tag(tn_parent=parent)
try:
temp.clean()
except ValidationError as e:
logger.debug("Tag parent validation failed: %s", e)
raise e
return super().validate(attrs)
class CorrespondentField(serializers.PrimaryKeyRelatedField[Correspondent]):
def get_queryset(self):
return Correspondent.objects.all()
class TagsField(serializers.PrimaryKeyRelatedField[Tag]):
def get_queryset(self):
return Tag.objects.all()
class DocumentTypeField(serializers.PrimaryKeyRelatedField[DocumentType]):
def get_queryset(self):
return DocumentType.objects.all()
class StoragePathField(serializers.PrimaryKeyRelatedField[StoragePath]):
def get_queryset(self):
return StoragePath.objects.all()
class CustomFieldSerializer(serializers.ModelSerializer[CustomField]):
data_type = serializers.ChoiceField(
choices=CustomField.FieldDataType,
read_only=False,
)
document_count = serializers.IntegerField(read_only=True)
class Meta:
model = CustomField
fields = [
"id",
"name",
"data_type",
"extra_data",
"document_count",
]
def validate(self, attrs):
# TODO: remove pending https://github.com/encode/django-rest-framework/issues/7173
name = attrs.get(
"name",
self.instance.name if hasattr(self.instance, "name") else None,
)
objects = (
self.Meta.model.objects.exclude(
pk=self.instance.pk,
)
if self.instance is not None
else self.Meta.model.objects.all()
)
if ("name" in attrs) and objects.filter(
name=name,
).exists():
raise serializers.ValidationError(
{"error": "Object violates name unique constraint"},
)
if (
"data_type" in attrs
and attrs["data_type"] == CustomField.FieldDataType.SELECT
) or (
self.instance
and self.instance.data_type == CustomField.FieldDataType.SELECT
):
if (
"extra_data" not in attrs
or "select_options" not in attrs["extra_data"]
or not isinstance(attrs["extra_data"]["select_options"], list)
or len(attrs["extra_data"]["select_options"]) == 0
or not all(
len(option.get("label", "")) > 0
for option in attrs["extra_data"]["select_options"]
)
):
raise serializers.ValidationError(
{"error": "extra_data.select_options must be a valid list"},
)
# labels are valid, generate ids if not present
for option in attrs["extra_data"]["select_options"]:
if option.get("id") is None:
option["id"] = get_random_string(length=16)
elif (
"data_type" in attrs
and attrs["data_type"] == CustomField.FieldDataType.MONETARY
and "extra_data" in attrs
and "default_currency" in attrs["extra_data"]
and attrs["extra_data"]["default_currency"] is not None
and (
not isinstance(attrs["extra_data"]["default_currency"], str)
or (
len(attrs["extra_data"]["default_currency"]) > 0
and len(attrs["extra_data"]["default_currency"]) != 3
)
)
):
raise serializers.ValidationError(
{"error": "extra_data.default_currency must be a 3-character string"},
)
return super().validate(attrs)
def validate_documentlink_targets(user, doc_ids):
if Document.objects.filter(id__in=doc_ids).count() != len(doc_ids):
raise serializers.ValidationError(
"Some documents in value don't exist or were specified twice.",
)
if user is None:
return
if (
Document.objects.filter(id__in=doc_ids)
.exclude(id__in=permitted_document_ids(user, perm="change_document"))
.exists()
):
raise PermissionDenied(
_("Insufficient permissions."),
)
class CustomFieldInstanceSerializer(serializers.ModelSerializer[CustomFieldInstance]):
field = serializers.PrimaryKeyRelatedField(queryset=CustomField.objects.all())
value = ReadWriteSerializerMethodField(allow_null=True)
def create(self, validated_data):
# An instance is attached to a document
document: Document = validated_data["document"]
# And to a CustomField
custom_field: CustomField = validated_data["field"]
# This key must exist, as it is validated
data_store_name = CustomFieldInstance.get_value_field_name(
custom_field.data_type,
)
if custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
# prior to update so we can look for any docs that are going to be removed
bulk_edit.reflect_doclinks(document, custom_field, validated_data["value"])
# Actually update or create the instance, providing the value
# to fill in the correct attribute based on the type
instance, _ = CustomFieldInstance.objects.update_or_create(
document=document,
field=custom_field,
defaults={data_store_name: validated_data["value"]},
)
return instance
def get_value(self, obj: CustomFieldInstance) -> str | int | float | dict | None:
return obj.value
def validate(self, data):
"""
Probably because we're kind of doing it odd, validation from the model
doesn't run against the field "value", so we have to re-create it here.
Don't like it, but it is better than returning an HTTP 500 when the database
hates the value
"""
data = super().validate(data)
field: CustomField = data["field"]
if "value" in data and data["value"] is not None:
if (
field.data_type == CustomField.FieldDataType.URL
and len(data["value"]) > 0
):
uri_validator(data["value"])
elif field.data_type == CustomField.FieldDataType.INT:
integer_validator(data["value"])
try:
value_int = int(data["value"])
except (TypeError, ValueError):
raise serializers.ValidationError("Enter a valid integer.")
# Keep values within the PostgreSQL integer range
MinValueValidator(-2147483648)(value_int)
MaxValueValidator(2147483647)(value_int)
elif (
field.data_type == CustomField.FieldDataType.MONETARY
and data["value"] != ""
):
try:
# First try to validate as a number from legacy format
DecimalValidator(max_digits=12, decimal_places=2)(
Decimal(str(data["value"])),
)
except Exception:
# If that fails, try to validate as a monetary string
RegexValidator(
regex=r"^[A-Z]{3}-?\d+(\.\d{1,2})$",
message="Must be a two-decimal number with optional currency code e.g. GBP123.45",
)(data["value"])
elif field.data_type == CustomField.FieldDataType.STRING:
MaxLengthValidator(limit_value=128)(data["value"])
elif field.data_type == CustomField.FieldDataType.SELECT:
select_options = field.extra_data["select_options"]
try:
next(
option
for option in select_options
if option["id"] == data["value"]
)
except Exception:
raise serializers.ValidationError(
f"Value must be an id of an element in {select_options}",
)
elif field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
if not (isinstance(data["value"], list) or data["value"] is None):
raise serializers.ValidationError(
"Value must be a list",
)
doc_ids = data["value"]
request = self.context.get("request")
validate_documentlink_targets(
getattr(request, "user", None) if request is not None else None,
doc_ids,
)
elif field.data_type == CustomField.FieldDataType.DATE:
data["value"] = serializers.DateField().to_internal_value(data["value"])
return data
class Meta:
model = CustomFieldInstance
fields = [
"value",
"field",
]
class StoragePathSerializer(MatchingModelSerializer, OwnedObjectSerializer):
class Meta:
model = StoragePath
fields = (
"id",
"slug",
"name",
"path",
"match",
"matching_algorithm",
"is_insensitive",
"document_count",
"owner",
"permissions",
"user_can_change",
"set_permissions",
)
def validate_path(self, path: str):
converted_path = convert_format_str_to_template_format(path)
if converted_path != path:
logger.warning(
f"Storage path {path} is not using the new style format, consider updating",
)
result = validate_filepath_template_and_render(converted_path)
if result is None:
raise serializers.ValidationError(_("Invalid variable detected."))
return converted_path
def update(self, instance, validated_data):
"""
When a storage path is updated, see if documents
using it require a rename/move
"""
doc_ids = [doc.id for doc in instance.documents.all()]
if doc_ids:
bulk_edit.bulk_update_documents.apply_async(
kwargs={"document_ids": doc_ids},
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
)
return super().update(instance, validated_data)
class StoragePathTestSerializer(SerializerWithPerms):
path = serializers.CharField(
required=True,
label="Path",
write_only=True,
)
document = serializers.PrimaryKeyRelatedField(
queryset=Document.objects.none(),
required=True,
label="Document",
write_only=True,
)
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
request = self.context.get("request")
user = getattr(request, "user", None) if request else None
if user is not None and user.is_authenticated:
document_field = self.fields.get("document")
if not isinstance(document_field, serializers.PrimaryKeyRelatedField):
return
document_field.queryset = Document.objects.filter(
id__in=permitted_document_ids(user),
)
-234
View File
@@ -1,234 +0,0 @@
from __future__ import annotations
import logging
import re
from django.conf import settings
from rest_framework import serializers
from documents.models import CustomField
from documents.models import SavedView
from documents.models import SavedViewFilterRule
from documents.models import UiSettings
from .base import OwnedObjectSerializer
logger = logging.getLogger("paperless.serializers")
class SavedViewFilterRuleSerializer(serializers.ModelSerializer[SavedViewFilterRule]):
class Meta:
model = SavedViewFilterRule
fields = ["rule_type", "value"]
class SavedViewSerializer(OwnedObjectSerializer):
filter_rules = SavedViewFilterRuleSerializer(many=True)
class Meta:
model = SavedView
fields = [
"id",
"name",
"icon",
"sort_field",
"sort_reverse",
"filter_rules",
"page_size",
"display_mode",
"display_fields",
"owner",
"permissions",
"user_can_change",
"set_permissions",
]
def _get_api_version(self) -> int:
request = self.context.get("request")
return int(
request.version if request else settings.REST_FRAMEWORK["DEFAULT_VERSION"],
)
def _update_legacy_visibility_preferences(
self,
saved_view_id: int,
*,
show_on_dashboard: bool | None,
show_in_sidebar: bool | None,
) -> UiSettings | None:
if show_on_dashboard is None and show_in_sidebar is None:
return None
request = self.context.get("request")
user = request.user if request else self.user
if user is None:
return None
ui_settings, _ = UiSettings.objects.get_or_create(
user=user,
defaults={"settings": {}},
)
current_settings = (
ui_settings.settings if isinstance(ui_settings.settings, dict) else {}
)
current_settings = dict(current_settings)
saved_views_settings = current_settings.get("saved_views")
if isinstance(saved_views_settings, dict):
saved_views_settings = dict(saved_views_settings)
else:
saved_views_settings = {}
dashboard_ids = {
int(raw_id)
for raw_id in saved_views_settings.get("dashboard_views_visible_ids", [])
if str(raw_id).isdigit()
}
sidebar_ids = {
int(raw_id)
for raw_id in saved_views_settings.get("sidebar_views_visible_ids", [])
if str(raw_id).isdigit()
}
if show_on_dashboard is not None:
if show_on_dashboard:
dashboard_ids.add(saved_view_id)
else:
dashboard_ids.discard(saved_view_id)
if show_in_sidebar is not None:
if show_in_sidebar:
sidebar_ids.add(saved_view_id)
else:
sidebar_ids.discard(saved_view_id)
saved_views_settings["dashboard_views_visible_ids"] = sorted(dashboard_ids)
saved_views_settings["sidebar_views_visible_ids"] = sorted(sidebar_ids)
current_settings["saved_views"] = saved_views_settings
ui_settings.settings = current_settings
ui_settings.save(update_fields=["settings"])
return ui_settings
def to_representation(self, instance):
# TODO: remove this and related backwards compatibility code when API v9 is dropped
ret = super().to_representation(instance)
request = self.context.get("request")
api_version = self._get_api_version()
if api_version < 10:
dashboard_ids = set()
sidebar_ids = set()
user = request.user if request else None
if user is not None and hasattr(user, "ui_settings"):
ui_settings = user.ui_settings.settings or None
saved_views = None
if isinstance(ui_settings, dict):
saved_views = ui_settings.get("saved_views", {})
if isinstance(saved_views, dict):
dashboard_ids = set(
saved_views.get("dashboard_views_visible_ids", []),
)
sidebar_ids = set(
saved_views.get("sidebar_views_visible_ids", []),
)
ret["show_on_dashboard"] = instance.id in dashboard_ids
ret["show_in_sidebar"] = instance.id in sidebar_ids
return ret
def to_internal_value(self, data):
# TODO: remove this and related backwards compatibility code when API v9 is dropped
api_version = self._get_api_version()
if api_version >= 10:
return super().to_internal_value(data)
normalized_data = data.copy()
legacy_visibility_fields = {}
boolean_field = serializers.BooleanField()
for field_name in ("show_on_dashboard", "show_in_sidebar"):
if field_name in normalized_data:
try:
legacy_visibility_fields[field_name] = (
boolean_field.to_internal_value(
normalized_data.get(field_name),
)
)
except serializers.ValidationError as exc:
raise serializers.ValidationError({field_name: exc.detail})
del normalized_data[field_name]
ret = super().to_internal_value(normalized_data)
ret.update(legacy_visibility_fields)
return ret
def validate(self, attrs):
attrs = super().validate(attrs)
if "display_fields" in attrs and attrs["display_fields"] is not None:
for field in attrs["display_fields"]:
if (
SavedView.DisplayFields.CUSTOM_FIELD[:-2] in field
): # i.e. check for 'custom_field_' prefix
field_id = int(re.search(r"\d+", field)[0])
if not CustomField.objects.filter(id=field_id).exists():
raise serializers.ValidationError(
f"Invalid field: {field}",
)
elif field not in SavedView.DisplayFields.values:
raise serializers.ValidationError(
f"Invalid field: {field}",
)
return attrs
def update(self, instance, validated_data):
request = self.context.get("request")
show_on_dashboard = validated_data.pop("show_on_dashboard", None)
show_in_sidebar = validated_data.pop("show_in_sidebar", None)
if "filter_rules" in validated_data:
rules_data = validated_data.pop("filter_rules")
else:
rules_data = None
if "user" in validated_data:
# backwards compatibility
validated_data["owner"] = validated_data.pop("user")
if (
"display_fields" in validated_data
and isinstance(
validated_data["display_fields"],
list,
)
and len(validated_data["display_fields"]) == 0
):
validated_data["display_fields"] = None
instance = super().update(instance, validated_data)
if rules_data is not None:
SavedViewFilterRule.objects.filter(saved_view=instance).delete()
for rule_data in rules_data:
SavedViewFilterRule.objects.create(saved_view=instance, **rule_data)
ui_settings = self._update_legacy_visibility_preferences(
instance.id,
show_on_dashboard=show_on_dashboard,
show_in_sidebar=show_in_sidebar,
)
if request is not None and ui_settings is not None:
request.user.ui_settings = ui_settings
return instance
def create(self, validated_data):
request = self.context.get("request")
show_on_dashboard = validated_data.pop("show_on_dashboard", None)
show_in_sidebar = validated_data.pop("show_in_sidebar", None)
rules_data = validated_data.pop("filter_rules")
if "user" in validated_data:
# backwards compatibility
validated_data["owner"] = validated_data.pop("user")
saved_view = super().create(validated_data)
for rule_data in rules_data:
SavedViewFilterRule.objects.create(saved_view=saved_view, **rule_data)
ui_settings = self._update_legacy_visibility_preferences(
saved_view.id,
show_on_dashboard=show_on_dashboard,
show_in_sidebar=show_in_sidebar,
)
if request is not None and ui_settings is not None:
request.user.ui_settings = ui_settings
return saved_view
-204
View File
@@ -1,204 +0,0 @@
from __future__ import annotations
import logging
from datetime import timedelta
from django.core.exceptions import ValidationError
from django.core.validators import EmailValidator
from django.utils import timezone
from django.utils.crypto import get_random_string
from django.utils.translation import gettext as _
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from rest_framework.fields import SerializerMethodField
from documents.models import Document
from documents.models import ShareLink
from documents.models import ShareLinkBundle
from documents.permissions import has_perms_owner_aware
from .base import DocumentListSerializer
from .base import OwnedObjectSerializer
logger = logging.getLogger("paperless.serializers")
class EmailSerializer(DocumentListSerializer):
addresses = serializers.CharField(
required=True,
label="Email addresses",
help_text="Comma-separated email addresses",
)
subject = serializers.CharField(
required=True,
label="Email subject",
)
message = serializers.CharField(
required=True,
label="Email message",
)
use_archive_version = serializers.BooleanField(
default=True,
label="Use archive version",
help_text="Use archive version of documents if available",
)
def validate_addresses(self, addresses):
address_list = [addr.strip() for addr in addresses.split(",")]
if not address_list:
raise serializers.ValidationError("At least one email address is required")
email_validator = EmailValidator()
try:
for address in address_list:
email_validator(address)
except ValidationError:
raise serializers.ValidationError(f"Invalid email address: {address}")
return ",".join(address_list)
def validate_documents(self, documents):
super().validate_documents(documents)
if not documents:
raise serializers.ValidationError("At least one document is required")
return documents
class ShareLinkSerializer(OwnedObjectSerializer):
document_title = serializers.CharField(
source="document.title",
read_only=True,
)
class Meta:
model = ShareLink
fields = (
"id",
"created",
"expiration",
"slug",
"document",
"document_title",
"file_version",
)
def create(self, validated_data):
validated_data["slug"] = get_random_string(50)
return super().create(validated_data)
def validate_document(self, document):
if (
self.user is not None
and self.user.has_perm("documents.view_document")
and has_perms_owner_aware(
self.user,
"view_document",
document,
)
):
return document
raise PermissionDenied(
_("Insufficient permissions."),
)
class ShareLinkBundleSerializer(OwnedObjectSerializer):
document_ids = serializers.ListField(
child=serializers.IntegerField(min_value=1),
allow_empty=False,
write_only=True,
)
expiration_days = serializers.IntegerField(
required=False,
allow_null=True,
min_value=1,
write_only=True,
)
documents = serializers.PrimaryKeyRelatedField(
many=True,
read_only=True,
)
document_count = SerializerMethodField()
class Meta:
model = ShareLinkBundle
fields = (
"id",
"created",
"expiration",
"expiration_days",
"slug",
"file_version",
"status",
"size_bytes",
"last_error",
"built_at",
"documents",
"document_ids",
"document_count",
)
read_only_fields = (
"id",
"created",
"expiration",
"slug",
"status",
"size_bytes",
"last_error",
"built_at",
"documents",
"document_count",
)
def validate_document_ids(self, value):
unique_ids = set(value)
if len(unique_ids) != len(value):
raise serializers.ValidationError(
_("Duplicate document identifiers are not allowed."),
)
return value
def create(self, validated_data):
document_ids = validated_data.pop("document_ids")
expiration_days = validated_data.pop("expiration_days", None)
validated_data["slug"] = get_random_string(50)
if expiration_days:
validated_data["expiration"] = timezone.now() + timedelta(
days=expiration_days,
)
else:
validated_data["expiration"] = None
share_link_bundle = super().create(validated_data)
documents = list(
Document.objects.filter(pk__in=document_ids).only(
"pk",
),
)
documents_by_id = {doc.pk: doc for doc in documents}
missing = [
str(doc_id) for doc_id in document_ids if doc_id not in documents_by_id
]
if missing:
raise serializers.ValidationError(
{
"document_ids": _(
"Documents not found: %(ids)s",
)
% {"ids": ", ".join(missing)},
},
)
ordered_documents = [documents_by_id[doc_id] for doc_id in document_ids]
share_link_bundle.documents.set(ordered_documents)
share_link_bundle.document_total = len(ordered_documents)
return share_link_bundle
def get_document_count(self, obj: ShareLinkBundle) -> int:
return getattr(obj, "document_total") or obj.documents.count()
-63
View File
@@ -1,63 +0,0 @@
from __future__ import annotations
import logging
from rest_framework import serializers
from documents.models import Document
from documents.models import UiSettings
from .base import SerializerWithPerms
logger = logging.getLogger("paperless.serializers")
class UiSettingsViewSerializer(serializers.ModelSerializer[UiSettings]):
settings = serializers.DictField(required=False, allow_null=True)
class Meta:
model = UiSettings
depth = 1
fields = [
"id",
"settings",
]
def validate_settings(self, settings):
# we never save update checking backend setting
if "update_checking" in settings:
try:
settings["update_checking"].pop("backend_setting")
except KeyError:
pass
return settings
def create(self, validated_data):
ui_settings = UiSettings.objects.update_or_create(
user=validated_data.get("user"),
defaults={"settings": validated_data.get("settings", None)},
)
return ui_settings
class TrashSerializer(SerializerWithPerms):
documents = serializers.ListField(
required=False,
label="Documents",
write_only=True,
child=serializers.IntegerField(),
)
action = serializers.ChoiceField(
choices=["restore", "empty"],
label="Action",
write_only=True,
)
def validate_documents(self, documents: list[int]) -> list[int]:
count = Document.deleted_objects.filter(id__in=documents).count()
if not count == len(documents):
raise serializers.ValidationError(
"Some documents in the list have not yet been deleted.",
)
return documents
-246
View File
@@ -1,246 +0,0 @@
from __future__ import annotations
import logging
from typing import Any
from rest_framework import serializers
from documents.models import Document
from documents.models import PaperlessTask
from documents.permissions import permitted_document_ids
from .base import OwnedObjectSerializer
logger = logging.getLogger("paperless.serializers")
class TaskSerializerV10(OwnedObjectSerializer):
"""Task serializer for API v10+ using new field names."""
related_document_ids = serializers.ListField(
child=serializers.IntegerField(),
read_only=True,
)
task_type_display = serializers.CharField(
source="get_task_type_display",
read_only=True,
)
trigger_source_display = serializers.CharField(
source="get_trigger_source_display",
read_only=True,
)
status_display = serializers.CharField(
source="get_status_display",
read_only=True,
)
class Meta:
model = PaperlessTask
fields = (
"id",
"task_id",
"task_type",
"task_type_display",
"trigger_source",
"trigger_source_display",
"status",
"status_display",
"date_created",
"date_started",
"date_done",
"duration_seconds",
"wait_time_seconds",
"input_data",
"result_data",
"related_document_ids",
"acknowledged",
"owner",
)
read_only_fields = fields
class TaskSerializerV9(serializers.ModelSerializer[PaperlessTask]):
"""Task serializer for API v9 backwards compatibility.
Maps old field names to the new model fields so existing clients continue
to work unchanged.
"""
# v9 field: task_name -> task_type (with value remapping for renamed tasks)
task_name = serializers.SerializerMethodField()
# v9 field: task_file_name -> input_data.filename
task_file_name = serializers.SerializerMethodField()
# v9 field: type -> trigger_source (mapped to old enum labels)
type = serializers.SerializerMethodField()
# v9 field: status -> uppercase Celery state strings
status = serializers.SerializerMethodField()
# v9 field: result -> derived from result_data
result = serializers.SerializerMethodField()
# v9 field: related_document -> first document ID from result_data
related_document = serializers.SerializerMethodField()
# v9 field: duplicate_documents -> list of duplicate IDs from result_data
duplicate_documents = serializers.SerializerMethodField()
class Meta:
model = PaperlessTask
fields = (
"id",
"task_id",
"task_name",
"task_file_name",
"type",
"status",
"date_created",
"date_done",
"result",
"acknowledged",
"related_document",
"duplicate_documents",
"owner",
)
read_only_fields = fields
_TASK_TYPE_TO_V9_NAME = {
PaperlessTask.TaskType.SANITY_CHECK: "check_sanity",
PaperlessTask.TaskType.LLM_INDEX: "llmindex_update",
}
def get_result(self, obj: PaperlessTask) -> str | None:
"""Reconstruct a human-readable result string from result_data for v9 clients."""
if not obj.result_data:
return None
if doc_id := obj.result_data.get("document_id"):
return f"Success. New document id {doc_id} created"
if reason := obj.result_data.get("reason"):
return reason
if dup_id := obj.result_data.get("duplicate_of"):
return f"Not consuming: It is a duplicate of document #{dup_id}"
if error := obj.result_data.get("error_message"):
return error
return None
def get_task_name(self, obj: PaperlessTask) -> str:
return self._TASK_TYPE_TO_V9_NAME.get(obj.task_type, obj.task_type)
def get_task_file_name(self, obj: PaperlessTask) -> str | None:
if not obj.input_data:
return None
return obj.input_data.get("filename")
_STATUS_TO_V9 = {
PaperlessTask.Status.PENDING: "PENDING",
PaperlessTask.Status.STARTED: "STARTED",
PaperlessTask.Status.SUCCESS: "SUCCESS",
PaperlessTask.Status.FAILURE: "FAILURE",
PaperlessTask.Status.REVOKED: "REVOKED",
}
def get_status(self, obj: PaperlessTask) -> str:
return self._STATUS_TO_V9.get(obj.status, obj.status.upper())
_TRIGGER_SOURCE_TO_V9_TYPE = {
PaperlessTask.TriggerSource.SCHEDULED: "scheduled_task",
PaperlessTask.TriggerSource.SYSTEM: "auto_task",
# Email and folder-consumer documents are system-initiated, not manually triggered
PaperlessTask.TriggerSource.EMAIL_CONSUME: "auto_task",
PaperlessTask.TriggerSource.FOLDER_CONSUME: "auto_task",
}
def get_type(self, obj: PaperlessTask) -> str:
return self._TRIGGER_SOURCE_TO_V9_TYPE.get(obj.trigger_source, "manual_task")
def get_related_document(self, obj: PaperlessTask) -> int | None:
ids = obj.related_document_ids
return ids[0] if ids else None
def get_duplicate_documents(
self,
obj: PaperlessTask,
) -> list[dict[str, Any]]:
if not obj.result_data:
return []
dup_of = obj.result_data.get("duplicate_of")
if dup_of is None:
return []
request = self.context.get("request")
if request is None:
return []
user = request.user
qs = Document.global_objects.filter(pk=dup_of)
if not user.is_staff:
allowed_ids = permitted_document_ids(user, include_deleted=True)
qs = qs.filter(pk__in=allowed_ids)
return list(qs.values("id", "title", "deleted_at"))
class TaskSummarySerializer(serializers.Serializer[dict[str, Any]]):
task_type = serializers.CharField()
total_count = serializers.IntegerField()
pending_count = serializers.IntegerField()
success_count = serializers.IntegerField()
failure_count = serializers.IntegerField()
avg_duration_seconds = serializers.FloatField(allow_null=True)
avg_wait_time_seconds = serializers.FloatField(allow_null=True)
last_run = serializers.DateTimeField(allow_null=True)
last_success = serializers.DateTimeField(allow_null=True)
last_failure = serializers.DateTimeField(allow_null=True)
class RunTaskSerializer(serializers.Serializer[dict[str, str]]):
task_type = serializers.ChoiceField(
choices=PaperlessTask.TaskType.choices,
label="Task Type",
write_only=True,
)
class AcknowledgeTasksViewSerializer(serializers.Serializer[dict[str, Any]]):
tasks = serializers.ListField(
required=False,
label="Tasks",
write_only=True,
child=serializers.IntegerField(),
)
all = serializers.BooleanField(
required=False,
default=False,
label="All",
write_only=True,
)
def _validate_task_id_list(self, tasks, name="tasks") -> None:
if not isinstance(tasks, list):
raise serializers.ValidationError(f"{name} must be a list")
if not all(isinstance(i, int) for i in tasks):
raise serializers.ValidationError(f"{name} must be a list of integers")
queryset = self.context.get("queryset", PaperlessTask.objects.all())
count = queryset.filter(id__in=tasks).count()
if not count == len(tasks):
raise serializers.ValidationError(
f"Some tasks in {name} don't exist or were specified twice.",
)
def validate_tasks(self, tasks):
self._validate_task_id_list(tasks)
return tasks
def validate(self, attrs):
acknowledge_all = attrs.get("all", False)
task_ids = attrs.get("tasks")
if acknowledge_all and task_ids is not None:
raise serializers.ValidationError(
"Set either all or tasks, not both.",
)
if not acknowledge_all and task_ids is None:
raise serializers.ValidationError(
"Either all must be true or tasks must be provided.",
)
return attrs
-199
View File
@@ -1,199 +0,0 @@
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
import magic
from django.conf import settings
from django.utils.translation import gettext as _
from rest_framework import serializers
from documents.models import Correspondent
from documents.models import CustomField
from documents.models import Document
from documents.models import DocumentType
from documents.models import StoragePath
from documents.models import Tag
from documents.parsers import is_mime_type_supported
from .metadata import CustomFieldInstanceSerializer
logger = logging.getLogger("paperless.serializers")
class PostDocumentSerializer(serializers.Serializer[dict[str, Any]]):
created = serializers.DateTimeField(
label="Created",
allow_null=True,
write_only=True,
required=False,
)
document = serializers.FileField(
label="Document",
write_only=True,
)
title = serializers.CharField(
label="Title",
write_only=True,
required=False,
)
correspondent = serializers.PrimaryKeyRelatedField(
queryset=Correspondent.objects.all(),
label="Correspondent",
allow_null=True,
write_only=True,
required=False,
)
document_type = serializers.PrimaryKeyRelatedField(
queryset=DocumentType.objects.all(),
label="Document type",
allow_null=True,
write_only=True,
required=False,
)
storage_path = serializers.PrimaryKeyRelatedField(
queryset=StoragePath.objects.all(),
label="Storage path",
allow_null=True,
write_only=True,
required=False,
)
tags = serializers.PrimaryKeyRelatedField(
many=True,
queryset=Tag.objects.all(),
label="Tags",
write_only=True,
required=False,
)
archive_serial_number = serializers.IntegerField(
label="ASN",
write_only=True,
required=False,
min_value=Document.ARCHIVE_SERIAL_NUMBER_MIN,
max_value=Document.ARCHIVE_SERIAL_NUMBER_MAX,
)
# Accept either a list of custom field ids or a dict mapping id -> value
custom_fields = serializers.JSONField(
label="Custom fields",
write_only=True,
required=False,
)
from_webui = serializers.BooleanField(
label="Documents are from Paperless-ngx WebUI",
write_only=True,
required=False,
)
def validate_document(self, document):
document_data = document.file.read()
mime_type = magic.from_buffer(document_data, mime=True)
if not is_mime_type_supported(mime_type):
if (
mime_type in settings.CONSUMER_PDF_RECOVERABLE_MIME_TYPES
and document.name.endswith(
".pdf",
)
):
# If the file is an invalid PDF, we can try to recover it later in the consumer
mime_type = "application/pdf"
else:
raise serializers.ValidationError(
_("File type %(type)s not supported") % {"type": mime_type},
)
return document.name, document_data
def validate_correspondent(self, correspondent):
if correspondent:
return correspondent.id
else:
return None
def validate_document_type(self, document_type):
if document_type:
return document_type.id
else:
return None
def validate_storage_path(self, storage_path):
if storage_path:
return storage_path.id
else:
return None
def validate_tags(self, tags):
if tags:
return [tag.id for tag in tags]
else:
return None
def validate_custom_fields(self, custom_fields):
if not custom_fields:
return None
# Normalize single values to a list
if isinstance(custom_fields, int):
custom_fields = [custom_fields]
if isinstance(custom_fields, dict):
custom_field_serializer = CustomFieldInstanceSerializer()
normalized = {}
for field_id, value in custom_fields.items():
try:
field_id_int = int(field_id)
except (TypeError, ValueError):
raise serializers.ValidationError(
_("Custom field id must be an integer: %(id)s")
% {"id": field_id},
)
try:
field = CustomField.objects.get(id=field_id_int)
except CustomField.DoesNotExist:
raise serializers.ValidationError(
_("Custom field with id %(id)s does not exist")
% {"id": field_id_int},
)
custom_field_serializer.validate(
{
"field": field,
"value": value,
},
)
normalized[field_id_int] = value
return normalized
elif isinstance(custom_fields, list):
try:
ids = [int(i) for i in custom_fields]
except (TypeError, ValueError):
raise serializers.ValidationError(
_(
"Custom fields must be a list of integers or an object mapping ids to values.",
),
)
if CustomField.objects.filter(id__in=ids).count() != len(set(ids)):
raise serializers.ValidationError(
_("Some custom fields don't exist or were specified twice."),
)
return ids
raise serializers.ValidationError(
_(
"Custom fields must be a list of integers or an object mapping ids to values.",
),
)
# custom_fields_w_values handled via validate_custom_fields
def validate_created(self, created):
# support datetime format for created for backwards compatibility
if isinstance(created, datetime):
return created.date()
-584
View File
@@ -1,584 +0,0 @@
from __future__ import annotations
import logging
from django.db.models import Count
from rest_framework import fields
from rest_framework import serializers
from documents.data_models import DocumentSource
from documents.filters import CustomFieldQueryParser
from documents.models import Workflow
from documents.models import WorkflowAction
from documents.models import WorkflowActionEmail
from documents.models import WorkflowActionWebhook
from documents.models import WorkflowTrigger
from documents.templating.workflows import validate_workflow_template
from documents.validators import url_validator
from .metadata import CorrespondentField
from .metadata import DocumentTypeField
from .metadata import StoragePathField
from .metadata import TagsField
logger = logging.getLogger("paperless.serializers")
class WorkflowTriggerSerializer(serializers.ModelSerializer[WorkflowTrigger]):
id = serializers.IntegerField(required=False, allow_null=True)
sources = fields.MultipleChoiceField(
choices=WorkflowTrigger.DocumentSourceChoices.choices,
allow_empty=True,
default={
DocumentSource.ConsumeFolder,
DocumentSource.ApiUpload,
DocumentSource.MailFetch,
},
)
type = serializers.ChoiceField(
choices=WorkflowTrigger.WorkflowTriggerType.choices,
label="Trigger Type",
)
class Meta:
model = WorkflowTrigger
fields = [
"id",
"sources",
"type",
"filter_path",
"filter_filename",
"filter_mailrule",
"matching_algorithm",
"match",
"is_insensitive",
"filter_has_tags",
"filter_has_all_tags",
"filter_has_not_tags",
"filter_custom_field_query",
"filter_has_any_correspondents",
"filter_has_not_correspondents",
"filter_has_any_document_types",
"filter_has_not_document_types",
"filter_has_any_storage_paths",
"filter_has_not_storage_paths",
"filter_has_correspondent",
"filter_has_document_type",
"filter_has_storage_path",
"schedule_offset_days",
"schedule_is_recurring",
"schedule_recurring_interval_days",
"schedule_date_field",
"schedule_date_custom_field",
]
def validate(self, attrs):
# Empty strings treated as None to avoid unexpected behavior
if (
"filter_filename" in attrs
and attrs["filter_filename"] is not None
and len(attrs["filter_filename"]) == 0
):
attrs["filter_filename"] = None
if (
"filter_path" in attrs
and attrs["filter_path"] is not None
and len(attrs["filter_path"]) == 0
):
attrs["filter_path"] = None
if (
"filter_custom_field_query" in attrs
and attrs["filter_custom_field_query"] is not None
and len(attrs["filter_custom_field_query"]) == 0
):
attrs["filter_custom_field_query"] = None
if (
"filter_custom_field_query" in attrs
and attrs["filter_custom_field_query"] is not None
):
parser = CustomFieldQueryParser("filter_custom_field_query")
parser.parse(attrs["filter_custom_field_query"])
trigger_type = attrs.get("type", getattr(self.instance, "type", None))
if (
trigger_type == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION
and "filter_mailrule" not in attrs
and ("filter_filename" not in attrs or attrs["filter_filename"] is None)
and ("filter_path" not in attrs or attrs["filter_path"] is None)
):
raise serializers.ValidationError(
"File name, path or mail rule filter are required",
)
return attrs
@staticmethod
def normalize_workflow_trigger_sources(trigger) -> None:
"""
Convert sources to strings to handle django-multiselectfield v1.0 changes
"""
if trigger and "sources" in trigger:
trigger["sources"] = [
str(s.value if hasattr(s, "value") else s) for s in trigger["sources"]
]
def create(self, validated_data):
WorkflowTriggerSerializer.normalize_workflow_trigger_sources(validated_data)
return super().create(validated_data)
def update(self, instance, validated_data):
WorkflowTriggerSerializer.normalize_workflow_trigger_sources(validated_data)
return super().update(instance, validated_data)
class WorkflowActionEmailSerializer(serializers.ModelSerializer[WorkflowActionEmail]):
id = serializers.IntegerField(allow_null=True, required=False)
class Meta:
model = WorkflowActionEmail
fields = [
"id",
"subject",
"body",
"to",
"include_document",
]
class WorkflowActionWebhookSerializer(
serializers.ModelSerializer[WorkflowActionWebhook],
):
id = serializers.IntegerField(allow_null=True, required=False)
def validate_url(self, url):
url_validator(url)
return url
class Meta:
model = WorkflowActionWebhook
fields = [
"id",
"url",
"use_params",
"as_json",
"params",
"body",
"headers",
"include_document",
]
class WorkflowActionSerializer(serializers.ModelSerializer[WorkflowAction]):
id = serializers.IntegerField(required=False, allow_null=True)
assign_correspondent = CorrespondentField(allow_null=True, required=False)
assign_tags = TagsField(many=True, allow_null=True, required=False)
assign_document_type = DocumentTypeField(allow_null=True, required=False)
assign_storage_path = StoragePathField(allow_null=True, required=False)
email = WorkflowActionEmailSerializer(allow_null=True, required=False)
webhook = WorkflowActionWebhookSerializer(allow_null=True, required=False)
class Meta:
model = WorkflowAction
fields = [
"id",
"type",
"assign_title",
"assign_tags",
"assign_correspondent",
"assign_document_type",
"assign_storage_path",
"assign_owner",
"assign_view_users",
"assign_view_groups",
"assign_change_users",
"assign_change_groups",
"assign_custom_fields",
"assign_custom_fields_values",
"remove_all_tags",
"remove_tags",
"remove_all_correspondents",
"remove_correspondents",
"remove_all_document_types",
"remove_document_types",
"remove_all_storage_paths",
"remove_storage_paths",
"remove_custom_fields",
"remove_all_custom_fields",
"remove_all_owners",
"remove_owners",
"remove_all_permissions",
"remove_view_users",
"remove_view_groups",
"remove_change_users",
"remove_change_groups",
"email",
"webhook",
"passwords",
"ai_suggestion_fields",
"ai_create_missing",
"ai_overwrite_existing",
]
def validate(self, attrs):
if "assign_title" in attrs and attrs["assign_title"] is not None:
if len(attrs["assign_title"]) == 0:
# Empty strings treated as None to avoid unexpected behavior
attrs["assign_title"] = None
else:
try:
validate_workflow_template(attrs["assign_title"])
except (ValueError, KeyError) as e:
raise serializers.ValidationError(
{"assign_title": f"{e.args[0]}"},
)
if attrs.get("assign_custom_fields_values"):
# Empty strings treated as None to avoid unexpected behavior
attrs["assign_custom_fields_values"] = {
field_id: (None if value == "" else value)
for field_id, value in attrs["assign_custom_fields_values"].items()
}
if (
"type" in attrs
and attrs["type"] == WorkflowAction.WorkflowActionType.EMAIL
and "email" not in attrs
):
raise serializers.ValidationError(
"Email data is required for email actions",
)
if (
"type" in attrs
and attrs["type"] == WorkflowAction.WorkflowActionType.WEBHOOK
and "webhook" not in attrs
):
raise serializers.ValidationError(
"Webhook data is required for webhook actions",
)
if (
"type" in attrs
and attrs["type"] == WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL
):
passwords = attrs.get("passwords")
# ensure passwords is a non-empty list of non-empty strings
if (
passwords is None
or not isinstance(passwords, list)
or len(passwords) == 0
or any(not isinstance(pw, str) for pw in passwords)
or any(len(pw.strip()) == 0 for pw in passwords)
):
raise serializers.ValidationError(
"Passwords are required for password removal actions",
)
if (
"type" in attrs
and attrs["type"] == WorkflowAction.WorkflowActionType.APPLY_AI_SUGGESTIONS
):
fields = attrs.get("ai_suggestion_fields")
valid_fields = set(WorkflowAction.AISuggestionField.values)
if (
fields is None
or not isinstance(fields, list)
or len(fields) == 0
or any(field not in valid_fields for field in fields)
):
raise serializers.ValidationError(
"At least one valid field is required for apply AI "
f"suggestions actions, options are: {sorted(valid_fields)}",
)
return attrs
class WorkflowSerializer(serializers.ModelSerializer[Workflow]):
order = serializers.IntegerField(required=False)
triggers = WorkflowTriggerSerializer(many=True)
actions = WorkflowActionSerializer(many=True)
class Meta:
model = Workflow
fields = [
"id",
"name",
"order",
"enabled",
"triggers",
"actions",
]
def validate(self, attrs):
attrs = super().validate(attrs)
if "actions" in attrs:
has_remote_ocr_action = any(
action.get("type") == WorkflowAction.WorkflowActionType.REMOTE_OCR
for action in attrs["actions"]
)
has_ai_suggestions_action = any(
action.get("type")
== WorkflowAction.WorkflowActionType.APPLY_AI_SUGGESTIONS
for action in attrs["actions"]
)
else:
has_remote_ocr_action = self.instance is not None and (
self.instance.actions.filter(
type=WorkflowAction.WorkflowActionType.REMOTE_OCR,
).exists()
)
has_ai_suggestions_action = self.instance is not None and (
self.instance.actions.filter(
type=WorkflowAction.WorkflowActionType.APPLY_AI_SUGGESTIONS,
).exists()
)
if "triggers" in attrs:
has_consumption_trigger = any(
trigger.get("type") == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION
for trigger in attrs["triggers"]
)
has_non_consumption_trigger = any(
trigger.get("type") != WorkflowTrigger.WorkflowTriggerType.CONSUMPTION
for trigger in attrs["triggers"]
)
else:
has_consumption_trigger = self.instance is not None and (
self.instance.triggers.filter(
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
).exists()
)
has_non_consumption_trigger = self.instance is not None and (
self.instance.triggers.exclude(
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
).exists()
)
# Remote OCR can only work with consumption triggers
if has_remote_ocr_action and not has_consumption_trigger:
raise serializers.ValidationError(
"Remote OCR actions require a consumption started trigger",
)
# Suggestions are made from the document content, which does not exist
# until after consumption has finished
if has_ai_suggestions_action and not has_non_consumption_trigger:
raise serializers.ValidationError(
"Apply AI suggestions actions require a trigger other than "
"consumption started",
)
return attrs
def update_triggers_and_actions(
self,
instance: Workflow,
triggers,
actions,
) -> None:
set_triggers = []
set_actions = []
if triggers is not None and triggers is not serializers.empty:
for trigger in triggers:
filter_has_tags = trigger.pop("filter_has_tags", None)
filter_has_all_tags = trigger.pop("filter_has_all_tags", None)
filter_has_not_tags = trigger.pop("filter_has_not_tags", None)
filter_has_any_correspondents = trigger.pop(
"filter_has_any_correspondents",
None,
)
filter_has_not_correspondents = trigger.pop(
"filter_has_not_correspondents",
None,
)
filter_has_any_document_types = trigger.pop(
"filter_has_any_document_types",
None,
)
filter_has_not_document_types = trigger.pop(
"filter_has_not_document_types",
None,
)
filter_has_any_storage_paths = trigger.pop(
"filter_has_any_storage_paths",
None,
)
filter_has_not_storage_paths = trigger.pop(
"filter_has_not_storage_paths",
None,
)
# Convert sources to strings to handle django-multiselectfield v1.0 changes
WorkflowTriggerSerializer.normalize_workflow_trigger_sources(trigger)
trigger_instance, _ = WorkflowTrigger.objects.update_or_create(
id=trigger.get("id"),
defaults=trigger,
)
if filter_has_tags is not None:
trigger_instance.filter_has_tags.set(filter_has_tags)
if filter_has_all_tags is not None:
trigger_instance.filter_has_all_tags.set(filter_has_all_tags)
if filter_has_not_tags is not None:
trigger_instance.filter_has_not_tags.set(filter_has_not_tags)
if filter_has_any_correspondents is not None:
trigger_instance.filter_has_any_correspondents.set(
filter_has_any_correspondents,
)
if filter_has_not_correspondents is not None:
trigger_instance.filter_has_not_correspondents.set(
filter_has_not_correspondents,
)
if filter_has_any_document_types is not None:
trigger_instance.filter_has_any_document_types.set(
filter_has_any_document_types,
)
if filter_has_not_document_types is not None:
trigger_instance.filter_has_not_document_types.set(
filter_has_not_document_types,
)
if filter_has_any_storage_paths is not None:
trigger_instance.filter_has_any_storage_paths.set(
filter_has_any_storage_paths,
)
if filter_has_not_storage_paths is not None:
trigger_instance.filter_has_not_storage_paths.set(
filter_has_not_storage_paths,
)
set_triggers.append(trigger_instance)
if actions is not None and actions is not serializers.empty:
for index, action in enumerate(actions):
action["order"] = index
assign_tags = action.pop("assign_tags", None)
assign_view_users = action.pop("assign_view_users", None)
assign_view_groups = action.pop("assign_view_groups", None)
assign_change_users = action.pop("assign_change_users", None)
assign_change_groups = action.pop("assign_change_groups", None)
assign_custom_fields = action.pop("assign_custom_fields", None)
remove_tags = action.pop("remove_tags", None)
remove_correspondents = action.pop("remove_correspondents", None)
remove_document_types = action.pop("remove_document_types", None)
remove_storage_paths = action.pop("remove_storage_paths", None)
remove_custom_fields = action.pop("remove_custom_fields", None)
remove_owners = action.pop("remove_owners", None)
remove_view_users = action.pop("remove_view_users", None)
remove_view_groups = action.pop("remove_view_groups", None)
remove_change_users = action.pop("remove_change_users", None)
remove_change_groups = action.pop("remove_change_groups", None)
email_data = action.pop("email", None)
webhook_data = action.pop("webhook", None)
action_instance, _ = WorkflowAction.objects.update_or_create(
id=action.get("id"),
defaults=action,
)
if email_data is not None:
serializer = WorkflowActionEmailSerializer(data=email_data)
serializer.is_valid(raise_exception=True)
email, _ = WorkflowActionEmail.objects.update_or_create(
id=email_data.get("id"),
defaults=serializer.validated_data,
)
action_instance.email = email
action_instance.save()
if webhook_data is not None:
serializer = WorkflowActionWebhookSerializer(data=webhook_data)
serializer.is_valid(raise_exception=True)
webhook, _ = WorkflowActionWebhook.objects.update_or_create(
id=webhook_data.get("id"),
defaults=serializer.validated_data,
)
action_instance.webhook = webhook
action_instance.save()
if assign_tags is not None:
action_instance.assign_tags.set(assign_tags)
if assign_view_users is not None:
action_instance.assign_view_users.set(assign_view_users)
if assign_view_groups is not None:
action_instance.assign_view_groups.set(assign_view_groups)
if assign_change_users is not None:
action_instance.assign_change_users.set(assign_change_users)
if assign_change_groups is not None:
action_instance.assign_change_groups.set(assign_change_groups)
if assign_custom_fields is not None:
action_instance.assign_custom_fields.set(assign_custom_fields)
if remove_tags is not None:
action_instance.remove_tags.set(remove_tags)
if remove_correspondents is not None:
action_instance.remove_correspondents.set(remove_correspondents)
if remove_document_types is not None:
action_instance.remove_document_types.set(remove_document_types)
if remove_storage_paths is not None:
action_instance.remove_storage_paths.set(remove_storage_paths)
if remove_custom_fields is not None:
action_instance.remove_custom_fields.set(remove_custom_fields)
if remove_owners is not None:
action_instance.remove_owners.set(remove_owners)
if remove_view_users is not None:
action_instance.remove_view_users.set(remove_view_users)
if remove_view_groups is not None:
action_instance.remove_view_groups.set(remove_view_groups)
if remove_change_users is not None:
action_instance.remove_change_users.set(remove_change_users)
if remove_change_groups is not None:
action_instance.remove_change_groups.set(remove_change_groups)
set_actions.append(action_instance)
if triggers is not serializers.empty:
instance.triggers.set(set_triggers)
if actions is not serializers.empty:
instance.actions.set(set_actions)
instance.save()
def prune_triggers_and_actions(self) -> None:
"""
ManyToMany fields dont support e.g. on_delete so we need to discard unattached
triggers and actions manually
"""
WorkflowTrigger.objects.annotate(
workflow_count=Count("workflows"),
).filter(workflow_count=0).delete()
WorkflowAction.objects.annotate(
workflow_count=Count("workflows"),
).filter(workflow_count=0).delete()
WorkflowActionEmail.objects.filter(action=None).delete()
WorkflowActionWebhook.objects.filter(action=None).delete()
def create(self, validated_data) -> Workflow:
if "triggers" in validated_data:
triggers = validated_data.pop("triggers")
if "actions" in validated_data:
actions = validated_data.pop("actions")
for action in actions:
action.pop("id", None)
instance = super().create(validated_data)
self.update_triggers_and_actions(instance, triggers, actions)
return instance
def update(self, instance: Workflow, validated_data) -> Workflow:
triggers = validated_data.pop("triggers", serializers.empty)
actions = validated_data.pop("actions", serializers.empty)
instance = super().update(instance, validated_data)
self.update_triggers_and_actions(instance, triggers, actions)
self.prune_triggers_and_actions()
return instance
+1 -6
View File
@@ -56,7 +56,6 @@ from documents.permissions import get_objects_for_user_owner_aware
from documents.plugins.helpers import DocumentsStatusManager from documents.plugins.helpers import DocumentsStatusManager
from documents.templating.utils import convert_format_str_to_template_format from documents.templating.utils import convert_format_str_to_template_format
from documents.utils import compute_checksum from documents.utils import compute_checksum
from documents.utils import copy_file_with_basic_stats
from documents.workflows.actions import build_workflow_action_context from documents.workflows.actions import build_workflow_action_context
from documents.workflows.actions import execute_email_action from documents.workflows.actions import execute_email_action
from documents.workflows.actions import execute_move_to_trash_action from documents.workflows.actions import execute_move_to_trash_action
@@ -364,11 +363,7 @@ def cleanup_document_deletion(sender, instance, **kwargs) -> None:
logger.debug(f"Moving {instance.source_path} to trash at {new_file_path}") logger.debug(f"Moving {instance.source_path} to trash at {new_file_path}")
try: try:
shutil.move( shutil.move(instance.source_path, new_file_path)
instance.source_path,
new_file_path,
copy_function=copy_file_with_basic_stats,
)
except OSError as e: except OSError as e:
logger.error( logger.error(
f"Failed to move {instance.source_path} to trash at " f"Failed to move {instance.source_path} to trash at "
+1 -1
View File
@@ -12,7 +12,7 @@
<meta name="robots" content="noindex,nofollow"> <meta name="robots" content="noindex,nofollow">
<meta name="author" content="The Paperless-ngx Team"> <meta name="author" content="The Paperless-ngx Team">
<link rel="icon" type="image/x-icon" href="favicon.ico"> <link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="manifest" href="{% static webmanifest %}" crossorigin="use-credentials"> <link rel="manifest" href="{% static webmanifest %}">
<link rel="stylesheet" href="{% static styles_css %}"> <link rel="stylesheet" href="{% static styles_css %}">
<link rel="apple-touch-icon" href="{% static apple_touch_icon %}"> <link rel="apple-touch-icon" href="{% static apple_touch_icon %}">
</head> </head>
+143 -18
View File
@@ -1,41 +1,88 @@
import shutil import shutil
import zoneinfo
from collections.abc import Generator
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import filelock import filelock
import pytest import pytest
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from guardian.shortcuts import clear_ct_cache
from pytest_django.fixtures import Settings
from rest_framework.test import APIClient
from paperless_testing.factories import DocumentFactory from documents.tests.factories import DocumentFactory
UserModelT = get_user_model()
if TYPE_CHECKING: if TYPE_CHECKING:
from documents.models import Document from documents.models import Document
from paperless_testing.dirs import PaperlessDirs
@dataclass(frozen=True, slots=True)
class PaperlessDirs:
"""Standard Paperless-ngx directory layout for tests."""
media: Path
originals: Path
archive: Path
thumbnails: Path
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def document_samples_dir() -> Path: def samples_dir() -> Path:
"""Path to the shared test sample documents.""" """Path to the shared test sample documents."""
return Path(__file__).parent / "samples" / "documents" return Path(__file__).parent / "samples" / "documents"
@pytest.fixture()
def paperless_dirs(tmp_path: Path) -> PaperlessDirs:
"""Create and return the directory structure for testing."""
media = tmp_path / "media"
dirs = PaperlessDirs(
media=media,
originals=media / "documents" / "originals",
archive=media / "documents" / "archive",
thumbnails=media / "documents" / "thumbnails",
)
for d in (dirs.originals, dirs.archive, dirs.thumbnails):
d.mkdir(parents=True)
return dirs
@pytest.fixture()
def _media_settings(paperless_dirs: PaperlessDirs, settings) -> None:
"""Configure Django settings to point at temp directories."""
settings.MEDIA_ROOT = paperless_dirs.media
settings.ORIGINALS_DIR = paperless_dirs.originals
settings.ARCHIVE_DIR = paperless_dirs.archive
settings.THUMBNAIL_DIR = paperless_dirs.thumbnails
settings.MEDIA_LOCK = paperless_dirs.media / "media.lock"
settings.IGNORABLE_FILES = {".DS_Store", "Thumbs.db", "desktop.ini"}
settings.APP_LOGO = ""
@pytest.fixture() @pytest.fixture()
def sample_doc( def sample_doc(
paperless_dirs: "PaperlessDirs", paperless_dirs: PaperlessDirs,
document_samples_dir: Path, _media_settings: None,
samples_dir: Path,
) -> "Document": ) -> "Document":
"""Create a document with valid files and matching checksums.""" """Create a document with valid files and matching checksums."""
with filelock.FileLock(paperless_dirs.media_lock): with filelock.FileLock(paperless_dirs.media / "media.lock"):
shutil.copy( shutil.copy(
document_samples_dir / "originals" / "0000001.pdf", samples_dir / "originals" / "0000001.pdf",
paperless_dirs.originals_dir / "0000001.pdf", paperless_dirs.originals / "0000001.pdf",
) )
shutil.copy( shutil.copy(
document_samples_dir / "archive" / "0000001.pdf", samples_dir / "archive" / "0000001.pdf",
paperless_dirs.archive_dir / "0000001.pdf", paperless_dirs.archive / "0000001.pdf",
) )
shutil.copy( shutil.copy(
document_samples_dir / "thumbnails" / "0000001.webp", samples_dir / "thumbnails" / "0000001.webp",
paperless_dirs.thumbnail_dir / "0000001.webp", paperless_dirs.thumbnails / "0000001.webp",
) )
return DocumentFactory( return DocumentFactory(
@@ -50,17 +97,95 @@ def sample_doc(
) )
@pytest.fixture @pytest.fixture()
def _search_index(paperless_dirs: "PaperlessDirs") -> None: def _search_index(
"""Point the search backend at a fresh, empty index directory. tmp_path: Path,
settings: Settings,
) -> Generator[None, None, None]:
"""Create a temp index directory and point INDEX_DIR at it.
paperless_dirs owns INDEX_DIR and resets the backend singleton on both Resets the backend singleton before and after so each test gets a clean
sides of the test, so requesting it is all that is needed. index rather than reusing a stale singleton from another test.
""" """
from documents.search import reset_backend
index_dir = tmp_path / "index"
index_dir.mkdir()
settings.INDEX_DIR = index_dir
reset_backend()
yield
reset_backend()
@pytest.fixture()
def settings_timezone(settings: Settings) -> zoneinfo.ZoneInfo:
return zoneinfo.ZoneInfo(settings.TIME_ZONE)
@pytest.fixture @pytest.fixture
def searchable_document(_search_index: None) -> "Document": def rest_api_client():
"""
The basic DRF ApiClient
"""
yield APIClient()
@pytest.fixture()
def regular_user(django_user_model: type[UserModelT]) -> UserModelT:
"""Unprivileged authenticated user for permission boundary tests."""
return django_user_model.objects.create_user(username="regular", password="regular")
@pytest.fixture()
def admin_client(rest_api_client: APIClient, admin_user: UserModelT) -> APIClient:
"""Admin client pre-authenticated and sending the v10 Accept header."""
rest_api_client.force_authenticate(user=admin_user)
rest_api_client.credentials(HTTP_ACCEPT="application/json; version=10")
return rest_api_client
@pytest.fixture()
def v9_client(rest_api_client: APIClient, admin_user: UserModelT) -> APIClient:
"""Admin client pre-authenticated and sending the v9 Accept header."""
rest_api_client.force_authenticate(user=admin_user)
rest_api_client.credentials(HTTP_ACCEPT="application/json; version=9")
return rest_api_client
@pytest.fixture()
def user_client(rest_api_client: APIClient, regular_user: UserModelT) -> APIClient:
"""Regular-user client pre-authenticated and sending the v10 Accept header."""
rest_api_client.force_authenticate(user=regular_user)
rest_api_client.credentials(HTTP_ACCEPT="application/json; version=10")
return rest_api_client
@pytest.fixture(autouse=True)
def _clear_content_type_caches() -> None:
"""Clear Django's ContentType cache and guardian's lru_cache before each test.
Tests that delete and reinsert ContentType/Permission rows (e.g. the
importer) corrupt both caches. Without this fixture a subsequent test on
the same xdist worker sees stale ContentType objects and guardian raises
MixedContentTypeError.
"""
ContentType.objects.clear_cache()
clear_ct_cache()
@pytest.fixture(scope="session", autouse=True)
def faker_session_locale():
"""Set Faker locale for reproducibility."""
return "en_US"
@pytest.fixture(scope="session", autouse=True)
def faker_seed():
return 12345
@pytest.fixture
def indexed_document(_search_index: None) -> "Document":
"""One searchable document, for tests about what the search endpoint """One searchable document, for tests about what the search endpoint
returns rather than about what it finds. returns rather than about what it finds.
""" """
@@ -5,7 +5,8 @@ Factory-boy factories for documents app models.
from __future__ import annotations from __future__ import annotations
import factory import factory
from django.contrib.auth.models import User from django.contrib.auth import get_user_model
from factory.django import DjangoModelFactory
from documents.models import Correspondent from documents.models import Correspondent
from documents.models import Document from documents.models import Document
@@ -14,10 +15,11 @@ from documents.models import MatchingModel
from documents.models import PaperlessTask from documents.models import PaperlessTask
from documents.models import StoragePath from documents.models import StoragePath
from documents.models import Tag from documents.models import Tag
from paperless_testing.typed_factory import TypedModelFactory
UserModelT = get_user_model()
class CorrespondentFactory(TypedModelFactory[Correspondent]): class CorrespondentFactory(DjangoModelFactory[Correspondent]):
class Meta: class Meta:
model = Correspondent model = Correspondent
@@ -26,7 +28,7 @@ class CorrespondentFactory(TypedModelFactory[Correspondent]):
matching_algorithm = MatchingModel.MATCH_NONE matching_algorithm = MatchingModel.MATCH_NONE
class DocumentTypeFactory(TypedModelFactory[DocumentType]): class DocumentTypeFactory(DjangoModelFactory[DocumentType]):
class Meta: class Meta:
model = DocumentType model = DocumentType
@@ -35,7 +37,7 @@ class DocumentTypeFactory(TypedModelFactory[DocumentType]):
matching_algorithm = MatchingModel.MATCH_NONE matching_algorithm = MatchingModel.MATCH_NONE
class TagFactory(TypedModelFactory[Tag]): class TagFactory(DjangoModelFactory[Tag]):
class Meta: class Meta:
model = Tag model = Tag
@@ -45,7 +47,7 @@ class TagFactory(TypedModelFactory[Tag]):
is_inbox_tag = False is_inbox_tag = False
class StoragePathFactory(TypedModelFactory[StoragePath]): class StoragePathFactory(DjangoModelFactory[StoragePath]):
class Meta: class Meta:
model = StoragePath model = StoragePath
@@ -57,7 +59,7 @@ class StoragePathFactory(TypedModelFactory[StoragePath]):
matching_algorithm = MatchingModel.MATCH_NONE matching_algorithm = MatchingModel.MATCH_NONE
class DocumentFactory(TypedModelFactory[Document]): class DocumentFactory(DjangoModelFactory[Document]):
class Meta: class Meta:
model = Document model = Document
@@ -69,24 +71,21 @@ class DocumentFactory(TypedModelFactory[Document]):
storage_path = None storage_path = None
class UserFactory(TypedModelFactory[User]): class UserFactory(DjangoModelFactory[UserModelT]):
class Meta: class Meta:
model = User model = UserModelT
username = factory.Sequence(lambda n: f"user{n}") username = factory.Sequence(lambda n: f"user{n}")
is_staff = False is_staff = False
is_superuser = False is_superuser = False
# Hashing a real password costs about half a second per user, and no test password = factory.django.Password("test")
# authenticates with its password. None gives the unusable password
# create_user() gives.
password = factory.django.Password(None)
class Params: class Params:
superuser = factory.Trait(is_staff=True, is_superuser=True) superuser = factory.Trait(is_staff=True, is_superuser=True)
staff = factory.Trait(is_staff=True) staff = factory.Trait(is_staff=True)
class PaperlessTaskFactory(TypedModelFactory[PaperlessTask]): class PaperlessTaskFactory(DjangoModelFactory[PaperlessTask]):
class Meta: class Meta:
model = PaperlessTask model = PaperlessTask
-10
View File
@@ -1,10 +0,0 @@
import re
def dummy_preprocess(content: str) -> str:
"""
Simpler, faster pre-processing for testing purposes
"""
content = content.lower().strip()
content = re.sub(r"\s+", " ", content)
return content
@@ -15,11 +15,11 @@ from rich.console import Console
from documents.management.commands.document_sanity_checker import Command from documents.management.commands.document_sanity_checker import Command
from documents.sanity_checker import SanityCheckMessages from documents.sanity_checker import SanityCheckMessages
from paperless_testing.factories import DocumentFactory from documents.tests.factories import DocumentFactory
if TYPE_CHECKING: if TYPE_CHECKING:
from documents.models import Document from documents.models import Document
from paperless_testing.dirs import PaperlessDirs from documents.tests.conftest import PaperlessDirs
def _render_to_string(messages: SanityCheckMessages) -> str: def _render_to_string(messages: SanityCheckMessages) -> str:
@@ -71,7 +71,7 @@ class TestRenderResultsWithIssues:
assert "INFO" in output assert "INFO" in output
assert "No OCR data" in output assert "No OCR data" in output
@pytest.mark.usefixtures("paperless_dirs") @pytest.mark.usefixtures("_media_settings")
def test_global_message(self) -> None: def test_global_message(self) -> None:
msgs = SanityCheckMessages() msgs = SanityCheckMessages()
msgs.warning(None, "Orphaned file: /tmp/stray.pdf") msgs.warning(None, "Orphaned file: /tmp/stray.pdf")
@@ -87,7 +87,7 @@ class TestRenderResultsWithIssues:
assert "Thumbnail missing" in output assert "Thumbnail missing" in output
assert "Checksum mismatch" in output assert "Checksum mismatch" in output
@pytest.mark.usefixtures("paperless_dirs") @pytest.mark.usefixtures("_media_settings")
def test_unknown_doc_pk(self) -> None: def test_unknown_doc_pk(self) -> None:
msgs = SanityCheckMessages() msgs = SanityCheckMessages()
msgs.error(99999, "Ghost document") msgs.error(99999, "Ghost document")
@@ -184,6 +184,7 @@ class TestDocumentSanityCheckerCommand:
assert "ERROR" in output assert "ERROR" in output
assert "Original of document does not exist" in output assert "Original of document does not exist" in output
@pytest.mark.usefixtures("_media_settings")
def test_checksum_mismatch(self, paperless_dirs: PaperlessDirs) -> None: def test_checksum_mismatch(self, paperless_dirs: PaperlessDirs) -> None:
"""Lightweight document with zero-byte files triggers checksum mismatch.""" """Lightweight document with zero-byte files triggers checksum mismatch."""
doc = DocumentFactory( doc = DocumentFactory(
+12 -4
View File
@@ -9,21 +9,29 @@ from documents.search._backend import TantivyBackend
from documents.search._backend import reset_backend from documents.search._backend import reset_backend
from documents.search._schema import build_schema from documents.search._schema import build_schema
from documents.search._tokenizer import register_tokenizers from documents.search._tokenizer import register_tokenizers
from paperless_testing.factories import DocumentFactory from documents.tests.factories import DocumentFactory
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
from collections.abc import Generator from collections.abc import Generator
from pathlib import Path
from pytest_django.fixtures import Settings from pytest_django.fixtures import Settings
from documents.models import Document from documents.models import Document
from paperless_testing.dirs import PaperlessDirs
@pytest.fixture @pytest.fixture
def backend(paperless_dirs: PaperlessDirs) -> Generator[TantivyBackend, None, None]: def index_dir(tmp_path: Path, settings: Settings) -> Path:
b = TantivyBackend(path=paperless_dirs.index_dir) path = tmp_path / "index"
path.mkdir()
settings.INDEX_DIR = path
return path
@pytest.fixture
def backend() -> Generator[TantivyBackend, None, None]:
b = TantivyBackend() # path=None → in-memory index
b.open() b.open()
try: try:
yield b yield b
@@ -14,6 +14,7 @@ from typing import TYPE_CHECKING
import pytest import pytest
import time_machine import time_machine
from django.contrib.auth.models import User
from documents.models import CustomField from documents.models import CustomField
from documents.models import CustomFieldInstance from documents.models import CustomFieldInstance
@@ -21,8 +22,7 @@ from documents.models import DocumentType
from documents.models import Note from documents.models import Note
from documents.models import StoragePath from documents.models import StoragePath
from documents.search._query import parse_user_query from documents.search._query import parse_user_query
from paperless_testing.factories import DocumentFactory from documents.tests.factories import DocumentFactory
from paperless_testing.factories import UserFactory
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
@@ -148,7 +148,7 @@ class TestJsonSubpaths:
THEN: THEN:
- Only the document with alice's note matches - Only the document with alice's note matches
""" """
alice = UserFactory(username="alice") alice = User.objects.create_user(username="alice")
doc_with_note = DocumentFactory( doc_with_note = DocumentFactory(
title="Has note", title="Has note",
content="x", content="x",
+16 -17
View File
@@ -3,8 +3,10 @@ from pathlib import Path
import pytest import pytest
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.contrib.auth.models import User
from django.db import connection from django.db import connection
from django.test.utils import CaptureQueriesContext from django.test.utils import CaptureQueriesContext
from guardian.shortcuts import assign_perm
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from documents.models import CustomField from documents.models import CustomField
@@ -17,12 +19,11 @@ from documents.search._backend import WriteBatch
from documents.search._backend import get_backend from documents.search._backend import get_backend
from documents.search._backend import reset_backend from documents.search._backend import reset_backend
from documents.signals.handlers import add_to_index from documents.signals.handlers import add_to_index
from paperless_testing.factories import CorrespondentFactory from documents.tests.factories import CorrespondentFactory
from paperless_testing.factories import DocumentFactory from documents.tests.factories import DocumentFactory
from paperless_testing.factories import DocumentTypeFactory from documents.tests.factories import DocumentTypeFactory
from paperless_testing.factories import TagFactory from documents.tests.factories import TagFactory
from paperless_testing.factories import UserFactory from documents.tests.factories import UserFactory
from paperless_testing.permissions import grant_object
pytestmark = [pytest.mark.search, pytest.mark.django_db] pytestmark = [pytest.mark.search, pytest.mark.django_db]
@@ -188,7 +189,7 @@ class TestAddOrUpdateIds:
pk=1, pk=1,
owner=owner, owner=owner,
) )
grant_object(user, doc, "view_document") assign_perm("view_document", user, doc)
with backend.batch_update() as batch: with backend.batch_update() as batch:
batch.add_or_update_ids([doc.pk]) batch.add_or_update_ids([doc.pk])
@@ -208,7 +209,7 @@ class TestAddOrUpdateIds:
pk=1, pk=1,
owner=owner, owner=owner,
) )
grant_object(group, doc, "view_document") assign_perm("view_document", group, doc)
with backend.batch_update() as batch: with backend.batch_update() as batch:
batch.add_or_update_ids([doc.pk]) batch.add_or_update_ids([doc.pk])
@@ -762,8 +763,8 @@ class TestSearchIds:
def test_respects_permission_filter(self, backend: TantivyBackend) -> None: def test_respects_permission_filter(self, backend: TantivyBackend) -> None:
"""search_ids must respect user permission filtering.""" """search_ids must respect user permission filtering."""
owner = UserFactory(username="ids_owner") owner = User.objects.create_user("ids_owner")
other = UserFactory(username="ids_other") other = User.objects.create_user("ids_other")
doc = Document.objects.create( doc = Document.objects.create(
title="private doc", title="private doc",
content="secret keyword", content="secret keyword",
@@ -842,7 +843,7 @@ class TestRebuild:
content="group secret keyword", content="group secret keyword",
owner=owner, owner=owner,
) )
grant_object(group, doc, "view_document") assign_perm("view_document", group, doc)
backend.rebuild(Document.objects.all()) backend.rebuild(Document.objects.all())
@@ -947,8 +948,7 @@ class TestSingleton:
yield yield
reset_backend() reset_backend()
@pytest.mark.usefixtures("paperless_dirs") def test_returns_same_instance_on_repeated_calls(self, index_dir) -> None:
def test_returns_same_instance_on_repeated_calls(self) -> None:
"""Singleton pattern: repeated calls to get_backend() must return the same instance.""" """Singleton pattern: repeated calls to get_backend() must return the same instance."""
assert get_backend() is get_backend() assert get_backend() is get_backend()
@@ -965,8 +965,7 @@ class TestSingleton:
assert b1 is not b2 assert b1 is not b2
assert b2._path == tmp_path / "b" assert b2._path == tmp_path / "b"
@pytest.mark.usefixtures("paperless_dirs") def test_reset_forces_new_instance(self, index_dir) -> None:
def test_reset_forces_new_instance(self) -> None:
"""reset_backend() must force creation of a new backend instance on next get_backend() call.""" """reset_backend() must force creation of a new backend instance on next get_backend() call."""
b1 = get_backend() b1 = get_backend()
reset_backend() reset_backend()
@@ -1072,7 +1071,7 @@ class TestFieldHandling:
def test_notes_include_user_information(self, backend: TantivyBackend) -> None: def test_notes_include_user_information(self, backend: TantivyBackend) -> None:
"""Notes must be indexed with user information when available for structured queries.""" """Notes must be indexed with user information when available for structured queries."""
user = UserFactory(username="notewriter") user = User.objects.create_user("notewriter")
doc = Document.objects.create( doc = Document.objects.create(
title="Doc with notes", title="Doc with notes",
content="test", content="test",
@@ -1174,7 +1173,7 @@ class TestHighlightHits:
notes.note: prefix so the query targets notes content directly, but notes.note: prefix so the query targets notes content directly, but
the snippet is generated from notes_text which stores the same text. the snippet is generated from notes_text which stores the same text.
""" """
user = UserFactory(username="hl_noteuser") user = User.objects.create_user("hl_noteuser")
doc = Document.objects.create( doc = Document.objects.create(
title="Doc with matching note", title="Doc with matching note",
content="unrelated content", content="unrelated content",
@@ -27,7 +27,7 @@ import time_machine
from documents.models import Note from documents.models import Note
from documents.models import Tag from documents.models import Tag
from documents.search._errors import InvalidDateQuery from documents.search._errors import InvalidDateQuery
from paperless_testing.factories import DocumentFactory from documents.tests.factories import DocumentFactory
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
@@ -269,7 +269,7 @@ class TestDocumentedDateForms:
yield yield
@pytest.fixture @pytest.fixture
def dated(self, backend: TantivyBackend) -> dict[str, int]: def dated(self, index_document: Callable[..., Document]) -> dict[str, int]:
stamps = { stamps = {
"today": datetime(2026, 6, 15, 9, 0, tzinfo=UTC), "today": datetime(2026, 6, 15, 9, 0, tzinfo=UTC),
"yesterday": datetime(2026, 6, 14, 9, 0, tzinfo=UTC), "yesterday": datetime(2026, 6, 14, 9, 0, tzinfo=UTC),
@@ -279,14 +279,14 @@ class TestDocumentedDateForms:
"january": datetime(2026, 1, 10, 10, 0, tzinfo=UTC), "january": datetime(2026, 1, 10, 10, 0, tzinfo=UTC),
"old": datetime(2005, 3, 4, 15, 30, tzinfo=UTC), "old": datetime(2005, 3, 4, 15, 30, tzinfo=UTC),
} }
docs = { return {
label: DocumentFactory(title=label, content="dated body", added=stamp) label: index_document(
title=label,
content="dated body",
added=stamp,
).pk
for label, stamp in stamps.items() for label, stamp in stamps.items()
} }
with backend.batch_update() as batch:
for doc in docs.values():
batch.add_or_update(doc)
return {label: doc.pk for label, doc in docs.items()}
@pytest.mark.parametrize( @pytest.mark.parametrize(
("query", "label"), ("query", "label"),
@@ -29,7 +29,7 @@ from rest_framework import status
from documents.search._backend import SearchMode from documents.search._backend import SearchMode
from documents.search._query import parse_simple_text_highlight_query from documents.search._query import parse_simple_text_highlight_query
from paperless_testing.factories import DocumentFactory from documents.tests.factories import DocumentFactory
if TYPE_CHECKING: if TYPE_CHECKING:
from rest_framework.test import APIClient from rest_framework.test import APIClient
@@ -17,12 +17,12 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import pytest import pytest
from django.contrib.auth.models import User
from documents.models import CustomField from documents.models import CustomField
from documents.models import CustomFieldInstance from documents.models import CustomFieldInstance
from documents.models import Note from documents.models import Note
from paperless_testing.factories import DocumentFactory from documents.tests.factories import DocumentFactory
from paperless_testing.factories import UserFactory
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
@@ -53,7 +53,7 @@ class TestBareJsonFieldPrefixes:
decoy's content match does not resurface through a demoted decoy's content match does not resurface through a demoted
text search text search
""" """
alice = UserFactory(username="alice") alice = User.objects.create_user(username="alice")
with_note = DocumentFactory(title="Has note", content="x") with_note = DocumentFactory(title="Has note", content="x")
Note.objects.create(document=with_note, user=alice, note="crocodile") Note.objects.create(document=with_note, user=alice, note="crocodile")
backend.add_or_update(with_note) backend.add_or_update(with_note)
@@ -116,7 +116,7 @@ class TestBareJsonFieldPrefixes:
document; the default-subpath resolution for the bare document; the default-subpath resolution for the bare
prefix does not interfere with explicit subpath addressing prefix does not interfere with explicit subpath addressing
""" """
bob = UserFactory(username="bob") bob = User.objects.create_user(username="bob")
doc = DocumentFactory(title="Bob note", content="x") doc = DocumentFactory(title="Bob note", content="x")
Note.objects.create(document=doc, user=bob, note="remark") Note.objects.create(document=doc, user=bob, note="remark")
backend.add_or_update(doc) backend.add_or_update(doc)
@@ -20,6 +20,7 @@ from typing import TYPE_CHECKING
import pytest import pytest
import tantivy import tantivy
from django.contrib.auth.models import User
from whoosh_compat import FieldKind from whoosh_compat import FieldKind
from documents.models import CustomField from documents.models import CustomField
@@ -27,7 +28,6 @@ from documents.models import CustomFieldInstance
from documents.models import Document from documents.models import Document
from documents.models import Note from documents.models import Note
from documents.search._fields import PUBLIC_FIELDS from documents.search._fields import PUBLIC_FIELDS
from paperless_testing.factories import UserFactory
if TYPE_CHECKING: if TYPE_CHECKING:
from documents.search._backend import TantivyBackend from documents.search._backend import TantivyBackend
@@ -49,7 +49,7 @@ class TestJsonSubpathsAreWrittenAtIndexTime:
- Every subpath PUBLIC_FIELDS declares for notes/custom_fields - Every subpath PUBLIC_FIELDS declares for notes/custom_fields
is present as a key in the document's stored JSON payload is present as a key in the document's stored JSON payload
""" """
user = UserFactory(username="completeness-user") user = User.objects.create_user(username="completeness-user")
field = CustomField.objects.create( field = CustomField.objects.create(
name="Completeness Field", name="Completeness Field",
data_type=CustomField.FieldDataType.STRING, data_type=CustomField.FieldDataType.STRING,
@@ -15,7 +15,7 @@ from documents.search._backend import SearchIndexLockError
from documents.search._backend import TantivyBackend from documents.search._backend import TantivyBackend
from documents.tasks import index_document from documents.tasks import index_document
from documents.tasks import remove_document_from_index from documents.tasks import remove_document_from_index
from paperless_testing.factories import DocumentFactory from documents.tests.factories import DocumentFactory
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Generator from collections.abc import Generator
@@ -1,6 +1,6 @@
import pytest import pytest
from paperless_testing.migrations import TestMigrations from documents.tests.utils import TestMigrations
pytestmark = pytest.mark.search pytestmark = pytest.mark.search
@@ -18,14 +18,13 @@ from typing import TYPE_CHECKING
import pytest import pytest
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.contrib.auth.models import User from django.contrib.auth.models import User
from guardian.shortcuts import assign_perm
from documents.models import Correspondent from documents.models import Correspondent
from documents.models import Document from documents.models import Document
from documents.models import DocumentType from documents.models import DocumentType
from documents.models import StoragePath from documents.models import StoragePath
from documents.models import Tag from documents.models import Tag
from paperless_testing.factories import UserFactory
from paperless_testing.permissions import grant_object
if TYPE_CHECKING: if TYPE_CHECKING:
from documents.search._backend import TantivyBackend from documents.search._backend import TantivyBackend
@@ -35,22 +34,22 @@ pytestmark = [pytest.mark.search, pytest.mark.django_db]
@pytest.fixture @pytest.fixture
def owner() -> User: def owner() -> User:
return UserFactory(username="owner") return User.objects.create_user(username="owner")
@pytest.fixture @pytest.fixture
def stranger() -> User: def stranger() -> User:
return UserFactory(username="stranger") return User.objects.create_user(username="stranger")
@pytest.fixture @pytest.fixture
def viewer() -> User: def viewer() -> User:
return UserFactory(username="viewer") return User.objects.create_user(username="viewer")
@pytest.fixture @pytest.fixture
def group_member() -> User: def group_member() -> User:
user = UserFactory(username="group_member") user = User.objects.create_user(username="group_member")
user.groups.add(Group.objects.create(name="accounting")) user.groups.add(Group.objects.create(name="accounting"))
return user return user
@@ -128,7 +127,7 @@ class TestPermissionFilteringOnIndexedDocuments:
checksum="perm-shared-user", checksum="perm-shared-user",
owner=owner, owner=owner,
) )
grant_object(viewer, doc, "view_document") assign_perm("view_document", viewer, doc)
backend.add_or_update(doc) backend.add_or_update(doc)
assert backend.search_ids("invoice", user=viewer) == [doc.pk] assert backend.search_ids("invoice", user=viewer) == [doc.pk]
@@ -158,7 +157,7 @@ class TestPermissionFilteringOnIndexedDocuments:
checksum="perm-shared-group", checksum="perm-shared-group",
owner=owner, owner=owner,
) )
grant_object(group_member.groups.first(), doc, "view_document") assign_perm("view_document", group_member.groups.first(), doc)
backend.add_or_update(doc) backend.add_or_update(doc)
assert backend.search_ids("invoice", user=group_member) == [doc.pk] assert backend.search_ids("invoice", user=group_member) == [doc.pk]
+19 -22
View File
@@ -13,11 +13,11 @@ from documents.search._schema import needs_rebuild
from documents.search._schema import schema_fingerprint from documents.search._schema import schema_fingerprint
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path
import tantivy import tantivy
from pytest_django.fixtures import Settings from pytest_django.fixtures import Settings
from paperless_testing.dirs import PaperlessDirs
pytestmark = pytest.mark.search pytestmark = pytest.mark.search
@@ -25,19 +25,16 @@ pytestmark = pytest.mark.search
class TestNeedsRebuild: class TestNeedsRebuild:
"""needs_rebuild covers all sentinel-file states that require a full reindex.""" """needs_rebuild covers all sentinel-file states that require a full reindex."""
def test_returns_true_when_settings_file_missing( def test_returns_true_when_settings_file_missing(self, index_dir: Path) -> None:
self, assert needs_rebuild(index_dir) is True
paperless_dirs: PaperlessDirs,
) -> None:
assert needs_rebuild(paperless_dirs.index_dir) is True
def test_returns_false_when_version_and_language_match( def test_returns_false_when_version_and_language_match(
self, self,
paperless_dirs: PaperlessDirs, index_dir: Path,
settings: Settings, settings: Settings,
) -> None: ) -> None:
settings.SEARCH_LANGUAGE = "en" settings.SEARCH_LANGUAGE = "en"
(paperless_dirs.index_dir / ".index_settings.json").write_text( (index_dir / ".index_settings.json").write_text(
json.dumps( json.dumps(
{ {
"schema_version": SCHEMA_VERSION, "schema_version": SCHEMA_VERSION,
@@ -46,51 +43,51 @@ class TestNeedsRebuild:
}, },
), ),
) )
assert needs_rebuild(paperless_dirs.index_dir) is False assert needs_rebuild(index_dir) is False
def test_returns_true_on_schema_version_mismatch( def test_returns_true_on_schema_version_mismatch(
self, self,
paperless_dirs: PaperlessDirs, index_dir: Path,
settings: Settings, settings: Settings,
) -> None: ) -> None:
settings.SEARCH_LANGUAGE = None settings.SEARCH_LANGUAGE = None
(paperless_dirs.index_dir / ".index_settings.json").write_text( (index_dir / ".index_settings.json").write_text(
json.dumps({"schema_version": SCHEMA_VERSION - 1, "language": None}), json.dumps({"schema_version": SCHEMA_VERSION - 1, "language": None}),
) )
assert needs_rebuild(paperless_dirs.index_dir) is True assert needs_rebuild(index_dir) is True
def test_returns_true_when_version_is_not_an_integer( def test_returns_true_when_version_is_not_an_integer(
self, self,
paperless_dirs: PaperlessDirs, index_dir: Path,
settings: Settings, settings: Settings,
) -> None: ) -> None:
settings.SEARCH_LANGUAGE = None settings.SEARCH_LANGUAGE = None
(paperless_dirs.index_dir / ".index_settings.json").write_text( (index_dir / ".index_settings.json").write_text(
json.dumps({"schema_version": "not-a-number", "language": None}), json.dumps({"schema_version": "not-a-number", "language": None}),
) )
assert needs_rebuild(paperless_dirs.index_dir) is True assert needs_rebuild(index_dir) is True
def test_returns_true_when_language_key_missing( def test_returns_true_when_language_key_missing(
self, self,
paperless_dirs: PaperlessDirs, index_dir: Path,
settings: Settings, settings: Settings,
) -> None: ) -> None:
settings.SEARCH_LANGUAGE = "en" settings.SEARCH_LANGUAGE = "en"
(paperless_dirs.index_dir / ".index_settings.json").write_text( (index_dir / ".index_settings.json").write_text(
json.dumps({"schema_version": SCHEMA_VERSION}), json.dumps({"schema_version": SCHEMA_VERSION}),
) )
assert needs_rebuild(paperless_dirs.index_dir) is True assert needs_rebuild(index_dir) is True
def test_returns_true_when_language_differs( def test_returns_true_when_language_differs(
self, self,
paperless_dirs: PaperlessDirs, index_dir: Path,
settings: Settings, settings: Settings,
) -> None: ) -> None:
settings.SEARCH_LANGUAGE = "de" settings.SEARCH_LANGUAGE = "de"
(paperless_dirs.index_dir / ".index_settings.json").write_text( (index_dir / ".index_settings.json").write_text(
json.dumps({"schema_version": SCHEMA_VERSION, "language": "en"}), json.dumps({"schema_version": SCHEMA_VERSION, "language": "en"}),
) )
assert needs_rebuild(paperless_dirs.index_dir) is True assert needs_rebuild(index_dir) is True
def _schema_fields(schema: tantivy.Schema) -> dict[str, dict]: def _schema_fields(schema: tantivy.Schema) -> dict[str, dict]:
@@ -35,8 +35,6 @@ if TYPE_CHECKING:
from pytest_django.fixtures import SettingsWrapper from pytest_django.fixtures import SettingsWrapper
from paperless_testing.dirs import PaperlessDirs
pytestmark = pytest.mark.search pytestmark = pytest.mark.search
# The on-disk field layout of a v2 index, pinned as data. Any edit here is an # The on-disk field layout of a v2 index, pinned as data. Any edit here is an
@@ -471,7 +469,7 @@ def _fingerprint_of(descriptors: list[FieldDescriptor]) -> str:
class TestNeedsRebuildOnFingerprint: class TestNeedsRebuildOnFingerprint:
def test_matching_fingerprint_does_not_rebuild( def test_matching_fingerprint_does_not_rebuild(
self, self,
paperless_dirs: PaperlessDirs, index_dir: Path,
settings: SettingsWrapper, settings: SettingsWrapper,
) -> None: ) -> None:
""" """
@@ -484,13 +482,13 @@ class TestNeedsRebuildOnFingerprint:
- It returns False - It returns False
""" """
settings.SEARCH_LANGUAGE = None settings.SEARCH_LANGUAGE = None
_sentinels(paperless_dirs.index_dir) _sentinels(index_dir)
assert needs_rebuild(paperless_dirs.index_dir) is False assert needs_rebuild(index_dir) is False
def test_stale_fingerprint_rebuilds_despite_a_matching_version( def test_stale_fingerprint_rebuilds_despite_a_matching_version(
self, self,
paperless_dirs: PaperlessDirs, index_dir: Path,
settings: SettingsWrapper, settings: SettingsWrapper,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
@@ -507,7 +505,7 @@ class TestNeedsRebuildOnFingerprint:
every subsequent write would raise every subsequent write would raise
""" """
settings.SEARCH_LANGUAGE = None settings.SEARCH_LANGUAGE = None
_sentinels(paperless_dirs.index_dir) _sentinels(index_dir)
extended = [ extended = [
*field_descriptors(), *field_descriptors(),
FieldDescriptor( FieldDescriptor(
@@ -521,11 +519,11 @@ class TestNeedsRebuildOnFingerprint:
] ]
monkeypatch.setattr(_schema, "field_descriptors", lambda: extended) monkeypatch.setattr(_schema, "field_descriptors", lambda: extended)
assert needs_rebuild(paperless_dirs.index_dir) is True assert needs_rebuild(index_dir) is True
def test_reordered_schema_rebuilds( def test_reordered_schema_rebuilds(
self, self,
paperless_dirs: PaperlessDirs, index_dir: Path,
settings: SettingsWrapper, settings: SettingsWrapper,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
@@ -540,16 +538,16 @@ class TestNeedsRebuildOnFingerprint:
- It returns True - It returns True
""" """
settings.SEARCH_LANGUAGE = None settings.SEARCH_LANGUAGE = None
_sentinels(paperless_dirs.index_dir) _sentinels(index_dir)
reordered = field_descriptors() reordered = field_descriptors()
reordered[1], reordered[2] = reordered[2], reordered[1] reordered[1], reordered[2] = reordered[2], reordered[1]
monkeypatch.setattr(_schema, "field_descriptors", lambda: reordered) monkeypatch.setattr(_schema, "field_descriptors", lambda: reordered)
assert needs_rebuild(paperless_dirs.index_dir) is True assert needs_rebuild(index_dir) is True
def test_missing_fingerprint_rebuilds( def test_missing_fingerprint_rebuilds(
self, self,
paperless_dirs: PaperlessDirs, index_dir: Path,
settings: SettingsWrapper, settings: SettingsWrapper,
) -> None: ) -> None:
""" """
@@ -563,15 +561,15 @@ class TestNeedsRebuildOnFingerprint:
is rebuilt rather than trusted is rebuilt rather than trusted
""" """
settings.SEARCH_LANGUAGE = None settings.SEARCH_LANGUAGE = None
(paperless_dirs.index_dir / ".index_settings.json").write_text( (index_dir / ".index_settings.json").write_text(
json.dumps({"schema_version": SCHEMA_VERSION, "language": None}), json.dumps({"schema_version": SCHEMA_VERSION, "language": None}),
) )
assert needs_rebuild(paperless_dirs.index_dir) is True assert needs_rebuild(index_dir) is True
def test_written_sentinels_satisfy_the_check( def test_written_sentinels_satisfy_the_check(
self, self,
paperless_dirs: PaperlessDirs, index_dir: Path,
settings: SettingsWrapper, settings: SettingsWrapper,
) -> None: ) -> None:
""" """
@@ -584,6 +582,6 @@ class TestNeedsRebuildOnFingerprint:
- It returns False - It returns False
""" """
settings.SEARCH_LANGUAGE = "en" settings.SEARCH_LANGUAGE = "en"
_write_sentinels(paperless_dirs.index_dir) _write_sentinels(index_dir)
assert needs_rebuild(paperless_dirs.index_dir) is False assert needs_rebuild(index_dir) is False

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