mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-29 04:40:30 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
411b208131 | ||
|
|
61f36eb253 | ||
|
|
e50bcc2efb | ||
|
|
88000aab45 | ||
|
|
01b4ab392c | ||
|
|
52aa8758e3 | ||
|
|
4b7cc1c0c3 | ||
|
|
f02d7455be | ||
|
|
a61c35fcd8 | ||
|
|
64552d5d5b | ||
|
|
a59edf3e5b | ||
|
|
6a5d06ee26 | ||
|
|
126ec414a8 | ||
|
|
773744d04b | ||
|
|
7b98085b31 | ||
|
|
b3bf220f56 | ||
|
|
d4429c3cc7 | ||
|
|
4f777d7438 | ||
|
|
2a44d8b5ba | ||
|
|
abf5050ea7 | ||
|
|
091ddf7c45 | ||
|
|
c9f7f2cfbe | ||
|
|
b457610ffb | ||
|
|
969c2ea0e2 | ||
|
|
31b806a285 | ||
|
|
99851b418c | ||
|
|
e34eda07bb | ||
|
|
793459b416 | ||
|
|
04297fd02c | ||
|
|
1b277dd8e1 | ||
|
|
3c20abeb4c | ||
|
|
b11f1f8459 | ||
|
|
7424e7ce0b | ||
|
|
a53a3d3769 | ||
|
|
15b73b890c | ||
|
|
02d355061f | ||
|
|
d8b5b4d447 | ||
|
|
03ac4aed7e | ||
|
|
90d23bad9c | ||
|
|
e4367b5648 | ||
|
|
cb85441c2f | ||
|
|
cceaa559d4 | ||
|
|
a748d4c64f | ||
|
|
452ed005bd | ||
|
|
40058ff7d5 | ||
|
|
f502cd5e34 | ||
|
|
99ce6b5db3 | ||
|
|
2d955e9697 | ||
|
|
f440e8d33c | ||
|
|
4c264651e8 | ||
|
|
48d97b78bb | ||
|
|
659a0cb2ef | ||
|
|
d02d1e1711 | ||
|
|
3b41810e7b | ||
|
|
12314fcaa8 | ||
|
|
8705bd510a | ||
|
|
7575d60782 | ||
|
|
8b02a23b86 | ||
|
|
87d157096e | ||
|
|
54e332d259 | ||
|
|
506afb4200 | ||
|
|
22a8a4f4ad | ||
|
|
d5425a893d | ||
|
|
cc25144dbf | ||
|
|
961f018d3e | ||
|
|
405ab39b40 | ||
|
|
36c10b72b9 | ||
|
|
2c7ad1ed7e | ||
|
|
54306ec0f2 | ||
|
|
4c4ef5617c | ||
|
|
31d6e984f2 | ||
|
|
cf2f913a06 | ||
|
|
f4ff87e69b | ||
|
|
d80997a070 | ||
|
|
c0000d37c7 | ||
|
|
d9ebbe1486 | ||
|
|
24b157622d | ||
|
|
51f3266014 | ||
|
|
fdd32b73bb | ||
|
|
20d309a413 | ||
|
|
762e8cf4d1 | ||
|
|
420bf503e8 | ||
|
|
3e38b246d4 | ||
|
|
684b3c9885 | ||
|
|
6ad95b63b2 | ||
|
|
0d14d80995 | ||
|
|
c97c6923ea | ||
|
|
cd53d1b2c8 | ||
|
|
530059c5c0 | ||
|
|
989f138556 | ||
|
|
a419f1ffac | ||
|
|
7e698360ff | ||
|
|
e8b7a98d3e | ||
|
|
f919c981e1 | ||
|
|
ec4e45ecf4 | ||
|
|
27927fab4d | ||
|
|
683f2ac250 | ||
|
|
73de6b17bf | ||
|
|
ef26bc1570 | ||
|
|
ff1e61b162 | ||
|
|
1f374cd656 | ||
|
|
7354427229 | ||
|
|
970eb55a70 | ||
|
|
d2170f1267 | ||
|
|
2ac5a1c2e2 | ||
|
|
a4e69aac9d | ||
|
|
1e62f5e811 | ||
|
|
1f8b6c95b1 | ||
|
|
e78521c912 | ||
|
|
9cea08118e | ||
|
|
cb2506900e | ||
|
|
5293194551 | ||
|
|
1b86488e2e | ||
|
|
c9a5607902 | ||
|
|
05b7697c35 | ||
|
|
c626ecd9bc | ||
|
|
aeed83b14a | ||
|
|
72ea38ab12 | ||
|
|
4421d4fe58 | ||
|
|
4d64632f70 | ||
|
|
26094bc863 | ||
|
|
9dbad4de09 | ||
|
|
4a54935b3d | ||
|
|
d53c9070ba | ||
|
|
f885833a38 | ||
|
|
197c80ea68 | ||
|
|
a0c9500b6a | ||
|
|
df8e95cbd4 | ||
|
|
95944a553d | ||
|
|
2256cb3d38 | ||
|
|
9a8163fbbb | ||
|
|
d5f9605daf | ||
|
|
8593f84cae | ||
|
|
ca512af5ec | ||
|
|
a00755907e | ||
|
|
abdf15466c | ||
|
|
54a6f0fd2b | ||
|
|
2d64684043 | ||
|
|
60709b8319 | ||
|
|
1c96819625 | ||
|
|
3e56dace73 |
@@ -15,6 +15,8 @@
|
||||
# Test related
|
||||
**/.pytest_cache
|
||||
**/tests
|
||||
src/paperless_testing
|
||||
src/conftest.py
|
||||
**/*.spec.ts
|
||||
**/htmlcov
|
||||
# Local folders
|
||||
|
||||
@@ -59,7 +59,6 @@ updates:
|
||||
- "drf-*"
|
||||
- "djangorestframework"
|
||||
- "whitenoise"
|
||||
- "bleach"
|
||||
- "jinja2"
|
||||
# Async, Task Queuing & Caching
|
||||
async-tasks:
|
||||
@@ -81,7 +80,6 @@ updates:
|
||||
# Data, NLP, and Search
|
||||
data-nlp-search:
|
||||
patterns:
|
||||
- "nltk"
|
||||
- "scikit-learn"
|
||||
- "langdetect"
|
||||
- "rapidfuzz"
|
||||
|
||||
@@ -12,7 +12,9 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
env:
|
||||
DEFAULT_UV_VERSION: "0.12.x"
|
||||
NLTK_DATA: "/usr/share/nltk_data"
|
||||
# Match the Docker image: nltk refuses to read hardlinked data files, such as
|
||||
# the copy bundled with llama-index when uv links packages from its cache
|
||||
UV_LINK_MODE: copy
|
||||
permissions: {}
|
||||
jobs:
|
||||
changes:
|
||||
@@ -100,7 +102,7 @@ jobs:
|
||||
with:
|
||||
python-version: "${{ matrix.python-version }}"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: true
|
||||
@@ -125,12 +127,8 @@ jobs:
|
||||
- name: List installed Python dependencies
|
||||
run: |
|
||||
uv pip list
|
||||
- name: Install NLTK data
|
||||
run: |
|
||||
uv run python -m nltk.downloader punkt punkt_tab snowball_data stopwords -d "${NLTK_DATA}"
|
||||
- name: Run tests
|
||||
env:
|
||||
NLTK_DATA: ${{ env.NLTK_DATA }}
|
||||
PAPERLESS_CI_TEST: 1
|
||||
PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }}
|
||||
run: |
|
||||
@@ -141,13 +139,13 @@ jobs:
|
||||
pytest
|
||||
- name: Upload test results to Codecov
|
||||
if: always()
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
|
||||
with:
|
||||
flags: backend-python-${{ matrix.python-version }}
|
||||
files: junit.xml
|
||||
report_type: test_results
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
|
||||
with:
|
||||
flags: backend-python-${{ matrix.python-version }}
|
||||
files: coverage.xml
|
||||
@@ -178,7 +176,7 @@ jobs:
|
||||
with:
|
||||
python-version: "${{ env.DEFAULT_PYTHON }}"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: true
|
||||
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
echo "repository=${repo_name}"
|
||||
echo "name=${repo_name}" >> $GITHUB_OUTPUT
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
@@ -132,7 +132,7 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
@@ -182,7 +182,7 @@ jobs:
|
||||
echo "Downloaded digests:"
|
||||
ls -la /tmp/digests/
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: true
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy GitHub Pages
|
||||
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
|
||||
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
|
||||
id: deployment
|
||||
with:
|
||||
artifact_name: github-pages-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
package_json_file: src-ui/package.json
|
||||
- name: Use Node.js 24
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
package_json_file: src-ui/package.json
|
||||
- name: Use Node.js 24
|
||||
@@ -152,7 +152,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
package_json_file: src-ui/package.json
|
||||
- name: Use Node.js 24
|
||||
@@ -174,13 +174,13 @@ jobs:
|
||||
run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
||||
- name: Upload test results to Codecov
|
||||
if: always()
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
|
||||
with:
|
||||
flags: frontend-node-${{ matrix.node-version }}
|
||||
directory: src-ui/
|
||||
report_type: test_results
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
|
||||
with:
|
||||
flags: frontend-node-${{ matrix.node-version }}
|
||||
directory: src-ui/coverage/
|
||||
@@ -201,7 +201,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
package_json_file: src-ui/package.json
|
||||
- name: Use Node.js 24
|
||||
@@ -216,7 +216,7 @@ jobs:
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: '0.12.x'
|
||||
enable-cache: false
|
||||
@@ -255,7 +255,7 @@ jobs:
|
||||
fetch-depth: 2
|
||||
persist-credentials: false
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
package_json_file: src-ui/package.json
|
||||
- name: Use Node.js 24
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
# ---- Frontend Build ----
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
package_json_file: src-ui/package.json
|
||||
- name: Use Node.js 24
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: false
|
||||
@@ -212,7 +212,7 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: false
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run zizmor
|
||||
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
|
||||
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
|
||||
semgrep:
|
||||
name: Semgrep CE
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
- name: Run Semgrep
|
||||
run: semgrep scan --config auto --sarif-output results.sarif
|
||||
- name: Upload results to GitHub code scanning
|
||||
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
steps:
|
||||
- name: Clean temporary images
|
||||
if: "${{ env.TOKEN != '' }}"
|
||||
uses: stumpylog/image-cleaner-action/ephemeral@4fe057d991d63b8f6d5d22c40f17c1bca2226537 # v0.12.0
|
||||
uses: stumpylog/image-cleaner-action/ephemeral@21f875bab2376314e0525c614e923c669434f0e4 # v0.13.0
|
||||
with:
|
||||
token: "${{ env.TOKEN }}"
|
||||
owner: "${{ github.repository_owner }}"
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
steps:
|
||||
- name: Clean untagged images
|
||||
if: "${{ env.TOKEN != '' }}"
|
||||
uses: stumpylog/image-cleaner-action/untagged@4fe057d991d63b8f6d5d22c40f17c1bca2226537 # v0.12.0
|
||||
uses: stumpylog/image-cleaner-action/untagged@21f875bab2376314e0525c614e923c669434f0e4 # v0.13.0
|
||||
with:
|
||||
token: "${{ env.TOKEN }}"
|
||||
owner: "${{ github.repository_owner }}"
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -47,4 +47,4 @@ jobs:
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
token: ${{ secrets.PNGX_BOT_PAT }}
|
||||
persist-credentials: false
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@e4a6c1338b4063c77d46a81875265f9e8bd76f95 # v3.0.0
|
||||
uses: crowdin/github-action@df474cdfb9f41d6ae777118749477c2cdf7cacc8 # v3.2.0
|
||||
with:
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -qq --no-install-recommends gettext
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: true
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
PAPERLESS_SECRET_KEY: "ci-translate-not-a-real-secret"
|
||||
run: cd src/ && uv run manage.py makemessages -l en_US -i "samples*"
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
package_json_file: src-ui/package.json
|
||||
- name: Use Node.js 24
|
||||
|
||||
+706
-456
File diff suppressed because it is too large
Load Diff
@@ -50,12 +50,12 @@ repos:
|
||||
- 'prettier-plugin-organize-imports@4.3.0'
|
||||
# Python hooks
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.16.5
|
||||
rev: v0.16.7
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
- id: ruff-format
|
||||
- repo: https://github.com/tox-dev/pyproject-fmt
|
||||
rev: "v2.28.1"
|
||||
rev: "v2.29.4"
|
||||
hooks:
|
||||
- id: pyproject-fmt
|
||||
additional_dependencies: [tomli]
|
||||
|
||||
+4804
-12941
File diff suppressed because one or more lines are too long
+4
-6
@@ -30,7 +30,7 @@ RUN set -eux \
|
||||
# Purpose: Installs s6-overlay and rootfs
|
||||
# Comments:
|
||||
# - Don't leave anything extra in here either
|
||||
FROM ghcr.io/astral-sh/uv:0.12.9-python3.14-trixie-slim AS s6-overlay-base
|
||||
FROM ghcr.io/astral-sh/uv:0.12.16-python3.14-trixie-slim AS s6-overlay-base
|
||||
|
||||
WORKDIR /usr/src/s6
|
||||
|
||||
@@ -171,7 +171,9 @@ RUN set -eux \
|
||||
&& cp /etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-6/policy.xml \
|
||||
&& echo "Cleaning up image layer" \
|
||||
&& rm --force --verbose *.deb \
|
||||
&& rm --recursive --force --verbose /var/lib/apt/lists/*
|
||||
&& rm --recursive --force --verbose /var/lib/apt/lists/* \
|
||||
&& echo "Configuring interactive shells to source the s6 container environment" \
|
||||
&& echo '. /etc/profile.d/contenv.sh' >> /etc/bash.bashrc
|
||||
|
||||
WORKDIR /usr/src/paperless/src/
|
||||
|
||||
@@ -199,10 +201,6 @@ RUN set -eux \
|
||||
--index https://download.pytorch.org/whl/cpu \
|
||||
--index-strategy unsafe-best-match \
|
||||
--requirements requirements.txt \
|
||||
&& echo "Installing NLTK data" \
|
||||
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" snowball_data \
|
||||
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" stopwords \
|
||||
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" punkt_tab \
|
||||
&& echo "Cleaning up image" \
|
||||
&& apt-get --yes purge ${BUILD_PACKAGES} \
|
||||
&& apt-get --yes autoremove --purge \
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# correct networking for the tests
|
||||
services:
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.36
|
||||
image: docker.io/gotenberg/gotenberg:8.37
|
||||
hostname: gotenberg
|
||||
container_name: gotenberg
|
||||
network_mode: host
|
||||
@@ -35,7 +35,7 @@ services:
|
||||
- "3143:3143" # IMAP
|
||||
restart: unless-stopped
|
||||
nginx:
|
||||
image: docker.io/nginx:1.31.5-alpine
|
||||
image: docker.io/nginx:1.31.6-alpine
|
||||
hostname: nginx
|
||||
container_name: nginx
|
||||
ports:
|
||||
|
||||
@@ -72,7 +72,7 @@ services:
|
||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.36
|
||||
image: docker.io/gotenberg/gotenberg:8.37
|
||||
restart: unless-stopped
|
||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||
# want to allow external content like tracking pixels or even javascript.
|
||||
|
||||
@@ -67,7 +67,7 @@ services:
|
||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.36
|
||||
image: docker.io/gotenberg/gotenberg:8.37
|
||||
restart: unless-stopped
|
||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||
# want to allow external content like tracking pixels or even javascript.
|
||||
|
||||
@@ -56,7 +56,7 @@ services:
|
||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.36
|
||||
image: docker.io/gotenberg/gotenberg:8.37
|
||||
restart: unless-stopped
|
||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||
# want to allow external content like tracking pixels or even javascript.
|
||||
|
||||
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
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/command/with-contenv /usr/bin/bash
|
||||
# shellcheck shell=bash
|
||||
declare -r log_prefix="[init-compile-bytecode]"
|
||||
|
||||
# PYTHONDONTWRITEBYTECODE=1 is set for the whole container. This unit compiles a
|
||||
# scoped set of libraries anyway, to speed up startup without bloating image size.
|
||||
|
||||
# Handle the people using a read only file system
|
||||
if [[ "${S6_READ_ONLY_ROOT}" == "1" ]]; then
|
||||
echo "${log_prefix} S6_READ_ONLY_ROOT=1, skipping (nothing to write bytecode to)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# When running as a non-root user, site-packages is still root-owned and unwritable,
|
||||
# so this step would just fail loudly on every container start. Skip it.
|
||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||
echo "${log_prefix} USER_IS_NON_ROOT is set, skipping (site-packages is not writable)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
declare -r site_packages="$(python3 -c 'import site; print(site.getsitepackages()[0])')"
|
||||
|
||||
# Deliberately scoped to packages that paperless.settings/paperless/__init__.py import
|
||||
# unconditionally on every manage.py invocation (Django itself, the always-loaded
|
||||
# INSTALLED_APPS, and celery). This is NOT "compile everything" - the optional AI stack
|
||||
# (torch, llama-index, sentence-transformers, ...) is intentionally excluded since it is
|
||||
# lazy-imported and large.
|
||||
declare -a scope=(
|
||||
"${PAPERLESS_SRC_DIR}"
|
||||
"${site_packages}/django"
|
||||
"${site_packages}/celery"
|
||||
"${site_packages}/kombu"
|
||||
"${site_packages}/rest_framework"
|
||||
"${site_packages}/django_filters"
|
||||
"${site_packages}/whitenoise"
|
||||
"${site_packages}/corsheaders"
|
||||
"${site_packages}/django_extensions"
|
||||
"${site_packages}/guardian"
|
||||
"${site_packages}/allauth"
|
||||
"${site_packages}/drf_spectacular"
|
||||
"${site_packages}/drf_spectacular_sidecar"
|
||||
"${site_packages}/treenode"
|
||||
"${site_packages}/compression_middleware"
|
||||
)
|
||||
|
||||
declare -a existing_scope=()
|
||||
for path in "${scope[@]}"; do
|
||||
[[ -d "${path}" ]] && existing_scope+=("${path}")
|
||||
done
|
||||
|
||||
echo "${log_prefix} Compiling bytecode for: ${existing_scope[*]}"
|
||||
declare -r start_seconds=${SECONDS}
|
||||
|
||||
if ! PYTHONDONTWRITEBYTECODE= python3 -m compileall -q "${existing_scope[@]}"; then
|
||||
echo "${log_prefix} WARNING: compileall reported errors (read-only filesystem or unwritable site-packages?); continuing without a bytecode cache"
|
||||
fi
|
||||
|
||||
echo "${log_prefix} Done in $((SECONDS - start_seconds))s"
|
||||
@@ -0,0 +1 @@
|
||||
oneshot
|
||||
@@ -0,0 +1 @@
|
||||
/etc/s6-overlay/s6-rc.d/init-compile-bytecode/run
|
||||
@@ -2,6 +2,7 @@
|
||||
# shellcheck shell=bash
|
||||
|
||||
declare -r log_prefix="[svc-flower]"
|
||||
declare -r flower_config="${PAPERLESS_SRC_DIR}/paperless/flowerconfig.py"
|
||||
|
||||
echo "${log_prefix} Checking if we should start flower..."
|
||||
|
||||
@@ -9,12 +10,20 @@ if [[ -n "${PAPERLESS_ENABLE_FLOWER}" ]]; then
|
||||
# Small delay to allow celery to be up first
|
||||
echo "${log_prefix} Starting flower in 5s"
|
||||
sleep 5
|
||||
cd ${PAPERLESS_SRC_DIR}
|
||||
cd "${PAPERLESS_SRC_DIR}" || exit 1
|
||||
|
||||
# Only pass --conf if the file is actually there. The image does not ship one, and
|
||||
# flower >= 2.1.0 exits with FileNotFoundError when an explicitly given --conf path
|
||||
# does not exist (mher/flower#1391). Earlier versions silently ignored it.
|
||||
declare -a conf_args=()
|
||||
if [[ -f "${flower_config}" ]]; then
|
||||
conf_args=(--conf="${flower_config}")
|
||||
fi
|
||||
|
||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||
exec /usr/local/bin/celery --app paperless flower --conf=${PAPERLESS_SRC_DIR}/paperless/flowerconfig.py
|
||||
exec /usr/local/bin/celery --app paperless flower "${conf_args[@]}"
|
||||
else
|
||||
exec s6-setuidgid paperless /usr/local/bin/celery --app paperless flower --conf=${PAPERLESS_SRC_DIR}/paperless/flowerconfig.py
|
||||
exec s6-setuidgid paperless /usr/local/bin/celery --app paperless flower "${conf_args[@]}"
|
||||
fi
|
||||
|
||||
else
|
||||
|
||||
@@ -521,7 +521,8 @@ Pass `--recreate` to wipe the existing index before rebuilding. Use this when th
|
||||
index is corrupted or you want a fully clean rebuild.
|
||||
|
||||
Pass `--if-needed` to skip the rebuild if the index is already up to date (schema
|
||||
version and search language match). Safe to run on every startup or upgrade.
|
||||
version, schema fingerprint and search language all match). Safe to run on every
|
||||
startup or upgrade.
|
||||
|
||||
Specify `optimize` to optimize the index. This command is regularly invoked by the
|
||||
task scheduler.
|
||||
|
||||
@@ -153,8 +153,11 @@ in similar existing documents, and the document chat can retrieve relevant conte
|
||||
|
||||
Enable it by setting
|
||||
[`PAPERLESS_AI_LLM_EMBEDDING_BACKEND`](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_BACKEND)
|
||||
(`huggingface` for fully-local embeddings, or `ollama` / `openai-like`). The index is only
|
||||
built when AI is enabled **and** an embedding backend is set.
|
||||
(`huggingface` for fully-local embeddings, or `ollama` / `openai-like`). By default, the main
|
||||
LLM API key and endpoint are used, but an optional embedding-specific[API key](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_API_KEY)
|
||||
and [endpoint](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT) can be configured.
|
||||
|
||||
The index is only built when AI is enabled **and** an embedding backend is set.
|
||||
|
||||
The index is updated automatically on a schedule controlled by
|
||||
[`PAPERLESS_LLM_INDEX_TASK_CRON`](configuration.md#PAPERLESS_LLM_INDEX_TASK_CRON) (daily by
|
||||
|
||||
@@ -1,5 +1,210 @@
|
||||
# Changelog
|
||||
|
||||
## paperless-ngx 3.2.1
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: only pass --conf to flower when flowerconfig.py exists [@bitfoo1](https://github.com/bitfoo1) ([#14182](https://github.com/paperless-ngx/paperless-ngx/pull/14182))
|
||||
- Fix: replace stale mail-fetch overlap check with a self-expiring lock [@stumpylog](https://github.com/stumpylog) ([#14189](https://github.com/paperless-ngx/paperless-ngx/pull/14189))
|
||||
- Fix: bump ocrmypdf to 17.12 to pick up the ligature text-layer fix [@stumpylog](https://github.com/stumpylog) ([#14190](https://github.com/paperless-ngx/paperless-ngx/pull/14190))
|
||||
- Fix: rebuild the search index automatically when it is missing Tantivy files [@stumpylog](https://github.com/stumpylog) ([#14180](https://github.com/paperless-ngx/paperless-ngx/pull/14180))
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Chore(deps): Bump anyio from 4.12.1 to 4.14.2 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#14175](https://github.com/paperless-ngx/paperless-ngx/pull/14175))
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>4 changes</summary>
|
||||
|
||||
- Chore(deps): Bump anyio from 4.12.1 to 4.14.2 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#14175](https://github.com/paperless-ngx/paperless-ngx/pull/14175))
|
||||
- Fix: replace stale mail-fetch overlap check with a self-expiring lock [@stumpylog](https://github.com/stumpylog) ([#14189](https://github.com/paperless-ngx/paperless-ngx/pull/14189))
|
||||
- Fix: bump ocrmypdf to 17.12 to pick up the ligature text-layer fix [@stumpylog](https://github.com/stumpylog) ([#14190](https://github.com/paperless-ngx/paperless-ngx/pull/14190))
|
||||
- Fix: rebuild the search index automatically when it is missing Tantivy files [@stumpylog](https://github.com/stumpylog) ([#14180](https://github.com/paperless-ngx/paperless-ngx/pull/14180))
|
||||
|
||||
</details>
|
||||
|
||||
## paperless-ngx 3.2.0
|
||||
|
||||
### Features / Enhancements
|
||||
|
||||
- Enhancement (QoL): support deselecting single items from "select all" [@shamoon](https://github.com/shamoon) ([#14117](https://github.com/paperless-ngx/paperless-ngx/pull/14117))
|
||||
- Enhancement: Match fuzzy terms in place inside the parsed query [@stumpylog](https://github.com/stumpylog) ([#14157](https://github.com/paperless-ngx/paperless-ngx/pull/14157))
|
||||
- Enhancement: Match CJK terms through their bigram fields in place [@stumpylog](https://github.com/stumpylog) ([#14156](https://github.com/paperless-ngx/paperless-ngx/pull/14156))
|
||||
- Enhancement: centralized management of share links + bundles [@shamoon](https://github.com/shamoon) ([#14115](https://github.com/paperless-ngx/paperless-ngx/pull/14115))
|
||||
- Enhancement: parse advanced search with whoosh-compat and delete the handwritten translation [@stumpylog](https://github.com/stumpylog) ([#14072](https://github.com/paperless-ngx/paperless-ngx/pull/14072))
|
||||
- Enhancement: allow regex timeout configuration [@shamoon](https://github.com/shamoon) ([#14085](https://github.com/paperless-ngx/paperless-ngx/pull/14085))
|
||||
- Enhancement: hide-able sidebar items [@shamoon](https://github.com/shamoon) ([#14052](https://github.com/paperless-ngx/paperless-ngx/pull/14052))
|
||||
- Enhancement: Improve matching for correspondents, storage path and labels by removing bias + adding minimum match threshold [@dewey](https://github.com/dewey) ([#12164](https://github.com/paperless-ngx/paperless-ngx/pull/12164))
|
||||
- Enhancement: add Tantivy full-text fallback adapter for taxonomy candidates [@stumpylog](https://github.com/stumpylog) ([#13820](https://github.com/paperless-ngx/paperless-ngx/pull/13820))
|
||||
- Enhancement (QoL): surface externally-set options in Config UI [@shamoon](https://github.com/shamoon) ([#13989](https://github.com/paperless-ngx/paperless-ngx/pull/13989))
|
||||
- Enhancement: allow disabling auto-suggestions for inbox documents [@shamoon](https://github.com/shamoon) ([#13946](https://github.com/paperless-ngx/paperless-ngx/pull/13946))
|
||||
- Change: skip documents with empty content in apply AI suggestions WF [@shamoon](https://github.com/shamoon) ([#13985](https://github.com/paperless-ngx/paperless-ngx/pull/13985))
|
||||
- Enhancement: duplicates filter [@shamoon](https://github.com/shamoon) ([#13994](https://github.com/paperless-ngx/paperless-ngx/pull/13994))
|
||||
- Tweak: note that apply AI suggestions runs async in WF editor [@shamoon](https://github.com/shamoon) ([#14004](https://github.com/paperless-ngx/paperless-ngx/pull/14004))
|
||||
- Enhancement (QoL): attempt to localize firstDayOfWeek for date picker [@shamoon](https://github.com/shamoon) ([#13999](https://github.com/paperless-ngx/paperless-ngx/pull/13999))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: don't redirect to signup on first install when regular login is disabled [@cyberb](https://github.com/cyberb) ([#14165](https://github.com/paperless-ngx/paperless-ngx/pull/14165))
|
||||
- Fix: better catch email workflow placeholder parsing errors [@shamoon](https://github.com/shamoon) ([#14129](https://github.com/paperless-ngx/paperless-ngx/pull/14129))
|
||||
- Fix: validate legacy bulk edit owner, rotate and split parameters [@stumpylog](https://github.com/stumpylog) ([#14120](https://github.com/paperless-ngx/paperless-ngx/pull/14120))
|
||||
- Fix: validate set\_permissions with a nested serializer [@stumpylog](https://github.com/stumpylog) ([#14119](https://github.com/paperless-ngx/paperless-ngx/pull/14119))
|
||||
- Fix: Use prefetching to reduce query counts during classifier training [@stumpylog](https://github.com/stumpylog) ([#14122](https://github.com/paperless-ngx/paperless-ngx/pull/14122))
|
||||
- Fix: reject non-dict user\_args/barcode\_tag\_mapping in config API [@stumpylog](https://github.com/stumpylog) ([#14118](https://github.com/paperless-ngx/paperless-ngx/pull/14118))
|
||||
- Fix: type edit\_pdf operations via a nested serializer [@stumpylog](https://github.com/stumpylog) ([#14116](https://github.com/paperless-ngx/paperless-ngx/pull/14116))
|
||||
- Fix: ensure django setup is run for management comments under 3.14 [@shamoon](https://github.com/shamoon) ([#14100](https://github.com/paperless-ngx/paperless-ngx/pull/14100))
|
||||
- Fix: validate PDF output doc indexes in bulk edit [@shamoon](https://github.com/shamoon) ([#14083](https://github.com/paperless-ngx/paperless-ngx/pull/14083))
|
||||
- Fix: avoid IntegrityError when a retried task republishes with the same ID [@stumpylog](https://github.com/stumpylog) ([#14096](https://github.com/paperless-ngx/paperless-ngx/pull/14096))
|
||||
- Fix: update some api global perms inconsistencies [@shamoon](https://github.com/shamoon) ([#14086](https://github.com/paperless-ngx/paperless-ngx/pull/14086))
|
||||
- Fix: ignore nested action IDs on WF create [@shamoon](https://github.com/shamoon) ([#14084](https://github.com/paperless-ngx/paperless-ngx/pull/14084))
|
||||
- Fix: correct text/stream compression workaround [@shamoon](https://github.com/shamoon) ([#14064](https://github.com/paperless-ngx/paperless-ngx/pull/14064))
|
||||
- Fix: ui version content switching inconsistencies [@shamoon](https://github.com/shamoon) ([#14066](https://github.com/paperless-ngx/paperless-ngx/pull/14066))
|
||||
- Fix: prevent saving changes to stale cached document object [@shamoon](https://github.com/shamoon) ([#14065](https://github.com/paperless-ngx/paperless-ngx/pull/14065))
|
||||
- Fix: ensure remove inbox tag children on remove\_inbox\_tags [@shamoon](https://github.com/shamoon) ([#14050](https://github.com/paperless-ngx/paperless-ngx/pull/14050))
|
||||
- Fix: connect add\_to\_index handler after document\_added [@shamoon](https://github.com/shamoon) ([#14058](https://github.com/paperless-ngx/paperless-ngx/pull/14058))
|
||||
- Fix: Drop empty files from tracking after the stability window has passed [@stumpylog](https://github.com/stumpylog) ([#14047](https://github.com/paperless-ngx/paperless-ngx/pull/14047))
|
||||
- Fixhancement: better LLM errors [@shamoon](https://github.com/shamoon) ([#14031](https://github.com/paperless-ngx/paperless-ngx/pull/14031))
|
||||
- Fix: prevent orphaned versions from bulk delete [@shamoon](https://github.com/shamoon) ([#14030](https://github.com/paperless-ngx/paperless-ngx/pull/14030))
|
||||
- Fixhancement: prevent overlapping mail-account processing runs [@stumpylog](https://github.com/stumpylog) ([#14046](https://github.com/paperless-ngx/paperless-ngx/pull/14046))
|
||||
- Fix: Use PAPERLESS\_REDIS\_PREFIX for Celery result backend keys [@bdd](https://github.com/bdd) ([#14015](https://github.com/paperless-ngx/paperless-ngx/pull/14015))
|
||||
- Fix: correct setting ai\_enabled to false via UI [@shamoon](https://github.com/shamoon) ([#13987](https://github.com/paperless-ngx/paperless-ngx/pull/13987))
|
||||
- Fix: catch some frontend failed object retrievals [@shamoon](https://github.com/shamoon) ([#14023](https://github.com/paperless-ngx/paperless-ngx/pull/14023))
|
||||
- Fix: more v3 icons cleanup [@shamoon](https://github.com/shamoon) ([#14017](https://github.com/paperless-ngx/paperless-ngx/pull/14017))
|
||||
- Fix: correct add version actor parity [@shamoon](https://github.com/shamoon) ([#14016](https://github.com/paperless-ngx/paperless-ngx/pull/14016))
|
||||
- Fix: fix v3 favicon file [@shamoon](https://github.com/shamoon) ([#14014](https://github.com/paperless-ngx/paperless-ngx/pull/14014))
|
||||
- Fix: change share link bundle dialog button to close after create, don't toast on copied [@shamoon](https://github.com/shamoon) ([#14002](https://github.com/paperless-ngx/paperless-ngx/pull/14002))
|
||||
- Fix: truncate mail subjects to field max length [@shamoon](https://github.com/shamoon) ([#13991](https://github.com/paperless-ngx/paperless-ngx/pull/13991))
|
||||
- Fix: enforce the overflow hidden rule on pdf editor thumbnails [@shamoon](https://github.com/shamoon) ([#13976](https://github.com/paperless-ngx/paperless-ngx/pull/13976))
|
||||
- Fix: also correct unbroken long names on small cards [@shamoon](https://github.com/shamoon) ([#13974](https://github.com/paperless-ngx/paperless-ngx/pull/13974))
|
||||
- Fix: ensure parent + child tags change together in bulk editor [@shamoon](https://github.com/shamoon) ([#13972](https://github.com/paperless-ngx/paperless-ngx/pull/13972))
|
||||
|
||||
### Dependencies
|
||||
|
||||
<details>
|
||||
<summary>29 changes</summary>
|
||||
|
||||
- Chore(deps): Bump the utilities-patch group across 1 directory with 15 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14167](https://github.com/paperless-ngx/paperless-ngx/pull/14167))
|
||||
- docker(deps): Bump astral-sh/uv from 0.12.9-python3.14-trixie-slim to 0.12.16-python3.14-trixie-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#14134](https://github.com/paperless-ngx/paperless-ngx/pull/14134))
|
||||
- Chore(deps): Bump the utilities-minor group across 1 directory with 10 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14149](https://github.com/paperless-ngx/paperless-ngx/pull/14149))
|
||||
- Chore(deps): Bump the actions group across 1 directory with 8 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14162](https://github.com/paperless-ngx/paperless-ngx/pull/14162))
|
||||
- Chore(deps): Bump the frontend-angular-dependencies group across 1 directory with 19 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14141](https://github.com/paperless-ngx/paperless-ngx/pull/14141))
|
||||
- docker-compose(deps): bump gotenberg/gotenberg from 8.36 to 8.37 in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#14137](https://github.com/paperless-ngx/paperless-ngx/pull/14137))
|
||||
- docker-compose(deps): Bump nginx from 1.31.5-alpine to 1.31.6-alpine in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#14138](https://github.com/paperless-ngx/paperless-ngx/pull/14138))
|
||||
- Chore(deps): Bump pdfjs-dist from 6.2.108 to 6.3.289 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14144](https://github.com/paperless-ngx/paperless-ngx/pull/14144))
|
||||
- Chore(deps-dev): Bump the frontend-eslint-dependencies group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14143](https://github.com/paperless-ngx/paperless-ngx/pull/14143))
|
||||
- Chore(deps-dev): Bump @types/node from 26.4.0 to 26.5.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14146](https://github.com/paperless-ngx/paperless-ngx/pull/14146))
|
||||
- Chore(deps-dev): Bump the frontend-jest-dependencies group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14142](https://github.com/paperless-ngx/paperless-ngx/pull/14142))
|
||||
- Chore(deps): Bump the utilities-minor group across 1 directory with 11 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13988](https://github.com/paperless-ngx/paperless-ngx/pull/13988))
|
||||
- Chore(deps): Bump sentence-transformers from 5.6.1 to 6.0.0 @[dependabot[bot]](https://github.com/apps/dependabot) ([#13983](https://github.com/paperless-ngx/paperless-ngx/pull/13983))
|
||||
- Chore(deps-dev): Bump types-markdown from 3.10.2.20260518 to 3.10.2.20260712 @[dependabot[bot]](https://github.com/apps/dependabot) ([#13982](https://github.com/paperless-ngx/paperless-ngx/pull/13982))
|
||||
- Chore(deps): Bump the utilities-patch group across 1 directory with 6 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13980](https://github.com/paperless-ngx/paperless-ngx/pull/13980))
|
||||
- Chore(deps): Update granian[uvloop] requirement from ~=2.7.0 to >=2.7,\<2.9 @[dependabot[bot]](https://github.com/apps/dependabot) ([#13984](https://github.com/paperless-ngx/paperless-ngx/pull/13984))
|
||||
- Chore: Updates our direct Redis pin [@stumpylog](https://github.com/stumpylog) ([#13986](https://github.com/paperless-ngx/paperless-ngx/pull/13986))
|
||||
- Chore(deps-dev): Bump the frontend-eslint-dependencies group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13918](https://github.com/paperless-ngx/paperless-ngx/pull/13918))
|
||||
- Chore(deps): Bump uuid from 14.0.1 to 14.0.2 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#13921](https://github.com/paperless-ngx/paperless-ngx/pull/13921))
|
||||
- Chore(deps-dev): Bump @types/node from 26.2.0 to 26.4.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#13919](https://github.com/paperless-ngx/paperless-ngx/pull/13919))
|
||||
- Chore: update ng-select to v24, handle breaking changes [@shamoon](https://github.com/shamoon) ([#13951](https://github.com/paperless-ngx/paperless-ngx/pull/13951))
|
||||
- Chore(deps): Bump djangorestframework from 3.17.2 to 3.18.0 in the django-ecosystem group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13912](https://github.com/paperless-ngx/paperless-ngx/pull/13912))
|
||||
- Chore(deps): Bump the pre-commit-dependencies group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13922](https://github.com/paperless-ngx/paperless-ngx/pull/13922))
|
||||
- Chore(deps): Bump the document-processing group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13916](https://github.com/paperless-ngx/paperless-ngx/pull/13916))
|
||||
- Chore(deps): Bump flower from 2.0.1 to 2.1.0 in the async-tasks group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13913](https://github.com/paperless-ngx/paperless-ngx/pull/13913))
|
||||
- Chore(deps): Bump the actions group across 1 directory with 15 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13920](https://github.com/paperless-ngx/paperless-ngx/pull/13920))
|
||||
- docker-compose(deps): Bump gotenberg/gotenberg from 8.34 to 8.36 in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#13910](https://github.com/paperless-ngx/paperless-ngx/pull/13910))
|
||||
- Chore(deps-dev): Bump the development group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13911](https://github.com/paperless-ngx/paperless-ngx/pull/13911))
|
||||
- docker(deps): Bump astral-sh/uv from 0.12.5-python3.14-trixie-slim to 0.12.9-python3.14-trixie-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#13914](https://github.com/paperless-ngx/paperless-ngx/pull/13914))
|
||||
|
||||
</details>
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>80 changes</summary>
|
||||
|
||||
- Chore(deps): Bump the utilities-patch group across 1 directory with 15 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14167](https://github.com/paperless-ngx/paperless-ngx/pull/14167))
|
||||
- Fix: don't redirect to signup on first install when regular login is disabled [@cyberb](https://github.com/cyberb) ([#14165](https://github.com/paperless-ngx/paperless-ngx/pull/14165))
|
||||
- Chore(deps): Bump the utilities-minor group across 1 directory with 10 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14149](https://github.com/paperless-ngx/paperless-ngx/pull/14149))
|
||||
- Enhancement (QoL): support deselecting single items from "select all" [@shamoon](https://github.com/shamoon) ([#14117](https://github.com/paperless-ngx/paperless-ngx/pull/14117))
|
||||
- Chore(deps): Bump the frontend-angular-dependencies group across 1 directory with 19 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14141](https://github.com/paperless-ngx/paperless-ngx/pull/14141))
|
||||
- Enhancement: Match fuzzy terms in place inside the parsed query [@stumpylog](https://github.com/stumpylog) ([#14157](https://github.com/paperless-ngx/paperless-ngx/pull/14157))
|
||||
- Enhancement: Match CJK terms through their bigram fields in place [@stumpylog](https://github.com/stumpylog) ([#14156](https://github.com/paperless-ngx/paperless-ngx/pull/14156))
|
||||
- Chore(deps): Bump pdfjs-dist from 6.2.108 to 6.3.289 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14144](https://github.com/paperless-ngx/paperless-ngx/pull/14144))
|
||||
- Chore(deps-dev): Bump the frontend-eslint-dependencies group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14143](https://github.com/paperless-ngx/paperless-ngx/pull/14143))
|
||||
- Chore(deps-dev): Bump @types/node from 26.4.0 to 26.5.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14146](https://github.com/paperless-ngx/paperless-ngx/pull/14146))
|
||||
- Chore(deps-dev): Bump the frontend-jest-dependencies group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14142](https://github.com/paperless-ngx/paperless-ngx/pull/14142))
|
||||
- Enhancement: centralized management of share links + bundles [@shamoon](https://github.com/shamoon) ([#14115](https://github.com/paperless-ngx/paperless-ngx/pull/14115))
|
||||
- Performance: Preprocess classifier text with Tantivy instead of NLTK [@stumpylog](https://github.com/stumpylog) ([#14127](https://github.com/paperless-ngx/paperless-ngx/pull/14127))
|
||||
- Performance: Drops fields from the classifier before pickling [@stumpylog](https://github.com/stumpylog) ([#14114](https://github.com/paperless-ngx/paperless-ngx/pull/14114))
|
||||
- Fix: better catch email workflow placeholder parsing errors [@shamoon](https://github.com/shamoon) ([#14129](https://github.com/paperless-ngx/paperless-ngx/pull/14129))
|
||||
- Performance: Improves the memory efficiency of classifier training [@stumpylog](https://github.com/stumpylog) ([#14124](https://github.com/paperless-ngx/paperless-ngx/pull/14124))
|
||||
- Performance: Streams the classifier pickle file during save as well [@stumpylog](https://github.com/stumpylog) ([#14121](https://github.com/paperless-ngx/paperless-ngx/pull/14121))
|
||||
- Fix: validate legacy bulk edit owner, rotate and split parameters [@stumpylog](https://github.com/stumpylog) ([#14120](https://github.com/paperless-ngx/paperless-ngx/pull/14120))
|
||||
- Fix: validate set\_permissions with a nested serializer [@stumpylog](https://github.com/stumpylog) ([#14119](https://github.com/paperless-ngx/paperless-ngx/pull/14119))
|
||||
- Fix: Use prefetching to reduce query counts during classifier training [@stumpylog](https://github.com/stumpylog) ([#14122](https://github.com/paperless-ngx/paperless-ngx/pull/14122))
|
||||
- Fix: reject non-dict user\_args/barcode\_tag\_mapping in config API [@stumpylog](https://github.com/stumpylog) ([#14118](https://github.com/paperless-ngx/paperless-ngx/pull/14118))
|
||||
- Fix: type edit\_pdf operations via a nested serializer [@stumpylog](https://github.com/stumpylog) ([#14116](https://github.com/paperless-ngx/paperless-ngx/pull/14116))
|
||||
- Performance: Loads the classifier through a memory view to reduce memory usage [@stumpylog](https://github.com/stumpylog) ([#14113](https://github.com/paperless-ngx/paperless-ngx/pull/14113))
|
||||
- Feature: parse advanced search with whoosh-compat and delete the handwritten translation [@stumpylog](https://github.com/stumpylog) ([#14072](https://github.com/paperless-ngx/paperless-ngx/pull/14072))
|
||||
- Fix: ensure django setup is run for management comments under 3.14 [@shamoon](https://github.com/shamoon) ([#14100](https://github.com/paperless-ngx/paperless-ngx/pull/14100))
|
||||
- Fix: validate PDF output doc indexes in bulk edit [@shamoon](https://github.com/shamoon) ([#14083](https://github.com/paperless-ngx/paperless-ngx/pull/14083))
|
||||
- Enhancement: allow regex timeout configuration [@shamoon](https://github.com/shamoon) ([#14085](https://github.com/paperless-ngx/paperless-ngx/pull/14085))
|
||||
- Fix: avoid IntegrityError when a retried task republishes with the same ID [@stumpylog](https://github.com/stumpylog) ([#14096](https://github.com/paperless-ngx/paperless-ngx/pull/14096))
|
||||
- Chore: include Apply AI Suggestions in the tasks UI filter dropdown [@shamoon](https://github.com/shamoon) ([#14093](https://github.com/paperless-ngx/paperless-ngx/pull/14093))
|
||||
- Fix: update some api global perms inconsistencies [@shamoon](https://github.com/shamoon) ([#14086](https://github.com/paperless-ngx/paperless-ngx/pull/14086))
|
||||
- Fix: ignore nested action IDs on WF create [@shamoon](https://github.com/shamoon) ([#14084](https://github.com/paperless-ngx/paperless-ngx/pull/14084))
|
||||
- Fix: correct text/stream compression workaround [@shamoon](https://github.com/shamoon) ([#14064](https://github.com/paperless-ngx/paperless-ngx/pull/14064))
|
||||
- Fix: ui version content switching inconsistencies [@shamoon](https://github.com/shamoon) ([#14066](https://github.com/paperless-ngx/paperless-ngx/pull/14066))
|
||||
- Fix: prevent saving changes to stale cached document object [@shamoon](https://github.com/shamoon) ([#14065](https://github.com/paperless-ngx/paperless-ngx/pull/14065))
|
||||
- Performance: batch permission assignment in bulk `set_permissions` [@stumpylog](https://github.com/stumpylog) ([#13806](https://github.com/paperless-ngx/paperless-ngx/pull/13806))
|
||||
- Performance: skip effective\_content annotation on document list unless required [@stumpylog](https://github.com/stumpylog) ([#13789](https://github.com/paperless-ngx/paperless-ngx/pull/13789))
|
||||
- Fix: ensure remove inbox tag children on remove\_inbox\_tags [@shamoon](https://github.com/shamoon) ([#14050](https://github.com/paperless-ngx/paperless-ngx/pull/14050))
|
||||
- Fix: connect add\_to\_index handler after document\_added [@shamoon](https://github.com/shamoon) ([#14058](https://github.com/paperless-ngx/paperless-ngx/pull/14058))
|
||||
- Enhancement: hide-able sidebar items [@shamoon](https://github.com/shamoon) ([#14052](https://github.com/paperless-ngx/paperless-ngx/pull/14052))
|
||||
- Performance: cut redundant per-document lookups in bulk `modify_custom_fields` [@stumpylog](https://github.com/stumpylog) ([#13807](https://github.com/paperless-ngx/paperless-ngx/pull/13807))
|
||||
- Enhancement: Improve matching for correspondents, storage path and labels by removing bias + adding minimum match threshold [@dewey](https://github.com/dewey) ([#12164](https://github.com/paperless-ngx/paperless-ngx/pull/12164))
|
||||
- Fix: Drop empty files from tracking after the stability window has passed [@stumpylog](https://github.com/stumpylog) ([#14047](https://github.com/paperless-ngx/paperless-ngx/pull/14047))
|
||||
- Fixhancement: better LLM errors [@shamoon](https://github.com/shamoon) ([#14031](https://github.com/paperless-ngx/paperless-ngx/pull/14031))
|
||||
- Fix: prevent orphaned versions from bulk delete [@shamoon](https://github.com/shamoon) ([#14030](https://github.com/paperless-ngx/paperless-ngx/pull/14030))
|
||||
- Enhancement: add Tantivy full-text fallback adapter for taxonomy candidates [@stumpylog](https://github.com/stumpylog) ([#13820](https://github.com/paperless-ngx/paperless-ngx/pull/13820))
|
||||
- Fixhancement: prevent overlapping mail-account processing runs [@stumpylog](https://github.com/stumpylog) ([#14046](https://github.com/paperless-ngx/paperless-ngx/pull/14046))
|
||||
- Performance: ensure version-aware content filters on querysets [@shamoon](https://github.com/shamoon) ([#13792](https://github.com/paperless-ngx/paperless-ngx/pull/13792))
|
||||
- Performance: skip nested TagSerializer construction when a tag has no children [@stumpylog](https://github.com/stumpylog) ([#14039](https://github.com/paperless-ngx/paperless-ngx/pull/14039))
|
||||
- Enhancement (QoL): surface externally-set options in Config UI [@shamoon](https://github.com/shamoon) ([#13989](https://github.com/paperless-ngx/paperless-ngx/pull/13989))
|
||||
- Enhancement: allow disabling auto-suggestions for inbox documents [@shamoon](https://github.com/shamoon) ([#13946](https://github.com/paperless-ngx/paperless-ngx/pull/13946))
|
||||
- Change: skip documents with empty content in apply AI suggestions WF [@shamoon](https://github.com/shamoon) ([#13985](https://github.com/paperless-ngx/paperless-ngx/pull/13985))
|
||||
- Performance: resolve index-write permissions and effective content in bulk [@stumpylog](https://github.com/stumpylog) ([#13869](https://github.com/paperless-ngx/paperless-ngx/pull/13869))
|
||||
- Fix: Use PAPERLESS\_REDIS\_PREFIX for Celery result backend keys [@bdd](https://github.com/bdd) ([#14015](https://github.com/paperless-ngx/paperless-ngx/pull/14015))
|
||||
- Enhancement: duplicates filter [@shamoon](https://github.com/shamoon) ([#13994](https://github.com/paperless-ngx/paperless-ngx/pull/13994))
|
||||
- Fix: correct setting ai\_enabled to false via UI [@shamoon](https://github.com/shamoon) ([#13987](https://github.com/paperless-ngx/paperless-ngx/pull/13987))
|
||||
- Chore(deps): Bump the utilities-minor group across 1 directory with 11 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13988](https://github.com/paperless-ngx/paperless-ngx/pull/13988))
|
||||
- Fix: catch some frontend failed object retrievals [@shamoon](https://github.com/shamoon) ([#14023](https://github.com/paperless-ngx/paperless-ngx/pull/14023))
|
||||
- Fix: more v3 icons cleanup [@shamoon](https://github.com/shamoon) ([#14017](https://github.com/paperless-ngx/paperless-ngx/pull/14017))
|
||||
- Fix: correct add version actor parity [@shamoon](https://github.com/shamoon) ([#14016](https://github.com/paperless-ngx/paperless-ngx/pull/14016))
|
||||
- Fix: fix v3 favicon file [@shamoon](https://github.com/shamoon) ([#14014](https://github.com/paperless-ngx/paperless-ngx/pull/14014))
|
||||
- Tweak: note that apply AI suggestions runs async in WF editor [@shamoon](https://github.com/shamoon) ([#14004](https://github.com/paperless-ngx/paperless-ngx/pull/14004))
|
||||
- Fix: change share link bundle dialog button to close after create, don't toast on copied [@shamoon](https://github.com/shamoon) ([#14002](https://github.com/paperless-ngx/paperless-ngx/pull/14002))
|
||||
- Enhancement (QoL): attempt to localize firstDayOfWeek for date picker [@shamoon](https://github.com/shamoon) ([#13999](https://github.com/paperless-ngx/paperless-ngx/pull/13999))
|
||||
- Fix: truncate mail subjects to field max length [@shamoon](https://github.com/shamoon) ([#13991](https://github.com/paperless-ngx/paperless-ngx/pull/13991))
|
||||
- Chore(deps): Bump sentence-transformers from 5.6.1 to 6.0.0 @[dependabot[bot]](https://github.com/apps/dependabot) ([#13983](https://github.com/paperless-ngx/paperless-ngx/pull/13983))
|
||||
- Chore(deps-dev): Bump types-markdown from 3.10.2.20260518 to 3.10.2.20260712 @[dependabot[bot]](https://github.com/apps/dependabot) ([#13982](https://github.com/paperless-ngx/paperless-ngx/pull/13982))
|
||||
- Chore(deps): Bump the utilities-patch group across 1 directory with 6 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13980](https://github.com/paperless-ngx/paperless-ngx/pull/13980))
|
||||
- Chore(deps): Update granian[uvloop] requirement from ~=2.7.0 to >=2.7,\<2.9 @[dependabot[bot]](https://github.com/apps/dependabot) ([#13984](https://github.com/paperless-ngx/paperless-ngx/pull/13984))
|
||||
- Chore: Updates our direct Redis pin [@stumpylog](https://github.com/stumpylog) ([#13986](https://github.com/paperless-ngx/paperless-ngx/pull/13986))
|
||||
- Fix: enforce the overflow hidden rule on pdf editor thumbnails [@shamoon](https://github.com/shamoon) ([#13976](https://github.com/paperless-ngx/paperless-ngx/pull/13976))
|
||||
- Fix: also correct unbroken long names on small cards [@shamoon](https://github.com/shamoon) ([#13974](https://github.com/paperless-ngx/paperless-ngx/pull/13974))
|
||||
- Chore(deps-dev): Bump the frontend-eslint-dependencies group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13918](https://github.com/paperless-ngx/paperless-ngx/pull/13918))
|
||||
- Chore(deps): Bump uuid from 14.0.1 to 14.0.2 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#13921](https://github.com/paperless-ngx/paperless-ngx/pull/13921))
|
||||
- Chore(deps-dev): Bump @types/node from 26.2.0 to 26.4.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#13919](https://github.com/paperless-ngx/paperless-ngx/pull/13919))
|
||||
- Chore: update ng-select to v24, handle breaking changes [@shamoon](https://github.com/shamoon) ([#13951](https://github.com/paperless-ngx/paperless-ngx/pull/13951))
|
||||
- Chore(deps): Bump djangorestframework from 3.17.2 to 3.18.0 in the django-ecosystem group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13912](https://github.com/paperless-ngx/paperless-ngx/pull/13912))
|
||||
- Chore(deps): Bump the document-processing group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13916](https://github.com/paperless-ngx/paperless-ngx/pull/13916))
|
||||
- Chore(deps): Bump flower from 2.0.1 to 2.1.0 in the async-tasks group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13913](https://github.com/paperless-ngx/paperless-ngx/pull/13913))
|
||||
- Chore(deps-dev): Bump the development group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13911](https://github.com/paperless-ngx/paperless-ngx/pull/13911))
|
||||
- Fix: ensure parent + child tags change together in bulk editor [@shamoon](https://github.com/shamoon) ([#13972](https://github.com/paperless-ngx/paperless-ngx/pull/13972))
|
||||
|
||||
</details>
|
||||
|
||||
## paperless-ngx 3.1.3
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
+48
-16
@@ -413,18 +413,12 @@ details.
|
||||
|
||||
Defaults to `PAPERLESS_DATA_DIR/log/`.
|
||||
|
||||
#### [`PAPERLESS_NLTK_DIR=<path>`](#PAPERLESS_NLTK_DIR) {#PAPERLESS_NLTK_DIR}
|
||||
#### ~~[`PAPERLESS_NLTK_DIR`](#PAPERLESS_NLTK_DIR)~~ {#PAPERLESS_NLTK_DIR}
|
||||
|
||||
: This is where paperless will search for the data required for NLTK
|
||||
processing, if you are using it. If you are using the Docker image,
|
||||
this should not be changed, as the data is included in the image
|
||||
already.
|
||||
!!! failure "Removed in v3.2"
|
||||
|
||||
Previously, the location defaulted to `PAPERLESS_DATA_DIR/nltk`.
|
||||
Unless you are using this in a bare metal install or other setup,
|
||||
this folder is no longer needed and can be removed manually.
|
||||
|
||||
Defaults to `/usr/share/nltk_data`
|
||||
Removed and ignored. Any previously downloaded NLTK data folder can be
|
||||
deleted.
|
||||
|
||||
#### [`PAPERLESS_MODEL_FILE=<path>`](#PAPERLESS_MODEL_FILE) {#PAPERLESS_MODEL_FILE}
|
||||
|
||||
@@ -1190,15 +1184,31 @@ for details on how to set it.
|
||||
|
||||
Defaults to UTC.
|
||||
|
||||
#### [`PAPERLESS_ENABLE_NLTK=<bool>`](#PAPERLESS_ENABLE_NLTK) {#PAPERLESS_ENABLE_NLTK}
|
||||
#### ~~[`PAPERLESS_ENABLE_NLTK`](#PAPERLESS_ENABLE_NLTK)~~ {#PAPERLESS_ENABLE_NLTK}
|
||||
|
||||
: Enables or disables the advanced natural language processing
|
||||
used during automatic classification. If disabled, paperless will
|
||||
still perform some basic text pre-processing before matching.
|
||||
!!! failure "Removed in v3.2"
|
||||
|
||||
: See also `PAPERLESS_NLTK_DIR`.
|
||||
Removed and ignored. Automatic classification always removes stop words
|
||||
and stems words when the primary OCR language is Danish, Dutch, English,
|
||||
Finnish, French, German, Italian, Norwegian, Portuguese, Russian, Spanish
|
||||
or Swedish. Other languages are only lowercased and split into words.
|
||||
|
||||
Defaults to true, enabling the feature.
|
||||
#### [`PAPERLESS_CLASSIFIER_MATCH_THRESHOLD=<float>`](#PAPERLESS_CLASSIFIER_MATCH_THRESHOLD) {#PAPERLESS_CLASSIFIER_MATCH_THRESHOLD}
|
||||
|
||||
: Sets the minimum confidence score (0.0-1.0) required for the automatic
|
||||
classifier to assign a correspondent, document type, or storage path to a
|
||||
document. Predictions below this threshold are discarded and the field is
|
||||
left unassigned, preventing low-confidence guesses from being applied.
|
||||
|
||||
Defaults to 0.3.
|
||||
|
||||
#### [`PAPERLESS_MATCH_REGEX_TIMEOUT_SECONDS=<float>`](#PAPERLESS_MATCH_REGEX_TIMEOUT_SECONDS) {#PAPERLESS_MATCH_REGEX_TIMEOUT_SECONDS}
|
||||
|
||||
: Sets the timeout, in seconds, for regular expression matching. Increase this
|
||||
value if date parsing or user-defined matching rules time out when processing
|
||||
long documents, especially on slower hardware.
|
||||
|
||||
Defaults to 0.1 seconds.
|
||||
|
||||
#### [`PAPERLESS_DATE_PARSER_LANGUAGES=<lang>`](#PAPERLESS_DATE_PARSER_LANGUAGES) {#PAPERLESS_DATE_PARSER_LANGUAGES}
|
||||
|
||||
@@ -1269,6 +1279,8 @@ Tantivy stemmer equivalent, stemming is disabled.
|
||||
matching. Fuzzy results rank below exact matches. A value of `0.5` is a reasonable
|
||||
starting point. Leave unset to disable fuzzy matching entirely.
|
||||
|
||||
Words of a single character are not fuzzy-matched, since a single-character approximate match would match nearly every term in the index.
|
||||
|
||||
Defaults to unset (disabled).
|
||||
|
||||
#### [`PAPERLESS_SANITY_TASK_CRON=<cron expression>`](#PAPERLESS_SANITY_TASK_CRON) {#PAPERLESS_SANITY_TASK_CRON}
|
||||
@@ -2121,6 +2133,13 @@ for language and resource considerations.
|
||||
|
||||
Defaults to None.
|
||||
|
||||
#### [`PAPERLESS_AI_LLM_EMBEDDING_API_KEY=<str>`](#PAPERLESS_AI_LLM_EMBEDDING_API_KEY) {#PAPERLESS_AI_LLM_EMBEDDING_API_KEY}
|
||||
|
||||
: The API key to use for the embedding backend. If not supplied, embeddings use
|
||||
`PAPERLESS_AI_LLM_API_KEY`.
|
||||
|
||||
Defaults to None.
|
||||
|
||||
#### [`PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT=<str>`](#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT) {#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT}
|
||||
|
||||
: The endpoint / url to use for the embedding backend. If not supplied, embeddings use
|
||||
@@ -2205,6 +2224,19 @@ used with the OpenAI-compatible backend to target a custom provider or local gat
|
||||
|
||||
Defaults to true, which allows internal endpoints.
|
||||
|
||||
#### [`PAPERLESS_AI_LLM_EXTRA_PARAMS=<json>`](#PAPERLESS_AI_LLM_EXTRA_PARAMS) {#PAPERLESS_AI_LLM_EXTRA_PARAMS}
|
||||
|
||||
: A JSON object of extra parameters sent with every LLM request, for providers that require a parameter Paperless does not
|
||||
set itself. Values here override Paperless' own, and no validation is performed. Whatever you put here is passed to the
|
||||
backend as-is, so an invalid parameter will simply be rejected by your provider. For example, current OpenAI reasoning
|
||||
models refuse tool calls on the chat completions API unless reasoning is off:
|
||||
|
||||
```
|
||||
PAPERLESS_AI_LLM_EXTRA_PARAMS={"reasoning_effort": "none"}
|
||||
```
|
||||
|
||||
Defaults to empty, which adds nothing to requests.
|
||||
|
||||
#### [`PAPERLESS_LLM_INDEX_TASK_CRON=<cron expression>`](#PAPERLESS_LLM_INDEX_TASK_CRON) {#PAPERLESS_LLM_INDEX_TASK_CRON}
|
||||
|
||||
: Configures the schedule to update the AI embeddings of text content and metadata for all documents. Only performed if
|
||||
|
||||
@@ -150,6 +150,7 @@ pnpm ng build --configuration production
|
||||
is loaded as well. However, the tests rely on the default
|
||||
configuration. This is not ideal. But for now, make sure no settings
|
||||
except for DEBUG are overridden when testing.
|
||||
- Tests run in a random order each session, so that one test cannot quietly depend on another having run first. The seed is printed at the top of the run; pass `--randomly-seed=<seed>` to replay that exact order, or `--randomly-seed=last` to repeat the previous run.
|
||||
|
||||
!!! note
|
||||
|
||||
|
||||
@@ -430,11 +430,6 @@ to a positive number to enable polling and disable native filesystem notificatio
|
||||
This will reduce the size of generated PDF documents. You'll most likely need to compile this yourself, because this
|
||||
software has been patented until around 2017 and binary packages are not available for most distributions.
|
||||
|
||||
**Optional: download the NLTK data**
|
||||
If using the NLTK machine-learning processing (see [`PAPERLESS_ENABLE_NLTK`](configuration.md#PAPERLESS_ENABLE_NLTK) for details),
|
||||
download the NLTK data for the Snowball Stemmer, Stopwords and Punkt tokenizer to `/usr/share/nltk_data`. Refer to the [NLTK
|
||||
instructions](https://www.nltk.org/data.html) for details on how to download the data.
|
||||
|
||||
#### After installation
|
||||
|
||||
Your Paperless-ngx instance should now be accessible at `http://localhost:8000` (or similar, depending on your configuration).
|
||||
@@ -650,9 +645,6 @@ hardware, but a few settings can improve performance:
|
||||
`PAPERLESS_OCR_CLEAN=none`. This will speed up OCR times and use
|
||||
less memory at the expense of slightly worse OCR results.
|
||||
- If using Docker, consider setting [`PAPERLESS_WEBSERVER_WORKERS`](configuration.md#PAPERLESS_WEBSERVER_WORKERS) to 1. This will save some memory.
|
||||
- Consider setting [`PAPERLESS_ENABLE_NLTK`](configuration.md#PAPERLESS_ENABLE_NLTK) to false, to disable the
|
||||
more advanced language processing, which can take more memory and
|
||||
processing time.
|
||||
|
||||
For details, refer to [configuration](configuration.md).
|
||||
|
||||
|
||||
+85
-38
@@ -927,52 +927,105 @@ typed in the search bar. A few things to know about how matching works:
|
||||
|
||||
Paperless also offers advanced search syntax if you want to drill down further.
|
||||
|
||||
Matching documents with logical expressions:
|
||||
#### Combining terms
|
||||
|
||||
```
|
||||
shopname AND (product1 OR product2)
|
||||
invoice NOT draft
|
||||
"quick brown fox"
|
||||
```
|
||||
|
||||
Matching specific tags, correspondents or types:
|
||||
- `AND`, `OR` and `NOT` must be written in capitals. Parentheses group terms.
|
||||
- Terms with no operator between them are combined with `AND`.
|
||||
- Quotes match an exact phrase, with the words in that order.
|
||||
|
||||
!!! warning
|
||||
|
||||
A leading `-` does **not** exclude a term. `invoice -secret` finds documents containing both words. Use `invoice NOT secret` instead.
|
||||
|
||||
#### Searching by field
|
||||
|
||||
Put a field name and a colon in front of a value to search only that field:
|
||||
|
||||
```
|
||||
type:invoice tag:unpaid
|
||||
correspondent:university certificate
|
||||
correspondent:"acme corp"
|
||||
tag:bills,unpaid
|
||||
asn:[50 to 150]
|
||||
checksum:9f86d081*
|
||||
```
|
||||
|
||||
Matching dates:
|
||||
| Field | Searches |
|
||||
| ------------------------- | ---------------------------------------- |
|
||||
| `title` | Title |
|
||||
| `content` | Text content |
|
||||
| `correspondent` | Correspondent |
|
||||
| `document_type` or `type` | Document type |
|
||||
| `storage_path` or `path` | Storage path |
|
||||
| `tag` | Tags |
|
||||
| `original_filename` | File name the document was consumed with |
|
||||
| `asn` | Archive serial number |
|
||||
| `page_count` | Number of pages |
|
||||
| `num_notes` | Number of notes |
|
||||
| `checksum` | Checksum of the original file |
|
||||
| `created` | Created date |
|
||||
| `added` | When the document was added to paperless |
|
||||
| `modified` | When the document was last modified |
|
||||
|
||||
- A field applies only to the word right after it. Quote multi-word values: `correspondent:"acme corp"`.
|
||||
- A comma-separated `tag` list requires every listed tag, so `tag:bills,unpaid` only matches documents tagged with both.
|
||||
- `asn`, `page_count` and `num_notes` are numbers. They accept ranges like `asn:[50 to 150]`, but not wildcards.
|
||||
- `checksum` only matches the complete checksum, in lowercase. To search by its first few characters, add a wildcard: `checksum:9f86d081*`.
|
||||
- `created`, `added` and `modified` take the values described in [Searching by date](#searching-by-date).
|
||||
- Custom fields and notes have their own syntax, described [below](#searching-custom-fields).
|
||||
|
||||
#### Wildcards
|
||||
|
||||
```
|
||||
invoice*
|
||||
title:Invoice*
|
||||
20[12]?
|
||||
20[!0]?
|
||||
```
|
||||
|
||||
- `*` matches any number of characters, and `?` matches exactly one.
|
||||
- `[...]` matches one character from a set or range, and `[!...]` matches one character not in it. `20[12]?` matches 2010 to 2029.
|
||||
- Brackets only act as a wildcard when the value also contains a `*` or `?`. Otherwise they are searched as ordinary text. The exception is a single-character range such as `title:200[1-9]`, which is rejected with an error. Add a wildcard to use it as a pattern: `title:200[1-9]*`.
|
||||
|
||||
!!! note
|
||||
|
||||
When a [stemmer is available](configuration.md#PAPERLESS_SEARCH_LANGUAGE) for your search language, words are indexed by their stem, so `copy*` also finds "copies". A prefix that runs past the stem can find nothing: `universit*` misses "university", which is stored as `univers`. If a wildcard finds nothing, try a shorter prefix, such as `univers*`.
|
||||
|
||||
#### Searching by date
|
||||
|
||||
```
|
||||
created:[2005 to 2009]
|
||||
added:yesterday
|
||||
modified:today
|
||||
modified:"previous month"
|
||||
created:[2005 to 2009]
|
||||
added:[-1 week to now]
|
||||
```
|
||||
|
||||
Matching inexact words:
|
||||
These keywords each cover a whole period, and work with or without quotes: `today`, `yesterday`, `tomorrow`, `previous week`, `this month`, `previous month`, `previous quarter`, `this year`, `previous year`.
|
||||
|
||||
```
|
||||
produ*name
|
||||
```
|
||||
Other supported forms:
|
||||
|
||||
Matching natural date keywords:
|
||||
| Example | Matches | Quotes |
|
||||
| ------------------------------------------------------- | ------------------------------ | -------- |
|
||||
| `created:2005`, `created:2005-01`, `created:2005-03-04` | That year, month or day | Optional |
|
||||
| `added:january` | That month in the current year | Optional |
|
||||
| `added:"next monday"`, `added:"last monday"` | That day | Required |
|
||||
| `added:"12 december 2019"` | That day | Required |
|
||||
| `added:"2005-01-01T00:00:00Z"` | That exact time | Required |
|
||||
|
||||
```
|
||||
added:today
|
||||
modified:yesterday
|
||||
created:"previous week"
|
||||
added:"previous month"
|
||||
modified:"this year"
|
||||
```
|
||||
Ranges take two bounds in square brackets, for example `created:[2005 to 2009]`. A bound can be any of the forms above, or a relative time like `-1 week`, `now-7d` or `now`. Bounds don't need quotes. If you do quote one, use single quotes (`added:['-1 week' to now]`), because double quotes are rejected.
|
||||
|
||||
Supported date keywords: `today`, `yesterday`, `previous week`,
|
||||
`this month`, `previous month`, `this year`, `previous year`,
|
||||
`previous quarter`.
|
||||
!!! warning
|
||||
|
||||
`now`, `noon`, `midnight` and relative times like `-1 week` only work as range bounds. On their own they mean a single instant, so `added:"-1 week"` finds nothing. Use `added:[-1 week to now]` instead. A bare weekday (`monday`) and spellings like `3 days ago` or `this week` are not supported at all.
|
||||
|
||||
#### Searching custom fields
|
||||
|
||||
Custom field names and values are included in the full-text index, but they
|
||||
are not searched by a plain, unqualified query. Use the advanced search syntax
|
||||
to search by field name or value:
|
||||
Custom field names and values are included in the full-text index, but a plain search without a field name does not look at them. Use the advanced search syntax to search by field name or value:
|
||||
|
||||
```
|
||||
custom_fields.value:policy
|
||||
@@ -983,10 +1036,9 @@ custom_fields.name:Insurance custom_fields.value:policy
|
||||
- `custom_fields.value` matches against the value of any custom field.
|
||||
- `custom_fields.name` matches the name of the field (use quotes for multi-word names).
|
||||
- Combine both to find documents where a specific named field contains a specific value.
|
||||
- The bare `custom_fields:` prefix is shorthand for `custom_fields.value:`.
|
||||
|
||||
Because separators are stripped during indexing, individual parts of formatted
|
||||
codes are searchable on their own. A value stored as `A-1312/99.50` produces the
|
||||
tokens `a`, `1312`, `99`, `50` — each searchable independently:
|
||||
Because separators are stripped during indexing, each part of a formatted code can be searched on its own. A value stored as `A-1312/99.50` is indexed as `a`, `1312`, `99` and `50`:
|
||||
|
||||
```
|
||||
custom_fields.value:1312
|
||||
@@ -995,14 +1047,11 @@ custom_fields.name:"Contract Number" custom_fields.value:1312
|
||||
|
||||
!!! note
|
||||
|
||||
Custom date fields do not support relative date syntax (e.g. `[now to 2 weeks]`).
|
||||
For date ranges on custom date fields, use the document list filters in the web UI.
|
||||
Custom date fields do not support relative date syntax such as `[now to 2 weeks]`. For date ranges on custom date fields, use the document list filters in the web UI.
|
||||
|
||||
#### Searching notes
|
||||
|
||||
Notes are included in the full-text index, but they are not searched by a
|
||||
plain, unqualified query. Use the advanced search syntax to search by note
|
||||
author or content:
|
||||
Notes are included in the full-text index, but a plain search without a field name does not look at them. Use the advanced search syntax to search by note author or content:
|
||||
|
||||
```
|
||||
notes.user:alice
|
||||
@@ -1010,15 +1059,13 @@ notes.note:reminder
|
||||
notes.user:alice notes.note:insurance
|
||||
```
|
||||
|
||||
All of these constructs can be combined as you see fit. If you want to
|
||||
learn more about the query language used by paperless, see the
|
||||
[Tantivy query language documentation](https://docs.rs/tantivy/latest/tantivy/query/struct.QueryParser.html).
|
||||
The bare `notes:` prefix is shorthand for `notes.note:`.
|
||||
|
||||
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
|
||||
|
||||
Fuzzy (approximate) matching can be enabled by setting
|
||||
[`PAPERLESS_ADVANCED_FUZZY_SEARCH_THRESHOLD`](configuration.md#PAPERLESS_ADVANCED_FUZZY_SEARCH_THRESHOLD).
|
||||
When enabled, paperless will include near-miss results ranked below exact matches.
|
||||
Fuzzy (approximate) matching can be enabled by setting [`PAPERLESS_ADVANCED_FUZZY_SEARCH_THRESHOLD`](configuration.md#PAPERLESS_ADVANCED_FUZZY_SEARCH_THRESHOLD). When enabled, paperless also includes near-miss results, ranked below exact matches.
|
||||
|
||||
## Keyboard shortcuts / hotkeys
|
||||
|
||||
|
||||
+25
-14
@@ -1,7 +1,9 @@
|
||||
[project]
|
||||
name = "paperless-ngx"
|
||||
version = "3.1.3"
|
||||
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
||||
version = "3.2.1"
|
||||
description = """\
|
||||
A community-supported supercharged document management system: scan, index and archive all your physical documents\
|
||||
"""
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
classifiers = [
|
||||
@@ -10,13 +12,13 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Programming Language :: Python :: 3.15",
|
||||
]
|
||||
# TODO: Move certain things to groups and then utilize that further
|
||||
# This will allow testing to not install a webserver, mysql, etc
|
||||
dependencies = [
|
||||
"azure-ai-documentintelligence>=1.0.2",
|
||||
"babel>=2.17",
|
||||
"bleach~=6.4.0",
|
||||
"celery[redis]~=5.6.2",
|
||||
"channels~=4.2",
|
||||
"channels-redis~=4.2",
|
||||
@@ -39,7 +41,7 @@ dependencies = [
|
||||
"django-treenode>=0.24",
|
||||
"djangorestframework~=3.16",
|
||||
"drf-spectacular~=0.30",
|
||||
"drf-spectacular-sidecar>=2026.7.1,<2026.9",
|
||||
"drf-spectacular-sidecar>=2026.7.1,<2026.10",
|
||||
"drf-writable-nested~=0.7.1",
|
||||
"filelock~=3.32.0",
|
||||
"flower>=2.0.1,<2.2",
|
||||
@@ -55,8 +57,7 @@ dependencies = [
|
||||
"llama-index-embeddings-openai-like>=0.2.2",
|
||||
"llama-index-llms-ollama>=0.9.1",
|
||||
"llama-index-llms-openai-like>=0.7.1",
|
||||
"nltk~=3.10.0",
|
||||
"ocrmypdf>=17.7,<17.12",
|
||||
"ocrmypdf[heic]>=17.12,<17.13",
|
||||
"openai>=2.48",
|
||||
"pathvalidate~=3.3.1",
|
||||
"pdf2image~=1.17.0",
|
||||
@@ -74,9 +75,11 @@ dependencies = [
|
||||
"sqlite-vec==0.1.9",
|
||||
"tantivy~=0.26.0",
|
||||
"tika-client[httpx]~=1.0",
|
||||
"torch~=2.13.0",
|
||||
"torch>=2.13,<2.15",
|
||||
"turbohtml~=1.10.0",
|
||||
"watchfiles>=1.2",
|
||||
"whitenoise~=6.11",
|
||||
"whoosh-compat[tantivy]==0.3",
|
||||
"zxing-cpp~=3.1.0",
|
||||
]
|
||||
[project.optional-dependencies]
|
||||
@@ -109,7 +112,7 @@ lint = [
|
||||
testing = [
|
||||
"daphne",
|
||||
"factory-boy~=3.3.1",
|
||||
"faker>=40.36,<40.38",
|
||||
"faker>=40.36,<40.39",
|
||||
"imagehash",
|
||||
"pytest~=9.1.1",
|
||||
"pytest-cov~=7.1.0",
|
||||
@@ -117,7 +120,7 @@ testing = [
|
||||
"pytest-env~=1.7.0",
|
||||
"pytest-httpx",
|
||||
"pytest-mock~=3.15.1",
|
||||
# "pytest-randomly~=4.0.1",
|
||||
"pytest-randomly~=5.0.0",
|
||||
"pytest-rerunfailures~=16.4",
|
||||
"pytest-sugar",
|
||||
"pytest-xdist~=3.8.0",
|
||||
@@ -133,7 +136,6 @@ typing = [
|
||||
"mypy",
|
||||
"mypy-baseline",
|
||||
"pyrefly",
|
||||
"types-bleach",
|
||||
"types-channels",
|
||||
"types-colorama",
|
||||
"types-dateparser",
|
||||
@@ -148,6 +150,7 @@ typing = [
|
||||
|
||||
[tool.uv]
|
||||
required-version = ">=0.9.0"
|
||||
prerelease = "disallow"
|
||||
environments = [
|
||||
"sys_platform == 'darwin'",
|
||||
"sys_platform == 'linux'",
|
||||
@@ -244,10 +247,14 @@ per-file-ignores."docker/wait-for-redis.py" = [
|
||||
per-file-ignores."src/documents/models.py" = [
|
||||
"SIM115",
|
||||
]
|
||||
per-file-ignores."src/documents/tests/*.py" = [
|
||||
"TID251",
|
||||
]
|
||||
flake8-tidy-imports.banned-api."documents.tests".msg = "Shared test infrastructure lives in src/paperless_testing/."
|
||||
isort.force-single-line = true
|
||||
|
||||
[tool.codespell]
|
||||
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish,NIN,nin"
|
||||
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish,NIN,nin,reprot"
|
||||
skip = """\
|
||||
src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/paperless/tests/samples\
|
||||
/mail/*,src/documents/tests/samples/*,*.po,*.json\
|
||||
@@ -271,9 +278,9 @@ plugins = [
|
||||
]
|
||||
|
||||
[tool.pyrefly]
|
||||
baseline = ".pyrefly-baseline.json"
|
||||
python-platform = "linux"
|
||||
search-path = [ "src" ]
|
||||
baseline = ".pyrefly-baseline.json"
|
||||
|
||||
[tool.django-stubs]
|
||||
django_settings_module = "paperless.settings"
|
||||
@@ -326,6 +333,8 @@ PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
|
||||
PAPERLESS_CHANNELS_BACKEND = "channels.layers.InMemoryChannelLayer"
|
||||
# I don't think anything hits this, but just in case, basically infinite
|
||||
PAPERLESS_TOKEN_THROTTLE_RATE = "1000/min"
|
||||
# The 0.1s production default trips on a stalled CI runner, the date parsing tests then find no dates
|
||||
PAPERLESS_MATCH_REGEX_TIMEOUT_SECONDS = "5"
|
||||
|
||||
[tool.coverage.run]
|
||||
source = [
|
||||
@@ -334,13 +343,15 @@ source = [
|
||||
omit = [
|
||||
"*/tests/*",
|
||||
"manage.py",
|
||||
"paperless/wsgi.py",
|
||||
"paperless/auth.py",
|
||||
"paperless/wsgi.py",
|
||||
"src/conftest.py",
|
||||
"src/paperless_testing/*",
|
||||
]
|
||||
[tool.coverage.report]
|
||||
exclude_also = [
|
||||
"if settings.AUDIT_LOG_ENABLED:",
|
||||
"if AUDIT_LOG_ENABLED:",
|
||||
"if settings.AUDIT_LOG_ENABLED:",
|
||||
"if TYPE_CHECKING:",
|
||||
]
|
||||
|
||||
|
||||
+809
-559
File diff suppressed because it is too large
Load Diff
+29
-29
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paperless-ngx-ui",
|
||||
"version": "3.1.3",
|
||||
"version": "3.2.1",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"ng": "ng",
|
||||
@@ -15,16 +15,16 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^22.1.4",
|
||||
"@angular/common": "~22.1.3",
|
||||
"@angular/compiler": "~22.1.3",
|
||||
"@angular/core": "~22.1.3",
|
||||
"@angular/forms": "~22.1.3",
|
||||
"@angular/localize": "~22.1.3",
|
||||
"@angular/platform-browser": "~22.1.3",
|
||||
"@angular/router": "~22.1.3",
|
||||
"@angular/cdk": "^22.1.7",
|
||||
"@angular/common": "~22.1.7",
|
||||
"@angular/compiler": "~22.1.7",
|
||||
"@angular/core": "~22.1.7",
|
||||
"@angular/forms": "~22.1.7",
|
||||
"@angular/localize": "~22.1.7",
|
||||
"@angular/platform-browser": "~22.1.7",
|
||||
"@angular/router": "~22.1.7",
|
||||
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
||||
"@ng-select/ng-select": "~24.0.2",
|
||||
"@ng-select/ng-select": "~24.1.2",
|
||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"bootstrap": "^5.3.8",
|
||||
@@ -37,7 +37,7 @@
|
||||
"ngx-device-detector": "^12.0.0",
|
||||
"ngx-ui-tour-ng-bootstrap": "^19.0.0",
|
||||
"normalize-diacritics": "^5.0.0",
|
||||
"pdfjs-dist": "^6.2.108",
|
||||
"pdfjs-dist": "^6.3.289",
|
||||
"rxjs": "^7.8.2",
|
||||
"tslib": "^2.8.1",
|
||||
"utif": "^3.1.0",
|
||||
@@ -45,29 +45,29 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-builders/jest": "^22.0.1",
|
||||
"@angular-devkit/core": "^22.1.6",
|
||||
"@angular-devkit/schematics": "^22.1.6",
|
||||
"@angular-eslint/builder": "22.1.0",
|
||||
"@angular-eslint/eslint-plugin": "22.1.0",
|
||||
"@angular-eslint/eslint-plugin-template": "22.1.0",
|
||||
"@angular-eslint/schematics": "22.1.0",
|
||||
"@angular-eslint/template-parser": "22.1.0",
|
||||
"@angular/build": "22.1.6",
|
||||
"@angular/cli": "22.1.6",
|
||||
"@angular/compiler-cli": "~22.1.3",
|
||||
"@angular-devkit/core": "^22.1.8",
|
||||
"@angular-devkit/schematics": "^22.1.8",
|
||||
"@angular-eslint/builder": "22.5.0",
|
||||
"@angular-eslint/eslint-plugin": "22.5.0",
|
||||
"@angular-eslint/eslint-plugin-template": "22.5.0",
|
||||
"@angular-eslint/schematics": "22.5.0",
|
||||
"@angular-eslint/template-parser": "22.5.0",
|
||||
"@angular/build": "22.1.8",
|
||||
"@angular/cli": "22.1.8",
|
||||
"@angular/compiler-cli": "~22.1.7",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^26.4.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.68.0",
|
||||
"@typescript-eslint/parser": "^8.68.0",
|
||||
"@typescript-eslint/utils": "^8.68.0",
|
||||
"eslint": "^10.9.1",
|
||||
"jest": "30.4.2",
|
||||
"jest-environment-jsdom": "^30.4.1",
|
||||
"@types/node": "^26.6.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.70.0",
|
||||
"@typescript-eslint/parser": "^8.70.0",
|
||||
"@typescript-eslint/utils": "^8.70.0",
|
||||
"eslint": "^10.11.0",
|
||||
"jest": "30.5.2",
|
||||
"jest-environment-jsdom": "^30.5.2",
|
||||
"jest-junit": "^17.0.0",
|
||||
"jest-preset-angular": "^17.0.0",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"prettier": "^3.9.6",
|
||||
"prettier": "^3.9.8",
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"ts-node": "~10.9.2",
|
||||
"typescript": "^6.0.3"
|
||||
|
||||
Generated
+1337
-1075
File diff suppressed because it is too large
Load Diff
@@ -13,9 +13,8 @@ import { DocumentDetailComponent } from './components/document-detail/document-d
|
||||
import { DocumentListComponent } from './components/document-list/document-list.component'
|
||||
import { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.component'
|
||||
import { MailComponent } from './components/manage/mail/mail.component'
|
||||
import { OcrTemplateEditorComponent } from './components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component'
|
||||
import { OcrTemplatesComponent } from './components/manage/ocr-templates/ocr-templates.component'
|
||||
import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component'
|
||||
import { ShareLinksComponent } from './components/manage/share-links/share-links.component'
|
||||
import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
|
||||
import { NotFoundComponent } from './components/not-found/not-found.component'
|
||||
import { DirtyDocGuard } from './guards/dirty-doc.guard'
|
||||
@@ -276,42 +275,6 @@ export const routes: Routes = [
|
||||
componentName: 'WorkflowsComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'ocr-templates',
|
||||
component: OcrTemplatesComponent,
|
||||
canActivate: [PermissionsGuard],
|
||||
data: {
|
||||
requiredPermission: {
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.OcrTemplate,
|
||||
},
|
||||
componentName: 'OcrTemplatesComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'ocr-templates/new',
|
||||
component: OcrTemplateEditorComponent,
|
||||
canActivate: [PermissionsGuard],
|
||||
data: {
|
||||
requiredPermission: {
|
||||
action: PermissionAction.Add,
|
||||
type: PermissionType.OcrTemplate,
|
||||
},
|
||||
componentName: 'OcrTemplateEditorComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'ocr-templates/:id',
|
||||
component: OcrTemplateEditorComponent,
|
||||
canActivate: [PermissionsGuard],
|
||||
data: {
|
||||
requiredPermission: {
|
||||
action: PermissionAction.Change,
|
||||
type: PermissionType.OcrTemplate,
|
||||
},
|
||||
componentName: 'OcrTemplateEditorComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'mail',
|
||||
component: MailComponent,
|
||||
@@ -348,6 +311,24 @@ export const routes: Routes = [
|
||||
componentName: 'SavedViewsComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'share-links',
|
||||
component: ShareLinksComponent,
|
||||
canActivate: [PermissionsGuard],
|
||||
data: {
|
||||
requiredPermissionAny: [
|
||||
{
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.ShareLink,
|
||||
},
|
||||
{
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.ShareLinkBundle,
|
||||
},
|
||||
],
|
||||
componentName: 'ShareLinksComponent',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -112,6 +112,22 @@
|
||||
|
||||
<pngx-input-check i18n-title title="Use 'slim' sidebar (icons only)" formControlName="slimSidebarEnabled"></pngx-input-check>
|
||||
|
||||
<p class="mb-2 mt-3" i18n>Sidebar items to show:</p>
|
||||
@for (option of sidebarItemOptions; track option.id) {
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
[id]="'sidebar-item-setting-' + option.id"
|
||||
[checked]="isSidebarItemShown(option.id)"
|
||||
(change)="toggleSidebarItem(option.id, $event.target.checked)"
|
||||
/>
|
||||
<label class="form-check-label" [for]="'sidebar-item-setting-' + option.id">
|
||||
{{ option.label }}
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
SystemStatus,
|
||||
SystemStatusItemStatus,
|
||||
} from 'src/app/data/system-status'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { HideableSidebarItemID, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||
@@ -209,6 +209,45 @@ describe('SettingsComponent', () => {
|
||||
fixture.detectChanges()
|
||||
}
|
||||
|
||||
it('supports configuring sidebar items and canceling changes', () => {
|
||||
completeSetup()
|
||||
|
||||
component.toggleSidebarItem(HideableSidebarItemID.Workflows, false)
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(component.settingsForm.value.sidebarHiddenItems).toContain(
|
||||
HideableSidebarItemID.Workflows
|
||||
)
|
||||
|
||||
settingsService.updateSidebarItemVisibility(
|
||||
HideableSidebarItemID.Mail,
|
||||
false
|
||||
)
|
||||
|
||||
expect(component.settingsForm.value.sidebarHiddenItems).toContain(
|
||||
HideableSidebarItemID.Mail
|
||||
)
|
||||
|
||||
component.reset()
|
||||
|
||||
expect(component.settingsForm.value.sidebarHiddenItems).not.toContain(
|
||||
HideableSidebarItemID.Workflows
|
||||
)
|
||||
expect(component.settingsForm.value.sidebarHiddenItems).not.toContain(
|
||||
HideableSidebarItemID.Mail
|
||||
)
|
||||
})
|
||||
|
||||
it('enables sidebar item controls on general settings until destroyed', () => {
|
||||
completeSetup()
|
||||
|
||||
expect(settingsService.organizingSidebarItems()).toBe(true)
|
||||
|
||||
component.ngOnDestroy()
|
||||
|
||||
expect(settingsService.organizingSidebarItems()).toBe(false)
|
||||
})
|
||||
|
||||
it('should support tabbed settings & change URL, prevent navigation if dirty confirmation rejected', async () => {
|
||||
completeSetup()
|
||||
const navigateSpy = jest.spyOn(router, 'navigate')
|
||||
@@ -249,6 +288,7 @@ describe('SettingsComponent', () => {
|
||||
|
||||
it('should support save local settings updating appearance settings and calling API, show error', () => {
|
||||
completeSetup()
|
||||
component.toggleSidebarItem(HideableSidebarItemID.Workflows, false)
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const toastSpy = jest.spyOn(toastService, 'show')
|
||||
const storeSpy = jest.spyOn(settingsService, 'storeSettings')
|
||||
@@ -267,7 +307,10 @@ describe('SettingsComponent', () => {
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(storeSpy).toHaveBeenCalled()
|
||||
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
||||
expect(setSpy).toHaveBeenCalledTimes(33)
|
||||
expect(setSpy).toHaveBeenCalledTimes(34)
|
||||
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
|
||||
HideableSidebarItemID.Workflows,
|
||||
])
|
||||
|
||||
// succeed
|
||||
storeSpy.mockReturnValueOnce(of(true))
|
||||
|
||||
@@ -39,7 +39,12 @@ import {
|
||||
SystemStatus,
|
||||
SystemStatusItemStatus,
|
||||
} from 'src/app/data/system-status'
|
||||
import { GlobalSearchType, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import {
|
||||
GlobalSearchType,
|
||||
HIDEABLE_SIDEBAR_ITEM_IDS,
|
||||
HideableSidebarItemID,
|
||||
SETTINGS_KEYS,
|
||||
} from 'src/app/data/ui-settings'
|
||||
import { User } from 'src/app/data/user'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
@@ -102,6 +107,15 @@ const documentDetailFieldOptions = [
|
||||
{ id: DocumentDetailFieldID.Tags, label: $localize`Tags` },
|
||||
]
|
||||
|
||||
const sidebarItemLabels: Record<HideableSidebarItemID, string> = {
|
||||
[HideableSidebarItemID.Dashboard]: $localize`Dashboard`,
|
||||
[HideableSidebarItemID.SavedViews]: $localize`Saved Views`,
|
||||
[HideableSidebarItemID.ShareLinks]: $localize`Share Links`,
|
||||
[HideableSidebarItemID.Workflows]: $localize`Workflows`,
|
||||
[HideableSidebarItemID.Mail]: $localize`Mail`,
|
||||
[HideableSidebarItemID.Documentation]: $localize`Documentation`,
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-settings',
|
||||
templateUrl: './settings.component.html',
|
||||
@@ -149,6 +163,7 @@ export class SettingsComponent
|
||||
bulkEditApplyOnClose: new FormControl(null),
|
||||
documentListItemPerPage: new FormControl(null),
|
||||
slimSidebarEnabled: new FormControl(null),
|
||||
sidebarHiddenItems: new FormControl<HideableSidebarItemID[]>([]),
|
||||
darkModeUseSystem: new FormControl(null),
|
||||
darkModeEnabled: new FormControl(null),
|
||||
darkModeInvertThumbs: new FormControl(null),
|
||||
@@ -186,6 +201,7 @@ export class SettingsComponent
|
||||
|
||||
store: BehaviorSubject<any>
|
||||
storeSub: Subscription
|
||||
sidebarItemsSub: Subscription
|
||||
isDirty$: Observable<boolean>
|
||||
isDirty: boolean = false
|
||||
unsubscribeNotifier: Subject<any> = new Subject()
|
||||
@@ -203,6 +219,10 @@ export class SettingsComponent
|
||||
public readonly PdfEditorEditMode = PdfEditorEditMode
|
||||
|
||||
public readonly documentDetailFieldOptions = documentDetailFieldOptions
|
||||
public readonly sidebarItemOptions = HIDEABLE_SIDEBAR_ITEM_IDS.map((id) => ({
|
||||
id,
|
||||
label: sidebarItemLabels[id],
|
||||
}))
|
||||
|
||||
get systemStatusHasErrors(): boolean {
|
||||
const status = this.systemStatus()
|
||||
@@ -230,6 +250,10 @@ export class SettingsComponent
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.sidebarItemsSub =
|
||||
this.settings.sidebarHiddenItemsEditingChanged.subscribe((hiddenItems) =>
|
||||
this.settingsForm.controls.sidebarHiddenItems.setValue(hiddenItems)
|
||||
)
|
||||
this.settings.settingsSaved.subscribe(() => {
|
||||
if (!this.savePending) this.initialize()
|
||||
this.savedViewsService.maybeRefreshDocumentCounts()
|
||||
@@ -279,14 +303,21 @@ export class SettingsComponent
|
||||
|
||||
this.activatedRoute.paramMap.subscribe((paramMap) => {
|
||||
const section = paramMap.get('section')
|
||||
let navID = SettingsNavIDs.General
|
||||
if (section) {
|
||||
const navIDKey: string = Object.keys(SettingsNavIDs).find(
|
||||
(navID) => navID.toLowerCase() == section
|
||||
)
|
||||
if (navIDKey) {
|
||||
this.activeNavID.set(SettingsNavIDs[navIDKey])
|
||||
navID = SettingsNavIDs[navIDKey]
|
||||
}
|
||||
}
|
||||
this.activeNavID.set(navID)
|
||||
this.settings.sidebarHiddenItemsEditing.set(
|
||||
navID === SettingsNavIDs.General
|
||||
? [...this.settingsForm.controls.sidebarHiddenItems.value]
|
||||
: null
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -310,6 +341,7 @@ export class SettingsComponent
|
||||
SETTINGS_KEYS.DOCUMENT_LIST_SIZE
|
||||
),
|
||||
slimSidebarEnabled: this.settings.get(SETTINGS_KEYS.SLIM_SIDEBAR),
|
||||
sidebarHiddenItems: this.settings.get(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS),
|
||||
darkModeUseSystem: this.settings.get(SETTINGS_KEYS.DARK_MODE_USE_SYSTEM),
|
||||
darkModeEnabled: this.settings.get(SETTINGS_KEYS.DARK_MODE_ENABLED),
|
||||
darkModeInvertThumbs: this.settings.get(
|
||||
@@ -436,6 +468,12 @@ export class SettingsComponent
|
||||
this.settingsForm.patchValue(currentFormValue)
|
||||
}
|
||||
|
||||
if (this.settings.organizingSidebarItems()) {
|
||||
this.settings.sidebarHiddenItemsEditing.set([
|
||||
...this.settingsForm.controls.sidebarHiddenItems.value,
|
||||
])
|
||||
}
|
||||
|
||||
if (this.canViewSystemStatus) {
|
||||
this.systemStatusService.get().subscribe((status) => {
|
||||
this.systemStatus.set(status)
|
||||
@@ -444,8 +482,18 @@ export class SettingsComponent
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.settings.sidebarHiddenItemsEditing.set(null)
|
||||
if (this.isDirty) this.settings.updateAppearanceSettings() // in case user changed appearance but didn't save
|
||||
this.storeSub && this.storeSub.unsubscribe()
|
||||
this.sidebarItemsSub.unsubscribe()
|
||||
}
|
||||
|
||||
isSidebarItemShown(item: HideableSidebarItemID): boolean {
|
||||
return !(this.settingsForm.value.sidebarHiddenItems || []).includes(item)
|
||||
}
|
||||
|
||||
toggleSidebarItem(item: HideableSidebarItemID, checked: boolean): void {
|
||||
this.settings.updateSidebarItemVisibility(item, checked)
|
||||
}
|
||||
|
||||
public saveSettings() {
|
||||
@@ -473,6 +521,10 @@ export class SettingsComponent
|
||||
SETTINGS_KEYS.SLIM_SIDEBAR,
|
||||
this.settingsForm.value.slimSidebarEnabled
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS,
|
||||
this.settingsForm.value.sidebarHiddenItems
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.DARK_MODE_USE_SYSTEM,
|
||||
this.settingsForm.value.darkModeUseSystem
|
||||
@@ -632,6 +684,11 @@ export class SettingsComponent
|
||||
|
||||
reset() {
|
||||
this.settingsForm.patchValue(this.store.getValue())
|
||||
if (this.settings.organizingSidebarItems()) {
|
||||
this.settings.sidebarHiddenItemsEditing.set([
|
||||
...this.settingsForm.controls.sidebarHiddenItems.value,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
clearThemeColor() {
|
||||
|
||||
@@ -99,6 +99,10 @@ const TASK_TYPE_OPTIONS: Array<{
|
||||
value: PaperlessTaskType.BulkDelete,
|
||||
label: $localize`Bulk Delete`,
|
||||
},
|
||||
{
|
||||
value: PaperlessTaskType.ApplyAiSuggestions,
|
||||
label: $localize`Apply AI Suggestions`,
|
||||
},
|
||||
]
|
||||
|
||||
const TRIGGER_SOURCE_OPTIONS: Array<{
|
||||
|
||||
@@ -86,12 +86,15 @@
|
||||
}
|
||||
<div class="sidebar-sticky pt-3 pb-1 d-flex flex-column justify-space-around">
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item app-link">
|
||||
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
||||
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard) && !settingsService.organizingSidebarItems()">
|
||||
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="house"></i-bs><span class="nav-link-label"><ng-container i18n>Dashboard</ng-container></span>
|
||||
</a>
|
||||
@if (settingsService.organizingSidebarItems()) {
|
||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Dashboard" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Dashboard, $event)"></pngx-input-switch>
|
||||
}
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||
<a class="nav-link" routerLink="documents" routerLinkActive="active"
|
||||
@@ -237,37 +240,50 @@
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
||||
<a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
|
||||
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews) && !settingsService.organizingSidebarItems()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
||||
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="window-stack"></i-bs><span class="nav-link-label"><ng-container i18n>Saved Views</ng-container></span>
|
||||
</a>
|
||||
@if (settingsService.organizingSidebarItems()) {
|
||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Saved Views" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.SavedViews, $event)"></pngx-input-switch>
|
||||
}
|
||||
</li>
|
||||
<li class="nav-item app-link"
|
||||
@if (canManageShareLinks) {
|
||||
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.ShareLinks) && !settingsService.organizingSidebarItems()">
|
||||
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.ShareLinks)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="share-links" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Share links" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="link"></i-bs><span class="nav-link-label"><ng-container i18n>Share links</ng-container></span>
|
||||
</a>
|
||||
@if (settingsService.organizingSidebarItems()) {
|
||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Share Links" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.ShareLinks)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.ShareLinks, $event)"></pngx-input-switch>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows) && !settingsService.organizingSidebarItems()"
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
|
||||
tourAnchor="tour.workflows">
|
||||
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
||||
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="boxes"></i-bs><span class="nav-link-label"><ng-container i18n>Workflows</ng-container></span>
|
||||
</a>
|
||||
@if (settingsService.organizingSidebarItems()) {
|
||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Workflows" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Workflows, $event)"></pngx-input-switch>
|
||||
}
|
||||
</li>
|
||||
<li class="nav-item app-link"
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.OcrTemplate }">
|
||||
<a class="nav-link" routerLink="ocr-templates" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="OCR Templates" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="file-earmark-break"></i-bs><span><ng-container i18n>OCR Templates</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
||||
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail) && !settingsService.organizingSidebarItems()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
||||
tourAnchor="tour.mail">
|
||||
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
|
||||
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
|
||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="envelope"></i-bs><span class="nav-link-label"><ng-container i18n>Mail</ng-container></span>
|
||||
</a>
|
||||
@if (settingsService.organizingSidebarItems()) {
|
||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Mail" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Mail, $event)"></pngx-input-switch>
|
||||
}
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
|
||||
<a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash"
|
||||
@@ -330,13 +346,16 @@
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
<li class="nav-item mt-2" tourAnchor="tour.outro">
|
||||
<a class="text-muted small d-flex align-items-center flex-wrap text-decoration-none nav-anchor"
|
||||
<li class="nav-item mt-2 position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Documentation) && !settingsService.organizingSidebarItems()" tourAnchor="tour.outro">
|
||||
<a class="text-muted small d-flex align-items-center flex-wrap text-decoration-none nav-anchor" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Documentation)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()"
|
||||
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
|
||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="d-flex me-2" name="question-circle"></i-bs><span><ng-container i18n>Documentation</ng-container></span>
|
||||
</a>
|
||||
@if (settingsService.organizingSidebarItems()) {
|
||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Documentation" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Documentation)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Documentation, $event)"></pngx-input-switch>
|
||||
}
|
||||
</li>
|
||||
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
||||
<div class="text-muted small d-flex align-items-center flex-wrap nav-label">
|
||||
|
||||
@@ -15,7 +15,7 @@ import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { routes } from 'src/app/app-routing.module'
|
||||
import { SavedView } from 'src/app/data/saved-view'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { HideableSidebarItemID, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||
import {
|
||||
@@ -287,6 +287,87 @@ describe('AppFrameComponent', () => {
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
it('should hide configured sidebar items', () => {
|
||||
settingsService.set(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
|
||||
HideableSidebarItemID.Dashboard,
|
||||
HideableSidebarItemID.Workflows,
|
||||
HideableSidebarItemID.ShareLinks,
|
||||
])
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[routerLink="dashboard"]')
|
||||
.parentElement.classList
|
||||
).toContain('d-none')
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[routerLink="workflows"]')
|
||||
.parentElement.classList
|
||||
).toContain('d-none')
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[routerLink="share-links"]')
|
||||
.parentElement.classList
|
||||
).toContain('d-none')
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[routerLink="mail"]').parentElement
|
||||
.classList
|
||||
).not.toContain('d-none')
|
||||
})
|
||||
|
||||
it('should show hidden items and visibility switches while customizing', () => {
|
||||
settingsService.set(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
|
||||
HideableSidebarItemID.Dashboard,
|
||||
])
|
||||
settingsService.sidebarHiddenItemsEditing.set([
|
||||
HideableSidebarItemID.Dashboard,
|
||||
])
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelectorAll('pngx-input-switch').length
|
||||
).toBe(6)
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[routerLink="dashboard"]')
|
||||
.parentElement.classList
|
||||
).not.toContain('d-none')
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[routerLink="dashboard"]').classList
|
||||
).toContain('opacity-50')
|
||||
|
||||
settingsService.set(SETTINGS_KEYS.SLIM_SIDEBAR, true)
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(
|
||||
Array.from(
|
||||
fixture.nativeElement.querySelectorAll('pngx-input-switch')
|
||||
).every((toggle: HTMLElement) => toggle.classList.contains('d-none'))
|
||||
).toBe(true)
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[routerLink="dashboard"]').classList
|
||||
).not.toContain('pe-5')
|
||||
|
||||
settingsService.set(SETTINGS_KEYS.SLIM_SIDEBAR, false)
|
||||
component.slimSidebarAnimating.set(true)
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(
|
||||
Array.from(
|
||||
fixture.nativeElement.querySelectorAll('pngx-input-switch')
|
||||
).every((toggle: HTMLElement) => toggle.classList.contains('d-none'))
|
||||
).toBe(true)
|
||||
|
||||
component.slimSidebarAnimating.set(false)
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(
|
||||
Array.from(
|
||||
fixture.nativeElement.querySelectorAll('pngx-input-switch')
|
||||
).every((toggle: HTMLElement) => !toggle.classList.contains('d-none'))
|
||||
).toBe(true)
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[routerLink="dashboard"]').classList
|
||||
).toContain('pe-5')
|
||||
})
|
||||
|
||||
it('should show error on toggle slim sidebar if store settings fails', () => {
|
||||
jest.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '@angular/cdk/drag-drop'
|
||||
import { NgClass } from '@angular/common'
|
||||
import { Component, HostListener, inject, OnInit, signal } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
||||
import {
|
||||
NgbCollapseModule,
|
||||
@@ -21,7 +22,11 @@ import { Observable } from 'rxjs'
|
||||
import { first } from 'rxjs/operators'
|
||||
import { Document } from 'src/app/data/document'
|
||||
import { SavedView } from 'src/app/data/saved-view'
|
||||
import { CollapsibleSection, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import {
|
||||
CollapsibleSection,
|
||||
HideableSidebarItemID,
|
||||
SETTINGS_KEYS,
|
||||
} from 'src/app/data/ui-settings'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { ComponentCanDeactivate } from 'src/app/guards/dirty-doc.guard'
|
||||
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
||||
@@ -48,6 +53,7 @@ import { ChatComponent } from '../chat/chat/chat.component'
|
||||
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
|
||||
import { LogoComponent } from '../common/logo/logo.component'
|
||||
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||
import { SwitchComponent } from '../common/input/switch/switch.component'
|
||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
||||
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
||||
import { GlobalSearchComponent } from './global-search/global-search.component'
|
||||
@@ -76,6 +82,8 @@ const SCROLL_THRESHOLD = 16
|
||||
NgxBootstrapIconsModule,
|
||||
DragDropModule,
|
||||
TourNgBootstrap,
|
||||
FormsModule,
|
||||
SwitchComponent,
|
||||
],
|
||||
})
|
||||
export class AppFrameComponent
|
||||
@@ -98,6 +106,7 @@ export class AppFrameComponent
|
||||
readonly isMenuCollapsed = signal(true)
|
||||
readonly slimSidebarAnimating = signal(false)
|
||||
readonly mobileSearchHidden = signal(false)
|
||||
readonly HideableSidebarItemID = HideableSidebarItemID
|
||||
private readonly versionSetting = this.settingsService.getSignal<string>(
|
||||
SETTINGS_KEYS.VERSION
|
||||
)
|
||||
@@ -195,6 +204,10 @@ export class AppFrameComponent
|
||||
}, 200) // slightly longer than css animation for slim sidebar
|
||||
}
|
||||
|
||||
toggleSidebarItem(item: HideableSidebarItemID, visible: boolean): void {
|
||||
this.settingsService.updateSidebarItemVisibility(item, visible)
|
||||
}
|
||||
|
||||
toggleAttributesSections(event?: Event): void {
|
||||
event?.preventDefault()
|
||||
event?.stopPropagation()
|
||||
@@ -221,6 +234,19 @@ export class AppFrameComponent
|
||||
return this.appTitleSetting() || environment.appTitle
|
||||
}
|
||||
|
||||
get canManageShareLinks(): boolean {
|
||||
return (
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.ShareLink
|
||||
) ||
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.ShareLinkBundle
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
get customAppTitle(): string {
|
||||
return this.appTitleSetting()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="mb-3">
|
||||
<div class="row">
|
||||
@if (!horizontal) {
|
||||
<div [class.mb-3]="!compact">
|
||||
<div [class.row]="!compact">
|
||||
@if (!horizontal && !compact) {
|
||||
<div class="d-flex align-items-center position-relative hidden-button-container col-md-3">
|
||||
<label class="form-label" [for]="inputId" [ngbTooltip]="showUnsetNote && isUnset ? tipContent: null" placement="end">
|
||||
{{title}}
|
||||
@@ -17,8 +17,8 @@
|
||||
}
|
||||
<div [ngClass]="{'align-items-center': horizontal, 'd-flex': horizontal}">
|
||||
<div class="form-check form-switch">
|
||||
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" [ngModelOptions]="{standalone: true}" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
|
||||
@if (horizontal) {
|
||||
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" [ngModelOptions]="{standalone: true}" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled" [attr.aria-label]="compact ? title : null">
|
||||
@if (horizontal && !compact) {
|
||||
<label class="form-check-label" [class.text-muted]="showUnsetNote && isUnset" [for]="inputId" [ngbTooltip]="showUnsetNote && isUnset ? tipContent: null" placement="end">
|
||||
{{title}}
|
||||
@if (showUnsetNote && isUnset) {
|
||||
|
||||
@@ -48,4 +48,14 @@ describe('SwitchComponent', () => {
|
||||
component.value = undefined
|
||||
expect(component.isUnset).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should support a compact layout', () => {
|
||||
component.compact = true
|
||||
component.title = 'Test switch'
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(fixture.nativeElement.querySelector('.mb-3')).toBeNull()
|
||||
expect(fixture.nativeElement.querySelector('.row')).toBeNull()
|
||||
expect(input.getAttribute('aria-label')).toEqual('Test switch')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,6 +25,9 @@ export class SwitchComponent extends AbstractInputComponent<boolean> {
|
||||
@Input()
|
||||
showUnsetNote: boolean = false
|
||||
|
||||
@Input()
|
||||
compact: boolean = false
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
@@ -154,11 +154,29 @@
|
||||
& section {
|
||||
position: absolute;
|
||||
text-align: initial;
|
||||
pointer-events: auto;
|
||||
box-sizing: border-box;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
& :is(.linkAnnotation, .buttonWidgetAnnotation.pushButton) > a {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
font-size: 1em;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
& :is(.linkAnnotation, .buttonWidgetAnnotation.pushButton):not(.hasBorder)
|
||||
> a:hover {
|
||||
opacity: 0.2;
|
||||
background-color: rgb(255 255 0);
|
||||
}
|
||||
|
||||
& .annotationTextContent {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:host ::ng-deep .textLayer.selecting ~ .annotationLayer section {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { SimpleChange } from '@angular/core'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import * as pdfjs from 'pdfjs-dist/legacy/build/pdf.mjs'
|
||||
import { PDFSinglePageViewer, PDFViewer } from 'pdfjs-dist/web/pdf_viewer.mjs'
|
||||
import {
|
||||
LinkTarget,
|
||||
PDFSinglePageViewer,
|
||||
PDFViewer,
|
||||
} from 'pdfjs-dist/web/pdf_viewer.mjs'
|
||||
import { PngxPdfViewerComponent } from './pdf-viewer.component'
|
||||
import { PdfRenderMode, PdfZoomLevel, PdfZoomScale } from './pdf-viewer.types'
|
||||
|
||||
@@ -58,6 +62,16 @@ describe('PngxPdfViewerComponent', () => {
|
||||
expect((component as any).pdfViewer).toBeInstanceOf(PDFViewer)
|
||||
})
|
||||
|
||||
it('opens external links in a new tab', () => {
|
||||
const linkService = (component as any).linkService
|
||||
expect(linkService.options).toEqual(
|
||||
expect.objectContaining({
|
||||
externalLinkTarget: LinkTarget.BLANK,
|
||||
externalLinkRel: 'noopener noreferrer nofollow',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves the worker source relative to the document base URI', async () => {
|
||||
setBaseHref('/paperless/')
|
||||
const getDocumentSpy = jest.spyOn(pdfjs, 'getDocument')
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from 'pdfjs-dist/legacy/build/pdf.mjs'
|
||||
import {
|
||||
EventBus,
|
||||
LinkTarget,
|
||||
PDFFindController,
|
||||
PDFLinkService,
|
||||
PDFSinglePageViewer,
|
||||
@@ -75,7 +76,11 @@ export class PngxPdfViewerComponent
|
||||
private lastViewerPage?: number
|
||||
|
||||
private readonly eventBus = new EventBus()
|
||||
private readonly linkService = new PDFLinkService({ eventBus: this.eventBus })
|
||||
private readonly linkService = new PDFLinkService({
|
||||
eventBus: this.eventBus,
|
||||
externalLinkTarget: LinkTarget.BLANK,
|
||||
externalLinkRel: 'noopener noreferrer nofollow',
|
||||
})
|
||||
private readonly findController = new PDFFindController({
|
||||
eventBus: this.eventBus,
|
||||
linkService: this.linkService,
|
||||
|
||||
@@ -82,23 +82,6 @@
|
||||
<i-bs name="pencil" class="me-1"></i-bs><ng-container i18n>PDF Editor</ng-container>
|
||||
</button>
|
||||
|
||||
<button
|
||||
ngbDropdownItem
|
||||
(click)="runZoneOcr()"
|
||||
[disabled]="!userCanEdit || !document?.document_type"
|
||||
*pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.OcrTemplate }"
|
||||
>
|
||||
<i-bs width="1em" height="1em" name="file-earmark-ruled" class="me-1"></i-bs><span i18n>Run Zone OCR</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
ngbDropdownItem
|
||||
(click)="createOcrTemplate()"
|
||||
*pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.OcrTemplate }"
|
||||
>
|
||||
<i-bs width="1em" height="1em" name="file-earmark-medical" class="me-1"></i-bs><span i18n>Create OCR Template</span>
|
||||
</button>
|
||||
|
||||
@if (userIsOwner && (requiresPassword || password)) {
|
||||
<button ngbDropdownItem (click)="removePassword()" [disabled]="!password">
|
||||
<i-bs name="unlock" class="me-1"></i-bs><ng-container i18n>Remove Password</ng-container>
|
||||
|
||||
@@ -28,8 +28,9 @@ import { Subject, of, throwError } from 'rxjs'
|
||||
import { routes } from 'src/app/app-routing.module'
|
||||
import { Correspondent } from 'src/app/data/correspondent'
|
||||
import { CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
import { CustomFieldInstance } from 'src/app/data/custom-field-instance'
|
||||
import { DataType } from 'src/app/data/datatype'
|
||||
import { Document } from 'src/app/data/document'
|
||||
import { Document, DocumentVersionInfo } from 'src/app/data/document'
|
||||
import { DocumentType } from 'src/app/data/document-type'
|
||||
import {
|
||||
FILTER_CORRESPONDENT,
|
||||
@@ -100,13 +101,18 @@ const doc: Document = {
|
||||
custom_fields: [
|
||||
{
|
||||
field: 0,
|
||||
document: 3,
|
||||
created: new Date(),
|
||||
value: 'custom foo bar',
|
||||
},
|
||||
],
|
||||
] as CustomFieldInstance[],
|
||||
}
|
||||
|
||||
// Newest first, as the API returns them: 12 is the latest, 3 is the root
|
||||
const docVersions: DocumentVersionInfo[] = [
|
||||
{ id: 12, is_root: false },
|
||||
{ id: 10, is_root: false },
|
||||
{ id: doc.id, is_root: true },
|
||||
]
|
||||
|
||||
const customFields = [
|
||||
{
|
||||
id: 0,
|
||||
@@ -2045,6 +2051,208 @@ describe('DocumentDetailComponent', () => {
|
||||
expect(saveSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('selectVersion should use the version content as the baseline and ignore stale responses', () => {
|
||||
initNormally()
|
||||
const version10Content = new Subject<Document>()
|
||||
jest
|
||||
.spyOn(documentService, 'get')
|
||||
.mockReturnValueOnce(version10Content)
|
||||
.mockReturnValueOnce(of({ content: 'version 12 content' } as Document))
|
||||
const version10Metadata = new Subject<any>()
|
||||
jest
|
||||
.spyOn(documentService, 'getMetadata')
|
||||
.mockReturnValueOnce(version10Metadata)
|
||||
.mockReturnValueOnce(of({ lang: 'de' }))
|
||||
|
||||
component.selectVersion(10)
|
||||
component.selectVersion(12)
|
||||
version10Content.next({ content: 'version 10 content' } as Document)
|
||||
version10Metadata.next({ lang: 'en' })
|
||||
|
||||
expect(component.documentForm.get('content').value).toEqual(
|
||||
'version 12 content'
|
||||
)
|
||||
expect(component.store.value.content).toEqual('version 12 content')
|
||||
expect(component.metadata().lang).toEqual('de')
|
||||
expect(
|
||||
httpTestingController.expectOne(component.previewUrl()).cancelled
|
||||
).toBeFalsy()
|
||||
expect(
|
||||
httpTestingController.match((req) => req.url.includes('version=10'))[0]
|
||||
?.cancelled
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should confirm before discarding unsaved content edits when switching versions', () => {
|
||||
initNormally()
|
||||
component.document().versions = docVersions
|
||||
jest
|
||||
.spyOn(documentService, 'get')
|
||||
.mockImplementation((id, versionID) =>
|
||||
of({ content: `version ${versionID} content` } as Document)
|
||||
)
|
||||
let openModal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((modals) => (openModal = modals[0]))
|
||||
const modalSpy = jest.spyOn(modalService, 'open')
|
||||
|
||||
// shared fields carry over between versions, so no confirmation
|
||||
component.documentForm.get('title').setValue('Edited title')
|
||||
component.documentForm.get('title').markAsDirty()
|
||||
component.documentForm.get('content').markAsDirty()
|
||||
component.onVersionSelected(12)
|
||||
expect(modalSpy).not.toHaveBeenCalled()
|
||||
expect(component.selectedVersionId()).toEqual(12)
|
||||
|
||||
component.documentForm.get('content').setValue('edited content')
|
||||
component.documentForm.get('content').markAsDirty()
|
||||
component.onVersionSelected(12) // already selected, nothing to do
|
||||
expect(modalSpy).not.toHaveBeenCalled()
|
||||
component.onVersionSelected(10)
|
||||
expect(modalSpy).toHaveBeenCalledWith(
|
||||
ConfirmDialogComponent,
|
||||
expect.anything()
|
||||
)
|
||||
openModal.componentInstance.cancel()
|
||||
expect(component.selectedVersionId()).toEqual(12)
|
||||
expect(component.documentForm.get('content').value).toEqual(
|
||||
'edited content'
|
||||
)
|
||||
|
||||
component.onVersionSelected(10)
|
||||
openModal.componentInstance.confirmClicked.emit()
|
||||
expect(component.selectedVersionId()).toEqual(10)
|
||||
expect(component.documentForm.get('content').value).toEqual(
|
||||
'version 10 content'
|
||||
)
|
||||
expect(component.documentForm.get('content').dirty).toBeFalsy()
|
||||
expect(component.documentForm.get('title').value).toEqual('Edited title')
|
||||
})
|
||||
|
||||
it('should save unsaved content edits to the current version before switching, and stay if that fails', () => {
|
||||
initNormally()
|
||||
component.document().versions = docVersions
|
||||
component.selectedVersionId.set(12)
|
||||
jest
|
||||
.spyOn(documentService, 'get')
|
||||
.mockReturnValue(of({ content: 'version 10 content' } as Document))
|
||||
const savedDoc = new Subject<Document>()
|
||||
const patchSpy = jest
|
||||
.spyOn(documentService, 'patch')
|
||||
.mockReturnValueOnce(throwError(() => new Error('failed to save')))
|
||||
.mockReturnValueOnce(savedDoc)
|
||||
const modalSpy = jest.spyOn(modalService, 'open')
|
||||
component.documentForm.get('content').setValue('edited content')
|
||||
component.documentForm.get('content').markAsDirty()
|
||||
|
||||
component.onVersionSelected(10)
|
||||
let modal: NgbModalRef = modalSpy.mock.results[0].value
|
||||
const closeSpy = jest.spyOn(modal, 'close')
|
||||
modal.componentInstance.alternativeClicked.emit()
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
expect(component.selectedVersionId()).toEqual(12)
|
||||
expect(component.documentForm.get('content').value).toEqual(
|
||||
'edited content'
|
||||
)
|
||||
|
||||
component.onVersionSelected(10)
|
||||
modal = modalSpy.mock.results[1].value
|
||||
modal.componentInstance.alternativeClicked.emit()
|
||||
expect(patchSpy).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ content: 'edited content' }),
|
||||
12
|
||||
)
|
||||
component.onVersionSelected(doc.id) // ignored while saving
|
||||
expect(modalSpy).toHaveBeenCalledTimes(2)
|
||||
savedDoc.next(doc)
|
||||
expect(component.selectedVersionId()).toEqual(10)
|
||||
expect(component.documentForm.get('content').value).toEqual(
|
||||
'version 10 content'
|
||||
)
|
||||
})
|
||||
|
||||
it('should switch without confirmation when the selected version was deleted, even while saving', () => {
|
||||
initNormally()
|
||||
component.document().versions = docVersions
|
||||
component.selectedVersionId.set(10)
|
||||
jest
|
||||
.spyOn(documentService, 'get')
|
||||
.mockReturnValue(of({ content: 'version 12 content' } as Document))
|
||||
const modalSpy = jest.spyOn(modalService, 'open')
|
||||
component.documentForm.get('content').setValue('edited content')
|
||||
component.documentForm.get('content').markAsDirty()
|
||||
component.networkActive.set(true)
|
||||
|
||||
// the version dropdown emits this after deleting the selected version
|
||||
component.onVersionsUpdated(docVersions.filter((v) => v.id !== 10))
|
||||
component.onVersionSelected(12)
|
||||
|
||||
expect(modalSpy).not.toHaveBeenCalled()
|
||||
expect(component.selectedVersionId()).toEqual(12)
|
||||
expect(component.documentForm.get('content').value).toEqual(
|
||||
'version 12 content'
|
||||
)
|
||||
})
|
||||
|
||||
it('should restore the selected version and its unsaved content when returning to a document', () => {
|
||||
initNormally()
|
||||
const openDoc = component.document()
|
||||
openDoc.versions = docVersions
|
||||
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
|
||||
jest
|
||||
.spyOn(documentService, 'get')
|
||||
.mockImplementation((id, versionID) =>
|
||||
of(
|
||||
(versionID
|
||||
? { content: `version ${versionID} content` }
|
||||
: { ...doc, versions: docVersions }) as Document
|
||||
)
|
||||
)
|
||||
component.selectVersion(10)
|
||||
// an edit that happens to match the latest version's content
|
||||
component.documentForm.get('content').setValue(doc.content)
|
||||
openDoc.__changedFields = ['content']
|
||||
|
||||
component['loadDocument'](doc.id)
|
||||
|
||||
expect(component.selectedVersionId()).toEqual(10)
|
||||
expect(component.documentForm.get('content').value).toEqual(doc.content)
|
||||
expect(openDocumentsService.isDirty(openDoc)).toBeTruthy()
|
||||
const patchSpy = jest
|
||||
.spyOn(documentService, 'patch')
|
||||
.mockReturnValue(of(doc))
|
||||
component.save()
|
||||
expect(patchSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ content: doc.content }),
|
||||
10
|
||||
)
|
||||
})
|
||||
|
||||
it('should fall back to the latest version when the remembered version no longer exists', () => {
|
||||
initNormally()
|
||||
const openDoc = component.document()
|
||||
openDoc.versions = docVersions
|
||||
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
|
||||
jest.spyOn(documentService, 'get').mockImplementation((id, versionID) =>
|
||||
of(
|
||||
(versionID
|
||||
? { content: `version ${versionID} content` }
|
||||
: {
|
||||
...doc,
|
||||
content: 'version 12 content',
|
||||
versions: docVersions.filter((v) => v.id !== 10),
|
||||
}) as Document
|
||||
)
|
||||
)
|
||||
component.selectVersion(10)
|
||||
|
||||
component['loadDocument'](doc.id)
|
||||
|
||||
expect(component.selectedVersionId()).toEqual(12)
|
||||
expect(component.documentForm.get('content').value).toEqual(
|
||||
'version 12 content'
|
||||
)
|
||||
})
|
||||
|
||||
it('createDisabled should return true if the user does not have permission to add the specified data type', () => {
|
||||
currentUserCan = false
|
||||
expect(component.createDisabled(DataType.Correspondent)).toBeTruthy()
|
||||
|
||||
@@ -98,8 +98,8 @@ import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
|
||||
import * as UTIF from 'utif'
|
||||
import { DocumentDetailFieldID } from '../admin/settings/settings.component'
|
||||
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
||||
import { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
|
||||
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
|
||||
import { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
|
||||
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
|
||||
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||
@@ -304,6 +304,7 @@ export class DocumentDetailComponent
|
||||
isDirty$: Observable<boolean>
|
||||
unsubscribeNotifier: Subject<any> = new Subject()
|
||||
docChangeNotifier: Subject<any> = new Subject()
|
||||
versionChangeNotifier: Subject<void> = new Subject()
|
||||
private incomingUpdateModal: NgbModalRef
|
||||
private pendingIncomingUpdate: IncomingDocumentUpdate
|
||||
private lastLocalSaveModified: string | null = null
|
||||
@@ -417,7 +418,8 @@ export class DocumentDetailComponent
|
||||
.pipe(
|
||||
first(),
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
takeUntil(this.docChangeNotifier)
|
||||
takeUntil(this.docChangeNotifier),
|
||||
takeUntil(this.versionChangeNotifier)
|
||||
)
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
@@ -533,7 +535,8 @@ export class DocumentDetailComponent
|
||||
.pipe(
|
||||
first(),
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
takeUntil(this.docChangeNotifier)
|
||||
takeUntil(this.docChangeNotifier),
|
||||
takeUntil(this.versionChangeNotifier)
|
||||
)
|
||||
.subscribe({
|
||||
next: (res) => this.previewText.set(res.toString()),
|
||||
@@ -595,6 +598,13 @@ export class DocumentDetailComponent
|
||||
openDocument.duplicate_documents = doc.duplicate_documents
|
||||
this.openDocumentService.save()
|
||||
}
|
||||
// use server versions
|
||||
if (openDocument) {
|
||||
openDocument.versions = doc.versions
|
||||
if (!openDocument.__changedFields?.includes('content')) {
|
||||
openDocument.content = doc.content
|
||||
}
|
||||
}
|
||||
let useDoc = openDocument || doc
|
||||
if (openDocument && forceRemote) {
|
||||
Object.assign(openDocument, doc)
|
||||
@@ -642,7 +652,14 @@ export class DocumentDetailComponent
|
||||
this.documentForm.patchValue({ title: titleValue })
|
||||
this.documentForm.get('title').markAsDirty()
|
||||
})
|
||||
const keepContentEdits =
|
||||
useDoc.__selectedVersionId === this.selectedVersionId() &&
|
||||
!!useDoc.__changedFields?.includes('content')
|
||||
this.setupDirtyTracking(useDoc, doc)
|
||||
// Maybe load the stored version
|
||||
if (useDoc.__selectedVersionId) {
|
||||
this.selectVersion(this.selectedVersionId(), keepContentEdits)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -903,9 +920,11 @@ export class DocumentDetailComponent
|
||||
|
||||
updateComponent(doc: Document) {
|
||||
this.document.set(doc)
|
||||
// Default selected version is the newest version, which the API returns first
|
||||
// Load the selected version, or default to API first (newest)
|
||||
const versions = doc.versions ?? []
|
||||
this.selectedVersionId.set(versions.length ? versions[0].id : doc.id)
|
||||
const selectedVersion =
|
||||
versions.find((v) => v.id === doc.__selectedVersionId) ?? versions[0]
|
||||
this.selectedVersionId.set(selectedVersion?.id ?? doc.id)
|
||||
this.previewLoaded.set(false)
|
||||
this.requiresPassword = false
|
||||
this.updateFormForCustomFields()
|
||||
@@ -940,8 +959,12 @@ export class DocumentDetailComponent
|
||||
}
|
||||
|
||||
// Update file preview and download target to a specific version (by document id)
|
||||
selectVersion(versionId: number) {
|
||||
selectVersion(versionId: number, keepContentEdits: boolean = false) {
|
||||
this.versionChangeNotifier.next()
|
||||
this.selectedVersionId.set(versionId)
|
||||
// remember so the version can be restored when returning to the document
|
||||
this.document().__selectedVersionId = versionId
|
||||
this.openDocumentService.save()
|
||||
this.previewLoaded.set(false)
|
||||
this.previewUrl.set(
|
||||
this.documentsService.getPreviewUrl(
|
||||
@@ -963,20 +986,20 @@ export class DocumentDetailComponent
|
||||
.pipe(
|
||||
first(),
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
takeUntil(this.docChangeNotifier)
|
||||
takeUntil(this.docChangeNotifier),
|
||||
takeUntil(this.versionChangeNotifier)
|
||||
)
|
||||
.subscribe({
|
||||
next: (doc) => {
|
||||
const content = doc?.content ?? ''
|
||||
this.document().content = content
|
||||
this.documentForm.patchValue(
|
||||
{
|
||||
content,
|
||||
},
|
||||
{
|
||||
emitEvent: false,
|
||||
}
|
||||
)
|
||||
if (keepContentEdits) {
|
||||
this.store.next({ ...this.store.value, content })
|
||||
} else {
|
||||
// Update in-place and avoid the debounce wait
|
||||
this.store.value.content = content
|
||||
this.documentForm.patchValue({ content })
|
||||
this.documentForm.get('content').markAsPristine()
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError(
|
||||
@@ -991,7 +1014,8 @@ export class DocumentDetailComponent
|
||||
.pipe(
|
||||
first(),
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
takeUntil(this.docChangeNotifier)
|
||||
takeUntil(this.docChangeNotifier),
|
||||
takeUntil(this.versionChangeNotifier)
|
||||
)
|
||||
.subscribe({
|
||||
next: (res) => this.previewText.set(res.toString()),
|
||||
@@ -1005,7 +1029,39 @@ export class DocumentDetailComponent
|
||||
}
|
||||
|
||||
onVersionSelected(versionId: number) {
|
||||
this.selectVersion(versionId)
|
||||
if (versionId === this.selectedVersionId()) return
|
||||
// Bail if the selected version was just deleted.
|
||||
const selectedVersionExists = this.document()?.versions?.some(
|
||||
(v) => v.id === this.selectedVersionId()
|
||||
)
|
||||
if (this.networkActive() && selectedVersionExists) return
|
||||
if (
|
||||
!selectedVersionExists ||
|
||||
this.documentForm.get('content').value === this.store.value.content
|
||||
) {
|
||||
this.selectVersion(versionId)
|
||||
return
|
||||
}
|
||||
|
||||
// Confirm any unsaved content changes
|
||||
const modal = this.modalService.open(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.title = $localize`Unsaved Changes`
|
||||
modal.componentInstance.messageBold = $localize`You have unsaved changes to the content of this version.`
|
||||
modal.componentInstance.message = $localize`Switching versions will discard them.`
|
||||
modal.componentInstance.btnClass = 'btn-secondary'
|
||||
modal.componentInstance.btnCaption = $localize`Discard and switch`
|
||||
modal.componentInstance.alternativeBtnClass = 'btn-primary'
|
||||
modal.componentInstance.alternativeBtnCaption = $localize`Save and switch`
|
||||
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
||||
modal.close()
|
||||
this.selectVersion(versionId)
|
||||
})
|
||||
modal.componentInstance.alternativeClicked.pipe(first()).subscribe(() => {
|
||||
modal.close()
|
||||
this.save(false, () => this.selectVersion(versionId))
|
||||
})
|
||||
}
|
||||
|
||||
onVersionsUpdated(versions: DocumentVersionInfo[]) {
|
||||
@@ -1233,7 +1289,7 @@ export class DocumentDetailComponent
|
||||
return changes
|
||||
}
|
||||
|
||||
save(close: boolean = false) {
|
||||
save(close: boolean = false, savedCallback: () => void = null) {
|
||||
this.networkActive.set(true)
|
||||
;(document.activeElement as HTMLElement)?.dispatchEvent(new Event('change'))
|
||||
this.documentsService
|
||||
@@ -1266,6 +1322,7 @@ export class DocumentDetailComponent
|
||||
this.flushPendingIncomingUpdate()
|
||||
}
|
||||
this.savedViewService.maybeRefreshDocumentCounts()
|
||||
savedCallback?.()
|
||||
},
|
||||
error: (error) => {
|
||||
this.networkActive.set(false)
|
||||
@@ -1449,48 +1506,6 @@ export class DocumentDetailComponent
|
||||
})
|
||||
}
|
||||
|
||||
runZoneOcr() {
|
||||
this.documentsService.runZoneOcr(this.document.id).subscribe({
|
||||
next: (res) => {
|
||||
const results = res.results ?? []
|
||||
if (results.length) {
|
||||
const failed = results.filter(
|
||||
(r) =>
|
||||
r.value === null ||
|
||||
r.value === undefined ||
|
||||
`${r.value}`.trim() === ''
|
||||
)
|
||||
const filled = results.length - failed.length
|
||||
let msg = $localize`Filled ${filled} of ${results.length} fields`
|
||||
if (failed.length) {
|
||||
const names = failed.map((r) => r.zone).join(', ')
|
||||
msg = `${msg}. ${$localize`Failed to match zones: ${names}`}`
|
||||
}
|
||||
this.toastService.showInfo(msg)
|
||||
} else {
|
||||
this.toastService.showInfo(
|
||||
$localize`Zone OCR ran but no results extracted.`
|
||||
)
|
||||
}
|
||||
this.documentsService
|
||||
.get(this.documentId)
|
||||
.subscribe((doc) => this.updateComponent(doc))
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError($localize`Zone OCR failed`, error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
createOcrTemplate() {
|
||||
this.router.navigate(['/ocr-templates', 'new'], {
|
||||
queryParams: {
|
||||
document_type: this.document.document_type,
|
||||
sample_document: this.document.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private getSelectedNonLatestVersionId(): number | null {
|
||||
const versions = this.document()?.versions ?? []
|
||||
if (!versions.length || !this.selectedVersionId()) {
|
||||
|
||||
@@ -98,9 +98,6 @@
|
||||
<button ngbDropdownItem (click)="mergeSelectedAsVersions()" [disabled]="!userOwnsAll || !userCanEditAll || !userCanDelete || list.allSelected || list.selectedCount < 2">
|
||||
<i-bs name="journal-bookmark-fill" class="me-1"></i-bs><ng-container i18n>Merge as versions</ng-container>
|
||||
</button>
|
||||
<button ngbDropdownItem (click)="runZoneOcrSelected()" [disabled]="!userCanEditAll || list.allSelected">
|
||||
<i-bs name="file-earmark-ruled" class="me-1"></i-bs><ng-container i18n>Run Zone OCR</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+101
-20
@@ -7,8 +7,9 @@ import {
|
||||
import { EventEmitter, signal } from '@angular/core'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { Router } from '@angular/router'
|
||||
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { Correspondent } from 'src/app/data/correspondent'
|
||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
@@ -46,7 +47,6 @@ import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage
|
||||
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
|
||||
import { FilterableDropdownComponent } from '../../common/filterable-dropdown/filterable-dropdown.component'
|
||||
import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component'
|
||||
import { ShareLinkBundleManageDialogComponent } from '../../common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component'
|
||||
import { BulkEditorComponent } from './bulk-editor.component'
|
||||
|
||||
const selectionData: SelectionData = {
|
||||
@@ -82,6 +82,7 @@ describe('BulkEditorComponent', () => {
|
||||
let customFieldsService: CustomFieldsService
|
||||
let httpTestingController: HttpTestingController
|
||||
let shareLinkBundleService: ShareLinkBundleService
|
||||
let router: Router
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
@@ -167,11 +168,14 @@ describe('BulkEditorComponent', () => {
|
||||
provide: ShareLinkBundleService,
|
||||
useValue: {
|
||||
createBundle: jest.fn(),
|
||||
listAllBundles: jest.fn(),
|
||||
rebuildBundle: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: Router,
|
||||
useValue: { navigate: jest.fn().mockResolvedValue(true) },
|
||||
},
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
@@ -189,6 +193,7 @@ describe('BulkEditorComponent', () => {
|
||||
customFieldsService = TestBed.inject(CustomFieldsService)
|
||||
httpTestingController = TestBed.inject(HttpTestingController)
|
||||
shareLinkBundleService = TestBed.inject(ShareLinkBundleService)
|
||||
router = TestBed.inject(Router)
|
||||
|
||||
fixture = TestBed.createComponent(BulkEditorComponent)
|
||||
component = fixture.componentInstance
|
||||
@@ -387,6 +392,42 @@ describe('BulkEditorComponent', () => {
|
||||
expect(component.tagSelectionModel.selectionSize()).toEqual(1)
|
||||
})
|
||||
|
||||
it('should request selection data for tags when documents are excluded from an all-filtered selection', () => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
fixture.detectChanges()
|
||||
jest
|
||||
.spyOn(documentListViewService, 'allSelected', 'get')
|
||||
.mockReturnValue(true)
|
||||
jest
|
||||
.spyOn(documentListViewService, 'excluded', 'get')
|
||||
.mockReturnValue(new Set([4]))
|
||||
jest
|
||||
.spyOn(documentListViewService, 'filterRules', 'get')
|
||||
.mockReturnValue([{ rule_type: FILTER_TITLE, value: 'apple' }])
|
||||
jest
|
||||
.spyOn(documentListViewService, 'selectedCount', 'get')
|
||||
.mockReturnValue(2)
|
||||
const adjustedSelectionData: SelectionData = {
|
||||
...selectionData,
|
||||
selected_tags: [{ id: 12, document_count: 2 }],
|
||||
}
|
||||
const getSelectionDataSpy = jest
|
||||
.spyOn(documentService, 'getSelectionData')
|
||||
.mockReturnValue(of(adjustedSelectionData))
|
||||
|
||||
component.openTagsDropdown()
|
||||
|
||||
expect(getSelectionDataSpy).toHaveBeenCalledWith({
|
||||
all: true,
|
||||
filters: { title_search: 'apple' },
|
||||
excluded_documents: [4],
|
||||
})
|
||||
expect(component.tagDocumentCounts()).toEqual(
|
||||
adjustedSelectionData.selected_tags
|
||||
)
|
||||
expect(component.tagSelectionModel.selectionSize()).toEqual(1)
|
||||
})
|
||||
|
||||
it('should apply list selection data to document types menu when all filtered documents are selected', () => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
fixture.detectChanges()
|
||||
@@ -455,6 +496,47 @@ describe('BulkEditorComponent', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should request selection data for the other metadata menus when documents are excluded', () => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
fixture.detectChanges()
|
||||
jest
|
||||
.spyOn(documentListViewService, 'allSelected', 'get')
|
||||
.mockReturnValue(true)
|
||||
jest
|
||||
.spyOn(documentListViewService, 'excluded', 'get')
|
||||
.mockReturnValue(new Set([4]))
|
||||
jest
|
||||
.spyOn(documentListViewService, 'filterRules', 'get')
|
||||
.mockReturnValue([{ rule_type: FILTER_TITLE, value: 'apple' }])
|
||||
const getSelectionDataSpy = jest
|
||||
.spyOn(documentService, 'getSelectionData')
|
||||
.mockReturnValue(of(selectionData))
|
||||
|
||||
component.openDocumentTypeDropdown()
|
||||
component.openCorrespondentDropdown()
|
||||
component.openStoragePathDropdown()
|
||||
component.openCustomFieldsDropdown()
|
||||
|
||||
expect(getSelectionDataSpy).toHaveBeenCalledTimes(4)
|
||||
expect(getSelectionDataSpy).toHaveBeenCalledWith({
|
||||
all: true,
|
||||
filters: { title_search: 'apple' },
|
||||
excluded_documents: [4],
|
||||
})
|
||||
expect(component.documentTypeDocumentCounts()).toEqual(
|
||||
selectionData.selected_document_types
|
||||
)
|
||||
expect(component.correspondentDocumentCounts()).toEqual(
|
||||
selectionData.selected_correspondents
|
||||
)
|
||||
expect(component.storagePathDocumentCounts()).toEqual(
|
||||
selectionData.selected_storage_paths
|
||||
)
|
||||
expect(component.customFieldDocumentCounts()).toEqual(
|
||||
selectionData.selected_custom_fields
|
||||
)
|
||||
})
|
||||
|
||||
it('should execute modify tags bulk operation', () => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
jest
|
||||
@@ -496,16 +578,19 @@ describe('BulkEditorComponent', () => {
|
||||
.mockReturnValue([{ id: 3 }, { id: 4 }])
|
||||
jest
|
||||
.spyOn(documentListViewService, 'selected', 'get')
|
||||
.mockReturnValue(new Set([3, 4]))
|
||||
.mockReturnValue(new Set([3]))
|
||||
jest
|
||||
.spyOn(documentListViewService, 'allSelected', 'get')
|
||||
.mockReturnValue(true)
|
||||
jest
|
||||
.spyOn(documentListViewService, 'excluded', 'get')
|
||||
.mockReturnValue(new Set([4]))
|
||||
jest
|
||||
.spyOn(documentListViewService, 'filterRules', 'get')
|
||||
.mockReturnValue([{ rule_type: FILTER_TITLE, value: 'apple' }])
|
||||
jest
|
||||
.spyOn(documentListViewService, 'selectedCount', 'get')
|
||||
.mockReturnValue(25)
|
||||
.mockReturnValue(24)
|
||||
jest
|
||||
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
|
||||
.mockReturnValue(true)
|
||||
@@ -524,6 +609,7 @@ describe('BulkEditorComponent', () => {
|
||||
expect(req.request.body).toEqual({
|
||||
all: true,
|
||||
filters: { title_search: 'apple' },
|
||||
excluded_documents: [4],
|
||||
method: 'modify_tags',
|
||||
parameters: { add_tags: [101], remove_tags: [] },
|
||||
})
|
||||
@@ -1824,9 +1910,9 @@ describe('BulkEditorComponent', () => {
|
||||
},
|
||||
}
|
||||
|
||||
const openSpy = jest.spyOn(modalService, 'open')
|
||||
openSpy.mockReturnValueOnce(modalRef as NgbModalRef)
|
||||
openSpy.mockReturnValueOnce({} as NgbModalRef)
|
||||
const openSpy = jest
|
||||
.spyOn(modalService, 'open')
|
||||
.mockReturnValueOnce(modalRef as NgbModalRef)
|
||||
;(shareLinkBundleService.createBundle as jest.Mock).mockReturnValueOnce(
|
||||
of({ id: 42 })
|
||||
)
|
||||
@@ -1860,11 +1946,9 @@ describe('BulkEditorComponent', () => {
|
||||
|
||||
dialogInstance.onOpenManage()
|
||||
expect(modalRef.close).toHaveBeenCalled()
|
||||
expect(openSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
ShareLinkBundleManageDialogComponent,
|
||||
expect.objectContaining({ backdrop: 'static', size: 'lg' })
|
||||
)
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/share-links'], {
|
||||
queryParams: { type: 'bundles' },
|
||||
})
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
@@ -1917,13 +2001,10 @@ describe('BulkEditorComponent', () => {
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should open share link bundle management dialog', () => {
|
||||
const openSpy = jest.spyOn(modalService, 'open')
|
||||
it('should navigate to share link bundle management', () => {
|
||||
component.manageShareLinkBundles()
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
ShareLinkBundleManageDialogComponent,
|
||||
expect.objectContaining({ backdrop: 'static', size: 'lg' })
|
||||
)
|
||||
openSpy.mockRestore()
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/share-links'], {
|
||||
queryParams: { type: 'bundles' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { Router } from '@angular/router'
|
||||
import {
|
||||
NgbDropdownModule,
|
||||
NgbModal,
|
||||
@@ -19,15 +20,7 @@ import {
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { saveAs } from 'file-saver'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import {
|
||||
first,
|
||||
forkJoin,
|
||||
map,
|
||||
Observable,
|
||||
Subject,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
} from 'rxjs'
|
||||
import { first, map, Observable, Subject, switchMap, takeUntil } from 'rxjs'
|
||||
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
|
||||
import { CustomField } from 'src/app/data/custom-field'
|
||||
import { MatchingModel } from 'src/app/data/matching-model'
|
||||
@@ -77,7 +70,6 @@ import {
|
||||
import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'
|
||||
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
|
||||
import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component'
|
||||
import { ShareLinkBundleManageDialogComponent } from '../../common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component'
|
||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||
import { CustomFieldsBulkEditDialogComponent } from './custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component'
|
||||
|
||||
@@ -112,6 +104,7 @@ export class BulkEditorComponent
|
||||
public readonly permissionService = inject(PermissionsService)
|
||||
private savedViewService = inject(SavedViewService)
|
||||
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
|
||||
private readonly router = inject(Router)
|
||||
|
||||
tagSelectionModel = new FilterableDropdownSelectionModel(true)
|
||||
correspondentSelectionModel = new FilterableDropdownSelectionModel()
|
||||
@@ -368,6 +361,7 @@ export class BulkEditorComponent
|
||||
return {
|
||||
all: true,
|
||||
filters: queryParamsFromFilterRules(this.list.filterRules),
|
||||
excluded_documents: Array.from(this.list.excluded),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,7 +375,8 @@ export class BulkEditorComponent
|
||||
}
|
||||
|
||||
openTagsDropdown() {
|
||||
if (this.list.allSelected) {
|
||||
// If none excluded, use the selection data already available in the list view, otherwise fetch
|
||||
if (this.list.allSelected && this.list.excluded.size === 0) {
|
||||
const selectionData = this.list.selectionData
|
||||
this.tagDocumentCounts.set(selectionData?.selected_tags ?? [])
|
||||
this.applySelectionData(this.tagDocumentCounts(), this.tagSelectionModel)
|
||||
@@ -389,7 +384,7 @@ export class BulkEditorComponent
|
||||
}
|
||||
|
||||
this.documentService
|
||||
.getSelectionData(Array.from(this.list.selected))
|
||||
.getSelectionData(this.getSelectionQuery())
|
||||
.pipe(first())
|
||||
.subscribe((s) => {
|
||||
this.tagDocumentCounts.set(s.selected_tags)
|
||||
@@ -398,7 +393,7 @@ export class BulkEditorComponent
|
||||
}
|
||||
|
||||
openDocumentTypeDropdown() {
|
||||
if (this.list.allSelected) {
|
||||
if (this.list.allSelected && this.list.excluded.size === 0) {
|
||||
const selectionData = this.list.selectionData
|
||||
this.documentTypeDocumentCounts.set(
|
||||
selectionData?.selected_document_types ?? []
|
||||
@@ -411,7 +406,7 @@ export class BulkEditorComponent
|
||||
}
|
||||
|
||||
this.documentService
|
||||
.getSelectionData(Array.from(this.list.selected))
|
||||
.getSelectionData(this.getSelectionQuery())
|
||||
.pipe(first())
|
||||
.subscribe((s) => {
|
||||
this.documentTypeDocumentCounts.set(s.selected_document_types)
|
||||
@@ -423,7 +418,7 @@ export class BulkEditorComponent
|
||||
}
|
||||
|
||||
openCorrespondentDropdown() {
|
||||
if (this.list.allSelected) {
|
||||
if (this.list.allSelected && this.list.excluded.size === 0) {
|
||||
const selectionData = this.list.selectionData
|
||||
this.correspondentDocumentCounts.set(
|
||||
selectionData?.selected_correspondents ?? []
|
||||
@@ -436,7 +431,7 @@ export class BulkEditorComponent
|
||||
}
|
||||
|
||||
this.documentService
|
||||
.getSelectionData(Array.from(this.list.selected))
|
||||
.getSelectionData(this.getSelectionQuery())
|
||||
.pipe(first())
|
||||
.subscribe((s) => {
|
||||
this.correspondentDocumentCounts.set(s.selected_correspondents)
|
||||
@@ -448,7 +443,7 @@ export class BulkEditorComponent
|
||||
}
|
||||
|
||||
openStoragePathDropdown() {
|
||||
if (this.list.allSelected) {
|
||||
if (this.list.allSelected && this.list.excluded.size === 0) {
|
||||
const selectionData = this.list.selectionData
|
||||
this.storagePathDocumentCounts.set(
|
||||
selectionData?.selected_storage_paths ?? []
|
||||
@@ -461,7 +456,7 @@ export class BulkEditorComponent
|
||||
}
|
||||
|
||||
this.documentService
|
||||
.getSelectionData(Array.from(this.list.selected))
|
||||
.getSelectionData(this.getSelectionQuery())
|
||||
.pipe(first())
|
||||
.subscribe((s) => {
|
||||
this.storagePathDocumentCounts.set(s.selected_storage_paths)
|
||||
@@ -473,7 +468,7 @@ export class BulkEditorComponent
|
||||
}
|
||||
|
||||
openCustomFieldsDropdown() {
|
||||
if (this.list.allSelected) {
|
||||
if (this.list.allSelected && this.list.excluded.size === 0) {
|
||||
const selectionData = this.list.selectionData
|
||||
this.customFieldDocumentCounts.set(
|
||||
selectionData?.selected_custom_fields ?? []
|
||||
@@ -486,7 +481,7 @@ export class BulkEditorComponent
|
||||
}
|
||||
|
||||
this.documentService
|
||||
.getSelectionData(Array.from(this.list.selected))
|
||||
.getSelectionData(this.getSelectionQuery())
|
||||
.pipe(first())
|
||||
.subscribe((s) => {
|
||||
this.customFieldDocumentCounts.set(s.selected_custom_fields)
|
||||
@@ -947,27 +942,6 @@ export class BulkEditorComponent
|
||||
})
|
||||
}
|
||||
|
||||
runZoneOcrSelected() {
|
||||
const ids = Array.from(this.list.selected)
|
||||
if (!ids.length) return
|
||||
const modal = this.modalService.open(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.title = $localize`Run Zone OCR`
|
||||
modal.componentInstance.messageBold = $localize`Run zone OCR on ${this.getSelectionSize()} selected document(s)?`
|
||||
modal.componentInstance.message = $localize`Each document's type template (if it has one) is applied, overwriting the mapped fields.`
|
||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||
modal.componentInstance.confirmClicked
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
this.executeDocumentAction(
|
||||
modal,
|
||||
forkJoin(ids.map((id) => this.documentService.runZoneOcr(id)))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
setPermissions() {
|
||||
let modal = this.modalService.open(PermissionsDialogComponent, {
|
||||
backdrop: 'static',
|
||||
@@ -1164,9 +1138,8 @@ export class BulkEditorComponent
|
||||
}
|
||||
|
||||
manageShareLinkBundles() {
|
||||
this.modalService.open(ShareLinkBundleManageDialogComponent, {
|
||||
backdrop: 'static',
|
||||
size: 'lg',
|
||||
void this.router.navigate(['/share-links'], {
|
||||
queryParams: { type: 'bundles' },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -146,6 +146,19 @@ describe('DocumentListComponent', () => {
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should stop reloading on document deleted after destroy', () => {
|
||||
const reloadSpy = jest.spyOn(documentListService, 'reload')
|
||||
const documentDeletedSubject = new Subject<boolean>()
|
||||
jest
|
||||
.spyOn(websocketStatusService, 'onDocumentDeleted')
|
||||
.mockReturnValue(documentDeletedSubject)
|
||||
fixture.detectChanges()
|
||||
fixture.destroy()
|
||||
reloadSpy.mockClear()
|
||||
documentDeletedSubject.next(true)
|
||||
expect(reloadSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show score sort fields on fulltext queries', () => {
|
||||
documentListService.setFilterRules([
|
||||
{
|
||||
|
||||
@@ -270,9 +270,12 @@ export class DocumentListComponent
|
||||
this.list.reload()
|
||||
})
|
||||
|
||||
this.websocketStatusService.onDocumentDeleted().subscribe(() => {
|
||||
this.list.reload()
|
||||
})
|
||||
this.websocketStatusService
|
||||
.onDocumentDeleted()
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
this.list.reload()
|
||||
})
|
||||
|
||||
this.route.paramMap
|
||||
.pipe(
|
||||
|
||||
+11
-12
@@ -1423,11 +1423,16 @@ describe('FilterEditorComponent', () => {
|
||||
])
|
||||
})
|
||||
|
||||
const clickTextFilterTarget = (name: string) => {
|
||||
const item = fixture.debugElement
|
||||
.queryAll(By.directive(NgbDropdownItem))
|
||||
.find((el) => el.nativeElement.textContent.trim() === name)
|
||||
expect(item).not.toBeUndefined()
|
||||
item.triggerEventHandler('click')
|
||||
}
|
||||
|
||||
it('should convert duplicate target input to the correct filter rule', () => {
|
||||
const textFieldTargetDropdown = fixture.debugElement.queryAll(
|
||||
By.directive(NgbDropdownItem)
|
||||
)[5]
|
||||
textFieldTargetDropdown.triggerEventHandler('click')
|
||||
clickTextFilterTarget('Duplicates')
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(component.textFilterTarget).toEqual('duplicates')
|
||||
@@ -1453,10 +1458,7 @@ describe('FilterEditorComponent', () => {
|
||||
it('should convert user input to correct filter rules on full text query', () => {
|
||||
component.textFilterInput.nativeElement.value = 'foo'
|
||||
component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
|
||||
const textFieldTargetDropdown = fixture.debugElement.queryAll(
|
||||
By.directive(NgbDropdownItem)
|
||||
)[4]
|
||||
textFieldTargetDropdown.triggerEventHandler('click') // TEXT_FILTER_TARGET_FULLTEXT_QUERY
|
||||
clickTextFilterTarget('Advanced search')
|
||||
fixture.detectChanges()
|
||||
tick(400)
|
||||
expect(component.textFilterTarget).toEqual('fulltext-query')
|
||||
@@ -1925,10 +1927,7 @@ describe('FilterEditorComponent', () => {
|
||||
it('should leave relative dates not in quick list intact', () => {
|
||||
component.textFilterInput.nativeElement.value = 'created:[-2 week to now]'
|
||||
component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
|
||||
const textFieldTargetDropdown = fixture.debugElement.queryAll(
|
||||
By.directive(NgbDropdownItem)
|
||||
)[4]
|
||||
textFieldTargetDropdown.triggerEventHandler('click')
|
||||
clickTextFilterTarget('Advanced search')
|
||||
fixture.detectChanges()
|
||||
tick(400)
|
||||
expect(component.filterRules).toEqual([
|
||||
|
||||
@@ -205,11 +205,11 @@ const DEFAULT_TEXT_FILTER_TARGET_OPTIONS = [
|
||||
},
|
||||
{ id: TEXT_FILTER_TARGET_ASN, name: $localize`ASN` },
|
||||
{ id: TEXT_FILTER_TARGET_MIME_TYPE, name: $localize`File type` },
|
||||
{ id: TEXT_FILTER_TARGET_DUPLICATES, name: $localize`Duplicates` },
|
||||
{
|
||||
id: TEXT_FILTER_TARGET_FULLTEXT_QUERY,
|
||||
name: $localize`Advanced search`,
|
||||
},
|
||||
{ id: TEXT_FILTER_TARGET_DUPLICATES, name: $localize`Duplicates` },
|
||||
]
|
||||
|
||||
const DEPRECATED_CUSTOM_FIELDS_TEXT_FILTER_TARGET_OPTION = {
|
||||
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
@if (zones.length === 0) {
|
||||
<p class="text-muted" i18n>
|
||||
No zones defined. Load a document preview and draw rectangles to add zones.
|
||||
</p>
|
||||
}
|
||||
|
||||
<div class="list-group">
|
||||
@for (zone of zones; track $index; let i = $index) {
|
||||
<div
|
||||
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center"
|
||||
[style.box-shadow]="selectedZoneIndex === i ? 'inset 3px 0 0 0 var(--bs-primary)' : null"
|
||||
>
|
||||
<div class="flex-grow-1" role="button" style="cursor: pointer;" (click)="zoneSelected.emit(i)">
|
||||
<div>
|
||||
<strong [class.text-primary]="selectedZoneIndex === i">
|
||||
{{ zone.name }}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="small text-muted">
|
||||
{{ getZoneTargetName(zone) }} - {{ zone.width }}x{{ zone.height }}px
|
||||
<ng-container i18n>p.</ng-container>{{ zonePage(zone) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="zoneSelected.emit(i)" title="Edit" i18n-title>
|
||||
<i-bs name="pencil"></i-bs>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" type="button" (click)="zoneRemoved.emit(i)" title="Delete" i18n-title>
|
||||
<i-bs name="trash"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { CustomField } from 'src/app/data/custom-field'
|
||||
import { OcrTemplateZone } from 'src/app/data/ocr-template'
|
||||
import { OcrTemplateEditorZoneListComponent } from './ocr-template-editor-zone-list.component'
|
||||
|
||||
function zone(overrides: Partial<OcrTemplateZone> = {}): OcrTemplateZone {
|
||||
return {
|
||||
name: 'Zone 1',
|
||||
target: 'custom_field',
|
||||
custom_field: 7,
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 30,
|
||||
height: 40,
|
||||
page: 1,
|
||||
ocr_language: 'eng',
|
||||
transform: 'strip',
|
||||
validation_regex: '',
|
||||
order: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('OcrTemplateEditorZoneListComponent', () => {
|
||||
let fixture: ComponentFixture<OcrTemplateEditorZoneListComponent>
|
||||
let component: OcrTemplateEditorZoneListComponent
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
OcrTemplateEditorZoneListComponent,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(OcrTemplateEditorZoneListComponent)
|
||||
component = fixture.componentInstance
|
||||
})
|
||||
|
||||
it('shows empty state when no zones are defined', () => {
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('No zones defined')
|
||||
})
|
||||
|
||||
it('renders zone target, size, and page', () => {
|
||||
component.zones = [zone()]
|
||||
component.customFields = [{ id: 7, name: 'Invoice Number' } as CustomField]
|
||||
fixture.detectChanges()
|
||||
|
||||
const text = fixture.nativeElement.textContent
|
||||
expect(text).toContain('Zone 1')
|
||||
expect(text).toContain('Invoice Number')
|
||||
expect(text).toContain('30x40px')
|
||||
expect(text).toContain('p.1')
|
||||
})
|
||||
|
||||
it('emits select and remove events', () => {
|
||||
component.zones = [zone()]
|
||||
const selectSpy = jest.spyOn(component.zoneSelected, 'emit')
|
||||
const removeSpy = jest.spyOn(component.zoneRemoved, 'emit')
|
||||
fixture.detectChanges()
|
||||
|
||||
const buttons = fixture.nativeElement.querySelectorAll('button')
|
||||
buttons[0].click()
|
||||
buttons[1].click()
|
||||
|
||||
expect(selectSpy).toHaveBeenCalledWith(0)
|
||||
expect(removeSpy).toHaveBeenCalledWith(0)
|
||||
})
|
||||
})
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { CustomField } from 'src/app/data/custom-field'
|
||||
import { OCR_BUILTIN_TARGETS, OcrTemplateZone } from 'src/app/data/ocr-template'
|
||||
import { getZonePage } from '../zone-geometry'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-ocr-template-zone-list',
|
||||
imports: [NgxBootstrapIconsModule],
|
||||
templateUrl: './ocr-template-editor-zone-list.component.html',
|
||||
})
|
||||
export class OcrTemplateEditorZoneListComponent {
|
||||
@Input() zones: OcrTemplateZone[] = []
|
||||
@Input() selectedZoneIndex: number | null = null
|
||||
@Input() previewPage = 0
|
||||
@Input() previewPageCount: number | null = null
|
||||
@Input() customFields: CustomField[] = []
|
||||
|
||||
@Output() zoneSelected = new EventEmitter<number>()
|
||||
@Output() zoneRemoved = new EventEmitter<number>()
|
||||
|
||||
zonePage(zone: OcrTemplateZone): number {
|
||||
return getZonePage(zone, this.previewPage, this.previewPageCount)
|
||||
}
|
||||
|
||||
getZoneTargetName(zone: OcrTemplateZone): string {
|
||||
const target = zone.target || 'custom_field'
|
||||
if (target === 'custom_field') {
|
||||
return zone.custom_field
|
||||
? this.getCustomFieldName(zone.custom_field)
|
||||
: $localize`(no field)`
|
||||
}
|
||||
return OCR_BUILTIN_TARGETS.find((t) => t.id === target)?.name ?? target
|
||||
}
|
||||
|
||||
private getCustomFieldName(id: number): string {
|
||||
return (
|
||||
this.customFields.find((field) => field.id === id)?.name ?? `Field #${id}`
|
||||
)
|
||||
}
|
||||
}
|
||||
-442
@@ -1,442 +0,0 @@
|
||||
<pngx-page-header [title]="pageTitle" [id]="template.id">
|
||||
<div class="input-group input-group-sm me-5 align-items-center">
|
||||
<div class="input-group-text">
|
||||
<i-bs name="file-text"></i-bs>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
[(ngModel)]="previewDocModel"
|
||||
[ngbTypeahead]="searchDocuments"
|
||||
[inputFormatter]="documentFormatter"
|
||||
[resultFormatter]="documentFormatter"
|
||||
(selectItem)="onPreviewDocSelected($event)"
|
||||
[editable]="false"
|
||||
placeholder="Search documents by title..."
|
||||
i18n-placeholder
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center flex-wrap gap-2">
|
||||
<div class="input-group input-group-sm ms-2 d-none d-md-flex">
|
||||
<div class="input-group-text" i18n>Page</div>
|
||||
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewPageCount" [(ngModel)]="previewPageDisplay" />
|
||||
<div class="input-group-text" i18n>of {{previewPageCount}}</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Previous" (click)="prevPage()" [disabled]="!pageImageUrl || previewPage <= 0">
|
||||
<i-bs width="1.2em" height="1.2em" name="arrow-left"></i-bs>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Next" (click)="nextPage()" [disabled]="!pageImageUrl || previewPage >= (previewPageCount ?? 1) - 1">
|
||||
<i-bs width="1.2em" height="1.2em" name="arrow-right"></i-bs>
|
||||
</button>
|
||||
|
||||
<div class="input-group input-group-sm">
|
||||
<button class="btn btn-outline-secondary" (click)="zoomOut()" i18n>-</button>
|
||||
<span class="input-group-text">{{ zoom * 100 | number: '1.0-0' }}%</span>
|
||||
<button class="btn btn-outline-secondary" (click)="zoomIn()" i18n>+</button>
|
||||
</div>
|
||||
</div>
|
||||
</pngx-page-header>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="btn-toolbar mb-1 border-bottom">
|
||||
<div class="btn-group pb-3">
|
||||
<a routerLink="/ocr-templates" class="btn btn-sm btn-outline-secondary">
|
||||
<i-bs width="1.2em" height="1.2em" name="x"></i-bs>
|
||||
<span class="ms-1" i18n>Close</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="btn-group ms-auto pb-3">
|
||||
<button class="btn btn-sm btn-primary" (click)="save()" [disabled]="saving">
|
||||
@if (saving) {
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||
}
|
||||
<span i18n>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTab" class="nav-underline flex-nowrap flex-md-wrap overflow-auto">
|
||||
<li ngbNavItem="settings">
|
||||
<a ngbNavLink i18n>Settings</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="row mb-3">
|
||||
<div class="col-9">
|
||||
<pngx-input-text [(ngModel)]="template.name" title="Template name" i18n-title></pngx-input-text>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<pngx-input-switch [(ngModel)]="template.enabled" title="Enabled" i18n-title></pngx-input-switch>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pngx-input-select [(ngModel)]="template.document_type" [items]="documentTypes" bindLabel="name" bindValue="id" title="Document type" i18n-title></pngx-input-select>
|
||||
|
||||
<small class="text-muted" i18n>
|
||||
Draw rectangles on the preview to define extraction zones. Use the
|
||||
page controls above the preview to add zones on different pages.
|
||||
</small>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li ngbNavItem="zones">
|
||||
<a ngbNavLink><ng-container i18n>Zones</ng-container> <span class="badge bg-primary ms-2">{{ template.zones.length }}</span></a>
|
||||
<ng-template ngbNavContent>
|
||||
<pngx-ocr-template-zone-list
|
||||
[zones]="template.zones"
|
||||
[selectedZoneIndex]="selectedZoneIndex"
|
||||
[previewPage]="previewPage"
|
||||
[previewPageCount]="previewPageCount"
|
||||
[customFields]="customFields"
|
||||
(zoneSelected)="selectZone($event)"
|
||||
(zoneRemoved)="removeZone($event)"
|
||||
></pngx-ocr-template-zone-list>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li ngbNavItem="zone">
|
||||
<a ngbNavLink i18n>Zone</a>
|
||||
<ng-template ngbNavContent>
|
||||
@if (selectedZone; as zone) {
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<strong>{{ zone.name }}</strong>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-primary" (click)="save()" [disabled]="saving">
|
||||
@if (saving) {
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||
}
|
||||
<span i18n>Save</span>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" (click)="deleteSelectedZone()">
|
||||
<i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Delete zone</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" i18n>Zone Name</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
[(ngModel)]="zone.name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" i18n>Page</label>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control"
|
||||
[(ngModel)]="zone.page"
|
||||
min="-1"
|
||||
/>
|
||||
<small class="text-muted" i18n>Page this zone is on. Use -1 for the last page. Set automatically when you draw it.</small>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" i18n>Field</label>
|
||||
<div class="input-group">
|
||||
<select class="form-select" [ngModel]="zoneFieldValue(zone)" (ngModelChange)="setZoneField(zone, $event)">
|
||||
<optgroup label="Built-in fields" i18n-label>
|
||||
@for (t of builtinTargets; track t.id) {
|
||||
<option [ngValue]="t.id">{{ t.name }}</option>
|
||||
}
|
||||
</optgroup>
|
||||
<optgroup label="Custom fields" i18n-label>
|
||||
@for (cf of customFields; track cf.id) {
|
||||
<option [ngValue]="cf.id">{{ cf.name }} ({{ cf.data_type }})</option>
|
||||
}
|
||||
</optgroup>
|
||||
</select>
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
type="button"
|
||||
(click)="openQuickCreate(selectedZoneIndex)"
|
||||
title="Create new custom field"
|
||||
i18n-title
|
||||
>
|
||||
<i-bs name="plus"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
<small class="text-muted" i18n>Write the extracted value to a custom field, or to a built-in field (Title, ASN, Date created).</small>
|
||||
</div>
|
||||
|
||||
@if (isFieldShared(zone)) {
|
||||
<div class="card mb-3 border-info">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title d-flex align-items-center gap-2">
|
||||
<i-bs name="braces"></i-bs>
|
||||
<span i18n>Combine zones into this field</span>
|
||||
</h6>
|
||||
<p class="small text-muted mb-2" i18n>
|
||||
More than one zone writes to this field. Build the combined
|
||||
value below: click a zone to insert its token, and type any
|
||||
separators or literal text between tokens.
|
||||
</p>
|
||||
<div class="d-flex flex-wrap gap-1 mb-2">
|
||||
@for (z of zonesForField(zone); track $index) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-info"
|
||||
(click)="insertCombineToken(zone, z)"
|
||||
title="Insert token"
|
||||
i18n-title
|
||||
>
|
||||
+ {{ z.name || 'Zone' }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control font-monospace"
|
||||
[ngModel]="getCombineFormat(zone)"
|
||||
(ngModelChange)="setCombineFormat(zone, $event)"
|
||||
placeholder="{Zone 1} - {Zone 2}"
|
||||
/>
|
||||
<small class="text-muted" i18n>
|
||||
Tokens are matched by zone name. An empty zone leaves its
|
||||
token blank and the stray separator is trimmed. Leave empty
|
||||
to just join the zones in order with a space.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showQuickCreate) {
|
||||
<div class="card mb-3 border-primary">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title" i18n>Create Custom Field</h6>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small" i18n>Field Name</label>
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
[(ngModel)]="quickCreateName" placeholder="e.g. Invoice Number" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small" i18n>Field Type</label>
|
||||
<select class="form-select form-select-sm" [(ngModel)]="quickCreateType">
|
||||
@for (t of quickCreateTypes; track t.id) {
|
||||
<option [ngValue]="t.id">{{ t.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-primary btn-sm" (click)="submitQuickCreate()"
|
||||
[disabled]="!quickCreateName.trim()" i18n>
|
||||
Create & Assign
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" (click)="cancelQuickCreate()" i18n>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" i18n>OCR Language</label>
|
||||
<ng-select
|
||||
[items]="ocrLanguageOptions"
|
||||
bindLabel="name"
|
||||
bindValue="id"
|
||||
[multiple]="true"
|
||||
[closeOnSelect]="false"
|
||||
[ngModel]="ocrLanguageArray(zone)"
|
||||
(ngModelChange)="setOcrLanguages(zone, $event)"
|
||||
placeholder="Select languages"
|
||||
i18n-placeholder
|
||||
></ng-select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" i18n>Transform</label>
|
||||
<select class="form-select" [(ngModel)]="zone.transform">
|
||||
@for (opt of transformOptions; track opt.id) {
|
||||
<option [ngValue]="opt.id">{{ opt.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@if (zone.transform === dateTransform) {
|
||||
<div class="mb-3">
|
||||
<label class="form-label" i18n>Date format</label>
|
||||
<select class="form-select" [ngModel]="dateFormatChoice(zone)" (ngModelChange)="setDateFormatChoice(zone, $event)">
|
||||
@for (opt of dateFormatOptions; track opt.id) {
|
||||
<option [ngValue]="opt.id">{{ opt.name }}</option>
|
||||
}
|
||||
<option [ngValue]="customDateFormatChoice" i18n>Custom...</option>
|
||||
</select>
|
||||
@if (usesCustomDateFormat(zone)) {
|
||||
<div class="input-group mt-2">
|
||||
<input type="text" class="form-control font-monospace" [(ngModel)]="zone.date_format" placeholder="%d.%m.%Y" />
|
||||
<button class="btn btn-outline-secondary" type="button" [ngbPopover]="dateFmtHelp" [autoClose]="true" title="Date format help" i18n-title>
|
||||
<i-bs name="question-circle"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
<ng-template #dateFmtHelp>
|
||||
<p class="mb-1" i18n>Python date codes:</p>
|
||||
<ul class="mb-1 ps-3">
|
||||
<li><code>%d</code> <ng-container i18n>day (01-31)</ng-container></li>
|
||||
<li><code>%m</code> <ng-container i18n>month (01-12)</ng-container></li>
|
||||
<li><code>%Y</code> <ng-container i18n>year, 4-digit</ng-container></li>
|
||||
<li><code>%y</code> <ng-container i18n>year, 2-digit</ng-container></li>
|
||||
<li><code>%b</code> <ng-container i18n>month name (Jan)</ng-container></li>
|
||||
</ul>
|
||||
<span i18n>Example:</span> <code>%d.%m.%Y</code> -> 03.03.2026
|
||||
</ng-template>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" i18n>Validation Regex</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control font-monospace"
|
||||
[(ngModel)]="zone.validation_regex"
|
||||
placeholder="e.g. \d{2}\.\d{2}\.\d{4}"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="text-muted small">
|
||||
{{ zone.x }}, {{ zone.y }} - {{ zone.width }}x{{ zone.height }}px
|
||||
</div>
|
||||
|
||||
<hr class="my-3" />
|
||||
<h6 i18n>Test</h6>
|
||||
@if (!previewDocId) {
|
||||
<p class="text-muted small mb-0" i18n>
|
||||
Load a document in the Settings tab to test this zone.
|
||||
</p>
|
||||
} @else {
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="testZone()" [disabled]="zoneTesting">
|
||||
@if (zoneTesting) {
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||
}
|
||||
<span i18n>Test this zone</span>
|
||||
</button>
|
||||
@if (zoneTestResult) {
|
||||
@if (zoneTestResult.error) {
|
||||
<div class="alert alert-warning py-2 mt-2 mb-0 small">{{ zoneTestResult.error }}</div>
|
||||
} @else {
|
||||
<dl class="row small mt-2 mb-0">
|
||||
<dt class="col-sm-4" i18n>OCR text</dt>
|
||||
<dd class="col-sm-8"><code>{{ zoneTestResult.raw_text || '(nothing detected)' }}</code></dd>
|
||||
<dt class="col-sm-4" i18n>Value</dt>
|
||||
<dd class="col-sm-8"><code>{{ zoneTestResult.value || '(empty)' }}</code></dd>
|
||||
@if (zoneTestResult.regex) {
|
||||
<dt class="col-sm-4" i18n>Validation</dt>
|
||||
<dd class="col-sm-8">
|
||||
@if (zoneTestResult.regex_match) {
|
||||
<span class="badge bg-success" i18n>Regex matches</span>
|
||||
} @else {
|
||||
<span class="badge bg-danger" i18n>Regex does not match</span>
|
||||
}
|
||||
</dd>
|
||||
}
|
||||
</dl>
|
||||
}
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
<p class="text-muted" i18n>
|
||||
Select a zone from the Zones tab, or draw a rectangle on the document to create one.
|
||||
</p>
|
||||
}
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div [ngbNavOutlet]="nav" class="mt-3"></div>
|
||||
</div>
|
||||
|
||||
<!-- Right column: Document preview with zone overlay -->
|
||||
<div class="col-md-8">
|
||||
@if (pageImageUrl) {
|
||||
<div class="zone-preview-scroll border">
|
||||
<div class="zone-preview-stage" [style.width.%]="zoom * 100">
|
||||
<img
|
||||
#pageImage
|
||||
[src]="pageImageUrl"
|
||||
(load)="onImageLoad()"
|
||||
class="zone-preview-image"
|
||||
[style.visibility]="imageLoaded ? 'visible' : 'hidden'"
|
||||
crossorigin="use-credentials"
|
||||
/>
|
||||
@if (imageLoaded) {
|
||||
<svg
|
||||
#zoneOverlay
|
||||
class="zone-overlay"
|
||||
[attr.viewBox]="overlayViewBox()"
|
||||
preserveAspectRatio="none"
|
||||
[style.cursor]="overlayCursor"
|
||||
(mousedown)="onOverlayMouseDown($event)"
|
||||
(mousemove)="onOverlayMouseMove($event)"
|
||||
(mouseup)="onOverlayMouseUp($event)"
|
||||
>
|
||||
@for (zone of template.zones; track $index; let i = $index) {
|
||||
@if (zoneDisplayRect(i); as rect) {
|
||||
<g>
|
||||
<rect
|
||||
class="zone-rect"
|
||||
[class.zone-rect-selected]="selectedZoneIndex === i"
|
||||
[attr.x]="rect.x"
|
||||
[attr.y]="rect.y"
|
||||
[attr.width]="rect.w"
|
||||
[attr.height]="rect.h"
|
||||
[attr.stroke]="zoneColor(i)"
|
||||
[attr.fill]="zoneFill(i)"
|
||||
></rect>
|
||||
<text
|
||||
class="zone-label"
|
||||
[attr.x]="rect.x + overlayUnitSize(6)"
|
||||
[attr.y]="zoneLabelY(rect)"
|
||||
[attr.font-size]="overlayFontSize()"
|
||||
[attr.fill]="zoneColor(i)"
|
||||
>{{ zoneLabel(zone, i) }}</text>
|
||||
|
||||
@if (selectedZoneIndex === i) {
|
||||
@for (handle of resizeHandles(rect); track handle.handle) {
|
||||
<rect
|
||||
class="zone-resize-handle"
|
||||
[attr.x]="handle.x - overlayHandleSize() / 2"
|
||||
[attr.y]="handle.y - overlayHandleSize() / 2"
|
||||
[attr.width]="overlayHandleSize()"
|
||||
[attr.height]="overlayHandleSize()"
|
||||
[attr.fill]="zoneColor(i)"
|
||||
></rect>
|
||||
}
|
||||
}
|
||||
</g>
|
||||
}
|
||||
}
|
||||
|
||||
@if (drawingRect(); as rect) {
|
||||
<rect
|
||||
class="zone-drawing-rect"
|
||||
[attr.x]="rect.x"
|
||||
[attr.y]="rect.y"
|
||||
[attr.width]="rect.w"
|
||||
[attr.height]="rect.h"
|
||||
></rect>
|
||||
}
|
||||
</svg>
|
||||
}
|
||||
@if (!imageLoaded) {
|
||||
<div class="d-flex justify-content-center p-5">
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="visually-hidden" i18n>Loading page...</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="border rounded p-5 text-center text-muted">
|
||||
<i-bs name="file-earmark-image" width="48" height="48"></i-bs>
|
||||
<p class="mt-3" i18n>
|
||||
Enter a document ID and click "Load" to preview a page and draw extraction zones.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.zone-preview-scroll {
|
||||
max-height: 78vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.zone-preview-stage {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.zone-preview-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.zone-overlay {
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
touch-action: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.zone-rect,
|
||||
.zone-drawing-rect {
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.zone-rect {
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.zone-rect-selected {
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.zone-label {
|
||||
font-family: var(--bs-font-sans-serif);
|
||||
font-weight: 600;
|
||||
paint-order: stroke;
|
||||
pointer-events: none;
|
||||
stroke: #fff;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 4px;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.zone-resize-handle {
|
||||
stroke: #fff;
|
||||
stroke-width: 1;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.zone-drawing-rect {
|
||||
fill: rgba(105, 219, 124, 0.25);
|
||||
stroke: #69db7c;
|
||||
stroke-dasharray: 5 5;
|
||||
stroke-width: 2;
|
||||
}
|
||||
-962
@@ -1,962 +0,0 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
inject,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
ViewChild,
|
||||
} from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
||||
import {
|
||||
NgbNavModule,
|
||||
NgbPopoverModule,
|
||||
NgbTypeaheadModule,
|
||||
NgbTypeaheadSelectItemEvent,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import {
|
||||
catchError,
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
Observable,
|
||||
of,
|
||||
Subject,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
} from 'rxjs'
|
||||
import { SelectComponent } from 'src/app/components/common/input/select/select.component'
|
||||
import { SwitchComponent } from 'src/app/components/common/input/switch/switch.component'
|
||||
import { TextComponent } from 'src/app/components/common/input/text/text.component'
|
||||
import { PageHeaderComponent } from 'src/app/components/common/page-header/page-header.component'
|
||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
import { Document } from 'src/app/data/document'
|
||||
import { DocumentType } from 'src/app/data/document-type'
|
||||
import {
|
||||
DATE_FORMAT_OPTIONS,
|
||||
DEFAULT_OCR_ZONE_LANGUAGE,
|
||||
DEFAULT_OCR_ZONE_TARGET,
|
||||
DEFAULT_OCR_ZONE_TRANSFORM,
|
||||
isOcrBuiltinTarget,
|
||||
OCR_BUILTIN_TARGETS,
|
||||
OCR_LANGUAGE_OPTIONS,
|
||||
OCR_ZONE_TARGET,
|
||||
OCR_ZONE_TRANSFORM,
|
||||
OcrBuiltinTarget,
|
||||
OcrTemplate,
|
||||
OcrTemplateZone,
|
||||
OcrZoneTestResult,
|
||||
TRANSFORM_OPTIONS,
|
||||
ZoneTestRequest,
|
||||
} from 'src/app/data/ocr-template'
|
||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { OcrTemplateEditorZoneListComponent } from './ocr-template-editor-zone-list/ocr-template-editor-zone-list.component'
|
||||
import {
|
||||
DisplayRect,
|
||||
DrawingRect,
|
||||
findHandleAt,
|
||||
findZoneAt,
|
||||
getZoneDisplayRect,
|
||||
getZonePage,
|
||||
HANDLE_SIZE,
|
||||
isZoneOnPage,
|
||||
MoveStart,
|
||||
moveZone,
|
||||
Point,
|
||||
ResizeHandle,
|
||||
resizeZone,
|
||||
} from './zone-geometry'
|
||||
|
||||
type ActiveTab = 'settings' | 'zones' | 'zone'
|
||||
type ZoneFieldSelection = OcrBuiltinTarget | number | null
|
||||
type OverlayInteraction =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'drawing'; rect: DrawingRect }
|
||||
| { kind: 'moving'; zoneIndex: number; start: MoveStart }
|
||||
| { kind: 'resizing'; zoneIndex: number; handle: ResizeHandle }
|
||||
interface ResizeHandleMarker extends Point {
|
||||
handle: ResizeHandle
|
||||
}
|
||||
|
||||
const CUSTOM_DATE_FORMAT_CHOICE = 'custom'
|
||||
const MIN_DRAWN_ZONE_SIZE = 10
|
||||
const NO_OVERLAY_INTERACTION: OverlayInteraction = { kind: 'idle' }
|
||||
const ZONE_COLORS = [
|
||||
'#4f8ff7',
|
||||
'#ff6b6b',
|
||||
'#51cf66',
|
||||
'#ffd43b',
|
||||
'#cc5de8',
|
||||
'#ff922b',
|
||||
'#20c997',
|
||||
'#e599f7',
|
||||
]
|
||||
const RESIZE_CURSOR: Record<ResizeHandle, string> = {
|
||||
nw: 'nw-resize',
|
||||
ne: 'ne-resize',
|
||||
sw: 'sw-resize',
|
||||
se: 'se-resize',
|
||||
n: 'n-resize',
|
||||
s: 's-resize',
|
||||
w: 'w-resize',
|
||||
e: 'e-resize',
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-ocr-template-editor',
|
||||
standalone: true,
|
||||
imports: [
|
||||
PageHeaderComponent,
|
||||
TextComponent,
|
||||
SelectComponent,
|
||||
SwitchComponent,
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule,
|
||||
NgbNavModule,
|
||||
NgbPopoverModule,
|
||||
NgbTypeaheadModule,
|
||||
NgSelectModule,
|
||||
NgxBootstrapIconsModule,
|
||||
OcrTemplateEditorZoneListComponent,
|
||||
],
|
||||
templateUrl: './ocr-template-editor.component.html',
|
||||
styleUrls: ['./ocr-template-editor.component.scss'],
|
||||
})
|
||||
export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
private readonly route = inject(ActivatedRoute)
|
||||
private readonly router = inject(Router)
|
||||
private readonly templateService = inject(OcrTemplateService)
|
||||
private readonly customFieldsService = inject(CustomFieldsService)
|
||||
private readonly documentTypeService = inject(DocumentTypeService)
|
||||
private readonly correspondentService = inject(CorrespondentService)
|
||||
private readonly documentService = inject(DocumentService)
|
||||
private readonly toastService = inject(ToastService)
|
||||
private readonly destroy$ = new Subject<void>()
|
||||
private readonly customDateFormatZones = new WeakSet<OcrTemplateZone>()
|
||||
|
||||
@ViewChild('zoneOverlay') overlayRef: ElementRef<SVGSVGElement>
|
||||
@ViewChild('pageImage') imageRef: ElementRef<HTMLImageElement>
|
||||
|
||||
template: OcrTemplate = {
|
||||
id: null,
|
||||
name: '',
|
||||
document_type: null,
|
||||
sample_document: null,
|
||||
source_width: 0,
|
||||
source_height: 0,
|
||||
enabled: true,
|
||||
combine_formats: {},
|
||||
zones: [],
|
||||
}
|
||||
|
||||
customFields: CustomField[] = []
|
||||
documentTypes: DocumentType[] = []
|
||||
transformOptions = TRANSFORM_OPTIONS
|
||||
builtinTargets = OCR_BUILTIN_TARGETS
|
||||
dateFormatOptions = DATE_FORMAT_OPTIONS
|
||||
ocrLanguageOptions = OCR_LANGUAGE_OPTIONS
|
||||
dateTransform = OCR_ZONE_TRANSFORM.Date
|
||||
customDateFormatChoice = CUSTOM_DATE_FORMAT_CHOICE
|
||||
isNew = true
|
||||
saving = false
|
||||
|
||||
previewDocId: number | null = null
|
||||
previewPage = 0
|
||||
previewPageCount: number | null = null
|
||||
private pageCountForDoc: number | null = null
|
||||
pageImageUrl: string | null = null
|
||||
imageLoaded = false
|
||||
zoom = 1
|
||||
previewDocModel: Document | string = ''
|
||||
private correspondentNames = new Map<number, string>()
|
||||
|
||||
public get previewPageDisplay(): number {
|
||||
return this.previewPage + 1
|
||||
}
|
||||
|
||||
public set previewPageDisplay(value: number) {
|
||||
this.goToPage(value - 1)
|
||||
}
|
||||
|
||||
activeTab: ActiveTab = 'settings'
|
||||
|
||||
selectedZoneIndex: number | null = null
|
||||
private overlayInteraction: OverlayInteraction = NO_OVERLAY_INTERACTION
|
||||
overlayCursor = 'crosshair'
|
||||
|
||||
zoneTestResult: OcrZoneTestResult | null = null
|
||||
zoneTesting = false
|
||||
|
||||
showQuickCreate = false
|
||||
quickCreateName = ''
|
||||
quickCreateType = CustomFieldDataType.String
|
||||
quickCreateForZoneIndex: number | null = null
|
||||
quickCreateTypes = [
|
||||
{ id: CustomFieldDataType.String, name: $localize`String` },
|
||||
{ id: CustomFieldDataType.Integer, name: $localize`Integer` },
|
||||
{ id: CustomFieldDataType.Float, name: $localize`Float` },
|
||||
{ id: CustomFieldDataType.Date, name: $localize`Date` },
|
||||
{ id: CustomFieldDataType.Monetary, name: $localize`Monetary` },
|
||||
{ id: CustomFieldDataType.Boolean, name: $localize`Boolean` },
|
||||
{ id: CustomFieldDataType.Url, name: $localize`URL` },
|
||||
{ id: CustomFieldDataType.LongText, name: $localize`Long Text` },
|
||||
]
|
||||
|
||||
get selectedZone(): OcrTemplateZone | null {
|
||||
return this.selectedZoneIndex !== null
|
||||
? (this.template.zones[this.selectedZoneIndex] ?? null)
|
||||
: null
|
||||
}
|
||||
|
||||
get pageTitle(): string {
|
||||
return this.isNew
|
||||
? $localize`New OCR Template`
|
||||
: $localize`Edit OCR Template`
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.customFieldsService
|
||||
.listAll()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((r) => (this.customFields = r.results))
|
||||
|
||||
this.documentTypeService
|
||||
.listAll()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((r) => (this.documentTypes = r.results))
|
||||
|
||||
this.correspondentService
|
||||
.listAll()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((r) => {
|
||||
this.correspondentNames = new Map(r.results.map((c) => [c.id, c.name]))
|
||||
})
|
||||
|
||||
const id = this.route.snapshot.paramMap.get('id')
|
||||
if (id && id !== 'new') {
|
||||
this.isNew = false
|
||||
this.templateService
|
||||
.get(parseInt(id))
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((t) => {
|
||||
this.template = t
|
||||
this.template.combine_formats ??= {}
|
||||
if (t.sample_document) {
|
||||
this.previewDocId = t.sample_document
|
||||
this.loadPreview()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const qp = this.route.snapshot.queryParams
|
||||
if (qp['document_type']) {
|
||||
this.template.document_type = parseInt(qp['document_type'])
|
||||
}
|
||||
if (qp['sample_document']) {
|
||||
const docId = parseInt(qp['sample_document'])
|
||||
this.template.sample_document = docId
|
||||
this.previewDocId = docId
|
||||
this.loadPreview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
searchDocuments = (text$: Observable<string>): Observable<Document[]> =>
|
||||
text$.pipe(
|
||||
debounceTime(250),
|
||||
distinctUntilChanged(),
|
||||
switchMap((term) => {
|
||||
if (!term || term.trim().length < 2) return of([])
|
||||
const params: { title__icontains: string; document_type__id?: number } =
|
||||
{ title__icontains: term.trim() }
|
||||
if (this.template.document_type) {
|
||||
params['document_type__id'] = this.template.document_type
|
||||
}
|
||||
return this.documentService.list(1, 10, 'created', true, params).pipe(
|
||||
map((r) => r.results),
|
||||
catchError(() => of([]))
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
documentFormatter = (doc: Document | string): string => {
|
||||
if (typeof doc === 'string') return doc
|
||||
const corr = doc.correspondent
|
||||
? this.correspondentNames.get(doc.correspondent)
|
||||
: null
|
||||
return corr
|
||||
? `#${doc.id} ${doc.title} (${corr})`
|
||||
: `#${doc.id} ${doc.title}`
|
||||
}
|
||||
|
||||
onPreviewDocSelected(event: NgbTypeaheadSelectItemEvent<Document>) {
|
||||
event.preventDefault()
|
||||
const doc: Document = event.item
|
||||
this.previewDocModel = doc
|
||||
this.previewDocId = doc.id
|
||||
if (!this.template.document_type && doc.document_type) {
|
||||
this.template.document_type = doc.document_type
|
||||
}
|
||||
this.previewPage = 0
|
||||
this.loadPreview()
|
||||
}
|
||||
|
||||
clearPreviewDoc() {
|
||||
this.previewDocModel = ''
|
||||
this.previewDocId = null
|
||||
this.previewPageCount = null
|
||||
this.pageCountForDoc = null
|
||||
this.previewPage = 0
|
||||
this.pageImageUrl = null
|
||||
this.imageLoaded = false
|
||||
}
|
||||
|
||||
loadPreview() {
|
||||
if (!this.previewDocId) return
|
||||
if (this.pageCountForDoc !== this.previewDocId) {
|
||||
this.pageCountForDoc = this.previewDocId
|
||||
this.previewPageCount = null
|
||||
this.documentService
|
||||
.get(this.previewDocId)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (doc) => {
|
||||
this.previewPageCount = doc?.page_count ?? null
|
||||
if (doc && !this.previewDocModel) this.previewDocModel = doc
|
||||
},
|
||||
error: () => (this.previewPageCount = null),
|
||||
})
|
||||
}
|
||||
this.pageImageUrl = this.templateService.getPageImageUrl(
|
||||
this.previewDocId,
|
||||
this.previewPage
|
||||
)
|
||||
this.imageLoaded = false
|
||||
}
|
||||
|
||||
goToPage(page: number) {
|
||||
if (!Number.isFinite(page)) return
|
||||
const max = this.previewPageCount ? this.previewPageCount - 1 : page
|
||||
const clamped = Math.max(0, Math.min(page, max))
|
||||
if (clamped === this.previewPage) return
|
||||
this.previewPage = clamped
|
||||
this.loadPreview()
|
||||
}
|
||||
|
||||
prevPage() {
|
||||
this.goToPage(this.previewPage - 1)
|
||||
}
|
||||
|
||||
nextPage() {
|
||||
this.goToPage(this.previewPage + 1)
|
||||
}
|
||||
|
||||
zoomIn() {
|
||||
this.zoom = Math.min(4, Math.round((this.zoom + 0.25) * 100) / 100)
|
||||
}
|
||||
|
||||
zoomOut() {
|
||||
this.zoom = Math.max(0.5, Math.round((this.zoom - 0.25) * 100) / 100)
|
||||
}
|
||||
|
||||
resetZoom() {
|
||||
this.zoom = 1
|
||||
}
|
||||
|
||||
zonePage(zone: OcrTemplateZone): number {
|
||||
return getZonePage(zone, this.previewPage, this.previewPageCount)
|
||||
}
|
||||
|
||||
private isOnCurrentPage(zone: OcrTemplateZone): boolean {
|
||||
return isZoneOnPage(zone, this.previewPage, this.previewPageCount)
|
||||
}
|
||||
|
||||
onImageLoad() {
|
||||
this.imageLoaded = true
|
||||
const img = this.imageRef.nativeElement
|
||||
this.template.source_width = img.naturalWidth
|
||||
this.template.source_height = img.naturalHeight
|
||||
}
|
||||
|
||||
onOverlayMouseDown(event: MouseEvent) {
|
||||
const point = this.svgPointFromEvent(event)
|
||||
if (!point) return
|
||||
event.preventDefault()
|
||||
|
||||
if (this.selectedZoneIndex !== null) {
|
||||
const handle = this.findHandleAt(point, this.selectedZoneIndex)
|
||||
if (handle) {
|
||||
this.overlayInteraction = {
|
||||
kind: 'resizing',
|
||||
zoneIndex: this.selectedZoneIndex,
|
||||
handle,
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const clickedIdx = this.findZoneAt(point)
|
||||
if (clickedIdx !== null && !event.shiftKey) {
|
||||
this.selectZone(clickedIdx)
|
||||
const zone = this.template.zones[clickedIdx]
|
||||
this.overlayInteraction = {
|
||||
kind: 'moving',
|
||||
zoneIndex: clickedIdx,
|
||||
start: {
|
||||
mouseX: point.x,
|
||||
mouseY: point.y,
|
||||
zoneX: zone.x,
|
||||
zoneY: zone.y,
|
||||
},
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Shift+click or click on empty area starts a new zone.
|
||||
this.overlayInteraction = {
|
||||
kind: 'drawing',
|
||||
rect: {
|
||||
startX: point.x,
|
||||
startY: point.y,
|
||||
endX: point.x,
|
||||
endY: point.y,
|
||||
},
|
||||
}
|
||||
this.selectedZoneIndex = null
|
||||
}
|
||||
|
||||
onOverlayMouseMove(event: MouseEvent) {
|
||||
const point = this.svgPointFromEvent(event)
|
||||
if (!point) return
|
||||
|
||||
if (this.overlayInteraction.kind === 'resizing') {
|
||||
this.applyResize(
|
||||
this.overlayInteraction.zoneIndex,
|
||||
this.overlayInteraction.handle,
|
||||
point
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.overlayInteraction.kind === 'moving') {
|
||||
moveZone(
|
||||
this.template.zones[this.overlayInteraction.zoneIndex],
|
||||
point,
|
||||
this.overlayInteraction.start,
|
||||
this.imageNaturalSize(),
|
||||
this.imageNaturalSize()
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.overlayInteraction.kind === 'drawing') {
|
||||
this.overlayInteraction.rect.endX = point.x
|
||||
this.overlayInteraction.rect.endY = point.y
|
||||
return
|
||||
}
|
||||
|
||||
this.updateOverlayCursor(point)
|
||||
}
|
||||
|
||||
private updateOverlayCursor(point: Point) {
|
||||
if (this.selectedZoneIndex !== null) {
|
||||
const handle = this.findHandleAt(point, this.selectedZoneIndex)
|
||||
if (handle) {
|
||||
this.overlayCursor = RESIZE_CURSOR[handle] || 'crosshair'
|
||||
return
|
||||
}
|
||||
}
|
||||
this.overlayCursor = this.findZoneAt(point) !== null ? 'move' : 'crosshair'
|
||||
}
|
||||
|
||||
onOverlayMouseUp(_event: MouseEvent) {
|
||||
if (
|
||||
this.overlayInteraction.kind === 'moving' ||
|
||||
this.overlayInteraction.kind === 'resizing'
|
||||
) {
|
||||
this.stopOverlayInteraction()
|
||||
return
|
||||
}
|
||||
|
||||
if (this.overlayInteraction.kind !== 'drawing') return
|
||||
const drawingRect = this.overlayInteraction.rect
|
||||
this.stopOverlayInteraction()
|
||||
|
||||
const rect = this.sourceRectFromDrawing(drawingRect)
|
||||
|
||||
// Ignore tiny accidental clicks.
|
||||
if (rect.w < MIN_DRAWN_ZONE_SIZE || rect.h < MIN_DRAWN_ZONE_SIZE) {
|
||||
return
|
||||
}
|
||||
|
||||
this.template.zones.push(this.createZoneFromRect(rect))
|
||||
this.selectZone(this.template.zones.length - 1)
|
||||
}
|
||||
|
||||
private createZoneFromRect(rect: DisplayRect): OcrTemplateZone {
|
||||
const imageSize = this.imageNaturalSize()
|
||||
return {
|
||||
name: `Zone ${this.template.zones.length + 1}`,
|
||||
target: DEFAULT_OCR_ZONE_TARGET,
|
||||
custom_field: this.defaultCustomFieldId(),
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.w,
|
||||
height: rect.h,
|
||||
page: this.previewPageDisplay,
|
||||
ocr_language: DEFAULT_OCR_ZONE_LANGUAGE,
|
||||
transform: DEFAULT_OCR_ZONE_TRANSFORM,
|
||||
date_format: '',
|
||||
validation_regex: '',
|
||||
order: this.template.zones.length,
|
||||
zone_source_width: imageSize.width,
|
||||
zone_source_height: imageSize.height,
|
||||
}
|
||||
}
|
||||
|
||||
private defaultCustomFieldId(): number | null {
|
||||
return this.customFields[0]?.id ?? null
|
||||
}
|
||||
|
||||
@HostListener('document:mouseup')
|
||||
onDocumentMouseUp() {
|
||||
if (this.overlayInteraction.kind === 'idle') return
|
||||
this.stopOverlayInteraction()
|
||||
}
|
||||
|
||||
private stopOverlayInteraction() {
|
||||
this.overlayInteraction = NO_OVERLAY_INTERACTION
|
||||
this.overlayCursor = 'crosshair'
|
||||
}
|
||||
|
||||
drawingRect(): DisplayRect | null {
|
||||
return this.overlayInteraction.kind === 'drawing'
|
||||
? this.displayRectFromDrawing(this.overlayInteraction.rect)
|
||||
: null
|
||||
}
|
||||
|
||||
zoneDisplayRect(zoneIdx: number): DisplayRect | null {
|
||||
const img = this.imageRef?.nativeElement
|
||||
if (!img || !img.naturalWidth) return null
|
||||
const zone = this.template.zones[zoneIdx]
|
||||
if (!zone) return null
|
||||
if (!this.isOnCurrentPage(zone)) return null
|
||||
return getZoneDisplayRect(
|
||||
zone,
|
||||
this.imageNaturalSize(),
|
||||
this.imageNaturalSize()
|
||||
)
|
||||
}
|
||||
|
||||
private findHandleAt(point: Point, zoneIdx: number): ResizeHandle | null {
|
||||
const r = this.zoneDisplayRect(zoneIdx)
|
||||
if (!r) return null
|
||||
return findHandleAt(point, r, this.overlayHandleSize())
|
||||
}
|
||||
|
||||
private applyResize(zoneIndex: number, handle: ResizeHandle, point: Point) {
|
||||
const zone = this.template.zones[zoneIndex]
|
||||
if (!zone) return
|
||||
resizeZone(
|
||||
zone,
|
||||
handle,
|
||||
point,
|
||||
this.imageNaturalSize(),
|
||||
this.imageNaturalSize()
|
||||
)
|
||||
}
|
||||
|
||||
private findZoneAt(point: Point): number | null {
|
||||
const img = this.imageRef.nativeElement
|
||||
if (!img.naturalWidth) return null
|
||||
|
||||
return findZoneAt(
|
||||
point,
|
||||
this.template.zones,
|
||||
this.previewPage,
|
||||
this.previewPageCount,
|
||||
this.imageNaturalSize(),
|
||||
this.imageNaturalSize()
|
||||
)
|
||||
}
|
||||
|
||||
overlayViewBox(): string {
|
||||
const imageSize = this.imageNaturalSize()
|
||||
return `0 0 ${imageSize.width} ${imageSize.height}`
|
||||
}
|
||||
|
||||
zoneColor(index: number): string {
|
||||
return ZONE_COLORS[index % ZONE_COLORS.length]
|
||||
}
|
||||
|
||||
zoneFill(index: number): string {
|
||||
return `${this.zoneColor(index)}33`
|
||||
}
|
||||
|
||||
zoneLabel(zone: OcrTemplateZone, index: number): string {
|
||||
return zone.name || `Zone ${index + 1}`
|
||||
}
|
||||
|
||||
zoneLabelY(rect: DisplayRect): number {
|
||||
return Math.max(this.overlayUnitSize(14), rect.y - this.overlayUnitSize(4))
|
||||
}
|
||||
|
||||
resizeHandles(rect: DisplayRect): ResizeHandleMarker[] {
|
||||
return [
|
||||
{ handle: 'nw', x: rect.x, y: rect.y },
|
||||
{ handle: 'n', x: rect.x + rect.w / 2, y: rect.y },
|
||||
{ handle: 'ne', x: rect.x + rect.w, y: rect.y },
|
||||
{ handle: 'w', x: rect.x, y: rect.y + rect.h / 2 },
|
||||
{ handle: 'e', x: rect.x + rect.w, y: rect.y + rect.h / 2 },
|
||||
{ handle: 'sw', x: rect.x, y: rect.y + rect.h },
|
||||
{ handle: 's', x: rect.x + rect.w / 2, y: rect.y + rect.h },
|
||||
{ handle: 'se', x: rect.x + rect.w, y: rect.y + rect.h },
|
||||
]
|
||||
}
|
||||
|
||||
overlayHandleSize(): number {
|
||||
return this.overlayUnitSize(HANDLE_SIZE)
|
||||
}
|
||||
|
||||
overlayFontSize(): number {
|
||||
return this.overlayUnitSize(12)
|
||||
}
|
||||
|
||||
overlayUnitSize(screenPixels: number): number {
|
||||
const img = this.imageRef?.nativeElement
|
||||
if (!img?.naturalWidth || !img.clientWidth) return screenPixels
|
||||
return (screenPixels * img.naturalWidth) / img.clientWidth
|
||||
}
|
||||
|
||||
private svgPointFromEvent(event: MouseEvent): Point | null {
|
||||
const svg = this.overlayRef?.nativeElement
|
||||
const matrix = svg?.getScreenCTM()
|
||||
if (!svg || !matrix) return null
|
||||
|
||||
const point = svg.createSVGPoint()
|
||||
point.x = event.clientX
|
||||
point.y = event.clientY
|
||||
|
||||
const svgPoint = point.matrixTransform(matrix.inverse())
|
||||
return { x: svgPoint.x, y: svgPoint.y }
|
||||
}
|
||||
|
||||
private displayRectFromDrawing(rect: DrawingRect): DisplayRect {
|
||||
return {
|
||||
x: Math.min(rect.startX, rect.endX),
|
||||
y: Math.min(rect.startY, rect.endY),
|
||||
w: Math.abs(rect.endX - rect.startX),
|
||||
h: Math.abs(rect.endY - rect.startY),
|
||||
}
|
||||
}
|
||||
|
||||
private sourceRectFromDrawing(rect: DrawingRect): DisplayRect {
|
||||
const displayRect = this.displayRectFromDrawing(rect)
|
||||
return {
|
||||
x: Math.round(displayRect.x),
|
||||
y: Math.round(displayRect.y),
|
||||
w: Math.round(displayRect.w),
|
||||
h: Math.round(displayRect.h),
|
||||
}
|
||||
}
|
||||
|
||||
private imageNaturalSize() {
|
||||
const img = this.imageRef.nativeElement
|
||||
return { width: img.naturalWidth, height: img.naturalHeight }
|
||||
}
|
||||
|
||||
removeZone(index: number) {
|
||||
this.template.zones.splice(index, 1)
|
||||
if (this.selectedZoneIndex === index) {
|
||||
this.selectedZoneIndex = null
|
||||
} else if (this.selectedZoneIndex > index) {
|
||||
this.selectedZoneIndex--
|
||||
}
|
||||
}
|
||||
|
||||
selectZone(index: number) {
|
||||
this.selectedZoneIndex = index
|
||||
this.activeTab = 'zone'
|
||||
this.zoneTestResult = null
|
||||
const zone = this.template.zones[index]
|
||||
if (zone) {
|
||||
this.seedCombineDefault(zone)
|
||||
this.goToPage(this.zonePage(zone) - 1)
|
||||
}
|
||||
}
|
||||
|
||||
testZone() {
|
||||
const zone = this.selectedZone
|
||||
if (!zone || !this.previewDocId) return
|
||||
this.zoneTesting = true
|
||||
this.zoneTestResult = null
|
||||
this.templateService
|
||||
.testZone(this.previewDocId, this.zoneTestRequest(zone))
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (res) => {
|
||||
this.zoneTestResult = res
|
||||
this.zoneTesting = false
|
||||
},
|
||||
error: (err) => {
|
||||
this.zoneTestResult = {
|
||||
error: err.error?.error || $localize`Test failed`,
|
||||
}
|
||||
this.zoneTesting = false
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private zoneTestRequest(zone: OcrTemplateZone): ZoneTestRequest {
|
||||
return {
|
||||
name: zone.name,
|
||||
x: zone.x,
|
||||
y: zone.y,
|
||||
width: zone.width,
|
||||
height: zone.height,
|
||||
page: zone.page ?? 1,
|
||||
ocr_language: zone.ocr_language,
|
||||
transform: zone.transform,
|
||||
date_format: zone.date_format,
|
||||
validation_regex: zone.validation_regex,
|
||||
zone_source_width: zone.zone_source_width,
|
||||
zone_source_height: zone.zone_source_height,
|
||||
}
|
||||
}
|
||||
|
||||
deleteSelectedZone() {
|
||||
if (this.selectedZoneIndex === null) return
|
||||
this.removeZone(this.selectedZoneIndex)
|
||||
this.activeTab = 'zones'
|
||||
}
|
||||
|
||||
save() {
|
||||
this.saving = true
|
||||
this.pruneCombineFormats()
|
||||
this.template.sample_document = this.previewDocId
|
||||
const obs = this.isNew
|
||||
? this.templateService.create(this.template)
|
||||
: this.templateService.update(this.template)
|
||||
|
||||
obs.pipe(takeUntil(this.destroy$)).subscribe({
|
||||
next: (saved) => {
|
||||
const idx = this.selectedZoneIndex
|
||||
this.template = saved
|
||||
this.isNew = false
|
||||
this.selectedZoneIndex = idx
|
||||
this.saving = false
|
||||
this.toastService.showInfo($localize`OCR template saved.`)
|
||||
},
|
||||
error: (e) => {
|
||||
this.saving = false
|
||||
this.toastService.showError($localize`Error saving OCR template.`, e)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private ocrLangCache = new WeakMap<
|
||||
OcrTemplateZone,
|
||||
{ src: string; arr: string[] }
|
||||
>()
|
||||
|
||||
ocrLanguageArray(zone: OcrTemplateZone): string[] {
|
||||
const src = zone.ocr_language || ''
|
||||
const cached = this.ocrLangCache.get(zone)
|
||||
if (cached && cached.src === src) return cached.arr
|
||||
const arr = src ? src.split('+').filter(Boolean) : []
|
||||
this.ocrLangCache.set(zone, { src, arr })
|
||||
return arr
|
||||
}
|
||||
|
||||
setOcrLanguages(zone: OcrTemplateZone, langs: string[]) {
|
||||
zone.ocr_language = (langs || []).join('+')
|
||||
this.ocrLangCache.set(zone, {
|
||||
src: zone.ocr_language,
|
||||
arr: langs ? [...langs] : [],
|
||||
})
|
||||
}
|
||||
|
||||
getCustomFieldName(id: number): string {
|
||||
const cf = this.customFields.find((f) => f.id === id)
|
||||
return cf ? cf.name : `Field #${id}`
|
||||
}
|
||||
|
||||
/** Value bound to the field select: a built-in id string or a custom-field id. */
|
||||
zoneFieldValue(zone: OcrTemplateZone): ZoneFieldSelection {
|
||||
const target = zone.target || DEFAULT_OCR_ZONE_TARGET
|
||||
return target === OCR_ZONE_TARGET.CustomField ? zone.custom_field : target
|
||||
}
|
||||
|
||||
setZoneField(zone: OcrTemplateZone, value: ZoneFieldSelection) {
|
||||
if (isOcrBuiltinTarget(value)) {
|
||||
zone.target = value
|
||||
zone.custom_field = null
|
||||
} else {
|
||||
zone.target = OCR_ZONE_TARGET.CustomField
|
||||
zone.custom_field = typeof value === 'number' ? value : null
|
||||
}
|
||||
this.seedCombineDefault(zone)
|
||||
}
|
||||
|
||||
fieldKeyFor(zone: OcrTemplateZone): string | null {
|
||||
const v = this.zoneFieldValue(zone)
|
||||
return v === null || v === undefined ? null : String(v)
|
||||
}
|
||||
|
||||
zonesForField(zone: OcrTemplateZone): OcrTemplateZone[] {
|
||||
const key = this.fieldKeyFor(zone)
|
||||
if (!key) return []
|
||||
return this.template.zones.filter((z) => this.fieldKeyFor(z) === key)
|
||||
}
|
||||
|
||||
isFieldShared(zone: OcrTemplateZone): boolean {
|
||||
return this.zonesForField(zone).length > 1
|
||||
}
|
||||
|
||||
getCombineFormat(zone: OcrTemplateZone): string {
|
||||
const key = this.fieldKeyFor(zone)
|
||||
return (key && this.template.combine_formats?.[key]) || ''
|
||||
}
|
||||
|
||||
setCombineFormat(zone: OcrTemplateZone, value: string) {
|
||||
const key = this.fieldKeyFor(zone)
|
||||
if (!key) return
|
||||
this.template.combine_formats ??= {}
|
||||
this.template.combine_formats[key] = value
|
||||
}
|
||||
|
||||
insertCombineToken(zone: OcrTemplateZone, tokenZone: OcrTemplateZone) {
|
||||
const token = `{${tokenZone.name}}`
|
||||
const current = this.getCombineFormat(zone)
|
||||
const sep = current && !current.endsWith(' ') ? ' ' : ''
|
||||
this.setCombineFormat(zone, `${current}${sep}${token}`)
|
||||
}
|
||||
|
||||
private seedCombineDefault(zone: OcrTemplateZone) {
|
||||
const key = this.fieldKeyFor(zone)
|
||||
if (!key) return
|
||||
const shared = this.zonesForField(zone)
|
||||
if (shared.length <= 1) return
|
||||
this.template.combine_formats ??= {}
|
||||
if (!this.template.combine_formats[key]) {
|
||||
this.template.combine_formats[key] = shared
|
||||
.map((z) => `{${z.name}}`)
|
||||
.join(' ')
|
||||
}
|
||||
}
|
||||
|
||||
private pruneCombineFormats() {
|
||||
const formats = this.template.combine_formats
|
||||
if (!formats) return
|
||||
const counts = new Map<string, number>()
|
||||
for (const z of this.template.zones) {
|
||||
const key = this.fieldKeyFor(z)
|
||||
if (key) counts.set(key, (counts.get(key) ?? 0) + 1)
|
||||
}
|
||||
for (const key of Object.keys(formats)) {
|
||||
if ((counts.get(key) ?? 0) <= 1) delete formats[key]
|
||||
}
|
||||
}
|
||||
|
||||
/** Value bound to the date-format select: a preset, '' (auto), or 'custom'. */
|
||||
dateFormatChoice(zone: OcrTemplateZone): string {
|
||||
return this.usesCustomDateFormat(zone)
|
||||
? CUSTOM_DATE_FORMAT_CHOICE
|
||||
: zone.date_format || ''
|
||||
}
|
||||
|
||||
setDateFormatChoice(zone: OcrTemplateZone, value: string) {
|
||||
if (value === CUSTOM_DATE_FORMAT_CHOICE) {
|
||||
this.customDateFormatZones.add(zone)
|
||||
zone.date_format ||= ''
|
||||
} else {
|
||||
this.customDateFormatZones.delete(zone)
|
||||
zone.date_format = value
|
||||
}
|
||||
}
|
||||
|
||||
usesCustomDateFormat(zone: OcrTemplateZone): boolean {
|
||||
return (
|
||||
this.customDateFormatZones.has(zone) ||
|
||||
(!!zone.date_format &&
|
||||
!this.dateFormatOptions.some(
|
||||
(option) => option.id === zone.date_format
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
getZoneTargetName(zone: OcrTemplateZone): string {
|
||||
const target = zone.target || DEFAULT_OCR_ZONE_TARGET
|
||||
if (target === OCR_ZONE_TARGET.CustomField) {
|
||||
return zone.custom_field
|
||||
? this.getCustomFieldName(zone.custom_field)
|
||||
: $localize`(no field)`
|
||||
}
|
||||
return this.builtinTargets.find((t) => t.id === target)?.name ?? target
|
||||
}
|
||||
|
||||
getDocumentTypeName(id: number): string {
|
||||
const dt = this.documentTypes.find((d) => d.id === id)
|
||||
return dt ? dt.name : `Type #${id}`
|
||||
}
|
||||
|
||||
openQuickCreate(zoneIndex: number | null) {
|
||||
if (zoneIndex === null) return
|
||||
this.quickCreateForZoneIndex = zoneIndex
|
||||
this.quickCreateName = this.template.zones[zoneIndex]?.name || ''
|
||||
this.quickCreateType = CustomFieldDataType.String
|
||||
this.showQuickCreate = true
|
||||
}
|
||||
|
||||
cancelQuickCreate() {
|
||||
this.showQuickCreate = false
|
||||
this.quickCreateForZoneIndex = null
|
||||
}
|
||||
|
||||
submitQuickCreate() {
|
||||
if (!this.quickCreateName.trim()) return
|
||||
|
||||
this.templateService
|
||||
.quickCreateField(this.quickCreateName.trim(), this.quickCreateType)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
this.customFieldsService.clearCache()
|
||||
this.customFieldsService
|
||||
.listAll()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((r) => {
|
||||
this.customFields = r.results
|
||||
if (this.quickCreateForZoneIndex !== null) {
|
||||
this.template.zones[this.quickCreateForZoneIndex].custom_field =
|
||||
result.id
|
||||
this.template.zones[this.quickCreateForZoneIndex].target =
|
||||
OCR_ZONE_TARGET.CustomField
|
||||
}
|
||||
this.showQuickCreate = false
|
||||
this.quickCreateForZoneIndex = null
|
||||
})
|
||||
},
|
||||
error: (err) => {
|
||||
this.toastService.showError(
|
||||
$localize`Failed to create custom field.`,
|
||||
err
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next()
|
||||
this.destroy$.complete()
|
||||
}
|
||||
}
|
||||
-140
@@ -1,140 +0,0 @@
|
||||
import { OcrTemplateZone } from 'src/app/data/ocr-template'
|
||||
import {
|
||||
findHandleAt,
|
||||
findZoneAt,
|
||||
getZoneDisplayRect,
|
||||
getZonePage,
|
||||
isZoneOnPage,
|
||||
moveZone,
|
||||
resizeZone,
|
||||
sourceRectFromDrawing,
|
||||
} from './zone-geometry'
|
||||
|
||||
function zone(overrides: Partial<OcrTemplateZone> = {}): OcrTemplateZone {
|
||||
return {
|
||||
name: 'Zone',
|
||||
target: 'custom_field',
|
||||
custom_field: 1,
|
||||
x: 100,
|
||||
y: 200,
|
||||
width: 300,
|
||||
height: 400,
|
||||
page: 1,
|
||||
ocr_language: 'eng',
|
||||
transform: 'strip',
|
||||
validation_regex: '',
|
||||
order: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('OCR template editor geometry', () => {
|
||||
it('normalizes zone pages', () => {
|
||||
expect(getZonePage(zone({ page: 2 }), 0, 5)).toBe(2)
|
||||
expect(getZonePage(zone({ page: -1 }), 0, 5)).toBe(5)
|
||||
expect(getZonePage(zone({ page: -1 }), 2, null)).toBe(3)
|
||||
expect(getZonePage(zone({ page: 0 }), 0, 5)).toBe(1)
|
||||
expect(getZonePage(zone({ page: undefined }), 0, 5)).toBe(1)
|
||||
})
|
||||
|
||||
it('checks whether a zone is on the current preview page', () => {
|
||||
expect(isZoneOnPage(zone({ page: 2 }), 1, 5)).toBe(true)
|
||||
expect(isZoneOnPage(zone({ page: 2 }), 0, 5)).toBe(false)
|
||||
expect(isZoneOnPage(zone({ page: -1 }), 4, 5)).toBe(true)
|
||||
})
|
||||
|
||||
it('scales source coordinates to canvas display coordinates', () => {
|
||||
expect(
|
||||
getZoneDisplayRect(
|
||||
zone({ x: 100, y: 200, width: 300, height: 400 }),
|
||||
{ width: 500, height: 1000 },
|
||||
{ width: 1000, height: 2000 }
|
||||
)
|
||||
).toEqual({ x: 50, y: 100, w: 150, h: 200 })
|
||||
})
|
||||
|
||||
it('uses per-zone source dimensions when present', () => {
|
||||
expect(
|
||||
getZoneDisplayRect(
|
||||
zone({
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
zone_source_width: 1000,
|
||||
zone_source_height: 1000,
|
||||
}),
|
||||
{ width: 500, height: 500 },
|
||||
{ width: 2000, height: 2000 }
|
||||
)
|
||||
).toEqual({ x: 50, y: 50, w: 50, h: 50 })
|
||||
})
|
||||
|
||||
it('finds zones from topmost to bottommost on the current page', () => {
|
||||
const zones = [
|
||||
zone({ name: 'first', x: 0, y: 0, width: 100, height: 100, page: 1 }),
|
||||
zone({ name: 'second', x: 0, y: 0, width: 50, height: 50, page: 1 }),
|
||||
zone({ name: 'third', x: 0, y: 0, width: 50, height: 50, page: 2 }),
|
||||
]
|
||||
|
||||
expect(
|
||||
findZoneAt(
|
||||
{ x: 25, y: 25 },
|
||||
zones,
|
||||
0,
|
||||
2,
|
||||
{ width: 100, height: 100 },
|
||||
{ width: 100, height: 100 }
|
||||
)
|
||||
).toBe(1)
|
||||
})
|
||||
|
||||
it('finds resize handles around a display rect', () => {
|
||||
const rect = { x: 10, y: 20, w: 100, h: 200 }
|
||||
|
||||
expect(findHandleAt({ x: 10, y: 20 }, rect)).toBe('nw')
|
||||
expect(findHandleAt({ x: 110, y: 220 }, rect)).toBe('se')
|
||||
expect(findHandleAt({ x: 60, y: 20 }, rect)).toBe('n')
|
||||
expect(findHandleAt({ x: 90, y: 160 }, rect)).toBeNull()
|
||||
})
|
||||
|
||||
it('moves zones without leaving source image bounds', () => {
|
||||
const z = zone({ x: 50, y: 50, width: 100, height: 100 })
|
||||
|
||||
moveZone(
|
||||
z,
|
||||
{ x: 500, y: 500 },
|
||||
{ mouseX: 50, mouseY: 50, zoneX: 50, zoneY: 50 },
|
||||
{ width: 500, height: 500 },
|
||||
{ width: 500, height: 500 }
|
||||
)
|
||||
|
||||
expect(z.x).toBe(400)
|
||||
expect(z.y).toBe(400)
|
||||
})
|
||||
|
||||
it('resizes zones without leaving source image bounds', () => {
|
||||
const z = zone({ x: 50, y: 50, width: 100, height: 100 })
|
||||
|
||||
resizeZone(
|
||||
z,
|
||||
'se',
|
||||
{ x: 500, y: 500 },
|
||||
{ width: 500, height: 500 },
|
||||
{ width: 200, height: 200 }
|
||||
)
|
||||
|
||||
expect(z.width).toBe(150)
|
||||
expect(z.height).toBe(150)
|
||||
})
|
||||
|
||||
it('converts drawn canvas rectangles to source rectangles', () => {
|
||||
expect(
|
||||
sourceRectFromDrawing(
|
||||
{ startX: 100, startY: 200, endX: 50, endY: 100 },
|
||||
{ width: 500, height: 1000 },
|
||||
{ width: 1000, height: 2000 }
|
||||
)
|
||||
).toEqual({ x: 100, y: 200, w: 100, h: 200 })
|
||||
})
|
||||
})
|
||||
@@ -1,201 +0,0 @@
|
||||
import { OcrTemplateZone } from 'src/app/data/ocr-template'
|
||||
|
||||
export interface DrawingRect {
|
||||
startX: number
|
||||
startY: number
|
||||
endX: number
|
||||
endY: number
|
||||
}
|
||||
|
||||
export interface Dimensions {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface Point {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface DisplayRect {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
export interface MoveStart {
|
||||
mouseX: number
|
||||
mouseY: number
|
||||
zoneX: number
|
||||
zoneY: number
|
||||
}
|
||||
|
||||
export type ResizeHandle = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
|
||||
|
||||
export const HANDLE_SIZE = 8
|
||||
export const MIN_ZONE_SIZE = 10
|
||||
|
||||
export function getZonePage(
|
||||
zone: OcrTemplateZone,
|
||||
previewPage: number,
|
||||
previewPageCount: number | null
|
||||
): number {
|
||||
const page = zone.page ?? 1
|
||||
if (page === -1) return previewPageCount ?? previewPage + 1
|
||||
return page >= 1 ? page : 1
|
||||
}
|
||||
|
||||
export function isZoneOnPage(
|
||||
zone: OcrTemplateZone,
|
||||
previewPage: number,
|
||||
previewPageCount: number | null
|
||||
): boolean {
|
||||
return getZonePage(zone, previewPage, previewPageCount) === previewPage + 1
|
||||
}
|
||||
|
||||
export function getZoneSourceSize(
|
||||
zone: OcrTemplateZone,
|
||||
imageSize: Dimensions
|
||||
): Dimensions {
|
||||
return {
|
||||
width: zone.zone_source_width || imageSize.width,
|
||||
height: zone.zone_source_height || imageSize.height,
|
||||
}
|
||||
}
|
||||
|
||||
export function getZoneDisplayRect(
|
||||
zone: OcrTemplateZone,
|
||||
canvasSize: Dimensions,
|
||||
imageSize: Dimensions
|
||||
): DisplayRect {
|
||||
const sourceSize = getZoneSourceSize(zone, imageSize)
|
||||
const scaleX = canvasSize.width / sourceSize.width
|
||||
const scaleY = canvasSize.height / sourceSize.height
|
||||
|
||||
return {
|
||||
x: zone.x * scaleX,
|
||||
y: zone.y * scaleY,
|
||||
w: zone.width * scaleX,
|
||||
h: zone.height * scaleY,
|
||||
}
|
||||
}
|
||||
|
||||
export function findHandleAt(
|
||||
point: Point,
|
||||
rect: DisplayRect,
|
||||
handleSize = HANDLE_SIZE
|
||||
): ResizeHandle | null {
|
||||
const handles: [ResizeHandle, number, number][] = [
|
||||
['nw', rect.x, rect.y],
|
||||
['n', rect.x + rect.w / 2, rect.y],
|
||||
['ne', rect.x + rect.w, rect.y],
|
||||
['w', rect.x, rect.y + rect.h / 2],
|
||||
['e', rect.x + rect.w, rect.y + rect.h / 2],
|
||||
['sw', rect.x, rect.y + rect.h],
|
||||
['s', rect.x + rect.w / 2, rect.y + rect.h],
|
||||
['se', rect.x + rect.w, rect.y + rect.h],
|
||||
]
|
||||
|
||||
return (
|
||||
handles.find(
|
||||
([, x, y]) =>
|
||||
Math.abs(point.x - x) <= handleSize &&
|
||||
Math.abs(point.y - y) <= handleSize
|
||||
)?.[0] ?? null
|
||||
)
|
||||
}
|
||||
|
||||
export function findZoneAt(
|
||||
point: Point,
|
||||
zones: OcrTemplateZone[],
|
||||
previewPage: number,
|
||||
previewPageCount: number | null,
|
||||
canvasSize: Dimensions,
|
||||
imageSize: Dimensions
|
||||
): number | null {
|
||||
for (let i = zones.length - 1; i >= 0; i--) {
|
||||
const zone = zones[i]
|
||||
if (!isZoneOnPage(zone, previewPage, previewPageCount)) continue
|
||||
const rect = getZoneDisplayRect(zone, canvasSize, imageSize)
|
||||
|
||||
if (
|
||||
point.x >= rect.x &&
|
||||
point.x <= rect.x + rect.w &&
|
||||
point.y >= rect.y &&
|
||||
point.y <= rect.y + rect.h
|
||||
) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function moveZone(
|
||||
zone: OcrTemplateZone,
|
||||
point: Point,
|
||||
moveStart: MoveStart,
|
||||
canvasSize: Dimensions,
|
||||
imageSize: Dimensions
|
||||
) {
|
||||
const sourceSize = getZoneSourceSize(zone, imageSize)
|
||||
const scaleX = sourceSize.width / canvasSize.width
|
||||
const scaleY = sourceSize.height / canvasSize.height
|
||||
const dx = Math.round((point.x - moveStart.mouseX) * scaleX)
|
||||
const dy = Math.round((point.y - moveStart.mouseY) * scaleY)
|
||||
|
||||
zone.x = clamp(moveStart.zoneX + dx, 0, sourceSize.width - zone.width)
|
||||
zone.y = clamp(moveStart.zoneY + dy, 0, sourceSize.height - zone.height)
|
||||
}
|
||||
|
||||
export function resizeZone(
|
||||
zone: OcrTemplateZone,
|
||||
handle: ResizeHandle,
|
||||
point: Point,
|
||||
canvasSize: Dimensions,
|
||||
imageSize: Dimensions
|
||||
) {
|
||||
const sourceSize = getZoneSourceSize(zone, imageSize)
|
||||
const scaleX = sourceSize.width / canvasSize.width
|
||||
const scaleY = sourceSize.height / canvasSize.height
|
||||
const imageX = clamp(Math.round(point.x * scaleX), 0, sourceSize.width)
|
||||
const imageY = clamp(Math.round(point.y * scaleY), 0, sourceSize.height)
|
||||
|
||||
if (handle.includes('w')) {
|
||||
const right = Math.min(zone.x + zone.width, sourceSize.width)
|
||||
zone.x = clamp(imageX, 0, right - MIN_ZONE_SIZE)
|
||||
zone.width = right - zone.x
|
||||
}
|
||||
if (handle.includes('e')) {
|
||||
zone.width = Math.max(MIN_ZONE_SIZE, imageX - zone.x)
|
||||
}
|
||||
if (handle.includes('n')) {
|
||||
const bottom = Math.min(zone.y + zone.height, sourceSize.height)
|
||||
zone.y = clamp(imageY, 0, bottom - MIN_ZONE_SIZE)
|
||||
zone.height = bottom - zone.y
|
||||
}
|
||||
if (handle.includes('s')) {
|
||||
zone.height = Math.max(MIN_ZONE_SIZE, imageY - zone.y)
|
||||
}
|
||||
}
|
||||
|
||||
export function sourceRectFromDrawing(
|
||||
rect: DrawingRect,
|
||||
canvasSize: Dimensions,
|
||||
imageSize: Dimensions
|
||||
): DisplayRect {
|
||||
const scaleX = imageSize.width / canvasSize.width
|
||||
const scaleY = imageSize.height / canvasSize.height
|
||||
|
||||
return {
|
||||
x: Math.round(Math.min(rect.startX, rect.endX) * scaleX),
|
||||
y: Math.round(Math.min(rect.startY, rect.endY) * scaleY),
|
||||
w: Math.round(Math.abs(rect.endX - rect.startX) * scaleX),
|
||||
h: Math.round(Math.abs(rect.endY - rect.startY) * scaleY),
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(value, max))
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
<pngx-page-header
|
||||
title="OCR Templates"
|
||||
i18n-title
|
||||
info="Define extraction zones on document types to automatically populate custom fields via OCR."
|
||||
i18n-info
|
||||
>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="createTemplate()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.OcrTemplate }">
|
||||
<i-bs name="plus-circle" class="me-1"></i-bs><ng-container i18n>Create Template</ng-container>
|
||||
</button>
|
||||
</pngx-page-header>
|
||||
|
||||
<ul class="list-group">
|
||||
|
||||
<li class="list-group-item">
|
||||
<div class="row">
|
||||
<div class="col" i18n>Name</div>
|
||||
<div class="col d-none d-sm-flex" i18n>Document Type</div>
|
||||
<div class="col d-none d-sm-flex" i18n>Zones</div>
|
||||
<div class="col" i18n>Status</div>
|
||||
<div class="col" i18n>Actions</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@if (loading && templates.length === 0) {
|
||||
<li class="list-group-item">
|
||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
<ng-container i18n>Loading...</ng-container>
|
||||
</li>
|
||||
}
|
||||
|
||||
@for (t of templates; track t.id) {
|
||||
<li class="list-group-item">
|
||||
<div class="row fade" [class.show]="show">
|
||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editTemplate(t)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.OcrTemplate)">{{t.name}}</button></div>
|
||||
<div class="col d-flex align-items-center d-none d-sm-flex">{{getDocumentTypeName(t)}}</div>
|
||||
<div class="col d-flex align-items-center d-none d-sm-flex"><code>{{t.zones?.length || 0}}</code></div>
|
||||
<div class="col d-flex align-items-center">
|
||||
<div class="form-check form-switch mb-0">
|
||||
<input type="checkbox" class="form-check-input cursor-pointer" [id]="t.id+'_enable'" [(ngModel)]="t.enabled" (change)="toggleTemplate(t)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.OcrTemplate }">
|
||||
<label class="form-check-label cursor-pointer" [for]="t.id+'_enable'">
|
||||
<code> @if(t.enabled) { <ng-container i18n>Enabled</ng-container> } @else { <span i18n class="text-muted">Disabled</span> }</code>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
|
||||
<div class="btn-group d-block d-sm-none">
|
||||
<div ngbDropdown container="body" class="d-inline-block">
|
||||
<button type="button" class="btn btn-link" id="actionsMenuMobile{{t.id}}" (click)="$event.stopPropagation()" ngbDropdownToggle>
|
||||
<i-bs name="three-dots-vertical"></i-bs>
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile{{t.id}}">
|
||||
<button (click)="editTemplate(t)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.OcrTemplate }" ngbDropdownItem i18n>Edit</button>
|
||||
<button (click)="deleteTemplate(t)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.OcrTemplate }" ngbDropdownItem i18n>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-toolbar d-none d-sm-flex gap-2" role="toolbar">
|
||||
<div class="btn-group">
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.OcrTemplate }" class="btn btn-sm btn-outline-secondary" type="button" (click)="editTemplate(t)">
|
||||
<i-bs width="1em" height="1em" name="pencil" class="me-1"></i-bs><ng-container i18n>Edit</ng-container>
|
||||
</button>
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.OcrTemplate }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteTemplate(t)">
|
||||
<i-bs width="1em" height="1em" name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
@if (!loading && templates.length === 0) {
|
||||
<li class="list-group-item" [class.show]="show" i18n>No OCR templates defined.</li>
|
||||
}
|
||||
</ul>
|
||||
@@ -1,109 +0,0 @@
|
||||
import { Component, OnInit, inject } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { Router } from '@angular/router'
|
||||
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { delay, takeUntil, tap } from 'rxjs'
|
||||
import { OcrTemplate } from 'src/app/data/ocr-template'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-ocr-templates',
|
||||
templateUrl: './ocr-templates.component.html',
|
||||
imports: [
|
||||
PageHeaderComponent,
|
||||
IfPermissionsDirective,
|
||||
FormsModule,
|
||||
NgbDropdownModule,
|
||||
NgxBootstrapIconsModule,
|
||||
],
|
||||
})
|
||||
export class OcrTemplatesComponent
|
||||
extends LoadingComponentWithPermissions
|
||||
implements OnInit
|
||||
{
|
||||
private readonly service = inject(OcrTemplateService)
|
||||
private readonly documentTypeService = inject(DocumentTypeService)
|
||||
private readonly router = inject(Router)
|
||||
private readonly modalService = inject(NgbModal)
|
||||
private readonly toastService = inject(ToastService)
|
||||
permissionsService = inject(PermissionsService)
|
||||
|
||||
public templates: OcrTemplate[] = []
|
||||
private documentTypeNames: Map<number, string> = new Map()
|
||||
|
||||
ngOnInit() {
|
||||
this.documentTypeService
|
||||
.listAll()
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((r) => {
|
||||
this.documentTypeNames = new Map(
|
||||
r.results.map((dt) => [dt.id, dt.name])
|
||||
)
|
||||
})
|
||||
this.reload()
|
||||
}
|
||||
|
||||
reload() {
|
||||
this.loading = true
|
||||
this.service
|
||||
.listAll()
|
||||
.pipe(
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
tap((r) => (this.templates = r.results)),
|
||||
delay(100)
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.show = true
|
||||
this.loading = false
|
||||
})
|
||||
}
|
||||
|
||||
getDocumentTypeName(t: OcrTemplate): string {
|
||||
return (
|
||||
this.documentTypeNames.get(t.document_type) ?? `${t.document_type ?? ''}`
|
||||
)
|
||||
}
|
||||
|
||||
createTemplate() {
|
||||
this.router.navigate(['/ocr-templates', 'new'])
|
||||
}
|
||||
|
||||
editTemplate(t: OcrTemplate) {
|
||||
this.router.navigate(['/ocr-templates', t.id])
|
||||
}
|
||||
|
||||
toggleTemplate(t: OcrTemplate) {
|
||||
// ngModel has already flipped t.enabled; restore it if persistence fails.
|
||||
const enabled = t.enabled
|
||||
this.service.patch(t).subscribe({
|
||||
error: (error) => {
|
||||
t.enabled = !enabled
|
||||
this.toastService.showError(
|
||||
$localize`Error updating OCR template.`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
deleteTemplate(t: OcrTemplate) {
|
||||
const modal = this.modalService.open(ConfirmDialogComponent)
|
||||
modal.componentInstance.title = $localize`Delete OCR Template`
|
||||
modal.componentInstance.messageBoldPart = t.name
|
||||
modal.componentInstance.message = $localize`Do you really want to delete this OCR template?`
|
||||
modal.componentInstance.btnClass = 'btn-danger'
|
||||
modal.componentInstance.btnCaption = $localize`Delete`
|
||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||
modal.close()
|
||||
this.service.delete(t).subscribe(() => this.reload())
|
||||
})
|
||||
}
|
||||
}
|
||||
+73
-57
@@ -1,38 +1,22 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">{{ title }}</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
@if (loading()) {
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
<span i18n>Loading share link bundles…</span>
|
||||
</div>
|
||||
}
|
||||
<div class="border border-top-0 rounded-bottom p-3">
|
||||
@if (!loading() && error()) {
|
||||
<div class="alert alert-danger mb-0" role="alert">
|
||||
{{ error() }}
|
||||
</div>
|
||||
}
|
||||
@if (!loading() && !error()) {
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<p class="mb-0 text-muted small">
|
||||
<ng-container i18n>Status updates every few seconds while bundles are being prepared.</ng-container>
|
||||
</p>
|
||||
</div>
|
||||
@if (bundles().length === 0) {
|
||||
<p class="mb-0 text-muted fst-italic" i18n>No share link bundles currently exist.</p>
|
||||
}
|
||||
@if (bundles().length > 0) {
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<table class="table table-sm align-middle mb-0 bg-body">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" i18n>Created</th>
|
||||
<th scope="col" i18n>Status</th>
|
||||
<th scope="col" class="fw-normal" pngxSortable="created" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Created</th>
|
||||
<th scope="col" class="fw-normal" pngxSortable="status" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Status</th>
|
||||
<th scope="col" i18n>Size</th>
|
||||
<th scope="col" i18n>Expires</th>
|
||||
<th scope="col" class="fw-normal" pngxSortable="expiration" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Expires</th>
|
||||
<th scope="col" i18n>Documents</th>
|
||||
<th scope="col" i18n>File version</th>
|
||||
<th scope="col" class="text-end" i18n>Actions</th>
|
||||
@@ -96,6 +80,9 @@
|
||||
<td>
|
||||
@if (bundle.expiration) {
|
||||
{{ bundle.expiration | date: 'short' }}
|
||||
@if (isExpired(bundle.expiration)) {
|
||||
<span class="badge text-bg-danger ms-2" i18n>Expired</span>
|
||||
}
|
||||
}
|
||||
@if (!bundle.expiration) {
|
||||
<span i18n>Never</span>
|
||||
@@ -104,42 +91,49 @@
|
||||
<td>{{ bundle.document_count }}</td>
|
||||
<td>{{ fileVersionLabel(bundle.file_version) }}</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-primary"
|
||||
[disabled]="bundle.status !== statuses.Ready"
|
||||
(click)="copy(bundle)"
|
||||
title="Copy share link"
|
||||
i18n-title
|
||||
>
|
||||
@if (copiedSlug() === bundle.slug) {
|
||||
<i-bs name="clipboard-check"></i-bs>
|
||||
}
|
||||
@if (copiedSlug() !== bundle.slug) {
|
||||
<i-bs name="clipboard"></i-bs>
|
||||
}
|
||||
<span class="visually-hidden" i18n>Copy share link</span>
|
||||
</button>
|
||||
@if (bundle.status === statuses.Failed) {
|
||||
<div class="d-inline-block position-relative">
|
||||
<span
|
||||
class="badge bg-primary small fade position-absolute top-50 end-100 translate-middle-y me-2 pe-none z-3 text-nowrap"
|
||||
[class.show]="copiedSlug() === bundle.slug"
|
||||
i18n
|
||||
>Copied!</span>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-warning"
|
||||
[disabled]="loading()"
|
||||
(click)="retry(bundle)"
|
||||
class="btn btn-outline-primary"
|
||||
[disabled]="bundle.status !== statuses.Ready"
|
||||
(click)="copy(bundle)"
|
||||
title="Copy share link"
|
||||
i18n-title
|
||||
>
|
||||
<i-bs name="arrow-clockwise"></i-bs>
|
||||
<span class="visually-hidden" i18n>Retry</span>
|
||||
@if (copiedSlug() === bundle.slug) {
|
||||
<i-bs name="clipboard-check"></i-bs>
|
||||
}
|
||||
@if (copiedSlug() !== bundle.slug) {
|
||||
<i-bs name="clipboard"></i-bs>
|
||||
}
|
||||
<span class="visually-hidden" i18n>Copy share link</span>
|
||||
</button>
|
||||
}
|
||||
<pngx-confirm-button
|
||||
buttonClasses="btn btn-sm btn-outline-danger"
|
||||
[disabled]="loading()"
|
||||
(confirm)="delete(bundle)"
|
||||
iconName="trash"
|
||||
>
|
||||
<span class="visually-hidden" i18n>Delete share link bundle</span>
|
||||
</pngx-confirm-button>
|
||||
@if (bundle.status === statuses.Failed) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-warning"
|
||||
[disabled]="loading()"
|
||||
(click)="retry(bundle)"
|
||||
>
|
||||
<i-bs name="arrow-clockwise"></i-bs>
|
||||
<span class="visually-hidden" i18n>Retry</span>
|
||||
</button>
|
||||
}
|
||||
<pngx-confirm-button
|
||||
buttonClasses="btn btn-sm btn-outline-danger"
|
||||
[disabled]="loading()"
|
||||
(confirm)="delete(bundle)"
|
||||
iconName="trash"
|
||||
>
|
||||
<span class="visually-hidden" i18n>Delete share link bundle</span>
|
||||
</pngx-confirm-button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -147,10 +141,32 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap justify-content-end align-items-center gap-3 mt-3 ms-auto">
|
||||
<div class="d-flex flex-wrap justify-content-end align-items-center gap-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<label class="small text-muted me-2" for="shareLinkBundlePageSize" i18n>Show:</label>
|
||||
<select id="shareLinkBundlePageSize" class="form-select form-select-sm w-auto" [(ngModel)]="pageSize">
|
||||
<option [ngValue]="25">25</option>
|
||||
<option [ngValue]="50">50</option>
|
||||
<option [ngValue]="100">100</option>
|
||||
</select>
|
||||
<span class="small text-muted ms-2 d-none d-md-inline" i18n>per page</span>
|
||||
</div>
|
||||
@if (total() > pageSize) {
|
||||
<ngb-pagination
|
||||
class="mb-0"
|
||||
[pageSize]="pageSize"
|
||||
[collectionSize]="total()"
|
||||
[page]="page()"
|
||||
[maxSize]="5"
|
||||
(pageChange)="setPage($event)"
|
||||
size="sm"
|
||||
aria-label="Share link bundles pagination"
|
||||
i18n-aria-label
|
||||
></ngb-pagination>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" (click)="close()" i18n>Close</button>
|
||||
</div>
|
||||
+87
-35
@@ -1,6 +1,5 @@
|
||||
import { Clipboard } from '@angular/cdk/clipboard'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { FileVersion } from 'src/app/data/share-link'
|
||||
@@ -8,13 +7,15 @@ import {
|
||||
ShareLinkBundleStatus,
|
||||
ShareLinkBundleSummary,
|
||||
} from 'src/app/data/share-link-bundle'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { ShareLinkBundleManageDialogComponent } from './share-link-bundle-manage-dialog.component'
|
||||
import { ShareLinkBundleListComponent } from './share-link-bundle-list.component'
|
||||
|
||||
class MockShareLinkBundleService {
|
||||
listAllBundles = jest.fn()
|
||||
list = jest.fn()
|
||||
delete = jest.fn()
|
||||
rebuildBundle = jest.fn()
|
||||
}
|
||||
@@ -24,13 +25,12 @@ class MockToastService {
|
||||
showError = jest.fn()
|
||||
}
|
||||
|
||||
describe('ShareLinkBundleManageDialogComponent', () => {
|
||||
let component: ShareLinkBundleManageDialogComponent
|
||||
let fixture: ComponentFixture<ShareLinkBundleManageDialogComponent>
|
||||
describe('ShareLinkBundleListComponent', () => {
|
||||
let component: ShareLinkBundleListComponent
|
||||
let fixture: ComponentFixture<ShareLinkBundleListComponent>
|
||||
let service: MockShareLinkBundleService
|
||||
let toastService: MockToastService
|
||||
let clipboard: Clipboard
|
||||
let activeModal: NgbActiveModal
|
||||
let originalApiBaseUrl: string
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -38,26 +38,24 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
||||
toastService = new MockToastService()
|
||||
originalApiBaseUrl = environment.apiBaseUrl
|
||||
|
||||
service.listAllBundles.mockReturnValue(of([]))
|
||||
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||
service.delete.mockReturnValue(of(true))
|
||||
service.rebuildBundle.mockReturnValue(of(sampleBundle()))
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
ShareLinkBundleManageDialogComponent,
|
||||
ShareLinkBundleListComponent,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
],
|
||||
providers: [
|
||||
NgbActiveModal,
|
||||
{ provide: ShareLinkBundleService, useValue: service },
|
||||
{ provide: ToastService, useValue: toastService },
|
||||
],
|
||||
})
|
||||
|
||||
fixture = TestBed.createComponent(ShareLinkBundleManageDialogComponent)
|
||||
fixture = TestBed.createComponent(ShareLinkBundleListComponent)
|
||||
component = fixture.componentInstance
|
||||
clipboard = TestBed.inject(Clipboard)
|
||||
activeModal = TestBed.inject(NgbActiveModal)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -84,28 +82,28 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
||||
it('loads bundles on init and polls periodically', () => {
|
||||
jest.useFakeTimers()
|
||||
const bundles = [sampleBundle({ status: ShareLinkBundleStatus.Ready })]
|
||||
service.listAllBundles.mockReset()
|
||||
service.listAllBundles
|
||||
.mockReturnValueOnce(of(bundles))
|
||||
.mockReturnValue(of(bundles))
|
||||
service.list.mockReset()
|
||||
service.list
|
||||
.mockReturnValueOnce(of({ count: bundles.length, results: bundles }))
|
||||
.mockReturnValue(of({ count: bundles.length, results: bundles }))
|
||||
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(service.listAllBundles).toHaveBeenCalledTimes(1)
|
||||
expect(service.list).toHaveBeenCalledWith(1, 25, 'created', true)
|
||||
expect(component.bundles()).toEqual(bundles)
|
||||
expect(component.loading()).toBe(false)
|
||||
expect(component.error()).toBeNull()
|
||||
|
||||
jest.advanceTimersByTime(5000)
|
||||
expect(service.listAllBundles).toHaveBeenCalledTimes(2)
|
||||
expect(service.list).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('handles errors when loading bundles', () => {
|
||||
jest.useFakeTimers()
|
||||
service.listAllBundles.mockReset()
|
||||
service.listAllBundles
|
||||
service.list.mockReset()
|
||||
service.list
|
||||
.mockReturnValueOnce(throwError(() => new Error('load fail')))
|
||||
.mockReturnValue(of([]))
|
||||
.mockReturnValue(of({ count: 0, results: [] }))
|
||||
|
||||
fixture.detectChanges()
|
||||
|
||||
@@ -114,7 +112,57 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
||||
expect(component.loading()).toBe(false)
|
||||
|
||||
jest.advanceTimersByTime(5000)
|
||||
expect(service.listAllBundles).toHaveBeenCalledTimes(2)
|
||||
expect(service.list).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('loads another page', () => {
|
||||
fixture.detectChanges()
|
||||
|
||||
component.setPage(2)
|
||||
|
||||
expect(service.list).toHaveBeenLastCalledWith(2, 25, 'created', true)
|
||||
})
|
||||
|
||||
it('sorts bundles and returns to the first page', () => {
|
||||
fixture.detectChanges()
|
||||
component.page.set(2)
|
||||
|
||||
component.onSort({ column: 'status', reverse: false })
|
||||
|
||||
expect(component.page()).toBe(1)
|
||||
expect(service.list).toHaveBeenLastCalledWith(1, 25, 'status', false)
|
||||
})
|
||||
|
||||
it('marks expired share link bundles', () => {
|
||||
service.list.mockReturnValue(
|
||||
of({
|
||||
count: 1,
|
||||
results: [sampleBundle({ expiration: '2000-01-01T00:00:00.000Z' })],
|
||||
})
|
||||
)
|
||||
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Expired')
|
||||
})
|
||||
|
||||
it('stores a changed page size and reloads from the first page', () => {
|
||||
fixture.detectChanges()
|
||||
const settingsService = TestBed.inject(SettingsService)
|
||||
jest
|
||||
.spyOn(settingsService, 'get')
|
||||
.mockReturnValueOnce({ share_link_bundles: 25 })
|
||||
const setSpy = jest.spyOn(settingsService, 'set')
|
||||
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of({}))
|
||||
component.page.set(2)
|
||||
|
||||
component.pageSize = 100
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.OBJECT_LIST_SIZES, {
|
||||
share_link_bundles: 100,
|
||||
})
|
||||
expect(component.page()).toBe(1)
|
||||
expect(service.list).toHaveBeenLastCalledWith(1, 100, 'created', true)
|
||||
})
|
||||
|
||||
it('copies bundle links when ready', () => {
|
||||
@@ -126,16 +174,24 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
||||
slug: 'ready-slug',
|
||||
status: ShareLinkBundleStatus.Ready,
|
||||
})
|
||||
component.bundles.set([readyBundle])
|
||||
fixture.detectChanges()
|
||||
component.copy(readyBundle)
|
||||
|
||||
expect(clipboard.copy).toHaveBeenCalledWith(
|
||||
component.getShareUrl(readyBundle)
|
||||
)
|
||||
expect(component.copiedSlug()).toBe('ready-slug')
|
||||
expect(toastService.showInfo).toHaveBeenCalled()
|
||||
expect(toastService.showInfo).not.toHaveBeenCalled()
|
||||
fixture.detectChanges()
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.badge.show').textContent
|
||||
).toContain('Copied!')
|
||||
|
||||
jest.advanceTimersByTime(3000)
|
||||
expect(component.copiedSlug()).toBeNull()
|
||||
fixture.detectChanges()
|
||||
expect(fixture.nativeElement.querySelector('.badge.show')).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores copy requests for non-ready bundles', () => {
|
||||
@@ -146,7 +202,7 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
||||
})
|
||||
|
||||
it('deletes bundles and refreshes list', () => {
|
||||
service.listAllBundles.mockReturnValue(of([]))
|
||||
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||
service.delete.mockReturnValue(of(true))
|
||||
|
||||
fixture.detectChanges()
|
||||
@@ -157,12 +213,12 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
||||
expect(toastService.showInfo).toHaveBeenCalledWith(
|
||||
expect.stringContaining('deleted.')
|
||||
)
|
||||
expect(service.listAllBundles).toHaveBeenCalledTimes(2)
|
||||
expect(service.list).toHaveBeenCalledTimes(2)
|
||||
expect(component.loading()).toBe(false)
|
||||
})
|
||||
|
||||
it('handles delete errors gracefully', () => {
|
||||
service.listAllBundles.mockReturnValue(of([]))
|
||||
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||
service.delete.mockReturnValue(throwError(() => new Error('delete fail')))
|
||||
|
||||
fixture.detectChanges()
|
||||
@@ -174,7 +230,7 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
||||
})
|
||||
|
||||
it('retries bundle build and replaces existing entry', () => {
|
||||
service.listAllBundles.mockReturnValue(of([]))
|
||||
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||
const updated = sampleBundle({ status: ShareLinkBundleStatus.Ready })
|
||||
service.rebuildBundle.mockReturnValue(of(updated))
|
||||
|
||||
@@ -189,7 +245,7 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
||||
})
|
||||
|
||||
it('adds new bundle when retry returns unknown entry', () => {
|
||||
service.listAllBundles.mockReturnValue(of([]))
|
||||
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||
service.rebuildBundle.mockReturnValue(
|
||||
of(sampleBundle({ id: 99, slug: 'new-slug' }))
|
||||
)
|
||||
@@ -203,7 +259,7 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
||||
})
|
||||
|
||||
it('handles retry errors', () => {
|
||||
service.listAllBundles.mockReturnValue(of([]))
|
||||
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||
service.rebuildBundle.mockReturnValue(throwError(() => new Error('fail')))
|
||||
|
||||
fixture.detectChanges()
|
||||
@@ -213,8 +269,8 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
||||
expect(toastService.showError).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps helpers and closes dialog', () => {
|
||||
service.listAllBundles.mockReturnValue(of([]))
|
||||
it('maps status and file version helpers', () => {
|
||||
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(component.statusLabel(ShareLinkBundleStatus.Processing)).toContain(
|
||||
@@ -227,9 +283,5 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
||||
environment.apiBaseUrl = 'https://example.com/api/'
|
||||
const url = component.getShareUrl(sampleBundle({ slug: 'sluggy' }))
|
||||
expect(url).toBe('https://example.com/share/sluggy')
|
||||
|
||||
const closeSpy = jest.spyOn(activeModal, 'close')
|
||||
component.close()
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
+87
-26
@@ -1,7 +1,11 @@
|
||||
import { Clipboard } from '@angular/cdk/clipboard'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core'
|
||||
import { NgbActiveModal, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import {
|
||||
NgbPaginationModule,
|
||||
NgbPopoverModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { Subject, catchError, of, switchMap, takeUntil, timer } from 'rxjs'
|
||||
import { FileVersion } from 'src/app/data/share-link'
|
||||
@@ -11,42 +15,77 @@ import {
|
||||
ShareLinkBundleStatus,
|
||||
ShareLinkBundleSummary,
|
||||
} from 'src/app/data/share-link-bundle'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import {
|
||||
SortEvent,
|
||||
SortableDirective,
|
||||
} from 'src/app/directives/sortable.directive'
|
||||
import { FileSizePipe } from 'src/app/pipes/file-size.pipe'
|
||||
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||
import { ConfirmButtonComponent } from '../confirm-button/confirm-button.component'
|
||||
import { ConfirmButtonComponent } from 'src/app/components/common/confirm-button/confirm-button.component'
|
||||
import { LoadingComponentWithPermissions } from 'src/app/components/loading-component/loading.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-share-link-bundle-manage-dialog',
|
||||
templateUrl: './share-link-bundle-manage-dialog.component.html',
|
||||
styleUrls: ['./share-link-bundle-manage-dialog.component.scss'],
|
||||
selector: 'pngx-share-link-bundle-list',
|
||||
templateUrl: './share-link-bundle-list.component.html',
|
||||
styleUrls: ['./share-link-bundle-list.component.scss'],
|
||||
imports: [
|
||||
ConfirmButtonComponent,
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgbPaginationModule,
|
||||
NgbPopoverModule,
|
||||
NgxBootstrapIconsModule,
|
||||
SortableDirective,
|
||||
FileSizePipe,
|
||||
],
|
||||
})
|
||||
export class ShareLinkBundleManageDialogComponent
|
||||
export class ShareLinkBundleListComponent
|
||||
extends LoadingComponentWithPermissions
|
||||
implements OnInit, OnDestroy
|
||||
{
|
||||
private readonly activeModal = inject(NgbActiveModal)
|
||||
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
|
||||
private readonly settingsService = inject(SettingsService)
|
||||
private readonly toastService = inject(ToastService)
|
||||
private readonly clipboard = inject(Clipboard)
|
||||
|
||||
title = $localize`Share link bundles`
|
||||
readonly bundles = signal<ShareLinkBundleSummary[]>([])
|
||||
readonly error = signal<string | null>(null)
|
||||
readonly copiedSlug = signal<string | null>(null)
|
||||
readonly total = signal(0)
|
||||
readonly page = signal(1)
|
||||
readonly sortField = signal('created')
|
||||
readonly sortReverse = signal(true)
|
||||
|
||||
readonly statuses = ShareLinkBundleStatus
|
||||
readonly fileVersions = FileVersion
|
||||
|
||||
get pageSize(): number {
|
||||
return (
|
||||
this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES)
|
||||
?.share_link_bundles || 25
|
||||
)
|
||||
}
|
||||
|
||||
set pageSize(pageSize: number) {
|
||||
this.settingsService.set(SETTINGS_KEYS.OBJECT_LIST_SIZES, {
|
||||
...this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES),
|
||||
share_link_bundles: pageSize,
|
||||
})
|
||||
this.settingsService.storeSettings().subscribe({
|
||||
next: () => {
|
||||
this.page.set(1)
|
||||
this.triggerRefresh(false)
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError($localize`Error saving settings`, error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private readonly refresh$ = new Subject<boolean>()
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -57,25 +96,33 @@ export class ShareLinkBundleManageDialogComponent
|
||||
this.loading.set(true)
|
||||
}
|
||||
this.error.set(null)
|
||||
return this.shareLinkBundleService.listAllBundles().pipe(
|
||||
catchError((error) => {
|
||||
if (!silent) {
|
||||
this.loading.set(false)
|
||||
}
|
||||
this.error.set($localize`Failed to load share link bundles.`)
|
||||
this.toastService.showError(
|
||||
$localize`Error retrieving share link bundles.`,
|
||||
error
|
||||
)
|
||||
return of(null)
|
||||
})
|
||||
)
|
||||
return this.shareLinkBundleService
|
||||
.list(
|
||||
this.page(),
|
||||
this.pageSize,
|
||||
this.sortField(),
|
||||
this.sortReverse()
|
||||
)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
if (!silent) {
|
||||
this.loading.set(false)
|
||||
}
|
||||
this.error.set($localize`Failed to load share link bundles.`)
|
||||
this.toastService.showError(
|
||||
$localize`Error retrieving share link bundles.`,
|
||||
error
|
||||
)
|
||||
return of(null)
|
||||
})
|
||||
)
|
||||
}),
|
||||
takeUntil(this.unsubscribeNotifier)
|
||||
)
|
||||
.subscribe((results) => {
|
||||
if (results) {
|
||||
this.bundles.set(results)
|
||||
this.bundles.set(results.results)
|
||||
this.total.set(results.count)
|
||||
this.copiedSlug.set(null)
|
||||
}
|
||||
this.loading.set(false)
|
||||
@@ -98,6 +145,18 @@ export class ShareLinkBundleManageDialogComponent
|
||||
}`
|
||||
}
|
||||
|
||||
setPage(page: number): void {
|
||||
this.page.set(page)
|
||||
this.triggerRefresh(false)
|
||||
}
|
||||
|
||||
onSort(event: SortEvent): void {
|
||||
this.sortField.set(event.column || 'created')
|
||||
this.sortReverse.set(event.column ? event.reverse : true)
|
||||
this.page.set(1)
|
||||
this.triggerRefresh(false)
|
||||
}
|
||||
|
||||
copy(bundle: ShareLinkBundleSummary): void {
|
||||
if (bundle.status !== ShareLinkBundleStatus.Ready) {
|
||||
return
|
||||
@@ -108,7 +167,6 @@ export class ShareLinkBundleManageDialogComponent
|
||||
setTimeout(() => {
|
||||
this.copiedSlug.set(null)
|
||||
}, 3000)
|
||||
this.toastService.showInfo($localize`Share link copied to clipboard.`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +175,9 @@ export class ShareLinkBundleManageDialogComponent
|
||||
this.loading.set(true)
|
||||
this.shareLinkBundleService.delete(bundle).subscribe({
|
||||
next: () => {
|
||||
if (this.bundles().length === 1 && this.page() > 1) {
|
||||
this.page.update((page) => page - 1)
|
||||
}
|
||||
this.toastService.showInfo($localize`Share link bundle deleted.`)
|
||||
this.triggerRefresh(false)
|
||||
},
|
||||
@@ -153,8 +214,8 @@ export class ShareLinkBundleManageDialogComponent
|
||||
return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.activeModal.close()
|
||||
isExpired(expiration?: string): boolean {
|
||||
return !!expiration && Date.parse(expiration) <= Date.now()
|
||||
}
|
||||
|
||||
private replaceBundle(updated: ShareLinkBundleSummary): void {
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
<div class="border border-top-0 rounded-bottom p-3">
|
||||
@if (!loading() && error()) {
|
||||
<div class="alert alert-danger mb-0" role="alert">{{ error() }}</div>
|
||||
}
|
||||
@if (!loading() && !error() && links().length === 0) {
|
||||
<p class="mb-0 text-muted fst-italic" i18n>
|
||||
No document share links currently exist.
|
||||
</p>
|
||||
}
|
||||
@if (!loading() && !error() && links().length > 0) {
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-0 bg-body">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="fw-normal" pngxSortable="document__title" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Document</th>
|
||||
<th scope="col" class="fw-normal" pngxSortable="created" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Created</th>
|
||||
<th scope="col" class="fw-normal" pngxSortable="expiration" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Expires</th>
|
||||
<th scope="col" i18n>File version</th>
|
||||
<th scope="col" class="text-end" i18n>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (link of links(); track link.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<a routerLink="/documents/{{ link.document }}">{{ link.document_title | documentTitle }}</a>
|
||||
<span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyDocumentID(link.document)">
|
||||
@if (copiedDocumentID() === link.document) {
|
||||
<i-bs width="1em" height="1em" name="clipboard-check" class="me-1"></i-bs><ng-container i18n>Copied!</ng-container>
|
||||
} @else {
|
||||
ID: {{link.document}}
|
||||
}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ link.created | date: 'short' }}</td>
|
||||
<td>
|
||||
@if (link.expiration) {
|
||||
{{ link.expiration | date: 'short' }}
|
||||
@if (isExpired(link.expiration)) {
|
||||
<span class="badge text-bg-danger ms-2" i18n>Expired</span>
|
||||
}
|
||||
} @else {
|
||||
<span i18n>Never</span>
|
||||
}
|
||||
</td>
|
||||
<td>{{ fileVersionLabel(link.file_version) }}</td>
|
||||
<td class="text-end">
|
||||
<div class="d-inline-block position-relative">
|
||||
<span
|
||||
class="badge bg-primary small fade position-absolute top-50 end-100 translate-middle-y me-2 pe-none z-3 text-nowrap"
|
||||
[class.show]="copiedID() === link.id"
|
||||
i18n
|
||||
>Copied!</span>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-primary"
|
||||
(click)="copy(link)"
|
||||
title="Copy share link"
|
||||
i18n-title
|
||||
>
|
||||
@if (copiedID() === link.id) {
|
||||
<i-bs name="clipboard-check"></i-bs>
|
||||
} @else {
|
||||
<i-bs name="clipboard"></i-bs>
|
||||
}
|
||||
<span class="visually-hidden" i18n>Copy share link</span>
|
||||
</button>
|
||||
<pngx-confirm-button
|
||||
*pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.ShareLink }"
|
||||
buttonClasses="btn btn-sm btn-outline-danger"
|
||||
(confirm)="delete(link)"
|
||||
iconName="trash"
|
||||
>
|
||||
<span class="visually-hidden" i18n>Delete share link</span>
|
||||
</pngx-confirm-button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap justify-content-end align-items-center gap-3 mt-3 ms-auto">
|
||||
<div class="d-flex align-items-center">
|
||||
<label class="small text-muted me-2" for="shareLinkPageSize" i18n>Show:</label>
|
||||
<select id="shareLinkPageSize" class="form-select form-select-sm w-auto" [(ngModel)]="pageSize">
|
||||
<option [ngValue]="25">25</option>
|
||||
<option [ngValue]="50">50</option>
|
||||
<option [ngValue]="100">100</option>
|
||||
</select>
|
||||
<span class="small text-muted ms-2 d-none d-md-inline" i18n>per page</span>
|
||||
</div>
|
||||
@if (total() > pageSize) {
|
||||
<ngb-pagination
|
||||
class="mb-0"
|
||||
[pageSize]="pageSize"
|
||||
[collectionSize]="total()"
|
||||
[page]="page()"
|
||||
[maxSize]="5"
|
||||
(pageChange)="setPage($event)"
|
||||
size="sm"
|
||||
aria-label="Share links pagination"
|
||||
i18n-aria-label
|
||||
></ngb-pagination>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
import { Clipboard } from '@angular/cdk/clipboard'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { FileVersion, ShareLink } from 'src/app/data/share-link'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { ShareLinkService } from 'src/app/services/rest/share-link.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ShareLinkListComponent } from './share-link-list.component'
|
||||
|
||||
describe('ShareLinkListComponent', () => {
|
||||
let component: ShareLinkListComponent
|
||||
let fixture: ComponentFixture<ShareLinkListComponent>
|
||||
let service: jest.Mocked<Pick<ShareLinkService, 'list' | 'delete'>>
|
||||
let clipboard: Clipboard
|
||||
let toastService: jest.Mocked<Pick<ToastService, 'showInfo' | 'showError'>>
|
||||
|
||||
const link = {
|
||||
id: 1,
|
||||
document: 42,
|
||||
document_title: 'Test document',
|
||||
slug: 'share-slug',
|
||||
created: new Date().toISOString(),
|
||||
expiration: null,
|
||||
file_version: FileVersion.Archive,
|
||||
} as ShareLink
|
||||
|
||||
beforeEach(() => {
|
||||
service = {
|
||||
list: jest.fn().mockReturnValue(of({ count: 1, results: [link] })),
|
||||
delete: jest.fn().mockReturnValue(of(true)),
|
||||
}
|
||||
toastService = {
|
||||
showInfo: jest.fn(),
|
||||
showError: jest.fn(),
|
||||
}
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
ShareLinkListComponent,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
RouterTestingModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: ShareLinkService, useValue: service },
|
||||
{ provide: ToastService, useValue: toastService },
|
||||
],
|
||||
})
|
||||
|
||||
fixture = TestBed.createComponent(ShareLinkListComponent)
|
||||
component = fixture.componentInstance
|
||||
clipboard = TestBed.inject(Clipboard)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllTimers()
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
it('loads and renders document share links', () => {
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(service.list).toHaveBeenCalledWith(1, 25, 'created', true)
|
||||
expect(component.links()).toEqual([link])
|
||||
expect(fixture.nativeElement.textContent).toContain('Test document')
|
||||
expect(fixture.nativeElement.textContent).toContain('ID: 42')
|
||||
})
|
||||
|
||||
it('loads another page', () => {
|
||||
fixture.detectChanges()
|
||||
component.setPage(2)
|
||||
|
||||
expect(service.list).toHaveBeenLastCalledWith(2, 25, 'created', true)
|
||||
})
|
||||
|
||||
it('sorts links and returns to the first page', () => {
|
||||
fixture.detectChanges()
|
||||
component.page.set(2)
|
||||
|
||||
component.onSort({ column: 'expiration', reverse: false })
|
||||
|
||||
expect(component.page()).toBe(1)
|
||||
expect(service.list).toHaveBeenLastCalledWith(1, 25, 'expiration', false)
|
||||
})
|
||||
|
||||
it('marks expired share links', () => {
|
||||
service.list.mockReturnValue(
|
||||
of({
|
||||
count: 1,
|
||||
results: [
|
||||
{
|
||||
...link,
|
||||
expiration: '2000-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Expired')
|
||||
})
|
||||
|
||||
it('stores a changed page size and reloads from the first page', () => {
|
||||
const settingsService = TestBed.inject(SettingsService)
|
||||
jest.spyOn(settingsService, 'get').mockReturnValueOnce({ share_links: 25 })
|
||||
const setSpy = jest.spyOn(settingsService, 'set')
|
||||
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of({}))
|
||||
const reloadSpy = jest.spyOn(component, 'reload')
|
||||
component.page.set(2)
|
||||
|
||||
component.pageSize = 50
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.OBJECT_LIST_SIZES, {
|
||||
share_links: 50,
|
||||
})
|
||||
expect(component.page()).toBe(1)
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows local copy feedback without a toast', () => {
|
||||
jest.useFakeTimers()
|
||||
jest.spyOn(clipboard, 'copy').mockReturnValue(true)
|
||||
fixture.detectChanges()
|
||||
|
||||
component.copy(link)
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(component.copiedID()).toBe(link.id)
|
||||
expect(fixture.nativeElement.querySelector('.badge.show')).not.toBeNull()
|
||||
expect(toastService.showInfo).not.toHaveBeenCalled()
|
||||
|
||||
jest.advanceTimersByTime(3000)
|
||||
expect(component.copiedID()).toBeNull()
|
||||
})
|
||||
|
||||
it('deletes a link and reloads the list', () => {
|
||||
fixture.detectChanges()
|
||||
component.delete(link)
|
||||
|
||||
expect(service.delete).toHaveBeenCalledWith(link)
|
||||
expect(service.list).toHaveBeenCalledTimes(2)
|
||||
expect(toastService.showInfo).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows an error when loading fails', () => {
|
||||
service.list.mockReturnValue(throwError(() => new Error('load failed')))
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(component.error()).toContain('Failed to load share links.')
|
||||
expect(toastService.showError).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
import { Clipboard } from '@angular/cdk/clipboard'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { Component, OnInit, inject, signal } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { takeUntil } from 'rxjs'
|
||||
import { ConfirmButtonComponent } from 'src/app/components/common/confirm-button/confirm-button.component'
|
||||
import { LoadingComponentWithPermissions } from 'src/app/components/loading-component/loading.component'
|
||||
import { FileVersion, ShareLink } from 'src/app/data/share-link'
|
||||
import { SHARE_LINK_BUNDLE_FILE_VERSION_LABELS } from 'src/app/data/share-link-bundle'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import {
|
||||
SortEvent,
|
||||
SortableDirective,
|
||||
} from 'src/app/directives/sortable.directive'
|
||||
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
||||
import {
|
||||
PermissionAction,
|
||||
PermissionType,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { ShareLinkService } from 'src/app/services/rest/share-link.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-share-link-list',
|
||||
templateUrl: './share-link-list.component.html',
|
||||
imports: [
|
||||
CommonModule,
|
||||
ConfirmButtonComponent,
|
||||
DocumentTitlePipe,
|
||||
FormsModule,
|
||||
IfPermissionsDirective,
|
||||
NgbPaginationModule,
|
||||
NgxBootstrapIconsModule,
|
||||
RouterModule,
|
||||
SortableDirective,
|
||||
],
|
||||
})
|
||||
export class ShareLinkListComponent
|
||||
extends LoadingComponentWithPermissions
|
||||
implements OnInit
|
||||
{
|
||||
private readonly clipboard = inject(Clipboard)
|
||||
private readonly shareLinkService = inject(ShareLinkService)
|
||||
private readonly settingsService = inject(SettingsService)
|
||||
private readonly toastService = inject(ToastService)
|
||||
|
||||
readonly links = signal<ShareLink[]>([])
|
||||
readonly total = signal(0)
|
||||
readonly page = signal(1)
|
||||
readonly sortField = signal('created')
|
||||
readonly sortReverse = signal(true)
|
||||
readonly copiedID = signal<number | null>(null)
|
||||
readonly copiedDocumentID = signal<number | null>(null)
|
||||
readonly error = signal<string | null>(null)
|
||||
readonly PermissionAction = PermissionAction
|
||||
readonly PermissionType = PermissionType
|
||||
|
||||
get pageSize(): number {
|
||||
return (
|
||||
this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES)?.share_links ||
|
||||
25
|
||||
)
|
||||
}
|
||||
|
||||
set pageSize(pageSize: number) {
|
||||
this.settingsService.set(SETTINGS_KEYS.OBJECT_LIST_SIZES, {
|
||||
...this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES),
|
||||
share_links: pageSize,
|
||||
})
|
||||
this.settingsService.storeSettings().subscribe({
|
||||
next: () => {
|
||||
this.page.set(1)
|
||||
this.reload()
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError($localize`Error saving settings`, error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.reload()
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
this.loading.set(true)
|
||||
this.error.set(null)
|
||||
this.shareLinkService
|
||||
.list(this.page(), this.pageSize, this.sortField(), this.sortReverse())
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: (results) => {
|
||||
this.links.set(results.results)
|
||||
this.total.set(results.count)
|
||||
this.loading.set(false)
|
||||
},
|
||||
error: (error) => {
|
||||
this.loading.set(false)
|
||||
this.error.set($localize`Failed to load share links.`)
|
||||
this.toastService.showError(
|
||||
$localize`Error retrieving share links.`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
setPage(page: number): void {
|
||||
this.page.set(page)
|
||||
this.reload()
|
||||
}
|
||||
|
||||
onSort(event: SortEvent): void {
|
||||
this.sortField.set(event.column || 'created')
|
||||
this.sortReverse.set(event.column ? event.reverse : true)
|
||||
this.page.set(1)
|
||||
this.reload()
|
||||
}
|
||||
|
||||
getShareUrl(link: ShareLink): string {
|
||||
const apiURL = new URL(environment.apiBaseUrl)
|
||||
return `${apiURL.origin}${apiURL.pathname.replace(/\/api\/$/, '/share/')}${
|
||||
link.slug
|
||||
}`
|
||||
}
|
||||
|
||||
fileVersionLabel(version: FileVersion): string {
|
||||
return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version
|
||||
}
|
||||
|
||||
isExpired(expiration?: string): boolean {
|
||||
return !!expiration && Date.parse(expiration) <= Date.now()
|
||||
}
|
||||
|
||||
copy(link: ShareLink): void {
|
||||
if (this.clipboard.copy(this.getShareUrl(link))) {
|
||||
this.copiedID.set(link.id)
|
||||
setTimeout(() => this.copiedID.set(null), 3000)
|
||||
}
|
||||
}
|
||||
|
||||
delete(link: ShareLink): void {
|
||||
this.shareLinkService.delete(link).subscribe({
|
||||
next: () => {
|
||||
if (this.links().length === 1 && this.page() > 1) {
|
||||
this.page.update((page) => page - 1)
|
||||
}
|
||||
this.toastService.showInfo($localize`Share link deleted.`)
|
||||
this.reload()
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError(
|
||||
$localize`Error deleting share link.`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
copyDocumentID(documentID: number): void {
|
||||
if (this.clipboard.copy(documentID.toString())) {
|
||||
this.copiedDocumentID.set(documentID)
|
||||
setTimeout(() => this.copiedDocumentID.set(null), 3000)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<pngx-page-header
|
||||
title="Share links"
|
||||
i18n-title
|
||||
info="Manage public links to individual documents and document bundles."
|
||||
i18n-info
|
||||
[loading]="loading()"
|
||||
></pngx-page-header>
|
||||
|
||||
<ul
|
||||
ngbNav
|
||||
#nav="ngbNav"
|
||||
class="nav-tabs"
|
||||
[activeId]="activeNavID()"
|
||||
(activeIdChange)="selectTab($event)"
|
||||
>
|
||||
@if (canViewDocumentLinks) {
|
||||
<li [ngbNavItem]="ShareLinksNavIDs.DocumentLinks">
|
||||
<button ngbNavLink i18n>Document links</button>
|
||||
<ng-template ngbNavContent>
|
||||
<pngx-share-link-list></pngx-share-link-list>
|
||||
</ng-template>
|
||||
</li>
|
||||
}
|
||||
@if (canViewBundles) {
|
||||
<li [ngbNavItem]="ShareLinksNavIDs.Bundles">
|
||||
<button ngbNavLink i18n>Bundles</button>
|
||||
<ng-template ngbNavContent>
|
||||
<pngx-share-link-bundle-list></pngx-share-link-bundle-list>
|
||||
</ng-template>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
<div class="bg-body" [ngbNavOutlet]="nav"></div>
|
||||
@@ -0,0 +1,102 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'
|
||||
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { of } from 'rxjs'
|
||||
import {
|
||||
PermissionAction,
|
||||
PermissionsService,
|
||||
PermissionType,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
||||
import { ShareLinkService } from 'src/app/services/rest/share-link.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { ShareLinksComponent, ShareLinksNavIDs } from './share-links.component'
|
||||
|
||||
describe('ShareLinksComponent', () => {
|
||||
let fixture: ComponentFixture<ShareLinksComponent>
|
||||
let permissionsService: PermissionsService
|
||||
let router: Router
|
||||
|
||||
const configure = async (type: string = null) => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
ShareLinksComponent,
|
||||
NgbNavModule,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
PageHeaderComponent,
|
||||
],
|
||||
providers: [
|
||||
PermissionsService,
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: { queryParamMap: convertToParamMap({ type }) },
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: Router,
|
||||
useValue: { navigate: jest.fn().mockResolvedValue(true) },
|
||||
},
|
||||
{
|
||||
provide: ShareLinkBundleService,
|
||||
useValue: {
|
||||
list: jest.fn().mockReturnValue(of({ count: 0, results: [] })),
|
||||
rebuildBundle: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ShareLinkService,
|
||||
useValue: {
|
||||
list: jest.fn().mockReturnValue(of({ count: 0, results: [] })),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ToastService,
|
||||
useValue: { showInfo: jest.fn(), showError: jest.fn() },
|
||||
},
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
permissionsService = TestBed.inject(PermissionsService)
|
||||
router = TestBed.inject(Router)
|
||||
}
|
||||
|
||||
afterEach(() => TestBed.resetTestingModule())
|
||||
|
||||
it('uses the requested bundles tab when permitted', async () => {
|
||||
await configure(ShareLinksNavIDs.Bundles)
|
||||
jest
|
||||
.spyOn(permissionsService, 'currentUserCan')
|
||||
.mockImplementation(
|
||||
(action, type) =>
|
||||
action === PermissionAction.View &&
|
||||
type === PermissionType.ShareLinkBundle
|
||||
)
|
||||
|
||||
fixture = TestBed.createComponent(ShareLinksComponent)
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(fixture.componentInstance.activeNavID()).toBe(
|
||||
ShareLinksNavIDs.Bundles
|
||||
)
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Document links')
|
||||
})
|
||||
|
||||
it('updates the URL when a tab is selected', async () => {
|
||||
await configure()
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
|
||||
fixture = TestBed.createComponent(ShareLinksComponent)
|
||||
fixture.componentInstance.selectTab(ShareLinksNavIDs.Bundles)
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith([], {
|
||||
relativeTo: TestBed.inject(ActivatedRoute),
|
||||
queryParams: { type: ShareLinksNavIDs.Bundles },
|
||||
queryParamsHandling: 'merge',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Component, computed, inject, signal, viewChild } from '@angular/core'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import {
|
||||
PermissionAction,
|
||||
PermissionsService,
|
||||
PermissionType,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { ShareLinkBundleListComponent } from './share-link-bundle-list/share-link-bundle-list.component'
|
||||
import { ShareLinkListComponent } from './share-link-list/share-link-list.component'
|
||||
|
||||
export enum ShareLinksNavIDs {
|
||||
DocumentLinks = 'documents',
|
||||
Bundles = 'bundles',
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-share-links',
|
||||
templateUrl: './share-links.component.html',
|
||||
imports: [
|
||||
NgbNavModule,
|
||||
PageHeaderComponent,
|
||||
ShareLinkBundleListComponent,
|
||||
ShareLinkListComponent,
|
||||
],
|
||||
})
|
||||
export class ShareLinksComponent {
|
||||
private readonly route = inject(ActivatedRoute)
|
||||
private readonly router = inject(Router)
|
||||
private readonly permissionsService = inject(PermissionsService)
|
||||
|
||||
readonly ShareLinksNavIDs = ShareLinksNavIDs
|
||||
readonly activeNavID = signal(this.getInitialNavID())
|
||||
private readonly documentLinks = viewChild(ShareLinkListComponent)
|
||||
private readonly bundles = viewChild(ShareLinkBundleListComponent)
|
||||
readonly loading = computed(() => {
|
||||
const activeList =
|
||||
this.activeNavID() === ShareLinksNavIDs.DocumentLinks
|
||||
? this.documentLinks()
|
||||
: this.bundles()
|
||||
return activeList?.loading() ?? true
|
||||
})
|
||||
|
||||
get canViewDocumentLinks(): boolean {
|
||||
return this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.ShareLink
|
||||
)
|
||||
}
|
||||
|
||||
get canViewBundles(): boolean {
|
||||
return this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.ShareLinkBundle
|
||||
)
|
||||
}
|
||||
|
||||
selectTab(tab: ShareLinksNavIDs): void {
|
||||
this.activeNavID.set(tab)
|
||||
void this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
queryParams: { type: tab },
|
||||
queryParamsHandling: 'merge',
|
||||
})
|
||||
}
|
||||
|
||||
private getInitialNavID(): ShareLinksNavIDs {
|
||||
const requestedTab = this.route.snapshot.queryParamMap.get('type')
|
||||
if (requestedTab === ShareLinksNavIDs.Bundles && this.canViewBundles) {
|
||||
return ShareLinksNavIDs.Bundles
|
||||
}
|
||||
if (this.canViewDocumentLinks) {
|
||||
return ShareLinksNavIDs.DocumentLinks
|
||||
}
|
||||
return ShareLinksNavIDs.Bundles
|
||||
}
|
||||
}
|
||||
@@ -167,6 +167,7 @@ export interface Document extends ObjectWithPermissions {
|
||||
|
||||
// Frontend only
|
||||
__changedFields?: string[]
|
||||
__selectedVersionId?: number
|
||||
}
|
||||
|
||||
export interface DocumentVersionInfo {
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import { ObjectWithId } from './object-with-id'
|
||||
|
||||
export type OcrZoneTarget = 'custom_field' | 'title' | 'asn' | 'created'
|
||||
export type OcrBuiltinTarget = Exclude<OcrZoneTarget, 'custom_field'>
|
||||
export type OcrZoneTransform =
|
||||
| 'none'
|
||||
| 'strip'
|
||||
| 'uppercase'
|
||||
| 'lowercase'
|
||||
| 'numeric'
|
||||
| 'strip_punctuation'
|
||||
| 'date'
|
||||
| 'qr_code'
|
||||
|
||||
export const OCR_ZONE_TARGET = {
|
||||
CustomField: 'custom_field',
|
||||
Title: 'title',
|
||||
Asn: 'asn',
|
||||
Created: 'created',
|
||||
} as const satisfies Record<string, OcrZoneTarget>
|
||||
|
||||
export const OCR_ZONE_TRANSFORM = {
|
||||
None: 'none',
|
||||
Strip: 'strip',
|
||||
Uppercase: 'uppercase',
|
||||
Lowercase: 'lowercase',
|
||||
Numeric: 'numeric',
|
||||
StripPunctuation: 'strip_punctuation',
|
||||
Date: 'date',
|
||||
QrCode: 'qr_code',
|
||||
} as const satisfies Record<string, OcrZoneTransform>
|
||||
|
||||
export const DEFAULT_OCR_ZONE_TARGET = OCR_ZONE_TARGET.CustomField
|
||||
export const DEFAULT_OCR_ZONE_TRANSFORM = OCR_ZONE_TRANSFORM.Strip
|
||||
export const DEFAULT_OCR_ZONE_LANGUAGE = 'deu+eng'
|
||||
|
||||
export function isOcrBuiltinTarget(value: unknown): value is OcrBuiltinTarget {
|
||||
return (
|
||||
value === OCR_ZONE_TARGET.Title ||
|
||||
value === OCR_ZONE_TARGET.Asn ||
|
||||
value === OCR_ZONE_TARGET.Created
|
||||
)
|
||||
}
|
||||
|
||||
export const OCR_BUILTIN_TARGETS = [
|
||||
{ id: OCR_ZONE_TARGET.Title, name: $localize`Title` },
|
||||
{ id: OCR_ZONE_TARGET.Asn, name: $localize`Archive serial number` },
|
||||
{ id: OCR_ZONE_TARGET.Created, name: $localize`Date created` },
|
||||
]
|
||||
|
||||
export interface OcrTemplateZone {
|
||||
id?: number
|
||||
name: string
|
||||
target?: OcrZoneTarget
|
||||
custom_field: number | null
|
||||
page?: number
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
ocr_language: string
|
||||
transform: OcrZoneTransform
|
||||
date_format?: string
|
||||
validation_regex: string
|
||||
order: number
|
||||
zone_source_width?: number
|
||||
zone_source_height?: number
|
||||
}
|
||||
|
||||
export const TRANSFORM_OPTIONS = [
|
||||
{ id: OCR_ZONE_TRANSFORM.None, name: $localize`None` },
|
||||
{ id: OCR_ZONE_TRANSFORM.Strip, name: $localize`Strip whitespace` },
|
||||
{ id: OCR_ZONE_TRANSFORM.Uppercase, name: $localize`Uppercase` },
|
||||
{ id: OCR_ZONE_TRANSFORM.Lowercase, name: $localize`Lowercase` },
|
||||
{ id: OCR_ZONE_TRANSFORM.Numeric, name: $localize`Numeric only` },
|
||||
{
|
||||
id: OCR_ZONE_TRANSFORM.StripPunctuation,
|
||||
name: $localize`Remove leading/trailing punctuation`,
|
||||
},
|
||||
{ id: OCR_ZONE_TRANSFORM.Date, name: $localize`Parse date` },
|
||||
{ id: OCR_ZONE_TRANSFORM.QrCode, name: $localize`Read QR/barcode` },
|
||||
]
|
||||
|
||||
export const OCR_LANGUAGE_OPTIONS = [
|
||||
{ id: 'eng', name: $localize`English` },
|
||||
{ id: 'deu', name: $localize`German` },
|
||||
{ id: 'fra', name: $localize`French` },
|
||||
{ id: 'ita', name: $localize`Italian` },
|
||||
{ id: 'spa', name: $localize`Spanish` },
|
||||
{ id: 'por', name: $localize`Portuguese` },
|
||||
{ id: 'nld', name: $localize`Dutch` },
|
||||
]
|
||||
|
||||
export const DATE_FORMAT_OPTIONS = [
|
||||
{ id: '', name: $localize`Auto-detect` },
|
||||
{ id: '%d.%m.%Y', name: 'DD.MM.YYYY' },
|
||||
{ id: '%Y/%m/%d', name: 'YYYY/MM/DD' },
|
||||
{ id: '%d/%m/%Y', name: 'DD/MM/YYYY' },
|
||||
]
|
||||
|
||||
export interface OcrTemplate extends ObjectWithId {
|
||||
name: string
|
||||
document_type: number
|
||||
sample_document: number | null
|
||||
source_width: number
|
||||
source_height: number
|
||||
enabled: boolean
|
||||
combine_formats?: Record<string, string>
|
||||
created?: string
|
||||
updated?: string
|
||||
zones: OcrTemplateZone[]
|
||||
}
|
||||
|
||||
export interface ZoneTestRequest {
|
||||
name: string
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
page: number
|
||||
ocr_language: string
|
||||
transform: OcrZoneTransform
|
||||
date_format?: string
|
||||
validation_regex: string
|
||||
zone_source_width?: number
|
||||
zone_source_height?: number
|
||||
}
|
||||
|
||||
export interface OcrZoneTestResult {
|
||||
raw_text?: string | null
|
||||
value?: string | null
|
||||
regex?: string
|
||||
regex_match?: boolean | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface OcrZoneRunResult {
|
||||
template: string
|
||||
zone: string
|
||||
custom_field: string
|
||||
value: string | number | null
|
||||
}
|
||||
@@ -353,6 +353,14 @@ export const PaperlessConfigOptions: ConfigOption[] = [
|
||||
config_key: 'PAPERLESS_AI_LLM_EMBEDDING_MODEL',
|
||||
category: ConfigCategory.AI,
|
||||
},
|
||||
{
|
||||
key: 'llm_embedding_api_key',
|
||||
title: $localize`LLM Embedding API Key`,
|
||||
type: ConfigOptionType.Password,
|
||||
note: $localize`Used for embeddings when set, otherwise LLM API key is used.`,
|
||||
config_key: 'PAPERLESS_AI_LLM_EMBEDDING_API_KEY',
|
||||
category: ConfigCategory.AI,
|
||||
},
|
||||
{
|
||||
key: 'llm_embedding_endpoint',
|
||||
title: $localize`LLM Embedding Endpoint`,
|
||||
@@ -457,6 +465,7 @@ export interface PaperlessConfig extends ObjectWithId {
|
||||
ai_enabled: boolean
|
||||
llm_embedding_backend: string
|
||||
llm_embedding_model: string
|
||||
llm_embedding_api_key: string
|
||||
llm_embedding_endpoint: string
|
||||
llm_embedding_chunk_size: number
|
||||
llm_context_size: number
|
||||
|
||||
@@ -12,6 +12,7 @@ export enum PaperlessTaskType {
|
||||
ReprocessDocument = 'reprocess_document',
|
||||
BuildShareLink = 'build_share_link',
|
||||
BulkDelete = 'bulk_delete',
|
||||
ApplyAiSuggestions = 'apply_ai_suggestions',
|
||||
}
|
||||
|
||||
export enum PaperlessTaskTriggerSource {
|
||||
|
||||
@@ -26,5 +26,7 @@ export interface ShareLink extends ObjectWithPermissions {
|
||||
|
||||
document: number // Document
|
||||
|
||||
document_title?: string
|
||||
|
||||
file_version: string
|
||||
}
|
||||
|
||||
@@ -24,6 +24,17 @@ export enum CollapsibleSection {
|
||||
ATTRIBUTES = 'attributes',
|
||||
}
|
||||
|
||||
export enum HideableSidebarItemID {
|
||||
Dashboard = 'dashboard',
|
||||
SavedViews = 'saved_views',
|
||||
ShareLinks = 'share_links',
|
||||
Workflows = 'workflows',
|
||||
Mail = 'mail',
|
||||
Documentation = 'documentation',
|
||||
}
|
||||
|
||||
export const HIDEABLE_SIDEBAR_ITEM_IDS = Object.values(HideableSidebarItemID)
|
||||
|
||||
export const PAPERLESS_GREEN_HEX = '#17541f'
|
||||
|
||||
export const SETTINGS_KEYS = {
|
||||
@@ -56,6 +67,7 @@ export const SETTINGS_KEYS = {
|
||||
NOTES_ENABLED: 'general-settings:notes-enabled',
|
||||
AUDITLOG_ENABLED: 'general-settings:auditlog-enabled',
|
||||
SLIM_SIDEBAR: 'general-settings:slim-sidebar',
|
||||
SIDEBAR_HIDDEN_ITEMS: 'general-settings:sidebar:hidden-items',
|
||||
ATTRIBUTES_SECTIONS_COLLAPSED:
|
||||
'general-settings:attributes-sections-collapsed',
|
||||
UPDATE_CHECKING_ENABLED: 'general-settings:update-checking:enabled',
|
||||
@@ -127,6 +139,11 @@ export const SETTINGS: UiSetting[] = [
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS,
|
||||
type: 'array',
|
||||
default: [],
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
|
||||
type: 'array',
|
||||
@@ -230,6 +247,8 @@ export const SETTINGS: UiSetting[] = [
|
||||
document_types: 25,
|
||||
tags: 25,
|
||||
storage_paths: 25,
|
||||
share_links: 25,
|
||||
share_link_bundles: 25,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -580,7 +580,7 @@ describe('DocumentListViewService', () => {
|
||||
expect(documentListViewService.isSelected(documents[3])).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should clear all-selected mode when toggling a single document', () => {
|
||||
it('should exclude a toggled document while keeping all-selected mode', () => {
|
||||
documentListViewService.reload()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
@@ -592,8 +592,73 @@ describe('DocumentListViewService', () => {
|
||||
|
||||
documentListViewService.toggleSelected(documents[0])
|
||||
|
||||
expect(documentListViewService.allSelected).toBeFalsy()
|
||||
expect(documentListViewService.allSelected).toBeTruthy()
|
||||
expect(documentListViewService.excluded).toEqual(new Set([documents[0].id]))
|
||||
expect(documentListViewService.selectedCount).toEqual(documents.length - 1)
|
||||
expect(documentListViewService.isSelected(documents[0])).toBeFalsy()
|
||||
|
||||
documentListViewService.toggleSelected(documents[0])
|
||||
|
||||
expect(documentListViewService.excluded.size).toEqual(0)
|
||||
expect(documentListViewService.selectedCount).toEqual(documents.length)
|
||||
expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should preserve exclusions across pages', () => {
|
||||
documentListViewService.pageSize = 3
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
)
|
||||
req.flush({ count: documents.length, results: documents.slice(0, 3) })
|
||||
|
||||
documentListViewService.selectAll()
|
||||
documentListViewService.toggleSelected(documents[0])
|
||||
documentListViewService.currentPage = 2
|
||||
req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
)
|
||||
req.flush({ count: documents.length, results: documents.slice(3, 6) })
|
||||
|
||||
expect(documentListViewService.excluded).toEqual(new Set([documents[0].id]))
|
||||
expect(documentListViewService.selectedCount).toEqual(documents.length - 1)
|
||||
expect(documentListViewService.selected).toEqual(
|
||||
new Set(documents.slice(3, 6).map((document) => document.id))
|
||||
)
|
||||
|
||||
documentListViewService.currentPage = 1
|
||||
req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
)
|
||||
req.flush({ count: documents.length, results: documents.slice(0, 3) })
|
||||
|
||||
expect(documentListViewService.isSelected(documents[0])).toBeFalsy()
|
||||
expect(documentListViewService.isSelected(documents[1])).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should clear exclusions when filters change', () => {
|
||||
documentListViewService.reload()
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
)
|
||||
req.flush(full_results)
|
||||
documentListViewService.selectAll()
|
||||
documentListViewService.toggleSelected(documents[0])
|
||||
|
||||
documentListViewService.setFilterRules(filterRules)
|
||||
req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=9`
|
||||
)
|
||||
req.flush({ count: 3, results: documents.slice(0, 3) })
|
||||
|
||||
expect(documentListViewService.allSelected).toBeTruthy()
|
||||
expect(documentListViewService.excluded.size).toEqual(0)
|
||||
expect(documentListViewService.selectedCount).toEqual(3)
|
||||
|
||||
documentListViewService.setFilterRules([])
|
||||
req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
)
|
||||
req.flush(full_results)
|
||||
})
|
||||
|
||||
it('should clear all-selected mode when selecting a range', () => {
|
||||
|
||||
@@ -85,6 +85,11 @@ export interface ListViewState {
|
||||
*/
|
||||
allSelected?: boolean
|
||||
|
||||
/**
|
||||
* Document IDs excluded from the full filtered result set.
|
||||
*/
|
||||
excluded?: Set<number>
|
||||
|
||||
/**
|
||||
* The page size of the list view.
|
||||
*/
|
||||
@@ -215,6 +220,7 @@ export class DocumentListViewService {
|
||||
filterRules: [],
|
||||
selected: new Set<number>(),
|
||||
allSelected: false,
|
||||
excluded: new Set<number>(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,7 +230,9 @@ export class DocumentListViewService {
|
||||
}
|
||||
|
||||
this.selected.clear()
|
||||
this.documents?.forEach((doc) => this.selected.add(doc.id))
|
||||
this.documents
|
||||
?.filter((doc) => !this.excluded.has(doc.id))
|
||||
.forEach((doc) => this.selected.add(doc.id))
|
||||
|
||||
if (!this.collectionSize) {
|
||||
this.selectNone()
|
||||
@@ -491,14 +499,23 @@ export class DocumentListViewService {
|
||||
return this.activeListViewState.allSelected ?? false
|
||||
}
|
||||
|
||||
get excluded(): Set<number> {
|
||||
this.trackState()
|
||||
if (!this.activeListViewState.excluded) {
|
||||
this.activeListViewState.excluded = new Set<number>()
|
||||
}
|
||||
return this.activeListViewState.excluded
|
||||
}
|
||||
|
||||
get selectedCount(): number {
|
||||
return this.allSelected
|
||||
? (this.collectionSize ?? this.selected.size)
|
||||
: this.selected.size
|
||||
if (!this.allSelected || this.collectionSize == null) {
|
||||
return this.selected.size
|
||||
}
|
||||
return Math.max(0, this.collectionSize - this.excluded.size)
|
||||
}
|
||||
|
||||
get hasSelection(): boolean {
|
||||
return this.allSelected || this.selected.size > 0
|
||||
return this.selectedCount > 0
|
||||
}
|
||||
|
||||
setSort(field: string, reverse: boolean) {
|
||||
@@ -663,12 +680,14 @@ export class DocumentListViewService {
|
||||
selectNone() {
|
||||
this.activeListViewState.allSelected = false
|
||||
this.selected.clear()
|
||||
this.excluded.clear()
|
||||
this.rangeSelectionAnchorIndex = this.lastRangeSelectionToIndex = null
|
||||
this.markChanged()
|
||||
}
|
||||
|
||||
reduceSelectionToFilter() {
|
||||
if (this.allSelected) {
|
||||
this.excluded.clear()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -688,6 +707,7 @@ export class DocumentListViewService {
|
||||
|
||||
selectAll() {
|
||||
this.activeListViewState.allSelected = true
|
||||
this.excluded.clear()
|
||||
this.syncSelectedToCurrentPage()
|
||||
this.markChanged()
|
||||
}
|
||||
@@ -695,6 +715,7 @@ export class DocumentListViewService {
|
||||
selectPage() {
|
||||
this.activeListViewState.allSelected = false
|
||||
this.selected.clear()
|
||||
this.excluded.clear()
|
||||
this.documents.forEach((doc) => {
|
||||
this.selected.add(doc.id)
|
||||
})
|
||||
@@ -702,15 +723,23 @@ export class DocumentListViewService {
|
||||
}
|
||||
|
||||
isSelected(d: Document) {
|
||||
return this.allSelected || this.selected.has(d.id)
|
||||
return this.allSelected ? !this.excluded.has(d.id) : this.selected.has(d.id)
|
||||
}
|
||||
|
||||
toggleSelected(d: Document): void {
|
||||
if (this.allSelected) {
|
||||
this.activeListViewState.allSelected = false
|
||||
if (this.excluded.has(d.id)) {
|
||||
this.excluded.delete(d.id)
|
||||
this.selected.add(d.id)
|
||||
} else {
|
||||
this.excluded.add(d.id)
|
||||
this.selected.delete(d.id)
|
||||
}
|
||||
} else if (this.selected.has(d.id)) {
|
||||
this.selected.delete(d.id)
|
||||
} else {
|
||||
this.selected.add(d.id)
|
||||
}
|
||||
if (this.selected.has(d.id)) this.selected.delete(d.id)
|
||||
else this.selected.add(d.id)
|
||||
this.rangeSelectionAnchorIndex = this.documentIndexInCurrentView(d.id)
|
||||
this.lastRangeSelectionToIndex = null
|
||||
this.markChanged()
|
||||
@@ -719,6 +748,7 @@ export class DocumentListViewService {
|
||||
selectRangeTo(d: Document) {
|
||||
if (this.allSelected) {
|
||||
this.activeListViewState.allSelected = false
|
||||
this.excluded.clear()
|
||||
}
|
||||
|
||||
if (this.rangeSelectionAnchorIndex !== null) {
|
||||
|
||||
@@ -221,6 +221,25 @@ describe('OpenDocumentsService', () => {
|
||||
expect(openDocumentsService.getOpenDocuments()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should refresh documents in place and keep unsaved edits', () => {
|
||||
const openDoc = { ...documents[0] }
|
||||
subscriptions.push(openDocumentsService.openDocument(openDoc).subscribe())
|
||||
openDoc.title = 'Unsaved title'
|
||||
openDocumentsService.setDirty(openDoc, true, { title: openDoc.title })
|
||||
|
||||
openDocumentsService.refreshDocument(openDoc.id)
|
||||
httpTestingController
|
||||
.expectOne(
|
||||
`${environment.apiBaseUrl}documents/${openDoc.id}/?full_perms=true`
|
||||
)
|
||||
.flush({ ...documents[0], tags: [4] })
|
||||
|
||||
const refreshed = openDocumentsService.getOpenDocument(openDoc.id)
|
||||
expect(refreshed).toBe(openDoc)
|
||||
expect(refreshed.title).toEqual('Unsaved title')
|
||||
expect(refreshed.tags).toEqual([4])
|
||||
})
|
||||
|
||||
it('should handle error on refresh documents', () => {
|
||||
subscriptions.push(
|
||||
openDocumentsService.openDocument(documents[1]).subscribe()
|
||||
|
||||
@@ -50,7 +50,15 @@ export class OpenDocumentsService {
|
||||
if (index > -1) {
|
||||
this.documentService.get(id).subscribe({
|
||||
next: (doc) => {
|
||||
this.openDocuments[index] = doc
|
||||
const openDoc = this.openDocuments.find((d) => d.id == id)
|
||||
if (!openDoc) return
|
||||
const unsavedEdits = Object.fromEntries(
|
||||
(openDoc.__changedFields ?? []).map((field) => [
|
||||
field,
|
||||
openDoc[field],
|
||||
])
|
||||
)
|
||||
Object.assign(openDoc, doc, unsavedEdits)
|
||||
this.save()
|
||||
},
|
||||
error: () => {
|
||||
|
||||
@@ -29,7 +29,6 @@ export enum PermissionType {
|
||||
ShareLinkBundle = '%s_sharelinkbundle',
|
||||
CustomField = '%s_customfield',
|
||||
Workflow = '%s_workflow',
|
||||
OcrTemplate = '%s_ocrtemplate',
|
||||
ProcessedMail = '%s_processedmail',
|
||||
GlobalStatistics = '%s_global_statistics',
|
||||
SystemMonitoring = '%s_system_monitoring',
|
||||
|
||||
@@ -175,7 +175,7 @@ describe(`DocumentService`, () => {
|
||||
|
||||
it('should call appropriate api endpoint for getting selection data', () => {
|
||||
const ids = [documents[0].id]
|
||||
subscription = service.getSelectionData(ids).subscribe()
|
||||
subscription = service.getSelectionData({ documents: ids }).subscribe()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}${endpoint}/selection_data/`
|
||||
)
|
||||
@@ -185,6 +185,20 @@ describe(`DocumentService`, () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should get selection data with all, filters, and exclusions', () => {
|
||||
const selection = {
|
||||
all: true,
|
||||
filters: { title__icontains: 'apple' },
|
||||
excluded_documents: [2, 3],
|
||||
}
|
||||
subscription = service.getSelectionData(selection).subscribe()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}${endpoint}/selection_data/`
|
||||
)
|
||||
expect(req.request.method).toEqual('POST')
|
||||
expect(req.request.body).toEqual(selection)
|
||||
})
|
||||
|
||||
it('should call appropriate api endpoint for getting suggestions', () => {
|
||||
subscription = service.getSuggestions(documents[0].id).subscribe()
|
||||
const req = httpTestingController.expectOne(
|
||||
@@ -240,7 +254,7 @@ describe(`DocumentService`, () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should call appropriate api endpoint for bulk edit with all and filters', () => {
|
||||
it('should call appropriate api endpoint for bulk edit with all, filters, and exclusions', () => {
|
||||
const method = 'modify_tags'
|
||||
const parameters = {
|
||||
add_tags: [15],
|
||||
@@ -249,6 +263,7 @@ describe(`DocumentService`, () => {
|
||||
const selection = {
|
||||
all: true,
|
||||
filters: { title__icontains: 'apple' },
|
||||
excluded_documents: [2, 3],
|
||||
}
|
||||
subscription = service.bulkEdit(selection, method, parameters).subscribe()
|
||||
const req = httpTestingController.expectOne(
|
||||
@@ -258,6 +273,7 @@ describe(`DocumentService`, () => {
|
||||
expect(req.request.body).toEqual({
|
||||
all: true,
|
||||
filters: { title__icontains: 'apple' },
|
||||
excluded_documents: [2, 3],
|
||||
method,
|
||||
parameters,
|
||||
})
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import { DocumentMetadata } from 'src/app/data/document-metadata'
|
||||
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
|
||||
import { FilterRule } from 'src/app/data/filter-rule'
|
||||
import { OcrZoneRunResult } from 'src/app/data/ocr-template'
|
||||
import { Results, SelectionData } from 'src/app/data/results'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { queryParamsFromFilterRules } from '../../utils/query-params'
|
||||
@@ -73,6 +72,7 @@ export interface DocumentSelectionQuery {
|
||||
documents?: number[]
|
||||
all?: boolean
|
||||
filters?: { [key: string]: any }
|
||||
excluded_documents?: number[]
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
@@ -360,13 +360,6 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
||||
})
|
||||
}
|
||||
|
||||
runZoneOcr(id: number): Observable<{ results: OcrZoneRunResult[] }> {
|
||||
return this.http.post<{ results: OcrZoneRunResult[] }>(
|
||||
this.getResourceUrl(id, 'run-zone-ocr'),
|
||||
{}
|
||||
)
|
||||
}
|
||||
|
||||
rotateDocuments(
|
||||
selection: DocumentSelectionQuery,
|
||||
degrees: number,
|
||||
@@ -415,10 +408,12 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
||||
})
|
||||
}
|
||||
|
||||
getSelectionData(ids: number[]): Observable<SelectionData> {
|
||||
getSelectionData(
|
||||
selection: DocumentSelectionQuery
|
||||
): Observable<SelectionData> {
|
||||
return this.http.post<SelectionData>(
|
||||
this.getResourceUrl(null, 'selection_data'),
|
||||
{ documents: ids }
|
||||
selection
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { Observable } from 'rxjs'
|
||||
import {
|
||||
OcrTemplate,
|
||||
OcrZoneTestResult,
|
||||
ZoneTestRequest,
|
||||
} from '../../data/ocr-template'
|
||||
import { AbstractPaperlessService } from './abstract-paperless-service'
|
||||
|
||||
export interface QuickCreateFieldResult {
|
||||
id: number
|
||||
name: string
|
||||
data_type: string
|
||||
created: boolean
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class OcrTemplateService extends AbstractPaperlessService<OcrTemplate> {
|
||||
constructor() {
|
||||
super()
|
||||
this.resourceName = 'ocr_templates'
|
||||
}
|
||||
|
||||
getPageImageUrl(docId: number, page: number): string {
|
||||
return `${this.baseUrl}${this.resourceName}/document-page-image/${docId}/${page}/`
|
||||
}
|
||||
|
||||
testZone(
|
||||
docId: number,
|
||||
zone: ZoneTestRequest
|
||||
): Observable<OcrZoneTestResult> {
|
||||
return this.http.post<OcrZoneTestResult>(
|
||||
`${this.baseUrl}${this.resourceName}/test-zone/`,
|
||||
{ document: docId, zone }
|
||||
)
|
||||
}
|
||||
|
||||
quickCreateField(
|
||||
name: string,
|
||||
dataType: string
|
||||
): Observable<QuickCreateFieldResult> {
|
||||
return this.http.post<QuickCreateFieldResult>(
|
||||
`${this.baseUrl}${this.resourceName}/quick-create-field/`,
|
||||
{ name, data_type: dataType }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -48,13 +48,4 @@ describe('ShareLinkBundleService', () => {
|
||||
expect(req.request.body).toEqual({})
|
||||
req.flush({})
|
||||
})
|
||||
|
||||
it('lists bundles with expected parameters', () => {
|
||||
subscription = service.listAllBundles().subscribe()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}${endpoint}/?page=1&page_size=1000&ordering=-created`
|
||||
)
|
||||
expect(req.request.method).toBe('GET')
|
||||
req.flush({ results: [] })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { Observable } from 'rxjs'
|
||||
import { map } from 'rxjs/operators'
|
||||
import {
|
||||
ShareLinkBundleCreatePayload,
|
||||
ShareLinkBundleSummary,
|
||||
@@ -32,10 +31,4 @@ export class ShareLinkBundleService extends AbstractNameFilterService<ShareLinkB
|
||||
{}
|
||||
)
|
||||
}
|
||||
|
||||
listAllBundles(): Observable<ShareLinkBundleSummary[]> {
|
||||
return this.list(1, 1000, 'created', true).pipe(
|
||||
map((response) => response.results)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,11 @@ import { CustomFieldDataType } from '../data/custom-field'
|
||||
import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
|
||||
import { SavedView } from '../data/saved-view'
|
||||
import { RemoteOCRModeConfig } from '../data/paperless-config'
|
||||
import { SETTINGS_KEYS, UiSettings } from '../data/ui-settings'
|
||||
import {
|
||||
HideableSidebarItemID,
|
||||
SETTINGS_KEYS,
|
||||
UiSettings,
|
||||
} from '../data/ui-settings'
|
||||
import { PermissionsService } from './permissions.service'
|
||||
import { CustomFieldsService } from './rest/custom-fields.service'
|
||||
import { SettingsService } from './settings.service'
|
||||
@@ -230,6 +234,35 @@ describe('SettingsService', () => {
|
||||
expect(notesEnabled()).toBeFalsy()
|
||||
})
|
||||
|
||||
it('updates sidebar item visibility', () => {
|
||||
httpTestingController
|
||||
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
|
||||
.flush(ui_settings)
|
||||
|
||||
expect(
|
||||
settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)
|
||||
).toBe(false)
|
||||
|
||||
settingsService.updateSidebarItemVisibility(
|
||||
HideableSidebarItemID.Workflows,
|
||||
false
|
||||
)
|
||||
|
||||
expect(
|
||||
settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)
|
||||
).toBe(true)
|
||||
expect(settingsService.get(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS)).toEqual([])
|
||||
|
||||
settingsService.updateSidebarItemVisibility(
|
||||
HideableSidebarItemID.Workflows,
|
||||
true
|
||||
)
|
||||
|
||||
expect(
|
||||
settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('updates setting signals when settings are reinitialized', () => {
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}ui_settings/`
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user