mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-06 16:20:30 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f7ed1d4a7 | ||
|
|
0428bf6955 | ||
|
|
662a5237a3 | ||
|
|
562c2d0985 | ||
|
|
6cdb93cd59 | ||
|
|
85fcf66691 | ||
|
|
73b2321e87 | ||
|
|
5eecd6971e | ||
|
|
bc92e4ff98 | ||
|
|
dacc213917 | ||
|
|
c780586c72 | ||
|
|
62cc31fbdb | ||
|
|
312684aef3 | ||
|
|
1d42d372a2 | ||
|
|
08c4f3d3aa | ||
|
|
8adbff1423 | ||
|
|
7c11be81ca | ||
|
|
3e46186440 | ||
|
|
6ce43be400 | ||
|
|
0204009b66 | ||
|
|
cec0b99d2e | ||
|
|
3615a5a2a5 | ||
|
|
56687614df | ||
|
|
f1ae9c00ac | ||
|
|
bc2ff953ed | ||
|
|
e17689a91b | ||
|
|
764d1a492d | ||
|
|
9d50a3b261 | ||
|
|
da3de299fc | ||
|
|
3a022e2dc4 | ||
|
|
58bda48682 | ||
|
|
b2a2e15ead | ||
|
|
e865185425 | ||
|
|
d02c19a09e | ||
|
|
4a106454ce | ||
|
|
4254100fae | ||
|
|
74f727b1f3 | ||
|
|
fb6fad952e | ||
|
|
f9b4e0c8e0 | ||
|
|
e5041097af | ||
|
|
61f36eb253 | ||
|
|
e50bcc2efb | ||
|
|
88000aab45 | ||
|
|
01b4ab392c | ||
|
|
52aa8758e3 | ||
|
|
4b7cc1c0c3 | ||
|
|
f02d7455be | ||
|
|
a61c35fcd8 | ||
|
|
64552d5d5b | ||
|
|
a59edf3e5b | ||
|
|
6a5d06ee26 | ||
|
|
126ec414a8 | ||
|
|
773744d04b | ||
|
|
7b98085b31 | ||
|
|
b3bf220f56 | ||
|
|
d4429c3cc7 | ||
|
|
4f777d7438 | ||
|
|
2a44d8b5ba | ||
|
|
abf5050ea7 | ||
|
|
091ddf7c45 | ||
|
|
c9f7f2cfbe | ||
|
|
b457610ffb | ||
|
|
969c2ea0e2 | ||
|
|
31b806a285 | ||
|
|
99851b418c | ||
|
|
e34eda07bb | ||
|
|
793459b416 | ||
|
|
04297fd02c | ||
|
|
1b277dd8e1 | ||
|
|
c63afb47b2 | ||
|
|
3c20abeb4c | ||
|
|
b11f1f8459 | ||
|
|
7424e7ce0b | ||
|
|
a53a3d3769 | ||
|
|
15b73b890c | ||
|
|
02d355061f | ||
|
|
d8b5b4d447 | ||
|
|
03ac4aed7e | ||
|
|
90d23bad9c | ||
|
|
e4367b5648 | ||
|
|
cb85441c2f | ||
|
|
cceaa559d4 | ||
|
|
a748d4c64f | ||
|
|
452ed005bd | ||
|
|
40058ff7d5 | ||
|
|
f502cd5e34 | ||
|
|
99ce6b5db3 | ||
|
|
2d955e9697 | ||
|
|
f440e8d33c | ||
|
|
4c264651e8 | ||
|
|
48d97b78bb | ||
|
|
659a0cb2ef | ||
|
|
d02d1e1711 | ||
|
|
3b41810e7b | ||
|
|
12314fcaa8 | ||
|
|
8705bd510a |
No files matched your search
@@ -15,6 +15,8 @@
|
|||||||
# Test related
|
# Test related
|
||||||
**/.pytest_cache
|
**/.pytest_cache
|
||||||
**/tests
|
**/tests
|
||||||
|
src/paperless_testing
|
||||||
|
src/conftest.py
|
||||||
**/*.spec.ts
|
**/*.spec.ts
|
||||||
**/htmlcov
|
**/htmlcov
|
||||||
# Local folders
|
# Local folders
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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,7 +59,6 @@ updates:
|
|||||||
- "drf-*"
|
- "drf-*"
|
||||||
- "djangorestframework"
|
- "djangorestframework"
|
||||||
- "whitenoise"
|
- "whitenoise"
|
||||||
- "bleach"
|
|
||||||
- "jinja2"
|
- "jinja2"
|
||||||
# Async, Task Queuing & Caching
|
# Async, Task Queuing & Caching
|
||||||
async-tasks:
|
async-tasks:
|
||||||
|
|||||||
@@ -102,17 +102,16 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: "${{ matrix.python-version }}"
|
python-version: "${{ matrix.python-version }}"
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.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: 10
|
timeout-minutes: 12
|
||||||
run: |
|
uses: $/.github/actions/apt-install
|
||||||
sudo apt-get update -qq
|
with:
|
||||||
sudo apt-get install -qq --no-install-recommends \
|
packages: unpaper tesseract-ocr imagemagick ghostscript poppler-utils
|
||||||
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
|
||||||
@@ -139,13 +138,13 @@ jobs:
|
|||||||
pytest
|
pytest
|
||||||
- name: Upload test results to Codecov
|
- name: Upload test results to Codecov
|
||||||
if: always()
|
if: always()
|
||||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
|
||||||
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@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
|
||||||
with:
|
with:
|
||||||
flags: backend-python-${{ matrix.python-version }}
|
flags: backend-python-${{ matrix.python-version }}
|
||||||
files: coverage.xml
|
files: coverage.xml
|
||||||
@@ -176,7 +175,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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.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@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||||
- 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@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.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@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||||
- 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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.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@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
|
||||||
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@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
|
||||||
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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||||
with:
|
with:
|
||||||
version: '0.12.x'
|
version: '0.12.x'
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
@@ -231,9 +231,10 @@ 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
|
||||||
run: |
|
timeout-minutes: 12
|
||||||
apt-get update
|
uses: $/.github/actions/apt-install
|
||||||
apt-get install --yes --quiet --no-install-recommends libmagic1
|
with:
|
||||||
|
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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
@@ -70,9 +70,10 @@ 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
|
||||||
run: |
|
timeout-minutes: 12
|
||||||
sudo apt-get update -qq
|
uses: $/.github/actions/apt-install
|
||||||
sudo apt-get install -qq --no-install-recommends gettext liblept5
|
with:
|
||||||
|
packages: gettext liblept5
|
||||||
# ---- Build Documentation ----
|
# ---- Build Documentation ----
|
||||||
- name: Build documentation
|
- name: Build documentation
|
||||||
env:
|
env:
|
||||||
@@ -212,7 +213,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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.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@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||||
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@4fe057d991d63b8f6d5d22c40f17c1bca2226537 # v0.12.0
|
uses: stumpylog/image-cleaner-action/ephemeral@21f875bab2376314e0525c614e923c669434f0e4 # v0.13.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@4fe057d991d63b8f6d5d22c40f17c1bca2226537 # v0.12.0
|
uses: stumpylog/image-cleaner-action/untagged@21f875bab2376314e0525c614e923c669434f0e4 # v0.13.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@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||||
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@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||||
@@ -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@0d5670f539973aea2f01abce61a8989934df0025 # v3.0.2
|
uses: crowdin/github-action@9c23991700c0ec5256fd41089b9d9d7d540e424e # v3.3.0
|
||||||
with:
|
with:
|
||||||
upload_translations: false
|
upload_translations: false
|
||||||
download_translations: true
|
download_translations: true
|
||||||
|
|||||||
@@ -25,11 +25,12 @@ 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
|
||||||
run: |
|
timeout-minutes: 12
|
||||||
sudo apt-get update -qq
|
uses: $/.github/actions/apt-install
|
||||||
sudo apt-get install -qq --no-install-recommends gettext
|
with:
|
||||||
|
packages: gettext
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
|
|||||||
+706
-456
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.6'
|
rev: 'v3.9.9'
|
||||||
hooks:
|
hooks:
|
||||||
- id: prettier
|
- id: prettier
|
||||||
types_or:
|
types_or:
|
||||||
@@ -46,16 +46,16 @@ repos:
|
|||||||
- ts
|
- ts
|
||||||
- markdown
|
- markdown
|
||||||
additional_dependencies:
|
additional_dependencies:
|
||||||
- prettier@3.9.6
|
- prettier@3.9.9
|
||||||
- '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.5
|
rev: v0.16.9
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff-check
|
- id: ruff-check
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
- repo: https://github.com/tox-dev/pyproject-fmt
|
- repo: https://github.com/tox-dev/pyproject-fmt
|
||||||
rev: "v2.28.1"
|
rev: "v2.29.4"
|
||||||
hooks:
|
hooks:
|
||||||
- id: pyproject-fmt
|
- id: pyproject-fmt
|
||||||
additional_dependencies: [tomli]
|
additional_dependencies: [tomli]
|
||||||
|
|||||||
+4804
-12941
File diff suppressed because it is too large.
Load diff
+4
-2
@@ -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.16-python3.14-trixie-slim AS s6-overlay-base
|
FROM ghcr.io/astral-sh/uv:0.12.23-python3.14-trixie-slim AS s6-overlay-base
|
||||||
|
|
||||||
WORKDIR /usr/src/s6
|
WORKDIR /usr/src/s6
|
||||||
|
|
||||||
@@ -171,7 +171,9 @@ 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.13
|
image: docker.io/greenmail/standalone:2.1.14
|
||||||
hostname: greenmail
|
hostname: greenmail
|
||||||
container_name: greenmail
|
container_name: greenmail
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
Executable
+18
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Source s6 container environment for interactive shells.
|
||||||
|
# Ensures variables resolved from *_FILE secret injection are visible
|
||||||
|
# when using 'docker exec bash'. Does not affect s6 services (those
|
||||||
|
# use with-contenv directly). Has no effect in non-container contexts
|
||||||
|
# because the directory will not exist.
|
||||||
|
# Note: sh/dash shells opened via 'docker exec sh' are not covered;
|
||||||
|
# only bash-based sessions benefit from this file.
|
||||||
|
_pngx_contenv="/run/s6/container_environment"
|
||||||
|
if [ -d "${_pngx_contenv}" ]; then
|
||||||
|
for _pngx_f in "${_pngx_contenv}"/*; do
|
||||||
|
[ -f "${_pngx_f}" ] || continue
|
||||||
|
_pngx_name=$(basename "${_pngx_f}")
|
||||||
|
_pngx_val=$(cat "${_pngx_f}")
|
||||||
|
export "${_pngx_name}=${_pngx_val}"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
unset _pngx_contenv _pngx_f _pngx_name _pngx_val
|
||||||
+27
-9
@@ -136,13 +136,15 @@ 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. This is **opt-in per request**
|
storage path and dates by sending the document to the LLM using "Suggest" button on the document
|
||||||
and surfaces through the "Suggest" control on the document detail page, alongside the
|
detail page. You can choose which type of suggestions are requested by default under Settings >
|
||||||
classic classifier-based suggestions — it does not disable them. Suggestions are requested
|
Documents, either ML (classifier-based) suggestions, AI suggestions, or both. When both are requested
|
||||||
automatically when you open a document that carries an inbox tag unless "Automatically request
|
the results are combined.
|
||||||
suggestions for inbox documents" under Settings > Documents is disabled. Suggestion output
|
|
||||||
language can be steered with
|
Suggestions are requested automatically when you open a document that carries an inbox tag
|
||||||
[`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE`](configuration.md#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE)
|
unless "Automatically request suggestions for inbox documents" under Settings > Documents is disabled.
|
||||||
|
|
||||||
|
Suggestion output language can be steered with [`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE`](configuration.md#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE)
|
||||||
(otherwise it follows the user's UI language).
|
(otherwise it follows the user's UI language).
|
||||||
|
|
||||||
### The LLM index (RAG) and similar documents
|
### The LLM index (RAG) and similar documents
|
||||||
@@ -153,8 +155,11 @@ in similar existing documents, and the document chat can retrieve relevant conte
|
|||||||
|
|
||||||
Enable it by setting
|
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`). The index is only
|
(`huggingface` for fully-local embeddings, or `ollama` / `openai-like`). By default, the main
|
||||||
built when AI is enabled **and** an embedding backend is set.
|
LLM API key and endpoint are used, but an optional embedding-specific[API key](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_API_KEY)
|
||||||
|
and [endpoint](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT) can be configured.
|
||||||
|
|
||||||
|
The index is only built when AI is enabled **and** an embedding backend is set.
|
||||||
|
|
||||||
The index is updated automatically on a schedule controlled by
|
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
|
||||||
@@ -1005,6 +1010,19 @@ documents to both separate and categorize them in a single operation.
|
|||||||
**Example:** A 6-page scan with TAG:invoice on page 3 and TAG:receipt on page 5 will create
|
**Example:** A 6-page scan with TAG:invoice on page 3 and TAG:receipt on page 5 will create
|
||||||
three documents: pages 1-2 (no tags), pages 3-4 (tagged "invoice"), and pages 5-6 (tagged "receipt").
|
three documents: pages 1-2 (no tags), pages 3-4 (tagged "invoice"), and pages 5-6 (tagged "receipt").
|
||||||
|
|
||||||
|
### Barcode Contents {#barcode-contents}
|
||||||
|
|
||||||
|
By default, Paperless only uses barcodes for splitting, ASNs and tags. With
|
||||||
|
[`PAPERLESS_CONSUMER_STORE_BARCODE_VALUES`](configuration.md#PAPERLESS_CONSUMER_STORE_BARCODE_VALUES)
|
||||||
|
enabled, it stores the content of every barcode with the document, e.g. payment codes or QR codes.
|
||||||
|
|
||||||
|
- Barcodes are listed on the **Metadata** tab with page, type and content, and can be copied.
|
||||||
|
- The API returns them in the `barcodes` field of `/api/documents/{id}/metadata/`.
|
||||||
|
- They can be [searched](usage.md#searching-barcodes), e.g. `barcodes:DE89370400440532013000`.
|
||||||
|
- Only the first [`PAPERLESS_CONSUMER_BARCODE_MAX_PAGES`](configuration.md#PAPERLESS_CONSUMER_BARCODE_MAX_PAGES)
|
||||||
|
pages are scanned. Reprocessing reads the barcodes of existing documents.
|
||||||
|
- Each version keeps its own barcodes, and the newest version's are shown and searched.
|
||||||
|
|
||||||
## Automatic collation of double-sided documents {#collate}
|
## Automatic collation of double-sided documents {#collate}
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
|||||||
@@ -1,5 +1,30 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## paperless-ngx 3.2.1
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: only pass --conf to flower when flowerconfig.py exists [@bitfoo1](https://github.com/bitfoo1) ([#14182](https://github.com/paperless-ngx/paperless-ngx/pull/14182))
|
||||||
|
- Fix: replace stale mail-fetch overlap check with a self-expiring lock [@stumpylog](https://github.com/stumpylog) ([#14189](https://github.com/paperless-ngx/paperless-ngx/pull/14189))
|
||||||
|
- Fix: bump ocrmypdf to 17.12 to pick up the ligature text-layer fix [@stumpylog](https://github.com/stumpylog) ([#14190](https://github.com/paperless-ngx/paperless-ngx/pull/14190))
|
||||||
|
- Fix: rebuild the search index automatically when it is missing Tantivy files [@stumpylog](https://github.com/stumpylog) ([#14180](https://github.com/paperless-ngx/paperless-ngx/pull/14180))
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- Chore(deps): Bump anyio from 4.12.1 to 4.14.2 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#14175](https://github.com/paperless-ngx/paperless-ngx/pull/14175))
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>4 changes</summary>
|
||||||
|
|
||||||
|
- Chore(deps): Bump anyio from 4.12.1 to 4.14.2 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#14175](https://github.com/paperless-ngx/paperless-ngx/pull/14175))
|
||||||
|
- Fix: replace stale mail-fetch overlap check with a self-expiring lock [@stumpylog](https://github.com/stumpylog) ([#14189](https://github.com/paperless-ngx/paperless-ngx/pull/14189))
|
||||||
|
- Fix: bump ocrmypdf to 17.12 to pick up the ligature text-layer fix [@stumpylog](https://github.com/stumpylog) ([#14190](https://github.com/paperless-ngx/paperless-ngx/pull/14190))
|
||||||
|
- Fix: rebuild the search index automatically when it is missing Tantivy files [@stumpylog](https://github.com/stumpylog) ([#14180](https://github.com/paperless-ngx/paperless-ngx/pull/14180))
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## paperless-ngx 3.2.0
|
## paperless-ngx 3.2.0
|
||||||
|
|
||||||
### Features / Enhancements
|
### Features / Enhancements
|
||||||
|
|||||||
@@ -1796,6 +1796,13 @@ assigns or creates tags if a properly formatted barcode is detected.
|
|||||||
|
|
||||||
Defaults to false.
|
Defaults to false.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_CONSUMER_STORE_BARCODE_VALUES=<bool>`](#PAPERLESS_CONSUMER_STORE_BARCODE_VALUES) {#PAPERLESS_CONSUMER_STORE_BARCODE_VALUES}
|
||||||
|
|
||||||
|
: Stores the content of every barcode found during consumption, see
|
||||||
|
[Barcode Contents](advanced_usage.md#barcode-contents).
|
||||||
|
|
||||||
|
Defaults to false.
|
||||||
|
|
||||||
## Audit Trail
|
## Audit Trail
|
||||||
|
|
||||||
#### [`PAPERLESS_AUDIT_LOG_ENABLED=<bool>`](#PAPERLESS_AUDIT_LOG_ENABLED) {#PAPERLESS_AUDIT_LOG_ENABLED}
|
#### [`PAPERLESS_AUDIT_LOG_ENABLED=<bool>`](#PAPERLESS_AUDIT_LOG_ENABLED) {#PAPERLESS_AUDIT_LOG_ENABLED}
|
||||||
@@ -2133,6 +2140,13 @@ 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
|
||||||
@@ -2217,6 +2231,19 @@ used with the OpenAI-compatible backend to target a custom provider or local gat
|
|||||||
|
|
||||||
Defaults to true, which allows internal endpoints.
|
Defaults to true, which allows internal endpoints.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_AI_LLM_EXTRA_PARAMS=<json>`](#PAPERLESS_AI_LLM_EXTRA_PARAMS) {#PAPERLESS_AI_LLM_EXTRA_PARAMS}
|
||||||
|
|
||||||
|
: A JSON object of extra parameters sent with every LLM request, for providers that require a parameter Paperless does not
|
||||||
|
set itself. Values here override Paperless' own, and no validation is performed. Whatever you put here is passed to the
|
||||||
|
backend as-is, so an invalid parameter will simply be rejected by your provider. For example, current OpenAI reasoning
|
||||||
|
models refuse tool calls on the chat completions API unless reasoning is off:
|
||||||
|
|
||||||
|
```
|
||||||
|
PAPERLESS_AI_LLM_EXTRA_PARAMS={"reasoning_effort": "none"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Defaults to empty, which adds nothing to requests.
|
||||||
|
|
||||||
#### [`PAPERLESS_LLM_INDEX_TASK_CRON=<cron expression>`](#PAPERLESS_LLM_INDEX_TASK_CRON) {#PAPERLESS_LLM_INDEX_TASK_CRON}
|
#### [`PAPERLESS_LLM_INDEX_TASK_CRON=<cron expression>`](#PAPERLESS_LLM_INDEX_TASK_CRON) {#PAPERLESS_LLM_INDEX_TASK_CRON}
|
||||||
|
|
||||||
: Configures the schedule to update the AI embeddings of text content and metadata for all documents. Only performed if
|
: Configures the schedule to update the AI embeddings of text content and metadata for all documents. Only performed if
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ pnpm ng build --configuration production
|
|||||||
is loaded as well. However, the tests rely on the default
|
is loaded as well. However, the tests rely on the default
|
||||||
configuration. This is not ideal. But for now, make sure no settings
|
configuration. This is not ideal. But for now, make sure no settings
|
||||||
except for DEBUG are overridden when testing.
|
except for DEBUG are overridden when testing.
|
||||||
|
- Tests run in a random order each session, so that one test cannot quietly depend on another having run first. The seed is printed at the top of the run; pass `--randomly-seed=<seed>` to replay that exact order, or `--randomly-seed=last` to repeat the previous run.
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -76,7 +76,9 @@ is not supported by any of the available parsers.
|
|||||||
|
|
||||||
**A:** Not by default. As of v3, a file whose contents match an existing document is still
|
**A:** Not by default. As of v3, a file whose contents match an existing document is still
|
||||||
consumed, and the duplicate is flagged in the UI — open the document and check the
|
consumed, and the duplicate is flagged in the UI — open the document and check the
|
||||||
**Duplicates** tab to review documents that share the same content. If you prefer the old
|
**Duplicates** tab to review documents that share the same content, or filter the document
|
||||||
|
list by **Duplicates** to find all of them (see
|
||||||
|
[Duplicate documents](usage.md#duplicate-documents)). If you prefer the old
|
||||||
behavior of rejecting duplicates during consumption, set
|
behavior of rejecting duplicates during consumption, set
|
||||||
[`PAPERLESS_CONSUMER_DELETE_DUPLICATES`](configuration.md#PAPERLESS_CONSUMER_DELETE_DUPLICATES)
|
[`PAPERLESS_CONSUMER_DELETE_DUPLICATES`](configuration.md#PAPERLESS_CONSUMER_DELETE_DUPLICATES)
|
||||||
to `true`.
|
to `true`.
|
||||||
|
|||||||
+21
-8
@@ -299,19 +299,18 @@ for details.
|
|||||||
### Duplicate documents
|
### Duplicate documents
|
||||||
|
|
||||||
By default, Paperless-ngx **does not reject duplicates**. If you consume a file whose
|
By default, Paperless-ngx **does not reject duplicates**. If you consume a file whose
|
||||||
contents exactly match an existing document (same checksum), the new copy is still
|
contents match an existing document (same original or archive checksum), the new copy is
|
||||||
consumed and a warning is logged. The task entry for the upload also flags that a
|
still consumed and a warning is logged.
|
||||||
duplicate was detected and links to the existing document(s).
|
|
||||||
|
|
||||||
To review duplicates, open a document and switch to the **Duplicates** tab on the
|
When a document has duplicates, a **Duplicates** tab appears on its detail page, listing
|
||||||
document detail page. It lists other documents that share the same content, including any
|
the other documents you can view that share the same content (including any in the trash).
|
||||||
that are in the trash (shown with a badge), and links to each so you can decide which to
|
To find all documents with duplicates, choose **Duplicates** in the document list's text
|
||||||
keep.
|
filter dropdown, or use `has_duplicates=true` in the REST API.
|
||||||
|
|
||||||
If you would rather reject duplicates at consumption time (the pre-v3 behavior), set
|
If you would rather reject duplicates at consumption time (the pre-v3 behavior), set
|
||||||
[`PAPERLESS_CONSUMER_DELETE_DUPLICATES`](configuration.md#PAPERLESS_CONSUMER_DELETE_DUPLICATES)
|
[`PAPERLESS_CONSUMER_DELETE_DUPLICATES`](configuration.md#PAPERLESS_CONSUMER_DELETE_DUPLICATES)
|
||||||
to `true`. The duplicate file is then deleted instead of consumed, and the task fails with
|
to `true`. The duplicate file is then deleted instead of consumed, and the task fails with
|
||||||
a "document already exists" message.
|
a "Document already exists" message linking to the existing document.
|
||||||
|
|
||||||
## Document Suggestions
|
## Document Suggestions
|
||||||
|
|
||||||
@@ -1061,6 +1060,20 @@ notes.user:alice notes.note:insurance
|
|||||||
|
|
||||||
The bare `notes:` prefix is shorthand for `notes.note:`.
|
The bare `notes:` prefix is shorthand for `notes.note:`.
|
||||||
|
|
||||||
|
#### Searching barcodes
|
||||||
|
|
||||||
|
If [barcode contents are stored](advanced_usage.md#barcode-contents), they can be searched by
|
||||||
|
content or type, but only with a field name:
|
||||||
|
|
||||||
|
```
|
||||||
|
barcodes.value:DE89370400440532013000
|
||||||
|
barcodes.format:qrcode
|
||||||
|
barcodes:wifi barcodes:guest
|
||||||
|
```
|
||||||
|
|
||||||
|
`barcodes:` is shorthand for `barcodes.value:`. Separators are stripped, so each part of e.g.
|
||||||
|
`WIFI:S:Guest;P:secret;;` can be searched on its own.
|
||||||
|
|
||||||
All of these can be combined. Syntax not described here may not work as expected, and an unknown field name is searched as ordinary text.
|
All of these can be combined. Syntax not described here may not work as expected, and an unknown field name is searched as ordinary text.
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
|||||||
+25
-14
@@ -1,7 +1,9 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "paperless-ngx"
|
name = "paperless-ngx"
|
||||||
version = "3.2.1"
|
version = "3.3.0"
|
||||||
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
description = """\
|
||||||
|
A community-supported supercharged document management system: scan, index and archive all your physical documents\
|
||||||
|
"""
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
classifiers = [
|
classifiers = [
|
||||||
@@ -10,13 +12,13 @@ classifiers = [
|
|||||||
"Programming Language :: Python :: 3.12",
|
"Programming Language :: Python :: 3.12",
|
||||||
"Programming Language :: Python :: 3.13",
|
"Programming Language :: Python :: 3.13",
|
||||||
"Programming Language :: Python :: 3.14",
|
"Programming Language :: Python :: 3.14",
|
||||||
|
"Programming Language :: Python :: 3.15",
|
||||||
]
|
]
|
||||||
# TODO: Move certain things to groups and then utilize that further
|
# TODO: Move certain things to groups and then utilize that further
|
||||||
# This will allow testing to not install a webserver, mysql, etc
|
# This will allow testing to not install a webserver, mysql, etc
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"azure-ai-documentintelligence>=1.0.2",
|
"azure-ai-documentintelligence>=1.0.2",
|
||||||
"babel>=2.17",
|
"babel>=2.17",
|
||||||
"bleach~=6.4.0",
|
|
||||||
"celery[redis]~=5.6.2",
|
"celery[redis]~=5.6.2",
|
||||||
"channels~=4.2",
|
"channels~=4.2",
|
||||||
"channels-redis~=4.2",
|
"channels-redis~=4.2",
|
||||||
@@ -31,8 +33,8 @@ dependencies = [
|
|||||||
"django-compression-middleware~=0.5.0",
|
"django-compression-middleware~=0.5.0",
|
||||||
"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,<27",
|
||||||
"django-guardian>=3.3.3,<3.5",
|
"django-guardian>=3.3.3,<3.6",
|
||||||
"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",
|
||||||
@@ -42,7 +44,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.2",
|
"flower>=2.0.1,<2.3",
|
||||||
"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",
|
||||||
@@ -74,6 +76,7 @@ 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",
|
||||||
@@ -81,13 +84,13 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
mariadb = [
|
mariadb = [
|
||||||
"mysqlclient~=2.2.7",
|
"mysqlclient>=2.2.7,<2.4",
|
||||||
]
|
]
|
||||||
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.1",
|
"psycopg-pool==3.3.3",
|
||||||
]
|
]
|
||||||
webserver = [
|
webserver = [
|
||||||
"granian[uvloop]>=2.7,<2.9",
|
"granian[uvloop]>=2.7,<2.9",
|
||||||
@@ -109,7 +112,7 @@ lint = [
|
|||||||
testing = [
|
testing = [
|
||||||
"daphne",
|
"daphne",
|
||||||
"factory-boy~=3.3.1",
|
"factory-boy~=3.3.1",
|
||||||
"faker>=40.36,<40.39",
|
"faker>=40.36,<40.40",
|
||||||
"imagehash",
|
"imagehash",
|
||||||
"pytest~=9.1.1",
|
"pytest~=9.1.1",
|
||||||
"pytest-cov~=7.1.0",
|
"pytest-cov~=7.1.0",
|
||||||
@@ -117,7 +120,7 @@ testing = [
|
|||||||
"pytest-env~=1.7.0",
|
"pytest-env~=1.7.0",
|
||||||
"pytest-httpx",
|
"pytest-httpx",
|
||||||
"pytest-mock~=3.15.1",
|
"pytest-mock~=3.15.1",
|
||||||
# "pytest-randomly~=4.0.1",
|
"pytest-randomly~=5.0.0",
|
||||||
"pytest-rerunfailures~=16.4",
|
"pytest-rerunfailures~=16.4",
|
||||||
"pytest-sugar",
|
"pytest-sugar",
|
||||||
"pytest-xdist~=3.8.0",
|
"pytest-xdist~=3.8.0",
|
||||||
@@ -133,7 +136,6 @@ typing = [
|
|||||||
"mypy",
|
"mypy",
|
||||||
"mypy-baseline",
|
"mypy-baseline",
|
||||||
"pyrefly",
|
"pyrefly",
|
||||||
"types-bleach",
|
|
||||||
"types-channels",
|
"types-channels",
|
||||||
"types-colorama",
|
"types-colorama",
|
||||||
"types-dateparser",
|
"types-dateparser",
|
||||||
@@ -148,6 +150,7 @@ 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'",
|
||||||
@@ -244,6 +247,10 @@ 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]
|
||||||
@@ -271,9 +278,9 @@ plugins = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tool.pyrefly]
|
[tool.pyrefly]
|
||||||
baseline = ".pyrefly-baseline.json"
|
|
||||||
python-platform = "linux"
|
python-platform = "linux"
|
||||||
search-path = [ "src" ]
|
search-path = [ "src" ]
|
||||||
|
baseline = ".pyrefly-baseline.json"
|
||||||
|
|
||||||
[tool.django-stubs]
|
[tool.django-stubs]
|
||||||
django_settings_module = "paperless.settings"
|
django_settings_module = "paperless.settings"
|
||||||
@@ -326,6 +333,8 @@ 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 = [
|
||||||
@@ -334,13 +343,15 @@ source = [
|
|||||||
omit = [
|
omit = [
|
||||||
"*/tests/*",
|
"*/tests/*",
|
||||||
"manage.py",
|
"manage.py",
|
||||||
"paperless/wsgi.py",
|
|
||||||
"paperless/auth.py",
|
"paperless/auth.py",
|
||||||
|
"paperless/wsgi.py",
|
||||||
|
"src/conftest.py",
|
||||||
|
"src/paperless_testing/*",
|
||||||
]
|
]
|
||||||
[tool.coverage.report]
|
[tool.coverage.report]
|
||||||
exclude_also = [
|
exclude_also = [
|
||||||
"if settings.AUDIT_LOG_ENABLED:",
|
|
||||||
"if AUDIT_LOG_ENABLED:",
|
"if AUDIT_LOG_ENABLED:",
|
||||||
|
"if settings.AUDIT_LOG_ENABLED:",
|
||||||
"if TYPE_CHECKING:",
|
"if TYPE_CHECKING:",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,11 @@
|
|||||||
"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": [
|
||||||
|
|||||||
+364
-250
File diff suppressed because it is too large.
Load diff
+23
-23
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paperless-ngx-ui",
|
"name": "paperless-ngx-ui",
|
||||||
"version": "3.2.1",
|
"version": "3.3.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
@@ -15,16 +15,16 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^22.1.6",
|
"@angular/cdk": "^22.2.0",
|
||||||
"@angular/common": "~22.1.6",
|
"@angular/common": "~22.2.0",
|
||||||
"@angular/compiler": "~22.1.6",
|
"@angular/compiler": "~22.2.0",
|
||||||
"@angular/core": "~22.1.6",
|
"@angular/core": "~22.2.0",
|
||||||
"@angular/forms": "~22.1.6",
|
"@angular/forms": "~22.2.0",
|
||||||
"@angular/localize": "~22.1.6",
|
"@angular/localize": "~22.2.0",
|
||||||
"@angular/platform-browser": "~22.1.6",
|
"@angular/platform-browser": "~22.2.0",
|
||||||
"@angular/router": "~22.1.6",
|
"@angular/router": "~22.2.0",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
||||||
"@ng-select/ng-select": "~24.1.1",
|
"@ng-select/ng-select": "~24.1.2",
|
||||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
"@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.1.8",
|
"@angular-devkit/core": "^22.2.0",
|
||||||
"@angular-devkit/schematics": "^22.1.8",
|
"@angular-devkit/schematics": "^22.2.0",
|
||||||
"@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.1.8",
|
"@angular/build": "22.2.0",
|
||||||
"@angular/cli": "22.1.8",
|
"@angular/cli": "22.2.0",
|
||||||
"@angular/compiler-cli": "~22.1.6",
|
"@angular/compiler-cli": "~22.2.0",
|
||||||
"@playwright/test": "^1.62.1",
|
"@playwright/test": "^1.62.1",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^26.5.0",
|
"@types/node": "^26.6.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.70.0",
|
"@typescript-eslint/eslint-plugin": "^8.70.1",
|
||||||
"@typescript-eslint/parser": "^8.70.0",
|
"@typescript-eslint/parser": "^8.70.1",
|
||||||
"@typescript-eslint/utils": "^8.70.0",
|
"@typescript-eslint/utils": "^8.70.1",
|
||||||
"eslint": "^10.10.0",
|
"eslint": "^10.11.0",
|
||||||
"jest": "30.5.1",
|
"jest": "30.5.2",
|
||||||
"jest-environment-jsdom": "^30.5.1",
|
"jest-environment-jsdom": "^30.5.2",
|
||||||
"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.6",
|
"prettier": "^3.9.9",
|
||||||
"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
+1312
-1960
File diff suppressed because it is too large.
Load diff
@@ -253,6 +253,24 @@
|
|||||||
</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(34)
|
expect(setSpy).toHaveBeenCalledTimes(35)
|
||||||
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
|
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
|
||||||
HideableSidebarItemID.Workflows,
|
HideableSidebarItemID.Workflows,
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ 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'
|
||||||
@@ -184,6 +185,7 @@ 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),
|
||||||
@@ -217,6 +219,11 @@ 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) => ({
|
||||||
@@ -404,6 +411,9 @@ 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
|
||||||
),
|
),
|
||||||
@@ -625,6 +635,10 @@ 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,11 +154,29 @@
|
|||||||
& 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,7 +1,11 @@
|
|||||||
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 { PDFSinglePageViewer, PDFViewer } from 'pdfjs-dist/web/pdf_viewer.mjs'
|
import {
|
||||||
|
LinkTarget,
|
||||||
|
PDFSinglePageViewer,
|
||||||
|
PDFViewer,
|
||||||
|
} from 'pdfjs-dist/web/pdf_viewer.mjs'
|
||||||
import { PngxPdfViewerComponent } from './pdf-viewer.component'
|
import { PngxPdfViewerComponent } from './pdf-viewer.component'
|
||||||
import { PdfRenderMode, PdfZoomLevel, PdfZoomScale } from './pdf-viewer.types'
|
import { PdfRenderMode, PdfZoomLevel, PdfZoomScale } from './pdf-viewer.types'
|
||||||
|
|
||||||
@@ -58,6 +62,16 @@ 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')
|
||||||
@@ -76,6 +90,7 @@ 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,6 +21,7 @@ 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,
|
||||||
@@ -75,7 +76,11 @@ export class PngxPdfViewerComponent
|
|||||||
private lastViewerPage?: number
|
private lastViewerPage?: number
|
||||||
|
|
||||||
private readonly eventBus = new EventBus()
|
private readonly eventBus = new EventBus()
|
||||||
private readonly linkService = new PDFLinkService({ eventBus: this.eventBus })
|
private readonly linkService = new PDFLinkService({
|
||||||
|
eventBus: this.eventBus,
|
||||||
|
externalLinkTarget: LinkTarget.BLANK,
|
||||||
|
externalLinkRel: 'noopener noreferrer nofollow',
|
||||||
|
})
|
||||||
private readonly findController = new PDFFindController({
|
private readonly findController = new PDFFindController({
|
||||||
eventBus: this.eventBus,
|
eventBus: this.eventBus,
|
||||||
linkService: this.linkService,
|
linkService: this.linkService,
|
||||||
@@ -185,6 +190,7 @@ 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 {
|
||||||
|
|||||||
+77
-51
@@ -1,58 +1,84 @@
|
|||||||
<div class="btn-group">
|
<div class="d-flex align-items-center">
|
||||||
<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>
|
<div class="btn-group">
|
||||||
@if (loading()) {
|
<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>
|
||||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
@if (loading()) {
|
||||||
} @else if (noSuggestions) {
|
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||||
<i-bs width="1.2em" height="1.2em" name="check-circle"></i-bs>
|
} @else if (noSuggestions) {
|
||||||
} @else {
|
<i-bs width="1.2em" height="1.2em" name="check-circle"></i-bs>
|
||||||
<i-bs width="1.2em" height="1.2em" name="stars"></i-bs>
|
} @else {
|
||||||
|
<i-bs width="1.2em" height="1.2em" name="lightbulb"></i-bs>
|
||||||
|
}
|
||||||
|
@if (noSuggestions) {
|
||||||
|
<span class="d-none d-lg-inline ps-1" i18n>No suggestions</span>
|
||||||
|
} @else {
|
||||||
|
<span class="d-none d-lg-inline ps-1" i18n>Suggest</span>
|
||||||
|
}
|
||||||
|
@if (totalSuggestions > 0) {
|
||||||
|
<span class="badge bg-primary ms-2">{{ totalSuggestions }}</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (aiEnabled()) {
|
||||||
|
<div class="btn-group" ngbDropdown #dropdown="ngbDropdown" [popperOptions]="popperOptions">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary" ngbDropdownToggle [disabled]="disabled() || loading() || !suggestions()" aria-expanded="false" aria-controls="suggestionsDropdown" aria-label="Suggestions dropdown">
|
||||||
|
<span class="visually-hidden" i18n>Show suggestions</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
|
||||||
|
<div class="list-group list-group-flush small pb-0">
|
||||||
|
@if (novelSuggestions === 0 && fieldSuggestions === 0) {
|
||||||
|
<div class="list-group-item text-muted fst-italic">
|
||||||
|
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (suggestions()?.suggested_tags?.length > 0) {
|
||||||
|
<small class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="tags"></i-bs><ng-container i18n>Tags</ng-container></small>
|
||||||
|
@for (tag of suggestions().suggested_tags; track tag) {
|
||||||
|
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)">{{ tag }}</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (suggestions()?.suggested_document_types?.length > 0) {
|
||||||
|
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="hash"></i-bs><ng-container i18n>Document Types</ng-container></div>
|
||||||
|
@for (type of suggestions().suggested_document_types; track type) {
|
||||||
|
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)">{{ type }}</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (suggestions()?.suggested_correspondents?.length > 0) {
|
||||||
|
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="person"></i-bs><ng-container i18n>Correspondents</ng-container></div>
|
||||||
|
@for (correspondent of suggestions().suggested_correspondents; track correspondent) {
|
||||||
|
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (fieldSuggestions > 0) {
|
||||||
|
<div class="list-group-item text-muted fst-italic">
|
||||||
|
<small class="text-muted small fst-italic" i18n>{fieldSuggestions, plural, =1 {1 suggestion available below} other {{{fieldSuggestions}} suggestions available below}}</small>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
@if (noSuggestions) {
|
</div>
|
||||||
<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 class="btn-group" ngbDropdown #dropdown="ngbDropdown" [popperOptions]="popperOptions">
|
<div ngbDropdown autoClose="outside" placement="bottom-end" [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">
|
<button type="button" class="btn btn-sm btn-link position-relative" ngbDropdownToggle [disabled]="disabled() || loading()" i18n-title title="Suggestion options">
|
||||||
<span class="visually-hidden" i18n>Show suggestions</span>
|
<i-bs name="three-dots"></i-bs>
|
||||||
|
@if (source() !== defaultSource()) {
|
||||||
|
<span class="position-absolute top-0 start-100 translate-middle p-1 bg-primary border border-light rounded-circle">
|
||||||
|
<span class="visually-hidden" i18n>Not using default</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
</button>
|
</button>
|
||||||
|
<div ngbDropdownMenu class="shadow p-3">
|
||||||
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
|
<div class="small text-muted mb-2" i18n>Suggest using:</div>
|
||||||
<div class="list-group list-group-flush small pb-0">
|
<div class="form-check small">
|
||||||
@if (novelSuggestions === 0 && reusableSuggestions === 0) {
|
<input class="form-check-input" type="checkbox" id="suggestionSourceML" [checked]="useML" [disabled]="useML && !useAI" (change)="setSources($event.target.checked, useAI)">
|
||||||
<div class="list-group-item text-muted fst-italic">
|
<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>
|
||||||
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
|
</div>
|
||||||
</div>
|
<div class="form-check small">
|
||||||
}
|
<input class="form-check-input" type="checkbox" id="suggestionSourceAI" [checked]="useAI" [disabled]="useAI && !useML" (change)="setSources(useML, $event.target.checked)">
|
||||||
@if (suggestions()?.suggested_tags?.length > 0) {
|
<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>
|
||||||
<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,3 +1,7 @@
|
|||||||
.suggestions-dropdown {
|
.suggestions-dropdown {
|
||||||
min-width: 250px;
|
min-width: 250px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-link.dropdown-toggle::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
+121
-5
@@ -1,6 +1,7 @@
|
|||||||
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', () => {
|
||||||
@@ -83,7 +84,7 @@ describe('SuggestionsDropdownComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
expect(fixture.nativeElement.textContent).toContain(
|
expect(fixture.nativeElement.textContent).toContain(
|
||||||
'2 existing values suggested below'
|
'2 suggestions available below'
|
||||||
)
|
)
|
||||||
expect(fixture.nativeElement.textContent).not.toContain(
|
expect(fixture.nativeElement.textContent).not.toContain(
|
||||||
'No novel suggestions'
|
'No novel suggestions'
|
||||||
@@ -107,7 +108,7 @@ describe('SuggestionsDropdownComponent', () => {
|
|||||||
expect(component.totalSuggestions).toBe(4)
|
expect(component.totalSuggestions).toBe(4)
|
||||||
expect(fixture.nativeElement.textContent).toContain('Arbitration')
|
expect(fixture.nativeElement.textContent).toContain('Arbitration')
|
||||||
expect(fixture.nativeElement.textContent).toContain(
|
expect(fixture.nativeElement.textContent).toContain(
|
||||||
'2 existing values suggested below'
|
'2 suggestions available below'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -124,10 +125,46 @@ describe('SuggestionsDropdownComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(component.novelSuggestions).toBe(0)
|
expect(component.novelSuggestions).toBe(0)
|
||||||
expect(component.totalSuggestions).toBe(5)
|
expect(component.totalSuggestions).toBe(6)
|
||||||
|
|
||||||
fixture.componentRef.setInput('appliedStoragePath', 7)
|
fixture.componentRef.setInput('appliedStoragePath', 7)
|
||||||
expect(component.totalSuggestions).toBe(4)
|
expect(component.totalSuggestions).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should count title and dates as field suggestions without calling them existing values', () => {
|
||||||
|
fixture.componentRef.setInput('aiEnabled', true)
|
||||||
|
fixture.componentRef.setInput('fetchedSources', [SuggestionSource.ML])
|
||||||
|
fixture.componentRef.setInput('suggestions', {
|
||||||
|
title: 'Suggested title',
|
||||||
|
dates: ['2026-01-04', '2026-02-01', '2026-03-01'],
|
||||||
|
correspondents: [1, 2, 3, 4],
|
||||||
|
document_types: [1, 2, 3, 4],
|
||||||
|
tags: [1, 2, 3, 4, 5, 6, 7, 8],
|
||||||
|
})
|
||||||
|
fixture.detectChanges()
|
||||||
|
component.clickSuggest()
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(component.reusableSuggestions).toBe(16)
|
||||||
|
expect(component.fieldSuggestions).toBe(20)
|
||||||
|
expect(component.totalSuggestions).toBe(20)
|
||||||
|
expect(fixture.nativeElement.textContent).toContain(
|
||||||
|
'20 suggestions available below'
|
||||||
|
)
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('existing value')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not count title or date suggestions matching the current values', () => {
|
||||||
|
fixture.componentRef.setInput('suggestions', {
|
||||||
|
title: 'Current title',
|
||||||
|
dates: ['2026-01-04', '2026-02-01'],
|
||||||
|
})
|
||||||
|
expect(component.fieldSuggestions).toBe(3)
|
||||||
|
|
||||||
|
fixture.componentRef.setInput('appliedTitle', 'Current title')
|
||||||
|
fixture.componentRef.setInput('appliedCreated', '2026-01-04')
|
||||||
|
expect(component.fieldSuggestions).toBe(1)
|
||||||
|
expect(component.totalSuggestions).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should show when a completed request returned no suggestions', () => {
|
it('should show when a completed request returned no suggestions', () => {
|
||||||
@@ -144,6 +181,28 @@ describe('SuggestionsDropdownComponent', () => {
|
|||||||
expect(fixture.nativeElement.textContent).toContain('No suggestions')
|
expect(fixture.nativeElement.textContent).toContain('No suggestions')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should wait for all pending responses before showing the empty state', () => {
|
||||||
|
fixture.componentRef.setInput('aiEnabled', true)
|
||||||
|
fixture.componentRef.setInput('source', SuggestionSource.Both)
|
||||||
|
fixture.componentRef.setInput('fetchedSources', [SuggestionSource.ML])
|
||||||
|
fixture.componentRef.setInput('suggestions', { tags: [] })
|
||||||
|
fixture.componentRef.setInput('loading', true)
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(component.noSuggestions).toBeFalsy()
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('No suggestions')
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('[role="status"]')
|
||||||
|
).not.toBeNull()
|
||||||
|
|
||||||
|
fixture.componentRef.setInput('loading', false)
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(component.noSuggestions).toBeTruthy()
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('No suggestions')
|
||||||
|
expect(fixture.nativeElement.querySelector('[role="status"]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it('should not show the empty state before a request or with suggestions', () => {
|
it('should not show the empty state before a request or with suggestions', () => {
|
||||||
expect(component.noSuggestions).toBeFalsy()
|
expect(component.noSuggestions).toBeFalsy()
|
||||||
|
|
||||||
@@ -179,14 +238,71 @@ 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.open).toBeTruthy()
|
expect(component.dropdown.isOpen()).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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
+65
-4
@@ -8,6 +8,7 @@ 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({
|
||||||
@@ -18,21 +19,29 @@ 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)
|
||||||
readonly appliedDocumentType = input<number>(null)
|
readonly appliedDocumentType = input<number>(null)
|
||||||
readonly appliedStoragePath = input<number>(null)
|
readonly appliedStoragePath = input<number>(null)
|
||||||
|
readonly appliedTitle = input<string>(null)
|
||||||
|
readonly appliedCreated = input<string>(null)
|
||||||
|
|
||||||
@Output()
|
@Output()
|
||||||
getSuggestions: EventEmitter<SuggestionsDropdownComponent> =
|
getSuggestions: EventEmitter<SuggestionSource> = new EventEmitter()
|
||||||
new EventEmitter()
|
|
||||||
|
@Output()
|
||||||
|
sourceChange: EventEmitter<SuggestionSource> = new EventEmitter()
|
||||||
|
|
||||||
@Output()
|
@Output()
|
||||||
addTag: EventEmitter<string> = new EventEmitter()
|
addTag: EventEmitter<string> = new EventEmitter()
|
||||||
@@ -53,12 +62,42 @@ export class SuggestionsDropdownComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!this.suggestions()) {
|
if (!this.suggestions()) {
|
||||||
this.getSuggestions.emit(this)
|
this.getSuggestions.emit(this.source())
|
||||||
|
} else if (this.hasUnfetchedSources) {
|
||||||
|
// sources changed, fetch the rest and show what we have meanwhile
|
||||||
|
this.getSuggestions.emit(this.source())
|
||||||
|
this.dropdown?.open()
|
||||||
} else {
|
} 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) +
|
||||||
@@ -91,7 +130,28 @@ export class SuggestionsDropdownComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get totalSuggestions(): number {
|
get totalSuggestions(): number {
|
||||||
return this.novelSuggestions + this.reusableSuggestions
|
return this.novelSuggestions + this.fieldSuggestions
|
||||||
|
}
|
||||||
|
|
||||||
|
get fieldSuggestions(): number {
|
||||||
|
return (
|
||||||
|
this.reusableSuggestions +
|
||||||
|
this.unappliedTitleSuggestions +
|
||||||
|
this.unappliedDateSuggestions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hide a title or date suggestion equal to the current value
|
||||||
|
private get unappliedTitleSuggestions(): number {
|
||||||
|
const title = this.suggestions()?.title
|
||||||
|
return title && title !== this.appliedTitle() ? 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private get unappliedDateSuggestions(): number {
|
||||||
|
const created = this.appliedCreated()
|
||||||
|
return (this.suggestions()?.dates ?? []).filter(
|
||||||
|
(date) => !created || date !== created
|
||||||
|
).length
|
||||||
}
|
}
|
||||||
|
|
||||||
private countUnapplied(suggested: number[], applied: number[]): number {
|
private countUnapplied(suggested: number[], applied: number[]): number {
|
||||||
@@ -102,6 +162,7 @@ export class SuggestionsDropdownComponent {
|
|||||||
get noSuggestions(): boolean {
|
get noSuggestions(): boolean {
|
||||||
const suggestions = this.suggestions()
|
const suggestions = this.suggestions()
|
||||||
return (
|
return (
|
||||||
|
!this.loading() &&
|
||||||
suggestions != null &&
|
suggestions != null &&
|
||||||
!suggestions.title &&
|
!suggestions.title &&
|
||||||
!suggestions.tags?.length &&
|
!suggestions.tags?.length &&
|
||||||
|
|||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
<table class="table table-borderless align-baseline">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th i18n>Page</th>
|
||||||
|
<th i18n>Type</th>
|
||||||
|
<th i18n>Content</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (barcode of barcodes(); track $index) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ barcode.page }}</td>
|
||||||
|
<td class="text-nowrap">{{ barcode.format }}</td>
|
||||||
|
<td class="text-break">
|
||||||
|
@if (isLink(barcode.value)) {
|
||||||
|
<a
|
||||||
|
[href]="barcode.value"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer nofollow"
|
||||||
|
>{{ barcode.value }}</a
|
||||||
|
>
|
||||||
|
} @else {
|
||||||
|
{{ barcode.value }}
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-outline-primary"
|
||||||
|
(click)="copy($index)"
|
||||||
|
title="Copy content"
|
||||||
|
i18n-title
|
||||||
|
>
|
||||||
|
@if (copiedIndex() === $index) {
|
||||||
|
<i-bs name="clipboard-check"></i-bs>
|
||||||
|
} @else {
|
||||||
|
<i-bs name="clipboard"></i-bs>
|
||||||
|
}
|
||||||
|
<span class="visually-hidden" i18n>Copy content</span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
import { Clipboard } from '@angular/cdk/clipboard'
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { DocumentBarcodesComponent } from './document-barcodes.component'
|
||||||
|
|
||||||
|
const barcodes = [
|
||||||
|
{ page: 1, value: 'ASN00123', format: 'Code128' },
|
||||||
|
{ page: 2, value: 'https://example.com/invoice/4711', format: 'QRCode' },
|
||||||
|
{ page: 2, value: 'javascript:alert(1)', format: 'QRCode' },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('DocumentBarcodesComponent', () => {
|
||||||
|
let component: DocumentBarcodesComponent
|
||||||
|
let fixture: ComponentFixture<DocumentBarcodesComponent>
|
||||||
|
let clipboard: Clipboard
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
DocumentBarcodesComponent,
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
],
|
||||||
|
}).compileComponents()
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(DocumentBarcodesComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
clipboard = TestBed.inject(Clipboard)
|
||||||
|
fixture.componentRef.setInput('barcodes', barcodes)
|
||||||
|
fixture.detectChanges()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should display all barcodes', () => {
|
||||||
|
const rows = fixture.nativeElement.querySelectorAll('tbody tr')
|
||||||
|
expect(rows).toHaveLength(3)
|
||||||
|
expect(rows[0].textContent).toContain('ASN00123')
|
||||||
|
expect(rows[0].textContent).toContain('Code128')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should only link http(s) values', () => {
|
||||||
|
const links = fixture.nativeElement.querySelectorAll('tbody a')
|
||||||
|
expect(links).toHaveLength(1)
|
||||||
|
expect(links[0].getAttribute('href')).toEqual(
|
||||||
|
'https://example.com/invoice/4711'
|
||||||
|
)
|
||||||
|
expect(links[0].getAttribute('target')).toEqual('_blank')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should copy a value and show feedback', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
|
const copySpy = jest.spyOn(clipboard, 'copy').mockReturnValue(true)
|
||||||
|
const buttons = fixture.nativeElement.querySelectorAll('tbody button')
|
||||||
|
buttons[0].click()
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(copySpy).toHaveBeenCalledWith('ASN00123')
|
||||||
|
expect(component.copiedIndex()).toEqual(0)
|
||||||
|
expect(buttons[0].querySelector('i-bs').getAttribute('name')).toEqual(
|
||||||
|
'clipboard-check'
|
||||||
|
)
|
||||||
|
jest.advanceTimersByTime(3000)
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(component.copiedIndex()).toBeNull()
|
||||||
|
expect(buttons[0].querySelector('i-bs').getAttribute('name')).toEqual(
|
||||||
|
'clipboard'
|
||||||
|
)
|
||||||
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show feedback if copying failed', () => {
|
||||||
|
jest.spyOn(clipboard, 'copy').mockReturnValue(false)
|
||||||
|
component.copy(1)
|
||||||
|
expect(component.copiedIndex()).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
import { Clipboard } from '@angular/cdk/clipboard'
|
||||||
|
import { Component, inject, input, OnDestroy, signal } from '@angular/core'
|
||||||
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import { DocumentBarcode } from 'src/app/data/document-barcode'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-document-barcodes',
|
||||||
|
templateUrl: './document-barcodes.component.html',
|
||||||
|
imports: [NgxBootstrapIconsModule],
|
||||||
|
})
|
||||||
|
export class DocumentBarcodesComponent implements OnDestroy {
|
||||||
|
private readonly clipboard = inject(Clipboard)
|
||||||
|
|
||||||
|
readonly barcodes = input<DocumentBarcode[]>([])
|
||||||
|
|
||||||
|
readonly copiedIndex = signal<number>(null)
|
||||||
|
private copyTimeout: ReturnType<typeof setTimeout>
|
||||||
|
|
||||||
|
public isLink(value: string): boolean {
|
||||||
|
try {
|
||||||
|
const url = new URL(value.trim())
|
||||||
|
return ['http:', 'https:'].includes(url.protocol) && !!url.host
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public copy(index: number) {
|
||||||
|
if (!this.clipboard.copy(this.barcodes()[index].value)) return
|
||||||
|
this.copiedIndex.set(index)
|
||||||
|
clearTimeout(this.copyTimeout)
|
||||||
|
this.copyTimeout = setTimeout(() => this.copiedIndex.set(null), 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
clearTimeout(this.copyTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -134,11 +134,17 @@
|
|||||||
[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()"
|
[appliedTitle]="documentForm.value.title"
|
||||||
|
[appliedCreated]="documentForm.value.created"
|
||||||
|
(getSuggestions)="getSuggestions($event)"
|
||||||
|
(sourceChange)="suggestionSourceOverride.set($event)"
|
||||||
(addTag)="createTag($event)"
|
(addTag)="createTag($event)"
|
||||||
(addDocumentType)="createDocumentType($event)"
|
(addDocumentType)="createDocumentType($event)"
|
||||||
(addCorrespondent)="createCorrespondent($event)">
|
(addCorrespondent)="createCorrespondent($event)">
|
||||||
@@ -350,6 +356,10 @@
|
|||||||
</table>
|
</table>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (metadata()?.barcodes?.length > 0) {
|
||||||
|
<h6 i18n>Barcodes</h6>
|
||||||
|
<pngx-document-barcodes [barcodes]="metadata().barcodes"></pngx-document-barcodes>
|
||||||
|
}
|
||||||
@if (metadata()?.original_metadata?.length > 0) {
|
@if (metadata()?.original_metadata?.length > 0) {
|
||||||
<pngx-metadata-collapse i18n-title title="Original document metadata" [metadata]="metadata()?.original_metadata"></pngx-metadata-collapse>
|
<pngx-metadata-collapse i18n-title title="Original document metadata" [metadata]="metadata()?.original_metadata"></pngx-metadata-collapse>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import {
|
|||||||
} from 'src/app/data/filter-rule-type'
|
} 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 } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS, SuggestionSource } from 'src/app/data/ui-settings'
|
||||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
import { 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'
|
||||||
@@ -662,6 +662,45 @@ describe('DocumentDetailComponent', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['tag', 'createTag', 'tags', 'suggested_tags'],
|
||||||
|
[
|
||||||
|
'document type',
|
||||||
|
'createDocumentType',
|
||||||
|
'document_type',
|
||||||
|
'suggested_document_types',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'correspondent',
|
||||||
|
'createCorrespondent',
|
||||||
|
'correspondent',
|
||||||
|
'suggested_correspondents',
|
||||||
|
],
|
||||||
|
])(
|
||||||
|
'should create a %s after ML-only suggestions',
|
||||||
|
(_, method, field, suggestedField) => {
|
||||||
|
initNormally()
|
||||||
|
component.suggestions.set({ tags: [1] })
|
||||||
|
let openModal: NgbModalRef
|
||||||
|
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
||||||
|
component[method]('New value')
|
||||||
|
openModal.componentInstance.succeeded.next({
|
||||||
|
id: 12,
|
||||||
|
name: 'New value',
|
||||||
|
is_inbox_tag: false,
|
||||||
|
color: '#ff0000',
|
||||||
|
text_color: '#000000',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (field === 'tags') {
|
||||||
|
expect(component.tagsInput.value).toContain(12)
|
||||||
|
} else {
|
||||||
|
expect(component.documentForm.get(field).value).toBe(12)
|
||||||
|
}
|
||||||
|
expect(component.suggestions()[suggestedField]).toEqual([])
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
it('should support creating storage path', () => {
|
it('should support creating storage path', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
let openModal: NgbModalRef
|
let openModal: NgbModalRef
|
||||||
@@ -1528,6 +1567,113 @@ 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, Observable, of, Subject, timer } from 'rxjs'
|
import { BehaviorSubject, merge, Observable, of, Subject, timer } from 'rxjs'
|
||||||
import {
|
import {
|
||||||
catchError,
|
catchError,
|
||||||
debounceTime,
|
debounceTime,
|
||||||
@@ -48,7 +48,10 @@ 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 { DocumentSuggestions } from 'src/app/data/document-suggestions'
|
import {
|
||||||
|
DocumentSuggestions,
|
||||||
|
mergeSuggestions,
|
||||||
|
} from 'src/app/data/document-suggestions'
|
||||||
import { DocumentType } from 'src/app/data/document-type'
|
import { 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 {
|
||||||
@@ -63,7 +66,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 } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS, SuggestionSource } 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'
|
||||||
@@ -132,6 +135,7 @@ import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-li
|
|||||||
import { SuggestionsDropdownComponent } from '../common/suggestions-dropdown/suggestions-dropdown.component'
|
import { SuggestionsDropdownComponent } from '../common/suggestions-dropdown/suggestions-dropdown.component'
|
||||||
import { DocumentNotesComponent } from '../document-notes/document-notes.component'
|
import { DocumentNotesComponent } from '../document-notes/document-notes.component'
|
||||||
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
||||||
|
import { DocumentBarcodesComponent } from './document-barcodes/document-barcodes.component'
|
||||||
import { DocumentHistoryComponent } from './document-history/document-history.component'
|
import { DocumentHistoryComponent } from './document-history/document-history.component'
|
||||||
import { DocumentVersionDropdownComponent } from './document-version-dropdown/document-version-dropdown.component'
|
import { DocumentVersionDropdownComponent } from './document-version-dropdown/document-version-dropdown.component'
|
||||||
import { MetadataCollapseComponent } from './metadata-collapse/metadata-collapse.component'
|
import { MetadataCollapseComponent } from './metadata-collapse/metadata-collapse.component'
|
||||||
@@ -174,6 +178,7 @@ interface IncomingDocumentUpdate {
|
|||||||
DateComponent,
|
DateComponent,
|
||||||
DocumentLinkComponent,
|
DocumentLinkComponent,
|
||||||
MetadataCollapseComponent,
|
MetadataCollapseComponent,
|
||||||
|
DocumentBarcodesComponent,
|
||||||
PermissionsFormComponent,
|
PermissionsFormComponent,
|
||||||
SelectComponent,
|
SelectComponent,
|
||||||
TagsComponent,
|
TagsComponent,
|
||||||
@@ -240,6 +245,10 @@ 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)
|
||||||
@@ -261,6 +270,9 @@ 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)
|
||||||
@@ -365,6 +377,15 @@ 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 ??
|
||||||
@@ -590,6 +611,8 @@ 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()
|
||||||
)
|
)
|
||||||
@@ -1077,29 +1100,44 @@ export class DocumentDetailComponent
|
|||||||
return this.documentForm.get('custom_fields') as FormArray
|
return this.documentForm.get('custom_fields') as FormArray
|
||||||
}
|
}
|
||||||
|
|
||||||
getSuggestions() {
|
getSuggestions(source: SuggestionSource = this.suggestionSource) {
|
||||||
|
const sources = (
|
||||||
|
source === SuggestionSource.Both
|
||||||
|
? [SuggestionSource.ML, SuggestionSource.AI]
|
||||||
|
: [source]
|
||||||
|
).filter((s) => !this.fetchedSuggestionSources().includes(s))
|
||||||
|
if (!sources.length) return
|
||||||
|
|
||||||
this.suggestionsLoading.set(true)
|
this.suggestionsLoading.set(true)
|
||||||
const suggestionsObservable = this.aiEnabled
|
merge(
|
||||||
? this.documentsService.getAiSuggestions(this.documentId())
|
...sources.map((s) =>
|
||||||
: this.documentsService.getSuggestions(this.documentId())
|
(s === SuggestionSource.AI
|
||||||
suggestionsObservable
|
? this.documentsService.getAiSuggestions(this.documentId())
|
||||||
|
: this.documentsService.getSuggestions(this.documentId())
|
||||||
|
).pipe(
|
||||||
|
first(),
|
||||||
|
map((result) => ({ source: s, result })),
|
||||||
|
catchError((error) => {
|
||||||
|
this.toastService.showError(
|
||||||
|
$localize`Error retrieving suggestions.`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
return of(null)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
.pipe(
|
.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({
|
.subscribe((response) => {
|
||||||
next: (result) => {
|
if (!response) return
|
||||||
this.suggestions.set(result)
|
this.fetchedSuggestionSources.update((f) => [...f, response.source])
|
||||||
},
|
this.suggestions.set(
|
||||||
error: (error) => {
|
mergeSuggestions(this.suggestions(), response.result)
|
||||||
this.suggestions.set(null)
|
)
|
||||||
this.toastService.showError(
|
|
||||||
$localize`Error retrieving suggestions.`,
|
|
||||||
error
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1116,7 +1154,7 @@ export class DocumentDetailComponent
|
|||||||
if (this.suggestions()) {
|
if (this.suggestions()) {
|
||||||
this.suggestions.set({
|
this.suggestions.set({
|
||||||
...this.suggestions(),
|
...this.suggestions(),
|
||||||
suggested_tags: this.suggestions().suggested_tags.filter(
|
suggested_tags: (this.suggestions().suggested_tags ?? []).filter(
|
||||||
(tag) => tag !== newTag.name
|
(tag) => tag !== newTag.name
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
@@ -1155,10 +1193,12 @@ export class DocumentDetailComponent
|
|||||||
this.documentForm.get('document_type').setValue(newDocumentType.id)
|
this.documentForm.get('document_type').setValue(newDocumentType.id)
|
||||||
this.documentForm.get('document_type').markAsDirty()
|
this.documentForm.get('document_type').markAsDirty()
|
||||||
if (this.suggestions()) {
|
if (this.suggestions()) {
|
||||||
this.suggestions().suggested_document_types =
|
this.suggestions.set({
|
||||||
this.suggestions().suggested_document_types.filter(
|
...this.suggestions(),
|
||||||
(dt) => dt !== newName
|
suggested_document_types: (
|
||||||
)
|
this.suggestions().suggested_document_types ?? []
|
||||||
|
).filter((dt) => dt !== newName),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1185,10 +1225,12 @@ export class DocumentDetailComponent
|
|||||||
this.documentForm.get('correspondent').setValue(newCorrespondent.id)
|
this.documentForm.get('correspondent').setValue(newCorrespondent.id)
|
||||||
this.documentForm.get('correspondent').markAsDirty()
|
this.documentForm.get('correspondent').markAsDirty()
|
||||||
if (this.suggestions()) {
|
if (this.suggestions()) {
|
||||||
this.suggestions().suggested_correspondents =
|
this.suggestions.set({
|
||||||
this.suggestions().suggested_correspondents.filter(
|
...this.suggestions(),
|
||||||
(c) => c !== newName
|
suggested_correspondents: (
|
||||||
)
|
this.suggestions().suggested_correspondents ?? []
|
||||||
|
).filter((c) => c !== newName),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,6 +146,19 @@ 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,9 +270,12 @@ export class DocumentListComponent
|
|||||||
this.list.reload()
|
this.list.reload()
|
||||||
})
|
})
|
||||||
|
|
||||||
this.websocketStatusService.onDocumentDeleted().subscribe(() => {
|
this.websocketStatusService
|
||||||
this.list.reload()
|
.onDocumentDeleted()
|
||||||
})
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.list.reload()
|
||||||
|
})
|
||||||
|
|
||||||
this.route.paramMap
|
this.route.paramMap
|
||||||
.pipe(
|
.pipe(
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export interface DocumentBarcode {
|
||||||
|
page: number
|
||||||
|
|
||||||
|
value: string
|
||||||
|
|
||||||
|
format: string
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { DocumentBarcode } from './document-barcode'
|
||||||
|
|
||||||
export interface DocumentMetadata {
|
export interface DocumentMetadata {
|
||||||
original_checksum?: string
|
original_checksum?: string
|
||||||
|
|
||||||
@@ -12,4 +14,6 @@ export interface DocumentMetadata {
|
|||||||
has_archive_version?: boolean
|
has_archive_version?: boolean
|
||||||
|
|
||||||
lang?: string
|
lang?: string
|
||||||
|
|
||||||
|
barcodes?: DocumentBarcode[]
|
||||||
}
|
}
|
||||||
@@ -15,3 +15,33 @@ 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -330,6 +330,13 @@ export const PaperlessConfigOptions: ConfigOption[] = [
|
|||||||
config_key: 'PAPERLESS_CONSUMER_TAG_BARCODE_SPLIT',
|
config_key: 'PAPERLESS_CONSUMER_TAG_BARCODE_SPLIT',
|
||||||
category: ConfigCategory.Barcode,
|
category: ConfigCategory.Barcode,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'barcode_store_values',
|
||||||
|
title: $localize`Store Barcode Contents`,
|
||||||
|
type: ConfigOptionType.Boolean,
|
||||||
|
config_key: 'PAPERLESS_CONSUMER_STORE_BARCODE_VALUES',
|
||||||
|
category: ConfigCategory.Barcode,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'ai_enabled',
|
key: 'ai_enabled',
|
||||||
title: $localize`AI Enabled`,
|
title: $localize`AI Enabled`,
|
||||||
@@ -353,6 +360,14 @@ 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`,
|
||||||
@@ -450,6 +465,7 @@ export interface PaperlessConfig extends ObjectWithId {
|
|||||||
barcode_enable_tag: boolean
|
barcode_enable_tag: boolean
|
||||||
barcode_tag_mapping: object
|
barcode_tag_mapping: object
|
||||||
barcode_tag_split: boolean
|
barcode_tag_split: boolean
|
||||||
|
barcode_store_values: boolean
|
||||||
remote_ocr_engine: string
|
remote_ocr_engine: string
|
||||||
remote_ocr_api_key: string
|
remote_ocr_api_key: string
|
||||||
remote_ocr_endpoint: string
|
remote_ocr_endpoint: string
|
||||||
@@ -457,6 +473,7 @@ 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,6 +20,12 @@ 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',
|
||||||
}
|
}
|
||||||
@@ -98,6 +104,8 @@ 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',
|
||||||
@@ -326,6 +334,11 @@ 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',
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const environment = {
|
|||||||
apiVersion: '10', // match src/paperless/settings.py
|
apiVersion: '10', // match src/paperless/settings.py
|
||||||
appTitle: DEFAULT_APP_TITLE,
|
appTitle: DEFAULT_APP_TITLE,
|
||||||
tag: 'prod',
|
tag: 'prod',
|
||||||
version: '3.2.1',
|
version: '3.3.0',
|
||||||
webSocketHost: window.location.host,
|
webSocketHost: window.location.host,
|
||||||
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
||||||
webSocketBaseUrl: base_url.pathname + 'ws/',
|
webSocketBaseUrl: base_url.pathname + 'ws/',
|
||||||
|
|||||||
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
Loaded 100 of 298 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user