Compare commits

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 21:04:51 -07:00
shamoon 9a2c2b9331 Basic advanced search query foundations 2026-09-19 21:04:51 -07:00
313 changed files with 56295 additions and 55474 deletions

No files matched your search

-2
View File
@@ -15,8 +15,6 @@
# Test related
**/.pytest_cache
**/tests
src/paperless_testing
src/conftest.py
**/*.spec.ts
**/htmlcov
# Local folders
-53
View File
@@ -1,53 +0,0 @@
name: Install apt packages
description: >
Installs apt packages with a per-attempt timeout and retries. The hosted runner's Azure apt mirror intermittently serves archives at a crawl, which apt's own timeout does not catch. A stuck attempt is killed and retried, which usually lands on a healthy mirror node.
inputs:
packages:
description: Space separated list of packages to install
required: true
attempts:
description: Maximum number of attempts
default: "3"
attempt-timeout:
description: Seconds before a single attempt is killed
default: "180"
runs:
using: composite
steps:
- name: Install ${{ inputs.packages }}
shell: bash
env:
PACKAGES: ${{ inputs.packages }}
ATTEMPTS: ${{ inputs.attempts }}
ATTEMPT_TIMEOUT: ${{ inputs.attempt-timeout }}
run: |
SUDO=""
if [ "$(id -u)" -ne 0 ]; then
SUDO="sudo"
fi
attempt_install() {
$SUDO apt-get update -q
# Intentionally unquoted, this is a list of package names
$SUDO apt-get install -q --yes --no-install-recommends $PACKAGES
}
export -f attempt_install
export SUDO PACKAGES
for attempt in $(seq 1 "${ATTEMPTS}"); do
echo "::group::apt install attempt ${attempt}/${ATTEMPTS}"
start=$(date +%s)
if timeout --kill-after=10 "${ATTEMPT_TIMEOUT}" bash -c attempt_install; then
echo "::endgroup::"
echo "Installed in $(( $(date +%s) - start ))s"
exit 0
fi
echo "::endgroup::"
echo "::warning::apt install attempt ${attempt}/${ATTEMPTS} failed or exceeded ${ATTEMPT_TIMEOUT}s"
# A killed attempt can leave dpkg half configured
$SUDO dpkg --configure -a || true
done
echo "::error::apt install failed after ${ATTEMPTS} attempts"
exit 1
+1
View File
@@ -59,6 +59,7 @@ updates:
- "drf-*"
- "djangorestframework"
- "whitenoise"
- "bleach"
- "jinja2"
# Async, Task Queuing & Caching
async-tasks:
+9 -8
View File
@@ -102,16 +102,17 @@ jobs:
with:
python-version: "${{ matrix.python-version }}"
- name: Install uv
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
python-version: ${{ steps.setup-python.outputs.python-version }}
- name: Install system dependencies
timeout-minutes: 12
uses: $/.github/actions/apt-install
with:
packages: unpaper tesseract-ocr imagemagick ghostscript poppler-utils
timeout-minutes: 10
run: |
sudo apt-get update -qq
sudo apt-get install -qq --no-install-recommends \
unpaper tesseract-ocr imagemagick ghostscript poppler-utils
- name: Configure ImageMagick
run: |
sudo cp docker/rootfs/etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-6/policy.xml
@@ -138,13 +139,13 @@ jobs:
pytest
- name: Upload test results to Codecov
if: always()
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
flags: backend-python-${{ matrix.python-version }}
files: junit.xml
report_type: test_results
- name: Upload coverage to Codecov
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
flags: backend-python-${{ matrix.python-version }}
files: coverage.xml
@@ -175,7 +176,7 @@ jobs:
with:
python-version: "${{ env.DEFAULT_PYTHON }}"
- name: Install uv
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
+3 -3
View File
@@ -106,7 +106,7 @@ jobs:
echo "repository=${repo_name}"
echo "name=${repo_name}" >> $GITHUB_OUTPUT
- 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
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
@@ -132,7 +132,7 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
- name: Build and push by digest
id: build
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./Dockerfile
@@ -182,7 +182,7 @@ jobs:
echo "Downloaded digests:"
ls -la /tmp/digests/
- 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
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
+1 -1
View File
@@ -78,7 +78,7 @@ jobs:
with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
+6 -7
View File
@@ -174,13 +174,13 @@ jobs:
run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
- name: Upload test results to Codecov
if: always()
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
flags: frontend-node-${{ matrix.node-version }}
directory: src-ui/
report_type: test_results
- name: Upload coverage to Codecov
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
flags: frontend-node-${{ matrix.node-version }}
directory: src-ui/coverage/
@@ -216,7 +216,7 @@ jobs:
with:
python-version: '3.12'
- name: Install uv
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
version: '0.12.x'
enable-cache: false
@@ -231,10 +231,9 @@ jobs:
- name: Install dependencies
run: cd src-ui && pnpm install --frozen-lockfile
- name: Install backend system dependencies
timeout-minutes: 12
uses: $/.github/actions/apt-install
with:
packages: libmagic1
run: |
apt-get update
apt-get install --yes --quiet --no-install-recommends libmagic1
- name: Install backend dependencies
env:
PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }}
+5 -6
View File
@@ -59,7 +59,7 @@ jobs:
with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: false
@@ -70,10 +70,9 @@ jobs:
run: |
uv sync --python "${PYTHON_VERSION}" --dev --frozen
- name: Install system dependencies
timeout-minutes: 12
uses: $/.github/actions/apt-install
with:
packages: gettext liblept5
run: |
sudo apt-get update -qq
sudo apt-get install -qq --no-install-recommends gettext liblept5
# ---- Build Documentation ----
- name: Build documentation
env:
@@ -213,7 +212,7 @@ jobs:
with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: false
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
- name: Run Semgrep
run: semgrep scan --config auto --sarif-output results.sarif
- 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()
with:
sarif_file: results.sarif
+2 -2
View File
@@ -29,7 +29,7 @@ jobs:
steps:
- name: Clean temporary images
if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/ephemeral@21f875bab2376314e0525c614e923c669434f0e4 # v0.13.0
uses: stumpylog/image-cleaner-action/ephemeral@4fe057d991d63b8f6d5d22c40f17c1bca2226537 # v0.12.0
with:
token: "${{ env.TOKEN }}"
owner: "${{ github.repository_owner }}"
@@ -56,7 +56,7 @@ jobs:
steps:
- name: Clean untagged images
if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/untagged@21f875bab2376314e0525c614e923c669434f0e4 # v0.13.0
uses: stumpylog/image-cleaner-action/untagged@4fe057d991d63b8f6d5d22c40f17c1bca2226537 # v0.12.0
with:
token: "${{ env.TOKEN }}"
owner: "${{ github.repository_owner }}"
+2 -2
View File
@@ -39,7 +39,7 @@ jobs:
persist-credentials: false
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
with:
languages: ${{ matrix.language }}
# 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.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
token: ${{ secrets.PNGX_BOT_PAT }}
persist-credentials: false
- name: crowdin action
uses: crowdin/github-action@9c23991700c0ec5256fd41089b9d9d7d540e424e # v3.3.0
uses: crowdin/github-action@0d5670f539973aea2f01abce61a8989934df0025 # v3.0.2
with:
upload_translations: false
download_translations: true
+4 -5
View File
@@ -25,12 +25,11 @@ jobs:
id: setup-python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- name: Install system dependencies
timeout-minutes: 12
uses: $/.github/actions/apt-install
with:
packages: gettext
run: |
sudo apt-get update -qq
sudo apt-get install -qq --no-install-recommends gettext
- name: Install uv
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
+456 -706
View File
File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -38,7 +38,7 @@ repos:
- json
# See https://github.com/prettier/prettier/issues/15742 for the fork reason
- repo: https://github.com/rbubley/mirrors-prettier
rev: 'v3.9.9'
rev: 'v3.9.6'
hooks:
- id: prettier
types_or:
@@ -46,16 +46,16 @@ repos:
- ts
- markdown
additional_dependencies:
- prettier@3.9.9
- prettier@3.9.6
- 'prettier-plugin-organize-imports@4.3.0'
# Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.9
rev: v0.16.5
hooks:
- id: ruff-check
- id: ruff-format
- repo: https://github.com/tox-dev/pyproject-fmt
rev: "v2.29.4"
rev: "v2.28.1"
hooks:
- id: pyproject-fmt
additional_dependencies: [tomli]
+14241 -6104
View File
File diff suppressed because it is too large. Load diff
+2 -4
View File
@@ -30,7 +30,7 @@ RUN set -eux \
# Purpose: Installs s6-overlay and rootfs
# Comments:
# - Don't leave anything extra in here either
FROM ghcr.io/astral-sh/uv:0.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
@@ -171,9 +171,7 @@ RUN set -eux \
&& cp /etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-6/policy.xml \
&& echo "Cleaning up image layer" \
&& rm --force --verbose *.deb \
&& 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
&& rm --recursive --force --verbose /var/lib/apt/lists/*
WORKDIR /usr/src/paperless/src/
+6 -6
View File
@@ -21,7 +21,7 @@ Paperless-ngx is a document management system that transforms your physical docu
Paperless-ngx is the official successor to the original [Paperless](https://github.com/the-paperless-project/paperless) & [Paperless-ng](https://github.com/jonaswinkler/paperless-ng) projects and is designed to distribute the responsibility of advancing and supporting the project among a team of people. [Consider joining us!](#community-support)
Thanks to the generous folks at [Miget](https://miget.com), a demo is available at [demo.paperless-ngx.com](https://demo.paperless-ngx.com) using login `demo` / `demo`. _Note: demo content is reset frequently and confidential information should not be uploaded._
Thanks to the generous folks at [DigitalOcean](https://m.do.co/c/8d70b916d462), a demo is available at [demo.paperless-ngx.com](https://demo.paperless-ngx.com) using login `demo` / `demo`. _Note: demo content is reset frequently and confidential information should not be uploaded._
- [Features](#features)
- [Getting started](#getting-started)
@@ -33,12 +33,12 @@ Thanks to the generous folks at [Miget](https://miget.com), a demo is available
- [Related Projects](#related-projects)
- [Important Note](#important-note)
<p align="right">Demo hosting provided by:<br/>
<a href="https://miget.com" style="padding-top: 4px; display: block;">
<p align="right">This project is supported by:<br/>
<a href="https://m.do.co/c/8d70b916d462" style="padding-top: 4px; display: block;">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/sponsors/miget-white.png" width="140px">
<source media="(prefers-color-scheme: light)" srcset="docs/assets/sponsors/miget-black.png" width="140px">
<img src="docs/assets/sponsors/miget-black.png" alt="Miget" width="140px">
<source media="(prefers-color-scheme: dark)" srcset="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_white.svg" width="140px">
<source media="(prefers-color-scheme: light)" srcset="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" width="140px">
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_black_.svg" width="140px">
</picture>
</a>
</p>
+1 -1
View File
@@ -24,7 +24,7 @@ services:
network_mode: host
restart: unless-stopped
greenmail:
image: docker.io/greenmail/standalone:2.1.14
image: docker.io/greenmail/standalone:2.1.13
hostname: greenmail
container_name: greenmail
environment:
-18
View File
@@ -1,18 +0,0 @@
#!/bin/sh
# Source s6 container environment for interactive shells.
# Ensures variables resolved from *_FILE secret injection are visible
# when using 'docker exec bash'. Does not affect s6 services (those
# use with-contenv directly). Has no effect in non-container contexts
# because the directory will not exist.
# Note: sh/dash shells opened via 'docker exec sh' are not covered;
# only bash-based sessions benefit from this file.
_pngx_contenv="/run/s6/container_environment"
if [ -d "${_pngx_contenv}" ]; then
for _pngx_f in "${_pngx_contenv}"/*; do
[ -f "${_pngx_f}" ] || continue
_pngx_name=$(basename "${_pngx_f}")
_pngx_val=$(cat "${_pngx_f}")
export "${_pngx_name}=${_pngx_val}"
done
fi
unset _pngx_contenv _pngx_f _pngx_name _pngx_val
@@ -2,7 +2,6 @@
# shellcheck shell=bash
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..."
@@ -10,20 +9,12 @@ if [[ -n "${PAPERLESS_ENABLE_FLOWER}" ]]; then
# Small delay to allow celery to be up first
echo "${log_prefix} Starting flower in 5s"
sleep 5
cd "${PAPERLESS_SRC_DIR}" || exit 1
# 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
cd ${PAPERLESS_SRC_DIR}
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
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
else
+9 -27
View File
@@ -136,15 +136,13 @@ for suggested generation and embedding models.
### AI-assisted suggestions
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
detail page. You can choose which type of suggestions are requested by default under Settings >
Documents, either ML (classifier-based) suggestions, AI suggestions, or both. When both are requested
the results are combined.
Suggestions are requested 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 language can be steered with [`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE`](configuration.md#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE)
storage path and dates by sending the document to the LLM. This is **opt-in per request**
and surfaces through the "Suggest" control on the document detail page, alongside the
classic classifier-based suggestions — it does not disable them. Suggestions are requested
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
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).
### 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
[`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
LLM API key and endpoint are used, but an optional embedding-specific[API key](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_API_KEY)
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.
(`huggingface` for fully-local embeddings, or `ollama` / `openai-like`). 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
[`PAPERLESS_LLM_INDEX_TASK_CRON`](configuration.md#PAPERLESS_LLM_INDEX_TASK_CRON) (daily by
@@ -1010,19 +1005,6 @@ documents to both separate and categorize them in a single operation.
**Example:** A 6-page scan with TAG:invoice on page 3 and TAG:receipt on page 5 will create
three documents: pages 1-2 (no tags), pages 3-4 (tagged "invoice"), and pages 5-6 (tagged "receipt").
### Barcode Contents {#barcode-contents}
By default, Paperless only uses barcodes for splitting, ASNs and tags. With
[`PAPERLESS_CONSUMER_STORE_BARCODE_VALUES`](configuration.md#PAPERLESS_CONSUMER_STORE_BARCODE_VALUES)
enabled, it stores the content of every barcode with the document, e.g. payment codes or QR codes.
- Barcodes are listed on the **Metadata** tab with page, type and content, and can be copied.
- The API returns them in the `barcodes` field of `/api/documents/{id}/metadata/`.
- They can be [searched](usage.md#searching-barcodes), e.g. `barcodes:DE89370400440532013000`.
- Only the first [`PAPERLESS_CONSUMER_BARCODE_MAX_PAGES`](configuration.md#PAPERLESS_CONSUMER_BARCODE_MAX_PAGES)
pages are scanned. Reprocessing reads the barcodes of existing documents.
- Each version keeps its own barcodes, and the newest version's are shown and searched.
## Automatic collation of double-sided documents {#collate}
!!! note
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

-150
View File
@@ -1,155 +1,5 @@
# Changelog
## paperless-ngx 3.3.0
### Features / Enhancements
- Enhancement: more control over suggestion requests [@shamoon](https://github.com/shamoon) ([#14258](https://github.com/paperless-ngx/paperless-ngx/pull/14258))
- Chorehancement: set manifest CORS for credentials [@shamoon](https://github.com/shamoon) ([#14307](https://github.com/paperless-ngx/paperless-ngx/pull/14307))
- Enhancement: include Django admin with 2FA [@shamoon](https://github.com/shamoon) ([#14270](https://github.com/paperless-ngx/paperless-ngx/pull/14270))
- Feature: propagate resolved secrets to interactive container shells [@stumpylog](https://github.com/stumpylog) ([#14254](https://github.com/paperless-ngx/paperless-ngx/pull/14254))
- Enhancement: support separate embedding API key [@furkanural](https://github.com/furkanural) ([#14067](https://github.com/paperless-ngx/paperless-ngx/pull/14067))
- Enhancement: support passthrough extra params for LLMs [@shamoon](https://github.com/shamoon) ([#14202](https://github.com/paperless-ngx/paperless-ngx/pull/14202))
- Feature: store barcode contents, list and search them [@jurassicparkicecream](https://github.com/jurassicparkicecream) ([#14276](https://github.com/paperless-ngx/paperless-ngx/pull/14276))
### Bug Fixes
- Fix: Set the ProcessedMail owner based on the rule owner in all cases [@stumpylog](https://github.com/stumpylog) ([#14356](https://github.com/paperless-ngx/paperless-ngx/pull/14356))
- Fix: Ensure log rotation settings are converted to integers [@stumpylog](https://github.com/stumpylog) ([#14343](https://github.com/paperless-ngx/paperless-ngx/pull/14343))
- Fix: Wrap apt calls into a retry so we can ideally jump a slow mirror [@stumpylog](https://github.com/stumpylog) ([#14344](https://github.com/paperless-ngx/paperless-ngx/pull/14344))
- Fix: ship pdf.js CMaps so CJK documents render in the viewer [@MrOggy85](https://github.com/MrOggy85) ([#14318](https://github.com/paperless-ngx/paperless-ngx/pull/14318))
- Fix: use version page\_count for versioned document [@shamoon](https://github.com/shamoon) ([#14280](https://github.com/paperless-ngx/paperless-ngx/pull/14280))
- Fix: allow pointer events for pdf links in pngx viewer [@shamoon](https://github.com/shamoon) ([#14264](https://github.com/paperless-ngx/paperless-ngx/pull/14264))
- Fix: During a move to the trash directory, only attempt to copy metadata [@stumpylog](https://github.com/stumpylog) ([#14250](https://github.com/paperless-ngx/paperless-ngx/pull/14250))
- Fix: convert file mtime to the configured time zone directly [@stumpylog](https://github.com/stumpylog) ([#14249](https://github.com/paperless-ngx/paperless-ngx/pull/14249))
- Fix: ensure documentDeleted subscription is discarded [@shamoon](https://github.com/shamoon) ([#14247](https://github.com/paperless-ngx/paperless-ngx/pull/14247))
- Chore: Fix bugs in the test suite [@stumpylog](https://github.com/stumpylog) ([#14244](https://github.com/paperless-ngx/paperless-ngx/pull/14244))
- Fix: ensure bulk operations are checked against version root [@shamoon](https://github.com/shamoon) ([#14246](https://github.com/paperless-ngx/paperless-ngx/pull/14246))
- Fix: indexing after document-added workflows signal [@shamoon](https://github.com/shamoon) ([#14242](https://github.com/paperless-ngx/paperless-ngx/pull/14242))
- Fix: Record full tag and custom field lists in bulk edit audit log [@stumpylog](https://github.com/stumpylog) ([#14236](https://github.com/paperless-ngx/paperless-ngx/pull/14236))
- Chore: update pikepdf for ocrmypdf requirement [@shamoon](https://github.com/shamoon) ([#14235](https://github.com/paperless-ngx/paperless-ngx/pull/14235))
- Fix: handle legacy bulk edit split page range with missing page\_count [@shamoon](https://github.com/shamoon) ([#14212](https://github.com/paperless-ngx/paperless-ngx/pull/14212))
- Fix: ignore invalid EXIF orientation when generating image archives [@zhzy0077](https://github.com/zhzy0077) ([#14203](https://github.com/paperless-ngx/paperless-ngx/pull/14203))
### Documentation
- Documentation: correct duplicates info [@shamoon](https://github.com/shamoon) ([#14243](https://github.com/paperless-ngx/paperless-ngx/pull/14243))
### Maintenance
- Chore(deps): Bump the actions group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14332](https://github.com/paperless-ngx/paperless-ngx/pull/14332))
- Fix: Wrap apt calls into a retry so we can ideally jump a slow mirror [@stumpylog](https://github.com/stumpylog) ([#14344](https://github.com/paperless-ngx/paperless-ngx/pull/14344))
- Chore(deps): Bump the actions group across 1 directory with 10 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14301](https://github.com/paperless-ngx/paperless-ngx/pull/14301))
### Dependencies
<details>
<summary>27 changes</summary>
- Chore(deps): Bump django-filter from 25.2 to 26.1 @[dependabot[bot]](https://github.com/apps/dependabot) ([#14337](https://github.com/paperless-ngx/paperless-ngx/pull/14337))
- Chore(deps): Bump the utilities-patch group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14338](https://github.com/paperless-ngx/paperless-ngx/pull/14338))
- Chore(deps): Bump the pre-commit-dependencies group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14351](https://github.com/paperless-ngx/paperless-ngx/pull/14351))
- Chore(deps-dev): Bump types-channels from 4.3.0.20260408 to 4.3.0.20260518 @[dependabot[bot]](https://github.com/apps/dependabot) ([#14335](https://github.com/paperless-ngx/paperless-ngx/pull/14335))
- docker(deps): Bump astral-sh/uv from 0.12.20-python3.14-trixie-slim to 0.12.23-python3.14-trixie-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#14327](https://github.com/paperless-ngx/paperless-ngx/pull/14327))
- Chore(deps): Bump the actions group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14332](https://github.com/paperless-ngx/paperless-ngx/pull/14332))
- Chore(deps): Bump the uv group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14325](https://github.com/paperless-ngx/paperless-ngx/pull/14325))
- Chore(deps): Bump the frontend-angular-dependencies group across 1 directory with 13 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14329](https://github.com/paperless-ngx/paperless-ngx/pull/14329))
- Chore(deps-dev): Bump prettier from 3.9.8 to 3.9.9 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14331](https://github.com/paperless-ngx/paperless-ngx/pull/14331))
- Chore(deps-dev): Bump the frontend-eslint-dependencies group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14330](https://github.com/paperless-ngx/paperless-ngx/pull/14330))
- Chore(deps-dev): Bump zensical from 0.0.64 to 0.0.65 in the development group @[dependabot[bot]](https://github.com/apps/dependabot) ([#14326](https://github.com/paperless-ngx/paperless-ngx/pull/14326))
- Chore(deps): Bump the uv group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14314](https://github.com/paperless-ngx/paperless-ngx/pull/14314))
- Chore(deps): Bump the utilities-minor group across 1 directory with 7 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14305](https://github.com/paperless-ngx/paperless-ngx/pull/14305))
- docker-compose(deps): bump greenmail/standalone from 2.1.13 to 2.1.14 in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#14281](https://github.com/paperless-ngx/paperless-ngx/pull/14281))
- docker(deps): Bump astral-sh/uv from 0.12.16-python3.14-trixie-slim to 0.12.20-python3.14-trixie-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#14282](https://github.com/paperless-ngx/paperless-ngx/pull/14282))
- Chore(deps): Bump the pre-commit-dependencies group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14283](https://github.com/paperless-ngx/paperless-ngx/pull/14283))
- Chore(deps): Bump the utilities-patch group across 1 directory with 6 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14297](https://github.com/paperless-ngx/paperless-ngx/pull/14297))
- Chore(deps): Bump the actions group across 1 directory with 10 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14301](https://github.com/paperless-ngx/paperless-ngx/pull/14301))
- Chore(deps-dev): Bump the frontend-jest-dependencies group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14286](https://github.com/paperless-ngx/paperless-ngx/pull/14286))
- Chore(deps-dev): Bump eslint from 10.10.0 to 10.11.0 in /src-ui in the frontend-eslint-dependencies group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#14287](https://github.com/paperless-ngx/paperless-ngx/pull/14287))
- Chore(deps-dev): Bump @types/node from 26.5.0 to 26.6.2 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14288](https://github.com/paperless-ngx/paperless-ngx/pull/14288))
- Chore(deps-dev): Bump prettier from 3.9.6 to 3.9.8 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14289](https://github.com/paperless-ngx/paperless-ngx/pull/14289))
- Chore(deps): Bump the frontend-angular-dependencies group across 1 directory with 10 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14285](https://github.com/paperless-ngx/paperless-ngx/pull/14285))
- Chore: replace bleach with turbohtml [@gaborbernat](https://github.com/gaborbernat) ([#14269](https://github.com/paperless-ngx/paperless-ngx/pull/14269))
- Chore(deps): Bump autobahn from 25.12.2 to 26.7.1 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#14231](https://github.com/paperless-ngx/paperless-ngx/pull/14231))
- Chore: update pikepdf for ocrmypdf requirement [@shamoon](https://github.com/shamoon) ([#14235](https://github.com/paperless-ngx/paperless-ngx/pull/14235))
- Chore(deps): Bump the pre-commit-dependencies group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14133](https://github.com/paperless-ngx/paperless-ngx/pull/14133))
</details>
### All App Changes
<details>
<summary>41 changes</summary>
- Feature: store barcode contents, list and search them [@jurassicparkicecream](https://github.com/jurassicparkicecream) ([#14276](https://github.com/paperless-ngx/paperless-ngx/pull/14276))
- Fix: Set the ProcessedMail owner based on the rule owner in all cases [@stumpylog](https://github.com/stumpylog) ([#14356](https://github.com/paperless-ngx/paperless-ngx/pull/14356))
- Chore(deps): Bump django-filter from 25.2 to 26.1 @[dependabot[bot]](https://github.com/apps/dependabot) ([#14337](https://github.com/paperless-ngx/paperless-ngx/pull/14337))
- Chore(deps): Bump the utilities-patch group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14338](https://github.com/paperless-ngx/paperless-ngx/pull/14338))
- Chore(deps-dev): Bump types-channels from 4.3.0.20260408 to 4.3.0.20260518 @[dependabot[bot]](https://github.com/apps/dependabot) ([#14335](https://github.com/paperless-ngx/paperless-ngx/pull/14335))
- Chore(deps): Bump the uv group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14325](https://github.com/paperless-ngx/paperless-ngx/pull/14325))
- Fix: Ensure log rotation settings are converted to integers [@stumpylog](https://github.com/stumpylog) ([#14343](https://github.com/paperless-ngx/paperless-ngx/pull/14343))
- Chore(deps): Bump the frontend-angular-dependencies group across 1 directory with 13 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14329](https://github.com/paperless-ngx/paperless-ngx/pull/14329))
- Chore(deps-dev): Bump prettier from 3.9.8 to 3.9.9 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14331](https://github.com/paperless-ngx/paperless-ngx/pull/14331))
- Chore(deps-dev): Bump the frontend-eslint-dependencies group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14330](https://github.com/paperless-ngx/paperless-ngx/pull/14330))
- Fix: ship pdf.js CMaps so CJK documents render in the viewer [@MrOggy85](https://github.com/MrOggy85) ([#14318](https://github.com/paperless-ngx/paperless-ngx/pull/14318))
- Chore(deps-dev): Bump zensical from 0.0.64 to 0.0.65 in the development group @[dependabot[bot]](https://github.com/apps/dependabot) ([#14326](https://github.com/paperless-ngx/paperless-ngx/pull/14326))
- Enhancement: more control over suggestion requests [@shamoon](https://github.com/shamoon) ([#14258](https://github.com/paperless-ngx/paperless-ngx/pull/14258))
- Chore(deps): Bump the uv group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14314](https://github.com/paperless-ngx/paperless-ngx/pull/14314))
- Chore: anchor admin url pattern [@shamoon](https://github.com/shamoon) ([#14316](https://github.com/paperless-ngx/paperless-ngx/pull/14316))
- Chorehancement: set manifest CORS for credentials [@shamoon](https://github.com/shamoon) ([#14307](https://github.com/paperless-ngx/paperless-ngx/pull/14307))
- Chore(deps): Bump the utilities-minor group across 1 directory with 7 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14305](https://github.com/paperless-ngx/paperless-ngx/pull/14305))
- Chore(deps): Bump the utilities-patch group across 1 directory with 6 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14297](https://github.com/paperless-ngx/paperless-ngx/pull/14297))
- Chore(deps-dev): Bump the frontend-jest-dependencies group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14286](https://github.com/paperless-ngx/paperless-ngx/pull/14286))
- Chore(deps-dev): Bump eslint from 10.10.0 to 10.11.0 in /src-ui in the frontend-eslint-dependencies group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#14287](https://github.com/paperless-ngx/paperless-ngx/pull/14287))
- Chore(deps-dev): Bump @types/node from 26.5.0 to 26.6.2 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14288](https://github.com/paperless-ngx/paperless-ngx/pull/14288))
- Chore(deps-dev): Bump prettier from 3.9.6 to 3.9.8 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14289](https://github.com/paperless-ngx/paperless-ngx/pull/14289))
- Chore(deps): Bump the frontend-angular-dependencies group across 1 directory with 10 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14285](https://github.com/paperless-ngx/paperless-ngx/pull/14285))
- Fix: use version page\_count for versioned document [@shamoon](https://github.com/shamoon) ([#14280](https://github.com/paperless-ngx/paperless-ngx/pull/14280))
- Chore: replace bleach with turbohtml [@gaborbernat](https://github.com/gaborbernat) ([#14269](https://github.com/paperless-ngx/paperless-ngx/pull/14269))
- Enhancement: include Django admin with 2FA [@shamoon](https://github.com/shamoon) ([#14270](https://github.com/paperless-ngx/paperless-ngx/pull/14270))
- Fix: allow pointer events for pdf links in pngx viewer [@shamoon](https://github.com/shamoon) ([#14264](https://github.com/paperless-ngx/paperless-ngx/pull/14264))
- Feature: propagate resolved secrets to interactive container shells [@stumpylog](https://github.com/stumpylog) ([#14254](https://github.com/paperless-ngx/paperless-ngx/pull/14254))
- Fix: During a move to the trash directory, only attempt to copy metadata [@stumpylog](https://github.com/stumpylog) ([#14250](https://github.com/paperless-ngx/paperless-ngx/pull/14250))
- Fix: convert file mtime to the configured time zone directly [@stumpylog](https://github.com/stumpylog) ([#14249](https://github.com/paperless-ngx/paperless-ngx/pull/14249))
- Fix: ensure documentDeleted subscription is discarded [@shamoon](https://github.com/shamoon) ([#14247](https://github.com/paperless-ngx/paperless-ngx/pull/14247))
- Chore: Fix bugs in the test suite [@stumpylog](https://github.com/stumpylog) ([#14244](https://github.com/paperless-ngx/paperless-ngx/pull/14244))
- Fix: ensure bulk operations are checked against version root [@shamoon](https://github.com/shamoon) ([#14246](https://github.com/paperless-ngx/paperless-ngx/pull/14246))
- Enhancement: support separate embedding API key [@furkanural](https://github.com/furkanural) ([#14067](https://github.com/paperless-ngx/paperless-ngx/pull/14067))
- Enhancement: support passthrough extra params for LLMs [@shamoon](https://github.com/shamoon) ([#14202](https://github.com/paperless-ngx/paperless-ngx/pull/14202))
- Chore(deps): Bump autobahn from 25.12.2 to 26.7.1 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#14231](https://github.com/paperless-ngx/paperless-ngx/pull/14231))
- Fix: indexing after document-added workflows signal [@shamoon](https://github.com/shamoon) ([#14242](https://github.com/paperless-ngx/paperless-ngx/pull/14242))
- Fix: Record full tag and custom field lists in bulk edit audit log [@stumpylog](https://github.com/stumpylog) ([#14236](https://github.com/paperless-ngx/paperless-ngx/pull/14236))
- Chore: update pikepdf for ocrmypdf requirement [@shamoon](https://github.com/shamoon) ([#14235](https://github.com/paperless-ngx/paperless-ngx/pull/14235))
- Fix: handle legacy bulk edit split page range with missing page\_count [@shamoon](https://github.com/shamoon) ([#14212](https://github.com/paperless-ngx/paperless-ngx/pull/14212))
- Fix: ignore invalid EXIF orientation when generating image archives [@zhzy0077](https://github.com/zhzy0077) ([#14203](https://github.com/paperless-ngx/paperless-ngx/pull/14203))
</details>
## 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
### Features / Enhancements
-27
View File
@@ -1796,13 +1796,6 @@ assigns or creates tags if a properly formatted barcode is detected.
Defaults to false.
#### [`PAPERLESS_CONSUMER_STORE_BARCODE_VALUES=<bool>`](#PAPERLESS_CONSUMER_STORE_BARCODE_VALUES) {#PAPERLESS_CONSUMER_STORE_BARCODE_VALUES}
: Stores the content of every barcode found during consumption, see
[Barcode Contents](advanced_usage.md#barcode-contents).
Defaults to false.
## Audit Trail
#### [`PAPERLESS_AUDIT_LOG_ENABLED=<bool>`](#PAPERLESS_AUDIT_LOG_ENABLED) {#PAPERLESS_AUDIT_LOG_ENABLED}
@@ -2140,13 +2133,6 @@ for language and resource considerations.
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}
: The endpoint / url to use for the embedding backend. If not supplied, embeddings use
@@ -2231,19 +2217,6 @@ used with the OpenAI-compatible backend to target a custom provider or local gat
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}
: Configures the schedule to update the AI embeddings of text content and metadata for all documents. Only performed if
-1
View File
@@ -150,7 +150,6 @@ pnpm ng build --configuration production
is loaded as well. However, the tests rely on the default
configuration. This is not ideal. But for now, make sure no settings
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
+1 -3
View File
@@ -76,9 +76,7 @@ is not supported by any of the available parsers.
**A:** Not by default. As of v3, a file whose contents match an existing document is still
consumed, and the duplicate is flagged in the UI — open the document and check the
**Duplicates** tab to review documents that share the same content, or filter the document
list by **Duplicates** to find all of them (see
[Duplicate documents](usage.md#duplicate-documents)). If you prefer the old
**Duplicates** tab to review documents that share the same content. If you prefer the old
behavior of rejecting duplicates during consumption, set
[`PAPERLESS_CONSUMER_DELETE_DUPLICATES`](configuration.md#PAPERLESS_CONSUMER_DELETE_DUPLICATES)
to `true`.
+3 -3
View File
@@ -13,9 +13,9 @@ physical documents into a searchable online archive so you can keep, well, _less
[Demo](https://demo.paperless-ngx.com){ .md-button .md-button--secondary target=\_blank }
<div style="display: flex; justify-content: end; margin-top: -1.5rem;">
<a href="https://miget.com" target="_blank" aria-label="Demo hosting provided by Miget">
<img src="assets/sponsors/miget-white.png#only-dark" alt="Miget" class="no-lightbox" width="150px">
<img src="assets/sponsors/miget-black.png#only-light" alt="Miget" class="no-lightbox" width="150px">
<a href="https://m.do.co/c/8d70b916d462" target="_blank">
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_white.svg#only-dark" class="no-lightbox" width="150px">
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_black.svg#only-light" class="no-lightbox" width="150px">
</a>
</div>
+8 -21
View File
@@ -299,18 +299,19 @@ for details.
### Duplicate documents
By default, Paperless-ngx **does not reject duplicates**. If you consume a file whose
contents match an existing document (same original or archive checksum), the new copy is
still consumed and a warning is logged.
contents exactly match an existing document (same checksum), the new copy is still
consumed and a warning is logged. The task entry for the upload also flags that a
duplicate was detected and links to the existing document(s).
When a document has duplicates, a **Duplicates** tab appears on its detail page, listing
the other documents you can view that share the same content (including any in the trash).
To find all documents with duplicates, choose **Duplicates** in the document list's text
filter dropdown, or use `has_duplicates=true` in the REST API.
To review duplicates, open a document and switch to the **Duplicates** tab on the
document detail page. It lists other documents that share the same content, including any
that are in the trash (shown with a badge), and links to each so you can decide which to
keep.
If you would rather reject duplicates at consumption time (the pre-v3 behavior), set
[`PAPERLESS_CONSUMER_DELETE_DUPLICATES`](configuration.md#PAPERLESS_CONSUMER_DELETE_DUPLICATES)
to `true`. The duplicate file is then deleted instead of consumed, and the task fails with
a "Document already exists" message linking to the existing document.
a "document already exists" message.
## Document Suggestions
@@ -1060,20 +1061,6 @@ notes.user:alice notes.note:insurance
The bare `notes:` prefix is shorthand for `notes.note:`.
#### Searching barcodes
If [barcode contents are stored](advanced_usage.md#barcode-contents), they can be searched by
content or type, but only with a field name:
```
barcodes.value:DE89370400440532013000
barcodes.format:qrcode
barcodes:wifi barcodes:guest
```
`barcodes:` is shorthand for `barcodes.value:`. Separators are stripped, so each part of e.g.
`WIFI:S:Guest;P:secret;;` can be searched on its own.
All of these can be combined. Syntax not described here may not work as expected, and an unknown field name is searched as ordinary text.
!!! note
+14 -25
View File
@@ -1,9 +1,7 @@
[project]
name = "paperless-ngx"
version = "3.3.0"
description = """\
A community-supported supercharged document management system: scan, index and archive all your physical documents\
"""
version = "3.2.0"
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
readme = "README.md"
requires-python = ">=3.11"
classifiers = [
@@ -12,13 +10,13 @@ classifiers = [
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: 3.15",
]
# TODO: Move certain things to groups and then utilize that further
# This will allow testing to not install a webserver, mysql, etc
dependencies = [
"azure-ai-documentintelligence>=1.0.2",
"babel>=2.17",
"bleach~=6.4.0",
"celery[redis]~=5.6.2",
"channels~=4.2",
"channels-redis~=4.2",
@@ -33,8 +31,8 @@ dependencies = [
"django-compression-middleware~=0.5.0",
"django-cors-headers~=4.9.0",
"django-extensions~=4.1",
"django-filter>=25.1,<27",
"django-guardian>=3.3.3,<3.6",
"django-filter~=25.1",
"django-guardian>=3.3.3,<3.5",
"django-multiselectfield~=1.0.1",
"django-rich~=2.2.0",
"django-soft-delete~=1.0.18",
@@ -44,7 +42,7 @@ dependencies = [
"drf-spectacular-sidecar>=2026.7.1,<2026.10",
"drf-writable-nested~=0.7.1",
"filelock~=3.32.0",
"flower>=2.0.1,<2.3",
"flower>=2.0.1,<2.2",
"gotenberg-client[httpx]~=1.0",
"httpx-oauth~=0.17",
"ijson>=3.5.1",
@@ -76,7 +74,6 @@ dependencies = [
"tantivy~=0.26.0",
"tika-client[httpx]~=1.0",
"torch>=2.13,<2.15",
"turbohtml~=1.10.0",
"watchfiles>=1.2",
"whitenoise~=6.11",
"whoosh-compat[tantivy]==0.3",
@@ -84,13 +81,13 @@ dependencies = [
]
[project.optional-dependencies]
mariadb = [
"mysqlclient>=2.2.7,<2.4",
"mysqlclient~=2.2.7",
]
postgres = [
"psycopg[c,pool]==3.3.4",
# Direct dependency for proper resolution of the pre-built wheels
"psycopg-c==3.3.4",
"psycopg-pool==3.3.3",
"psycopg-pool==3.3.1",
]
webserver = [
"granian[uvloop]>=2.7,<2.9",
@@ -112,7 +109,7 @@ lint = [
testing = [
"daphne",
"factory-boy~=3.3.1",
"faker>=40.36,<40.40",
"faker>=40.36,<40.39",
"imagehash",
"pytest~=9.1.1",
"pytest-cov~=7.1.0",
@@ -120,7 +117,7 @@ testing = [
"pytest-env~=1.7.0",
"pytest-httpx",
"pytest-mock~=3.15.1",
"pytest-randomly~=5.0.0",
# "pytest-randomly~=4.0.1",
"pytest-rerunfailures~=16.4",
"pytest-sugar",
"pytest-xdist~=3.8.0",
@@ -136,6 +133,7 @@ typing = [
"mypy",
"mypy-baseline",
"pyrefly",
"types-bleach",
"types-channels",
"types-colorama",
"types-dateparser",
@@ -150,7 +148,6 @@ typing = [
[tool.uv]
required-version = ">=0.9.0"
prerelease = "disallow"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
@@ -247,10 +244,6 @@ per-file-ignores."docker/wait-for-redis.py" = [
per-file-ignores."src/documents/models.py" = [
"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
[tool.codespell]
@@ -278,9 +271,9 @@ plugins = [
]
[tool.pyrefly]
baseline = ".pyrefly-baseline.json"
python-platform = "linux"
search-path = [ "src" ]
baseline = ".pyrefly-baseline.json"
[tool.django-stubs]
django_settings_module = "paperless.settings"
@@ -333,8 +326,6 @@ PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
PAPERLESS_CHANNELS_BACKEND = "channels.layers.InMemoryChannelLayer"
# I don't think anything hits this, but just in case, basically infinite
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]
source = [
@@ -343,15 +334,13 @@ source = [
omit = [
"*/tests/*",
"manage.py",
"paperless/auth.py",
"paperless/wsgi.py",
"src/conftest.py",
"src/paperless_testing/*",
"paperless/auth.py",
]
[tool.coverage.report]
exclude_also = [
"if AUDIT_LOG_ENABLED:",
"if settings.AUDIT_LOG_ENABLED:",
"if AUDIT_LOG_ENABLED:",
"if TYPE_CHECKING:",
]
-5
View File
@@ -91,11 +91,6 @@
"glob": "**/*",
"input": "node_modules/pdfjs-dist/iccs/",
"output": "/assets/iccs/"
},
{
"glob": "**/*",
"input": "node_modules/pdfjs-dist/cmaps/",
"output": "/assets/cmaps/"
}
],
"styles": [
+250 -364
View File
File diff suppressed because it is too large. Load diff
+23 -23
View File
@@ -1,6 +1,6 @@
{
"name": "paperless-ngx-ui",
"version": "3.3.0",
"version": "3.2.0",
"scripts": {
"preinstall": "npx only-allow pnpm",
"ng": "ng",
@@ -15,16 +15,16 @@
},
"private": true,
"dependencies": {
"@angular/cdk": "^22.2.0",
"@angular/common": "~22.2.0",
"@angular/compiler": "~22.2.0",
"@angular/core": "~22.2.0",
"@angular/forms": "~22.2.0",
"@angular/localize": "~22.2.0",
"@angular/platform-browser": "~22.2.0",
"@angular/router": "~22.2.0",
"@angular/cdk": "^22.1.6",
"@angular/common": "~22.1.6",
"@angular/compiler": "~22.1.6",
"@angular/core": "~22.1.6",
"@angular/forms": "~22.1.6",
"@angular/localize": "~22.1.6",
"@angular/platform-browser": "~22.1.6",
"@angular/router": "~22.1.6",
"@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",
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
@@ -45,29 +45,29 @@
},
"devDependencies": {
"@angular-builders/jest": "^22.0.1",
"@angular-devkit/core": "^22.2.0",
"@angular-devkit/schematics": "^22.2.0",
"@angular-devkit/core": "^22.1.8",
"@angular-devkit/schematics": "^22.1.8",
"@angular-eslint/builder": "22.5.0",
"@angular-eslint/eslint-plugin": "22.5.0",
"@angular-eslint/eslint-plugin-template": "22.5.0",
"@angular-eslint/schematics": "22.5.0",
"@angular-eslint/template-parser": "22.5.0",
"@angular/build": "22.2.0",
"@angular/cli": "22.2.0",
"@angular/compiler-cli": "~22.2.0",
"@angular/build": "22.1.8",
"@angular/cli": "22.1.8",
"@angular/compiler-cli": "~22.1.6",
"@playwright/test": "^1.62.1",
"@types/jest": "^30.0.0",
"@types/node": "^26.6.2",
"@typescript-eslint/eslint-plugin": "^8.70.1",
"@typescript-eslint/parser": "^8.70.1",
"@typescript-eslint/utils": "^8.70.1",
"eslint": "^10.11.0",
"jest": "30.5.2",
"jest-environment-jsdom": "^30.5.2",
"@types/node": "^26.5.0",
"@typescript-eslint/eslint-plugin": "^8.70.0",
"@typescript-eslint/parser": "^8.70.0",
"@typescript-eslint/utils": "^8.70.0",
"eslint": "^10.10.0",
"jest": "30.5.1",
"jest-environment-jsdom": "^30.5.1",
"jest-junit": "^17.0.0",
"jest-preset-angular": "^17.0.0",
"jest-websocket-mock": "^2.5.0",
"prettier": "^3.9.9",
"prettier": "^3.9.6",
"prettier-plugin-organize-imports": "^4.3.0",
"ts-node": "~10.9.2",
"typescript": "^6.0.3"
+1959 -1311
View File
File diff suppressed because it is too large. Load diff
@@ -253,24 +253,6 @@
</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="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>
@@ -307,7 +307,7 @@ describe('SettingsComponent', () => {
expect(toastErrorSpy).toHaveBeenCalled()
expect(storeSpy).toHaveBeenCalled()
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
expect(setSpy).toHaveBeenCalledTimes(35)
expect(setSpy).toHaveBeenCalledTimes(34)
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
HideableSidebarItemID.Workflows,
])
@@ -44,7 +44,6 @@ import {
HIDEABLE_SIDEBAR_ITEM_IDS,
HideableSidebarItemID,
SETTINGS_KEYS,
SuggestionSource,
} from 'src/app/data/ui-settings'
import { User } from 'src/app/data/user'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
@@ -185,7 +184,6 @@ export class SettingsComponent
documentEditingRemoveInboxTags: new FormControl(null),
documentEditingOverlayThumbnail: new FormControl(null),
documentEditingAutoSuggest: new FormControl(null),
documentEditingSuggestionSource: new FormControl(null),
documentDetailsHiddenFields: new FormControl([]),
searchDbOnly: new FormControl(null),
searchLink: new FormControl(null),
@@ -219,11 +217,6 @@ export class SettingsComponent
public readonly PdfZoomScale = PdfZoomScale
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 sidebarItemOptions = HIDEABLE_SIDEBAR_ITEM_IDS.map((id) => ({
@@ -411,9 +404,6 @@ export class SettingsComponent
documentEditingAutoSuggest: this.settings.get(
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST
),
documentEditingSuggestionSource: this.settings.get(
SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE
),
documentDetailsHiddenFields: this.settings.get(
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS
),
@@ -635,10 +625,6 @@ export class SettingsComponent
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST,
this.settingsForm.value.documentEditingAutoSuggest
)
this.settings.set(
SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE,
this.settingsForm.value.documentEditingSuggestionSource
)
this.settings.set(
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS,
this.settingsForm.value.documentDetailsHiddenFields
@@ -0,0 +1,347 @@
<div class="modal-header">
<h4 class="modal-title" id="advanced-search-dialog-title" i18n>
Advanced search
</h4>
<button
type="button"
class="btn-close"
aria-label="Close"
i18n-aria-label
(click)="cancel()"
></button>
</div>
<div class="modal-body">
@if (unreadable) {
<div class="alert alert-warning d-flex flex-wrap gap-2 align-items-center">
<div class="flex-grow-1">
<span i18n
>The current query uses syntax this editor can't show. It still works
as typed.</span
>
</div>
<button
type="button"
class="btn btn-sm btn-outline-secondary"
(click)="startOver()"
i18n
>
Start a new query
</button>
</div>
}
<ng-container
*ngTemplateOutlet="
groupTemplate;
context: { group: root, parent: null, depth: 0 }
"
></ng-container>
<div class="mt-4">
<label
class="form-label small text-muted"
for="advanced-search-preview"
i18n
>Query</label
>
<pre
id="advanced-search-preview"
class="query-preview mb-0 p-2 rounded border"
>@if (generatedQuery) {{{ generatedQuery }}} @else {<span class="text-muted fst-italic" i18n>Nothing to search for yet</span>}</pre>
</div>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-outline-secondary"
(click)="cancel()"
i18n
>
Cancel
</button>
<button
type="button"
class="btn btn-primary"
(click)="apply()"
[disabled]="!generatedQuery"
i18n
>
Apply
</button>
</div>
<ng-template
#groupTemplate
let-group="group"
let-parent="parent"
let-depth="depth"
>
<div class="d-flex w-100 gap-2">
<div class="d-flex flex-grow-1 flex-column">
<div class="d-flex align-items-center flex-wrap">
<div
class="btn-group btn-group-xs"
role="group"
aria-label="Match"
i18n-aria-label
>
<input
type="radio"
class="btn-check"
[(ngModel)]="group.operator"
[ngModelOptions]="{ standalone: true }"
[value]="LogicalOperator.Or"
id="advancedSearchAny_{{ idFor(group) }}"
name="advancedSearchAny_{{ idFor(group) }}"
/>
<label
class="btn btn-outline-primary"
for="advancedSearchAny_{{ idFor(group) }}"
i18n
>Any</label
>
<input
type="radio"
class="btn-check"
[(ngModel)]="group.operator"
[ngModelOptions]="{ standalone: true }"
[value]="LogicalOperator.And"
id="advancedSearchAll_{{ idFor(group) }}"
name="advancedSearchAll_{{ idFor(group) }}"
/>
<label
class="btn btn-outline-primary"
for="advancedSearchAll_{{ idFor(group) }}"
i18n
>All</label
>
<input
type="radio"
class="btn-check"
[(ngModel)]="group.operator"
[ngModelOptions]="{ standalone: true }"
[value]="LogicalOperator.Not"
id="advancedSearchNot_{{ idFor(group) }}"
name="advancedSearchNot_{{ idFor(group) }}"
/>
<label
class="btn btn-outline-secondary"
for="advancedSearchNot_{{ idFor(group) }}"
i18n
>Not</label
>
</div>
<span class="small text-muted ms-2">
@switch (group.operator) {
@case (LogicalOperator.And) {
<ng-container i18n>match all of these</ng-container>
}
@case (LogicalOperator.Or) {
<ng-container i18n>match any of these</ng-container>
}
@case (LogicalOperator.Not) {
<ng-container i18n>match none of these</ng-container>
}
}
</span>
</div>
<div class="list-group list-group-flush">
@for (element of group.children; track element) {
<div class="list-group-item px-0 d-flex flex-nowrap">
@if (element.type === ElementType.Group) {
<ng-container
*ngTemplateOutlet="
groupTemplate;
context: { group: element, parent: group, depth: depth + 1 }
"
></ng-container>
} @else {
<ng-container
*ngTemplateOutlet="
atomTemplate;
context: { atom: element, parent: group }
"
></ng-container>
}
</div>
}
</div>
</div>
<div
class="btn-group-vertical align-self-start ms-2 ps-2 border-start"
role="group"
aria-label="Group actions"
i18n-aria-label
>
<button
type="button"
class="btn btn-sm btn-outline-secondary text-primary"
title="Add condition"
i18n-title
(click)="addAtom(group)"
[disabled]="group.children.length >= maxAtoms"
>
<i-bs name="node-plus"></i-bs>
</button>
<button
type="button"
class="btn btn-sm btn-outline-secondary text-primary"
title="Add group"
i18n-title
(click)="addGroup(group)"
[disabled]="depth >= maxDepth"
>
<i-bs name="braces"></i-bs>
</button>
@if (parent) {
<button
type="button"
class="btn btn-sm btn-outline-secondary text-danger"
aria-label="Remove group"
i18n-aria-label
(click)="remove(parent, group)"
>
<i-bs name="x-circle"></i-bs>
</button>
}
</div>
</div>
</ng-template>
<ng-template #atomTemplate let-atom="atom" let-parent="parent">
<div class="d-flex align-items-center gap-1 w-100">
<div class="input-group input-group-sm flex-wrap">
<select
class="form-select flex-grow-0 w-auto"
[(ngModel)]="atom.field"
[ngModelOptions]="{ standalone: true }"
(ngModelChange)="onFieldChange(atom)"
aria-label="Field"
i18n-aria-label
>
@for (fieldGroup of fieldGroups; track fieldGroup.label) {
<optgroup [label]="fieldGroup.label">
@for (field of fieldGroup.fields; track field) {
<option [ngValue]="field">{{ fieldLabels[field] }}</option>
}
</optgroup>
}
</select>
<select
class="form-select flex-grow-0 w-auto"
[(ngModel)]="atom.operator"
[ngModelOptions]="{ standalone: true }"
(ngModelChange)="onOperatorChange(atom)"
aria-label="Condition"
i18n-aria-label
>
@for (operator of operatorsFor(atom); track operator) {
<option [ngValue]="operator">
{{ operatorLabel(atom, operator) }}
</option>
}
</select>
@switch (atom.operator) {
@case (Operator.DateKeyword) {
<select
class="form-select"
[(ngModel)]="atom.value"
[ngModelOptions]="{ standalone: true }"
aria-label="Period"
i18n-aria-label
>
<option [ngValue]="''" disabled i18n>Choose a period</option>
@for (keyword of dateKeywords; track keyword) {
<option [ngValue]="keyword">
{{ dateKeywordLabels[keyword] }}
</option>
}
</select>
}
@case (Operator.WithinLast) {
<input
class="form-control amount"
type="number"
min="1"
[(ngModel)]="atom.value"
[ngModelOptions]="{ standalone: true }"
aria-label="Amount"
i18n-aria-label
/>
<select
class="form-select"
[(ngModel)]="atom.unit"
[ngModelOptions]="{ standalone: true }"
aria-label="Unit"
i18n-aria-label
>
@for (unit of dateUnits; track unit) {
<option [ngValue]="unit">{{ dateUnitLabels[unit] }}</option>
}
</select>
}
@case (Operator.Between) {
<input
class="form-control"
[type]="kindOf(atom) === FieldKind.Date ? 'date' : 'number'"
[(ngModel)]="atom.value"
[ngModelOptions]="{ standalone: true }"
aria-label="From"
i18n-aria-label
/>
<span class="input-group-text" i18n>and</span>
<input
class="form-control"
[type]="kindOf(atom) === FieldKind.Date ? 'date' : 'number'"
[(ngModel)]="atom.valueTo"
[ngModelOptions]="{ standalone: true }"
aria-label="To"
i18n-aria-label
/>
}
@default {
@if (kindOf(atom) === FieldKind.Date) {
<input
class="form-control"
type="date"
[(ngModel)]="atom.value"
[ngModelOptions]="{ standalone: true }"
aria-label="Date"
i18n-aria-label
/>
} @else if (kindOf(atom) === FieldKind.Number) {
<input
class="form-control"
type="number"
min="0"
[(ngModel)]="atom.value"
[ngModelOptions]="{ standalone: true }"
aria-label="Number"
i18n-aria-label
/>
} @else {
<input
class="form-control"
type="text"
[(ngModel)]="atom.value"
[ngModelOptions]="{ standalone: true }"
[placeholder]="placeholderFor(atom)"
aria-label="Value"
i18n-aria-label
/>
}
}
}
</div>
<button
class="btn btn-link btn-sm text-danger px-1"
type="button"
(click)="remove(parent, atom)"
aria-label="Remove condition"
i18n-aria-label
>
<i-bs name="x-circle"></i-bs>
</button>
</div>
</ng-template>
@@ -0,0 +1,11 @@
.query-preview {
font-size: 0.8125rem;
white-space: pre-wrap;
word-break: break-word;
background-color: var(--pngx-bg-darker);
border-color: var(--bs-border-color) !important;
}
.input-group .amount {
flex: 0 1 5rem;
}
@@ -0,0 +1,186 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import {
AdvancedSearchDateUnit,
AdvancedSearchField,
AdvancedSearchLogicalOperator,
AdvancedSearchOperator,
AdvancedSearchQueryAtom,
AdvancedSearchQueryElementType,
AdvancedSearchQueryGroup,
} from 'src/app/data/advanced-search-query'
import { AdvancedSearchDialogComponent } from './advanced-search-dialog.component'
describe('AdvancedSearchDialogComponent', () => {
let component: AdvancedSearchDialogComponent
let fixture: ComponentFixture<AdvancedSearchDialogComponent>
let activeModal: NgbActiveModal
const firstAtom = (): AdvancedSearchQueryAtom =>
component.root.children[0] as AdvancedSearchQueryAtom
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
AdvancedSearchDialogComponent,
NgxBootstrapIconsModule.pick(allIcons),
],
providers: [NgbActiveModal],
}).compileComponents()
fixture = TestBed.createComponent(AdvancedSearchDialogComponent)
activeModal = TestBed.inject(NgbActiveModal)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should start with one empty condition and nothing to search for', () => {
expect(component.root.children).toHaveLength(1)
expect(component.generatedQuery).toBe('')
expect(component.unreadable).toBeFalsy()
})
it('should show an existing query as conditions', () => {
component.query = 'title:invoice AND NOT tag:paid'
expect(component.unreadable).toBeFalsy()
expect(component.root.children).toHaveLength(2)
expect(component.generatedQuery).toBe('title:invoice AND NOT tag:paid')
})
it('should flag a query it cannot show and start empty', () => {
component.query = 'title:invoice^2'
expect(component.unreadable).toBeTruthy()
expect(component.generatedQuery).toBe('')
})
it('should clear the warning when starting a new query', () => {
component.query = 'title:invoice^2'
component.startOver()
expect(component.unreadable).toBeFalsy()
expect(component.root.children).toHaveLength(1)
})
it('should treat an empty query as a fresh start', () => {
component.query = ' '
expect(component.unreadable).toBeFalsy()
expect(component.root.children).toHaveLength(1)
})
it('should write the query as conditions are filled in', () => {
const atom = firstAtom()
atom.field = AdvancedSearchField.Title
atom.value = 'unpaid invoice'
expect(component.generatedQuery).toBe('title:unpaid AND title:invoice')
})
it('should offer the conditions of the chosen field', () => {
const atom = firstAtom()
atom.field = AdvancedSearchField.Added
component.onFieldChange(atom)
expect(component.operatorsFor(atom)).toContain(
AdvancedSearchOperator.WithinLast
)
expect(component.operatorsFor(atom)).not.toContain(
AdvancedSearchOperator.Phrase
)
})
it('should keep a condition the new field still offers', () => {
const atom = firstAtom()
atom.operator = AdvancedSearchOperator.Phrase
atom.field = AdvancedSearchField.Correspondent
component.onFieldChange(atom)
expect(atom.operator).toBe(AdvancedSearchOperator.Phrase)
})
it('should replace a condition the new field does not offer, and clear the value', () => {
const atom = firstAtom()
atom.operator = AdvancedSearchOperator.Phrase
atom.value = 'invoice'
atom.field = AdvancedSearchField.ASN
component.onFieldChange(atom)
expect(atom.operator).toBe(AdvancedSearchOperator.Equals)
expect(atom.value).toBe('')
})
it('should give a within-the-last condition a unit to start from', () => {
const atom = firstAtom()
atom.field = AdvancedSearchField.Added
atom.operator = AdvancedSearchOperator.WithinLast
component.onOperatorChange(atom)
expect(atom.unit).toBe(AdvancedSearchDateUnit.Day)
atom.value = '3'
expect(component.generatedQuery).toBe('added:[-3 days to now]')
})
it('should label date comparisons as dates', () => {
const atom = firstAtom()
atom.field = AdvancedSearchField.Created
expect(
component.operatorLabel(atom, AdvancedSearchOperator.AtLeast)
).toEqual('is on or after')
atom.field = AdvancedSearchField.ASN
expect(
component.operatorLabel(atom, AdvancedSearchOperator.AtLeast)
).toEqual('is at least')
})
it('should add and remove conditions', () => {
component.addAtom(component.root)
expect(component.root.children).toHaveLength(2)
component.remove(component.root, component.root.children[1])
expect(component.root.children).toHaveLength(1)
})
it('should add a group, which starts as Any', () => {
component.addGroup(component.root)
const group = component.root.children[1] as AdvancedSearchQueryGroup
expect(group.type).toBe(AdvancedSearchQueryElementType.Group)
expect(group.operator).toBe(AdvancedSearchLogicalOperator.Or)
expect(group.children).toHaveLength(1)
})
it('should give every group its own id, once', () => {
component.addGroup(component.root)
const group = component.root.children[1] as AdvancedSearchQueryGroup
expect(component.idFor(component.root)).not.toEqual(component.idFor(group))
expect(component.idFor(group)).toEqual(component.idFor(group))
})
it('should apply the query and close', () => {
const emitSpy = jest.spyOn(component.queryApplied, 'emit')
const closeSpy = jest.spyOn(activeModal, 'close')
const atom = firstAtom()
atom.value = 'invoice'
component.apply()
expect(emitSpy).toHaveBeenCalledWith('content:invoice')
expect(closeSpy).toHaveBeenCalled()
})
it('should close without applying on cancel', () => {
const emitSpy = jest.spyOn(component.queryApplied, 'emit')
const closeSpy = jest.spyOn(activeModal, 'close')
component.cancel()
expect(emitSpy).not.toHaveBeenCalled()
expect(closeSpy).toHaveBeenCalled()
})
it('should show the query it will apply', () => {
component.query = 'content:invoice OR content:receipt'
fixture.detectChanges()
const preview = fixture.nativeElement.querySelector(
'#advanced-search-preview'
)
expect(preview.textContent).toContain('content:invoice OR content:receipt')
})
it('should not offer to apply an empty query', () => {
fixture.detectChanges()
const apply = Array.from(
fixture.nativeElement.querySelectorAll('.modal-footer button')
).pop() as HTMLButtonElement
expect(apply.disabled).toBeTruthy()
})
})
@@ -0,0 +1,197 @@
import { NgTemplateOutlet } from '@angular/common'
import { Component, EventEmitter, inject, Input, Output } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import {
ADVANCED_SEARCH_DATE_KEYWORD_LABELS,
ADVANCED_SEARCH_DATE_KEYWORDS,
ADVANCED_SEARCH_DATE_OPERATOR_LABELS,
ADVANCED_SEARCH_DATE_UNIT_LABELS,
ADVANCED_SEARCH_FIELD_GROUPS,
ADVANCED_SEARCH_FIELD_KINDS,
ADVANCED_SEARCH_FIELD_LABELS,
ADVANCED_SEARCH_MAX_ATOMS,
ADVANCED_SEARCH_MAX_DEPTH,
ADVANCED_SEARCH_OPERATOR_LABELS,
ADVANCED_SEARCH_OPERATORS_BY_KIND,
AdvancedSearchDateUnit,
AdvancedSearchField,
AdvancedSearchFieldKind,
AdvancedSearchLogicalOperator,
AdvancedSearchOperator,
AdvancedSearchQueryAtom,
AdvancedSearchQueryElement,
AdvancedSearchQueryElementType,
AdvancedSearchQueryGroup,
} from 'src/app/data/advanced-search-query'
import {
parseAdvancedSearchQuery,
serializeAdvancedSearchQuery,
} from 'src/app/utils/advanced-search-query'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
@Component({
selector: 'pngx-advanced-search-dialog',
templateUrl: './advanced-search-dialog.component.html',
styleUrl: './advanced-search-dialog.component.scss',
imports: [FormsModule, NgTemplateOutlet, NgxBootstrapIconsModule],
})
export class AdvancedSearchDialogComponent extends LoadingComponentWithPermissions {
private activeModal = inject(NgbActiveModal)
public readonly ElementType = AdvancedSearchQueryElementType
public readonly LogicalOperator = AdvancedSearchLogicalOperator
public readonly Operator = AdvancedSearchOperator
public readonly FieldKind = AdvancedSearchFieldKind
public readonly fieldGroups = ADVANCED_SEARCH_FIELD_GROUPS
public readonly fieldLabels = ADVANCED_SEARCH_FIELD_LABELS
public readonly dateKeywords = ADVANCED_SEARCH_DATE_KEYWORDS
public readonly dateKeywordLabels = ADVANCED_SEARCH_DATE_KEYWORD_LABELS
public readonly dateUnits = Object.values(AdvancedSearchDateUnit)
public readonly dateUnitLabels = ADVANCED_SEARCH_DATE_UNIT_LABELS
public readonly maxDepth = ADVANCED_SEARCH_MAX_DEPTH
public readonly maxAtoms = ADVANCED_SEARCH_MAX_ATOMS
@Output()
public queryApplied = new EventEmitter<string>()
public root: AdvancedSearchQueryGroup = this.emptyRoot()
// True when the query in the search box uses syntax the editor cannot show
public unreadable: boolean = false
private _query: string = ''
@Input()
set query(query: string) {
this._query = query ?? ''
const parsed = parseAdvancedSearchQuery(this._query)
this.unreadable = !!this._query.trim() && !parsed
this.root = parsed ?? this.emptyRoot()
}
get query(): string {
return this._query
}
constructor() {
super()
this.loading.set(false)
}
// Stable ids for the radio groups, without putting them in the query model
private ids = new WeakMap<object, number>()
private nextId = 0
public idFor(element: AdvancedSearchQueryElement): number {
if (!this.ids.has(element)) {
this.ids.set(element, this.nextId++)
}
return this.ids.get(element)
}
private emptyRoot(): AdvancedSearchQueryGroup {
return {
type: AdvancedSearchQueryElementType.Group,
operator: AdvancedSearchLogicalOperator.And,
children: [this.newAtom()],
}
}
private newAtom(): AdvancedSearchQueryAtom {
return {
type: AdvancedSearchQueryElementType.Atom,
field: AdvancedSearchField.Content,
operator: AdvancedSearchOperator.AllWords,
value: '',
}
}
public get generatedQuery(): string {
return serializeAdvancedSearchQuery(this.root)
}
public kindOf(atom: AdvancedSearchQueryAtom): AdvancedSearchFieldKind {
return ADVANCED_SEARCH_FIELD_KINDS[atom.field]
}
public operatorsFor(atom: AdvancedSearchQueryAtom): AdvancedSearchOperator[] {
return ADVANCED_SEARCH_OPERATORS_BY_KIND[this.kindOf(atom)]
}
public operatorLabel(
atom: AdvancedSearchQueryAtom,
operator: AdvancedSearchOperator
): string {
return this.kindOf(atom) === AdvancedSearchFieldKind.Date
? (ADVANCED_SEARCH_DATE_OPERATOR_LABELS[operator] ??
ADVANCED_SEARCH_OPERATOR_LABELS[operator])
: ADVANCED_SEARCH_OPERATOR_LABELS[operator]
}
public placeholderFor(atom: AdvancedSearchQueryAtom): string {
switch (atom.operator) {
case AdvancedSearchOperator.Phrase:
return $localize`phrase`
case AdvancedSearchOperator.StartsWith:
return $localize`beginning of a word`
default:
return $localize`words`
}
}
public onFieldChange(atom: AdvancedSearchQueryAtom) {
// Keep the condition only if the new field still offers it
if (!this.operatorsFor(atom).includes(atom.operator)) {
atom.operator = this.operatorsFor(atom)[0]
}
this.clearValues(atom)
}
public onOperatorChange(atom: AdvancedSearchQueryAtom) {
this.clearValues(atom)
}
private clearValues(atom: AdvancedSearchQueryAtom) {
atom.value = ''
atom.valueTo = undefined
atom.unit =
atom.operator === AdvancedSearchOperator.WithinLast
? AdvancedSearchDateUnit.Day
: undefined
}
public addAtom(group: AdvancedSearchQueryGroup) {
group.children.push(this.newAtom())
}
public addGroup(group: AdvancedSearchQueryGroup) {
group.children.push({
type: AdvancedSearchQueryElementType.Group,
operator: AdvancedSearchLogicalOperator.Or,
children: [this.newAtom()],
})
}
public remove(
parent: AdvancedSearchQueryGroup,
element: AdvancedSearchQueryElement
) {
parent.children = parent.children.filter((child) => child !== element)
}
public startOver() {
this.unreadable = false
this.root = this.emptyRoot()
}
public apply() {
this.queryApplied.emit(this.generatedQuery)
this.activeModal.close()
}
public cancel() {
this.activeModal.close()
}
}
@@ -154,29 +154,11 @@
& section {
position: absolute;
text-align: initial;
pointer-events: auto;
box-sizing: border-box;
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 {
opacity: 0;
}
}
:host ::ng-deep .textLayer.selecting ~ .annotationLayer section {
pointer-events: none;
}
@@ -1,11 +1,7 @@
import { SimpleChange } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import * as pdfjs from 'pdfjs-dist/legacy/build/pdf.mjs'
import {
LinkTarget,
PDFSinglePageViewer,
PDFViewer,
} from 'pdfjs-dist/web/pdf_viewer.mjs'
import { PDFSinglePageViewer, PDFViewer } from 'pdfjs-dist/web/pdf_viewer.mjs'
import { PngxPdfViewerComponent } from './pdf-viewer.component'
import { PdfRenderMode, PdfZoomLevel, PdfZoomScale } from './pdf-viewer.types'
@@ -62,16 +58,6 @@ describe('PngxPdfViewerComponent', () => {
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 () => {
setBaseHref('/paperless/')
const getDocumentSpy = jest.spyOn(pdfjs, 'getDocument')
@@ -90,7 +76,6 @@ describe('PngxPdfViewerComponent', () => {
withCredentials: true,
wasmUrl: expect.stringContaining('/paperless/assets/wasm/'),
iccUrl: expect.stringContaining('/paperless/assets/iccs/'),
cMapUrl: expect.stringContaining('/paperless/assets/cmaps/'),
})
})
@@ -21,7 +21,6 @@ import {
} from 'pdfjs-dist/legacy/build/pdf.mjs'
import {
EventBus,
LinkTarget,
PDFFindController,
PDFLinkService,
PDFSinglePageViewer,
@@ -76,11 +75,7 @@ export class PngxPdfViewerComponent
private lastViewerPage?: number
private readonly eventBus = new EventBus()
private readonly linkService = new PDFLinkService({
eventBus: this.eventBus,
externalLinkTarget: LinkTarget.BLANK,
externalLinkRel: 'noopener noreferrer nofollow',
})
private readonly linkService = new PDFLinkService({ eventBus: this.eventBus })
private readonly findController = new PDFFindController({
eventBus: this.eventBus,
linkService: this.linkService,
@@ -190,7 +185,6 @@ export class PngxPdfViewerComponent
withCredentials: true,
wasmUrl: new URL('assets/wasm/', 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)
try {
@@ -1,84 +1,58 @@
<div class="d-flex align-items-center">
<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>
@if (loading()) {
<div class="spinner-border spinner-border-sm" role="status"></div>
} @else if (noSuggestions) {
<i-bs width="1.2em" height="1.2em" name="check-circle"></i-bs>
} @else {
<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 && fieldSuggestions === 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 (fieldSuggestions > 0) {
<div class="list-group-item text-muted fst-italic">
<small class="text-muted small fst-italic" i18n>{fieldSuggestions, plural, =1 {1 suggestion available below} other {{{fieldSuggestions}} suggestions available below}}</small>
</div>
}
</div>
</div>
</div>
<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>
@if (loading()) {
<div class="spinner-border spinner-border-sm" role="status"></div>
} @else if (noSuggestions) {
<i-bs width="1.2em" height="1.2em" name="check-circle"></i-bs>
} @else {
<i-bs width="1.2em" height="1.2em" name="stars"></i-bs>
}
</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()) {
<div ngbDropdown autoClose="outside" placement="bottom-end" [popperOptions]="popperOptions">
<button type="button" class="btn btn-sm btn-link position-relative" ngbDropdownToggle [disabled]="disabled() || loading()" i18n-title title="Suggestion options">
<i-bs name="three-dots"></i-bs>
@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>
}
<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 class="shadow p-3">
<div class="small text-muted mb-2" i18n>Suggest using:</div>
<div class="form-check small">
<input class="form-check-input" type="checkbox" id="suggestionSourceML" [checked]="useML" [disabled]="useML && !useAI" (change)="setSources($event.target.checked, useAI)">
<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>
<div class="form-check small">
<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>
<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>
@@ -1,7 +1,3 @@
.suggestions-dropdown {
min-width: 250px;
}
.btn-link.dropdown-toggle::after {
display: none;
}
@@ -1,7 +1,6 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { SuggestionSource } from 'src/app/data/ui-settings'
import { SuggestionsDropdownComponent } from './suggestions-dropdown.component'
describe('SuggestionsDropdownComponent', () => {
@@ -84,7 +83,7 @@ describe('SuggestionsDropdownComponent', () => {
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain(
'2 suggestions available below'
'2 existing values suggested below'
)
expect(fixture.nativeElement.textContent).not.toContain(
'No novel suggestions'
@@ -108,7 +107,7 @@ describe('SuggestionsDropdownComponent', () => {
expect(component.totalSuggestions).toBe(4)
expect(fixture.nativeElement.textContent).toContain('Arbitration')
expect(fixture.nativeElement.textContent).toContain(
'2 suggestions available below'
'2 existing values suggested below'
)
})
@@ -125,46 +124,10 @@ describe('SuggestionsDropdownComponent', () => {
})
expect(component.novelSuggestions).toBe(0)
expect(component.totalSuggestions).toBe(6)
expect(component.totalSuggestions).toBe(5)
fixture.componentRef.setInput('appliedStoragePath', 7)
expect(component.totalSuggestions).toBe(5)
})
it('should count title and dates as field suggestions without calling them existing values', () => {
fixture.componentRef.setInput('aiEnabled', true)
fixture.componentRef.setInput('fetchedSources', [SuggestionSource.ML])
fixture.componentRef.setInput('suggestions', {
title: 'Suggested title',
dates: ['2026-01-04', '2026-02-01', '2026-03-01'],
correspondents: [1, 2, 3, 4],
document_types: [1, 2, 3, 4],
tags: [1, 2, 3, 4, 5, 6, 7, 8],
})
fixture.detectChanges()
component.clickSuggest()
fixture.detectChanges()
expect(component.reusableSuggestions).toBe(16)
expect(component.fieldSuggestions).toBe(20)
expect(component.totalSuggestions).toBe(20)
expect(fixture.nativeElement.textContent).toContain(
'20 suggestions available below'
)
expect(fixture.nativeElement.textContent).not.toContain('existing value')
})
it('should not count title or date suggestions matching the current values', () => {
fixture.componentRef.setInput('suggestions', {
title: 'Current title',
dates: ['2026-01-04', '2026-02-01'],
})
expect(component.fieldSuggestions).toBe(3)
fixture.componentRef.setInput('appliedTitle', 'Current title')
fixture.componentRef.setInput('appliedCreated', '2026-01-04')
expect(component.fieldSuggestions).toBe(1)
expect(component.totalSuggestions).toBe(1)
expect(component.totalSuggestions).toBe(4)
})
it('should show when a completed request returned no suggestions', () => {
@@ -181,28 +144,6 @@ describe('SuggestionsDropdownComponent', () => {
expect(fixture.nativeElement.textContent).toContain('No suggestions')
})
it('should wait for all pending responses before showing the empty state', () => {
fixture.componentRef.setInput('aiEnabled', true)
fixture.componentRef.setInput('source', SuggestionSource.Both)
fixture.componentRef.setInput('fetchedSources', [SuggestionSource.ML])
fixture.componentRef.setInput('suggestions', { tags: [] })
fixture.componentRef.setInput('loading', true)
fixture.detectChanges()
expect(component.noSuggestions).toBeFalsy()
expect(fixture.nativeElement.textContent).not.toContain('No suggestions')
expect(
fixture.nativeElement.querySelector('[role="status"]')
).not.toBeNull()
fixture.componentRef.setInput('loading', false)
fixture.detectChanges()
expect(component.noSuggestions).toBeTruthy()
expect(fixture.nativeElement.textContent).toContain('No suggestions')
expect(fixture.nativeElement.querySelector('[role="status"]')).toBeNull()
})
it('should not show the empty state before a request or with suggestions', () => {
expect(component.noSuggestions).toBeFalsy()
@@ -238,71 +179,14 @@ describe('SuggestionsDropdownComponent', () => {
it('should toggle dropdown when clickSuggest is called and suggestions are not null', () => {
fixture.componentRef.setInput('aiEnabled', true)
fixture.componentRef.setInput('fetchedSources', [SuggestionSource.ML])
fixture.detectChanges()
fixture.componentRef.setInput('suggestions', {
suggested_correspondents: [],
suggested_tags: [],
suggested_document_types: [],
})
fixture.detectChanges()
component.clickSuggest()
expect(component.dropdown.isOpen()).toBeTruthy()
expect(component.dropdown.open).toBeTruthy()
expect(fixture.nativeElement.textContent).toContain('No novel suggestions')
})
it('should fetch unfetched sources and show existing suggestions', () => {
jest.spyOn(component.getSuggestions, 'emit')
fixture.componentRef.setInput('aiEnabled', true)
fixture.componentRef.setInput('source', SuggestionSource.Both)
fixture.componentRef.setInput('fetchedSources', [SuggestionSource.ML])
fixture.componentRef.setInput('suggestions', { tags: [1] })
fixture.detectChanges()
component.clickSuggest()
expect(component.getSuggestions.emit).toHaveBeenCalledWith(
SuggestionSource.Both
)
expect(component.dropdown.isOpen()).toBeTruthy()
})
it('should only show source options when AI is enabled', () => {
expect(
fixture.nativeElement.querySelector('#suggestionSourceML')
).toBeNull()
fixture.componentRef.setInput('aiEnabled', true)
fixture.detectChanges()
fixture.nativeElement
.querySelector('button[title="Suggestion options"]')
.click()
fixture.detectChanges()
expect(
fixture.nativeElement.querySelector('#suggestionSourceML')
).not.toBeNull()
})
it('should emit source changes and never allow no source', () => {
const emitSpy = jest.spyOn(component.sourceChange, 'emit')
component.setSources(true, true)
expect(emitSpy).toHaveBeenCalledWith(SuggestionSource.Both)
component.setSources(true, false)
expect(emitSpy).toHaveBeenCalledWith(SuggestionSource.ML)
component.setSources(false, true)
expect(emitSpy).toHaveBeenCalledWith(SuggestionSource.AI)
emitSpy.mockClear()
component.setSources(false, false)
expect(emitSpy).not.toHaveBeenCalled()
})
it('should indicate a non-default source', () => {
fixture.componentRef.setInput('aiEnabled', true)
fixture.componentRef.setInput('source', SuggestionSource.AI)
fixture.componentRef.setInput('defaultSource', SuggestionSource.AI)
fixture.detectChanges()
expect(fixture.nativeElement.textContent).not.toContain('Not using default')
fixture.componentRef.setInput('source', SuggestionSource.Both)
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Not using default')
})
})
@@ -8,7 +8,6 @@ import {
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
import { SuggestionSource } from 'src/app/data/ui-settings'
import { pngxPopperOptions } from 'src/app/utils/popper-options'
@Component({
@@ -19,29 +18,21 @@ import { pngxPopperOptions } from 'src/app/utils/popper-options'
})
export class SuggestionsDropdownComponent {
public popperOptions = pngxPopperOptions
public readonly SuggestionSource = SuggestionSource
@ViewChild('dropdown') dropdown: NgbDropdown
readonly suggestions = input<DocumentSuggestions>(null)
readonly aiEnabled = input(false)
readonly loading = 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 appliedCorrespondent = input<number>(null)
readonly appliedDocumentType = input<number>(null)
readonly appliedStoragePath = input<number>(null)
readonly appliedTitle = input<string>(null)
readonly appliedCreated = input<string>(null)
@Output()
getSuggestions: EventEmitter<SuggestionSource> = new EventEmitter()
@Output()
sourceChange: EventEmitter<SuggestionSource> = new EventEmitter()
getSuggestions: EventEmitter<SuggestionsDropdownComponent> =
new EventEmitter()
@Output()
addTag: EventEmitter<string> = new EventEmitter()
@@ -62,42 +53,12 @@ export class SuggestionsDropdownComponent {
}
if (!this.suggestions()) {
this.getSuggestions.emit(this.source())
} else if (this.hasUnfetchedSources) {
// sources changed, fetch the rest and show what we have meanwhile
this.getSuggestions.emit(this.source())
this.dropdown?.open()
this.getSuggestions.emit(this)
} else {
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 {
return (
(this.suggestions()?.suggested_correspondents?.length ?? 0) +
@@ -130,28 +91,7 @@ export class SuggestionsDropdownComponent {
}
get totalSuggestions(): number {
return this.novelSuggestions + this.fieldSuggestions
}
get fieldSuggestions(): number {
return (
this.reusableSuggestions +
this.unappliedTitleSuggestions +
this.unappliedDateSuggestions
)
}
// hide a title or date suggestion equal to the current value
private get unappliedTitleSuggestions(): number {
const title = this.suggestions()?.title
return title && title !== this.appliedTitle() ? 1 : 0
}
private get unappliedDateSuggestions(): number {
const created = this.appliedCreated()
return (this.suggestions()?.dates ?? []).filter(
(date) => !created || date !== created
).length
return this.novelSuggestions + this.reusableSuggestions
}
private countUnapplied(suggested: number[], applied: number[]): number {
@@ -162,7 +102,6 @@ export class SuggestionsDropdownComponent {
get noSuggestions(): boolean {
const suggestions = this.suggestions()
return (
!this.loading() &&
suggestions != null &&
!suggestions.title &&
!suggestions.tags?.length &&
@@ -1,46 +0,0 @@
<table class="table table-borderless align-baseline">
<thead>
<tr>
<th i18n>Page</th>
<th i18n>Type</th>
<th i18n>Content</th>
<th></th>
</tr>
</thead>
<tbody>
@for (barcode of barcodes(); track $index) {
<tr>
<td>{{ barcode.page }}</td>
<td class="text-nowrap">{{ barcode.format }}</td>
<td class="text-break">
@if (isLink(barcode.value)) {
<a
[href]="barcode.value"
target="_blank"
rel="noopener noreferrer nofollow"
>{{ barcode.value }}</a
>
} @else {
{{ barcode.value }}
}
</td>
<td class="text-end">
<button
type="button"
class="btn btn-sm btn-outline-primary"
(click)="copy($index)"
title="Copy content"
i18n-title
>
@if (copiedIndex() === $index) {
<i-bs name="clipboard-check"></i-bs>
} @else {
<i-bs name="clipboard"></i-bs>
}
<span class="visually-hidden" i18n>Copy content</span>
</button>
</td>
</tr>
}
</tbody>
</table>
@@ -1,73 +0,0 @@
import { Clipboard } from '@angular/cdk/clipboard'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { DocumentBarcodesComponent } from './document-barcodes.component'
const barcodes = [
{ page: 1, value: 'ASN00123', format: 'Code128' },
{ page: 2, value: 'https://example.com/invoice/4711', format: 'QRCode' },
{ page: 2, value: 'javascript:alert(1)', format: 'QRCode' },
]
describe('DocumentBarcodesComponent', () => {
let component: DocumentBarcodesComponent
let fixture: ComponentFixture<DocumentBarcodesComponent>
let clipboard: Clipboard
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [
DocumentBarcodesComponent,
NgxBootstrapIconsModule.pick(allIcons),
],
}).compileComponents()
fixture = TestBed.createComponent(DocumentBarcodesComponent)
component = fixture.componentInstance
clipboard = TestBed.inject(Clipboard)
fixture.componentRef.setInput('barcodes', barcodes)
fixture.detectChanges()
})
it('should display all barcodes', () => {
const rows = fixture.nativeElement.querySelectorAll('tbody tr')
expect(rows).toHaveLength(3)
expect(rows[0].textContent).toContain('ASN00123')
expect(rows[0].textContent).toContain('Code128')
})
it('should only link http(s) values', () => {
const links = fixture.nativeElement.querySelectorAll('tbody a')
expect(links).toHaveLength(1)
expect(links[0].getAttribute('href')).toEqual(
'https://example.com/invoice/4711'
)
expect(links[0].getAttribute('target')).toEqual('_blank')
})
it('should copy a value and show feedback', () => {
jest.useFakeTimers()
const copySpy = jest.spyOn(clipboard, 'copy').mockReturnValue(true)
const buttons = fixture.nativeElement.querySelectorAll('tbody button')
buttons[0].click()
fixture.detectChanges()
expect(copySpy).toHaveBeenCalledWith('ASN00123')
expect(component.copiedIndex()).toEqual(0)
expect(buttons[0].querySelector('i-bs').getAttribute('name')).toEqual(
'clipboard-check'
)
jest.advanceTimersByTime(3000)
fixture.detectChanges()
expect(component.copiedIndex()).toBeNull()
expect(buttons[0].querySelector('i-bs').getAttribute('name')).toEqual(
'clipboard'
)
jest.useRealTimers()
})
it('should not show feedback if copying failed', () => {
jest.spyOn(clipboard, 'copy').mockReturnValue(false)
component.copy(1)
expect(component.copiedIndex()).toBeNull()
})
})
@@ -1,38 +0,0 @@
import { Clipboard } from '@angular/cdk/clipboard'
import { Component, inject, input, OnDestroy, signal } from '@angular/core'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { DocumentBarcode } from 'src/app/data/document-barcode'
@Component({
selector: 'pngx-document-barcodes',
templateUrl: './document-barcodes.component.html',
imports: [NgxBootstrapIconsModule],
})
export class DocumentBarcodesComponent implements OnDestroy {
private readonly clipboard = inject(Clipboard)
readonly barcodes = input<DocumentBarcode[]>([])
readonly copiedIndex = signal<number>(null)
private copyTimeout: ReturnType<typeof setTimeout>
public isLink(value: string): boolean {
try {
const url = new URL(value.trim())
return ['http:', 'https:'].includes(url.protocol) && !!url.host
} catch {
return false
}
}
public copy(index: number) {
if (!this.clipboard.copy(this.barcodes()[index].value)) return
this.copiedIndex.set(index)
clearTimeout(this.copyTimeout)
this.copyTimeout = setTimeout(() => this.copiedIndex.set(null), 3000)
}
ngOnDestroy(): void {
clearTimeout(this.copyTimeout)
}
}
@@ -134,17 +134,11 @@
[loading]="suggestionsLoading()"
[suggestions]="suggestions()"
[aiEnabled]="aiEnabled"
[source]="suggestionSource"
[defaultSource]="defaultSuggestionSource"
[fetchedSources]="fetchedSuggestionSources()"
[appliedTags]="documentForm.value.tags"
[appliedCorrespondent]="documentForm.value.correspondent"
[appliedDocumentType]="documentForm.value.document_type"
[appliedStoragePath]="documentForm.value.storage_path"
[appliedTitle]="documentForm.value.title"
[appliedCreated]="documentForm.value.created"
(getSuggestions)="getSuggestions($event)"
(sourceChange)="suggestionSourceOverride.set($event)"
(getSuggestions)="getSuggestions()"
(addTag)="createTag($event)"
(addDocumentType)="createDocumentType($event)"
(addCorrespondent)="createCorrespondent($event)">
@@ -356,10 +350,6 @@
</table>
}
@if (metadata()?.barcodes?.length > 0) {
<h6 i18n>Barcodes</h6>
<pngx-document-barcodes [barcodes]="metadata().barcodes"></pngx-document-barcodes>
}
@if (metadata()?.original_metadata?.length > 0) {
<pngx-metadata-collapse i18n-title title="Original document metadata" [metadata]="metadata()?.original_metadata"></pngx-metadata-collapse>
}
@@ -43,7 +43,7 @@ import {
} from 'src/app/data/filter-rule-type'
import { StoragePath } from 'src/app/data/storage-path'
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 { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
@@ -662,45 +662,6 @@ describe('DocumentDetailComponent', () => {
)
})
it.each([
['tag', 'createTag', 'tags', 'suggested_tags'],
[
'document type',
'createDocumentType',
'document_type',
'suggested_document_types',
],
[
'correspondent',
'createCorrespondent',
'correspondent',
'suggested_correspondents',
],
])(
'should create a %s after ML-only suggestions',
(_, method, field, suggestedField) => {
initNormally()
component.suggestions.set({ tags: [1] })
let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
component[method]('New value')
openModal.componentInstance.succeeded.next({
id: 12,
name: 'New value',
is_inbox_tag: false,
color: '#ff0000',
text_color: '#000000',
})
if (field === 'tags') {
expect(component.tagsInput.value).toContain(12)
} else {
expect(component.documentForm.get(field).value).toBe(12)
}
expect(component.suggestions()[suggestedField]).toEqual([])
}
)
it('should support creating storage path', () => {
initNormally()
let openModal: NgbModalRef
@@ -1567,113 +1528,6 @@ describe('DocumentDetailComponent', () => {
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', () => {
const suggestionsSpy = jest.spyOn(documentService, 'getSuggestions')
const errorSpy = jest.spyOn(toastService, 'showError')
@@ -28,7 +28,7 @@ import {
import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { DeviceDetectorService } from 'ngx-device-detector'
import { BehaviorSubject, merge, Observable, of, Subject, timer } from 'rxjs'
import { BehaviorSubject, Observable, of, Subject, timer } from 'rxjs'
import {
catchError,
debounceTime,
@@ -48,10 +48,7 @@ import { DataType } from 'src/app/data/datatype'
import { Document, DocumentVersionInfo } from 'src/app/data/document'
import { DocumentMetadata } from 'src/app/data/document-metadata'
import { DocumentNote } from 'src/app/data/document-note'
import {
DocumentSuggestions,
mergeSuggestions,
} from 'src/app/data/document-suggestions'
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
import { DocumentType } from 'src/app/data/document-type'
import { FilterRule } from 'src/app/data/filter-rule'
import {
@@ -66,7 +63,7 @@ import {
import { ObjectWithId } from 'src/app/data/object-with-id'
import { StoragePath } from 'src/app/data/storage-path'
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 { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
@@ -135,7 +132,6 @@ import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-li
import { SuggestionsDropdownComponent } from '../common/suggestions-dropdown/suggestions-dropdown.component'
import { DocumentNotesComponent } from '../document-notes/document-notes.component'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
import { DocumentBarcodesComponent } from './document-barcodes/document-barcodes.component'
import { DocumentHistoryComponent } from './document-history/document-history.component'
import { DocumentVersionDropdownComponent } from './document-version-dropdown/document-version-dropdown.component'
import { MetadataCollapseComponent } from './metadata-collapse/metadata-collapse.component'
@@ -178,7 +174,6 @@ interface IncomingDocumentUpdate {
DateComponent,
DocumentLinkComponent,
MetadataCollapseComponent,
DocumentBarcodesComponent,
PermissionsFormComponent,
SelectComponent,
TagsComponent,
@@ -245,10 +240,6 @@ export class DocumentDetailComponent
private readonly autoSuggestSetting = this.settings.getSignal<boolean>(
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<
DocumentDetailFieldID[]
>(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS)
@@ -270,9 +261,6 @@ export class DocumentDetailComponent
readonly metadata = signal<DocumentMetadata>(undefined)
readonly suggestions = signal<DocumentSuggestions>(undefined)
readonly suggestionsLoading = signal(false)
// per-document, resets on navigation
readonly suggestionSourceOverride = signal<SuggestionSource>(null)
readonly fetchedSuggestionSources = signal<SuggestionSource[]>([])
readonly users = signal<User[]>(undefined)
readonly title = signal<string>(undefined)
@@ -377,15 +365,6 @@ export class DocumentDetailComponent
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 {
const hasArchiveVersion =
this.metadata()?.has_archive_version ??
@@ -611,8 +590,6 @@ export class DocumentDetailComponent
}
this.documentId.set(doc.id)
this.suggestions.set(null)
this.suggestionSourceOverride.set(null)
this.fetchedSuggestionSources.set([])
const openDocument = this.openDocumentService.getOpenDocument(
this.documentId()
)
@@ -1100,44 +1077,29 @@ export class DocumentDetailComponent
return this.documentForm.get('custom_fields') as FormArray
}
getSuggestions(source: SuggestionSource = this.suggestionSource) {
const sources = (
source === SuggestionSource.Both
? [SuggestionSource.ML, SuggestionSource.AI]
: [source]
).filter((s) => !this.fetchedSuggestionSources().includes(s))
if (!sources.length) return
getSuggestions() {
this.suggestionsLoading.set(true)
merge(
...sources.map((s) =>
(s === SuggestionSource.AI
? this.documentsService.getAiSuggestions(this.documentId())
: 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)
})
)
)
)
const suggestionsObservable = this.aiEnabled
? this.documentsService.getAiSuggestions(this.documentId())
: this.documentsService.getSuggestions(this.documentId())
suggestionsObservable
.pipe(
first(),
takeUntil(this.unsubscribeNotifier),
takeUntil(this.docChangeNotifier),
finalize(() => this.suggestionsLoading.set(false))
)
.subscribe((response) => {
if (!response) return
this.fetchedSuggestionSources.update((f) => [...f, response.source])
this.suggestions.set(
mergeSuggestions(this.suggestions(), response.result)
)
.subscribe({
next: (result) => {
this.suggestions.set(result)
},
error: (error) => {
this.suggestions.set(null)
this.toastService.showError(
$localize`Error retrieving suggestions.`,
error
)
},
})
}
@@ -1154,7 +1116,7 @@ export class DocumentDetailComponent
if (this.suggestions()) {
this.suggestions.set({
...this.suggestions(),
suggested_tags: (this.suggestions().suggested_tags ?? []).filter(
suggested_tags: this.suggestions().suggested_tags.filter(
(tag) => tag !== newTag.name
),
})
@@ -1193,12 +1155,10 @@ export class DocumentDetailComponent
this.documentForm.get('document_type').setValue(newDocumentType.id)
this.documentForm.get('document_type').markAsDirty()
if (this.suggestions()) {
this.suggestions.set({
...this.suggestions(),
suggested_document_types: (
this.suggestions().suggested_document_types ?? []
).filter((dt) => dt !== newName),
})
this.suggestions().suggested_document_types =
this.suggestions().suggested_document_types.filter(
(dt) => dt !== newName
)
}
})
}
@@ -1225,12 +1185,10 @@ export class DocumentDetailComponent
this.documentForm.get('correspondent').setValue(newCorrespondent.id)
this.documentForm.get('correspondent').markAsDirty()
if (this.suggestions()) {
this.suggestions.set({
...this.suggestions(),
suggested_correspondents: (
this.suggestions().suggested_correspondents ?? []
).filter((c) => c !== newName),
})
this.suggestions().suggested_correspondents =
this.suggestions().suggested_correspondents.filter(
(c) => c !== newName
)
}
})
}
@@ -146,19 +146,6 @@ describe('DocumentListComponent', () => {
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', () => {
documentListService.setFilterRules([
{
@@ -270,12 +270,9 @@ export class DocumentListComponent
this.list.reload()
})
this.websocketStatusService
.onDocumentDeleted()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
this.list.reload()
})
this.websocketStatusService.onDocumentDeleted().subscribe(() => {
this.list.reload()
})
this.route.paramMap
.pipe(
@@ -17,6 +17,12 @@
}
</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) {
<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>
@@ -12,6 +12,8 @@ import {
NgbDatepickerModule,
NgbDropdownItem,
NgbDropdownModule,
NgbModal,
NgbModalRef,
NgbTypeaheadModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select'
@@ -2515,4 +2517,45 @@ describe('FilterEditorComponent', () => {
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 {
NgbDropdownModule,
NgbModal,
NgbTypeahead,
NgbTypeaheadModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
import { Observable, Subject, from } from 'rxjs'
import { first, Observable, Subject, from } from 'rxjs'
import {
catchError,
debounceTime,
@@ -121,6 +122,7 @@ import {
PermissionsFilterDropdownComponent,
PermissionsSelectionModel,
} 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'
const TEXT_FILTER_TARGET_TITLE = 'title'
@@ -286,6 +288,7 @@ export class FilterEditorComponent
permissionsService = inject(PermissionsService)
private customFieldService = inject(CustomFieldsService)
private searchService = inject(SearchService)
private modalService = inject(NgbModal)
generateFilterName() {
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) {
if (event.key == 'Enter') {
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`,
}
-7
View File
@@ -1,7 +0,0 @@
export interface DocumentBarcode {
page: number
value: string
format: string
}
-4
View File
@@ -1,5 +1,3 @@
import { DocumentBarcode } from './document-barcode'
export interface DocumentMetadata {
original_checksum?: string
@@ -14,6 +12,4 @@ export interface DocumentMetadata {
has_archive_version?: boolean
lang?: string
barcodes?: DocumentBarcode[]
}
@@ -15,33 +15,3 @@ export interface DocumentSuggestions {
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),
}
}
-17
View File
@@ -330,13 +330,6 @@ export const PaperlessConfigOptions: ConfigOption[] = [
config_key: 'PAPERLESS_CONSUMER_TAG_BARCODE_SPLIT',
category: ConfigCategory.Barcode,
},
{
key: 'barcode_store_values',
title: $localize`Store Barcode Contents`,
type: ConfigOptionType.Boolean,
config_key: 'PAPERLESS_CONSUMER_STORE_BARCODE_VALUES',
category: ConfigCategory.Barcode,
},
{
key: 'ai_enabled',
title: $localize`AI Enabled`,
@@ -360,14 +353,6 @@ export const PaperlessConfigOptions: ConfigOption[] = [
config_key: 'PAPERLESS_AI_LLM_EMBEDDING_MODEL',
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',
title: $localize`LLM Embedding Endpoint`,
@@ -465,7 +450,6 @@ export interface PaperlessConfig extends ObjectWithId {
barcode_enable_tag: boolean
barcode_tag_mapping: object
barcode_tag_split: boolean
barcode_store_values: boolean
remote_ocr_engine: string
remote_ocr_api_key: string
remote_ocr_endpoint: string
@@ -473,7 +457,6 @@ export interface PaperlessConfig extends ObjectWithId {
ai_enabled: boolean
llm_embedding_backend: string
llm_embedding_model: string
llm_embedding_api_key: string
llm_embedding_endpoint: string
llm_embedding_chunk_size: number
llm_context_size: number
-13
View File
@@ -20,12 +20,6 @@ export enum GlobalSearchType {
TITLE_CONTENT = 'title-content',
}
export enum SuggestionSource {
ML = 'ml',
AI = 'ai',
Both = 'both',
}
export enum CollapsibleSection {
ATTRIBUTES = 'attributes',
}
@@ -104,8 +98,6 @@ export const SETTINGS_KEYS = {
'general-settings:document-editing:overlay-thumbnail',
DOCUMENT_EDITING_AUTO_SUGGEST:
'general-settings:document-editing:auto-suggest',
DOCUMENT_EDITING_SUGGESTION_SOURCE:
'general-settings:document-editing:suggestion-source',
DOCUMENT_DETAILS_HIDDEN_FIELDS:
'general-settings:document-details:hidden-fields',
SEARCH_DB_ONLY: 'general-settings:search:db-only',
@@ -334,11 +326,6 @@ export const SETTINGS: UiSetting[] = [
type: 'boolean',
default: true,
},
{
key: SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE,
type: 'string',
default: SuggestionSource.AI,
},
{
key: SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS,
type: 'array',
@@ -0,0 +1,515 @@
import {
AdvancedSearchDateUnit,
AdvancedSearchField,
AdvancedSearchLogicalOperator,
AdvancedSearchOperator,
AdvancedSearchQueryAtom,
AdvancedSearchQueryElement,
AdvancedSearchQueryElementType,
AdvancedSearchQueryGroup,
} from '../data/advanced-search-query'
import {
parseAdvancedSearchQuery,
serializeAdvancedSearchQuery,
} from './advanced-search-query'
const atom = (
field: AdvancedSearchField,
operator: AdvancedSearchOperator,
value?: string,
extra: Partial<AdvancedSearchQueryAtom> = {}
): AdvancedSearchQueryAtom => ({
type: AdvancedSearchQueryElementType.Atom,
field,
operator,
value,
...extra,
})
const group = (
operator: AdvancedSearchLogicalOperator,
...children: AdvancedSearchQueryElement[]
): AdvancedSearchQueryGroup => ({
type: AdvancedSearchQueryElementType.Group,
operator,
children,
})
const { And, Or, Not } = AdvancedSearchLogicalOperator
describe('serializeAdvancedSearchQuery', () => {
describe('text fields', () => {
it.each([
[AdvancedSearchOperator.AllWords, 'invoice', 'title:invoice'],
[
AdvancedSearchOperator.AllWords,
' invoice unpaid ',
'title:invoice AND title:unpaid',
],
[
AdvancedSearchOperator.AnyWord,
'invoice unpaid',
'title:invoice OR title:unpaid',
],
[
AdvancedSearchOperator.Phrase,
'quick brown fox',
'title:"quick brown fox"',
],
[AdvancedSearchOperator.Phrase, 'say "hi"', 'title:"say hi"'],
[AdvancedSearchOperator.StartsWith, 'invoi', 'title:invoi*'],
[AdvancedSearchOperator.StartsWith, 'in*v?oi', 'title:invoi*'],
])('%s %j writes %s', (operator, value, expected) => {
expect(
serializeAdvancedSearchQuery(
atom(AdvancedSearchField.Title, operator, value)
)
).toBe(expected)
})
it('writes bare words for the Any field', () => {
expect(
serializeAdvancedSearchQuery(
atom(AdvancedSearchField.Any, AdvancedSearchOperator.AllWords, 'a b')
)
).toBe('a AND b')
})
it.each([
['A-1312/99', 'custom_fields.value:A-1312/99'],
["O'Brien", "custom_fields.value:O'Brien"],
["'quoted'", `custom_fields.value:"'quoted'"`],
['foo:bar', 'custom_fields.value:"foo:bar"'],
['(x)', 'custom_fields.value:"(x)"'],
['2024*', 'custom_fields.value:"2024*"'],
['a,b', 'custom_fields.value:"a,b"'],
['OR', 'custom_fields.value:"OR"'],
['or', 'custom_fields.value:or'],
])(
'quotes %j only when the grammar would read it as syntax',
(value, expected) => {
expect(
serializeAdvancedSearchQuery(
atom(
AdvancedSearchField.CustomFieldValue,
AdvancedSearchOperator.AllWords,
value
)
)
).toBe(expected)
}
)
it('uses the dotted names for custom fields', () => {
expect(
serializeAdvancedSearchQuery(
group(
And,
atom(
AdvancedSearchField.CustomFieldName,
AdvancedSearchOperator.Phrase,
'status'
),
atom(
AdvancedSearchField.CustomFieldValue,
AdvancedSearchOperator.AllWords,
'paid'
)
)
)
).toBe('custom_fields.name:"status" AND custom_fields.value:paid')
})
it('uses the dotted names for notes', () => {
expect(
serializeAdvancedSearchQuery(
group(
And,
atom(
AdvancedSearchField.NoteText,
AdvancedSearchOperator.AllWords,
'call'
),
atom(
AdvancedSearchField.NoteAuthor,
AdvancedSearchOperator.AllWords,
'alice'
)
)
)
).toBe('notes.note:call AND notes.user:alice')
})
it.each([
[AdvancedSearchOperator.AllWords, ''],
[AdvancedSearchOperator.AllWords, ' '],
[AdvancedSearchOperator.AllWords, '!! --'],
[AdvancedSearchOperator.Phrase, '""'],
[AdvancedSearchOperator.StartsWith, 'two words'],
[AdvancedSearchOperator.StartsWith, '***'],
[AdvancedSearchOperator.StartsWith, undefined],
])('leaves out %s %j', (operator, value) => {
expect(
serializeAdvancedSearchQuery(
atom(AdvancedSearchField.Title, operator, value)
)
).toBe('')
})
})
describe('checksum', () => {
it('lowercases the prefix', () => {
expect(
serializeAdvancedSearchQuery(
atom(
AdvancedSearchField.Checksum,
AdvancedSearchOperator.StartsWith,
'9F86D081'
)
)
).toBe('checksum:9f86d081*')
})
})
describe('number fields', () => {
it.each([
[AdvancedSearchOperator.Equals, '42', undefined, 'asn:42'],
[AdvancedSearchOperator.AtLeast, '50', undefined, 'asn:[50 to]'],
[AdvancedSearchOperator.AtMost, '50', undefined, 'asn:[to 50]'],
[AdvancedSearchOperator.Between, '50', '150', 'asn:[50 to 150]'],
[AdvancedSearchOperator.Equals, '4.2', undefined, ''],
[AdvancedSearchOperator.Equals, '-1', undefined, ''],
[AdvancedSearchOperator.Equals, '2024-01-01', undefined, ''],
[AdvancedSearchOperator.Between, '50', '', ''],
])('%s %j %j writes %j', (operator, value, valueTo, expected) => {
expect(
serializeAdvancedSearchQuery(
atom(AdvancedSearchField.ASN, operator, value, { valueTo })
)
).toBe(expected)
})
})
describe('date fields', () => {
it.each([
['today', 'added:today'],
['previous month', 'added:"previous month"'],
['last tuesday', ''],
['', ''],
])('keyword %j writes %j', (value, expected) => {
expect(
serializeAdvancedSearchQuery(
atom(
AdvancedSearchField.Added,
AdvancedSearchOperator.DateKeyword,
value
)
)
).toBe(expected)
})
it.each([
['1', AdvancedSearchDateUnit.Day, 'added:[-1 day to now]'],
['3', AdvancedSearchDateUnit.Month, 'added:[-3 months to now]'],
['2', AdvancedSearchDateUnit.Week, 'added:[-2 weeks to now]'],
['0', AdvancedSearchDateUnit.Year, ''],
['1.5', AdvancedSearchDateUnit.Year, ''],
['3', undefined, ''],
['3', 'fortnight' as AdvancedSearchDateUnit, ''],
])('within the last %j %j writes %j', (value, unit, expected) => {
expect(
serializeAdvancedSearchQuery(
atom(
AdvancedSearchField.Added,
AdvancedSearchOperator.WithinLast,
value,
{
unit,
}
)
)
).toBe(expected)
})
it.each([
[
AdvancedSearchOperator.AtLeast,
'2024-01-01',
undefined,
'created:[2024-01-01 to]',
],
[
AdvancedSearchOperator.AtMost,
'2024-01-01',
undefined,
'created:[to 2024-01-01]',
],
[
AdvancedSearchOperator.Between,
'2024-01-01',
'2024-03-31',
'created:[2024-01-01 to 2024-03-31]',
],
[AdvancedSearchOperator.AtLeast, '2024', undefined, ''],
[AdvancedSearchOperator.Between, '2024-01-01', 'now', ''],
])('%s %j %j writes %j', (operator, value, valueTo, expected) => {
expect(
serializeAdvancedSearchQuery(
atom(AdvancedSearchField.Created, operator, value, { valueTo })
)
).toBe(expected)
})
})
describe('groups', () => {
const invoice = atom(
AdvancedSearchField.Content,
AdvancedSearchOperator.AllWords,
'invoice'
)
const letter = atom(
AdvancedSearchField.Title,
AdvancedSearchOperator.AllWords,
'letter'
)
const paid = atom(
AdvancedSearchField.Tag,
AdvancedSearchOperator.AllWords,
'paid'
)
const twoWords = atom(
AdvancedSearchField.Title,
AdvancedSearchOperator.AllWords,
'a b'
)
const anyWords = atom(
AdvancedSearchField.Title,
AdvancedSearchOperator.AnyWord,
'a b'
)
const empty = atom(
AdvancedSearchField.Title,
AdvancedSearchOperator.AllWords,
''
)
it.each([
['an empty group', group(And), ''],
['a group of empty atoms', group(Or, empty, group(And, empty)), ''],
[
'a single child without parentheses',
group(Or, invoice),
'content:invoice',
],
['All', group(And, invoice, letter), 'content:invoice AND title:letter'],
['Any', group(Or, invoice, letter), 'content:invoice OR title:letter'],
[
'skipped empty atoms',
group(And, empty, invoice, empty),
'content:invoice',
],
['Not with one child', group(Not, paid), 'NOT tag:paid'],
[
'Not as none of',
group(Not, paid, letter),
'NOT (tag:paid OR title:letter)',
],
[
'Not with a compound child',
group(Not, twoWords),
'NOT (title:a AND title:b)',
],
[
'Not inside All',
group(And, invoice, group(Not, paid)),
'content:invoice AND NOT tag:paid',
],
[
'Not inside Any',
group(Or, invoice, group(Not, paid)),
'content:invoice OR NOT tag:paid',
],
[
'Any inside All',
group(And, invoice, group(Or, letter, paid)),
'content:invoice AND (title:letter OR tag:paid)',
],
[
'All inside Any',
group(Or, invoice, group(And, letter, paid)),
'content:invoice OR (title:letter AND tag:paid)',
],
[
'All inside All flattened',
group(And, invoice, group(And, letter, paid)),
'content:invoice AND title:letter AND tag:paid',
],
[
'an all-words atom inside Any',
group(Or, invoice, twoWords),
'content:invoice OR (title:a AND title:b)',
],
[
'an any-word atom inside All',
group(And, invoice, anyWords),
'content:invoice AND (title:a OR title:b)',
],
[
'an all-words atom inside Not with siblings',
group(Not, paid, twoWords),
'NOT (tag:paid OR (title:a AND title:b))',
],
])('writes %s', (_, tree, expected) => {
expect(serializeAdvancedSearchQuery(tree)).toBe(expected)
})
it('writes the mockup example', () => {
expect(
serializeAdvancedSearchQuery(
group(
And,
invoice,
group(
Or,
atom(
AdvancedSearchField.Correspondent,
AdvancedSearchOperator.Phrase,
'acme corp'
),
atom(
AdvancedSearchField.Content,
AdvancedSearchOperator.Phrase,
'acme corporation'
)
),
atom(
AdvancedSearchField.Added,
AdvancedSearchOperator.WithinLast,
'3',
{
unit: AdvancedSearchDateUnit.Month,
}
),
group(Not, paid)
)
)
).toBe(
'content:invoice AND (correspondent:"acme corp" OR content:"acme corporation") AND added:[-3 months to now] AND NOT tag:paid'
)
})
})
})
describe('parseAdvancedSearchQuery', () => {
const canonical = [
'title:invoice',
'title:invoice AND title:unpaid',
'content:invoice OR content:receipt',
'a AND b',
'title:"quick brown fox"',
'title:invoi*',
"custom_fields.value:O'Brien",
'custom_fields.value:"foo:bar"',
'custom_fields.name:"status" AND custom_fields.value:paid',
'notes.note:call AND notes.user:alice',
'checksum:9f86d081*',
'asn:42',
'asn:[50 to 150]',
'asn:[50 to]',
'asn:[to 50]',
'page_count:[10 to]',
'added:today',
'added:"previous month"',
'added:[-1 day to now]',
'added:[-3 months to now]',
'created:[2024-01-01 to 2024-03-31]',
'created:[2024-01-01 to]',
'created:[to 2024-01-01]',
'NOT tag:paid',
'NOT (tag:paid OR title:letter)',
'NOT (title:a AND title:b)',
'content:invoice OR NOT tag:paid',
'content:invoice AND (title:letter OR tag:paid)',
'content:invoice OR (title:letter AND tag:paid)',
'content:invoice AND (correspondent:"acme corp" OR content:"acme corporation") AND added:[-3 months to now] AND NOT tag:paid',
]
it.each(canonical)('reads back %s unchanged', (query) => {
const tree = parseAdvancedSearchQuery(query)
expect(tree).not.toBeNull()
expect(serializeAdvancedSearchQuery(tree)).toBe(query)
})
it('reads surrounding whitespace', () => {
expect(
serializeAdvancedSearchQuery(
parseAdvancedSearchQuery(' title:invoice ')
)
).toBe('title:invoice')
})
it('puts the words of one condition back together', () => {
expect(parseAdvancedSearchQuery('title:invoice AND title:unpaid')).toEqual(
group(
And,
atom(
AdvancedSearchField.Title,
AdvancedSearchOperator.AllWords,
'invoice unpaid'
)
)
)
})
it('keeps words of different fields apart', () => {
expect(parseAdvancedSearchQuery('title:a OR content:b')).toEqual(
group(
Or,
atom(AdvancedSearchField.Title, AdvancedSearchOperator.AllWords, 'a'),
atom(AdvancedSearchField.Content, AdvancedSearchOperator.AllWords, 'b')
)
)
})
it('reads a range as its condition', () => {
expect(parseAdvancedSearchQuery('added:[-3 months to now]')).toEqual(
group(
And,
atom(
AdvancedSearchField.Added,
AdvancedSearchOperator.WithinLast,
'3',
{
unit: AdvancedSearchDateUnit.Month,
}
)
)
)
})
it.each([
['', 'nothing'],
[' ', 'whitespace'],
['type:invoice', 'a field alias'],
['notes:call', 'a bare notes prefix'],
['unknown:x', 'an unknown field'],
['title:a AND content:b OR title:c', 'AND and OR mixed at one level'],
['title:a b', 'an implicit AND'],
['title:"unterminated', 'an unterminated phrase'],
['asn:[50 to', 'an unterminated range'],
['title:a*b', 'a wildcard in the middle'],
['title:invoice^2', 'a boost'],
['asn:[50 to abc]', 'a non-numeric bound'],
['asn:invoice', 'a word on a number field'],
['created:[2024 to 2025]', 'a year-only date range'],
['added:"last tuesday"', 'a date keyword paperless does not have'],
['title:[a to b]', 'a range on a text field'],
['checksum:9f86d081', 'a checksum without a wildcard'],
['(title:invoice)', 'parentheses the editor would not write'],
['title:invoice AND', 'a trailing operator'],
['title:"a"b', 'a value running into the next'],
['ADDED:today', 'an uppercase field name'],
])('leaves %s alone, having %s', (query) => {
expect(parseAdvancedSearchQuery(query)).toBeNull()
})
})
@@ -0,0 +1,500 @@
import {
ADVANCED_SEARCH_DATE_KEYWORDS,
ADVANCED_SEARCH_FIELD_KINDS,
AdvancedSearchDateUnit,
AdvancedSearchField,
AdvancedSearchFieldKind,
AdvancedSearchLogicalOperator,
AdvancedSearchOperator,
AdvancedSearchQueryAtom,
AdvancedSearchQueryElement,
AdvancedSearchQueryElementType,
AdvancedSearchQueryGroup,
} from '../data/advanced-search-query'
// Anything the query grammar would read as syntax rather than as a word
const SYNTAX_CHARS = /[\s():"[\]*?,{}^~\\]/
const SYNTAX_CHARS_GLOBAL = new RegExp(SYNTAX_CHARS, 'g')
// A word wrapped in single quotes is also syntax, an apostrophe inside one is not
const EDGE_SINGLE_QUOTE = /^'|'$/
const RESERVED_WORDS = /^(AND|OR|NOT|TO)$/
const HAS_WORD_CHAR = /[\p{L}\p{N}]/u
const WHOLE_NUMBER = /^\d+$/
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
interface Serialized {
text: string
// The operator joining the top level of `text`, null when it is self-delimiting
join:
AdvancedSearchLogicalOperator.And | AdvancedSearchLogicalOperator.Or | null
}
const prefix = (field: AdvancedSearchField) => (field ? `${field}:` : '')
const quote = (text: string) => `"${text.replace(/"/g, '')}"`
const words = (value: string) =>
value
.trim()
.split(/\s+/)
.filter((word) => HAS_WORD_CHAR.test(word))
const word = (w: string) =>
SYNTAX_CHARS.test(w) || EDGE_SINGLE_QUOTE.test(w) || RESERVED_WORDS.test(w)
? quote(w)
: w
const atomic = (text: string): Serialized => ({ text, join: null })
function serializeWords(
atom: AdvancedSearchQueryAtom,
join: AdvancedSearchLogicalOperator.And | AdvancedSearchLogicalOperator.Or
): Serialized {
// A field applies only to the word right after it, so repeat it per word
const terms = words(atom.value ?? '').map(
(w) => `${prefix(atom.field)}${word(w)}`
)
if (terms.length === 0) return null
if (terms.length === 1) return atomic(terms[0])
return { text: terms.join(` ${join} `), join }
}
function serializeRange(
atom: AdvancedSearchQueryAtom,
isValid: (v: string) => boolean
): Serialized {
const lo = atom.value?.trim() ?? ''
const hi = atom.valueTo?.trim() ?? ''
const field = prefix(atom.field)
switch (atom.operator) {
case AdvancedSearchOperator.Equals:
return isValid(lo) ? atomic(`${field}${lo}`) : null
case AdvancedSearchOperator.AtLeast:
return isValid(lo) ? atomic(`${field}[${lo} to]`) : null
case AdvancedSearchOperator.AtMost:
return isValid(lo) ? atomic(`${field}[to ${lo}]`) : null
case AdvancedSearchOperator.Between:
return isValid(lo) && isValid(hi)
? atomic(`${field}[${lo} to ${hi}]`)
: null
}
return null
}
function serializeAtom(atom: AdvancedSearchQueryAtom): Serialized {
const field = prefix(atom.field)
const value = atom.value?.trim() ?? ''
switch (atom.operator) {
case AdvancedSearchOperator.AllWords:
return serializeWords(atom, AdvancedSearchLogicalOperator.And)
case AdvancedSearchOperator.AnyWord:
return serializeWords(atom, AdvancedSearchLogicalOperator.Or)
case AdvancedSearchOperator.Phrase:
return HAS_WORD_CHAR.test(value)
? atomic(`${field}${quote(value)}`)
: null
case AdvancedSearchOperator.StartsWith: {
if (/\s/.test(value)) return null
let stem = value.replace(SYNTAX_CHARS_GLOBAL, '')
if (!HAS_WORD_CHAR.test(stem)) return null
// checksum is indexed as-is, in lowercase
if (atom.field === AdvancedSearchField.Checksum) {
stem = stem.toLowerCase()
}
return atomic(`${field}${stem}*`)
}
case AdvancedSearchOperator.DateKeyword:
return (ADVANCED_SEARCH_DATE_KEYWORDS as readonly string[]).includes(
value
)
? atomic(`${field}${word(value)}`)
: null
case AdvancedSearchOperator.WithinLast: {
const amount = parseInt(value, 10)
const units = Object.values(AdvancedSearchDateUnit) as string[]
if (!WHOLE_NUMBER.test(value) || amount < 1) return null
if (!units.includes(atom.unit)) return null
const unit = amount === 1 ? atom.unit : `${atom.unit}s`
return atomic(`${field}[-${amount} ${unit} to now]`)
}
case AdvancedSearchOperator.Equals:
case AdvancedSearchOperator.AtLeast:
case AdvancedSearchOperator.AtMost:
case AdvancedSearchOperator.Between:
return serializeRange(
atom,
ADVANCED_SEARCH_FIELD_KINDS[atom.field] === AdvancedSearchFieldKind.Date
? (v) => ISO_DATE.test(v)
: (v) => WHOLE_NUMBER.test(v)
)
}
return null
}
function wrap(
child: Serialized,
parentJoin: AdvancedSearchLogicalOperator
): string {
return child.join === null || child.join === parentJoin
? child.text
: `(${child.text})`
}
function serializeGroup(group: AdvancedSearchQueryGroup): Serialized {
const children = group.children.map(serializeElement).filter(Boolean)
if (children.length === 0) return null
if (group.operator === AdvancedSearchLogicalOperator.Not) {
// A Not group matches documents matching none of its children
if (children.length === 1 && children[0].join === null) {
return atomic(`NOT ${children[0].text}`)
}
const inner =
children.length === 1
? children[0].text
: children
.map((c) => wrap(c, AdvancedSearchLogicalOperator.Or))
.join(' OR ')
return atomic(`NOT (${inner})`)
}
if (children.length === 1) return children[0]
return {
text: children
.map((c) => wrap(c, group.operator))
.join(` ${group.operator} `),
join: group.operator,
}
}
function serializeElement(element: AdvancedSearchQueryElement): Serialized {
return element.type === AdvancedSearchQueryElementType.Group
? serializeGroup(element)
: serializeAtom(element)
}
/**
* Writes an editor tree as a full-text query. Atoms that are not filled in
* (or not valid) are left out, and empty groups with them.
*/
export function serializeAdvancedSearchQuery(
element: AdvancedSearchQueryElement
): string {
return serializeElement(element)?.text ?? ''
}
// --- Reading a query back into the editor --------------------------------
//
// Deliberately narrow: this reads the forms serializeAdvancedSearchQuery
// writes, and nothing else. A query it cannot read is not a failure, it just
// stays text, so there is never a lossy or surprising conversion. The final
// round-trip check below is what holds that promise: a tree is only returned
// when writing it out again reproduces the query exactly.
const RELATIVE_BOUND = /^-(\d+) (day|week|month|year)s?$/
const FIELD_PREFIX = /^([a-z_]+(?:\.[a-z_]+)?):/
const KEYWORD_TOKEN = /^(AND|OR|NOT)(?=[\s(]|$)/
// Either bound may be missing: [50 to 150], [50 to], [to 50]
const RANGE_BOUNDS = /^(?:(.+?) )?to(?: (.+))?$/
const TRAILING_WILDCARD = /^([^*?]+)\*$/
class UnreadableQuery extends Error {}
interface Token {
type: 'term' | 'AND' | 'OR' | 'NOT' | '(' | ')'
field?: string
value?: string
quoted?: boolean
range?: boolean
}
// A parsed element, plus what it takes to merge the per-word terms the
// serializer writes for "contains all words" back into a single condition
interface Parsed {
element: AdvancedSearchQueryElement
word?: { field: AdvancedSearchField; text: string }
}
function tokenize(query: string): Token[] {
const tokens: Token[] = []
let i = 0
while (i < query.length) {
const rest = query.slice(i)
if (/^\s/.test(rest)) {
i++
continue
}
if (rest[0] === '(' || rest[0] === ')') {
tokens.push({ type: rest[0] as '(' | ')' })
i++
continue
}
const keyword = KEYWORD_TOKEN.exec(rest)
if (keyword) {
tokens.push({ type: keyword[1] as 'AND' | 'OR' | 'NOT' })
i += keyword[1].length
continue
}
const fieldMatch = FIELD_PREFIX.exec(rest)
const field = fieldMatch ? fieldMatch[1] : ''
i += fieldMatch ? fieldMatch[0].length : 0
const value = query.slice(i)
if (value.startsWith('"')) {
const end = query.indexOf('"', i + 1)
if (end < 0) throw new UnreadableQuery()
tokens.push({
type: 'term',
field,
value: query.slice(i + 1, end),
quoted: true,
})
i = end + 1
} else if (value.startsWith('[')) {
const end = query.indexOf(']', i + 1)
if (end < 0) throw new UnreadableQuery()
tokens.push({
type: 'term',
field,
value: query.slice(i + 1, end),
range: true,
})
i = end + 1
} else {
const bare = /^[^\s()]+/.exec(value)
if (!bare) throw new UnreadableQuery()
tokens.push({ type: 'term', field, value: bare[0] })
i += bare[0].length
}
// Nothing may run on directly after a value, e.g. title:"a"b
if (i < query.length && !/[\s)]/.test(query[i])) throw new UnreadableQuery()
}
return tokens
}
function resolveField(name: string): AdvancedSearchField {
const fields = Object.values(AdvancedSearchField) as string[]
// Aliases (type:, path:, notes:) are left to the text box on purpose:
// reading one would mean rewriting the user's query as it was read
if (!fields.includes(name)) throw new UnreadableQuery()
return name as AdvancedSearchField
}
function atomFrom(
field: AdvancedSearchField,
operator: AdvancedSearchOperator,
value: string,
extra: Partial<AdvancedSearchQueryAtom> = {}
): AdvancedSearchQueryAtom {
return {
type: AdvancedSearchQueryElementType.Atom,
field,
operator,
value,
...extra,
}
}
function parseRange(
field: AdvancedSearchField,
kind: AdvancedSearchFieldKind,
body: string
): AdvancedSearchQueryAtom {
const bounds = RANGE_BOUNDS.exec(body)
if (!bounds) throw new UnreadableQuery()
const lo = bounds[1] ?? ''
const hi = bounds[2] ?? ''
if (kind === AdvancedSearchFieldKind.Date) {
const relative = RELATIVE_BOUND.exec(lo)
if (relative && hi === 'now') {
return atomFrom(field, AdvancedSearchOperator.WithinLast, relative[1], {
unit: relative[2] as AdvancedSearchDateUnit,
})
}
} else if (kind !== AdvancedSearchFieldKind.Number) {
throw new UnreadableQuery()
}
const isValid =
kind === AdvancedSearchFieldKind.Date
? (v: string) => ISO_DATE.test(v)
: (v: string) => WHOLE_NUMBER.test(v)
if (lo && hi) {
if (!isValid(lo) || !isValid(hi)) throw new UnreadableQuery()
return atomFrom(field, AdvancedSearchOperator.Between, lo, { valueTo: hi })
}
if (lo && isValid(lo))
return atomFrom(field, AdvancedSearchOperator.AtLeast, lo)
if (hi && isValid(hi))
return atomFrom(field, AdvancedSearchOperator.AtMost, hi)
throw new UnreadableQuery()
}
function parseTerm(token: Token): Parsed {
const field = resolveField(token.field)
const kind = ADVANCED_SEARCH_FIELD_KINDS[field]
const value = token.value
const isKeyword = (
ADVANCED_SEARCH_DATE_KEYWORDS as readonly string[]
).includes(value)
if (token.range) {
return { element: parseRange(field, kind, value) }
}
if (kind === AdvancedSearchFieldKind.Date) {
if (!isKeyword) throw new UnreadableQuery()
return {
element: atomFrom(field, AdvancedSearchOperator.DateKeyword, value),
}
}
if (token.quoted) {
if (kind !== AdvancedSearchFieldKind.Text) throw new UnreadableQuery()
return { element: atomFrom(field, AdvancedSearchOperator.Phrase, value) }
}
const wildcard = TRAILING_WILDCARD.exec(value)
if (wildcard) {
if (kind === AdvancedSearchFieldKind.Number) throw new UnreadableQuery()
return {
element: atomFrom(field, AdvancedSearchOperator.StartsWith, wildcard[1]),
}
}
if (kind === AdvancedSearchFieldKind.Number) {
if (!WHOLE_NUMBER.test(value)) throw new UnreadableQuery()
return { element: atomFrom(field, AdvancedSearchOperator.Equals, value) }
}
// A checksum is only ever searched by its first characters
if (kind === AdvancedSearchFieldKind.Checksum) throw new UnreadableQuery()
return {
element: atomFrom(field, AdvancedSearchOperator.AllWords, value),
word: { field, text: value },
}
}
// The serializer repeats the field for every word, because a field applies
// only to the word after it. Put those back together into one condition.
function mergeWords(
parts: Parsed[],
operator: AdvancedSearchLogicalOperator.And | AdvancedSearchLogicalOperator.Or
): AdvancedSearchQueryElement[] {
const merged: AdvancedSearchQueryElement[] = []
for (let i = 0; i < parts.length; i++) {
const run = [parts[i]]
while (
parts[i].word &&
parts[i + 1]?.word &&
parts[i + 1].word.field === parts[i].word.field
) {
run.push(parts[++i])
}
if (run.length === 1) {
merged.push(run[0].element)
continue
}
merged.push(
atomFrom(
run[0].word.field,
operator === AdvancedSearchLogicalOperator.And
? AdvancedSearchOperator.AllWords
: AdvancedSearchOperator.AnyWord,
run.map((part) => part.word.text).join(' ')
)
)
}
return merged
}
interface Cursor {
tokens: Token[]
at: number
}
function parseExpression(cursor: Cursor): Parsed {
const parts: Parsed[] = [parseOperand(cursor)]
let operator:
AdvancedSearchLogicalOperator.And | AdvancedSearchLogicalOperator.Or
while (
cursor.tokens[cursor.at]?.type === 'AND' ||
cursor.tokens[cursor.at]?.type === 'OR'
) {
const next = cursor.tokens[cursor.at++].type as
AdvancedSearchLogicalOperator.And | AdvancedSearchLogicalOperator.Or
// One level mixing AND and OR is never something the editor wrote
if (operator && next !== operator) throw new UnreadableQuery()
operator = next
parts.push(parseOperand(cursor))
}
if (parts.length === 1) return parts[0]
const children = mergeWords(parts, operator)
if (children.length === 1) return { element: children[0] }
return {
element: {
type: AdvancedSearchQueryElementType.Group,
operator,
children,
},
}
}
function parseOperand(cursor: Cursor): Parsed {
const token = cursor.tokens[cursor.at++]
if (!token) throw new UnreadableQuery()
if (token.type === 'NOT') {
const child = parseOperand(cursor)
return {
element: {
type: AdvancedSearchQueryElementType.Group,
operator: AdvancedSearchLogicalOperator.Not,
children: [child.element],
},
}
}
if (token.type === '(') {
const inner = parseExpression(cursor)
if (cursor.tokens[cursor.at++]?.type !== ')') throw new UnreadableQuery()
return { element: inner.element }
}
if (token.type !== 'term') throw new UnreadableQuery()
return parseTerm(token)
}
/**
* Reads a query the editor could have written back into an editor tree, or
* returns null when the editor cannot show it, in which case the query stays
* text. Never returns a tree that would be written back differently.
*/
export function parseAdvancedSearchQuery(
query: string
): AdvancedSearchQueryGroup | null {
const trimmed = query?.trim() ?? ''
if (!trimmed) return null
let parsed: Parsed
try {
const cursor: Cursor = { tokens: tokenize(trimmed), at: 0 }
parsed = parseExpression(cursor)
if (cursor.at !== cursor.tokens.length) throw new UnreadableQuery()
} catch {
return null
}
const root =
parsed.element.type === AdvancedSearchQueryElementType.Group
? parsed.element
: {
type: AdvancedSearchQueryElementType.Group as const,
operator: AdvancedSearchLogicalOperator.And,
children: [parsed.element],
}
return serializeAdvancedSearchQuery(root) === trimmed ? root : null
}
+1 -1
View File
@@ -8,7 +8,7 @@ export const environment = {
apiVersion: '10', // match src/paperless/settings.py
appTitle: DEFAULT_APP_TITLE,
tag: 'prod',
version: '3.3.0',
version: '3.2.0',
webSocketHost: window.location.host,
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
webSocketBaseUrl: base_url.pathname + 'ws/',
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
Loaded 100 of 313 files, more files were not shown because too many files have changed in this diff. Show more