mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-01 22:00:31 +00:00
Compare commits
71
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f3c4f60f9 | ||
|
|
3a022e2dc4 | ||
|
|
58bda48682 | ||
|
|
b2a2e15ead | ||
|
|
e865185425 | ||
|
|
d02c19a09e | ||
|
|
4a106454ce | ||
|
|
4254100fae | ||
|
|
74f727b1f3 | ||
|
|
fb6fad952e | ||
|
|
f9b4e0c8e0 | ||
|
|
e5041097af | ||
|
|
61f36eb253 | ||
|
|
e50bcc2efb | ||
|
|
88000aab45 | ||
|
|
01b4ab392c | ||
|
|
52aa8758e3 | ||
|
|
4b7cc1c0c3 | ||
|
|
f02d7455be | ||
|
|
a61c35fcd8 | ||
|
|
64552d5d5b | ||
|
|
a59edf3e5b | ||
|
|
6a5d06ee26 | ||
|
|
126ec414a8 | ||
|
|
773744d04b | ||
|
|
7b98085b31 | ||
|
|
b3bf220f56 | ||
|
|
d4429c3cc7 | ||
|
|
4f777d7438 | ||
|
|
2a44d8b5ba | ||
|
|
abf5050ea7 | ||
|
|
091ddf7c45 | ||
|
|
c9f7f2cfbe | ||
|
|
b457610ffb | ||
|
|
969c2ea0e2 | ||
|
|
31b806a285 | ||
|
|
99851b418c | ||
|
|
e34eda07bb | ||
|
|
793459b416 | ||
|
|
04297fd02c | ||
|
|
1b277dd8e1 | ||
|
|
3c20abeb4c | ||
|
|
b11f1f8459 | ||
|
|
7424e7ce0b | ||
|
|
a53a3d3769 | ||
|
|
15b73b890c | ||
|
|
02d355061f | ||
|
|
d8b5b4d447 | ||
|
|
03ac4aed7e | ||
|
|
90d23bad9c | ||
|
|
e4367b5648 | ||
|
|
cb85441c2f | ||
|
|
cceaa559d4 | ||
|
|
a748d4c64f | ||
|
|
452ed005bd | ||
|
|
40058ff7d5 | ||
|
|
f502cd5e34 | ||
|
|
99ce6b5db3 | ||
|
|
2d955e9697 | ||
|
|
f440e8d33c | ||
|
|
4c264651e8 | ||
|
|
48d97b78bb | ||
|
|
659a0cb2ef | ||
|
|
d02d1e1711 | ||
|
|
3b41810e7b | ||
|
|
12314fcaa8 | ||
|
|
8705bd510a | ||
|
|
7575d60782 | ||
|
|
8b02a23b86 | ||
|
|
87d157096e | ||
|
|
54e332d259 |
@@ -15,6 +15,8 @@
|
||||
# Test related
|
||||
**/.pytest_cache
|
||||
**/tests
|
||||
src/paperless_testing
|
||||
src/conftest.py
|
||||
**/*.spec.ts
|
||||
**/htmlcov
|
||||
# Local folders
|
||||
|
||||
@@ -59,7 +59,6 @@ updates:
|
||||
- "drf-*"
|
||||
- "djangorestframework"
|
||||
- "whitenoise"
|
||||
- "bleach"
|
||||
- "jinja2"
|
||||
# Async, Task Queuing & Caching
|
||||
async-tasks:
|
||||
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
with:
|
||||
python-version: "${{ matrix.python-version }}"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: true
|
||||
@@ -139,13 +139,13 @@ jobs:
|
||||
pytest
|
||||
- name: Upload test results to Codecov
|
||||
if: always()
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
|
||||
with:
|
||||
flags: backend-python-${{ matrix.python-version }}
|
||||
files: junit.xml
|
||||
report_type: test_results
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
|
||||
with:
|
||||
flags: backend-python-${{ matrix.python-version }}
|
||||
files: coverage.xml
|
||||
@@ -176,7 +176,7 @@ jobs:
|
||||
with:
|
||||
python-version: "${{ env.DEFAULT_PYTHON }}"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: true
|
||||
|
||||
@@ -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@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
- 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@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.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@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: true
|
||||
|
||||
@@ -174,13 +174,13 @@ jobs:
|
||||
run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
||||
- name: Upload test results to Codecov
|
||||
if: always()
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
|
||||
with:
|
||||
flags: frontend-node-${{ matrix.node-version }}
|
||||
directory: src-ui/
|
||||
report_type: test_results
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
|
||||
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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: '0.12.x'
|
||||
enable-cache: false
|
||||
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: false
|
||||
@@ -212,7 +212,7 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: false
|
||||
|
||||
@@ -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@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||
uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
steps:
|
||||
- name: Clean temporary images
|
||||
if: "${{ env.TOKEN != '' }}"
|
||||
uses: stumpylog/image-cleaner-action/ephemeral@4fe057d991d63b8f6d5d22c40f17c1bca2226537 # v0.12.0
|
||||
uses: stumpylog/image-cleaner-action/ephemeral@21f875bab2376314e0525c614e923c669434f0e4 # v0.13.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@4fe057d991d63b8f6d5d22c40f17c1bca2226537 # v0.12.0
|
||||
uses: stumpylog/image-cleaner-action/untagged@21f875bab2376314e0525c614e923c669434f0e4 # v0.13.0
|
||||
with:
|
||||
token: "${{ env.TOKEN }}"
|
||||
owner: "${{ github.repository_owner }}"
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||
uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
|
||||
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@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||
uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
token: ${{ secrets.PNGX_BOT_PAT }}
|
||||
persist-credentials: false
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@0d5670f539973aea2f01abce61a8989934df0025 # v3.0.2
|
||||
uses: crowdin/github-action@df474cdfb9f41d6ae777118749477c2cdf7cacc8 # v3.2.0
|
||||
with:
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -qq --no-install-recommends gettext
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: true
|
||||
|
||||
+706
-456
File diff suppressed because it is too large
Load Diff
@@ -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.6'
|
||||
rev: 'v3.9.8'
|
||||
hooks:
|
||||
- id: prettier
|
||||
types_or:
|
||||
@@ -46,16 +46,16 @@ repos:
|
||||
- ts
|
||||
- markdown
|
||||
additional_dependencies:
|
||||
- prettier@3.9.6
|
||||
- prettier@3.9.8
|
||||
- 'prettier-plugin-organize-imports@4.3.0'
|
||||
# Python hooks
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.16.5
|
||||
rev: v0.16.8
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
- id: ruff-format
|
||||
- repo: https://github.com/tox-dev/pyproject-fmt
|
||||
rev: "v2.28.1"
|
||||
rev: "v2.29.4"
|
||||
hooks:
|
||||
- id: pyproject-fmt
|
||||
additional_dependencies: [tomli]
|
||||
|
||||
+4804
-12941
File diff suppressed because one or more lines are too long
+4
-2
@@ -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.16-python3.14-trixie-slim AS s6-overlay-base
|
||||
FROM ghcr.io/astral-sh/uv:0.12.20-python3.14-trixie-slim AS s6-overlay-base
|
||||
|
||||
WORKDIR /usr/src/s6
|
||||
|
||||
@@ -171,7 +171,9 @@ 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/*
|
||||
&& rm --recursive --force --verbose /var/lib/apt/lists/* \
|
||||
&& echo "Configuring interactive shells to source the s6 container environment" \
|
||||
&& echo '. /etc/profile.d/contenv.sh' >> /etc/bash.bashrc
|
||||
|
||||
WORKDIR /usr/src/paperless/src/
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ services:
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
greenmail:
|
||||
image: docker.io/greenmail/standalone:2.1.13
|
||||
image: docker.io/greenmail/standalone:2.1.14
|
||||
hostname: greenmail
|
||||
container_name: greenmail
|
||||
environment:
|
||||
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/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,6 +2,7 @@
|
||||
# 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..."
|
||||
|
||||
@@ -9,12 +10,20 @@ 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}
|
||||
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
|
||||
|
||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||
exec /usr/local/bin/celery --app paperless flower --conf=${PAPERLESS_SRC_DIR}/paperless/flowerconfig.py
|
||||
exec /usr/local/bin/celery --app paperless flower "${conf_args[@]}"
|
||||
else
|
||||
exec s6-setuidgid paperless /usr/local/bin/celery --app paperless flower --conf=${PAPERLESS_SRC_DIR}/paperless/flowerconfig.py
|
||||
exec s6-setuidgid paperless /usr/local/bin/celery --app paperless flower "${conf_args[@]}"
|
||||
fi
|
||||
|
||||
else
|
||||
|
||||
+14
-9
@@ -136,13 +136,15 @@ 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. 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)
|
||||
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)
|
||||
(otherwise it follows the user's UI language).
|
||||
|
||||
### The LLM index (RAG) and similar documents
|
||||
@@ -153,8 +155,11 @@ 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`). The index is only
|
||||
built when AI is enabled **and** an embedding backend is set.
|
||||
(`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.
|
||||
|
||||
The index is updated automatically on a schedule controlled by
|
||||
[`PAPERLESS_LLM_INDEX_TASK_CRON`](configuration.md#PAPERLESS_LLM_INDEX_TASK_CRON) (daily by
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
# Changelog
|
||||
|
||||
## paperless-ngx 3.2.1
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: only pass --conf to flower when flowerconfig.py exists [@bitfoo1](https://github.com/bitfoo1) ([#14182](https://github.com/paperless-ngx/paperless-ngx/pull/14182))
|
||||
- Fix: replace stale mail-fetch overlap check with a self-expiring lock [@stumpylog](https://github.com/stumpylog) ([#14189](https://github.com/paperless-ngx/paperless-ngx/pull/14189))
|
||||
- Fix: bump ocrmypdf to 17.12 to pick up the ligature text-layer fix [@stumpylog](https://github.com/stumpylog) ([#14190](https://github.com/paperless-ngx/paperless-ngx/pull/14190))
|
||||
- Fix: rebuild the search index automatically when it is missing Tantivy files [@stumpylog](https://github.com/stumpylog) ([#14180](https://github.com/paperless-ngx/paperless-ngx/pull/14180))
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Chore(deps): Bump anyio from 4.12.1 to 4.14.2 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#14175](https://github.com/paperless-ngx/paperless-ngx/pull/14175))
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>4 changes</summary>
|
||||
|
||||
- Chore(deps): Bump anyio from 4.12.1 to 4.14.2 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#14175](https://github.com/paperless-ngx/paperless-ngx/pull/14175))
|
||||
- Fix: replace stale mail-fetch overlap check with a self-expiring lock [@stumpylog](https://github.com/stumpylog) ([#14189](https://github.com/paperless-ngx/paperless-ngx/pull/14189))
|
||||
- Fix: bump ocrmypdf to 17.12 to pick up the ligature text-layer fix [@stumpylog](https://github.com/stumpylog) ([#14190](https://github.com/paperless-ngx/paperless-ngx/pull/14190))
|
||||
- Fix: rebuild the search index automatically when it is missing Tantivy files [@stumpylog](https://github.com/stumpylog) ([#14180](https://github.com/paperless-ngx/paperless-ngx/pull/14180))
|
||||
|
||||
</details>
|
||||
|
||||
## paperless-ngx 3.2.0
|
||||
|
||||
### Features / Enhancements
|
||||
|
||||
@@ -2133,6 +2133,13 @@ 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
|
||||
@@ -2217,6 +2224,19 @@ 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
|
||||
|
||||
@@ -150,6 +150,7 @@ 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
|
||||
|
||||
|
||||
+24
-13
@@ -1,7 +1,9 @@
|
||||
[project]
|
||||
name = "paperless-ngx"
|
||||
version = "3.2.0"
|
||||
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
||||
version = "3.2.1"
|
||||
description = """\
|
||||
A community-supported supercharged document management system: scan, index and archive all your physical documents\
|
||||
"""
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
classifiers = [
|
||||
@@ -10,13 +12,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",
|
||||
@@ -32,7 +34,7 @@ dependencies = [
|
||||
"django-cors-headers~=4.9.0",
|
||||
"django-extensions~=4.1",
|
||||
"django-filter~=25.1",
|
||||
"django-guardian>=3.3.3,<3.5",
|
||||
"django-guardian>=3.3.3,<3.6",
|
||||
"django-multiselectfield~=1.0.1",
|
||||
"django-rich~=2.2.0",
|
||||
"django-soft-delete~=1.0.18",
|
||||
@@ -42,7 +44,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.2",
|
||||
"flower>=2.0.1,<2.3",
|
||||
"gotenberg-client[httpx]~=1.0",
|
||||
"httpx-oauth~=0.17",
|
||||
"ijson>=3.5.1",
|
||||
@@ -74,6 +76,7 @@ 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",
|
||||
@@ -81,13 +84,13 @@ dependencies = [
|
||||
]
|
||||
[project.optional-dependencies]
|
||||
mariadb = [
|
||||
"mysqlclient~=2.2.7",
|
||||
"mysqlclient>=2.2.7,<2.4",
|
||||
]
|
||||
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.1",
|
||||
"psycopg-pool==3.3.2",
|
||||
]
|
||||
webserver = [
|
||||
"granian[uvloop]>=2.7,<2.9",
|
||||
@@ -109,7 +112,7 @@ lint = [
|
||||
testing = [
|
||||
"daphne",
|
||||
"factory-boy~=3.3.1",
|
||||
"faker>=40.36,<40.39",
|
||||
"faker>=40.36,<40.40",
|
||||
"imagehash",
|
||||
"pytest~=9.1.1",
|
||||
"pytest-cov~=7.1.0",
|
||||
@@ -117,7 +120,7 @@ testing = [
|
||||
"pytest-env~=1.7.0",
|
||||
"pytest-httpx",
|
||||
"pytest-mock~=3.15.1",
|
||||
# "pytest-randomly~=4.0.1",
|
||||
"pytest-randomly~=5.0.0",
|
||||
"pytest-rerunfailures~=16.4",
|
||||
"pytest-sugar",
|
||||
"pytest-xdist~=3.8.0",
|
||||
@@ -133,7 +136,6 @@ typing = [
|
||||
"mypy",
|
||||
"mypy-baseline",
|
||||
"pyrefly",
|
||||
"types-bleach",
|
||||
"types-channels",
|
||||
"types-colorama",
|
||||
"types-dateparser",
|
||||
@@ -148,6 +150,7 @@ typing = [
|
||||
|
||||
[tool.uv]
|
||||
required-version = ">=0.9.0"
|
||||
prerelease = "disallow"
|
||||
environments = [
|
||||
"sys_platform == 'darwin'",
|
||||
"sys_platform == 'linux'",
|
||||
@@ -244,6 +247,10 @@ 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]
|
||||
@@ -271,9 +278,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"
|
||||
@@ -326,6 +333,8 @@ 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 = [
|
||||
@@ -334,13 +343,15 @@ source = [
|
||||
omit = [
|
||||
"*/tests/*",
|
||||
"manage.py",
|
||||
"paperless/wsgi.py",
|
||||
"paperless/auth.py",
|
||||
"paperless/wsgi.py",
|
||||
"src/conftest.py",
|
||||
"src/paperless_testing/*",
|
||||
]
|
||||
[tool.coverage.report]
|
||||
exclude_also = [
|
||||
"if settings.AUDIT_LOG_ENABLED:",
|
||||
"if AUDIT_LOG_ENABLED:",
|
||||
"if settings.AUDIT_LOG_ENABLED:",
|
||||
"if TYPE_CHECKING:",
|
||||
]
|
||||
|
||||
|
||||
+286
-209
File diff suppressed because it is too large
Load Diff
+16
-16
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paperless-ngx-ui",
|
||||
"version": "3.2.0",
|
||||
"version": "3.2.1",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"ng": "ng",
|
||||
@@ -15,16 +15,16 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@angular/cdk": "^22.1.7",
|
||||
"@angular/common": "~22.1.7",
|
||||
"@angular/compiler": "~22.1.7",
|
||||
"@angular/core": "~22.1.7",
|
||||
"@angular/forms": "~22.1.7",
|
||||
"@angular/localize": "~22.1.7",
|
||||
"@angular/platform-browser": "~22.1.7",
|
||||
"@angular/router": "~22.1.7",
|
||||
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
||||
"@ng-select/ng-select": "~24.1.1",
|
||||
"@ng-select/ng-select": "~24.1.2",
|
||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"bootstrap": "^5.3.8",
|
||||
@@ -54,20 +54,20 @@
|
||||
"@angular-eslint/template-parser": "22.5.0",
|
||||
"@angular/build": "22.1.8",
|
||||
"@angular/cli": "22.1.8",
|
||||
"@angular/compiler-cli": "~22.1.6",
|
||||
"@angular/compiler-cli": "~22.1.7",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^26.5.0",
|
||||
"@types/node": "^26.6.2",
|
||||
"@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",
|
||||
"eslint": "^10.11.0",
|
||||
"jest": "30.5.2",
|
||||
"jest-environment-jsdom": "^30.5.2",
|
||||
"jest-junit": "^17.0.0",
|
||||
"jest-preset-angular": "^17.0.0",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"prettier": "^3.9.6",
|
||||
"prettier": "^3.9.8",
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"ts-node": "~10.9.2",
|
||||
"typescript": "^6.0.3"
|
||||
|
||||
Generated
+531
-518
File diff suppressed because it is too large
Load Diff
@@ -253,6 +253,24 @@
|
||||
</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(34)
|
||||
expect(setSpy).toHaveBeenCalledTimes(35)
|
||||
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
|
||||
HideableSidebarItemID.Workflows,
|
||||
])
|
||||
|
||||
@@ -44,6 +44,7 @@ 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'
|
||||
@@ -184,6 +185,7 @@ 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),
|
||||
@@ -217,6 +219,11 @@ 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) => ({
|
||||
@@ -404,6 +411,9 @@ 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
|
||||
),
|
||||
@@ -625,6 +635,10 @@ 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
|
||||
|
||||
-347
@@ -1,347 +0,0 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="advanced-search-dialog-title" i18n>
|
||||
Advanced search
|
||||
</h4>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
aria-label="Close"
|
||||
i18n-aria-label
|
||||
(click)="cancel()"
|
||||
></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (unreadable) {
|
||||
<div class="alert alert-warning d-flex flex-wrap gap-2 align-items-center">
|
||||
<div class="flex-grow-1">
|
||||
<span i18n
|
||||
>The current query uses syntax this editor can't show. It still works
|
||||
as typed.</span
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
(click)="startOver()"
|
||||
i18n
|
||||
>
|
||||
Start a new query
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ng-container
|
||||
*ngTemplateOutlet="
|
||||
groupTemplate;
|
||||
context: { group: root, parent: null, depth: 0 }
|
||||
"
|
||||
></ng-container>
|
||||
|
||||
<div class="mt-4">
|
||||
<label
|
||||
class="form-label small text-muted"
|
||||
for="advanced-search-preview"
|
||||
i18n
|
||||
>Query</label
|
||||
>
|
||||
<pre
|
||||
id="advanced-search-preview"
|
||||
class="query-preview mb-0 p-2 rounded border"
|
||||
>@if (generatedQuery) {{{ generatedQuery }}} @else {<span class="text-muted fst-italic" i18n>Nothing to search for yet</span>}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
(click)="cancel()"
|
||||
i18n
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
(click)="apply()"
|
||||
[disabled]="!generatedQuery"
|
||||
i18n
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ng-template
|
||||
#groupTemplate
|
||||
let-group="group"
|
||||
let-parent="parent"
|
||||
let-depth="depth"
|
||||
>
|
||||
<div class="d-flex w-100 gap-2">
|
||||
<div class="d-flex flex-grow-1 flex-column">
|
||||
<div class="d-flex align-items-center flex-wrap">
|
||||
<div
|
||||
class="btn-group btn-group-xs"
|
||||
role="group"
|
||||
aria-label="Match"
|
||||
i18n-aria-label
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
[(ngModel)]="group.operator"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[value]="LogicalOperator.Or"
|
||||
id="advancedSearchAny_{{ idFor(group) }}"
|
||||
name="advancedSearchAny_{{ idFor(group) }}"
|
||||
/>
|
||||
<label
|
||||
class="btn btn-outline-primary"
|
||||
for="advancedSearchAny_{{ idFor(group) }}"
|
||||
i18n
|
||||
>Any</label
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
[(ngModel)]="group.operator"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[value]="LogicalOperator.And"
|
||||
id="advancedSearchAll_{{ idFor(group) }}"
|
||||
name="advancedSearchAll_{{ idFor(group) }}"
|
||||
/>
|
||||
<label
|
||||
class="btn btn-outline-primary"
|
||||
for="advancedSearchAll_{{ idFor(group) }}"
|
||||
i18n
|
||||
>All</label
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
[(ngModel)]="group.operator"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[value]="LogicalOperator.Not"
|
||||
id="advancedSearchNot_{{ idFor(group) }}"
|
||||
name="advancedSearchNot_{{ idFor(group) }}"
|
||||
/>
|
||||
<label
|
||||
class="btn btn-outline-secondary"
|
||||
for="advancedSearchNot_{{ idFor(group) }}"
|
||||
i18n
|
||||
>Not</label
|
||||
>
|
||||
</div>
|
||||
<span class="small text-muted ms-2">
|
||||
@switch (group.operator) {
|
||||
@case (LogicalOperator.And) {
|
||||
<ng-container i18n>match all of these</ng-container>
|
||||
}
|
||||
@case (LogicalOperator.Or) {
|
||||
<ng-container i18n>match any of these</ng-container>
|
||||
}
|
||||
@case (LogicalOperator.Not) {
|
||||
<ng-container i18n>match none of these</ng-container>
|
||||
}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="list-group list-group-flush">
|
||||
@for (element of group.children; track element) {
|
||||
<div class="list-group-item px-0 d-flex flex-nowrap">
|
||||
@if (element.type === ElementType.Group) {
|
||||
<ng-container
|
||||
*ngTemplateOutlet="
|
||||
groupTemplate;
|
||||
context: { group: element, parent: group, depth: depth + 1 }
|
||||
"
|
||||
></ng-container>
|
||||
} @else {
|
||||
<ng-container
|
||||
*ngTemplateOutlet="
|
||||
atomTemplate;
|
||||
context: { atom: element, parent: group }
|
||||
"
|
||||
></ng-container>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="btn-group-vertical align-self-start ms-2 ps-2 border-start"
|
||||
role="group"
|
||||
aria-label="Group actions"
|
||||
i18n-aria-label
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-secondary text-primary"
|
||||
title="Add condition"
|
||||
i18n-title
|
||||
(click)="addAtom(group)"
|
||||
[disabled]="group.children.length >= maxAtoms"
|
||||
>
|
||||
<i-bs name="node-plus"></i-bs>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-secondary text-primary"
|
||||
title="Add group"
|
||||
i18n-title
|
||||
(click)="addGroup(group)"
|
||||
[disabled]="depth >= maxDepth"
|
||||
>
|
||||
<i-bs name="braces"></i-bs>
|
||||
</button>
|
||||
@if (parent) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-secondary text-danger"
|
||||
aria-label="Remove group"
|
||||
i18n-aria-label
|
||||
(click)="remove(parent, group)"
|
||||
>
|
||||
<i-bs name="x-circle"></i-bs>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #atomTemplate let-atom="atom" let-parent="parent">
|
||||
<div class="d-flex align-items-center gap-1 w-100">
|
||||
<div class="input-group input-group-sm flex-wrap">
|
||||
<select
|
||||
class="form-select flex-grow-0 w-auto"
|
||||
[(ngModel)]="atom.field"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
(ngModelChange)="onFieldChange(atom)"
|
||||
aria-label="Field"
|
||||
i18n-aria-label
|
||||
>
|
||||
@for (fieldGroup of fieldGroups; track fieldGroup.label) {
|
||||
<optgroup [label]="fieldGroup.label">
|
||||
@for (field of fieldGroup.fields; track field) {
|
||||
<option [ngValue]="field">{{ fieldLabels[field] }}</option>
|
||||
}
|
||||
</optgroup>
|
||||
}
|
||||
</select>
|
||||
<select
|
||||
class="form-select flex-grow-0 w-auto"
|
||||
[(ngModel)]="atom.operator"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
(ngModelChange)="onOperatorChange(atom)"
|
||||
aria-label="Condition"
|
||||
i18n-aria-label
|
||||
>
|
||||
@for (operator of operatorsFor(atom); track operator) {
|
||||
<option [ngValue]="operator">
|
||||
{{ operatorLabel(atom, operator) }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
|
||||
@switch (atom.operator) {
|
||||
@case (Operator.DateKeyword) {
|
||||
<select
|
||||
class="form-select"
|
||||
[(ngModel)]="atom.value"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
aria-label="Period"
|
||||
i18n-aria-label
|
||||
>
|
||||
<option [ngValue]="''" disabled i18n>Choose a period</option>
|
||||
@for (keyword of dateKeywords; track keyword) {
|
||||
<option [ngValue]="keyword">
|
||||
{{ dateKeywordLabels[keyword] }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
@case (Operator.WithinLast) {
|
||||
<input
|
||||
class="form-control amount"
|
||||
type="number"
|
||||
min="1"
|
||||
[(ngModel)]="atom.value"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
aria-label="Amount"
|
||||
i18n-aria-label
|
||||
/>
|
||||
<select
|
||||
class="form-select"
|
||||
[(ngModel)]="atom.unit"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
aria-label="Unit"
|
||||
i18n-aria-label
|
||||
>
|
||||
@for (unit of dateUnits; track unit) {
|
||||
<option [ngValue]="unit">{{ dateUnitLabels[unit] }}</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
@case (Operator.Between) {
|
||||
<input
|
||||
class="form-control"
|
||||
[type]="kindOf(atom) === FieldKind.Date ? 'date' : 'number'"
|
||||
[(ngModel)]="atom.value"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
aria-label="From"
|
||||
i18n-aria-label
|
||||
/>
|
||||
<span class="input-group-text" i18n>and</span>
|
||||
<input
|
||||
class="form-control"
|
||||
[type]="kindOf(atom) === FieldKind.Date ? 'date' : 'number'"
|
||||
[(ngModel)]="atom.valueTo"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
aria-label="To"
|
||||
i18n-aria-label
|
||||
/>
|
||||
}
|
||||
@default {
|
||||
@if (kindOf(atom) === FieldKind.Date) {
|
||||
<input
|
||||
class="form-control"
|
||||
type="date"
|
||||
[(ngModel)]="atom.value"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
aria-label="Date"
|
||||
i18n-aria-label
|
||||
/>
|
||||
} @else if (kindOf(atom) === FieldKind.Number) {
|
||||
<input
|
||||
class="form-control"
|
||||
type="number"
|
||||
min="0"
|
||||
[(ngModel)]="atom.value"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
aria-label="Number"
|
||||
i18n-aria-label
|
||||
/>
|
||||
} @else {
|
||||
<input
|
||||
class="form-control"
|
||||
type="text"
|
||||
[(ngModel)]="atom.value"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[placeholder]="placeholderFor(atom)"
|
||||
aria-label="Value"
|
||||
i18n-aria-label
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-link btn-sm text-danger px-1"
|
||||
type="button"
|
||||
(click)="remove(parent, atom)"
|
||||
aria-label="Remove condition"
|
||||
i18n-aria-label
|
||||
>
|
||||
<i-bs name="x-circle"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
.query-preview {
|
||||
font-size: 0.8125rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background-color: var(--pngx-bg-darker);
|
||||
border-color: var(--bs-border-color) !important;
|
||||
}
|
||||
|
||||
.input-group .amount {
|
||||
flex: 0 1 5rem;
|
||||
}
|
||||
-186
@@ -1,186 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import {
|
||||
AdvancedSearchDateUnit,
|
||||
AdvancedSearchField,
|
||||
AdvancedSearchLogicalOperator,
|
||||
AdvancedSearchOperator,
|
||||
AdvancedSearchQueryAtom,
|
||||
AdvancedSearchQueryElementType,
|
||||
AdvancedSearchQueryGroup,
|
||||
} from 'src/app/data/advanced-search-query'
|
||||
import { AdvancedSearchDialogComponent } from './advanced-search-dialog.component'
|
||||
|
||||
describe('AdvancedSearchDialogComponent', () => {
|
||||
let component: AdvancedSearchDialogComponent
|
||||
let fixture: ComponentFixture<AdvancedSearchDialogComponent>
|
||||
let activeModal: NgbActiveModal
|
||||
|
||||
const firstAtom = (): AdvancedSearchQueryAtom =>
|
||||
component.root.children[0] as AdvancedSearchQueryAtom
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
AdvancedSearchDialogComponent,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
],
|
||||
providers: [NgbActiveModal],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(AdvancedSearchDialogComponent)
|
||||
activeModal = TestBed.inject(NgbActiveModal)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should start with one empty condition and nothing to search for', () => {
|
||||
expect(component.root.children).toHaveLength(1)
|
||||
expect(component.generatedQuery).toBe('')
|
||||
expect(component.unreadable).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should show an existing query as conditions', () => {
|
||||
component.query = 'title:invoice AND NOT tag:paid'
|
||||
expect(component.unreadable).toBeFalsy()
|
||||
expect(component.root.children).toHaveLength(2)
|
||||
expect(component.generatedQuery).toBe('title:invoice AND NOT tag:paid')
|
||||
})
|
||||
|
||||
it('should flag a query it cannot show and start empty', () => {
|
||||
component.query = 'title:invoice^2'
|
||||
expect(component.unreadable).toBeTruthy()
|
||||
expect(component.generatedQuery).toBe('')
|
||||
})
|
||||
|
||||
it('should clear the warning when starting a new query', () => {
|
||||
component.query = 'title:invoice^2'
|
||||
component.startOver()
|
||||
expect(component.unreadable).toBeFalsy()
|
||||
expect(component.root.children).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should treat an empty query as a fresh start', () => {
|
||||
component.query = ' '
|
||||
expect(component.unreadable).toBeFalsy()
|
||||
expect(component.root.children).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should write the query as conditions are filled in', () => {
|
||||
const atom = firstAtom()
|
||||
atom.field = AdvancedSearchField.Title
|
||||
atom.value = 'unpaid invoice'
|
||||
expect(component.generatedQuery).toBe('title:unpaid AND title:invoice')
|
||||
})
|
||||
|
||||
it('should offer the conditions of the chosen field', () => {
|
||||
const atom = firstAtom()
|
||||
atom.field = AdvancedSearchField.Added
|
||||
component.onFieldChange(atom)
|
||||
expect(component.operatorsFor(atom)).toContain(
|
||||
AdvancedSearchOperator.WithinLast
|
||||
)
|
||||
expect(component.operatorsFor(atom)).not.toContain(
|
||||
AdvancedSearchOperator.Phrase
|
||||
)
|
||||
})
|
||||
|
||||
it('should keep a condition the new field still offers', () => {
|
||||
const atom = firstAtom()
|
||||
atom.operator = AdvancedSearchOperator.Phrase
|
||||
atom.field = AdvancedSearchField.Correspondent
|
||||
component.onFieldChange(atom)
|
||||
expect(atom.operator).toBe(AdvancedSearchOperator.Phrase)
|
||||
})
|
||||
|
||||
it('should replace a condition the new field does not offer, and clear the value', () => {
|
||||
const atom = firstAtom()
|
||||
atom.operator = AdvancedSearchOperator.Phrase
|
||||
atom.value = 'invoice'
|
||||
atom.field = AdvancedSearchField.ASN
|
||||
component.onFieldChange(atom)
|
||||
expect(atom.operator).toBe(AdvancedSearchOperator.Equals)
|
||||
expect(atom.value).toBe('')
|
||||
})
|
||||
|
||||
it('should give a within-the-last condition a unit to start from', () => {
|
||||
const atom = firstAtom()
|
||||
atom.field = AdvancedSearchField.Added
|
||||
atom.operator = AdvancedSearchOperator.WithinLast
|
||||
component.onOperatorChange(atom)
|
||||
expect(atom.unit).toBe(AdvancedSearchDateUnit.Day)
|
||||
atom.value = '3'
|
||||
expect(component.generatedQuery).toBe('added:[-3 days to now]')
|
||||
})
|
||||
|
||||
it('should label date comparisons as dates', () => {
|
||||
const atom = firstAtom()
|
||||
atom.field = AdvancedSearchField.Created
|
||||
expect(
|
||||
component.operatorLabel(atom, AdvancedSearchOperator.AtLeast)
|
||||
).toEqual('is on or after')
|
||||
atom.field = AdvancedSearchField.ASN
|
||||
expect(
|
||||
component.operatorLabel(atom, AdvancedSearchOperator.AtLeast)
|
||||
).toEqual('is at least')
|
||||
})
|
||||
|
||||
it('should add and remove conditions', () => {
|
||||
component.addAtom(component.root)
|
||||
expect(component.root.children).toHaveLength(2)
|
||||
component.remove(component.root, component.root.children[1])
|
||||
expect(component.root.children).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should add a group, which starts as Any', () => {
|
||||
component.addGroup(component.root)
|
||||
const group = component.root.children[1] as AdvancedSearchQueryGroup
|
||||
expect(group.type).toBe(AdvancedSearchQueryElementType.Group)
|
||||
expect(group.operator).toBe(AdvancedSearchLogicalOperator.Or)
|
||||
expect(group.children).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should give every group its own id, once', () => {
|
||||
component.addGroup(component.root)
|
||||
const group = component.root.children[1] as AdvancedSearchQueryGroup
|
||||
expect(component.idFor(component.root)).not.toEqual(component.idFor(group))
|
||||
expect(component.idFor(group)).toEqual(component.idFor(group))
|
||||
})
|
||||
|
||||
it('should apply the query and close', () => {
|
||||
const emitSpy = jest.spyOn(component.queryApplied, 'emit')
|
||||
const closeSpy = jest.spyOn(activeModal, 'close')
|
||||
const atom = firstAtom()
|
||||
atom.value = 'invoice'
|
||||
component.apply()
|
||||
expect(emitSpy).toHaveBeenCalledWith('content:invoice')
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should close without applying on cancel', () => {
|
||||
const emitSpy = jest.spyOn(component.queryApplied, 'emit')
|
||||
const closeSpy = jest.spyOn(activeModal, 'close')
|
||||
component.cancel()
|
||||
expect(emitSpy).not.toHaveBeenCalled()
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show the query it will apply', () => {
|
||||
component.query = 'content:invoice OR content:receipt'
|
||||
fixture.detectChanges()
|
||||
const preview = fixture.nativeElement.querySelector(
|
||||
'#advanced-search-preview'
|
||||
)
|
||||
expect(preview.textContent).toContain('content:invoice OR content:receipt')
|
||||
})
|
||||
|
||||
it('should not offer to apply an empty query', () => {
|
||||
fixture.detectChanges()
|
||||
const apply = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('.modal-footer button')
|
||||
).pop() as HTMLButtonElement
|
||||
expect(apply.disabled).toBeTruthy()
|
||||
})
|
||||
})
|
||||
-197
@@ -1,197 +0,0 @@
|
||||
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,11 +154,29 @@
|
||||
& 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,7 +1,11 @@
|
||||
import { SimpleChange } from '@angular/core'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import * as pdfjs from 'pdfjs-dist/legacy/build/pdf.mjs'
|
||||
import { PDFSinglePageViewer, PDFViewer } from 'pdfjs-dist/web/pdf_viewer.mjs'
|
||||
import {
|
||||
LinkTarget,
|
||||
PDFSinglePageViewer,
|
||||
PDFViewer,
|
||||
} from 'pdfjs-dist/web/pdf_viewer.mjs'
|
||||
import { PngxPdfViewerComponent } from './pdf-viewer.component'
|
||||
import { PdfRenderMode, PdfZoomLevel, PdfZoomScale } from './pdf-viewer.types'
|
||||
|
||||
@@ -58,6 +62,16 @@ 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')
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from 'pdfjs-dist/legacy/build/pdf.mjs'
|
||||
import {
|
||||
EventBus,
|
||||
LinkTarget,
|
||||
PDFFindController,
|
||||
PDFLinkService,
|
||||
PDFSinglePageViewer,
|
||||
@@ -75,7 +76,11 @@ export class PngxPdfViewerComponent
|
||||
private lastViewerPage?: number
|
||||
|
||||
private readonly eventBus = new EventBus()
|
||||
private readonly linkService = new PDFLinkService({ eventBus: this.eventBus })
|
||||
private readonly linkService = new PDFLinkService({
|
||||
eventBus: this.eventBus,
|
||||
externalLinkTarget: LinkTarget.BLANK,
|
||||
externalLinkRel: 'noopener noreferrer nofollow',
|
||||
})
|
||||
private readonly findController = new PDFFindController({
|
||||
eventBus: this.eventBus,
|
||||
linkService: this.linkService,
|
||||
|
||||
+77
-51
@@ -1,58 +1,84 @@
|
||||
<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 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 && 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>
|
||||
}
|
||||
@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>
|
||||
</div>
|
||||
|
||||
@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>
|
||||
<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>
|
||||
}
|
||||
</button>
|
||||
|
||||
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
|
||||
<div class="list-group list-group-flush small pb-0">
|
||||
@if (novelSuggestions === 0 && reusableSuggestions === 0) {
|
||||
<div class="list-group-item text-muted fst-italic">
|
||||
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
|
||||
</div>
|
||||
}
|
||||
@if (suggestions()?.suggested_tags?.length > 0) {
|
||||
<small class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="tags"></i-bs><ng-container i18n>Tags</ng-container></small>
|
||||
@for (tag of suggestions().suggested_tags; track tag) {
|
||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)">{{ tag }}</button>
|
||||
}
|
||||
}
|
||||
@if (suggestions()?.suggested_document_types?.length > 0) {
|
||||
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="hash"></i-bs><ng-container i18n>Document Types</ng-container></div>
|
||||
@for (type of suggestions().suggested_document_types; track type) {
|
||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)">{{ type }}</button>
|
||||
}
|
||||
}
|
||||
@if (suggestions()?.suggested_correspondents?.length > 0) {
|
||||
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="person"></i-bs><ng-container i18n>Correspondents</ng-container></div>
|
||||
@for (correspondent of suggestions().suggested_correspondents; track correspondent) {
|
||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button>
|
||||
}
|
||||
}
|
||||
@if (reusableSuggestions > 0) {
|
||||
<div class="list-group-item text-muted fst-italic">
|
||||
<small class="text-muted small fst-italic" i18n>{reusableSuggestions, plural, =1 {1 existing value suggested below} other {{{reusableSuggestions}} existing values suggested below}}</small>
|
||||
</div>
|
||||
}
|
||||
<div 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+4
@@ -1,3 +1,7 @@
|
||||
.suggestions-dropdown {
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.btn-link.dropdown-toggle::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
+59
-1
@@ -1,6 +1,7 @@
|
||||
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', () => {
|
||||
@@ -179,14 +180,71 @@ 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.open).toBeTruthy()
|
||||
expect(component.dropdown.isOpen()).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')
|
||||
})
|
||||
})
|
||||
|
||||
+40
-3
@@ -8,6 +8,7 @@ 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({
|
||||
@@ -18,12 +19,16 @@ 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)
|
||||
@@ -31,8 +36,10 @@ export class SuggestionsDropdownComponent {
|
||||
readonly appliedStoragePath = input<number>(null)
|
||||
|
||||
@Output()
|
||||
getSuggestions: EventEmitter<SuggestionsDropdownComponent> =
|
||||
new EventEmitter()
|
||||
getSuggestions: EventEmitter<SuggestionSource> = new EventEmitter()
|
||||
|
||||
@Output()
|
||||
sourceChange: EventEmitter<SuggestionSource> = new EventEmitter()
|
||||
|
||||
@Output()
|
||||
addTag: EventEmitter<string> = new EventEmitter()
|
||||
@@ -53,12 +60,42 @@ export class SuggestionsDropdownComponent {
|
||||
}
|
||||
|
||||
if (!this.suggestions()) {
|
||||
this.getSuggestions.emit(this)
|
||||
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()
|
||||
} 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) +
|
||||
|
||||
@@ -134,11 +134,15 @@
|
||||
[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"
|
||||
(getSuggestions)="getSuggestions()"
|
||||
(getSuggestions)="getSuggestions($event)"
|
||||
(sourceChange)="suggestionSourceOverride.set($event)"
|
||||
(addTag)="createTag($event)"
|
||||
(addDocumentType)="createDocumentType($event)"
|
||||
(addCorrespondent)="createCorrespondent($event)">
|
||||
|
||||
@@ -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 } from 'src/app/data/ui-settings'
|
||||
import { SETTINGS_KEYS, SuggestionSource } 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'
|
||||
@@ -1528,6 +1528,113 @@ 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, Observable, of, Subject, timer } from 'rxjs'
|
||||
import { BehaviorSubject, merge, Observable, of, Subject, timer } from 'rxjs'
|
||||
import {
|
||||
catchError,
|
||||
debounceTime,
|
||||
@@ -48,7 +48,10 @@ 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 } from 'src/app/data/document-suggestions'
|
||||
import {
|
||||
DocumentSuggestions,
|
||||
mergeSuggestions,
|
||||
} from 'src/app/data/document-suggestions'
|
||||
import { DocumentType } from 'src/app/data/document-type'
|
||||
import { FilterRule } from 'src/app/data/filter-rule'
|
||||
import {
|
||||
@@ -63,7 +66,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 } from 'src/app/data/ui-settings'
|
||||
import { 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'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
@@ -240,6 +243,10 @@ 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)
|
||||
@@ -261,6 +268,9 @@ 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)
|
||||
@@ -365,6 +375,15 @@ 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 ??
|
||||
@@ -590,6 +609,8 @@ 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()
|
||||
)
|
||||
@@ -1077,29 +1098,44 @@ export class DocumentDetailComponent
|
||||
return this.documentForm.get('custom_fields') as FormArray
|
||||
}
|
||||
|
||||
getSuggestions() {
|
||||
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
|
||||
|
||||
this.suggestionsLoading.set(true)
|
||||
const suggestionsObservable = this.aiEnabled
|
||||
? this.documentsService.getAiSuggestions(this.documentId())
|
||||
: this.documentsService.getSuggestions(this.documentId())
|
||||
suggestionsObservable
|
||||
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)
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
.pipe(
|
||||
first(),
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
takeUntil(this.docChangeNotifier),
|
||||
finalize(() => this.suggestionsLoading.set(false))
|
||||
)
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
this.suggestions.set(result)
|
||||
},
|
||||
error: (error) => {
|
||||
this.suggestions.set(null)
|
||||
this.toastService.showError(
|
||||
$localize`Error retrieving suggestions.`,
|
||||
error
|
||||
)
|
||||
},
|
||||
.subscribe((response) => {
|
||||
if (!response) return
|
||||
this.fetchedSuggestionSources.update((f) => [...f, response.source])
|
||||
this.suggestions.set(
|
||||
mergeSuggestions(this.suggestions(), response.result)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -146,6 +146,19 @@ 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,9 +270,12 @@ export class DocumentListComponent
|
||||
this.list.reload()
|
||||
})
|
||||
|
||||
this.websocketStatusService.onDocumentDeleted().subscribe(() => {
|
||||
this.list.reload()
|
||||
})
|
||||
this.websocketStatusService
|
||||
.onDocumentDeleted()
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
this.list.reload()
|
||||
})
|
||||
|
||||
this.route.paramMap
|
||||
.pipe(
|
||||
|
||||
@@ -17,12 +17,6 @@
|
||||
}
|
||||
</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,8 +12,6 @@ import {
|
||||
NgbDatepickerModule,
|
||||
NgbDropdownItem,
|
||||
NgbDropdownModule,
|
||||
NgbModal,
|
||||
NgbModalRef,
|
||||
NgbTypeaheadModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select'
|
||||
@@ -2517,45 +2515,4 @@ 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,13 +15,12 @@ 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 { first, Observable, Subject, from } from 'rxjs'
|
||||
import { Observable, Subject, from } from 'rxjs'
|
||||
import {
|
||||
catchError,
|
||||
debounceTime,
|
||||
@@ -122,7 +121,6 @@ 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'
|
||||
@@ -288,7 +286,6 @@ export class FilterEditorComponent
|
||||
permissionsService = inject(PermissionsService)
|
||||
private customFieldService = inject(CustomFieldsService)
|
||||
private searchService = inject(SearchService)
|
||||
private modalService = inject(NgbModal)
|
||||
|
||||
generateFilterName() {
|
||||
if (this.filterRules.length == 1) {
|
||||
@@ -1375,23 +1372,6 @@ 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) {
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
// Fields and forms documented in docs/usage.md > "Document searches"
|
||||
|
||||
export enum AdvancedSearchField {
|
||||
Any = '',
|
||||
Title = 'title',
|
||||
Content = 'content',
|
||||
OriginalFilename = 'original_filename',
|
||||
NoteText = 'notes.note',
|
||||
NoteAuthor = 'notes.user',
|
||||
CustomFieldName = 'custom_fields.name',
|
||||
CustomFieldValue = 'custom_fields.value',
|
||||
Correspondent = 'correspondent',
|
||||
DocumentType = 'document_type',
|
||||
StoragePath = 'storage_path',
|
||||
Tag = 'tag',
|
||||
ASN = 'asn',
|
||||
PageCount = 'page_count',
|
||||
NumNotes = 'num_notes',
|
||||
Created = 'created',
|
||||
Added = 'added',
|
||||
Modified = 'modified',
|
||||
Checksum = 'checksum',
|
||||
}
|
||||
|
||||
export enum AdvancedSearchFieldKind {
|
||||
Text = 'text',
|
||||
Number = 'number',
|
||||
Date = 'date',
|
||||
Checksum = 'checksum',
|
||||
}
|
||||
|
||||
export const ADVANCED_SEARCH_FIELD_KINDS: Record<
|
||||
AdvancedSearchField,
|
||||
AdvancedSearchFieldKind
|
||||
> = {
|
||||
[AdvancedSearchField.Any]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.Title]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.Content]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.OriginalFilename]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.NoteText]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.NoteAuthor]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.CustomFieldName]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.CustomFieldValue]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.Correspondent]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.DocumentType]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.StoragePath]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.Tag]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.ASN]: AdvancedSearchFieldKind.Number,
|
||||
[AdvancedSearchField.PageCount]: AdvancedSearchFieldKind.Number,
|
||||
[AdvancedSearchField.NumNotes]: AdvancedSearchFieldKind.Number,
|
||||
[AdvancedSearchField.Created]: AdvancedSearchFieldKind.Date,
|
||||
[AdvancedSearchField.Added]: AdvancedSearchFieldKind.Date,
|
||||
[AdvancedSearchField.Modified]: AdvancedSearchFieldKind.Date,
|
||||
[AdvancedSearchField.Checksum]: AdvancedSearchFieldKind.Checksum,
|
||||
}
|
||||
|
||||
export enum AdvancedSearchOperator {
|
||||
AllWords = 'all',
|
||||
AnyWord = 'any',
|
||||
Phrase = 'phrase',
|
||||
StartsWith = 'prefix',
|
||||
Equals = 'eq',
|
||||
AtLeast = 'gte',
|
||||
AtMost = 'lte',
|
||||
Between = 'between',
|
||||
DateKeyword = 'keyword',
|
||||
WithinLast = 'within',
|
||||
}
|
||||
|
||||
export const ADVANCED_SEARCH_OPERATORS_BY_KIND: Record<
|
||||
AdvancedSearchFieldKind,
|
||||
AdvancedSearchOperator[]
|
||||
> = {
|
||||
[AdvancedSearchFieldKind.Text]: [
|
||||
AdvancedSearchOperator.AllWords,
|
||||
AdvancedSearchOperator.AnyWord,
|
||||
AdvancedSearchOperator.Phrase,
|
||||
AdvancedSearchOperator.StartsWith,
|
||||
],
|
||||
[AdvancedSearchFieldKind.Number]: [
|
||||
AdvancedSearchOperator.Equals,
|
||||
AdvancedSearchOperator.AtLeast,
|
||||
AdvancedSearchOperator.AtMost,
|
||||
AdvancedSearchOperator.Between,
|
||||
],
|
||||
[AdvancedSearchFieldKind.Date]: [
|
||||
AdvancedSearchOperator.DateKeyword,
|
||||
AdvancedSearchOperator.WithinLast,
|
||||
AdvancedSearchOperator.AtLeast,
|
||||
AdvancedSearchOperator.AtMost,
|
||||
AdvancedSearchOperator.Between,
|
||||
],
|
||||
[AdvancedSearchFieldKind.Checksum]: [AdvancedSearchOperator.StartsWith],
|
||||
}
|
||||
|
||||
export const ADVANCED_SEARCH_DATE_KEYWORDS = [
|
||||
'today',
|
||||
'yesterday',
|
||||
'tomorrow',
|
||||
'previous week',
|
||||
'this month',
|
||||
'previous month',
|
||||
'previous quarter',
|
||||
'this year',
|
||||
'previous year',
|
||||
] as const
|
||||
|
||||
export type AdvancedSearchDateKeyword =
|
||||
(typeof ADVANCED_SEARCH_DATE_KEYWORDS)[number]
|
||||
|
||||
export enum AdvancedSearchDateUnit {
|
||||
Day = 'day',
|
||||
Week = 'week',
|
||||
Month = 'month',
|
||||
Year = 'year',
|
||||
}
|
||||
|
||||
export enum AdvancedSearchLogicalOperator {
|
||||
And = 'AND',
|
||||
Or = 'OR',
|
||||
Not = 'NOT',
|
||||
}
|
||||
|
||||
export enum AdvancedSearchQueryElementType {
|
||||
Atom = 'atom',
|
||||
Group = 'group',
|
||||
}
|
||||
|
||||
export interface AdvancedSearchQueryAtom {
|
||||
type: AdvancedSearchQueryElementType.Atom
|
||||
field: AdvancedSearchField
|
||||
operator: AdvancedSearchOperator
|
||||
value?: string
|
||||
valueTo?: string
|
||||
unit?: AdvancedSearchDateUnit // for WithinLast
|
||||
}
|
||||
|
||||
export interface AdvancedSearchQueryGroup {
|
||||
type: AdvancedSearchQueryElementType.Group
|
||||
operator: AdvancedSearchLogicalOperator
|
||||
children: AdvancedSearchQueryElement[]
|
||||
}
|
||||
|
||||
export type AdvancedSearchQueryElement =
|
||||
AdvancedSearchQueryAtom | AdvancedSearchQueryGroup
|
||||
|
||||
export const ADVANCED_SEARCH_MAX_DEPTH = 2
|
||||
export const ADVANCED_SEARCH_MAX_ATOMS = 10
|
||||
|
||||
export const ADVANCED_SEARCH_FIELD_LABELS: Record<AdvancedSearchField, string> =
|
||||
{
|
||||
[AdvancedSearchField.Any]: $localize`Any field`,
|
||||
[AdvancedSearchField.Title]: $localize`Title`,
|
||||
[AdvancedSearchField.Content]: $localize`Content`,
|
||||
[AdvancedSearchField.OriginalFilename]: $localize`File name`,
|
||||
[AdvancedSearchField.NoteText]: $localize`Note text`,
|
||||
[AdvancedSearchField.NoteAuthor]: $localize`Note author`,
|
||||
[AdvancedSearchField.CustomFieldName]: $localize`Custom field name`,
|
||||
[AdvancedSearchField.CustomFieldValue]: $localize`Custom field value`,
|
||||
[AdvancedSearchField.Correspondent]: $localize`Correspondent name`,
|
||||
[AdvancedSearchField.DocumentType]: $localize`Document type name`,
|
||||
[AdvancedSearchField.StoragePath]: $localize`Storage path name`,
|
||||
[AdvancedSearchField.Tag]: $localize`Tag name`,
|
||||
[AdvancedSearchField.ASN]: $localize`ASN`,
|
||||
[AdvancedSearchField.PageCount]: $localize`Pages`,
|
||||
[AdvancedSearchField.NumNotes]: $localize`Number of notes`,
|
||||
[AdvancedSearchField.Created]: $localize`Created`,
|
||||
[AdvancedSearchField.Added]: $localize`Added`,
|
||||
[AdvancedSearchField.Modified]: $localize`Modified`,
|
||||
[AdvancedSearchField.Checksum]: $localize`Checksum`,
|
||||
}
|
||||
|
||||
export const ADVANCED_SEARCH_FIELD_GROUPS: {
|
||||
label: string
|
||||
fields: AdvancedSearchField[]
|
||||
}[] = [
|
||||
{
|
||||
label: $localize`Text`,
|
||||
fields: [
|
||||
AdvancedSearchField.Any,
|
||||
AdvancedSearchField.Title,
|
||||
AdvancedSearchField.Content,
|
||||
AdvancedSearchField.OriginalFilename,
|
||||
AdvancedSearchField.NoteText,
|
||||
AdvancedSearchField.NoteAuthor,
|
||||
AdvancedSearchField.CustomFieldName,
|
||||
AdvancedSearchField.CustomFieldValue,
|
||||
],
|
||||
},
|
||||
{
|
||||
label: $localize`Names`,
|
||||
fields: [
|
||||
AdvancedSearchField.Correspondent,
|
||||
AdvancedSearchField.DocumentType,
|
||||
AdvancedSearchField.StoragePath,
|
||||
AdvancedSearchField.Tag,
|
||||
],
|
||||
},
|
||||
{
|
||||
label: $localize`Numbers`,
|
||||
fields: [
|
||||
AdvancedSearchField.ASN,
|
||||
AdvancedSearchField.PageCount,
|
||||
AdvancedSearchField.NumNotes,
|
||||
],
|
||||
},
|
||||
{
|
||||
label: $localize`Dates`,
|
||||
fields: [
|
||||
AdvancedSearchField.Created,
|
||||
AdvancedSearchField.Added,
|
||||
AdvancedSearchField.Modified,
|
||||
],
|
||||
},
|
||||
{ label: $localize`Other`, fields: [AdvancedSearchField.Checksum] },
|
||||
]
|
||||
|
||||
export const ADVANCED_SEARCH_OPERATOR_LABELS: Record<
|
||||
AdvancedSearchOperator,
|
||||
string
|
||||
> = {
|
||||
[AdvancedSearchOperator.AllWords]: $localize`contains all words`,
|
||||
[AdvancedSearchOperator.AnyWord]: $localize`contains any word`,
|
||||
[AdvancedSearchOperator.Phrase]: $localize`contains the phrase`,
|
||||
[AdvancedSearchOperator.StartsWith]: $localize`starts with`,
|
||||
[AdvancedSearchOperator.Equals]: $localize`is`,
|
||||
[AdvancedSearchOperator.AtLeast]: $localize`is at least`,
|
||||
[AdvancedSearchOperator.AtMost]: $localize`is at most`,
|
||||
[AdvancedSearchOperator.Between]: $localize`is between`,
|
||||
[AdvancedSearchOperator.DateKeyword]: $localize`is`,
|
||||
[AdvancedSearchOperator.WithinLast]: $localize`is within the last`,
|
||||
}
|
||||
|
||||
// Comparing dates reads differently than comparing counts
|
||||
export const ADVANCED_SEARCH_DATE_OPERATOR_LABELS: Partial<
|
||||
Record<AdvancedSearchOperator, string>
|
||||
> = {
|
||||
[AdvancedSearchOperator.AtLeast]: $localize`is on or after`,
|
||||
[AdvancedSearchOperator.AtMost]: $localize`is on or before`,
|
||||
}
|
||||
|
||||
export const ADVANCED_SEARCH_DATE_KEYWORD_LABELS: Record<string, string> = {
|
||||
today: $localize`today`,
|
||||
yesterday: $localize`yesterday`,
|
||||
tomorrow: $localize`tomorrow`,
|
||||
'previous week': $localize`previous week`,
|
||||
'this month': $localize`this month`,
|
||||
'previous month': $localize`previous month`,
|
||||
'previous quarter': $localize`previous quarter`,
|
||||
'this year': $localize`this year`,
|
||||
'previous year': $localize`previous year`,
|
||||
}
|
||||
|
||||
export const ADVANCED_SEARCH_DATE_UNIT_LABELS: Record<
|
||||
AdvancedSearchDateUnit,
|
||||
string
|
||||
> = {
|
||||
[AdvancedSearchDateUnit.Day]: $localize`days`,
|
||||
[AdvancedSearchDateUnit.Week]: $localize`weeks`,
|
||||
[AdvancedSearchDateUnit.Month]: $localize`months`,
|
||||
[AdvancedSearchDateUnit.Year]: $localize`years`,
|
||||
}
|
||||
@@ -15,3 +15,33 @@ 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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,6 +353,14 @@ 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`,
|
||||
@@ -457,6 +465,7 @@ 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
|
||||
|
||||
@@ -20,6 +20,12 @@ export enum GlobalSearchType {
|
||||
TITLE_CONTENT = 'title-content',
|
||||
}
|
||||
|
||||
export enum SuggestionSource {
|
||||
ML = 'ml',
|
||||
AI = 'ai',
|
||||
Both = 'both',
|
||||
}
|
||||
|
||||
export enum CollapsibleSection {
|
||||
ATTRIBUTES = 'attributes',
|
||||
}
|
||||
@@ -98,6 +104,8 @@ 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',
|
||||
@@ -326,6 +334,11 @@ 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',
|
||||
|
||||
@@ -1,515 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,500 +0,0 @@
|
||||
import {
|
||||
ADVANCED_SEARCH_DATE_KEYWORDS,
|
||||
ADVANCED_SEARCH_FIELD_KINDS,
|
||||
AdvancedSearchDateUnit,
|
||||
AdvancedSearchField,
|
||||
AdvancedSearchFieldKind,
|
||||
AdvancedSearchLogicalOperator,
|
||||
AdvancedSearchOperator,
|
||||
AdvancedSearchQueryAtom,
|
||||
AdvancedSearchQueryElement,
|
||||
AdvancedSearchQueryElementType,
|
||||
AdvancedSearchQueryGroup,
|
||||
} from '../data/advanced-search-query'
|
||||
|
||||
// Anything the query grammar would read as syntax rather than as a word
|
||||
const SYNTAX_CHARS = /[\s():"[\]*?,{}^~\\]/
|
||||
const SYNTAX_CHARS_GLOBAL = new RegExp(SYNTAX_CHARS, 'g')
|
||||
// A word wrapped in single quotes is also syntax, an apostrophe inside one is not
|
||||
const EDGE_SINGLE_QUOTE = /^'|'$/
|
||||
const RESERVED_WORDS = /^(AND|OR|NOT|TO)$/
|
||||
const HAS_WORD_CHAR = /[\p{L}\p{N}]/u
|
||||
const WHOLE_NUMBER = /^\d+$/
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
|
||||
|
||||
interface Serialized {
|
||||
text: string
|
||||
// The operator joining the top level of `text`, null when it is self-delimiting
|
||||
join:
|
||||
AdvancedSearchLogicalOperator.And | AdvancedSearchLogicalOperator.Or | null
|
||||
}
|
||||
|
||||
const prefix = (field: AdvancedSearchField) => (field ? `${field}:` : '')
|
||||
|
||||
const quote = (text: string) => `"${text.replace(/"/g, '')}"`
|
||||
|
||||
const words = (value: string) =>
|
||||
value
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((word) => HAS_WORD_CHAR.test(word))
|
||||
|
||||
const word = (w: string) =>
|
||||
SYNTAX_CHARS.test(w) || EDGE_SINGLE_QUOTE.test(w) || RESERVED_WORDS.test(w)
|
||||
? quote(w)
|
||||
: w
|
||||
|
||||
const atomic = (text: string): Serialized => ({ text, join: null })
|
||||
|
||||
function serializeWords(
|
||||
atom: AdvancedSearchQueryAtom,
|
||||
join: AdvancedSearchLogicalOperator.And | AdvancedSearchLogicalOperator.Or
|
||||
): Serialized {
|
||||
// A field applies only to the word right after it, so repeat it per word
|
||||
const terms = words(atom.value ?? '').map(
|
||||
(w) => `${prefix(atom.field)}${word(w)}`
|
||||
)
|
||||
if (terms.length === 0) return null
|
||||
if (terms.length === 1) return atomic(terms[0])
|
||||
return { text: terms.join(` ${join} `), join }
|
||||
}
|
||||
|
||||
function serializeRange(
|
||||
atom: AdvancedSearchQueryAtom,
|
||||
isValid: (v: string) => boolean
|
||||
): Serialized {
|
||||
const lo = atom.value?.trim() ?? ''
|
||||
const hi = atom.valueTo?.trim() ?? ''
|
||||
const field = prefix(atom.field)
|
||||
switch (atom.operator) {
|
||||
case AdvancedSearchOperator.Equals:
|
||||
return isValid(lo) ? atomic(`${field}${lo}`) : null
|
||||
case AdvancedSearchOperator.AtLeast:
|
||||
return isValid(lo) ? atomic(`${field}[${lo} to]`) : null
|
||||
case AdvancedSearchOperator.AtMost:
|
||||
return isValid(lo) ? atomic(`${field}[to ${lo}]`) : null
|
||||
case AdvancedSearchOperator.Between:
|
||||
return isValid(lo) && isValid(hi)
|
||||
? atomic(`${field}[${lo} to ${hi}]`)
|
||||
: null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function serializeAtom(atom: AdvancedSearchQueryAtom): Serialized {
|
||||
const field = prefix(atom.field)
|
||||
const value = atom.value?.trim() ?? ''
|
||||
|
||||
switch (atom.operator) {
|
||||
case AdvancedSearchOperator.AllWords:
|
||||
return serializeWords(atom, AdvancedSearchLogicalOperator.And)
|
||||
case AdvancedSearchOperator.AnyWord:
|
||||
return serializeWords(atom, AdvancedSearchLogicalOperator.Or)
|
||||
case AdvancedSearchOperator.Phrase:
|
||||
return HAS_WORD_CHAR.test(value)
|
||||
? atomic(`${field}${quote(value)}`)
|
||||
: null
|
||||
case AdvancedSearchOperator.StartsWith: {
|
||||
if (/\s/.test(value)) return null
|
||||
let stem = value.replace(SYNTAX_CHARS_GLOBAL, '')
|
||||
if (!HAS_WORD_CHAR.test(stem)) return null
|
||||
// checksum is indexed as-is, in lowercase
|
||||
if (atom.field === AdvancedSearchField.Checksum) {
|
||||
stem = stem.toLowerCase()
|
||||
}
|
||||
return atomic(`${field}${stem}*`)
|
||||
}
|
||||
case AdvancedSearchOperator.DateKeyword:
|
||||
return (ADVANCED_SEARCH_DATE_KEYWORDS as readonly string[]).includes(
|
||||
value
|
||||
)
|
||||
? atomic(`${field}${word(value)}`)
|
||||
: null
|
||||
case AdvancedSearchOperator.WithinLast: {
|
||||
const amount = parseInt(value, 10)
|
||||
const units = Object.values(AdvancedSearchDateUnit) as string[]
|
||||
if (!WHOLE_NUMBER.test(value) || amount < 1) return null
|
||||
if (!units.includes(atom.unit)) return null
|
||||
const unit = amount === 1 ? atom.unit : `${atom.unit}s`
|
||||
return atomic(`${field}[-${amount} ${unit} to now]`)
|
||||
}
|
||||
case AdvancedSearchOperator.Equals:
|
||||
case AdvancedSearchOperator.AtLeast:
|
||||
case AdvancedSearchOperator.AtMost:
|
||||
case AdvancedSearchOperator.Between:
|
||||
return serializeRange(
|
||||
atom,
|
||||
ADVANCED_SEARCH_FIELD_KINDS[atom.field] === AdvancedSearchFieldKind.Date
|
||||
? (v) => ISO_DATE.test(v)
|
||||
: (v) => WHOLE_NUMBER.test(v)
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function wrap(
|
||||
child: Serialized,
|
||||
parentJoin: AdvancedSearchLogicalOperator
|
||||
): string {
|
||||
return child.join === null || child.join === parentJoin
|
||||
? child.text
|
||||
: `(${child.text})`
|
||||
}
|
||||
|
||||
function serializeGroup(group: AdvancedSearchQueryGroup): Serialized {
|
||||
const children = group.children.map(serializeElement).filter(Boolean)
|
||||
if (children.length === 0) return null
|
||||
|
||||
if (group.operator === AdvancedSearchLogicalOperator.Not) {
|
||||
// A Not group matches documents matching none of its children
|
||||
if (children.length === 1 && children[0].join === null) {
|
||||
return atomic(`NOT ${children[0].text}`)
|
||||
}
|
||||
const inner =
|
||||
children.length === 1
|
||||
? children[0].text
|
||||
: children
|
||||
.map((c) => wrap(c, AdvancedSearchLogicalOperator.Or))
|
||||
.join(' OR ')
|
||||
return atomic(`NOT (${inner})`)
|
||||
}
|
||||
|
||||
if (children.length === 1) return children[0]
|
||||
return {
|
||||
text: children
|
||||
.map((c) => wrap(c, group.operator))
|
||||
.join(` ${group.operator} `),
|
||||
join: group.operator,
|
||||
}
|
||||
}
|
||||
|
||||
function serializeElement(element: AdvancedSearchQueryElement): Serialized {
|
||||
return element.type === AdvancedSearchQueryElementType.Group
|
||||
? serializeGroup(element)
|
||||
: serializeAtom(element)
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes an editor tree as a full-text query. Atoms that are not filled in
|
||||
* (or not valid) are left out, and empty groups with them.
|
||||
*/
|
||||
export function serializeAdvancedSearchQuery(
|
||||
element: AdvancedSearchQueryElement
|
||||
): string {
|
||||
return serializeElement(element)?.text ?? ''
|
||||
}
|
||||
|
||||
// --- Reading a query back into the editor --------------------------------
|
||||
//
|
||||
// Deliberately narrow: this reads the forms serializeAdvancedSearchQuery
|
||||
// writes, and nothing else. A query it cannot read is not a failure, it just
|
||||
// stays text, so there is never a lossy or surprising conversion. The final
|
||||
// round-trip check below is what holds that promise: a tree is only returned
|
||||
// when writing it out again reproduces the query exactly.
|
||||
|
||||
const RELATIVE_BOUND = /^-(\d+) (day|week|month|year)s?$/
|
||||
const FIELD_PREFIX = /^([a-z_]+(?:\.[a-z_]+)?):/
|
||||
const KEYWORD_TOKEN = /^(AND|OR|NOT)(?=[\s(]|$)/
|
||||
// Either bound may be missing: [50 to 150], [50 to], [to 50]
|
||||
const RANGE_BOUNDS = /^(?:(.+?) )?to(?: (.+))?$/
|
||||
const TRAILING_WILDCARD = /^([^*?]+)\*$/
|
||||
|
||||
class UnreadableQuery extends Error {}
|
||||
|
||||
interface Token {
|
||||
type: 'term' | 'AND' | 'OR' | 'NOT' | '(' | ')'
|
||||
field?: string
|
||||
value?: string
|
||||
quoted?: boolean
|
||||
range?: boolean
|
||||
}
|
||||
|
||||
// A parsed element, plus what it takes to merge the per-word terms the
|
||||
// serializer writes for "contains all words" back into a single condition
|
||||
interface Parsed {
|
||||
element: AdvancedSearchQueryElement
|
||||
word?: { field: AdvancedSearchField; text: string }
|
||||
}
|
||||
|
||||
function tokenize(query: string): Token[] {
|
||||
const tokens: Token[] = []
|
||||
let i = 0
|
||||
while (i < query.length) {
|
||||
const rest = query.slice(i)
|
||||
if (/^\s/.test(rest)) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (rest[0] === '(' || rest[0] === ')') {
|
||||
tokens.push({ type: rest[0] as '(' | ')' })
|
||||
i++
|
||||
continue
|
||||
}
|
||||
const keyword = KEYWORD_TOKEN.exec(rest)
|
||||
if (keyword) {
|
||||
tokens.push({ type: keyword[1] as 'AND' | 'OR' | 'NOT' })
|
||||
i += keyword[1].length
|
||||
continue
|
||||
}
|
||||
const fieldMatch = FIELD_PREFIX.exec(rest)
|
||||
const field = fieldMatch ? fieldMatch[1] : ''
|
||||
i += fieldMatch ? fieldMatch[0].length : 0
|
||||
|
||||
const value = query.slice(i)
|
||||
if (value.startsWith('"')) {
|
||||
const end = query.indexOf('"', i + 1)
|
||||
if (end < 0) throw new UnreadableQuery()
|
||||
tokens.push({
|
||||
type: 'term',
|
||||
field,
|
||||
value: query.slice(i + 1, end),
|
||||
quoted: true,
|
||||
})
|
||||
i = end + 1
|
||||
} else if (value.startsWith('[')) {
|
||||
const end = query.indexOf(']', i + 1)
|
||||
if (end < 0) throw new UnreadableQuery()
|
||||
tokens.push({
|
||||
type: 'term',
|
||||
field,
|
||||
value: query.slice(i + 1, end),
|
||||
range: true,
|
||||
})
|
||||
i = end + 1
|
||||
} else {
|
||||
const bare = /^[^\s()]+/.exec(value)
|
||||
if (!bare) throw new UnreadableQuery()
|
||||
tokens.push({ type: 'term', field, value: bare[0] })
|
||||
i += bare[0].length
|
||||
}
|
||||
// Nothing may run on directly after a value, e.g. title:"a"b
|
||||
if (i < query.length && !/[\s)]/.test(query[i])) throw new UnreadableQuery()
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
function resolveField(name: string): AdvancedSearchField {
|
||||
const fields = Object.values(AdvancedSearchField) as string[]
|
||||
// Aliases (type:, path:, notes:) are left to the text box on purpose:
|
||||
// reading one would mean rewriting the user's query as it was read
|
||||
if (!fields.includes(name)) throw new UnreadableQuery()
|
||||
return name as AdvancedSearchField
|
||||
}
|
||||
|
||||
function atomFrom(
|
||||
field: AdvancedSearchField,
|
||||
operator: AdvancedSearchOperator,
|
||||
value: string,
|
||||
extra: Partial<AdvancedSearchQueryAtom> = {}
|
||||
): AdvancedSearchQueryAtom {
|
||||
return {
|
||||
type: AdvancedSearchQueryElementType.Atom,
|
||||
field,
|
||||
operator,
|
||||
value,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
function parseRange(
|
||||
field: AdvancedSearchField,
|
||||
kind: AdvancedSearchFieldKind,
|
||||
body: string
|
||||
): AdvancedSearchQueryAtom {
|
||||
const bounds = RANGE_BOUNDS.exec(body)
|
||||
if (!bounds) throw new UnreadableQuery()
|
||||
const lo = bounds[1] ?? ''
|
||||
const hi = bounds[2] ?? ''
|
||||
|
||||
if (kind === AdvancedSearchFieldKind.Date) {
|
||||
const relative = RELATIVE_BOUND.exec(lo)
|
||||
if (relative && hi === 'now') {
|
||||
return atomFrom(field, AdvancedSearchOperator.WithinLast, relative[1], {
|
||||
unit: relative[2] as AdvancedSearchDateUnit,
|
||||
})
|
||||
}
|
||||
} else if (kind !== AdvancedSearchFieldKind.Number) {
|
||||
throw new UnreadableQuery()
|
||||
}
|
||||
|
||||
const isValid =
|
||||
kind === AdvancedSearchFieldKind.Date
|
||||
? (v: string) => ISO_DATE.test(v)
|
||||
: (v: string) => WHOLE_NUMBER.test(v)
|
||||
|
||||
if (lo && hi) {
|
||||
if (!isValid(lo) || !isValid(hi)) throw new UnreadableQuery()
|
||||
return atomFrom(field, AdvancedSearchOperator.Between, lo, { valueTo: hi })
|
||||
}
|
||||
if (lo && isValid(lo))
|
||||
return atomFrom(field, AdvancedSearchOperator.AtLeast, lo)
|
||||
if (hi && isValid(hi))
|
||||
return atomFrom(field, AdvancedSearchOperator.AtMost, hi)
|
||||
throw new UnreadableQuery()
|
||||
}
|
||||
|
||||
function parseTerm(token: Token): Parsed {
|
||||
const field = resolveField(token.field)
|
||||
const kind = ADVANCED_SEARCH_FIELD_KINDS[field]
|
||||
const value = token.value
|
||||
const isKeyword = (
|
||||
ADVANCED_SEARCH_DATE_KEYWORDS as readonly string[]
|
||||
).includes(value)
|
||||
|
||||
if (token.range) {
|
||||
return { element: parseRange(field, kind, value) }
|
||||
}
|
||||
|
||||
if (kind === AdvancedSearchFieldKind.Date) {
|
||||
if (!isKeyword) throw new UnreadableQuery()
|
||||
return {
|
||||
element: atomFrom(field, AdvancedSearchOperator.DateKeyword, value),
|
||||
}
|
||||
}
|
||||
|
||||
if (token.quoted) {
|
||||
if (kind !== AdvancedSearchFieldKind.Text) throw new UnreadableQuery()
|
||||
return { element: atomFrom(field, AdvancedSearchOperator.Phrase, value) }
|
||||
}
|
||||
|
||||
const wildcard = TRAILING_WILDCARD.exec(value)
|
||||
if (wildcard) {
|
||||
if (kind === AdvancedSearchFieldKind.Number) throw new UnreadableQuery()
|
||||
return {
|
||||
element: atomFrom(field, AdvancedSearchOperator.StartsWith, wildcard[1]),
|
||||
}
|
||||
}
|
||||
|
||||
if (kind === AdvancedSearchFieldKind.Number) {
|
||||
if (!WHOLE_NUMBER.test(value)) throw new UnreadableQuery()
|
||||
return { element: atomFrom(field, AdvancedSearchOperator.Equals, value) }
|
||||
}
|
||||
// A checksum is only ever searched by its first characters
|
||||
if (kind === AdvancedSearchFieldKind.Checksum) throw new UnreadableQuery()
|
||||
|
||||
return {
|
||||
element: atomFrom(field, AdvancedSearchOperator.AllWords, value),
|
||||
word: { field, text: value },
|
||||
}
|
||||
}
|
||||
|
||||
// The serializer repeats the field for every word, because a field applies
|
||||
// only to the word after it. Put those back together into one condition.
|
||||
function mergeWords(
|
||||
parts: Parsed[],
|
||||
operator: AdvancedSearchLogicalOperator.And | AdvancedSearchLogicalOperator.Or
|
||||
): AdvancedSearchQueryElement[] {
|
||||
const merged: AdvancedSearchQueryElement[] = []
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const run = [parts[i]]
|
||||
while (
|
||||
parts[i].word &&
|
||||
parts[i + 1]?.word &&
|
||||
parts[i + 1].word.field === parts[i].word.field
|
||||
) {
|
||||
run.push(parts[++i])
|
||||
}
|
||||
if (run.length === 1) {
|
||||
merged.push(run[0].element)
|
||||
continue
|
||||
}
|
||||
merged.push(
|
||||
atomFrom(
|
||||
run[0].word.field,
|
||||
operator === AdvancedSearchLogicalOperator.And
|
||||
? AdvancedSearchOperator.AllWords
|
||||
: AdvancedSearchOperator.AnyWord,
|
||||
run.map((part) => part.word.text).join(' ')
|
||||
)
|
||||
)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
interface Cursor {
|
||||
tokens: Token[]
|
||||
at: number
|
||||
}
|
||||
|
||||
function parseExpression(cursor: Cursor): Parsed {
|
||||
const parts: Parsed[] = [parseOperand(cursor)]
|
||||
let operator:
|
||||
AdvancedSearchLogicalOperator.And | AdvancedSearchLogicalOperator.Or
|
||||
while (
|
||||
cursor.tokens[cursor.at]?.type === 'AND' ||
|
||||
cursor.tokens[cursor.at]?.type === 'OR'
|
||||
) {
|
||||
const next = cursor.tokens[cursor.at++].type as
|
||||
AdvancedSearchLogicalOperator.And | AdvancedSearchLogicalOperator.Or
|
||||
// One level mixing AND and OR is never something the editor wrote
|
||||
if (operator && next !== operator) throw new UnreadableQuery()
|
||||
operator = next
|
||||
parts.push(parseOperand(cursor))
|
||||
}
|
||||
if (parts.length === 1) return parts[0]
|
||||
|
||||
const children = mergeWords(parts, operator)
|
||||
if (children.length === 1) return { element: children[0] }
|
||||
return {
|
||||
element: {
|
||||
type: AdvancedSearchQueryElementType.Group,
|
||||
operator,
|
||||
children,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function parseOperand(cursor: Cursor): Parsed {
|
||||
const token = cursor.tokens[cursor.at++]
|
||||
if (!token) throw new UnreadableQuery()
|
||||
|
||||
if (token.type === 'NOT') {
|
||||
const child = parseOperand(cursor)
|
||||
return {
|
||||
element: {
|
||||
type: AdvancedSearchQueryElementType.Group,
|
||||
operator: AdvancedSearchLogicalOperator.Not,
|
||||
children: [child.element],
|
||||
},
|
||||
}
|
||||
}
|
||||
if (token.type === '(') {
|
||||
const inner = parseExpression(cursor)
|
||||
if (cursor.tokens[cursor.at++]?.type !== ')') throw new UnreadableQuery()
|
||||
return { element: inner.element }
|
||||
}
|
||||
if (token.type !== 'term') throw new UnreadableQuery()
|
||||
return parseTerm(token)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a query the editor could have written back into an editor tree, or
|
||||
* returns null when the editor cannot show it, in which case the query stays
|
||||
* text. Never returns a tree that would be written back differently.
|
||||
*/
|
||||
export function parseAdvancedSearchQuery(
|
||||
query: string
|
||||
): AdvancedSearchQueryGroup | null {
|
||||
const trimmed = query?.trim() ?? ''
|
||||
if (!trimmed) return null
|
||||
|
||||
let parsed: Parsed
|
||||
try {
|
||||
const cursor: Cursor = { tokens: tokenize(trimmed), at: 0 }
|
||||
parsed = parseExpression(cursor)
|
||||
if (cursor.at !== cursor.tokens.length) throw new UnreadableQuery()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const root =
|
||||
parsed.element.type === AdvancedSearchQueryElementType.Group
|
||||
? parsed.element
|
||||
: {
|
||||
type: AdvancedSearchQueryElementType.Group as const,
|
||||
operator: AdvancedSearchLogicalOperator.And,
|
||||
children: [parsed.element],
|
||||
}
|
||||
|
||||
return serializeAdvancedSearchQuery(root) === trimmed ? root : null
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export const environment = {
|
||||
apiVersion: '10', // match src/paperless/settings.py
|
||||
appTitle: DEFAULT_APP_TITLE,
|
||||
tag: 'prod',
|
||||
version: '3.2.0',
|
||||
version: '3.2.1',
|
||||
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
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user