mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-03 14:50:31 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8849aff5d9 |
@@ -1,53 +0,0 @@
|
|||||||
name: Install apt packages
|
|
||||||
description: >
|
|
||||||
Installs apt packages with a per-attempt timeout and retries. The hosted runner's Azure apt mirror intermittently serves archives at a crawl, which apt's own timeout does not catch. A stuck attempt is killed and retried, which usually lands on a healthy mirror node.
|
|
||||||
|
|
||||||
inputs:
|
|
||||||
packages:
|
|
||||||
description: Space separated list of packages to install
|
|
||||||
required: true
|
|
||||||
attempts:
|
|
||||||
description: Maximum number of attempts
|
|
||||||
default: "3"
|
|
||||||
attempt-timeout:
|
|
||||||
description: Seconds before a single attempt is killed
|
|
||||||
default: "180"
|
|
||||||
runs:
|
|
||||||
using: composite
|
|
||||||
steps:
|
|
||||||
- name: Install ${{ inputs.packages }}
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
PACKAGES: ${{ inputs.packages }}
|
|
||||||
ATTEMPTS: ${{ inputs.attempts }}
|
|
||||||
ATTEMPT_TIMEOUT: ${{ inputs.attempt-timeout }}
|
|
||||||
run: |
|
|
||||||
SUDO=""
|
|
||||||
if [ "$(id -u)" -ne 0 ]; then
|
|
||||||
SUDO="sudo"
|
|
||||||
fi
|
|
||||||
|
|
||||||
attempt_install() {
|
|
||||||
$SUDO apt-get update -q
|
|
||||||
# Intentionally unquoted, this is a list of package names
|
|
||||||
$SUDO apt-get install -q --yes --no-install-recommends $PACKAGES
|
|
||||||
}
|
|
||||||
export -f attempt_install
|
|
||||||
export SUDO PACKAGES
|
|
||||||
|
|
||||||
for attempt in $(seq 1 "${ATTEMPTS}"); do
|
|
||||||
echo "::group::apt install attempt ${attempt}/${ATTEMPTS}"
|
|
||||||
start=$(date +%s)
|
|
||||||
if timeout --kill-after=10 "${ATTEMPT_TIMEOUT}" bash -c attempt_install; then
|
|
||||||
echo "::endgroup::"
|
|
||||||
echo "Installed in $(( $(date +%s) - start ))s"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "::endgroup::"
|
|
||||||
echo "::warning::apt install attempt ${attempt}/${ATTEMPTS} failed or exceeded ${ATTEMPT_TIMEOUT}s"
|
|
||||||
# A killed attempt can leave dpkg half configured
|
|
||||||
$SUDO dpkg --configure -a || true
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "::error::apt install failed after ${ATTEMPTS} attempts"
|
|
||||||
exit 1
|
|
||||||
@@ -59,6 +59,7 @@ updates:
|
|||||||
- "drf-*"
|
- "drf-*"
|
||||||
- "djangorestframework"
|
- "djangorestframework"
|
||||||
- "whitenoise"
|
- "whitenoise"
|
||||||
|
- "bleach"
|
||||||
- "jinja2"
|
- "jinja2"
|
||||||
# Async, Task Queuing & Caching
|
# Async, Task Queuing & Caching
|
||||||
async-tasks:
|
async-tasks:
|
||||||
|
|||||||
@@ -102,16 +102,17 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: "${{ matrix.python-version }}"
|
python-version: "${{ matrix.python-version }}"
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
python-version: ${{ steps.setup-python.outputs.python-version }}
|
python-version: ${{ steps.setup-python.outputs.python-version }}
|
||||||
- name: Install system dependencies
|
- name: Install system dependencies
|
||||||
timeout-minutes: 12
|
timeout-minutes: 10
|
||||||
uses: $/.github/actions/apt-install
|
run: |
|
||||||
with:
|
sudo apt-get update -qq
|
||||||
packages: unpaper tesseract-ocr imagemagick ghostscript poppler-utils
|
sudo apt-get install -qq --no-install-recommends \
|
||||||
|
unpaper tesseract-ocr imagemagick ghostscript poppler-utils
|
||||||
- name: Configure ImageMagick
|
- name: Configure ImageMagick
|
||||||
run: |
|
run: |
|
||||||
sudo cp docker/rootfs/etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-6/policy.xml
|
sudo cp docker/rootfs/etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-6/policy.xml
|
||||||
@@ -138,13 +139,13 @@ jobs:
|
|||||||
pytest
|
pytest
|
||||||
- name: Upload test results to Codecov
|
- name: Upload test results to Codecov
|
||||||
if: always()
|
if: always()
|
||||||
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
|
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||||
with:
|
with:
|
||||||
flags: backend-python-${{ matrix.python-version }}
|
flags: backend-python-${{ matrix.python-version }}
|
||||||
files: junit.xml
|
files: junit.xml
|
||||||
report_type: test_results
|
report_type: test_results
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
|
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||||
with:
|
with:
|
||||||
flags: backend-python-${{ matrix.python-version }}
|
flags: backend-python-${{ matrix.python-version }}
|
||||||
files: coverage.xml
|
files: coverage.xml
|
||||||
@@ -175,7 +176,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: "${{ env.DEFAULT_PYTHON }}"
|
python-version: "${{ env.DEFAULT_PYTHON }}"
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ jobs:
|
|||||||
echo "repository=${repo_name}"
|
echo "repository=${repo_name}"
|
||||||
echo "name=${repo_name}" >> $GITHUB_OUTPUT
|
echo "name=${repo_name}" >> $GITHUB_OUTPUT
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
@@ -132,7 +132,7 @@ jobs:
|
|||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
- name: Build and push by digest
|
- name: Build and push by digest
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
@@ -182,7 +182,7 @@ jobs:
|
|||||||
echo "Downloaded digests:"
|
echo "Downloaded digests:"
|
||||||
ls -la /tmp/digests/
|
ls -la /tmp/digests/
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
|
|||||||
@@ -174,13 +174,13 @@ jobs:
|
|||||||
run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
||||||
- name: Upload test results to Codecov
|
- name: Upload test results to Codecov
|
||||||
if: always()
|
if: always()
|
||||||
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
|
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||||
with:
|
with:
|
||||||
flags: frontend-node-${{ matrix.node-version }}
|
flags: frontend-node-${{ matrix.node-version }}
|
||||||
directory: src-ui/
|
directory: src-ui/
|
||||||
report_type: test_results
|
report_type: test_results
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
|
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||||
with:
|
with:
|
||||||
flags: frontend-node-${{ matrix.node-version }}
|
flags: frontend-node-${{ matrix.node-version }}
|
||||||
directory: src-ui/coverage/
|
directory: src-ui/coverage/
|
||||||
@@ -216,7 +216,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.12'
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||||
with:
|
with:
|
||||||
version: '0.12.x'
|
version: '0.12.x'
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
@@ -231,10 +231,9 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: cd src-ui && pnpm install --frozen-lockfile
|
run: cd src-ui && pnpm install --frozen-lockfile
|
||||||
- name: Install backend system dependencies
|
- name: Install backend system dependencies
|
||||||
timeout-minutes: 12
|
run: |
|
||||||
uses: $/.github/actions/apt-install
|
apt-get update
|
||||||
with:
|
apt-get install --yes --quiet --no-install-recommends libmagic1
|
||||||
packages: libmagic1
|
|
||||||
- name: Install backend dependencies
|
- name: Install backend dependencies
|
||||||
env:
|
env:
|
||||||
PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }}
|
PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
@@ -70,10 +70,9 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
uv sync --python "${PYTHON_VERSION}" --dev --frozen
|
uv sync --python "${PYTHON_VERSION}" --dev --frozen
|
||||||
- name: Install system dependencies
|
- name: Install system dependencies
|
||||||
timeout-minutes: 12
|
run: |
|
||||||
uses: $/.github/actions/apt-install
|
sudo apt-get update -qq
|
||||||
with:
|
sudo apt-get install -qq --no-install-recommends gettext liblept5
|
||||||
packages: gettext liblept5
|
|
||||||
# ---- Build Documentation ----
|
# ---- Build Documentation ----
|
||||||
- name: Build documentation
|
- name: Build documentation
|
||||||
env:
|
env:
|
||||||
@@ -213,7 +212,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ jobs:
|
|||||||
- name: Run Semgrep
|
- name: Run Semgrep
|
||||||
run: semgrep scan --config auto --sarif-output results.sarif
|
run: semgrep scan --config auto --sarif-output results.sarif
|
||||||
- name: Upload results to GitHub code scanning
|
- name: Upload results to GitHub code scanning
|
||||||
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
sarif_file: results.sarif
|
sarif_file: results.sarif
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Clean temporary images
|
- name: Clean temporary images
|
||||||
if: "${{ env.TOKEN != '' }}"
|
if: "${{ env.TOKEN != '' }}"
|
||||||
uses: stumpylog/image-cleaner-action/ephemeral@21f875bab2376314e0525c614e923c669434f0e4 # v0.13.0
|
uses: stumpylog/image-cleaner-action/ephemeral@4fe057d991d63b8f6d5d22c40f17c1bca2226537 # v0.12.0
|
||||||
with:
|
with:
|
||||||
token: "${{ env.TOKEN }}"
|
token: "${{ env.TOKEN }}"
|
||||||
owner: "${{ github.repository_owner }}"
|
owner: "${{ github.repository_owner }}"
|
||||||
@@ -56,7 +56,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Clean untagged images
|
- name: Clean untagged images
|
||||||
if: "${{ env.TOKEN != '' }}"
|
if: "${{ env.TOKEN != '' }}"
|
||||||
uses: stumpylog/image-cleaner-action/untagged@21f875bab2376314e0525c614e923c669434f0e4 # v0.13.0
|
uses: stumpylog/image-cleaner-action/untagged@4fe057d991d63b8f6d5d22c40f17c1bca2226537 # v0.12.0
|
||||||
with:
|
with:
|
||||||
token: "${{ env.TOKEN }}"
|
token: "${{ env.TOKEN }}"
|
||||||
owner: "${{ github.repository_owner }}"
|
owner: "${{ github.repository_owner }}"
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
@@ -47,4 +47,4 @@ jobs:
|
|||||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
token: ${{ secrets.PNGX_BOT_PAT }}
|
token: ${{ secrets.PNGX_BOT_PAT }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: crowdin action
|
- name: crowdin action
|
||||||
uses: crowdin/github-action@9c23991700c0ec5256fd41089b9d9d7d540e424e # v3.3.0
|
uses: crowdin/github-action@0d5670f539973aea2f01abce61a8989934df0025 # v3.0.2
|
||||||
with:
|
with:
|
||||||
upload_translations: false
|
upload_translations: false
|
||||||
download_translations: true
|
download_translations: true
|
||||||
|
|||||||
@@ -25,12 +25,11 @@ jobs:
|
|||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
- name: Install system dependencies
|
- name: Install system dependencies
|
||||||
timeout-minutes: 12
|
run: |
|
||||||
uses: $/.github/actions/apt-install
|
sudo apt-get update -qq
|
||||||
with:
|
sudo apt-get install -qq --no-install-recommends gettext
|
||||||
packages: gettext
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
|
|||||||
+456
-706
File diff suppressed because it is too large
Load Diff
@@ -38,7 +38,7 @@ repos:
|
|||||||
- json
|
- json
|
||||||
# See https://github.com/prettier/prettier/issues/15742 for the fork reason
|
# See https://github.com/prettier/prettier/issues/15742 for the fork reason
|
||||||
- repo: https://github.com/rbubley/mirrors-prettier
|
- repo: https://github.com/rbubley/mirrors-prettier
|
||||||
rev: 'v3.9.8'
|
rev: 'v3.9.6'
|
||||||
hooks:
|
hooks:
|
||||||
- id: prettier
|
- id: prettier
|
||||||
types_or:
|
types_or:
|
||||||
@@ -46,11 +46,11 @@ repos:
|
|||||||
- ts
|
- ts
|
||||||
- markdown
|
- markdown
|
||||||
additional_dependencies:
|
additional_dependencies:
|
||||||
- prettier@3.9.8
|
- prettier@3.9.6
|
||||||
- 'prettier-plugin-organize-imports@4.3.0'
|
- 'prettier-plugin-organize-imports@4.3.0'
|
||||||
# Python hooks
|
# Python hooks
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.16.8
|
rev: v0.16.7
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff-check
|
- id: ruff-check
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
|
|||||||
+14241
-6104
File diff suppressed because one or more lines are too long
+2
-4
@@ -30,7 +30,7 @@ RUN set -eux \
|
|||||||
# Purpose: Installs s6-overlay and rootfs
|
# Purpose: Installs s6-overlay and rootfs
|
||||||
# Comments:
|
# Comments:
|
||||||
# - Don't leave anything extra in here either
|
# - Don't leave anything extra in here either
|
||||||
FROM ghcr.io/astral-sh/uv:0.12.20-python3.14-trixie-slim AS s6-overlay-base
|
FROM ghcr.io/astral-sh/uv:0.12.16-python3.14-trixie-slim AS s6-overlay-base
|
||||||
|
|
||||||
WORKDIR /usr/src/s6
|
WORKDIR /usr/src/s6
|
||||||
|
|
||||||
@@ -171,9 +171,7 @@ RUN set -eux \
|
|||||||
&& cp /etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-6/policy.xml \
|
&& cp /etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-6/policy.xml \
|
||||||
&& echo "Cleaning up image layer" \
|
&& echo "Cleaning up image layer" \
|
||||||
&& rm --force --verbose *.deb \
|
&& rm --force --verbose *.deb \
|
||||||
&& rm --recursive --force --verbose /var/lib/apt/lists/* \
|
&& rm --recursive --force --verbose /var/lib/apt/lists/*
|
||||||
&& echo "Configuring interactive shells to source the s6 container environment" \
|
|
||||||
&& echo '. /etc/profile.d/contenv.sh' >> /etc/bash.bashrc
|
|
||||||
|
|
||||||
WORKDIR /usr/src/paperless/src/
|
WORKDIR /usr/src/paperless/src/
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ services:
|
|||||||
network_mode: host
|
network_mode: host
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
greenmail:
|
greenmail:
|
||||||
image: docker.io/greenmail/standalone:2.1.14
|
image: docker.io/greenmail/standalone:2.1.13
|
||||||
hostname: greenmail
|
hostname: greenmail
|
||||||
container_name: greenmail
|
container_name: greenmail
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# Source s6 container environment for interactive shells.
|
|
||||||
# Ensures variables resolved from *_FILE secret injection are visible
|
|
||||||
# when using 'docker exec bash'. Does not affect s6 services (those
|
|
||||||
# use with-contenv directly). Has no effect in non-container contexts
|
|
||||||
# because the directory will not exist.
|
|
||||||
# Note: sh/dash shells opened via 'docker exec sh' are not covered;
|
|
||||||
# only bash-based sessions benefit from this file.
|
|
||||||
_pngx_contenv="/run/s6/container_environment"
|
|
||||||
if [ -d "${_pngx_contenv}" ]; then
|
|
||||||
for _pngx_f in "${_pngx_contenv}"/*; do
|
|
||||||
[ -f "${_pngx_f}" ] || continue
|
|
||||||
_pngx_name=$(basename "${_pngx_f}")
|
|
||||||
_pngx_val=$(cat "${_pngx_f}")
|
|
||||||
export "${_pngx_name}=${_pngx_val}"
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
unset _pngx_contenv _pngx_f _pngx_name _pngx_val
|
|
||||||
+9
-14
@@ -136,15 +136,13 @@ for suggested generation and embedding models.
|
|||||||
### AI-assisted suggestions
|
### AI-assisted suggestions
|
||||||
|
|
||||||
With AI enabled, Paperless-ngx can suggest a title, tags, correspondent, document type,
|
With AI enabled, Paperless-ngx can suggest a title, tags, correspondent, document type,
|
||||||
storage path and dates by sending the document to the LLM using "Suggest" button on the document
|
storage path and dates by sending the document to the LLM. This is **opt-in per request**
|
||||||
detail page. You can choose which type of suggestions are requested by default under Settings >
|
and surfaces through the "Suggest" control on the document detail page, alongside the
|
||||||
Documents, either ML (classifier-based) suggestions, AI suggestions, or both. When both are requested
|
classic classifier-based suggestions — it does not disable them. Suggestions are requested
|
||||||
the results are combined.
|
automatically when you open a document that carries an inbox tag unless "Automatically request
|
||||||
|
suggestions for inbox documents" under Settings > Documents is disabled. Suggestion output
|
||||||
Suggestions are requested automatically when you open a document that carries an inbox tag
|
language can be steered with
|
||||||
unless "Automatically request suggestions for inbox documents" under Settings > Documents is disabled.
|
[`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE`](configuration.md#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE)
|
||||||
|
|
||||||
Suggestion output language can be steered with [`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE`](configuration.md#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE)
|
|
||||||
(otherwise it follows the user's UI language).
|
(otherwise it follows the user's UI language).
|
||||||
|
|
||||||
### The LLM index (RAG) and similar documents
|
### The LLM index (RAG) and similar documents
|
||||||
@@ -155,11 +153,8 @@ in similar existing documents, and the document chat can retrieve relevant conte
|
|||||||
|
|
||||||
Enable it by setting
|
Enable it by setting
|
||||||
[`PAPERLESS_AI_LLM_EMBEDDING_BACKEND`](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_BACKEND)
|
[`PAPERLESS_AI_LLM_EMBEDDING_BACKEND`](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_BACKEND)
|
||||||
(`huggingface` for fully-local embeddings, or `ollama` / `openai-like`). By default, the main
|
(`huggingface` for fully-local embeddings, or `ollama` / `openai-like`). The index is only
|
||||||
LLM API key and endpoint are used, but an optional embedding-specific[API key](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_API_KEY)
|
built when AI is enabled **and** an embedding backend is set.
|
||||||
and [endpoint](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT) can be configured.
|
|
||||||
|
|
||||||
The index is only built when AI is enabled **and** an embedding backend is set.
|
|
||||||
|
|
||||||
The index is updated automatically on a schedule controlled by
|
The index is updated automatically on a schedule controlled by
|
||||||
[`PAPERLESS_LLM_INDEX_TASK_CRON`](configuration.md#PAPERLESS_LLM_INDEX_TASK_CRON) (daily by
|
[`PAPERLESS_LLM_INDEX_TASK_CRON`](configuration.md#PAPERLESS_LLM_INDEX_TASK_CRON) (daily by
|
||||||
|
|||||||
@@ -2133,13 +2133,6 @@ for language and resource considerations.
|
|||||||
|
|
||||||
Defaults to None.
|
Defaults to None.
|
||||||
|
|
||||||
#### [`PAPERLESS_AI_LLM_EMBEDDING_API_KEY=<str>`](#PAPERLESS_AI_LLM_EMBEDDING_API_KEY) {#PAPERLESS_AI_LLM_EMBEDDING_API_KEY}
|
|
||||||
|
|
||||||
: The API key to use for the embedding backend. If not supplied, embeddings use
|
|
||||||
`PAPERLESS_AI_LLM_API_KEY`.
|
|
||||||
|
|
||||||
Defaults to None.
|
|
||||||
|
|
||||||
#### [`PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT=<str>`](#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT) {#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT}
|
#### [`PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT=<str>`](#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT) {#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT}
|
||||||
|
|
||||||
: The endpoint / url to use for the embedding backend. If not supplied, embeddings use
|
: The endpoint / url to use for the embedding backend. If not supplied, embeddings use
|
||||||
@@ -2224,19 +2217,6 @@ used with the OpenAI-compatible backend to target a custom provider or local gat
|
|||||||
|
|
||||||
Defaults to true, which allows internal endpoints.
|
Defaults to true, which allows internal endpoints.
|
||||||
|
|
||||||
#### [`PAPERLESS_AI_LLM_EXTRA_PARAMS=<json>`](#PAPERLESS_AI_LLM_EXTRA_PARAMS) {#PAPERLESS_AI_LLM_EXTRA_PARAMS}
|
|
||||||
|
|
||||||
: A JSON object of extra parameters sent with every LLM request, for providers that require a parameter Paperless does not
|
|
||||||
set itself. Values here override Paperless' own, and no validation is performed. Whatever you put here is passed to the
|
|
||||||
backend as-is, so an invalid parameter will simply be rejected by your provider. For example, current OpenAI reasoning
|
|
||||||
models refuse tool calls on the chat completions API unless reasoning is off:
|
|
||||||
|
|
||||||
```
|
|
||||||
PAPERLESS_AI_LLM_EXTRA_PARAMS={"reasoning_effort": "none"}
|
|
||||||
```
|
|
||||||
|
|
||||||
Defaults to empty, which adds nothing to requests.
|
|
||||||
|
|
||||||
#### [`PAPERLESS_LLM_INDEX_TASK_CRON=<cron expression>`](#PAPERLESS_LLM_INDEX_TASK_CRON) {#PAPERLESS_LLM_INDEX_TASK_CRON}
|
#### [`PAPERLESS_LLM_INDEX_TASK_CRON=<cron expression>`](#PAPERLESS_LLM_INDEX_TASK_CRON) {#PAPERLESS_LLM_INDEX_TASK_CRON}
|
||||||
|
|
||||||
: Configures the schedule to update the AI embeddings of text content and metadata for all documents. Only performed if
|
: Configures the schedule to update the AI embeddings of text content and metadata for all documents. Only performed if
|
||||||
|
|||||||
+7
-13
@@ -19,6 +19,7 @@ classifiers = [
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"azure-ai-documentintelligence>=1.0.2",
|
"azure-ai-documentintelligence>=1.0.2",
|
||||||
"babel>=2.17",
|
"babel>=2.17",
|
||||||
|
"bleach~=6.4.0",
|
||||||
"celery[redis]~=5.6.2",
|
"celery[redis]~=5.6.2",
|
||||||
"channels~=4.2",
|
"channels~=4.2",
|
||||||
"channels-redis~=4.2",
|
"channels-redis~=4.2",
|
||||||
@@ -34,7 +35,7 @@ dependencies = [
|
|||||||
"django-cors-headers~=4.9.0",
|
"django-cors-headers~=4.9.0",
|
||||||
"django-extensions~=4.1",
|
"django-extensions~=4.1",
|
||||||
"django-filter~=25.1",
|
"django-filter~=25.1",
|
||||||
"django-guardian>=3.3.3,<3.6",
|
"django-guardian>=3.3.3,<3.5",
|
||||||
"django-multiselectfield~=1.0.1",
|
"django-multiselectfield~=1.0.1",
|
||||||
"django-rich~=2.2.0",
|
"django-rich~=2.2.0",
|
||||||
"django-soft-delete~=1.0.18",
|
"django-soft-delete~=1.0.18",
|
||||||
@@ -44,7 +45,7 @@ dependencies = [
|
|||||||
"drf-spectacular-sidecar>=2026.7.1,<2026.10",
|
"drf-spectacular-sidecar>=2026.7.1,<2026.10",
|
||||||
"drf-writable-nested~=0.7.1",
|
"drf-writable-nested~=0.7.1",
|
||||||
"filelock~=3.32.0",
|
"filelock~=3.32.0",
|
||||||
"flower>=2.0.1,<2.3",
|
"flower>=2.0.1,<2.2",
|
||||||
"gotenberg-client[httpx]~=1.0",
|
"gotenberg-client[httpx]~=1.0",
|
||||||
"httpx-oauth~=0.17",
|
"httpx-oauth~=0.17",
|
||||||
"ijson>=3.5.1",
|
"ijson>=3.5.1",
|
||||||
@@ -76,7 +77,6 @@ dependencies = [
|
|||||||
"tantivy~=0.26.0",
|
"tantivy~=0.26.0",
|
||||||
"tika-client[httpx]~=1.0",
|
"tika-client[httpx]~=1.0",
|
||||||
"torch>=2.13,<2.15",
|
"torch>=2.13,<2.15",
|
||||||
"turbohtml~=1.10.0",
|
|
||||||
"watchfiles>=1.2",
|
"watchfiles>=1.2",
|
||||||
"whitenoise~=6.11",
|
"whitenoise~=6.11",
|
||||||
"whoosh-compat[tantivy]==0.3",
|
"whoosh-compat[tantivy]==0.3",
|
||||||
@@ -84,13 +84,13 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
mariadb = [
|
mariadb = [
|
||||||
"mysqlclient>=2.2.7,<2.4",
|
"mysqlclient~=2.2.7",
|
||||||
]
|
]
|
||||||
postgres = [
|
postgres = [
|
||||||
"psycopg[c,pool]==3.3.4",
|
"psycopg[c,pool]==3.3.4",
|
||||||
# Direct dependency for proper resolution of the pre-built wheels
|
# Direct dependency for proper resolution of the pre-built wheels
|
||||||
"psycopg-c==3.3.4",
|
"psycopg-c==3.3.4",
|
||||||
"psycopg-pool==3.3.2",
|
"psycopg-pool==3.3.1",
|
||||||
]
|
]
|
||||||
webserver = [
|
webserver = [
|
||||||
"granian[uvloop]>=2.7,<2.9",
|
"granian[uvloop]>=2.7,<2.9",
|
||||||
@@ -112,7 +112,7 @@ lint = [
|
|||||||
testing = [
|
testing = [
|
||||||
"daphne",
|
"daphne",
|
||||||
"factory-boy~=3.3.1",
|
"factory-boy~=3.3.1",
|
||||||
"faker>=40.36,<40.40",
|
"faker>=40.36,<40.39",
|
||||||
"imagehash",
|
"imagehash",
|
||||||
"pytest~=9.1.1",
|
"pytest~=9.1.1",
|
||||||
"pytest-cov~=7.1.0",
|
"pytest-cov~=7.1.0",
|
||||||
@@ -136,6 +136,7 @@ typing = [
|
|||||||
"mypy",
|
"mypy",
|
||||||
"mypy-baseline",
|
"mypy-baseline",
|
||||||
"pyrefly",
|
"pyrefly",
|
||||||
|
"types-bleach",
|
||||||
"types-channels",
|
"types-channels",
|
||||||
"types-colorama",
|
"types-colorama",
|
||||||
"types-dateparser",
|
"types-dateparser",
|
||||||
@@ -150,7 +151,6 @@ typing = [
|
|||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
required-version = ">=0.9.0"
|
required-version = ">=0.9.0"
|
||||||
prerelease = "disallow"
|
|
||||||
environments = [
|
environments = [
|
||||||
"sys_platform == 'darwin'",
|
"sys_platform == 'darwin'",
|
||||||
"sys_platform == 'linux'",
|
"sys_platform == 'linux'",
|
||||||
@@ -247,10 +247,6 @@ per-file-ignores."docker/wait-for-redis.py" = [
|
|||||||
per-file-ignores."src/documents/models.py" = [
|
per-file-ignores."src/documents/models.py" = [
|
||||||
"SIM115",
|
"SIM115",
|
||||||
]
|
]
|
||||||
per-file-ignores."src/documents/tests/*.py" = [
|
|
||||||
"TID251",
|
|
||||||
]
|
|
||||||
flake8-tidy-imports.banned-api."documents.tests".msg = "Shared test infrastructure lives in src/paperless_testing/."
|
|
||||||
isort.force-single-line = true
|
isort.force-single-line = true
|
||||||
|
|
||||||
[tool.codespell]
|
[tool.codespell]
|
||||||
@@ -333,8 +329,6 @@ PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
|
|||||||
PAPERLESS_CHANNELS_BACKEND = "channels.layers.InMemoryChannelLayer"
|
PAPERLESS_CHANNELS_BACKEND = "channels.layers.InMemoryChannelLayer"
|
||||||
# I don't think anything hits this, but just in case, basically infinite
|
# I don't think anything hits this, but just in case, basically infinite
|
||||||
PAPERLESS_TOKEN_THROTTLE_RATE = "1000/min"
|
PAPERLESS_TOKEN_THROTTLE_RATE = "1000/min"
|
||||||
# The 0.1s production default trips on a stalled CI runner, the date parsing tests then find no dates
|
|
||||||
PAPERLESS_MATCH_REGEX_TIMEOUT_SECONDS = "5"
|
|
||||||
|
|
||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
source = [
|
source = [
|
||||||
|
|||||||
@@ -91,11 +91,6 @@
|
|||||||
"glob": "**/*",
|
"glob": "**/*",
|
||||||
"input": "node_modules/pdfjs-dist/iccs/",
|
"input": "node_modules/pdfjs-dist/iccs/",
|
||||||
"output": "/assets/iccs/"
|
"output": "/assets/iccs/"
|
||||||
},
|
|
||||||
{
|
|
||||||
"glob": "**/*",
|
|
||||||
"input": "node_modules/pdfjs-dist/cmaps/",
|
|
||||||
"output": "/assets/cmaps/"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
|
|||||||
+238
-315
File diff suppressed because it is too large
Load Diff
+22
-22
@@ -15,16 +15,16 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^22.2.0",
|
"@angular/cdk": "^22.1.6",
|
||||||
"@angular/common": "~22.2.0",
|
"@angular/common": "~22.1.6",
|
||||||
"@angular/compiler": "~22.2.0",
|
"@angular/compiler": "~22.1.6",
|
||||||
"@angular/core": "~22.2.0",
|
"@angular/core": "~22.1.6",
|
||||||
"@angular/forms": "~22.2.0",
|
"@angular/forms": "~22.1.6",
|
||||||
"@angular/localize": "~22.2.0",
|
"@angular/localize": "~22.1.6",
|
||||||
"@angular/platform-browser": "~22.2.0",
|
"@angular/platform-browser": "~22.1.6",
|
||||||
"@angular/router": "~22.2.0",
|
"@angular/router": "~22.1.6",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
||||||
"@ng-select/ng-select": "~24.1.2",
|
"@ng-select/ng-select": "~24.1.1",
|
||||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
@@ -45,29 +45,29 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-builders/jest": "^22.0.1",
|
"@angular-builders/jest": "^22.0.1",
|
||||||
"@angular-devkit/core": "^22.2.0",
|
"@angular-devkit/core": "^22.1.8",
|
||||||
"@angular-devkit/schematics": "^22.2.0",
|
"@angular-devkit/schematics": "^22.1.8",
|
||||||
"@angular-eslint/builder": "22.5.0",
|
"@angular-eslint/builder": "22.5.0",
|
||||||
"@angular-eslint/eslint-plugin": "22.5.0",
|
"@angular-eslint/eslint-plugin": "22.5.0",
|
||||||
"@angular-eslint/eslint-plugin-template": "22.5.0",
|
"@angular-eslint/eslint-plugin-template": "22.5.0",
|
||||||
"@angular-eslint/schematics": "22.5.0",
|
"@angular-eslint/schematics": "22.5.0",
|
||||||
"@angular-eslint/template-parser": "22.5.0",
|
"@angular-eslint/template-parser": "22.5.0",
|
||||||
"@angular/build": "22.2.0",
|
"@angular/build": "22.1.8",
|
||||||
"@angular/cli": "22.2.0",
|
"@angular/cli": "22.1.8",
|
||||||
"@angular/compiler-cli": "~22.2.0",
|
"@angular/compiler-cli": "~22.1.6",
|
||||||
"@playwright/test": "^1.62.1",
|
"@playwright/test": "^1.62.1",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^26.6.2",
|
"@types/node": "^26.5.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.70.1",
|
"@typescript-eslint/eslint-plugin": "^8.70.0",
|
||||||
"@typescript-eslint/parser": "^8.70.1",
|
"@typescript-eslint/parser": "^8.70.0",
|
||||||
"@typescript-eslint/utils": "^8.70.1",
|
"@typescript-eslint/utils": "^8.70.0",
|
||||||
"eslint": "^10.11.0",
|
"eslint": "^10.10.0",
|
||||||
"jest": "30.5.2",
|
"jest": "30.5.1",
|
||||||
"jest-environment-jsdom": "^30.5.2",
|
"jest-environment-jsdom": "^30.5.1",
|
||||||
"jest-junit": "^17.0.0",
|
"jest-junit": "^17.0.0",
|
||||||
"jest-preset-angular": "^17.0.0",
|
"jest-preset-angular": "^17.0.0",
|
||||||
"jest-websocket-mock": "^2.5.0",
|
"jest-websocket-mock": "^2.5.0",
|
||||||
"prettier": "^3.9.9",
|
"prettier": "^3.9.6",
|
||||||
"prettier-plugin-organize-imports": "^4.3.0",
|
"prettier-plugin-organize-imports": "^4.3.0",
|
||||||
"ts-node": "~10.9.2",
|
"ts-node": "~10.9.2",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
|
|||||||
Generated
+1959
-1311
File diff suppressed because it is too large
Load Diff
@@ -253,24 +253,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (aiEnabled) {
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-md-3 col-form-label pt-0">
|
|
||||||
<span i18n>Suggestions default to</span>
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<fieldset class="btn-group btn-group-sm">
|
|
||||||
<input type="radio" class="btn-check" id="suggestionSourceBoth" [value]="SuggestionSource.Both" formControlName="documentEditingSuggestionSource">
|
|
||||||
<label class="btn btn-outline-primary" for="suggestionSourceBoth"><ng-container i18n>Both</ng-container></label>
|
|
||||||
<input type="radio" class="btn-check" id="suggestionSourceML" [value]="SuggestionSource.ML" formControlName="documentEditingSuggestionSource">
|
|
||||||
<label class="btn btn-outline-primary" for="suggestionSourceML"><i-bs class="me-1" name="cpu"></i-bs><ng-container i18n>ML only</ng-container></label>
|
|
||||||
<input type="radio" class="btn-check" id="suggestionSourceAI" [value]="SuggestionSource.AI" formControlName="documentEditingSuggestionSource">
|
|
||||||
<label class="btn btn-outline-primary" for="suggestionSourceAI"><i-bs class="me-1" name="stars"></i-bs><ng-container i18n>AI only</ng-container></label>
|
|
||||||
</fieldset>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<pngx-input-check i18n-title title="Automatically request suggestions for inbox documents" i18n-hint hint="If un-checked, suggestions must be requested via the Suggest button." formControlName="documentEditingAutoSuggest"></pngx-input-check>
|
<pngx-input-check i18n-title title="Automatically request suggestions for inbox documents" i18n-hint hint="If un-checked, suggestions must be requested via the Suggest button." formControlName="documentEditingAutoSuggest"></pngx-input-check>
|
||||||
|
|||||||
@@ -307,7 +307,7 @@ describe('SettingsComponent', () => {
|
|||||||
expect(toastErrorSpy).toHaveBeenCalled()
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
expect(storeSpy).toHaveBeenCalled()
|
expect(storeSpy).toHaveBeenCalled()
|
||||||
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
||||||
expect(setSpy).toHaveBeenCalledTimes(35)
|
expect(setSpy).toHaveBeenCalledTimes(34)
|
||||||
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
|
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
|
||||||
HideableSidebarItemID.Workflows,
|
HideableSidebarItemID.Workflows,
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ import {
|
|||||||
HIDEABLE_SIDEBAR_ITEM_IDS,
|
HIDEABLE_SIDEBAR_ITEM_IDS,
|
||||||
HideableSidebarItemID,
|
HideableSidebarItemID,
|
||||||
SETTINGS_KEYS,
|
SETTINGS_KEYS,
|
||||||
SuggestionSource,
|
|
||||||
} from 'src/app/data/ui-settings'
|
} from 'src/app/data/ui-settings'
|
||||||
import { User } from 'src/app/data/user'
|
import { User } from 'src/app/data/user'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
@@ -185,7 +184,6 @@ export class SettingsComponent
|
|||||||
documentEditingRemoveInboxTags: new FormControl(null),
|
documentEditingRemoveInboxTags: new FormControl(null),
|
||||||
documentEditingOverlayThumbnail: new FormControl(null),
|
documentEditingOverlayThumbnail: new FormControl(null),
|
||||||
documentEditingAutoSuggest: new FormControl(null),
|
documentEditingAutoSuggest: new FormControl(null),
|
||||||
documentEditingSuggestionSource: new FormControl(null),
|
|
||||||
documentDetailsHiddenFields: new FormControl([]),
|
documentDetailsHiddenFields: new FormControl([]),
|
||||||
searchDbOnly: new FormControl(null),
|
searchDbOnly: new FormControl(null),
|
||||||
searchLink: new FormControl(null),
|
searchLink: new FormControl(null),
|
||||||
@@ -219,11 +217,6 @@ export class SettingsComponent
|
|||||||
public readonly PdfZoomScale = PdfZoomScale
|
public readonly PdfZoomScale = PdfZoomScale
|
||||||
|
|
||||||
public readonly PdfEditorEditMode = PdfEditorEditMode
|
public readonly PdfEditorEditMode = PdfEditorEditMode
|
||||||
public readonly SuggestionSource = SuggestionSource
|
|
||||||
|
|
||||||
get aiEnabled(): boolean {
|
|
||||||
return this.settings.get(SETTINGS_KEYS.AI_ENABLED)
|
|
||||||
}
|
|
||||||
|
|
||||||
public readonly documentDetailFieldOptions = documentDetailFieldOptions
|
public readonly documentDetailFieldOptions = documentDetailFieldOptions
|
||||||
public readonly sidebarItemOptions = HIDEABLE_SIDEBAR_ITEM_IDS.map((id) => ({
|
public readonly sidebarItemOptions = HIDEABLE_SIDEBAR_ITEM_IDS.map((id) => ({
|
||||||
@@ -411,9 +404,6 @@ export class SettingsComponent
|
|||||||
documentEditingAutoSuggest: this.settings.get(
|
documentEditingAutoSuggest: this.settings.get(
|
||||||
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST
|
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST
|
||||||
),
|
),
|
||||||
documentEditingSuggestionSource: this.settings.get(
|
|
||||||
SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE
|
|
||||||
),
|
|
||||||
documentDetailsHiddenFields: this.settings.get(
|
documentDetailsHiddenFields: this.settings.get(
|
||||||
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS
|
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS
|
||||||
),
|
),
|
||||||
@@ -635,10 +625,6 @@ export class SettingsComponent
|
|||||||
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST,
|
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST,
|
||||||
this.settingsForm.value.documentEditingAutoSuggest
|
this.settingsForm.value.documentEditingAutoSuggest
|
||||||
)
|
)
|
||||||
this.settings.set(
|
|
||||||
SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE,
|
|
||||||
this.settingsForm.value.documentEditingSuggestionSource
|
|
||||||
)
|
|
||||||
this.settings.set(
|
this.settings.set(
|
||||||
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS,
|
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS,
|
||||||
this.settingsForm.value.documentDetailsHiddenFields
|
this.settingsForm.value.documentDetailsHiddenFields
|
||||||
|
|||||||
@@ -154,29 +154,11 @@
|
|||||||
& section {
|
& section {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
text-align: initial;
|
text-align: initial;
|
||||||
pointer-events: auto;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
transform-origin: 0 0;
|
transform-origin: 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
& :is(.linkAnnotation, .buttonWidgetAnnotation.pushButton) > a {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
font-size: 1em;
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
& :is(.linkAnnotation, .buttonWidgetAnnotation.pushButton):not(.hasBorder)
|
|
||||||
> a:hover {
|
|
||||||
opacity: 0.2;
|
|
||||||
background-color: rgb(255 255 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
& .annotationTextContent {
|
& .annotationTextContent {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:host ::ng-deep .textLayer.selecting ~ .annotationLayer section {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { SimpleChange } from '@angular/core'
|
import { SimpleChange } from '@angular/core'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import * as pdfjs from 'pdfjs-dist/legacy/build/pdf.mjs'
|
import * as pdfjs from 'pdfjs-dist/legacy/build/pdf.mjs'
|
||||||
import {
|
import { PDFSinglePageViewer, PDFViewer } from 'pdfjs-dist/web/pdf_viewer.mjs'
|
||||||
LinkTarget,
|
|
||||||
PDFSinglePageViewer,
|
|
||||||
PDFViewer,
|
|
||||||
} from 'pdfjs-dist/web/pdf_viewer.mjs'
|
|
||||||
import { PngxPdfViewerComponent } from './pdf-viewer.component'
|
import { PngxPdfViewerComponent } from './pdf-viewer.component'
|
||||||
import { PdfRenderMode, PdfZoomLevel, PdfZoomScale } from './pdf-viewer.types'
|
import { PdfRenderMode, PdfZoomLevel, PdfZoomScale } from './pdf-viewer.types'
|
||||||
|
|
||||||
@@ -62,16 +58,6 @@ describe('PngxPdfViewerComponent', () => {
|
|||||||
expect((component as any).pdfViewer).toBeInstanceOf(PDFViewer)
|
expect((component as any).pdfViewer).toBeInstanceOf(PDFViewer)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('opens external links in a new tab', () => {
|
|
||||||
const linkService = (component as any).linkService
|
|
||||||
expect(linkService.options).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
externalLinkTarget: LinkTarget.BLANK,
|
|
||||||
externalLinkRel: 'noopener noreferrer nofollow',
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('resolves the worker source relative to the document base URI', async () => {
|
it('resolves the worker source relative to the document base URI', async () => {
|
||||||
setBaseHref('/paperless/')
|
setBaseHref('/paperless/')
|
||||||
const getDocumentSpy = jest.spyOn(pdfjs, 'getDocument')
|
const getDocumentSpy = jest.spyOn(pdfjs, 'getDocument')
|
||||||
@@ -90,7 +76,6 @@ describe('PngxPdfViewerComponent', () => {
|
|||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
wasmUrl: expect.stringContaining('/paperless/assets/wasm/'),
|
wasmUrl: expect.stringContaining('/paperless/assets/wasm/'),
|
||||||
iccUrl: expect.stringContaining('/paperless/assets/iccs/'),
|
iccUrl: expect.stringContaining('/paperless/assets/iccs/'),
|
||||||
cMapUrl: expect.stringContaining('/paperless/assets/cmaps/'),
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
} from 'pdfjs-dist/legacy/build/pdf.mjs'
|
} from 'pdfjs-dist/legacy/build/pdf.mjs'
|
||||||
import {
|
import {
|
||||||
EventBus,
|
EventBus,
|
||||||
LinkTarget,
|
|
||||||
PDFFindController,
|
PDFFindController,
|
||||||
PDFLinkService,
|
PDFLinkService,
|
||||||
PDFSinglePageViewer,
|
PDFSinglePageViewer,
|
||||||
@@ -76,11 +75,7 @@ export class PngxPdfViewerComponent
|
|||||||
private lastViewerPage?: number
|
private lastViewerPage?: number
|
||||||
|
|
||||||
private readonly eventBus = new EventBus()
|
private readonly eventBus = new EventBus()
|
||||||
private readonly linkService = new PDFLinkService({
|
private readonly linkService = new PDFLinkService({ eventBus: this.eventBus })
|
||||||
eventBus: this.eventBus,
|
|
||||||
externalLinkTarget: LinkTarget.BLANK,
|
|
||||||
externalLinkRel: 'noopener noreferrer nofollow',
|
|
||||||
})
|
|
||||||
private readonly findController = new PDFFindController({
|
private readonly findController = new PDFFindController({
|
||||||
eventBus: this.eventBus,
|
eventBus: this.eventBus,
|
||||||
linkService: this.linkService,
|
linkService: this.linkService,
|
||||||
@@ -190,7 +185,6 @@ export class PngxPdfViewerComponent
|
|||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
wasmUrl: new URL('assets/wasm/', this.document.baseURI).toString(),
|
wasmUrl: new URL('assets/wasm/', this.document.baseURI).toString(),
|
||||||
iccUrl: new URL('assets/iccs/', this.document.baseURI).toString(),
|
iccUrl: new URL('assets/iccs/', this.document.baseURI).toString(),
|
||||||
cMapUrl: new URL('assets/cmaps/', this.document.baseURI).toString(),
|
|
||||||
}
|
}
|
||||||
this.loadingTask = getDocument(initOptions)
|
this.loadingTask = getDocument(initOptions)
|
||||||
try {
|
try {
|
||||||
|
|||||||
+51
-77
@@ -1,84 +1,58 @@
|
|||||||
<div class="d-flex align-items-center">
|
<div class="btn-group">
|
||||||
<div class="btn-group">
|
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading() || (suggestions() && !aiEnabled())" [aria-label]="noSuggestions ? 'No suggestions' : 'Suggest'" i18n-aria-label>
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading() || (suggestions() && !aiEnabled())" [aria-label]="noSuggestions ? 'No suggestions' : 'Suggest'" i18n-aria-label>
|
@if (loading()) {
|
||||||
@if (loading()) {
|
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
} @else if (noSuggestions) {
|
||||||
} @else if (noSuggestions) {
|
<i-bs width="1.2em" height="1.2em" name="check-circle"></i-bs>
|
||||||
<i-bs width="1.2em" height="1.2em" name="check-circle"></i-bs>
|
} @else {
|
||||||
} @else {
|
<i-bs width="1.2em" height="1.2em" name="stars"></i-bs>
|
||||||
<i-bs width="1.2em" height="1.2em" name="lightbulb"></i-bs>
|
|
||||||
}
|
|
||||||
@if (noSuggestions) {
|
|
||||||
<span class="d-none d-lg-inline ps-1" i18n>No suggestions</span>
|
|
||||||
} @else {
|
|
||||||
<span class="d-none d-lg-inline ps-1" i18n>Suggest</span>
|
|
||||||
}
|
|
||||||
@if (totalSuggestions > 0) {
|
|
||||||
<span class="badge bg-primary ms-2">{{ totalSuggestions }}</span>
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
@if (aiEnabled()) {
|
|
||||||
<div class="btn-group" ngbDropdown #dropdown="ngbDropdown" [popperOptions]="popperOptions">
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" ngbDropdownToggle [disabled]="disabled() || loading() || !suggestions()" aria-expanded="false" aria-controls="suggestionsDropdown" aria-label="Suggestions dropdown">
|
|
||||||
<span class="visually-hidden" i18n>Show suggestions</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
|
|
||||||
<div class="list-group list-group-flush small pb-0">
|
|
||||||
@if (novelSuggestions === 0 && reusableSuggestions === 0) {
|
|
||||||
<div class="list-group-item text-muted fst-italic">
|
|
||||||
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (suggestions()?.suggested_tags?.length > 0) {
|
|
||||||
<small class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="tags"></i-bs><ng-container i18n>Tags</ng-container></small>
|
|
||||||
@for (tag of suggestions().suggested_tags; track tag) {
|
|
||||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)">{{ tag }}</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@if (suggestions()?.suggested_document_types?.length > 0) {
|
|
||||||
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="hash"></i-bs><ng-container i18n>Document Types</ng-container></div>
|
|
||||||
@for (type of suggestions().suggested_document_types; track type) {
|
|
||||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)">{{ type }}</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@if (suggestions()?.suggested_correspondents?.length > 0) {
|
|
||||||
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="person"></i-bs><ng-container i18n>Correspondents</ng-container></div>
|
|
||||||
@for (correspondent of suggestions().suggested_correspondents; track correspondent) {
|
|
||||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@if (reusableSuggestions > 0) {
|
|
||||||
<div class="list-group-item text-muted fst-italic">
|
|
||||||
<small class="text-muted small fst-italic" i18n>{reusableSuggestions, plural, =1 {1 existing value suggested below} other {{{reusableSuggestions}} existing values suggested below}}</small>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
</div>
|
@if (noSuggestions) {
|
||||||
|
<span class="d-none d-lg-inline ps-1" i18n>No suggestions</span>
|
||||||
|
} @else {
|
||||||
|
<span class="d-none d-lg-inline ps-1" i18n>Suggest</span>
|
||||||
|
}
|
||||||
|
@if (totalSuggestions > 0) {
|
||||||
|
<span class="badge bg-primary ms-2">{{ totalSuggestions }}</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
|
||||||
@if (aiEnabled()) {
|
@if (aiEnabled()) {
|
||||||
<div ngbDropdown autoClose="outside" placement="bottom-end" [popperOptions]="popperOptions">
|
<div class="btn-group" ngbDropdown #dropdown="ngbDropdown" [popperOptions]="popperOptions">
|
||||||
<button type="button" class="btn btn-sm btn-link position-relative" ngbDropdownToggle [disabled]="disabled() || loading()" i18n-title title="Suggestion options">
|
<button type="button" class="btn btn-sm btn-outline-primary" ngbDropdownToggle [disabled]="disabled() || loading() || !suggestions()" aria-expanded="false" aria-controls="suggestionsDropdown" aria-label="Suggestions dropdown">
|
||||||
<i-bs name="three-dots"></i-bs>
|
<span class="visually-hidden" i18n>Show suggestions</span>
|
||||||
@if (source() !== defaultSource()) {
|
|
||||||
<span class="position-absolute top-0 start-100 translate-middle p-1 bg-primary border border-light rounded-circle">
|
|
||||||
<span class="visually-hidden" i18n>Not using default</span>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</button>
|
</button>
|
||||||
<div ngbDropdownMenu class="shadow p-3">
|
|
||||||
<div class="small text-muted mb-2" i18n>Suggest using:</div>
|
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
|
||||||
<div class="form-check small">
|
<div class="list-group list-group-flush small pb-0">
|
||||||
<input class="form-check-input" type="checkbox" id="suggestionSourceML" [checked]="useML" [disabled]="useML && !useAI" (change)="setSources($event.target.checked, useAI)">
|
@if (novelSuggestions === 0 && reusableSuggestions === 0) {
|
||||||
<label class="form-check-label d-inline-flex align-items-center gap-1" for="suggestionSourceML"><i-bs name="cpu"></i-bs><ng-container i18n>ML</ng-container></label>
|
<div class="list-group-item text-muted fst-italic">
|
||||||
</div>
|
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
|
||||||
<div class="form-check small">
|
</div>
|
||||||
<input class="form-check-input" type="checkbox" id="suggestionSourceAI" [checked]="useAI" [disabled]="useAI && !useML" (change)="setSources(useML, $event.target.checked)">
|
}
|
||||||
<label class="form-check-label d-inline-flex align-items-center gap-1" for="suggestionSourceAI"><i-bs name="stars"></i-bs><ng-container i18n>AI</ng-container></label>
|
@if (suggestions()?.suggested_tags?.length > 0) {
|
||||||
|
<small class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="tags"></i-bs><ng-container i18n>Tags</ng-container></small>
|
||||||
|
@for (tag of suggestions().suggested_tags; track tag) {
|
||||||
|
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)">{{ tag }}</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (suggestions()?.suggested_document_types?.length > 0) {
|
||||||
|
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="hash"></i-bs><ng-container i18n>Document Types</ng-container></div>
|
||||||
|
@for (type of suggestions().suggested_document_types; track type) {
|
||||||
|
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)">{{ type }}</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (suggestions()?.suggested_correspondents?.length > 0) {
|
||||||
|
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="person"></i-bs><ng-container i18n>Correspondents</ng-container></div>
|
||||||
|
@for (correspondent of suggestions().suggested_correspondents; track correspondent) {
|
||||||
|
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (reusableSuggestions > 0) {
|
||||||
|
<div class="list-group-item text-muted fst-italic">
|
||||||
|
<small class="text-muted small fst-italic" i18n>{reusableSuggestions, plural, =1 {1 existing value suggested below} other {{{reusableSuggestions}} existing values suggested below}}</small>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
-4
@@ -1,7 +1,3 @@
|
|||||||
.suggestions-dropdown {
|
.suggestions-dropdown {
|
||||||
min-width: 250px;
|
min-width: 250px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-link.dropdown-toggle::after {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|||||||
+1
-59
@@ -1,7 +1,6 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { SuggestionSource } from 'src/app/data/ui-settings'
|
|
||||||
import { SuggestionsDropdownComponent } from './suggestions-dropdown.component'
|
import { SuggestionsDropdownComponent } from './suggestions-dropdown.component'
|
||||||
|
|
||||||
describe('SuggestionsDropdownComponent', () => {
|
describe('SuggestionsDropdownComponent', () => {
|
||||||
@@ -180,71 +179,14 @@ describe('SuggestionsDropdownComponent', () => {
|
|||||||
|
|
||||||
it('should toggle dropdown when clickSuggest is called and suggestions are not null', () => {
|
it('should toggle dropdown when clickSuggest is called and suggestions are not null', () => {
|
||||||
fixture.componentRef.setInput('aiEnabled', true)
|
fixture.componentRef.setInput('aiEnabled', true)
|
||||||
fixture.componentRef.setInput('fetchedSources', [SuggestionSource.ML])
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
fixture.componentRef.setInput('suggestions', {
|
fixture.componentRef.setInput('suggestions', {
|
||||||
suggested_correspondents: [],
|
suggested_correspondents: [],
|
||||||
suggested_tags: [],
|
suggested_tags: [],
|
||||||
suggested_document_types: [],
|
suggested_document_types: [],
|
||||||
})
|
})
|
||||||
fixture.detectChanges()
|
|
||||||
component.clickSuggest()
|
component.clickSuggest()
|
||||||
expect(component.dropdown.isOpen()).toBeTruthy()
|
expect(component.dropdown.open).toBeTruthy()
|
||||||
expect(fixture.nativeElement.textContent).toContain('No novel suggestions')
|
expect(fixture.nativeElement.textContent).toContain('No novel suggestions')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should fetch unfetched sources and show existing suggestions', () => {
|
|
||||||
jest.spyOn(component.getSuggestions, 'emit')
|
|
||||||
fixture.componentRef.setInput('aiEnabled', true)
|
|
||||||
fixture.componentRef.setInput('source', SuggestionSource.Both)
|
|
||||||
fixture.componentRef.setInput('fetchedSources', [SuggestionSource.ML])
|
|
||||||
fixture.componentRef.setInput('suggestions', { tags: [1] })
|
|
||||||
fixture.detectChanges()
|
|
||||||
component.clickSuggest()
|
|
||||||
expect(component.getSuggestions.emit).toHaveBeenCalledWith(
|
|
||||||
SuggestionSource.Both
|
|
||||||
)
|
|
||||||
expect(component.dropdown.isOpen()).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should only show source options when AI is enabled', () => {
|
|
||||||
expect(
|
|
||||||
fixture.nativeElement.querySelector('#suggestionSourceML')
|
|
||||||
).toBeNull()
|
|
||||||
fixture.componentRef.setInput('aiEnabled', true)
|
|
||||||
fixture.detectChanges()
|
|
||||||
fixture.nativeElement
|
|
||||||
.querySelector('button[title="Suggestion options"]')
|
|
||||||
.click()
|
|
||||||
fixture.detectChanges()
|
|
||||||
expect(
|
|
||||||
fixture.nativeElement.querySelector('#suggestionSourceML')
|
|
||||||
).not.toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should emit source changes and never allow no source', () => {
|
|
||||||
const emitSpy = jest.spyOn(component.sourceChange, 'emit')
|
|
||||||
component.setSources(true, true)
|
|
||||||
expect(emitSpy).toHaveBeenCalledWith(SuggestionSource.Both)
|
|
||||||
component.setSources(true, false)
|
|
||||||
expect(emitSpy).toHaveBeenCalledWith(SuggestionSource.ML)
|
|
||||||
component.setSources(false, true)
|
|
||||||
expect(emitSpy).toHaveBeenCalledWith(SuggestionSource.AI)
|
|
||||||
|
|
||||||
emitSpy.mockClear()
|
|
||||||
component.setSources(false, false)
|
|
||||||
expect(emitSpy).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should indicate a non-default source', () => {
|
|
||||||
fixture.componentRef.setInput('aiEnabled', true)
|
|
||||||
fixture.componentRef.setInput('source', SuggestionSource.AI)
|
|
||||||
fixture.componentRef.setInput('defaultSource', SuggestionSource.AI)
|
|
||||||
fixture.detectChanges()
|
|
||||||
expect(fixture.nativeElement.textContent).not.toContain('Not using default')
|
|
||||||
|
|
||||||
fixture.componentRef.setInput('source', SuggestionSource.Both)
|
|
||||||
fixture.detectChanges()
|
|
||||||
expect(fixture.nativeElement.textContent).toContain('Not using default')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
+3
-40
@@ -8,7 +8,6 @@ import {
|
|||||||
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
|
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
|
||||||
import { SuggestionSource } from 'src/app/data/ui-settings'
|
|
||||||
import { pngxPopperOptions } from 'src/app/utils/popper-options'
|
import { pngxPopperOptions } from 'src/app/utils/popper-options'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -19,16 +18,12 @@ import { pngxPopperOptions } from 'src/app/utils/popper-options'
|
|||||||
})
|
})
|
||||||
export class SuggestionsDropdownComponent {
|
export class SuggestionsDropdownComponent {
|
||||||
public popperOptions = pngxPopperOptions
|
public popperOptions = pngxPopperOptions
|
||||||
public readonly SuggestionSource = SuggestionSource
|
|
||||||
|
|
||||||
@ViewChild('dropdown') dropdown: NgbDropdown
|
@ViewChild('dropdown') dropdown: NgbDropdown
|
||||||
readonly suggestions = input<DocumentSuggestions>(null)
|
readonly suggestions = input<DocumentSuggestions>(null)
|
||||||
readonly aiEnabled = input(false)
|
readonly aiEnabled = input(false)
|
||||||
readonly loading = input(false)
|
readonly loading = input(false)
|
||||||
readonly disabled = input(false)
|
readonly disabled = input(false)
|
||||||
readonly source = input<SuggestionSource>(SuggestionSource.ML)
|
|
||||||
readonly defaultSource = input<SuggestionSource>(SuggestionSource.ML)
|
|
||||||
readonly fetchedSources = input<SuggestionSource[]>([])
|
|
||||||
|
|
||||||
readonly appliedTags = input<number[]>([])
|
readonly appliedTags = input<number[]>([])
|
||||||
readonly appliedCorrespondent = input<number>(null)
|
readonly appliedCorrespondent = input<number>(null)
|
||||||
@@ -36,10 +31,8 @@ export class SuggestionsDropdownComponent {
|
|||||||
readonly appliedStoragePath = input<number>(null)
|
readonly appliedStoragePath = input<number>(null)
|
||||||
|
|
||||||
@Output()
|
@Output()
|
||||||
getSuggestions: EventEmitter<SuggestionSource> = new EventEmitter()
|
getSuggestions: EventEmitter<SuggestionsDropdownComponent> =
|
||||||
|
new EventEmitter()
|
||||||
@Output()
|
|
||||||
sourceChange: EventEmitter<SuggestionSource> = new EventEmitter()
|
|
||||||
|
|
||||||
@Output()
|
@Output()
|
||||||
addTag: EventEmitter<string> = new EventEmitter()
|
addTag: EventEmitter<string> = new EventEmitter()
|
||||||
@@ -60,42 +53,12 @@ export class SuggestionsDropdownComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!this.suggestions()) {
|
if (!this.suggestions()) {
|
||||||
this.getSuggestions.emit(this.source())
|
this.getSuggestions.emit(this)
|
||||||
} else if (this.hasUnfetchedSources) {
|
|
||||||
// sources changed, fetch the rest and show what we have meanwhile
|
|
||||||
this.getSuggestions.emit(this.source())
|
|
||||||
this.dropdown?.open()
|
|
||||||
} else {
|
} else {
|
||||||
this.dropdown?.toggle()
|
this.dropdown?.toggle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get useML(): boolean {
|
|
||||||
return this.source() !== SuggestionSource.AI
|
|
||||||
}
|
|
||||||
|
|
||||||
get useAI(): boolean {
|
|
||||||
return this.source() !== SuggestionSource.ML
|
|
||||||
}
|
|
||||||
|
|
||||||
get hasUnfetchedSources(): boolean {
|
|
||||||
const fetched = this.fetchedSources()
|
|
||||||
return (
|
|
||||||
(this.useML && !fetched.includes(SuggestionSource.ML)) ||
|
|
||||||
(this.useAI && !fetched.includes(SuggestionSource.AI))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
public setSources(ml: boolean, ai: boolean) {
|
|
||||||
if (ml && ai) {
|
|
||||||
this.sourceChange.emit(SuggestionSource.Both)
|
|
||||||
} else if (ml) {
|
|
||||||
this.sourceChange.emit(SuggestionSource.ML)
|
|
||||||
} else if (ai) {
|
|
||||||
this.sourceChange.emit(SuggestionSource.AI)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get novelSuggestions(): number {
|
get novelSuggestions(): number {
|
||||||
return (
|
return (
|
||||||
(this.suggestions()?.suggested_correspondents?.length ?? 0) +
|
(this.suggestions()?.suggested_correspondents?.length ?? 0) +
|
||||||
|
|||||||
@@ -134,15 +134,11 @@
|
|||||||
[loading]="suggestionsLoading()"
|
[loading]="suggestionsLoading()"
|
||||||
[suggestions]="suggestions()"
|
[suggestions]="suggestions()"
|
||||||
[aiEnabled]="aiEnabled"
|
[aiEnabled]="aiEnabled"
|
||||||
[source]="suggestionSource"
|
|
||||||
[defaultSource]="defaultSuggestionSource"
|
|
||||||
[fetchedSources]="fetchedSuggestionSources()"
|
|
||||||
[appliedTags]="documentForm.value.tags"
|
[appliedTags]="documentForm.value.tags"
|
||||||
[appliedCorrespondent]="documentForm.value.correspondent"
|
[appliedCorrespondent]="documentForm.value.correspondent"
|
||||||
[appliedDocumentType]="documentForm.value.document_type"
|
[appliedDocumentType]="documentForm.value.document_type"
|
||||||
[appliedStoragePath]="documentForm.value.storage_path"
|
[appliedStoragePath]="documentForm.value.storage_path"
|
||||||
(getSuggestions)="getSuggestions($event)"
|
(getSuggestions)="getSuggestions()"
|
||||||
(sourceChange)="suggestionSourceOverride.set($event)"
|
|
||||||
(addTag)="createTag($event)"
|
(addTag)="createTag($event)"
|
||||||
(addDocumentType)="createDocumentType($event)"
|
(addDocumentType)="createDocumentType($event)"
|
||||||
(addCorrespondent)="createCorrespondent($event)">
|
(addCorrespondent)="createCorrespondent($event)">
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import {
|
|||||||
} from 'src/app/data/filter-rule-type'
|
} from 'src/app/data/filter-rule-type'
|
||||||
import { StoragePath } from 'src/app/data/storage-path'
|
import { StoragePath } from 'src/app/data/storage-path'
|
||||||
import { Tag } from 'src/app/data/tag'
|
import { Tag } from 'src/app/data/tag'
|
||||||
import { SETTINGS_KEYS, SuggestionSource } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
||||||
@@ -1528,113 +1528,6 @@ describe('DocumentDetailComponent', () => {
|
|||||||
expect(component.suggestionsLoading()).toBeFalsy()
|
expect(component.suggestionsLoading()).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should get and merge ML and AI suggestions when source is both', () => {
|
|
||||||
settingsService.set(
|
|
||||||
SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE,
|
|
||||||
SuggestionSource.Both
|
|
||||||
)
|
|
||||||
const getSetting = settingsService.get.bind(settingsService)
|
|
||||||
jest
|
|
||||||
.spyOn(settingsService, 'get')
|
|
||||||
.mockImplementation((key) =>
|
|
||||||
key === SETTINGS_KEYS.AI_ENABLED ? true : getSetting(key)
|
|
||||||
)
|
|
||||||
const suggestionsSpy = jest
|
|
||||||
.spyOn(documentService, 'getSuggestions')
|
|
||||||
.mockReturnValue(of({ tags: [42], dates: ['2024-01-01'] }))
|
|
||||||
const aiSuggestionsSpy = jest
|
|
||||||
.spyOn(documentService, 'getAiSuggestions')
|
|
||||||
.mockReturnValue(
|
|
||||||
of({ title: 'AI title', tags: [42, 43], suggested_tags: ['New'] })
|
|
||||||
)
|
|
||||||
initNormally()
|
|
||||||
expect(suggestionsSpy).toHaveBeenCalled()
|
|
||||||
expect(aiSuggestionsSpy).toHaveBeenCalled()
|
|
||||||
expect(component.suggestions().title).toEqual('AI title')
|
|
||||||
expect(component.suggestions().tags).toEqual([42, 43])
|
|
||||||
expect(component.suggestions().suggested_tags).toEqual(['New'])
|
|
||||||
expect(component.suggestions().dates).toEqual(['2024-01-01'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should only fetch sources not yet fetched for the document', () => {
|
|
||||||
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST, false)
|
|
||||||
settingsService.set(
|
|
||||||
SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE,
|
|
||||||
SuggestionSource.ML
|
|
||||||
)
|
|
||||||
const getSetting = settingsService.get.bind(settingsService)
|
|
||||||
jest
|
|
||||||
.spyOn(settingsService, 'get')
|
|
||||||
.mockImplementation((key) =>
|
|
||||||
key === SETTINGS_KEYS.AI_ENABLED ? true : getSetting(key)
|
|
||||||
)
|
|
||||||
const suggestionsSpy = jest
|
|
||||||
.spyOn(documentService, 'getSuggestions')
|
|
||||||
.mockReturnValue(of({ tags: [42] }))
|
|
||||||
const aiSuggestionsSpy = jest
|
|
||||||
.spyOn(documentService, 'getAiSuggestions')
|
|
||||||
.mockReturnValue(of({ tags: [43] }))
|
|
||||||
initNormally()
|
|
||||||
|
|
||||||
component.getSuggestions()
|
|
||||||
expect(suggestionsSpy).toHaveBeenCalledTimes(1)
|
|
||||||
expect(aiSuggestionsSpy).not.toHaveBeenCalled()
|
|
||||||
|
|
||||||
component.getSuggestions(SuggestionSource.Both)
|
|
||||||
expect(suggestionsSpy).toHaveBeenCalledTimes(1)
|
|
||||||
expect(aiSuggestionsSpy).toHaveBeenCalledTimes(1)
|
|
||||||
expect(component.suggestions().tags).toEqual([42, 43])
|
|
||||||
|
|
||||||
component.getSuggestions(SuggestionSource.Both)
|
|
||||||
expect(suggestionsSpy).toHaveBeenCalledTimes(1)
|
|
||||||
expect(aiSuggestionsSpy).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should use the per-document source override and reset it on document change', () => {
|
|
||||||
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST, false)
|
|
||||||
const getSetting = settingsService.get.bind(settingsService)
|
|
||||||
jest
|
|
||||||
.spyOn(settingsService, 'get')
|
|
||||||
.mockImplementation((key) =>
|
|
||||||
key === SETTINGS_KEYS.AI_ENABLED ? true : getSetting(key)
|
|
||||||
)
|
|
||||||
initNormally()
|
|
||||||
expect(component.suggestionSource).toEqual(SuggestionSource.AI)
|
|
||||||
component.suggestionSourceOverride.set(SuggestionSource.ML)
|
|
||||||
expect(component.suggestionSource).toEqual(SuggestionSource.ML)
|
|
||||||
|
|
||||||
jest
|
|
||||||
.spyOn(documentService, 'get')
|
|
||||||
.mockReturnValueOnce(of(Object.assign({}, doc)))
|
|
||||||
;(component as any).loadDocument(doc.id, true)
|
|
||||||
expect(component.suggestionSourceOverride()).toBeNull()
|
|
||||||
expect(component.fetchedSuggestionSources()).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should keep suggestions from one source if the other fails', () => {
|
|
||||||
settingsService.set(
|
|
||||||
SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE,
|
|
||||||
SuggestionSource.Both
|
|
||||||
)
|
|
||||||
const getSetting = settingsService.get.bind(settingsService)
|
|
||||||
jest
|
|
||||||
.spyOn(settingsService, 'get')
|
|
||||||
.mockImplementation((key) =>
|
|
||||||
key === SETTINGS_KEYS.AI_ENABLED ? true : getSetting(key)
|
|
||||||
)
|
|
||||||
const errorSpy = jest.spyOn(toastService, 'showError')
|
|
||||||
jest
|
|
||||||
.spyOn(documentService, 'getSuggestions')
|
|
||||||
.mockReturnValue(of({ tags: [42] }))
|
|
||||||
jest
|
|
||||||
.spyOn(documentService, 'getAiSuggestions')
|
|
||||||
.mockReturnValue(throwError(() => new Error('failed')))
|
|
||||||
initNormally()
|
|
||||||
expect(errorSpy).toHaveBeenCalled()
|
|
||||||
expect(component.suggestions().tags).toEqual([42])
|
|
||||||
expect(component.fetchedSuggestionSources()).toEqual([SuggestionSource.ML])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should show error if needed for get suggestions', () => {
|
it('should show error if needed for get suggestions', () => {
|
||||||
const suggestionsSpy = jest.spyOn(documentService, 'getSuggestions')
|
const suggestionsSpy = jest.spyOn(documentService, 'getSuggestions')
|
||||||
const errorSpy = jest.spyOn(toastService, 'showError')
|
const errorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms'
|
import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { DeviceDetectorService } from 'ngx-device-detector'
|
import { DeviceDetectorService } from 'ngx-device-detector'
|
||||||
import { BehaviorSubject, merge, Observable, of, Subject, timer } from 'rxjs'
|
import { BehaviorSubject, Observable, of, Subject, timer } from 'rxjs'
|
||||||
import {
|
import {
|
||||||
catchError,
|
catchError,
|
||||||
debounceTime,
|
debounceTime,
|
||||||
@@ -48,10 +48,7 @@ import { DataType } from 'src/app/data/datatype'
|
|||||||
import { Document, DocumentVersionInfo } from 'src/app/data/document'
|
import { Document, DocumentVersionInfo } from 'src/app/data/document'
|
||||||
import { DocumentMetadata } from 'src/app/data/document-metadata'
|
import { DocumentMetadata } from 'src/app/data/document-metadata'
|
||||||
import { DocumentNote } from 'src/app/data/document-note'
|
import { DocumentNote } from 'src/app/data/document-note'
|
||||||
import {
|
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
|
||||||
DocumentSuggestions,
|
|
||||||
mergeSuggestions,
|
|
||||||
} from 'src/app/data/document-suggestions'
|
|
||||||
import { DocumentType } from 'src/app/data/document-type'
|
import { DocumentType } from 'src/app/data/document-type'
|
||||||
import { FilterRule } from 'src/app/data/filter-rule'
|
import { FilterRule } from 'src/app/data/filter-rule'
|
||||||
import {
|
import {
|
||||||
@@ -66,7 +63,7 @@ import {
|
|||||||
import { ObjectWithId } from 'src/app/data/object-with-id'
|
import { ObjectWithId } from 'src/app/data/object-with-id'
|
||||||
import { StoragePath } from 'src/app/data/storage-path'
|
import { StoragePath } from 'src/app/data/storage-path'
|
||||||
import { Tag } from 'src/app/data/tag'
|
import { Tag } from 'src/app/data/tag'
|
||||||
import { SETTINGS_KEYS, SuggestionSource } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { User } from 'src/app/data/user'
|
import { User } from 'src/app/data/user'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
@@ -243,10 +240,6 @@ export class DocumentDetailComponent
|
|||||||
private readonly autoSuggestSetting = this.settings.getSignal<boolean>(
|
private readonly autoSuggestSetting = this.settings.getSignal<boolean>(
|
||||||
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST
|
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST
|
||||||
)
|
)
|
||||||
private readonly suggestionSourceSetting =
|
|
||||||
this.settings.getSignal<SuggestionSource>(
|
|
||||||
SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE
|
|
||||||
)
|
|
||||||
private readonly hiddenFieldsSetting = this.settings.getSignal<
|
private readonly hiddenFieldsSetting = this.settings.getSignal<
|
||||||
DocumentDetailFieldID[]
|
DocumentDetailFieldID[]
|
||||||
>(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS)
|
>(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS)
|
||||||
@@ -268,9 +261,6 @@ export class DocumentDetailComponent
|
|||||||
readonly metadata = signal<DocumentMetadata>(undefined)
|
readonly metadata = signal<DocumentMetadata>(undefined)
|
||||||
readonly suggestions = signal<DocumentSuggestions>(undefined)
|
readonly suggestions = signal<DocumentSuggestions>(undefined)
|
||||||
readonly suggestionsLoading = signal(false)
|
readonly suggestionsLoading = signal(false)
|
||||||
// per-document, resets on navigation
|
|
||||||
readonly suggestionSourceOverride = signal<SuggestionSource>(null)
|
|
||||||
readonly fetchedSuggestionSources = signal<SuggestionSource[]>([])
|
|
||||||
readonly users = signal<User[]>(undefined)
|
readonly users = signal<User[]>(undefined)
|
||||||
|
|
||||||
readonly title = signal<string>(undefined)
|
readonly title = signal<string>(undefined)
|
||||||
@@ -375,15 +365,6 @@ export class DocumentDetailComponent
|
|||||||
return this.autoSuggestSetting()
|
return this.autoSuggestSetting()
|
||||||
}
|
}
|
||||||
|
|
||||||
get defaultSuggestionSource(): SuggestionSource {
|
|
||||||
return this.aiEnabled ? this.suggestionSourceSetting() : SuggestionSource.ML
|
|
||||||
}
|
|
||||||
|
|
||||||
get suggestionSource(): SuggestionSource {
|
|
||||||
if (!this.aiEnabled) return SuggestionSource.ML
|
|
||||||
return this.suggestionSourceOverride() ?? this.defaultSuggestionSource
|
|
||||||
}
|
|
||||||
|
|
||||||
get archiveContentRenderType(): ContentRenderType {
|
get archiveContentRenderType(): ContentRenderType {
|
||||||
const hasArchiveVersion =
|
const hasArchiveVersion =
|
||||||
this.metadata()?.has_archive_version ??
|
this.metadata()?.has_archive_version ??
|
||||||
@@ -609,8 +590,6 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
this.documentId.set(doc.id)
|
this.documentId.set(doc.id)
|
||||||
this.suggestions.set(null)
|
this.suggestions.set(null)
|
||||||
this.suggestionSourceOverride.set(null)
|
|
||||||
this.fetchedSuggestionSources.set([])
|
|
||||||
const openDocument = this.openDocumentService.getOpenDocument(
|
const openDocument = this.openDocumentService.getOpenDocument(
|
||||||
this.documentId()
|
this.documentId()
|
||||||
)
|
)
|
||||||
@@ -1098,44 +1077,29 @@ export class DocumentDetailComponent
|
|||||||
return this.documentForm.get('custom_fields') as FormArray
|
return this.documentForm.get('custom_fields') as FormArray
|
||||||
}
|
}
|
||||||
|
|
||||||
getSuggestions(source: SuggestionSource = this.suggestionSource) {
|
getSuggestions() {
|
||||||
const sources = (
|
|
||||||
source === SuggestionSource.Both
|
|
||||||
? [SuggestionSource.ML, SuggestionSource.AI]
|
|
||||||
: [source]
|
|
||||||
).filter((s) => !this.fetchedSuggestionSources().includes(s))
|
|
||||||
if (!sources.length) return
|
|
||||||
|
|
||||||
this.suggestionsLoading.set(true)
|
this.suggestionsLoading.set(true)
|
||||||
merge(
|
const suggestionsObservable = this.aiEnabled
|
||||||
...sources.map((s) =>
|
? this.documentsService.getAiSuggestions(this.documentId())
|
||||||
(s === SuggestionSource.AI
|
: this.documentsService.getSuggestions(this.documentId())
|
||||||
? this.documentsService.getAiSuggestions(this.documentId())
|
suggestionsObservable
|
||||||
: this.documentsService.getSuggestions(this.documentId())
|
|
||||||
).pipe(
|
|
||||||
first(),
|
|
||||||
map((result) => ({ source: s, result })),
|
|
||||||
catchError((error) => {
|
|
||||||
this.toastService.showError(
|
|
||||||
$localize`Error retrieving suggestions.`,
|
|
||||||
error
|
|
||||||
)
|
|
||||||
return of(null)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.pipe(
|
.pipe(
|
||||||
|
first(),
|
||||||
takeUntil(this.unsubscribeNotifier),
|
takeUntil(this.unsubscribeNotifier),
|
||||||
takeUntil(this.docChangeNotifier),
|
takeUntil(this.docChangeNotifier),
|
||||||
finalize(() => this.suggestionsLoading.set(false))
|
finalize(() => this.suggestionsLoading.set(false))
|
||||||
)
|
)
|
||||||
.subscribe((response) => {
|
.subscribe({
|
||||||
if (!response) return
|
next: (result) => {
|
||||||
this.fetchedSuggestionSources.update((f) => [...f, response.source])
|
this.suggestions.set(result)
|
||||||
this.suggestions.set(
|
},
|
||||||
mergeSuggestions(this.suggestions(), response.result)
|
error: (error) => {
|
||||||
)
|
this.suggestions.set(null)
|
||||||
|
this.toastService.showError(
|
||||||
|
$localize`Error retrieving suggestions.`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -146,19 +146,6 @@ describe('DocumentListComponent', () => {
|
|||||||
expect(reloadSpy).toHaveBeenCalled()
|
expect(reloadSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should stop reloading on document deleted after destroy', () => {
|
|
||||||
const reloadSpy = jest.spyOn(documentListService, 'reload')
|
|
||||||
const documentDeletedSubject = new Subject<boolean>()
|
|
||||||
jest
|
|
||||||
.spyOn(websocketStatusService, 'onDocumentDeleted')
|
|
||||||
.mockReturnValue(documentDeletedSubject)
|
|
||||||
fixture.detectChanges()
|
|
||||||
fixture.destroy()
|
|
||||||
reloadSpy.mockClear()
|
|
||||||
documentDeletedSubject.next(true)
|
|
||||||
expect(reloadSpy).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should show score sort fields on fulltext queries', () => {
|
it('should show score sort fields on fulltext queries', () => {
|
||||||
documentListService.setFilterRules([
|
documentListService.setFilterRules([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -270,12 +270,9 @@ export class DocumentListComponent
|
|||||||
this.list.reload()
|
this.list.reload()
|
||||||
})
|
})
|
||||||
|
|
||||||
this.websocketStatusService
|
this.websocketStatusService.onDocumentDeleted().subscribe(() => {
|
||||||
.onDocumentDeleted()
|
this.list.reload()
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
})
|
||||||
.subscribe(() => {
|
|
||||||
this.list.reload()
|
|
||||||
})
|
|
||||||
|
|
||||||
this.route.paramMap
|
this.route.paramMap
|
||||||
.pipe(
|
.pipe(
|
||||||
|
|||||||
@@ -15,33 +15,3 @@ export interface DocumentSuggestions {
|
|||||||
|
|
||||||
dates?: string[] // ISO-formatted date string e.g. 2022-11-03
|
dates?: string[] // ISO-formatted date string e.g. 2022-11-03
|
||||||
}
|
}
|
||||||
|
|
||||||
const union = <T>(a: T[] = [], b: T[] = []): T[] => [...new Set([...a, ...b])]
|
|
||||||
|
|
||||||
export function mergeSuggestions(
|
|
||||||
a: DocumentSuggestions,
|
|
||||||
b: DocumentSuggestions
|
|
||||||
): DocumentSuggestions {
|
|
||||||
if (!a) return b
|
|
||||||
return {
|
|
||||||
title: a.title || b.title,
|
|
||||||
tags: union(a.tags, b.tags),
|
|
||||||
suggested_tags: union(a.suggested_tags, b.suggested_tags),
|
|
||||||
correspondents: union(a.correspondents, b.correspondents),
|
|
||||||
suggested_correspondents: union(
|
|
||||||
a.suggested_correspondents,
|
|
||||||
b.suggested_correspondents
|
|
||||||
),
|
|
||||||
document_types: union(a.document_types, b.document_types),
|
|
||||||
suggested_document_types: union(
|
|
||||||
a.suggested_document_types,
|
|
||||||
b.suggested_document_types
|
|
||||||
),
|
|
||||||
storage_paths: union(a.storage_paths, b.storage_paths),
|
|
||||||
suggested_storage_paths: union(
|
|
||||||
a.suggested_storage_paths,
|
|
||||||
b.suggested_storage_paths
|
|
||||||
),
|
|
||||||
dates: union(a.dates, b.dates),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -353,14 +353,6 @@ export const PaperlessConfigOptions: ConfigOption[] = [
|
|||||||
config_key: 'PAPERLESS_AI_LLM_EMBEDDING_MODEL',
|
config_key: 'PAPERLESS_AI_LLM_EMBEDDING_MODEL',
|
||||||
category: ConfigCategory.AI,
|
category: ConfigCategory.AI,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'llm_embedding_api_key',
|
|
||||||
title: $localize`LLM Embedding API Key`,
|
|
||||||
type: ConfigOptionType.Password,
|
|
||||||
note: $localize`Used for embeddings when set, otherwise LLM API key is used.`,
|
|
||||||
config_key: 'PAPERLESS_AI_LLM_EMBEDDING_API_KEY',
|
|
||||||
category: ConfigCategory.AI,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'llm_embedding_endpoint',
|
key: 'llm_embedding_endpoint',
|
||||||
title: $localize`LLM Embedding Endpoint`,
|
title: $localize`LLM Embedding Endpoint`,
|
||||||
@@ -465,7 +457,6 @@ export interface PaperlessConfig extends ObjectWithId {
|
|||||||
ai_enabled: boolean
|
ai_enabled: boolean
|
||||||
llm_embedding_backend: string
|
llm_embedding_backend: string
|
||||||
llm_embedding_model: string
|
llm_embedding_model: string
|
||||||
llm_embedding_api_key: string
|
|
||||||
llm_embedding_endpoint: string
|
llm_embedding_endpoint: string
|
||||||
llm_embedding_chunk_size: number
|
llm_embedding_chunk_size: number
|
||||||
llm_context_size: number
|
llm_context_size: number
|
||||||
|
|||||||
@@ -20,12 +20,6 @@ export enum GlobalSearchType {
|
|||||||
TITLE_CONTENT = 'title-content',
|
TITLE_CONTENT = 'title-content',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum SuggestionSource {
|
|
||||||
ML = 'ml',
|
|
||||||
AI = 'ai',
|
|
||||||
Both = 'both',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum CollapsibleSection {
|
export enum CollapsibleSection {
|
||||||
ATTRIBUTES = 'attributes',
|
ATTRIBUTES = 'attributes',
|
||||||
}
|
}
|
||||||
@@ -104,8 +98,6 @@ export const SETTINGS_KEYS = {
|
|||||||
'general-settings:document-editing:overlay-thumbnail',
|
'general-settings:document-editing:overlay-thumbnail',
|
||||||
DOCUMENT_EDITING_AUTO_SUGGEST:
|
DOCUMENT_EDITING_AUTO_SUGGEST:
|
||||||
'general-settings:document-editing:auto-suggest',
|
'general-settings:document-editing:auto-suggest',
|
||||||
DOCUMENT_EDITING_SUGGESTION_SOURCE:
|
|
||||||
'general-settings:document-editing:suggestion-source',
|
|
||||||
DOCUMENT_DETAILS_HIDDEN_FIELDS:
|
DOCUMENT_DETAILS_HIDDEN_FIELDS:
|
||||||
'general-settings:document-details:hidden-fields',
|
'general-settings:document-details:hidden-fields',
|
||||||
SEARCH_DB_ONLY: 'general-settings:search:db-only',
|
SEARCH_DB_ONLY: 'general-settings:search:db-only',
|
||||||
@@ -334,11 +326,6 @@ export const SETTINGS: UiSetting[] = [
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE,
|
|
||||||
type: 'string',
|
|
||||||
default: SuggestionSource.AI,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS,
|
key: SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS,
|
||||||
type: 'array',
|
type: 'array',
|
||||||
|
|||||||
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
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
@@ -74,7 +74,6 @@ import {
|
|||||||
clipboardCheckFill,
|
clipboardCheckFill,
|
||||||
clipboardFill,
|
clipboardFill,
|
||||||
clockHistory,
|
clockHistory,
|
||||||
cpu,
|
|
||||||
creditCard,
|
creditCard,
|
||||||
dash,
|
dash,
|
||||||
dashCircle,
|
dashCircle,
|
||||||
@@ -119,7 +118,6 @@ import {
|
|||||||
infoCircle,
|
infoCircle,
|
||||||
journalBookmarkFill,
|
journalBookmarkFill,
|
||||||
journals,
|
journals,
|
||||||
lightbulb,
|
|
||||||
link,
|
link,
|
||||||
list,
|
list,
|
||||||
listNested,
|
listNested,
|
||||||
@@ -324,7 +322,6 @@ const icons = {
|
|||||||
clipboardCheckFill,
|
clipboardCheckFill,
|
||||||
clipboardFill,
|
clipboardFill,
|
||||||
clockHistory,
|
clockHistory,
|
||||||
cpu,
|
|
||||||
cash,
|
cash,
|
||||||
creditCard,
|
creditCard,
|
||||||
dash,
|
dash,
|
||||||
@@ -370,7 +367,6 @@ const icons = {
|
|||||||
infoCircle,
|
infoCircle,
|
||||||
journalBookmarkFill,
|
journalBookmarkFill,
|
||||||
journals,
|
journals,
|
||||||
lightbulb,
|
|
||||||
link,
|
link,
|
||||||
list,
|
list,
|
||||||
listNested,
|
listNested,
|
||||||
|
|||||||
@@ -292,7 +292,6 @@ a.btn-link:active,
|
|||||||
a.btn-link:focus-visible,
|
a.btn-link:focus-visible,
|
||||||
.btn-link:hover,
|
.btn-link:hover,
|
||||||
.btn-link:active,
|
.btn-link:active,
|
||||||
.btn-link.show,
|
|
||||||
.btn-link:focus-visible {
|
.btn-link:focus-visible {
|
||||||
color: var(--pngx-primary-lighten-10) !important;
|
color: var(--pngx-primary-lighten-10) !important;
|
||||||
.primary-light & {
|
.primary-light & {
|
||||||
|
|||||||
@@ -25,20 +25,10 @@ export class PDFFindController {
|
|||||||
onIsPageVisible?: () => boolean
|
onIsPageVisible?: () => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LinkTarget = {
|
|
||||||
NONE: 0,
|
|
||||||
SELF: 1,
|
|
||||||
BLANK: 2,
|
|
||||||
PARENT: 3,
|
|
||||||
TOP: 4,
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PDFLinkService {
|
export class PDFLinkService {
|
||||||
private document?: unknown
|
private document?: unknown
|
||||||
private viewer?: unknown
|
private viewer?: unknown
|
||||||
|
|
||||||
constructor(readonly options: Record<string, unknown> = {}) {}
|
|
||||||
|
|
||||||
setDocument(document: unknown): void {
|
setDocument(document: unknown): void {
|
||||||
this.document = document
|
this.document = document
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ if TYPE_CHECKING:
|
|||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from paperless_testing.dirs import PaperlessDirs
|
from paperless_testing.dirs import PaperlessDirs
|
||||||
from paperless_testing.fakes.progress import FakeProgressManager
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session", autouse=True)
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
@@ -32,18 +31,6 @@ def faker_session_locale() -> str:
|
|||||||
return "en_US"
|
return "en_US"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _fast_password_hasher(settings: Settings) -> None:
|
|
||||||
"""Hash test passwords with MD5 instead of Django's default PBKDF2.
|
|
||||||
|
|
||||||
PBKDF2 is deliberately slow, and every ``admin_user`` or
|
|
||||||
``create_superuser`` call pays for it: about 600 ms each. No test depends
|
|
||||||
on the hash format, only on ``check_password`` and on the stored value
|
|
||||||
changing when the password does.
|
|
||||||
"""
|
|
||||||
settings.PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _clear_content_type_caches() -> None:
|
def _clear_content_type_caches() -> None:
|
||||||
"""Clear Django's ContentType cache and guardian's lru_cache before each test.
|
"""Clear Django's ContentType cache and guardian's lru_cache before each test.
|
||||||
@@ -137,15 +124,3 @@ def user_client(rest_api_client: APIClient, regular_user: User) -> APIClient:
|
|||||||
rest_api_client.force_authenticate(user=regular_user)
|
rest_api_client.force_authenticate(user=regular_user)
|
||||||
rest_api_client.credentials(HTTP_ACCEPT="application/json; version=10")
|
rest_api_client.credentials(HTTP_ACCEPT="application/json; version=10")
|
||||||
return rest_api_client
|
return rest_api_client
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def fake_progress_manager(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> type[FakeProgressManager]:
|
|
||||||
"""Replace documents.tasks.ProgressManager with the fake, so consuming a file
|
|
||||||
in a test never tries to reach a broker."""
|
|
||||||
from paperless_testing.fakes.progress import FakeProgressManager
|
|
||||||
|
|
||||||
monkeypatch.setattr("documents.tasks.ProgressManager", FakeProgressManager)
|
|
||||||
return FakeProgressManager
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class DocumentsConfig(AppConfig):
|
|||||||
document_consumption_finished.connect(set_document_type)
|
document_consumption_finished.connect(set_document_type)
|
||||||
document_consumption_finished.connect(set_tags)
|
document_consumption_finished.connect(set_tags)
|
||||||
document_consumption_finished.connect(set_storage_path)
|
document_consumption_finished.connect(set_storage_path)
|
||||||
|
document_consumption_finished.connect(add_to_index)
|
||||||
document_consumption_finished.connect(run_workflows_added)
|
document_consumption_finished.connect(run_workflows_added)
|
||||||
document_consumption_finished.connect(add_to_index)
|
document_consumption_finished.connect(add_to_index)
|
||||||
document_consumption_finished.connect(add_or_update_document_in_llm_index)
|
document_consumption_finished.connect(add_or_update_document_in_llm_index)
|
||||||
|
|||||||
@@ -857,9 +857,8 @@ class ConsumerPlugin(
|
|||||||
self.log.debug(f"Creation date from parse_date: {create_date}")
|
self.log.debug(f"Creation date from parse_date: {create_date}")
|
||||||
else:
|
else:
|
||||||
stats = Path(self.input_doc.original_file).stat()
|
stats = Path(self.input_doc.original_file).stat()
|
||||||
create_date = datetime.datetime.fromtimestamp(
|
create_date = timezone.make_aware(
|
||||||
stats.st_mtime,
|
datetime.datetime.fromtimestamp(stats.st_mtime),
|
||||||
tz=timezone.get_current_timezone(),
|
|
||||||
)
|
)
|
||||||
self.log.debug(f"Creation date from st_mtime: {create_date}")
|
self.log.debug(f"Creation date from st_mtime: {create_date}")
|
||||||
|
|
||||||
|
|||||||
@@ -196,49 +196,52 @@ class WriteBatch:
|
|||||||
return self._raw_writer
|
return self._raw_writer
|
||||||
|
|
||||||
def __enter__(self) -> Self:
|
def __enter__(self) -> Self:
|
||||||
lock_path = self._backend._path / ".tantivy.lock"
|
if self._backend._path is not None:
|
||||||
self._lock = filelock.FileLock(str(lock_path))
|
lock_path = self._backend._path / ".tantivy.lock"
|
||||||
for attempt in range(_LOCK_RETRY_ATTEMPTS):
|
self._lock = filelock.FileLock(str(lock_path))
|
||||||
try:
|
for attempt in range(_LOCK_RETRY_ATTEMPTS):
|
||||||
self._lock.acquire(timeout=self._lock_timeout)
|
try:
|
||||||
break
|
self._lock.acquire(timeout=self._lock_timeout)
|
||||||
except filelock.Timeout:
|
break
|
||||||
if attempt == _LOCK_RETRY_ATTEMPTS - 1:
|
except filelock.Timeout:
|
||||||
raise SearchIndexLockError(
|
if attempt == _LOCK_RETRY_ATTEMPTS - 1:
|
||||||
f"Could not acquire index lock after {_LOCK_RETRY_ATTEMPTS} "
|
raise SearchIndexLockError(
|
||||||
f"attempts (timeout={self._lock_timeout}s each)",
|
f"Could not acquire index lock after {_LOCK_RETRY_ATTEMPTS} "
|
||||||
|
f"attempts (timeout={self._lock_timeout}s each)",
|
||||||
|
)
|
||||||
|
sleep_s = random.uniform(
|
||||||
|
0,
|
||||||
|
min(_LOCK_BACKOFF_CAP, _LOCK_BACKOFF_BASE * (2**attempt)),
|
||||||
)
|
)
|
||||||
sleep_s = random.uniform(
|
logger.debug(
|
||||||
0,
|
"Index lock contention; retrying in %.2fs (attempt %d/%d)",
|
||||||
min(_LOCK_BACKOFF_CAP, _LOCK_BACKOFF_BASE * (2**attempt)),
|
sleep_s,
|
||||||
)
|
attempt + 1,
|
||||||
logger.debug(
|
_LOCK_RETRY_ATTEMPTS,
|
||||||
"Index lock contention; retrying in %.2fs (attempt %d/%d)",
|
)
|
||||||
sleep_s,
|
time.sleep(sleep_s)
|
||||||
attempt + 1,
|
|
||||||
_LOCK_RETRY_ATTEMPTS,
|
|
||||||
)
|
|
||||||
time.sleep(sleep_s)
|
|
||||||
|
|
||||||
# Open a fresh Index (and thus a fresh Tantivy ManagedDirectory)
|
# Open a fresh Index (and thus a fresh Tantivy ManagedDirectory)
|
||||||
# for the write, rather than reusing the process-local cached
|
# for the write, rather than reusing the process-local cached
|
||||||
# index. ManagedDirectory loads its GC bookkeeping (.managed.json)
|
# index. ManagedDirectory loads its GC bookkeeping (.managed.json)
|
||||||
# once, at construction, and never re-reads it; paperless runs
|
# once, at construction, and never re-reads it; paperless runs
|
||||||
# several long-lived processes (Granian workers, Celery workers)
|
# several long-lived processes (Granian workers, Celery workers)
|
||||||
# that take turns writing under the file lock above. A cached,
|
# that take turns writing under the file lock above. A cached,
|
||||||
# long-lived writer index would carry a stale managed-files view
|
# long-lived writer index would carry a stale managed-files view
|
||||||
# and, on commit, overwrite .managed.json with that stale view -
|
# and, on commit, overwrite .managed.json with that stale view -
|
||||||
# permanently losing track of segment files other processes
|
# permanently losing track of segment files other processes
|
||||||
# registered in the meantime, so they can never be garbage
|
# registered in the meantime, so they can never be garbage
|
||||||
# collected. Reopening fresh here always picks up the current
|
# collected. Reopening fresh here always picks up the current
|
||||||
# on-disk state. The long-lived self._backend._index is used for
|
# on-disk state. The long-lived self._backend._index is used for
|
||||||
# reads only and is reloaded (not reopened) after commit below.
|
# reads only and is reloaded (not reopened) after commit below.
|
||||||
write_index = tantivy.Index(
|
write_index = tantivy.Index(
|
||||||
build_schema(),
|
build_schema(),
|
||||||
path=str(self._backend._path),
|
path=str(self._backend._path),
|
||||||
)
|
)
|
||||||
register_tokenizers(write_index, settings.SEARCH_LANGUAGE)
|
register_tokenizers(write_index, settings.SEARCH_LANGUAGE)
|
||||||
self._raw_writer = write_index.writer()
|
self._raw_writer = write_index.writer()
|
||||||
|
else:
|
||||||
|
self._raw_writer = self._backend._index.writer()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
@@ -369,8 +372,9 @@ class TantivyBackend:
|
|||||||
Tantivy search backend with explicit lifecycle management.
|
Tantivy search backend with explicit lifecycle management.
|
||||||
|
|
||||||
Provides full-text search capabilities using the Tantivy search engine.
|
Provides full-text search capabilities using the Tantivy search engine.
|
||||||
Keeps a persistent on-disk index. Handles document indexing, search queries,
|
Supports in-memory indexes (for testing) and persistent on-disk indexes
|
||||||
autocompletion, and "more like this" functionality.
|
(for production use). Handles document indexing, search queries, autocompletion,
|
||||||
|
and "more like this" functionality.
|
||||||
|
|
||||||
The backend manages its own connection lifecycle and can be reset when
|
The backend manages its own connection lifecycle and can be reset when
|
||||||
the underlying index directory changes (e.g., during test isolation).
|
the underlying index directory changes (e.g., during test isolation).
|
||||||
@@ -404,7 +408,9 @@ class TantivyBackend:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, path: Path):
|
def __init__(self, path: Path | None = None):
|
||||||
|
# path=None → in-memory index (for tests)
|
||||||
|
# path=some_dir → on-disk index (for production)
|
||||||
self._path = path
|
self._path = path
|
||||||
self._raw_index: tantivy.Index | None = None
|
self._raw_index: tantivy.Index | None = None
|
||||||
self._raw_schema: tantivy.Schema | None = None
|
self._raw_schema: tantivy.Schema | None = None
|
||||||
@@ -423,13 +429,16 @@ class TantivyBackend:
|
|||||||
"""
|
"""
|
||||||
Open or rebuild the index as needed.
|
Open or rebuild the index as needed.
|
||||||
|
|
||||||
Checks if rebuilding is needed due to schema version or language
|
For disk-based indexes, checks if rebuilding is needed due to schema
|
||||||
changes. Registers custom tokenizers after opening.
|
version or language changes. Registers custom tokenizers after opening.
|
||||||
Safe to call multiple times - subsequent calls are no-ops.
|
Safe to call multiple times - subsequent calls are no-ops.
|
||||||
"""
|
"""
|
||||||
if self._raw_index is not None:
|
if self._raw_index is not None:
|
||||||
return # pragma: no cover
|
return # pragma: no cover
|
||||||
self._raw_index = open_or_rebuild_index(self._path)
|
if self._path is not None:
|
||||||
|
self._raw_index = open_or_rebuild_index(self._path)
|
||||||
|
else:
|
||||||
|
self._raw_index = tantivy.Index(build_schema())
|
||||||
register_tokenizers(self._raw_index, settings.SEARCH_LANGUAGE)
|
register_tokenizers(self._raw_index, settings.SEARCH_LANGUAGE)
|
||||||
self._raw_schema = self._raw_index.schema
|
self._raw_schema = self._raw_index.schema
|
||||||
|
|
||||||
@@ -1093,9 +1102,13 @@ class TantivyBackend:
|
|||||||
writer's threads). Larger values buffer more docs in RAM before
|
writer's threads). Larger values buffer more docs in RAM before
|
||||||
flushing a segment, deferring merge work; they do not avoid it.
|
flushing a segment, deferring merge work; they do not avoid it.
|
||||||
"""
|
"""
|
||||||
wipe_index(self._path)
|
# Create new index (on-disk or in-memory)
|
||||||
new_index = tantivy.Index(build_schema(), path=str(self._path))
|
if self._path is not None:
|
||||||
_write_sentinels(self._path)
|
wipe_index(self._path)
|
||||||
|
new_index = tantivy.Index(build_schema(), path=str(self._path))
|
||||||
|
_write_sentinels(self._path)
|
||||||
|
else:
|
||||||
|
new_index = tantivy.Index(build_schema())
|
||||||
register_tokenizers(new_index, settings.SEARCH_LANGUAGE)
|
register_tokenizers(new_index, settings.SEARCH_LANGUAGE)
|
||||||
|
|
||||||
# Point instance at the new index so _build_tantivy_doc uses it
|
# Point instance at the new index so _build_tantivy_doc uses it
|
||||||
|
|||||||
@@ -1079,16 +1079,6 @@ class DocumentSerializer(
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_page_count(self, obj) -> int | None:
|
def get_page_count(self, obj) -> int | None:
|
||||||
# Like content versions get their own page count from the newest version,
|
|
||||||
# use the prefetched versions cache to avoid an extra query
|
|
||||||
prefetched_cache = getattr(obj, "_prefetched_objects_cache", None)
|
|
||||||
prefetched_versions = (
|
|
||||||
prefetched_cache.get("versions")
|
|
||||||
if isinstance(prefetched_cache, dict)
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
if obj.root_document_id is None and prefetched_versions:
|
|
||||||
return sort_versions_newest_first(prefetched_versions)[0].page_count
|
|
||||||
return obj.page_count
|
return obj.page_count
|
||||||
|
|
||||||
@extend_schema_field(DuplicateDocumentSummarySerializer(many=True))
|
@extend_schema_field(DuplicateDocumentSummarySerializer(many=True))
|
||||||
@@ -2108,8 +2098,6 @@ class BulkEditSerializer(
|
|||||||
if not isinstance(parameters["pages"], str):
|
if not isinstance(parameters["pages"], str):
|
||||||
raise serializers.ValidationError("invalid pages specified")
|
raise serializers.ValidationError("invalid pages specified")
|
||||||
page_count = Document.objects.get(id=document_id).page_count
|
page_count = Document.objects.get(id=document_id).page_count
|
||||||
if not page_count:
|
|
||||||
raise serializers.ValidationError("document page count is unknown")
|
|
||||||
pages = []
|
pages = []
|
||||||
for group in parameters["pages"].split(","):
|
for group in parameters["pages"].split(","):
|
||||||
start, is_range, end = group.partition("-")
|
start, is_range, end = group.partition("-")
|
||||||
@@ -2119,7 +2107,7 @@ class BulkEditSerializer(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise serializers.ValidationError("invalid pages specified") from e
|
raise serializers.ValidationError("invalid pages specified") from e
|
||||||
# Bound the range before building it, a huge one would exhaust memory
|
# Bound the range before building it, a huge one would exhaust memory
|
||||||
if not 1 <= first <= last <= page_count:
|
if not 1 <= first <= last or (page_count and last > page_count):
|
||||||
raise serializers.ValidationError("invalid pages specified")
|
raise serializers.ValidationError("invalid pages specified")
|
||||||
pages.append(list(range(first, last + 1)))
|
pages.append(list(range(first, last + 1)))
|
||||||
parameters["pages"] = pages
|
parameters["pages"] = pages
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ from documents.permissions import get_objects_for_user_owner_aware
|
|||||||
from documents.plugins.helpers import DocumentsStatusManager
|
from documents.plugins.helpers import DocumentsStatusManager
|
||||||
from documents.templating.utils import convert_format_str_to_template_format
|
from documents.templating.utils import convert_format_str_to_template_format
|
||||||
from documents.utils import compute_checksum
|
from documents.utils import compute_checksum
|
||||||
from documents.utils import copy_file_with_basic_stats
|
|
||||||
from documents.workflows.actions import build_workflow_action_context
|
from documents.workflows.actions import build_workflow_action_context
|
||||||
from documents.workflows.actions import execute_email_action
|
from documents.workflows.actions import execute_email_action
|
||||||
from documents.workflows.actions import execute_move_to_trash_action
|
from documents.workflows.actions import execute_move_to_trash_action
|
||||||
@@ -364,11 +363,7 @@ def cleanup_document_deletion(sender, instance, **kwargs) -> None:
|
|||||||
|
|
||||||
logger.debug(f"Moving {instance.source_path} to trash at {new_file_path}")
|
logger.debug(f"Moving {instance.source_path} to trash at {new_file_path}")
|
||||||
try:
|
try:
|
||||||
shutil.move(
|
shutil.move(instance.source_path, new_file_path)
|
||||||
instance.source_path,
|
|
||||||
new_file_path,
|
|
||||||
copy_function=copy_file_with_basic_stats,
|
|
||||||
)
|
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Failed to move {instance.source_path} to trash at "
|
f"Failed to move {instance.source_path} to trash at "
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<meta name="robots" content="noindex,nofollow">
|
<meta name="robots" content="noindex,nofollow">
|
||||||
<meta name="author" content="The Paperless-ngx Team">
|
<meta name="author" content="The Paperless-ngx Team">
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
<link rel="manifest" href="{% static webmanifest %}" crossorigin="use-credentials">
|
<link rel="manifest" href="{% static webmanifest %}">
|
||||||
<link rel="stylesheet" href="{% static styles_css %}">
|
<link rel="stylesheet" href="{% static styles_css %}">
|
||||||
<link rel="apple-touch-icon" href="{% static apple_touch_icon %}">
|
<link rel="apple-touch-icon" href="{% static apple_touch_icon %}">
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user