mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-03 23:00:31 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5a4026d4f | ||
|
|
339f9a4d30 | ||
|
|
856140c068 | ||
|
|
62cf7c4429 | ||
|
|
9a2c2b9331 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }}"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because one or more lines are too long
+2
-4
@@ -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/
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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:",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+23
-23
@@ -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"
|
||||||
|
|||||||
Generated
+1959
-1311
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
|
||||||
|
|||||||
+347
@@ -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>
|
||||||
+11
@@ -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;
|
||||||
|
}
|
||||||
+186
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
+197
@@ -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 {
|
||||||
|
|||||||
+51
-77
@@ -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>
|
||||||
|
|||||||
-4
@@ -1,7 +1,3 @@
|
|||||||
.suggestions-dropdown {
|
.suggestions-dropdown {
|
||||||
min-width: 250px;
|
min-width: 250px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-link.dropdown-toggle::after {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|||||||
+1
-59
@@ -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')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
+3
-40
@@ -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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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/',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 'trigger' a workflow.</source>
|
<source>Use workflows to customize the behavior of Paperless-ngx when events 'trigger' a workflow.</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">Ссылки для обмена</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 'trigger' a workflow.</source>
|
<source>Use workflows to customize the behavior of Paperless-ngx when events 'trigger' a workflow.</source>
|
||||||
|
|||||||
@@ -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 'trigger' a workflow.</source>
|
<source>Use workflows to customize the behavior of Paperless-ngx when events 'trigger' a workflow.</source>
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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"]
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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),
|
|
||||||
)
|
|
||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
|
|
||||||
@@ -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 "
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user