mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-11-15 18:36:42 +00:00
Compare commits
153
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30e95a6d28 | ||
|
|
8c50a5ee7b | ||
|
|
b63ea77543 | ||
|
|
bfd5628dcc | ||
|
|
3dbeca603e | ||
|
|
62d729e301 | ||
|
|
a1d76f3a19 | ||
|
|
f5ca9bbcb6 | ||
|
|
60a35edee1 | ||
|
|
7cdcd71a6d | ||
|
|
759f9cd22b | ||
|
|
d05e96cda2 | ||
|
|
fefce7be1b | ||
|
|
cc65226d6d | ||
|
|
00096e0252 | ||
|
|
2829e78009 | ||
|
|
d7d4014bbd | ||
|
|
a139bdff46 | ||
|
|
886761a53b | ||
|
|
198b165f26 | ||
|
|
e63f27a712 | ||
|
|
3ce5f8bc69 | ||
|
|
58cdd4f19a | ||
|
|
5c8c0db08f | ||
|
|
5c28d8282d | ||
|
|
936b1b3bfa | ||
|
|
e0c33b3d39 | ||
|
|
32bc5522e9 | ||
|
|
d5698329f5 | ||
|
|
2e0d92d8f5 | ||
|
|
0736c07d8e | ||
|
|
53d9aa34a1 | ||
|
|
19fdc3b815 | ||
|
|
5357be2d43 | ||
|
|
2642cefd35 | ||
|
|
8aa99a381a | ||
|
|
d14cfc93f4 | ||
|
|
53e386ec81 | ||
|
|
aede2e6a5a | ||
|
|
fa792d33c2 | ||
|
|
6fd91beae2 | ||
|
|
a4543c03dc | ||
|
|
45a2ac4879 | ||
|
|
8bc9db816a | ||
|
|
c5239f9f74 | ||
|
|
e88303cc16 | ||
|
|
cb3513c6f3 | ||
|
|
4468a510a6 | ||
|
|
09d70ed7da | ||
|
|
057aee8bbd | ||
|
|
fa83a4c5dc | ||
|
|
22746aa202 | ||
|
|
137342c1f9 | ||
|
|
0ae5e2d3d4 | ||
|
|
b7b0de100f | ||
|
|
8531d3e0c9 | ||
|
|
05f7545a4a | ||
|
|
ade5901a4d | ||
|
|
2b620a2823 | ||
|
|
45b8977623 | ||
|
|
800748d5ab | ||
|
|
182b0b4712 | ||
|
|
c718636f79 | ||
|
|
54f7f43f35 | ||
|
|
94da3d4c57 | ||
|
|
453e89d6dd | ||
|
|
6562415894 | ||
|
|
257c33453d | ||
|
|
87913411ef | ||
|
|
cec393dcae | ||
|
|
f5526d5700 | ||
|
|
2534d3c16b | ||
|
|
95c8bf2058 | ||
|
|
930d23956b | ||
|
|
d2e6834602 | ||
|
|
a7bf04ddee | ||
|
|
8ab2bf63aa | ||
|
|
c63a7e25e7 | ||
|
|
9171a1aa6a | ||
|
|
55c5d9121f | ||
|
|
e4efb62667 | ||
|
|
f0597a7c54 | ||
|
|
bbdc016a32 | ||
|
|
eae392469f | ||
|
|
bedefd85f2 | ||
|
|
bd73fe1e80 | ||
|
|
8b77a924dc | ||
|
|
1bdeb7ebcd | ||
|
|
fa3acfe13f | ||
|
|
2bc22b14bd | ||
|
|
dbce8c1409 | ||
|
|
e976b2ec7a | ||
|
|
05da0ba5a3 | ||
|
|
4ff2cb9c72 | ||
|
|
77a7fb40ec | ||
|
|
8100a8e1eb | ||
|
|
1e334903f3 | ||
|
|
a958d89bf4 | ||
|
|
8b3295fce0 | ||
|
|
9783c4995f | ||
|
|
e89495dc02 | ||
|
|
8bb2f88015 | ||
|
|
7d9b8c770d | ||
|
|
3296fd7cb5 | ||
|
|
bff41907c2 | ||
|
|
592f882394 | ||
|
|
29ea9cec66 | ||
|
|
123df2e826 | ||
|
|
639827ec65 | ||
|
|
b3106e408c | ||
|
|
ea31b6a043 | ||
|
|
0572b54579 | ||
|
|
2afec3e0b7 | ||
|
|
57019bccbe | ||
|
|
26b3964a20 | ||
|
|
a2eeadca7f | ||
|
|
ba5474ba81 | ||
|
|
e3592804bd | ||
|
|
2d54927be8 | ||
|
|
1d2832ac30 | ||
|
|
3321ba46d6 | ||
|
|
bf316cba8a | ||
|
|
36c31b3a73 | ||
|
|
d10d303980 | ||
|
|
a51e6a71f4 | ||
|
|
ebd8dba3bb | ||
|
|
3ed9586347 | ||
|
|
7055f7a745 | ||
|
|
8f7500ba64 | ||
|
|
816774815c | ||
|
|
c5e6748771 | ||
|
|
9b42170554 | ||
|
|
d2ed0c55a4 | ||
|
|
4d31063082 | ||
|
|
a4fb9bf2b0 | ||
|
|
e028257da5 | ||
|
|
68eb4fe96d | ||
|
|
d2d434535b | ||
|
|
1d75c0c99b | ||
|
|
1de1261728 | ||
|
|
f5f33e0744 | ||
|
|
c9d62afa50 | ||
|
|
b5a9353780 | ||
|
|
cf6d16edcd | ||
|
|
551445773a | ||
|
|
c54cb4a84b | ||
|
|
af61044b63 | ||
|
|
1ef5a6d3cf | ||
|
|
4da311f91f | ||
|
|
cbb84649c7 | ||
|
|
8da734a495 | ||
|
|
351671011d | ||
|
|
15c95c251d |
No files matched your search
@@ -1,5 +1,23 @@
|
|||||||
title: "[Feature Request] "
|
title: "[Feature Request] "
|
||||||
body:
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
#### Use of AI tools
|
||||||
|
|
||||||
|
If this request was generated, in whole or in part, by an AI tool or agent you **must** say so using the field below. Failing to clearly disclose this is a violation of our [Code of Conduct](https://github.com/paperless-ngx/paperless-ngx/blob/main/CODE_OF_CONDUCT.md).
|
||||||
|
|
||||||
|
The project prefers thoughtful, well-described ideas to AI-generated content. Please describe the problem, your use case, the desired behavior, and who would benefit.
|
||||||
|
- type: dropdown
|
||||||
|
id: ai-usage
|
||||||
|
attributes:
|
||||||
|
label: AI usage
|
||||||
|
description: Was any part of this request written by an AI tool or agent?
|
||||||
|
options:
|
||||||
|
- No AI tools were used
|
||||||
|
- Yes, AI tools helped write this request
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: description
|
id: description
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
title: "[Support] "
|
title: "[Support] "
|
||||||
body:
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
#### Use of AI tools
|
||||||
|
|
||||||
|
If this post was generated, in whole or in part, by an AI tool or agent you **must** say so using the field below. Failing to clearly disclose this is a violation of our [Code of Conduct](https://github.com/paperless-ngx/paperless-ngx/blob/main/CODE_OF_CONDUCT.md).
|
||||||
|
- type: dropdown
|
||||||
|
id: ai-usage
|
||||||
|
attributes:
|
||||||
|
label: AI usage
|
||||||
|
description: Was any part of this post written by an AI tool or agent?
|
||||||
|
options:
|
||||||
|
- No AI tools were used
|
||||||
|
- Yes, AI tools helped write this post
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: description
|
id: description
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
@@ -1,53 +1,53 @@
|
|||||||
name: Install apt packages
|
name: Install apt packages
|
||||||
description: >
|
description: >
|
||||||
Installs apt packages with a per-attempt timeout and retries. The hosted runner's Azure apt mirror intermittently serves archives at a crawl, which apt's own timeout does not catch. A stuck attempt is killed and retried, which usually lands on a healthy mirror node.
|
Installs apt packages with bounded download retries and Ubuntu mirror fallback. Caches downloaded packages as an optimization; apt resolves dependencies on every run.
|
||||||
|
|
||||||
inputs:
|
inputs:
|
||||||
packages:
|
packages:
|
||||||
description: Space separated list of packages to install
|
description: Space separated list of package names to install
|
||||||
required: true
|
required: true
|
||||||
attempts:
|
attempts:
|
||||||
description: Maximum number of attempts
|
description: Maximum number of download attempts
|
||||||
default: "3"
|
default: "3"
|
||||||
attempt-timeout:
|
attempt-timeout:
|
||||||
description: Seconds before a single attempt is killed
|
description: Seconds before a single download attempt is killed
|
||||||
default: "180"
|
default: "180"
|
||||||
runs:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
steps:
|
steps:
|
||||||
|
- name: Identify apt cache
|
||||||
|
id: cache-key
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
PACKAGES: ${{ inputs.packages }}
|
||||||
|
run: |
|
||||||
|
. /etc/os-release
|
||||||
|
packages_hash=$(printf '%s' "$PACKAGES" | sha256sum | cut -d ' ' -f 1)
|
||||||
|
# Cached downloads are optional: apt fetches any missing/newer packages.
|
||||||
|
# Refresh weekly, including inside job containers without ImageVersion.
|
||||||
|
echo "key=apt-v2-${ID}-${VERSION_ID}-$(dpkg --print-architecture)-$(date -u +%G-%V)-${packages_hash}" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Restore apt packages
|
||||||
|
id: cache
|
||||||
|
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ runner.temp }}/apt-packages/*.deb
|
||||||
|
${{ runner.temp }}/apt-packages/.keep
|
||||||
|
key: ${{ steps.cache-key.outputs.key }}
|
||||||
- name: Install ${{ inputs.packages }}
|
- name: Install ${{ inputs.packages }}
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
PACKAGES: ${{ inputs.packages }}
|
PACKAGES: ${{ inputs.packages }}
|
||||||
ATTEMPTS: ${{ inputs.attempts }}
|
ATTEMPTS: ${{ inputs.attempts }}
|
||||||
ATTEMPT_TIMEOUT: ${{ inputs.attempt-timeout }}
|
ATTEMPT_TIMEOUT: ${{ inputs.attempt-timeout }}
|
||||||
run: |
|
CACHE_DIR: ${{ runner.temp }}/apt-packages
|
||||||
SUDO=""
|
run: bash "$GITHUB_ACTION_PATH/install.sh"
|
||||||
if [ "$(id -u)" -ne 0 ]; then
|
# Save immediately: a later test failure must not discard downloaded packages.
|
||||||
SUDO="sudo"
|
- name: Save apt packages
|
||||||
fi
|
if: steps.cache.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
attempt_install() {
|
with:
|
||||||
$SUDO apt-get update -q
|
path: |
|
||||||
# Intentionally unquoted, this is a list of package names
|
${{ runner.temp }}/apt-packages/*.deb
|
||||||
$SUDO apt-get install -q --yes --no-install-recommends $PACKAGES
|
${{ runner.temp }}/apt-packages/.keep
|
||||||
}
|
key: ${{ steps.cache-key.outputs.key }}
|
||||||
export -f attempt_install
|
|
||||||
export SUDO PACKAGES
|
|
||||||
|
|
||||||
for attempt in $(seq 1 "${ATTEMPTS}"); do
|
|
||||||
echo "::group::apt install attempt ${attempt}/${ATTEMPTS}"
|
|
||||||
start=$(date +%s)
|
|
||||||
if timeout --kill-after=10 "${ATTEMPT_TIMEOUT}" bash -c attempt_install; then
|
|
||||||
echo "::endgroup::"
|
|
||||||
echo "Installed in $(( $(date +%s) - start ))s"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "::endgroup::"
|
|
||||||
echo "::warning::apt install attempt ${attempt}/${ATTEMPTS} failed or exceeded ${ATTEMPT_TIMEOUT}s"
|
|
||||||
# A killed attempt can leave dpkg half configured
|
|
||||||
$SUDO dpkg --configure -a || true
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "::error::apt install failed after ${ATTEMPTS} attempts"
|
|
||||||
exit 1
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Run as root in the timed child process. Only downloads may be interrupted.
|
||||||
|
if [ "${1:-}" = download ]; then
|
||||||
|
download_archive_dir=$2
|
||||||
|
shift 2
|
||||||
|
apt-get update -q
|
||||||
|
# Keep apt itself under timeout supervision, including the kill grace period.
|
||||||
|
exec apt-get install -q --yes --no-install-recommends --download-only \
|
||||||
|
-o "Dir::Cache::archives=$download_archive_dir" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
SUDO=()
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
SUDO=(sudo)
|
||||||
|
fi
|
||||||
|
read -r -a requested_packages <<< "$PACKAGES"
|
||||||
|
mkdir -p "$CACHE_DIR"
|
||||||
|
touch "$CACHE_DIR/.keep"
|
||||||
|
|
||||||
|
use_ubuntu_mirrors() {
|
||||||
|
# Hosted runners use a mirror list; job containers can point directly at Azure.
|
||||||
|
# Handle both layouts, leaving other repositories and signature checks intact.
|
||||||
|
if [ -f /etc/apt/apt-mirrors.txt ]; then
|
||||||
|
"${SUDO[@]}" sed -i '\|azure\.archive\.ubuntu\.com|d' /etc/apt/apt-mirrors.txt
|
||||||
|
fi
|
||||||
|
for source in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do
|
||||||
|
if [ -f "$source" ]; then
|
||||||
|
"${SUDO[@]}" sed -i 's|azure\.archive\.ubuntu\.com|archive.ubuntu.com|g' "$source"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
for attempt in $(seq 1 "$ATTEMPTS"); do
|
||||||
|
echo "::group::apt download attempt ${attempt}/${ATTEMPTS}"
|
||||||
|
# Run timeout as root so it can signal the root-owned apt processes.
|
||||||
|
if "${SUDO[@]}" timeout --kill-after=10 "$ATTEMPT_TIMEOUT" bash "$0" download "$CACHE_DIR" "${requested_packages[@]}"; then
|
||||||
|
echo "::endgroup::"
|
||||||
|
# Apt handles aliases (e.g. libmagic1 -> libmagic1t64) and dependencies.
|
||||||
|
"${SUDO[@]}" apt-get install -q --yes --no-install-recommends --no-download \
|
||||||
|
-o "Dir::Cache::archives=$CACHE_DIR" "${requested_packages[@]}"
|
||||||
|
# Allow the unprivileged cache action to read archives downloaded as root.
|
||||||
|
find "$CACHE_DIR" -maxdepth 1 -name '*.deb' -exec "${SUDO[@]}" chmod a+r {} +
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "::endgroup::"
|
||||||
|
echo "::warning::apt download attempt ${attempt}/${ATTEMPTS} failed or exceeded ${ATTEMPT_TIMEOUT}s"
|
||||||
|
if [ "$attempt" -eq 1 ] && [ "$ATTEMPTS" -gt 1 ]; then
|
||||||
|
echo "Switching from Azure to Ubuntu archive mirrors for remaining attempts"
|
||||||
|
use_ubuntu_mirrors
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "::error::apt download failed after ${ATTEMPTS} attempts"
|
||||||
|
exit 1
|
||||||
@@ -77,6 +77,7 @@ jobs:
|
|||||||
- 'uv.lock'
|
- 'uv.lock'
|
||||||
- 'docker/compose/docker-compose.ci-test.yml'
|
- 'docker/compose/docker-compose.ci-test.yml'
|
||||||
- '.github/workflows/ci-backend.yml'
|
- '.github/workflows/ci-backend.yml'
|
||||||
|
- '.github/actions/apt-install/**'
|
||||||
test:
|
test:
|
||||||
needs: changes
|
needs: changes
|
||||||
if: needs.changes.outputs.backend_changed == 'true'
|
if: needs.changes.outputs.backend_changed == 'true'
|
||||||
@@ -99,11 +100,11 @@ jobs:
|
|||||||
docker compose --file docker/compose/docker-compose.ci-test.yml up --detach
|
docker compose --file docker/compose/docker-compose.ci-test.yml up --detach
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||||
with:
|
with:
|
||||||
python-version: "${{ matrix.python-version }}"
|
python-version: "${{ matrix.python-version }}"
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
@@ -169,11 +170,11 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||||
with:
|
with:
|
||||||
python-version: "${{ env.DEFAULT_PYTHON }}"
|
python-version: "${{ env.DEFAULT_PYTHON }}"
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -155,7 +155,7 @@ jobs:
|
|||||||
echo "${digest}" > "/tmp/digests/digest-${{ matrix.arch }}.txt"
|
echo "${digest}" > "/tmp/digests/digest-${{ matrix.arch }}.txt"
|
||||||
- name: Upload digest
|
- name: Upload digest
|
||||||
if: steps.check-push.outputs.should-push == 'true'
|
if: steps.check-push.outputs.should-push == 'true'
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||||
with:
|
with:
|
||||||
name: digests-${{ matrix.arch }}
|
name: digests-${{ matrix.arch }}
|
||||||
path: /tmp/digests/digest-${{ matrix.arch }}.txt
|
path: /tmp/digests/digest-${{ matrix.arch }}.txt
|
||||||
@@ -173,7 +173,7 @@ jobs:
|
|||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Download digests
|
- name: Download digests
|
||||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||||
with:
|
with:
|
||||||
path: /tmp/digests
|
path: /tmp/digests
|
||||||
pattern: digest-*.txt
|
pattern: digest-*.txt
|
||||||
@@ -185,20 +185,20 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
if: needs.build-arch.outputs.push-external == 'true'
|
if: needs.build-arch.outputs.push-external == 'true'
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Login to Quay.io
|
- name: Login to Quay.io
|
||||||
if: needs.build-arch.outputs.push-external == 'true'
|
if: needs.build-arch.outputs.push-external == 'true'
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||||
with:
|
with:
|
||||||
registry: quay.io
|
registry: quay.io
|
||||||
username: ${{ secrets.QUAY_USERNAME }}
|
username: ${{ secrets.QUAY_USERNAME }}
|
||||||
|
|||||||
@@ -75,11 +75,11 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ jobs:
|
|||||||
frontend:
|
frontend:
|
||||||
- 'src-ui/**'
|
- 'src-ui/**'
|
||||||
- '.github/workflows/ci-frontend.yml'
|
- '.github/workflows/ci-frontend.yml'
|
||||||
|
- '.github/actions/apt-install/**'
|
||||||
install-dependencies:
|
install-dependencies:
|
||||||
needs: changes
|
needs: changes
|
||||||
if: needs.changes.outputs.frontend_changed == 'true'
|
if: needs.changes.outputs.frontend_changed == 'true'
|
||||||
@@ -86,7 +87,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
package_json_file: src-ui/package.json
|
package_json_file: src-ui/package.json
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -118,7 +119,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
package_json_file: src-ui/package.json
|
package_json_file: src-ui/package.json
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -157,7 +158,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
package_json_file: src-ui/package.json
|
package_json_file: src-ui/package.json
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -206,18 +207,18 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
package_json_file: src-ui/package.json
|
package_json_file: src-ui/package.json
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
|
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.12'
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
|
||||||
with:
|
with:
|
||||||
version: '0.13.x'
|
version: '0.13.x'
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
@@ -261,7 +262,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
package_json_file: src-ui/package.json
|
package_json_file: src-ui/package.json
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.14"
|
python-version: "3.14"
|
||||||
- name: Run prek
|
- name: Run prek
|
||||||
uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0
|
uses: j178/prek-action@44924fe9ef4a6f88253c4b6b1bf11c689d3ce53d # v3.1.0
|
||||||
@@ -44,7 +44,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
package_json_file: src-ui/package.json
|
package_json_file: src-ui/package.json
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
package-manager-cache: false
|
package-manager-cache: false
|
||||||
@@ -55,11 +55,11 @@ jobs:
|
|||||||
# ---- Backend Setup ----
|
# ---- Backend Setup ----
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
@@ -137,7 +137,7 @@ jobs:
|
|||||||
sudo chown -R 1000:1000 paperless-ngx/
|
sudo chown -R 1000:1000 paperless-ngx/
|
||||||
tar -cJf paperless-ngx.tar.xz paperless-ngx/
|
tar -cJf paperless-ngx.tar.xz paperless-ngx/
|
||||||
- name: Upload release artifact
|
- name: Upload release artifact
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||||
with:
|
with:
|
||||||
name: release
|
name: release
|
||||||
path: dist/paperless-ngx.tar.xz
|
path: dist/paperless-ngx.tar.xz
|
||||||
@@ -155,7 +155,7 @@ jobs:
|
|||||||
version: ${{ steps.get-version.outputs.version }}
|
version: ${{ steps.get-version.outputs.version }}
|
||||||
steps:
|
steps:
|
||||||
- name: Download release artifact
|
- name: Download release artifact
|
||||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||||
with:
|
with:
|
||||||
name: release
|
name: release
|
||||||
path: ./
|
path: ./
|
||||||
@@ -172,7 +172,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
- name: Create release and changelog
|
- name: Create release and changelog
|
||||||
id: create-release
|
id: create-release
|
||||||
uses: release-drafter/release-drafter@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7.7.0
|
uses: release-drafter/release-drafter@978bb0b30c5ee8cdd332ced253758157150d43f0 # v7.10.0
|
||||||
with:
|
with:
|
||||||
name: Paperless-ngx ${{ steps.get-version.outputs.version }}
|
name: Paperless-ngx ${{ steps.get-version.outputs.version }}
|
||||||
tag: ${{ steps.get-version.outputs.version }}
|
tag: ${{ steps.get-version.outputs.version }}
|
||||||
@@ -183,7 +183,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Upload release archive
|
- name: Upload release archive
|
||||||
uses: shogo82148/actions-upload-release-asset@aaba0f56bdbc1071f4af234d5cb16055e8a400de # v1.10.4
|
uses: shogo82148/actions-upload-release-asset@73f83b114970a3485e1423ecc1fb93d887c9b096 # v1.10.7
|
||||||
with:
|
with:
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
upload_url: ${{ steps.create-release.outputs.upload_url }}
|
upload_url: ${{ steps.create-release.outputs.upload_url }}
|
||||||
@@ -209,11 +209,11 @@ jobs:
|
|||||||
persist-credentials: true # for pushing changelog branch
|
persist-credentials: true # for pushing changelog branch
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Run zizmor
|
- name: Run zizmor
|
||||||
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
|
uses: zizmorcore/zizmor-action@53290badfa369cb0bf7ad16342cb62c2fa47ac7b # v0.8.0
|
||||||
semgrep:
|
semgrep:
|
||||||
name: Semgrep CE
|
name: Semgrep CE
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-26.04
|
||||||
@@ -45,7 +45,7 @@ jobs:
|
|||||||
- name: Run Semgrep
|
- name: Run Semgrep
|
||||||
run: semgrep scan --config auto --sarif-output results.sarif
|
run: semgrep scan --config auto --sarif-output results.sarif
|
||||||
- name: Upload results to GitHub code scanning
|
- name: Upload results to GitHub code scanning
|
||||||
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
sarif_file: results.sarif
|
sarif_file: results.sarif
|
||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Clean temporary images
|
- name: Clean temporary images
|
||||||
if: "${{ env.TOKEN != '' }}"
|
if: "${{ env.TOKEN != '' }}"
|
||||||
uses: stumpylog/image-cleaner-action/ephemeral@21f875bab2376314e0525c614e923c669434f0e4 # v0.13.0
|
uses: stumpylog/image-cleaner-action/ephemeral@9796867b2a7994f0a3b6b1f2e38a6579f502c977 # v0.14.0
|
||||||
with:
|
with:
|
||||||
token: "${{ env.TOKEN }}"
|
token: "${{ env.TOKEN }}"
|
||||||
owner: "${{ github.repository_owner }}"
|
owner: "${{ github.repository_owner }}"
|
||||||
@@ -56,7 +56,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Clean untagged images
|
- name: Clean untagged images
|
||||||
if: "${{ env.TOKEN != '' }}"
|
if: "${{ env.TOKEN != '' }}"
|
||||||
uses: stumpylog/image-cleaner-action/untagged@21f875bab2376314e0525c614e923c669434f0e4 # v0.13.0
|
uses: stumpylog/image-cleaner-action/untagged@9796867b2a7994f0a3b6b1f2e38a6579f502c977 # v0.14.0
|
||||||
with:
|
with:
|
||||||
token: "${{ env.TOKEN }}"
|
token: "${{ env.TOKEN }}"
|
||||||
owner: "${{ github.repository_owner }}"
|
owner: "${{ github.repository_owner }}"
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
@@ -47,4 +47,4 @@ jobs:
|
|||||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
token: ${{ secrets.PNGX_BOT_PAT }}
|
token: ${{ secrets.PNGX_BOT_PAT }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: crowdin action
|
- name: crowdin action
|
||||||
uses: crowdin/github-action@9c23991700c0ec5256fd41089b9d9d7d540e424e # v3.3.0
|
uses: crowdin/github-action@959dc74ac104447731cba99a016f85d0ae719788 # v3.5.0
|
||||||
with:
|
with:
|
||||||
upload_translations: false
|
upload_translations: false
|
||||||
download_translations: true
|
download_translations: true
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ jobs:
|
|||||||
if: github.event_name == 'pull_request_target' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login != 'dependabot'
|
if: github.event_name == 'pull_request_target' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login != 'dependabot'
|
||||||
steps:
|
steps:
|
||||||
- name: Label PR with release-drafter
|
- name: Label PR with release-drafter
|
||||||
uses: release-drafter/release-drafter@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7.7.0
|
uses: release-drafter/release-drafter@978bb0b30c5ee8cdd332ced253758157150d43f0 # v7.10.0
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -23,14 +23,14 @@ jobs:
|
|||||||
persist-credentials: true # for pushing translation branch
|
persist-credentials: true # for pushing translation branch
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||||
- name: Install system dependencies
|
- name: Install system dependencies
|
||||||
timeout-minutes: 12
|
timeout-minutes: 12
|
||||||
uses: $/.github/actions/apt-install
|
uses: $/.github/actions/apt-install
|
||||||
with:
|
with:
|
||||||
packages: gettext
|
packages: gettext
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
@@ -48,7 +48,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
package_json_file: src-ui/package.json
|
package_json_file: src-ui/package.json
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -68,7 +68,7 @@ jobs:
|
|||||||
cd src-ui
|
cd src-ui
|
||||||
pnpm run ng extract-i18n
|
pnpm run ng extract-i18n
|
||||||
- name: Commit changes
|
- name: Commit changes
|
||||||
uses: stefanzweifel/git-auto-commit-action@4a55954c782fc1ea30b9056cd3e7a2b40ca8887d # v7.2.0
|
uses: stefanzweifel/git-auto-commit-action@d0b01107b5db882071f96de5dd4b54961ea93f4c # v7.2.1
|
||||||
with:
|
with:
|
||||||
file_pattern: 'src-ui/messages.xlf src/locale/en_US/LC_MESSAGES/django.po'
|
file_pattern: 'src-ui/messages.xlf src/locale/en_US/LC_MESSAGES/django.po'
|
||||||
commit_message: "Auto translate strings"
|
commit_message: "Auto translate strings"
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ repos:
|
|||||||
- json
|
- json
|
||||||
# See https://github.com/prettier/prettier/issues/15742 for the fork reason
|
# See https://github.com/prettier/prettier/issues/15742 for the fork reason
|
||||||
- repo: https://github.com/rbubley/mirrors-prettier
|
- repo: https://github.com/rbubley/mirrors-prettier
|
||||||
rev: 'v3.9.9'
|
rev: 'v3.9.10'
|
||||||
hooks:
|
hooks:
|
||||||
- id: prettier
|
- id: prettier
|
||||||
types_or:
|
types_or:
|
||||||
@@ -46,16 +46,16 @@ repos:
|
|||||||
- ts
|
- ts
|
||||||
- markdown
|
- markdown
|
||||||
additional_dependencies:
|
additional_dependencies:
|
||||||
- prettier@3.9.9
|
- prettier@3.9.10
|
||||||
- 'prettier-plugin-organize-imports@4.3.0'
|
- 'prettier-plugin-organize-imports@4.3.0'
|
||||||
# Python hooks
|
# Python hooks
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.16.9
|
rev: v0.17.2
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff-check
|
- id: ruff-check
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
- repo: https://github.com/tox-dev/pyproject-fmt
|
- repo: https://github.com/tox-dev/pyproject-fmt
|
||||||
rev: "v2.29.4"
|
rev: "v2.30.3"
|
||||||
hooks:
|
hooks:
|
||||||
- id: pyproject-fmt
|
- id: pyproject-fmt
|
||||||
additional_dependencies: [tomli]
|
additional_dependencies: [tomli]
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Guidance for AI agents
|
||||||
|
|
||||||
|
Read [CONTRIBUTING.md](CONTRIBUTING.md) before working on this repository, especially [Use of AI Tools](CONTRIBUTING.md#use-of-ai-tools). These contribution rules apply to all agent-assisted work.
|
||||||
|
|
||||||
|
## Contribution boundaries
|
||||||
|
|
||||||
|
- Do not open or submit pull requests, including "on behalf of" a human. The project does not accept PRs from AI agents. Prepare local changes for a human to review, verify, and submit personally.
|
||||||
|
- The project does not accept PRs that are entirely or mostly AI-derived. Human submission and disclosure do not override this rule.
|
||||||
|
- AI-generated content must be clearly disclosed as described in the contributing guidelines. When handing work back to a human, identify what was generated or changed with AI assistance so they can disclose it accurately.
|
||||||
|
- Trust matters. Encourage first-time contributors to discuss their proposed change before preparing a PR, especially for large changes or changes involving AI-generated code. New features and enhancements should almost always target an existing feature request with community interest and discussion.
|
||||||
|
- The project prefers thoughtful, well-described ideas to AI-generated pull requests. Help contributors describe their problem, use case, desired behavior, and who would benefit. An implementation is not required for a valuable contribution, and a flawed implementation can obscure a useful idea before it receives proper discussion.
|
||||||
|
- AI tools have lowered the effort needed to produce a PR, but reviewing and maintaining it still requires substantial work. Encourage discussion of the idea before investing in implementation and review.
|
||||||
|
- Issue reports must describe observed behavior, reproduction steps, and relevant logs. Do not add speculative root causes, code analysis, or proposed fixes to agent-generated reports.
|
||||||
|
|
||||||
|
## Security reports
|
||||||
|
|
||||||
|
- AI-assisted security reports must meet the legitimacy and verification requirements in [SECURITY.md](SECURITY.md#ai-assisted-submissions). A human must verify a real Paperless-ngx code path, concrete reproduction steps, affected versions, and realistic security impact before submission.
|
||||||
|
- Account for existing permission checks, configuration requirements, and trust boundaries. Speculation, scanner output, or concerns about third-party software or deployment choices without a demonstrated Paperless-ngx vulnerability do not meet this threshold and may be closed without extended discussion.
|
||||||
|
- Disclose AI assistance and use the private vulnerability reporting channel specified in [SECURITY.md](SECURITY.md#reporting-a-vulnerability).
|
||||||
|
|
||||||
|
## Working on changes
|
||||||
|
|
||||||
|
- Read the relevant code, tests, and documentation before editing. Paperless-ngx is complex: trace interactions between affected components rather than assuming a change is isolated.
|
||||||
|
- Keep changes focused on the requested work and follow existing patterns. Avoid unrelated refactoring, formatting, dependency updates, or generated-file changes.
|
||||||
|
- Consider effects on permissions, document processing, background tasks, storage, and the API when those areas are involved.
|
||||||
|
- Add or update meaningful tests for behavior changes and regressions. Do not weaken tests or checks to make a change pass.
|
||||||
|
- Update user or developer documentation when behavior or setup changes.
|
||||||
|
- Keep credentials and private documents out of commits, fixtures, logs, and examples.
|
||||||
|
|
||||||
|
## Repository and validation
|
||||||
|
|
||||||
|
- `src/`: Django back end.
|
||||||
|
- `src-ui/`: Angular front end, managed with `pnpm`.
|
||||||
|
- `docs/`: Documentation.
|
||||||
|
- `docker/` and `scripts/`: Deployment and development tooling.
|
||||||
|
- Base functional changes on `dev`; documentation-only changes may target `main`.
|
||||||
|
|
||||||
|
Follow [the development documentation](docs/development.md) for environment setup and testing. Common checks after setup are:
|
||||||
|
|
||||||
|
- Back end: run `uv run pytest` from `src/` (use a test path to focus on the affected area).
|
||||||
|
- Front end: run `pnpm test` and `pnpm lint` from `src-ui/`.
|
||||||
|
- Formatting and other pre-commit checks: run `uv run prek run --files <changed-files>` from the repository root.
|
||||||
|
|
||||||
|
When handing work back, summarize the changes, the checks actually run and their results, and any remaining uncertainty. Clearly state when a check could not be run; do not claim verification that did not happen. The human contributor must understand and independently verify the contribution before submitting it.
|
||||||
+16
-3
@@ -4,6 +4,18 @@ If you feel like contributing to the project, please do! Bug fixes and improveme
|
|||||||
|
|
||||||
⚠️ Please note: Pull requests that implement a new feature or enhancement _should almost always target an existing feature request_ with evidence of community interest and discussion. This is in order to balance the work of implementing and maintaining new features / enhancements. Pull requests that are opened without meeting this requirement may not be merged.
|
⚠️ Please note: Pull requests that implement a new feature or enhancement _should almost always target an existing feature request_ with evidence of community interest and discussion. This is in order to balance the work of implementing and maintaining new features / enhancements. Pull requests that are opened without meeting this requirement may not be merged.
|
||||||
|
|
||||||
|
## Trust and collaboration
|
||||||
|
|
||||||
|
Paperless-ngx places a high value on trust between contributors and maintainers. If you have not contributed before, consider starting with a discussion before preparing a pull request, especially for a large contribution or one involving AI-generated code.
|
||||||
|
|
||||||
|
Paperless-ngx is complex, and understanding how its components interact takes time. A change that appears correct in isolation can have consequences elsewhere in the application. Contributors should understand the code they submit, explain how it fits into the project, and work with maintainers through review. Starting with smaller, focused contributions can help build that understanding and trust.
|
||||||
|
|
||||||
|
We prefer thoughtful, well-described ideas to AI-generated pull requests. You do not need to supply an implementation for your contribution to be valuable. Explain the problem you have encountered, your use case, the behavior you would like to see, and who would benefit. Concrete examples, alternatives you have considered, and relevant constraints help the community discuss and refine the idea.
|
||||||
|
|
||||||
|
AI tools have lowered the effort needed to produce a pull request, but evaluating one still requires substantial maintainer time. Maintainers still need to establish whether the implementation is correct, fits the project, and can be maintained over time.
|
||||||
|
|
||||||
|
A flawed implementation can also obscure a good idea: discussion becomes focused on problems with the generated code before the community has considered whether the underlying proposal is useful. Starting with the idea gives it a fair discussion on its own merits and lets contributors and maintainers agree on an approach before investing in implementation and review.
|
||||||
|
|
||||||
If you want to implement something big:
|
If you want to implement something big:
|
||||||
|
|
||||||
- As above, please start with a discussion! Maybe something similar is already in development and we can make it happen together.
|
- As above, please start with a discussion! Maybe something similar is already in development and we can make it happen together.
|
||||||
@@ -61,9 +73,10 @@ This project does not specifically prohibit the use of AI tools or agents _durin
|
|||||||
|
|
||||||
1. Any content — code, pull request descriptions, issue reports or comments — that was generated in whole or in part by an AI tool or agent **must be clearly disclosed as such**. Failing to clearly disclose this is considered a violation of our [Code of Conduct](CODE_OF_CONDUCT.md) and may result in your contributions being closed and further action being taken.
|
1. Any content — code, pull request descriptions, issue reports or comments — that was generated in whole or in part by an AI tool or agent **must be clearly disclosed as such**. Failing to clearly disclose this is considered a violation of our [Code of Conduct](CODE_OF_CONDUCT.md) and may result in your contributions being closed and further action being taken.
|
||||||
2. AI-generated code must not violate copyright protections.
|
2. AI-generated code must not violate copyright protections.
|
||||||
3. We will not accept PRs that are entirely or mostly AI-derived.
|
3. We will not accept PRs opened or submitted by AI agents, including agents acting "on behalf of" a human. A human contributor must personally review, verify, and submit the contribution and take responsibility for it throughout review.
|
||||||
4. Issue reports must describe _observed behavior only_: what happened, how to reproduce it and the relevant logs. Reports generated by an AI agent should not include code analysis, speculation about the root cause or suggest fixes.
|
4. We will not accept PRs that are entirely or mostly AI-derived.
|
||||||
5. Contributors are responsible for everything they submit, regardless of how it was produced. Please do not open issues or PRs you have not verified yourself.
|
5. Issue reports must describe _observed behavior only_: what happened, how to reproduce it and the relevant logs. Reports generated by an AI agent should not include code analysis, speculation about the root cause or suggest fixes.
|
||||||
|
6. Contributors are responsible for everything they submit, regardless of how it was produced. Please do not open issues or PRs you have not verified yourself.
|
||||||
|
|
||||||
# Translating Paperless-ngx
|
# Translating Paperless-ngx
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@ RUN set -eux \
|
|||||||
# Purpose: Installs s6-overlay and rootfs
|
# Purpose: Installs s6-overlay and rootfs
|
||||||
# Comments:
|
# Comments:
|
||||||
# - Don't leave anything extra in here either
|
# - Don't leave anything extra in here either
|
||||||
FROM ghcr.io/astral-sh/uv:0.13.5-python3.14-trixie-slim@sha256:d8e732839dfc4225a1ff698803b478b2223c05636c055d3add41ab9dbec0f315 AS s6-overlay-base
|
FROM ghcr.io/astral-sh/uv:0.13.7-python3.14-trixie-slim@sha256:e80c05a310400cfc90af00b5b7271ef9ad24816e3265e1162af8a678e1aad099 AS s6-overlay-base
|
||||||
|
|
||||||
WORKDIR /usr/src/s6
|
WORKDIR /usr/src/s6
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -56,6 +56,9 @@ We may close reports that are:
|
|||||||
|
|
||||||
The following are not generally considered vulnerabilities unless accompanied by a concrete, reproducible impact in Paperless-ngx:
|
The following are not generally considered vulnerabilities unless accompanied by a concrete, reproducible impact in Paperless-ngx:
|
||||||
|
|
||||||
|
- password length inferred from masked mail-account passwords by users who already have permission to view the account, without disclosure of the password or token itself
|
||||||
|
- disclosure limited to document IDs, existence, page counts, checksums, or modification/deletion timestamps, without access to document contents or an unintended privileged action
|
||||||
|
- authenticated access to non-secret application settings, such as service endpoint URLs, model names, or configured environment variable names, without disclosure of credentials or secret values
|
||||||
- large uploads or resource usage that do not bypass documented limits or privileges
|
- large uploads or resource usage that do not bypass documented limits or privileges
|
||||||
- IDOR / access control claims regarding the ability to attach an un-viewable object to a document. This is expected behavior.
|
- IDOR / access control claims regarding the ability to attach an un-viewable object to a document. This is expected behavior.
|
||||||
- claims based solely on the presence of a library, framework feature or code pattern without a working exploit
|
- claims based solely on the presence of a library, framework feature or code pattern without a working exploit
|
||||||
@@ -66,7 +69,7 @@ The following are not generally considered vulnerabilities unless accompanied by
|
|||||||
- optional webhook, mail, AI, OCR, or integration behavior described without a product-level vulnerability
|
- optional webhook, mail, AI, OCR, or integration behavior described without a product-level vulnerability
|
||||||
- missing limits or hardening settings presented without concrete impact
|
- missing limits or hardening settings presented without concrete impact
|
||||||
- generic AI or static-analysis output that is not confirmed against the current codebase and a real deployment scenario
|
- generic AI or static-analysis output that is not confirmed against the current codebase and a real deployment scenario
|
||||||
- metadata names visible in a document's custom storage path, even when the user cannot access the underlying metadata object; this is expected behavior
|
- metadata names or normalized words visible in a document's custom storage path or through search/autocomplete for documents the user can access, even when the user cannot access the underlying metadata object; this is expected behavior
|
||||||
- the ability to attach objects that a user cannot access to a document by ID is an intentional design choice, and not considered a vulnerability
|
- the ability to attach objects that a user cannot access to a document by ID is an intentional design choice, and not considered a vulnerability
|
||||||
|
|
||||||
## Transparency
|
## Transparency
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ declare -a scope=(
|
|||||||
"${site_packages}/allauth"
|
"${site_packages}/allauth"
|
||||||
"${site_packages}/drf_spectacular"
|
"${site_packages}/drf_spectacular"
|
||||||
"${site_packages}/drf_spectacular_sidecar"
|
"${site_packages}/drf_spectacular_sidecar"
|
||||||
"${site_packages}/treenode"
|
|
||||||
"${site_packages}/compression_middleware"
|
"${site_packages}/compression_middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -565,6 +565,8 @@ Specify `rebuild` to build the index from scratch from all documents in the data
|
|||||||
this the first time you enable the feature, or after changing the embedding backend or
|
this the first time you enable the feature, or after changing the embedding backend or
|
||||||
model.
|
model.
|
||||||
|
|
||||||
|
When using the `huggingface` embedding backend, a model that is already downloaded is loaded from the local cache without contacting the Hugging Face Hub. Only `rebuild` checks the Hub for a newer revision of the model, so a model update is picked up together with re-embedding every document. If the model is not cached yet, it is downloaded on first use.
|
||||||
|
|
||||||
Specify `update` to incrementally index new and changed documents. This is what the
|
Specify `update` to incrementally index new and changed documents. This is what the
|
||||||
scheduled task runs.
|
scheduled task runs.
|
||||||
|
|
||||||
|
|||||||
+22
-11
@@ -501,20 +501,20 @@ See the [Django project documentation on the settings](https://docs.djangoprojec
|
|||||||
|
|
||||||
#### [`PAPERLESS_ALLOWED_HOSTS=<comma-separated-list>`](#PAPERLESS_ALLOWED_HOSTS) {#PAPERLESS_ALLOWED_HOSTS}
|
#### [`PAPERLESS_ALLOWED_HOSTS=<comma-separated-list>`](#PAPERLESS_ALLOWED_HOSTS) {#PAPERLESS_ALLOWED_HOSTS}
|
||||||
|
|
||||||
: If you're planning on putting Paperless on the open internet, then
|
: If you expose Paperless to the internet, set `PAPERLESS_URL` to its public URL
|
||||||
you really should set this value to the domain name you're using.
|
or list its public hostname in `PAPERLESS_ALLOWED_HOSTS` to help protect against
|
||||||
Failing to do so leaves you open to HTTP host header attacks.
|
HTTP Host header attacks.
|
||||||
You can read more about this in [the Django project's documentation](https://docs.djangoproject.com/en/4.1/topics/security/#host-header-validation)
|
You can read more about this in [the Django project's documentation](https://docs.djangoproject.com/en/4.1/topics/security/#host-header-validation)
|
||||||
|
|
||||||
Just remember that this is a comma-separated list, so
|
Separate multiple hostnames with commas, as in `example.com,www.example.com`.
|
||||||
"example.com" is fine, as is "example.com,www.example.com", but
|
|
||||||
NOT " example.com" or "example.com,"
|
|
||||||
|
|
||||||
Can also be set using PAPERLESS_URL (see above).
|
- With neither setting configured, Paperless accepts any host.
|
||||||
|
- With only `PAPERLESS_URL` set, Paperless accepts the hostname from that URL
|
||||||
"localhost" is always allowed for docker healthcheck
|
and `localhost` (used by the Docker health check).
|
||||||
|
- `PAPERLESS_ALLOWED_HOSTS` adds hosts to `localhost` and the hostname from
|
||||||
Defaults to "\*", which is all hosts.
|
`PAPERLESS_URL`, if set. A blank list adds no hosts.
|
||||||
|
- With `PAPERLESS_ALLOWED_HOSTS=*`, Paperless accepts any host, even when
|
||||||
|
`PAPERLESS_URL` is set.
|
||||||
|
|
||||||
#### [`PAPERLESS_CORS_ALLOWED_HOSTS=<comma-separated-list>`](#PAPERLESS_CORS_ALLOWED_HOSTS) {#PAPERLESS_CORS_ALLOWED_HOSTS}
|
#### [`PAPERLESS_CORS_ALLOWED_HOSTS=<comma-separated-list>`](#PAPERLESS_CORS_ALLOWED_HOSTS) {#PAPERLESS_CORS_ALLOWED_HOSTS}
|
||||||
|
|
||||||
@@ -873,6 +873,14 @@ If both the [PAPERLESS_ACCOUNT_DEFAULT_GROUPS](#PAPERLESS_ACCOUNT_DEFAULT_GROUPS
|
|||||||
|
|
||||||
Defaults to 1209600 (2 weeks)
|
Defaults to 1209600 (2 weeks)
|
||||||
|
|
||||||
|
#### [`PAPERLESS_SESSION_CLEANUP_TASK_CRON=<cron expression>`](#PAPERLESS_SESSION_CLEANUP_TASK_CRON) {#PAPERLESS_SESSION_CLEANUP_TASK_CRON}
|
||||||
|
|
||||||
|
: Configures the schedule to remove expired sessions from the session store.
|
||||||
|
Django does not prune these automatically, so without this they would
|
||||||
|
accumulate indefinitely.
|
||||||
|
|
||||||
|
Defaults to `0 3 * * *`, once per day at 03:00.
|
||||||
|
|
||||||
#### [`PAPERLESS_TOKEN_THROTTLE_RATE=<rate>`](#PAPERLESS_TOKEN_THROTTLE_RATE) {#PAPERLESS_TOKEN_THROTTLE_RATE}
|
#### [`PAPERLESS_TOKEN_THROTTLE_RATE=<rate>`](#PAPERLESS_TOKEN_THROTTLE_RATE) {#PAPERLESS_TOKEN_THROTTLE_RATE}
|
||||||
|
|
||||||
: Rate limit for the API token authentication endpoint (`/api/token/`), used to mitigate brute-force login attempts.
|
: Rate limit for the API token authentication endpoint (`/api/token/`), used to mitigate brute-force login attempts.
|
||||||
@@ -1149,6 +1157,9 @@ they use underscores instead of dashes.
|
|||||||
be changed by trusted users. This applies to the `AppConfig` permission as well,
|
be changed by trusted users. This applies to the `AppConfig` permission as well,
|
||||||
which allows setting these arguments from the UI.
|
which allows setting these arguments from the UI.
|
||||||
|
|
||||||
|
Arguments paperless manages itself (`input_file_or_options`, `output_file`
|
||||||
|
and `sidecar`) are rejected by the API and ignored if set here.
|
||||||
|
|
||||||
Specify arguments as a JSON dictionary. Keep note of lower case
|
Specify arguments as a JSON dictionary. Keep note of lower case
|
||||||
booleans and double quoted parameter names and strings. Examples:
|
booleans and double quoted parameter names and strings. Examples:
|
||||||
|
|
||||||
|
|||||||
+112
-6
@@ -109,7 +109,7 @@ Think of versions as **file history** for a document.
|
|||||||
Paperless-ngx includes management lists for tags, correspondents, document types
|
Paperless-ngx includes management lists for tags, correspondents, document types
|
||||||
and more. These areas allow you to view, add, edit, delete and manage permissions
|
and more. These areas allow you to view, add, edit, delete and manage permissions
|
||||||
for these objects. You can also manage saved views, mail accounts, mail rules,
|
for these objects. You can also manage saved views, mail accounts, mail rules,
|
||||||
workflows and more from the management sections.
|
workflows, [OCR templates](#ocr-templates) and more from the management sections.
|
||||||
|
|
||||||
### Nested Tags
|
### Nested Tags
|
||||||
|
|
||||||
@@ -493,7 +493,7 @@ fields and permissions, which will be merged.
|
|||||||
|
|
||||||
#### Types {#workflow-trigger-types}
|
#### Types {#workflow-trigger-types}
|
||||||
|
|
||||||
Currently, there are four events that correspond to workflow trigger 'types':
|
Currently, there are five events that correspond to workflow trigger 'types':
|
||||||
|
|
||||||
1. **Consumption Started**: _before_ a document is consumed, so events can include filters by source (mail, consumption
|
1. **Consumption Started**: _before_ a document is consumed, so events can include filters by source (mail, consumption
|
||||||
folder or API), file path, file name, mail rule
|
folder or API), file path, file name, mail rule
|
||||||
@@ -505,8 +505,10 @@ Currently, there are four events that correspond to workflow trigger 'types':
|
|||||||
4. **Scheduled**: a scheduled trigger that can be used to run workflows at a specific time. The date used can be either the document
|
4. **Scheduled**: a scheduled trigger that can be used to run workflows at a specific time. The date used can be either the document
|
||||||
added, created, updated date or you can specify a (date) custom field. You can also specify a day offset from the date (positive
|
added, created, updated date or you can specify a (date) custom field. You can also specify a day offset from the date (positive
|
||||||
offsets will trigger after the date, negative offsets will trigger before).
|
offsets will trigger after the date, negative offsets will trigger before).
|
||||||
|
5. **Manual**: a trigger that only runs when you explicitly choose to run the workflow against one or more existing documents. See
|
||||||
|
[running a workflow manually](#running-a-workflow-manually) below.
|
||||||
|
|
||||||
The following flow diagram illustrates the four document trigger types:
|
The following flow diagram illustrates the document trigger types:
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
@@ -526,6 +528,10 @@ flowchart TD
|
|||||||
matching
|
matching
|
||||||
trigger(s)"}
|
trigger(s)"}
|
||||||
|
|
||||||
|
manual{"Matching
|
||||||
|
'Manual'
|
||||||
|
trigger(s)"}
|
||||||
|
|
||||||
A[New Document] --> consumption
|
A[New Document] --> consumption
|
||||||
consumption --> |Yes| C[Workflow Actions Run]
|
consumption --> |Yes| C[Workflow Actions Run]
|
||||||
consumption --> |No| D
|
consumption --> |No| D
|
||||||
@@ -543,6 +549,10 @@ flowchart TD
|
|||||||
scheduled --> |Yes| N[Workflow Actions Run]
|
scheduled --> |Yes| N[Workflow Actions Run]
|
||||||
scheduled --> |No| O[Document Saved]
|
scheduled --> |No| O[Document Saved]
|
||||||
N --> O
|
N --> O
|
||||||
|
P[User Runs Workflow] --> manual
|
||||||
|
manual --> |Yes| Q[Workflow Actions Run]
|
||||||
|
manual --> |No| R[Document Saved]
|
||||||
|
Q --> R
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Filters {#workflow-trigger-filters}
|
#### Filters {#workflow-trigger-filters}
|
||||||
@@ -554,9 +564,9 @@ Workflows allow you to filter by:
|
|||||||
- File path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for
|
- File path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for
|
||||||
example, automatically assigning documents to different owners based on the upload directory.
|
example, automatically assigning documents to different owners based on the upload directory.
|
||||||
- Mail rule. Choosing this option will force 'mail fetch' to be the workflow source.
|
- Mail rule. Choosing this option will force 'mail fetch' to be the workflow source.
|
||||||
- Content matching (`Added`, `Updated` and `Scheduled` triggers only). Filter document content using the matching settings.
|
- Content matching (`Added`, `Updated`, `Scheduled` and `Manual` triggers only). Filter document content using the matching settings.
|
||||||
|
|
||||||
There are also 'advanced' filters available for `Added`, `Updated` and `Scheduled` triggers:
|
There are also 'advanced' filters available for `Added`, `Updated`, `Scheduled` and `Manual` triggers:
|
||||||
|
|
||||||
- Any Tags: Filter for documents with any of the specified tags.
|
- Any Tags: Filter for documents with any of the specified tags.
|
||||||
- All Tags: Filter for documents with all of the specified tags.
|
- All Tags: Filter for documents with all of the specified tags.
|
||||||
@@ -569,6 +579,21 @@ There are also 'advanced' filters available for `Added`, `Updated` and `Schedule
|
|||||||
- Not Storage paths: Filter documents without any of these storage paths.
|
- Not Storage paths: Filter documents without any of these storage paths.
|
||||||
- Custom field query: Filter documents with a custom field query (the same as used for the document list filters).
|
- Custom field query: Filter documents with a custom field query (the same as used for the document list filters).
|
||||||
|
|
||||||
|
#### Running a workflow manually {#running-a-workflow-manually}
|
||||||
|
|
||||||
|
A workflow with a **Manual** trigger does not run automatically. Instead, it can be run on demand against existing
|
||||||
|
documents from the web UI:
|
||||||
|
|
||||||
|
- From the document list, select one or more documents and choose **Run workflow** from the "Actions" menu.
|
||||||
|
- From an individual document, choose **Run workflow** from the "Actions" menu.
|
||||||
|
|
||||||
|
You will be asked which workflow to run. By default the workflow's trigger filters still apply, so documents that do
|
||||||
|
not match are skipped. You can instead choose to **run on all selected documents**, which ignores the trigger filters
|
||||||
|
and runs the workflow's actions on every selected document.
|
||||||
|
|
||||||
|
Manual runs happen in the background. As with scheduled workflows, the document list is not refreshed automatically when
|
||||||
|
they finish, so you may need to reload the list to see the results.
|
||||||
|
|
||||||
### Workflow Actions
|
### Workflow Actions
|
||||||
|
|
||||||
#### Types {#workflow-action-types}
|
#### Types {#workflow-action-types}
|
||||||
@@ -649,7 +674,7 @@ new version of the same document, this time with normal OCR/text extraction appl
|
|||||||
a password-protected file added with this trigger will briefly exist as an un-OCR'd version before
|
a password-protected file added with this trigger will briefly exist as an un-OCR'd version before
|
||||||
the properly processed version is created.
|
the properly processed version is created.
|
||||||
|
|
||||||
**Document Added**, **Document Updated**, **Scheduled**: these triggers run against a document that
|
**Document Added**, **Document Updated**, **Scheduled**, **Manual**: these triggers run against a document that
|
||||||
already exists, so password removal happens immediately: the decrypted content is queued for
|
already exists, so password removal happens immediately: the decrypted content is queued for
|
||||||
consumption as a new version right away. Note that if the document's initial consumption also
|
consumption as a new version right away. Note that if the document's initial consumption also
|
||||||
happened while it was still encrypted, that original version will likewise be missing OCR text.
|
happened while it was still encrypted, that original version will likewise be missing OCR text.
|
||||||
@@ -795,6 +820,87 @@ The following custom field types are supported:
|
|||||||
- `Document Link`: reference(s) to other document(s) displayed as links, automatically creates a symmetrical link in reverse
|
- `Document Link`: reference(s) to other document(s) displayed as links, automatically creates a symmetrical link in reverse
|
||||||
- `Select`: a pre-defined list of strings from which the user can choose
|
- `Select`: a pre-defined list of strings from which the user can choose
|
||||||
|
|
||||||
|
## OCR Templates {#ocr-templates}
|
||||||
|
|
||||||
|
OCR templates read text from fixed areas ("zones") of a document and write it to a
|
||||||
|
[custom field](#custom-fields), or to the document's title, archive serial number or created date. They are
|
||||||
|
useful for documents that always share the same layout, such as bills from a particular utility. They allow you
|
||||||
|
to draw a zone around the account number once, and every new bill gets its account number filled in.
|
||||||
|
|
||||||
|
Like tags or document types, each template has a [matching algorithm](advanced_usage.md#matching) that
|
||||||
|
decides which documents it automatically applies to (or not), based on their content.
|
||||||
|
|
||||||
|
### Creating a template
|
||||||
|
|
||||||
|
1. Open **OCR Templates** under "Manage" and choose **Add Template**.
|
||||||
|
2. Give the template a name and set up its matching, for example **Any word** with the utility's name. Choose
|
||||||
|
**None** for a template that should only ever be run manually.
|
||||||
|
3. Search for a sample document with the layout the template is for. Its pages are shown next to the template
|
||||||
|
settings.
|
||||||
|
4. Draw zones on the document. Click a zone to select and edit it.
|
||||||
|
5. Choose the field each zone fills.
|
||||||
|
6. Use **Test** to run OCR on the selected zone of the sample document and see the text it finds and the value
|
||||||
|
that would be written.
|
||||||
|
|
||||||
|
Zones are positioned relative to the sample document's page, so templates work best when the documents they
|
||||||
|
match have an identical layout. Keep the matching specific enough that it only catches documents with that
|
||||||
|
layout.
|
||||||
|
|
||||||
|
### Zone settings
|
||||||
|
|
||||||
|
- **Page**: the page the zone is on. Turn on **Last page** for zones that are always on the final page,
|
||||||
|
regardless of how many pages a document has.
|
||||||
|
- **OCR language**: the Tesseract languages used to read the zone. Languages other than the defaults must be
|
||||||
|
installed, see [`PAPERLESS_OCR_LANGUAGES`](configuration.md#PAPERLESS_OCR_LANGUAGES).
|
||||||
|
- **Transform**: post-processing applied to the text, such as stripping whitespace, keeping only numbers,
|
||||||
|
parsing a date or reading a QR code or barcode instead of text.
|
||||||
|
- **Date format**: for the "Parse date" transform, the format of the date using
|
||||||
|
[Python date codes](https://docs.python.org/3/library/datetime.html#format-codes), e.g. `%d.%m.%Y`.
|
||||||
|
Leave it empty to detect the format automatically.
|
||||||
|
- **Validation regex**: a regular expression the whole value must match. Values that do not match are not
|
||||||
|
written.
|
||||||
|
|
||||||
|
Zones can fill custom fields of type text, long text, URL, date, integer, number, monetary or boolean.
|
||||||
|
Document link and select fields are not supported.
|
||||||
|
|
||||||
|
### Combining zones
|
||||||
|
|
||||||
|
When several zones fill the same field, a **Combined value** setting appears for that field. Use it to arrange
|
||||||
|
the zones' values with `{Zone name}` tokens and any text in between, for example `{Start} - {End}`. If it
|
||||||
|
is left empty, the values are joined with spaces in zone order.
|
||||||
|
|
||||||
|
### When templates run
|
||||||
|
|
||||||
|
When a document is added, every enabled template that matches its content runs automatically. A template is
|
||||||
|
only used for a document if the document's owner can view the template, so users' templates apply to their own
|
||||||
|
documents, and unowned templates apply to everyone's. Templates run in name order, so if two templates fill the
|
||||||
|
same field, the later one wins.
|
||||||
|
|
||||||
|
Turn off **Enabled** to pause a template without deleting it. Disabled templates never run automatically, but
|
||||||
|
can still be run manually.
|
||||||
|
|
||||||
|
### Running templates manually
|
||||||
|
|
||||||
|
To run templates on documents that are already in Paperless-ngx, open the **Actions** menu on the document
|
||||||
|
details page:
|
||||||
|
|
||||||
|
- **Run Zone OCR** runs every enabled template that matches the document.
|
||||||
|
- The button next to it opens a dialog where you can choose one template to run
|
||||||
|
instead. A template you choose runs even if it does not match the document or is disabled.
|
||||||
|
|
||||||
|
Both actions use the file version selected in document details. The latest version is selected by default.
|
||||||
|
Extracted values update the document's metadata, which is shared across its versions.
|
||||||
|
|
||||||
|
The bulk editor's **Run Zone OCR** action opens the same dialog for the selected documents. Bulk runs use
|
||||||
|
the latest version of each document and are processed in the background.
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
|
||||||
|
Zone OCR overwrites the fields it fills. A zone that finds no value leaves its field unchanged.
|
||||||
|
|
||||||
|
Managing OCR templates requires the OCR template permissions. Running zone OCR on documents requires
|
||||||
|
permission to change them, and choosing a template requires permission to view it.
|
||||||
|
|
||||||
## PDF Actions
|
## PDF Actions
|
||||||
|
|
||||||
Paperless-ngx supports basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files). When viewing an individual document you can
|
Paperless-ngx supports basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files). When viewing an individual document you can
|
||||||
|
|||||||
+5
-11
@@ -27,7 +27,7 @@ dependencies = [
|
|||||||
# WARNING: django does not use semver.
|
# WARNING: django does not use semver.
|
||||||
# Only patch versions are guaranteed to not introduce breaking changes.
|
# Only patch versions are guaranteed to not introduce breaking changes.
|
||||||
"django~=5.2.18",
|
"django~=5.2.18",
|
||||||
"django-allauth[mfa,socialaccount]~=65.20.0",
|
"django-allauth[mfa,socialaccount]~=65.21.1",
|
||||||
"django-auditlog~=3.4.1",
|
"django-auditlog~=3.4.1",
|
||||||
"django-cachalot~=2.9.0",
|
"django-cachalot~=2.9.0",
|
||||||
"django-compression-middleware~=0.5.0",
|
"django-compression-middleware~=0.5.0",
|
||||||
@@ -38,7 +38,6 @@ dependencies = [
|
|||||||
"django-multiselectfield~=1.0.1",
|
"django-multiselectfield~=1.0.1",
|
||||||
"django-rich~=2.2.0",
|
"django-rich~=2.2.0",
|
||||||
"django-soft-delete~=1.0.18",
|
"django-soft-delete~=1.0.18",
|
||||||
"django-treenode>=0.24",
|
|
||||||
"djangorestframework~=3.16",
|
"djangorestframework~=3.16",
|
||||||
"drf-spectacular~=0.30",
|
"drf-spectacular~=0.30",
|
||||||
"drf-spectacular-sidecar>=2026.10.1,<2026.11",
|
"drf-spectacular-sidecar>=2026.10.1,<2026.11",
|
||||||
@@ -112,14 +111,14 @@ lint = [
|
|||||||
testing = [
|
testing = [
|
||||||
"daphne",
|
"daphne",
|
||||||
"factory-boy~=3.3.1",
|
"factory-boy~=3.3.1",
|
||||||
"faker>=40.36,<40.40",
|
"faker>=40.36,<40.44",
|
||||||
"imagehash",
|
"imagehash",
|
||||||
"pytest~=9.1.1",
|
"pytest~=9.1.1",
|
||||||
"pytest-cov~=7.1.0",
|
"pytest-cov~=7.1.0",
|
||||||
"pytest-django>=4.12,<4.15",
|
"pytest-django>=4.12,<4.15",
|
||||||
"pytest-env~=1.7.0",
|
"pytest-env~=1.8.1",
|
||||||
"pytest-httpx",
|
"pytest-httpx",
|
||||||
"pytest-mock~=3.15.1",
|
"pytest-mock~=3.17.0",
|
||||||
"pytest-randomly~=5.0.0",
|
"pytest-randomly~=5.0.0",
|
||||||
"pytest-rerunfailures~=16.4",
|
"pytest-rerunfailures~=16.4",
|
||||||
"pytest-sugar",
|
"pytest-sugar",
|
||||||
@@ -213,12 +212,7 @@ extend-select = [
|
|||||||
"UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up
|
"UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up
|
||||||
"W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
|
"W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
|
||||||
]
|
]
|
||||||
ignore = [
|
ignore = [ "DJ001", "ICN001", "PLC0415", "RUF012", "SIM105" ]
|
||||||
"DJ001",
|
|
||||||
"PLC0415",
|
|
||||||
"RUF012",
|
|
||||||
"SIM105",
|
|
||||||
]
|
|
||||||
# Migrations
|
# Migrations
|
||||||
per-file-ignores."*/migrations/*.py" = [
|
per-file-ignores."*/migrations/*.py" = [
|
||||||
"E501",
|
"E501",
|
||||||
|
|||||||
+425
-296
File diff suppressed because it is too large.
Load diff
@@ -13,6 +13,7 @@ import { DocumentDetailComponent } from './components/document-detail/document-d
|
|||||||
import { DocumentListComponent } from './components/document-list/document-list.component'
|
import { DocumentListComponent } from './components/document-list/document-list.component'
|
||||||
import { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.component'
|
import { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.component'
|
||||||
import { MailComponent } from './components/manage/mail/mail.component'
|
import { MailComponent } from './components/manage/mail/mail.component'
|
||||||
|
import { OcrTemplatesComponent } from './components/manage/ocr-templates/ocr-templates.component'
|
||||||
import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component'
|
import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component'
|
||||||
import { ShareLinksComponent } from './components/manage/share-links/share-links.component'
|
import { ShareLinksComponent } from './components/manage/share-links/share-links.component'
|
||||||
import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
|
import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
|
||||||
@@ -37,7 +38,7 @@ export const routes: Routes = [
|
|||||||
path: 'dashboard',
|
path: 'dashboard',
|
||||||
component: DashboardComponent,
|
component: DashboardComponent,
|
||||||
data: {
|
data: {
|
||||||
componentName: 'AppFrameComponent',
|
componentName: 'DashboardComponent',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -275,6 +276,18 @@ export const routes: Routes = [
|
|||||||
componentName: 'WorkflowsComponent',
|
componentName: 'WorkflowsComponent',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'ocr-templates',
|
||||||
|
component: OcrTemplatesComponent,
|
||||||
|
canActivate: [PermissionsGuard],
|
||||||
|
data: {
|
||||||
|
requiredPermission: {
|
||||||
|
action: PermissionAction.View,
|
||||||
|
type: PermissionType.OcrTemplate,
|
||||||
|
},
|
||||||
|
componentName: 'OcrTemplatesComponent',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'mail',
|
path: 'mail',
|
||||||
component: MailComponent,
|
component: MailComponent,
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ const sidebarItemLabels: Record<HideableSidebarItemID, string> = {
|
|||||||
[HideableSidebarItemID.SavedViews]: $localize`Saved Views`,
|
[HideableSidebarItemID.SavedViews]: $localize`Saved Views`,
|
||||||
[HideableSidebarItemID.ShareLinks]: $localize`Share Links`,
|
[HideableSidebarItemID.ShareLinks]: $localize`Share Links`,
|
||||||
[HideableSidebarItemID.Workflows]: $localize`Workflows`,
|
[HideableSidebarItemID.Workflows]: $localize`Workflows`,
|
||||||
|
[HideableSidebarItemID.OcrTemplates]: $localize`OCR Templates`,
|
||||||
[HideableSidebarItemID.Mail]: $localize`Mail`,
|
[HideableSidebarItemID.Mail]: $localize`Mail`,
|
||||||
[HideableSidebarItemID.Documentation]: $localize`Documentation`,
|
[HideableSidebarItemID.Documentation]: $localize`Documentation`,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,15 @@ import {
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { FormsModule } from '@angular/forms'
|
import { FormsModule } from '@angular/forms'
|
||||||
import { By } from '@angular/platform-browser'
|
import { By } from '@angular/platform-browser'
|
||||||
import { Router } from '@angular/router'
|
import {
|
||||||
|
ActivatedRoute,
|
||||||
|
convertToParamMap,
|
||||||
|
ParamMap,
|
||||||
|
Router,
|
||||||
|
} from '@angular/router'
|
||||||
import { RouterTestingModule } from '@angular/router/testing'
|
import { RouterTestingModule } from '@angular/router/testing'
|
||||||
import { NgbModal, NgbModalRef, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal, NgbModalRef, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { of, throwError } from 'rxjs'
|
import { BehaviorSubject, of, throwError } from 'rxjs'
|
||||||
import { routes } from 'src/app/app-routing.module'
|
import { routes } from 'src/app/app-routing.module'
|
||||||
import {
|
import {
|
||||||
PaperlessTask,
|
PaperlessTask,
|
||||||
@@ -159,6 +164,7 @@ const sectionCountResponse = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('TasksComponent', () => {
|
describe('TasksComponent', () => {
|
||||||
|
let queryParams: BehaviorSubject<ParamMap>
|
||||||
let component: TasksComponent
|
let component: TasksComponent
|
||||||
let fixture: ComponentFixture<TasksComponent>
|
let fixture: ComponentFixture<TasksComponent>
|
||||||
let tasksService: TasksService
|
let tasksService: TasksService
|
||||||
@@ -169,6 +175,7 @@ describe('TasksComponent', () => {
|
|||||||
let toastService: ToastService
|
let toastService: ToastService
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
queryParams = new BehaviorSubject(convertToParamMap({}))
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [
|
imports: [
|
||||||
NgbModule,
|
NgbModule,
|
||||||
@@ -181,6 +188,17 @@ describe('TasksComponent', () => {
|
|||||||
ConfirmDialogComponent,
|
ConfirmDialogComponent,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
|
{
|
||||||
|
provide: ActivatedRoute,
|
||||||
|
useValue: {
|
||||||
|
queryParamMap: queryParams.asObservable(),
|
||||||
|
snapshot: {
|
||||||
|
get queryParamMap() {
|
||||||
|
return queryParams.value
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: PermissionsService,
|
provide: PermissionsService,
|
||||||
useValue: {
|
useValue: {
|
||||||
@@ -219,7 +237,11 @@ describe('TasksComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// answers task list requests with the fixture tasks matching their status filter
|
// answers task list requests with the fixture tasks matching their status filter
|
||||||
function flushTaskLists(pageSize: number, count?: number) {
|
function flushTaskLists(
|
||||||
|
pageSize: number,
|
||||||
|
count?: number,
|
||||||
|
filteredTasks: PaperlessTask[] = tasks
|
||||||
|
) {
|
||||||
httpTestingController
|
httpTestingController
|
||||||
.match(
|
.match(
|
||||||
(req) =>
|
(req) =>
|
||||||
@@ -228,7 +250,9 @@ describe('TasksComponent', () => {
|
|||||||
)
|
)
|
||||||
.forEach((req) => {
|
.forEach((req) => {
|
||||||
const statuses = req.request.params.getAll('status')
|
const statuses = req.request.params.getAll('status')
|
||||||
const results = tasks.filter((task) => statuses.includes(task.status))
|
const results = filteredTasks.filter((task) =>
|
||||||
|
statuses.includes(task.status)
|
||||||
|
)
|
||||||
req.flush({ count: count ?? results.length, results })
|
req.flush({ count: count ?? results.length, results })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -238,6 +262,71 @@ describe('TasksComponent', () => {
|
|||||||
flushTaskLists(25, count)
|
flushTaskLists(25, count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it('consumes a task link on load into the normal resettable Task ID filter', () => {
|
||||||
|
jest
|
||||||
|
.spyOn(tasksService, 'list')
|
||||||
|
.mockReturnValue(of({ count: 0, results: [] }))
|
||||||
|
jest
|
||||||
|
.spyOn(tasksService, 'statusCounts')
|
||||||
|
.mockReturnValue(of(sectionCountResponse))
|
||||||
|
jest.spyOn(router, 'navigate').mockResolvedValue(true)
|
||||||
|
queryParams.next(convertToParamMap({ task_id: 'linked-task' }))
|
||||||
|
fixture.destroy()
|
||||||
|
fixture = TestBed.createComponent(TasksComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(router.navigate).toHaveBeenCalledTimes(1)
|
||||||
|
expect(router.navigate).toHaveBeenCalledWith(
|
||||||
|
[],
|
||||||
|
expect.objectContaining({
|
||||||
|
queryParams: { task_id: null },
|
||||||
|
replaceUrl: true,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(component.filterTargetName).toBe('Task ID')
|
||||||
|
expect(component.filterText).toBe('linked-task')
|
||||||
|
expect(component.isFiltered).toBe(true)
|
||||||
|
component.setSection(TaskSection.InProgress)
|
||||||
|
expect(tasksService.list).toHaveBeenLastCalledWith(1, 25, {
|
||||||
|
task_id: 'linked-task',
|
||||||
|
status: [PaperlessTaskStatus.Pending, PaperlessTaskStatus.Started],
|
||||||
|
})
|
||||||
|
component.resetFilters()
|
||||||
|
expect(component.filterText).toBe('')
|
||||||
|
expect(component.isFiltered).toBe(false)
|
||||||
|
expect(tasksService.list).toHaveBeenLastCalledWith(1, 25, {
|
||||||
|
acknowledged: false,
|
||||||
|
status: [PaperlessTaskStatus.Pending, PaperlessTaskStatus.Started],
|
||||||
|
})
|
||||||
|
expect(router.navigate).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports manually filtering by Task ID and clearing the text filter', () => {
|
||||||
|
jest
|
||||||
|
.spyOn(tasksService, 'list')
|
||||||
|
.mockReturnValue(of({ count: 0, results: [] }))
|
||||||
|
jest
|
||||||
|
.spyOn(tasksService, 'statusCounts')
|
||||||
|
.mockReturnValue(of(sectionCountResponse))
|
||||||
|
component.setSection(TaskSection.InProgress)
|
||||||
|
component.setFilterTarget(TaskFilterTargetID.TaskId)
|
||||||
|
component.filterText = tasks[0].task_id
|
||||||
|
jest.advanceTimersByTime(150)
|
||||||
|
expect(tasksService.list).toHaveBeenLastCalledWith(1, 25, {
|
||||||
|
task_id: tasks[0].task_id,
|
||||||
|
status: [PaperlessTaskStatus.Pending, PaperlessTaskStatus.Started],
|
||||||
|
})
|
||||||
|
component.pagedTasks.set(tasks)
|
||||||
|
expect(component.tasksForSection(TaskSection.NeedsAttention)).toEqual([
|
||||||
|
tasks[0],
|
||||||
|
])
|
||||||
|
component.resetFilter()
|
||||||
|
expect(tasksService.list).toHaveBeenLastCalledWith(1, 25, {
|
||||||
|
acknowledged: false,
|
||||||
|
status: [PaperlessTaskStatus.Pending, PaperlessTaskStatus.Started],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('should display task sections with counts', () => {
|
it('should display task sections with counts', () => {
|
||||||
expect(component.selectedSection).toBe(TaskSection.All)
|
expect(component.selectedSection).toBe(TaskSection.All)
|
||||||
expect(component.selectedTaskType).toBeNull()
|
expect(component.selectedTaskType).toBeNull()
|
||||||
@@ -771,6 +860,7 @@ describe('TasksComponent', () => {
|
|||||||
input.nativeElement.value = '191092'
|
input.nativeElement.value = '191092'
|
||||||
input.nativeElement.dispatchEvent(new Event('input'))
|
input.nativeElement.dispatchEvent(new Event('input'))
|
||||||
jest.advanceTimersByTime(150)
|
jest.advanceTimersByTime(150)
|
||||||
|
flushTaskLists(10, undefined, [tasks[1]])
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(component.filterText).toEqual('191092')
|
expect(component.filterText).toEqual('191092')
|
||||||
expect(component.tasksForSection(TaskSection.NeedsAttention)).toHaveLength(
|
expect(component.tasksForSection(TaskSection.NeedsAttention)).toHaveLength(
|
||||||
@@ -782,6 +872,7 @@ describe('TasksComponent', () => {
|
|||||||
component.setSection(TaskSection.InProgress)
|
component.setSection(TaskSection.InProgress)
|
||||||
component.filterText = 'system'
|
component.filterText = 'system'
|
||||||
jest.advanceTimersByTime(150)
|
jest.advanceTimersByTime(150)
|
||||||
|
flushTaskLists(25, undefined, [tasks[6]])
|
||||||
|
|
||||||
expect(component.tasksForSection(TaskSection.InProgress)).toHaveLength(1)
|
expect(component.tasksForSection(TaskSection.InProgress)).toHaveLength(1)
|
||||||
expect(component.tasksForSection(TaskSection.InProgress)[0].task_type).toBe(
|
expect(component.tasksForSection(TaskSection.InProgress)[0].task_type).toBe(
|
||||||
@@ -815,6 +906,7 @@ describe('TasksComponent', () => {
|
|||||||
input.nativeElement.value = 'duplicate'
|
input.nativeElement.value = 'duplicate'
|
||||||
input.nativeElement.dispatchEvent(new Event('input'))
|
input.nativeElement.dispatchEvent(new Event('input'))
|
||||||
jest.advanceTimersByTime(150)
|
jest.advanceTimersByTime(150)
|
||||||
|
flushTaskLists(25, undefined, [tasks[0], tasks[1]])
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(component.filterText).toEqual('duplicate')
|
expect(component.filterText).toEqual('duplicate')
|
||||||
expect(component.tasksForSection(TaskSection.NeedsAttention)).toHaveLength(
|
expect(component.tasksForSection(TaskSection.NeedsAttention)).toHaveLength(
|
||||||
@@ -822,6 +914,53 @@ describe('TasksComponent', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should show server-matched names when the displayed source is translated', () => {
|
||||||
|
component.setSection(TaskSection.InProgress)
|
||||||
|
flushTaskLists(25)
|
||||||
|
component.filterText = 'email'
|
||||||
|
jest.advanceTimersByTime(150)
|
||||||
|
|
||||||
|
const request = httpTestingController.expectOne(
|
||||||
|
(req) =>
|
||||||
|
req.url === `${environment.apiBaseUrl}tasks/` &&
|
||||||
|
req.params.get('name') === 'email' &&
|
||||||
|
req.params.get('page_size') === '25'
|
||||||
|
)
|
||||||
|
const translatedTask = {
|
||||||
|
...tasks[5],
|
||||||
|
trigger_source_display: 'E-Mail',
|
||||||
|
}
|
||||||
|
request.flush({ count: 1, results: [translatedTask] })
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(component.totalTasks()).toBe(1)
|
||||||
|
expect(component.visibleTasks).toEqual([translatedTask])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show server-matched duplicates when the displayed result is translated', () => {
|
||||||
|
component.setSection(TaskSection.NeedsAttention)
|
||||||
|
flushTaskLists(25)
|
||||||
|
jest
|
||||||
|
.spyOn(component, 'taskResultMessage')
|
||||||
|
.mockReturnValue('Duplikat von Document #311')
|
||||||
|
component.setFilterTarget(TaskFilterTargetID.Result)
|
||||||
|
component.filterText = 'duplicate'
|
||||||
|
jest.advanceTimersByTime(150)
|
||||||
|
|
||||||
|
const request = httpTestingController.expectOne(
|
||||||
|
(req) =>
|
||||||
|
req.url === `${environment.apiBaseUrl}tasks/` &&
|
||||||
|
req.params.get('result') === 'duplicate' &&
|
||||||
|
req.params.get('page_size') === '25'
|
||||||
|
)
|
||||||
|
request.flush({ count: 1, results: [tasks[1]] })
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(component.totalTasks()).toBe(1)
|
||||||
|
expect(component.visibleTasks).toEqual([tasks[1]])
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('191092.pdf')
|
||||||
|
})
|
||||||
|
|
||||||
it('should prefer explicit reason in the result message', () => {
|
it('should prefer explicit reason in the result message', () => {
|
||||||
expect(
|
expect(
|
||||||
component.taskResultMessage({
|
component.taskResultMessage({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { JsonPipe, NgTemplateOutlet } from '@angular/common'
|
import { JsonPipe, NgTemplateOutlet } from '@angular/common'
|
||||||
import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core'
|
import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { Router, RouterLink } from '@angular/router'
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router'
|
||||||
import {
|
import {
|
||||||
NgbCollapseModule,
|
NgbCollapseModule,
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
@@ -47,11 +47,13 @@ export enum TaskSection {
|
|||||||
export enum TaskFilterTargetID {
|
export enum TaskFilterTargetID {
|
||||||
Name,
|
Name,
|
||||||
Result,
|
Result,
|
||||||
|
TaskId,
|
||||||
}
|
}
|
||||||
|
|
||||||
const FILTER_TARGETS = [
|
const FILTER_TARGETS = [
|
||||||
{ id: TaskFilterTargetID.Name, name: $localize`Name` },
|
{ id: TaskFilterTargetID.Name, name: $localize`Name` },
|
||||||
{ id: TaskFilterTargetID.Result, name: $localize`Result` },
|
{ id: TaskFilterTargetID.Result, name: $localize`Result` },
|
||||||
|
{ id: TaskFilterTargetID.TaskId, name: $localize`Task ID` },
|
||||||
]
|
]
|
||||||
|
|
||||||
const SECTION_LABELS = {
|
const SECTION_LABELS = {
|
||||||
@@ -161,6 +163,7 @@ export class TasksComponent
|
|||||||
tasksService = inject(TasksService)
|
tasksService = inject(TasksService)
|
||||||
private modalService = inject(NgbModal)
|
private modalService = inject(NgbModal)
|
||||||
private readonly router = inject(Router)
|
private readonly router = inject(Router)
|
||||||
|
private readonly route = inject(ActivatedRoute)
|
||||||
private readonly toastService = inject(ToastService)
|
private readonly toastService = inject(ToastService)
|
||||||
|
|
||||||
readonly TaskSection = TaskSection
|
readonly TaskSection = TaskSection
|
||||||
@@ -275,6 +278,17 @@ export class TasksComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
|
const taskId = this.route.snapshot.queryParamMap.get('task_id')
|
||||||
|
if (taskId) {
|
||||||
|
this.filterTargetID = TaskFilterTargetID.TaskId
|
||||||
|
this._filterText = taskId
|
||||||
|
this.router.navigate([], {
|
||||||
|
relativeTo: this.route,
|
||||||
|
queryParams: { task_id: null },
|
||||||
|
queryParamsHandling: 'merge',
|
||||||
|
replaceUrl: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
this.tasksService.reload()
|
this.tasksService.reload()
|
||||||
this.reloadPage()
|
this.reloadPage()
|
||||||
timer(5000, 5000)
|
timer(5000, 5000)
|
||||||
@@ -636,17 +650,12 @@ export class TasksComponent
|
|||||||
|
|
||||||
const query = this._filterText.toLowerCase()
|
const query = this._filterText.toLowerCase()
|
||||||
|
|
||||||
if (this.filterTargetID == TaskFilterTargetID.Name) {
|
if (this.filterTargetID === TaskFilterTargetID.TaskId) {
|
||||||
return [
|
return task.task_id.toLowerCase() === query
|
||||||
this.taskDisplayName(task),
|
|
||||||
task.task_type_display,
|
|
||||||
task.trigger_source_display,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.some((value) => value.toLowerCase().includes(query))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.taskResultMessage(task)?.toLowerCase().includes(query) ?? false
|
// Text filtering now handled API-side
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private tasksForOptionCounts({
|
private tasksForOptionCounts({
|
||||||
@@ -706,9 +715,15 @@ export class TasksComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this._filterText.length) {
|
if (this._filterText.length) {
|
||||||
params[
|
const parameter = {
|
||||||
this.filterTargetID === TaskFilterTargetID.Name ? 'name' : 'result'
|
[TaskFilterTargetID.Name]: 'name',
|
||||||
] = this._filterText
|
[TaskFilterTargetID.Result]: 'result',
|
||||||
|
[TaskFilterTargetID.TaskId]: 'task_id',
|
||||||
|
}[this.filterTargetID]
|
||||||
|
params[parameter] = this._filterText
|
||||||
|
if (this.filterTargetID === TaskFilterTargetID.TaskId) {
|
||||||
|
delete params.acknowledged
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return params
|
return params
|
||||||
|
|||||||
@@ -289,6 +289,17 @@
|
|||||||
<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>
|
<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>
|
||||||
|
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.OcrTemplates) && !settingsService.organizingSidebarItems()"
|
||||||
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.OcrTemplate }">
|
||||||
|
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.OcrTemplates)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="ocr-templates" routerLinkActive="active" (click)="closeMenu()"
|
||||||
|
ngbPopover="OCR Templates" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
|
<pngx-bs-icon class="me-2" name="bounding-box-circles"></pngx-bs-icon><span class="nav-link-label"><ng-container i18n>OCR Templates</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="OCR Templates" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.OcrTemplates)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.OcrTemplates, $event)"></pngx-input-switch>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail) && !settingsService.organizingSidebarItems()" *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">
|
tourAnchor="tour.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"
|
<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"
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import { ActivatedRoute, Router } from '@angular/router'
|
|||||||
import { RouterTestingModule } from '@angular/router/testing'
|
import { RouterTestingModule } from '@angular/router/testing'
|
||||||
import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
|
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
|
||||||
import { of, throwError } from 'rxjs'
|
import { of, Subject, throwError } from 'rxjs'
|
||||||
import { routes } from 'src/app/app-routing.module'
|
import { routes } from 'src/app/app-routing.module'
|
||||||
|
import { BulkOperationStatus } from 'src/app/data/bulk-operation'
|
||||||
|
import { PaperlessTaskStatus } from 'src/app/data/paperless-task'
|
||||||
import { SavedView } from 'src/app/data/saved-view'
|
import { SavedView } from 'src/app/data/saved-view'
|
||||||
import { HideableSidebarItemID, 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 { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
@@ -21,10 +23,11 @@ import {
|
|||||||
DjangoMessageLevel,
|
DjangoMessageLevel,
|
||||||
DjangoMessagesService,
|
DjangoMessagesService,
|
||||||
} from 'src/app/services/django-messages.service'
|
} from 'src/app/services/django-messages.service'
|
||||||
|
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||||
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
||||||
import {
|
import {
|
||||||
PermissionType,
|
|
||||||
PermissionsService,
|
PermissionsService,
|
||||||
|
PermissionType,
|
||||||
} from 'src/app/services/permissions.service'
|
} from 'src/app/services/permissions.service'
|
||||||
import { RemoteVersionService } from 'src/app/services/rest/remote-version.service'
|
import { RemoteVersionService } from 'src/app/services/rest/remote-version.service'
|
||||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||||
@@ -95,6 +98,7 @@ describe('AppFrameComponent', () => {
|
|||||||
let savedViewSpy
|
let savedViewSpy
|
||||||
let modalService: NgbModal
|
let modalService: NgbModal
|
||||||
let maybeRefreshSpy
|
let maybeRefreshSpy
|
||||||
|
let bulkEvents: Subject<BulkOperationStatus>
|
||||||
let tasksService: TasksService
|
let tasksService: TasksService
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -182,6 +186,10 @@ describe('AppFrameComponent', () => {
|
|||||||
savedViewSpy = jest.spyOn(savedViewService, 'reload')
|
savedViewSpy = jest.spyOn(savedViewService, 'reload')
|
||||||
maybeRefreshSpy = jest.spyOn(savedViewService, 'maybeRefreshDocumentCounts')
|
maybeRefreshSpy = jest.spyOn(savedViewService, 'maybeRefreshDocumentCounts')
|
||||||
|
|
||||||
|
bulkEvents = new Subject<BulkOperationStatus>()
|
||||||
|
jest
|
||||||
|
.spyOn(TestBed.inject(WebsocketStatusService), 'onBulkOperationStatus')
|
||||||
|
.mockReturnValue(bulkEvents.asObservable())
|
||||||
fixture = TestBed.createComponent(AppFrameComponent)
|
fixture = TestBed.createComponent(AppFrameComponent)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
|
|
||||||
@@ -190,6 +198,78 @@ describe('AppFrameComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('refreshes and notifies on completion without client-side task tracking', () => {
|
||||||
|
const list = TestBed.inject(DocumentListViewService)
|
||||||
|
const reload = jest.spyOn(list, 'reload').mockImplementation(() => {})
|
||||||
|
jest.spyOn(list, 'reduceSelectionToFilter').mockImplementation(() => {})
|
||||||
|
const tasksReload = jest
|
||||||
|
.spyOn(tasksService, 'reload')
|
||||||
|
.mockImplementation(() => {})
|
||||||
|
const success = jest.spyOn(toastService, 'showSuccess')
|
||||||
|
const refresh = jest
|
||||||
|
.spyOn(openDocumentsService, 'refreshDocument')
|
||||||
|
.mockImplementation(() => {})
|
||||||
|
jest
|
||||||
|
.spyOn(openDocumentsService, 'getOpenDocuments')
|
||||||
|
.mockReturnValue([document])
|
||||||
|
tasksReload.mockClear()
|
||||||
|
maybeRefreshSpy.mockClear()
|
||||||
|
bulkEvents.next({
|
||||||
|
task_id: 'bulk-task',
|
||||||
|
operation: 'set_permissions',
|
||||||
|
status: PaperlessTaskStatus.Started,
|
||||||
|
})
|
||||||
|
expect(reload).not.toHaveBeenCalled()
|
||||||
|
bulkEvents.next({
|
||||||
|
task_id: 'bulk-task',
|
||||||
|
operation: 'set_permissions',
|
||||||
|
status: PaperlessTaskStatus.Success,
|
||||||
|
})
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1)
|
||||||
|
expect(reload).toHaveBeenCalledWith(null, false)
|
||||||
|
expect(refresh).toHaveBeenCalledWith(document.id)
|
||||||
|
expect(maybeRefreshSpy).toHaveBeenCalledTimes(1)
|
||||||
|
expect(tasksReload).toHaveBeenCalledTimes(1)
|
||||||
|
expect(success).toHaveBeenCalledWith('Bulk operation completed.')
|
||||||
|
fixture.destroy()
|
||||||
|
bulkEvents.next({
|
||||||
|
task_id: 'bulk-task',
|
||||||
|
operation: 'set_permissions',
|
||||||
|
status: PaperlessTaskStatus.Success,
|
||||||
|
})
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refreshes and reports failed or revoked bulk operations', () => {
|
||||||
|
const list = TestBed.inject(DocumentListViewService)
|
||||||
|
const reload = jest.spyOn(list, 'reload').mockImplementation(() => {})
|
||||||
|
jest.spyOn(list, 'reduceSelectionToFilter').mockImplementation(() => {})
|
||||||
|
jest.spyOn(tasksService, 'reload').mockImplementation(() => {})
|
||||||
|
const error = jest.spyOn(toastService, 'showError')
|
||||||
|
const success = jest.spyOn(toastService, 'showSuccess')
|
||||||
|
for (const status of [
|
||||||
|
PaperlessTaskStatus.Failure,
|
||||||
|
PaperlessTaskStatus.Revoked,
|
||||||
|
]) {
|
||||||
|
bulkEvents.next({
|
||||||
|
task_id: 'bulk-task',
|
||||||
|
operation: 'set_permissions',
|
||||||
|
status,
|
||||||
|
error:
|
||||||
|
status === PaperlessTaskStatus.Revoked
|
||||||
|
? 'Task expired before execution.'
|
||||||
|
: 'Could not update',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
expect(reload).toHaveBeenCalledTimes(2)
|
||||||
|
expect(error).toHaveBeenCalledWith(expect.any(String), 'Could not update')
|
||||||
|
expect(error).toHaveBeenCalledWith(
|
||||||
|
'Bulk operation revoked.',
|
||||||
|
'Task expired before execution.'
|
||||||
|
)
|
||||||
|
expect(success).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('should initialize the saved view service', () => {
|
it('should initialize the saved view service', () => {
|
||||||
expect(savedViewSpy).toHaveBeenCalled()
|
expect(savedViewSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
@@ -293,6 +373,7 @@ describe('AppFrameComponent', () => {
|
|||||||
HideableSidebarItemID.Dashboard,
|
HideableSidebarItemID.Dashboard,
|
||||||
HideableSidebarItemID.Workflows,
|
HideableSidebarItemID.Workflows,
|
||||||
HideableSidebarItemID.ShareLinks,
|
HideableSidebarItemID.ShareLinks,
|
||||||
|
HideableSidebarItemID.OcrTemplates,
|
||||||
])
|
])
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
@@ -308,6 +389,10 @@ describe('AppFrameComponent', () => {
|
|||||||
fixture.nativeElement.querySelector('[routerLink="share-links"]')
|
fixture.nativeElement.querySelector('[routerLink="share-links"]')
|
||||||
.parentElement.classList
|
.parentElement.classList
|
||||||
).toContain('d-none')
|
).toContain('d-none')
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('[routerLink="ocr-templates"]')
|
||||||
|
.parentElement.classList
|
||||||
|
).toContain('d-none')
|
||||||
expect(
|
expect(
|
||||||
fixture.nativeElement.querySelector('[routerLink="mail"]').parentElement
|
fixture.nativeElement.querySelector('[routerLink="mail"]').parentElement
|
||||||
.classList
|
.classList
|
||||||
@@ -320,12 +405,21 @@ describe('AppFrameComponent', () => {
|
|||||||
])
|
])
|
||||||
settingsService.sidebarHiddenItemsEditing.set([
|
settingsService.sidebarHiddenItemsEditing.set([
|
||||||
HideableSidebarItemID.Dashboard,
|
HideableSidebarItemID.Dashboard,
|
||||||
|
HideableSidebarItemID.OcrTemplates,
|
||||||
])
|
])
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
fixture.nativeElement.querySelectorAll('pngx-input-switch').length
|
fixture.nativeElement.querySelectorAll('pngx-input-switch').length
|
||||||
).toBe(6)
|
).toBe(7)
|
||||||
|
const ocrTemplatesLink = fixture.nativeElement.querySelector(
|
||||||
|
'[routerLink="ocr-templates"]'
|
||||||
|
)
|
||||||
|
expect(ocrTemplatesLink.parentElement.classList).not.toContain('d-none')
|
||||||
|
expect(ocrTemplatesLink.classList).toContain('opacity-50')
|
||||||
|
expect(
|
||||||
|
ocrTemplatesLink.parentElement.querySelector('pngx-input-switch')
|
||||||
|
).not.toBeNull()
|
||||||
expect(
|
expect(
|
||||||
fixture.nativeElement.querySelector('[routerLink="dashboard"]')
|
fixture.nativeElement.querySelector('[routerLink="dashboard"]')
|
||||||
.parentElement.classList
|
.parentElement.classList
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import {
|
|||||||
import { NgClass } from '@angular/common'
|
import { NgClass } from '@angular/common'
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
|
DestroyRef,
|
||||||
HostListener,
|
HostListener,
|
||||||
inject,
|
inject,
|
||||||
OnInit,
|
OnInit,
|
||||||
signal,
|
signal,
|
||||||
viewChild,
|
viewChild,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
|
||||||
import { FormsModule } from '@angular/forms'
|
import { FormsModule } from '@angular/forms'
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
||||||
import {
|
import {
|
||||||
@@ -24,10 +26,11 @@ import {
|
|||||||
NgbPopoverModule,
|
NgbPopoverModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
|
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
|
||||||
import { Observable } from 'rxjs'
|
import { filter, Observable } from 'rxjs'
|
||||||
import { first } from 'rxjs/operators'
|
import { first } from 'rxjs/operators'
|
||||||
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
||||||
import { Document } from 'src/app/data/document'
|
import { Document } from 'src/app/data/document'
|
||||||
|
import { PaperlessTaskStatus } from 'src/app/data/paperless-task'
|
||||||
import { SavedView } from 'src/app/data/saved-view'
|
import { SavedView } from 'src/app/data/saved-view'
|
||||||
import {
|
import {
|
||||||
CollapsibleSection,
|
CollapsibleSection,
|
||||||
@@ -41,6 +44,7 @@ import {
|
|||||||
DjangoMessageLevel,
|
DjangoMessageLevel,
|
||||||
DjangoMessagesService,
|
DjangoMessagesService,
|
||||||
} from 'src/app/services/django-messages.service'
|
} from 'src/app/services/django-messages.service'
|
||||||
|
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||||
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
||||||
import {
|
import {
|
||||||
PermissionAction,
|
PermissionAction,
|
||||||
@@ -101,6 +105,8 @@ export class AppFrameComponent
|
|||||||
extends ComponentWithPermissions
|
extends ComponentWithPermissions
|
||||||
implements OnInit, ComponentCanDeactivate
|
implements OnInit, ComponentCanDeactivate
|
||||||
{
|
{
|
||||||
|
private destroyRef = inject(DestroyRef)
|
||||||
|
private documentList = inject(DocumentListViewService)
|
||||||
router = inject(Router)
|
router = inject(Router)
|
||||||
private activatedRoute = inject(ActivatedRoute)
|
private activatedRoute = inject(ActivatedRoute)
|
||||||
private openDocumentsService = inject(OpenDocumentsService)
|
private openDocumentsService = inject(OpenDocumentsService)
|
||||||
@@ -164,6 +170,46 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
this.websocketStatusService
|
||||||
|
.onBulkOperationStatus()
|
||||||
|
.pipe(
|
||||||
|
filter(({ status }) =>
|
||||||
|
[
|
||||||
|
PaperlessTaskStatus.Success,
|
||||||
|
PaperlessTaskStatus.Failure,
|
||||||
|
PaperlessTaskStatus.Revoked,
|
||||||
|
].includes(status)
|
||||||
|
),
|
||||||
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
)
|
||||||
|
.subscribe(({ status, error }) => {
|
||||||
|
this.documentList.reload(null, false)
|
||||||
|
this.documentList.reduceSelectionToFilter()
|
||||||
|
// maybe someday we can optimize to only refresh affected documents
|
||||||
|
this.openDocumentsService.getOpenDocuments().forEach((doc) => {
|
||||||
|
this.openDocumentsService.refreshDocument(doc.id)
|
||||||
|
})
|
||||||
|
this.savedViewService.maybeRefreshDocumentCounts()
|
||||||
|
if (
|
||||||
|
this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.PaperlessTask
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.tasksService.reload()
|
||||||
|
}
|
||||||
|
if (status === PaperlessTaskStatus.Success) {
|
||||||
|
this.toastService.showSuccess($localize`Bulk operation completed.`)
|
||||||
|
} else if (status === PaperlessTaskStatus.Revoked) {
|
||||||
|
this.toastService.showError($localize`Bulk operation revoked.`, error)
|
||||||
|
} else {
|
||||||
|
this.toastService.showError(
|
||||||
|
$localize`Bulk operation failed. Some changes may already have been applied.`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
this.lastScrollY = window.scrollY
|
this.lastScrollY = window.scrollY
|
||||||
this.detectClassicScrollbars()
|
this.detectClassicScrollbars()
|
||||||
|
|
||||||
|
|||||||
@@ -42,11 +42,16 @@
|
|||||||
<span class="text-break">{{ message.content }}</span>
|
<span class="text-break">{{ message.content }}</span>
|
||||||
}
|
}
|
||||||
@if (message.role === 'assistant' && !message.isStreaming && message.references?.length) {
|
@if (message.role === 'assistant' && !message.isStreaming && message.references?.length) {
|
||||||
<div class="chat-references list-group mt-3">
|
<div class="chat-references mt-2 pt-2 border-top">
|
||||||
<div class="list-group-item text-muted small py-1" i18n>Sources</div>
|
<div class="d-flex align-items-center text-muted mb-1">
|
||||||
@for (reference of message.references; track reference.id) {
|
<span i18n>Sources</span>
|
||||||
<a class="list-group-item list-group-item-action d-flex align-items-center text-primary" [routerLink]="['/documents', reference.id]" [title]="reference.title">
|
@if (hiddenReferenceCount(message) > 0 && !expandedReferences().has(message)) {
|
||||||
<pngx-bs-icon width="0.9em" height="0.9em" name="file-text" class="me-2 flex-shrink-0"></pngx-bs-icon><span class="text-truncate">{{ reference.title }}</span>
|
<button type="button" class="badge rounded-pill bg-body-secondary text-body-secondary fw-normal border-0 ms-2" (click)="expandReferences(message)" i18n>+{{ hiddenReferenceCount(message) }} more</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@for (reference of visibleReferences(message); track reference.id) {
|
||||||
|
<a class="reference-link d-flex align-items-center lh-sm text-primary text-decoration-none py-1" [routerLink]="['/documents', reference.id]" [title]="reference.title">
|
||||||
|
<pngx-bs-icon width="0.8em" height="0.8em" name="file-text" class="me-2 flex-shrink-0"></pngx-bs-icon><span class="text-truncate">{{ reference.title }}</span>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,10 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message .flex-grow-1 {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown-item {
|
.dropdown-item {
|
||||||
white-space: initial;
|
white-space: initial;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon
|
|||||||
import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'
|
import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'
|
||||||
import {
|
import {
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
|
ChatReference,
|
||||||
ChatService,
|
ChatService,
|
||||||
parseChatResponse,
|
parseChatResponse,
|
||||||
} from 'src/app/services/chat.service'
|
} from 'src/app/services/chat.service'
|
||||||
@@ -40,8 +41,29 @@ export class ChatComponent implements OnInit {
|
|||||||
readonly input = signal('')
|
readonly input = signal('')
|
||||||
readonly documentId = signal<number>(undefined)
|
readonly documentId = signal<number>(undefined)
|
||||||
readonly slowResponse = signal(false)
|
readonly slowResponse = signal(false)
|
||||||
|
readonly expandedReferences = signal<Set<ChatMessage>>(new Set())
|
||||||
public popperOptions = pngxPopperOptions
|
public popperOptions = pngxPopperOptions
|
||||||
|
|
||||||
|
static readonly COLLAPSED_REFERENCE_COUNT = 2
|
||||||
|
|
||||||
|
public visibleReferences(message: ChatMessage): ChatReference[] {
|
||||||
|
if (this.expandedReferences().has(message)) {
|
||||||
|
return message.references
|
||||||
|
}
|
||||||
|
return message.references.slice(0, ChatComponent.COLLAPSED_REFERENCE_COUNT)
|
||||||
|
}
|
||||||
|
|
||||||
|
public hiddenReferenceCount(message: ChatMessage): number {
|
||||||
|
return Math.max(
|
||||||
|
0,
|
||||||
|
message.references.length - ChatComponent.COLLAPSED_REFERENCE_COUNT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public expandReferences(message: ChatMessage): void {
|
||||||
|
this.expandedReferences.update((expanded) => new Set(expanded).add(message))
|
||||||
|
}
|
||||||
|
|
||||||
private chatService: ChatService = inject(ChatService)
|
private chatService: ChatService = inject(ChatService)
|
||||||
private router: Router = inject(Router)
|
private router: Router = inject(Router)
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import basket from 'bootstrap-icons/icons/basket.svg'
|
|||||||
import bell from 'bootstrap-icons/icons/bell.svg'
|
import bell from 'bootstrap-icons/icons/bell.svg'
|
||||||
import bodyText from 'bootstrap-icons/icons/body-text.svg'
|
import bodyText from 'bootstrap-icons/icons/body-text.svg'
|
||||||
import bookmark from 'bootstrap-icons/icons/bookmark.svg'
|
import bookmark from 'bootstrap-icons/icons/bookmark.svg'
|
||||||
|
import boundingBoxCircles from 'bootstrap-icons/icons/bounding-box-circles.svg'
|
||||||
import boxArrowInRight from 'bootstrap-icons/icons/box-arrow-in-right.svg'
|
import boxArrowInRight from 'bootstrap-icons/icons/box-arrow-in-right.svg'
|
||||||
import boxArrowUpRight from 'bootstrap-icons/icons/box-arrow-up-right.svg'
|
import boxArrowUpRight from 'bootstrap-icons/icons/box-arrow-up-right.svg'
|
||||||
import boxArrowUp from 'bootstrap-icons/icons/box-arrow-up.svg'
|
import boxArrowUp from 'bootstrap-icons/icons/box-arrow-up.svg'
|
||||||
@@ -77,6 +78,7 @@ import fileEarmarkMinus from 'bootstrap-icons/icons/file-earmark-minus.svg'
|
|||||||
import fileEarmarkPerson from 'bootstrap-icons/icons/file-earmark-person.svg'
|
import fileEarmarkPerson from 'bootstrap-icons/icons/file-earmark-person.svg'
|
||||||
import fileEarmarkPlus from 'bootstrap-icons/icons/file-earmark-plus.svg'
|
import fileEarmarkPlus from 'bootstrap-icons/icons/file-earmark-plus.svg'
|
||||||
import fileEarmarkRichtext from 'bootstrap-icons/icons/file-earmark-richtext.svg'
|
import fileEarmarkRichtext from 'bootstrap-icons/icons/file-earmark-richtext.svg'
|
||||||
|
import fileEarmarkRuled from 'bootstrap-icons/icons/file-earmark-ruled.svg'
|
||||||
import fileEarmarkSpreadsheet from 'bootstrap-icons/icons/file-earmark-spreadsheet.svg'
|
import fileEarmarkSpreadsheet from 'bootstrap-icons/icons/file-earmark-spreadsheet.svg'
|
||||||
import fileEarmark from 'bootstrap-icons/icons/file-earmark.svg'
|
import fileEarmark from 'bootstrap-icons/icons/file-earmark.svg'
|
||||||
import fileText from 'bootstrap-icons/icons/file-text.svg'
|
import fileText from 'bootstrap-icons/icons/file-text.svg'
|
||||||
@@ -118,6 +120,7 @@ import personFill from 'bootstrap-icons/icons/person-fill.svg'
|
|||||||
import personLock from 'bootstrap-icons/icons/person-lock.svg'
|
import personLock from 'bootstrap-icons/icons/person-lock.svg'
|
||||||
import personSquare from 'bootstrap-icons/icons/person-square.svg'
|
import personSquare from 'bootstrap-icons/icons/person-square.svg'
|
||||||
import person from 'bootstrap-icons/icons/person.svg'
|
import person from 'bootstrap-icons/icons/person.svg'
|
||||||
|
import playCircle from 'bootstrap-icons/icons/play-circle.svg'
|
||||||
import playFill from 'bootstrap-icons/icons/play-fill.svg'
|
import playFill from 'bootstrap-icons/icons/play-fill.svg'
|
||||||
import plusCircle from 'bootstrap-icons/icons/plus-circle.svg'
|
import plusCircle from 'bootstrap-icons/icons/plus-circle.svg'
|
||||||
import plus from 'bootstrap-icons/icons/plus.svg'
|
import plus from 'bootstrap-icons/icons/plus.svg'
|
||||||
@@ -130,6 +133,7 @@ import search from 'bootstrap-icons/icons/search.svg'
|
|||||||
import send from 'bootstrap-icons/icons/send.svg'
|
import send from 'bootstrap-icons/icons/send.svg'
|
||||||
import shop from 'bootstrap-icons/icons/shop.svg'
|
import shop from 'bootstrap-icons/icons/shop.svg'
|
||||||
import slashCircle from 'bootstrap-icons/icons/slash-circle.svg'
|
import slashCircle from 'bootstrap-icons/icons/slash-circle.svg'
|
||||||
|
import sliders2 from 'bootstrap-icons/icons/sliders2.svg'
|
||||||
import sliders2Vertical from 'bootstrap-icons/icons/sliders2-vertical.svg'
|
import sliders2Vertical from 'bootstrap-icons/icons/sliders2-vertical.svg'
|
||||||
import sortAlphaDown from 'bootstrap-icons/icons/sort-alpha-down.svg'
|
import sortAlphaDown from 'bootstrap-icons/icons/sort-alpha-down.svg'
|
||||||
import sortAlphaUpAlt from 'bootstrap-icons/icons/sort-alpha-up-alt.svg'
|
import sortAlphaUpAlt from 'bootstrap-icons/icons/sort-alpha-up-alt.svg'
|
||||||
@@ -178,6 +182,7 @@ export const icons: Record<string, string> = {
|
|||||||
braces,
|
braces,
|
||||||
bodyText,
|
bodyText,
|
||||||
bookmark,
|
bookmark,
|
||||||
|
boundingBoxCircles,
|
||||||
boxArrowInRight,
|
boxArrowInRight,
|
||||||
boxArrowUp,
|
boxArrowUp,
|
||||||
boxArrowUpRight,
|
boxArrowUpRight,
|
||||||
@@ -239,6 +244,7 @@ export const icons: Record<string, string> = {
|
|||||||
fileEarmarkPerson,
|
fileEarmarkPerson,
|
||||||
fileEarmarkPlus,
|
fileEarmarkPlus,
|
||||||
fileEarmarkRichtext,
|
fileEarmarkRichtext,
|
||||||
|
fileEarmarkRuled,
|
||||||
fileEarmarkSpreadsheet,
|
fileEarmarkSpreadsheet,
|
||||||
files,
|
files,
|
||||||
fileText,
|
fileText,
|
||||||
@@ -279,6 +285,7 @@ export const icons: Record<string, string> = {
|
|||||||
personFillLock,
|
personFillLock,
|
||||||
personLock,
|
personLock,
|
||||||
personSquare,
|
personSquare,
|
||||||
|
playCircle,
|
||||||
playFill,
|
playFill,
|
||||||
plus,
|
plus,
|
||||||
plusCircle,
|
plusCircle,
|
||||||
@@ -290,6 +297,7 @@ export const icons: Record<string, string> = {
|
|||||||
search,
|
search,
|
||||||
send,
|
send,
|
||||||
slashCircle,
|
slashCircle,
|
||||||
|
sliders2,
|
||||||
shop,
|
shop,
|
||||||
sliders2Vertical,
|
sliders2Vertical,
|
||||||
sortAlphaDown,
|
sortAlphaDown,
|
||||||
|
|||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
<div class="modal-header">
|
||||||
|
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
@if (message) {
|
||||||
|
<p class="text-break" [innerHTML]="message"></p>
|
||||||
|
}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="runWorkflowSelect" i18n>Workflow</label>
|
||||||
|
<select class="form-select" id="runWorkflowSelect" [(ngModel)]="workflowId" (ngModelChange)="workflowSelected()">
|
||||||
|
<option [ngValue]="null" disabled i18n>Select a workflow</option>
|
||||||
|
@for (workflow of workflows; track workflow.id) {
|
||||||
|
<option [ngValue]="workflow.id">{{workflow.name}}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="runWorkflowForce" [(ngModel)]="force" />
|
||||||
|
<label class="form-check-label" for="runWorkflowForce" i18n>Run on all selected documents</label>
|
||||||
|
<div class="form-text" i18n>Ignore the workflow's trigger filters and run on every selected document.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
|
||||||
|
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
|
||||||
|
{{btnCaption}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { RunWorkflowConfirmDialogComponent } from './run-workflow-confirm-dialog.component'
|
||||||
|
|
||||||
|
describe('RunWorkflowConfirmDialogComponent', () => {
|
||||||
|
let component: RunWorkflowConfirmDialogComponent
|
||||||
|
let fixture: ComponentFixture<RunWorkflowConfirmDialogComponent>
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [NgbActiveModal],
|
||||||
|
imports: [RunWorkflowConfirmDialogComponent],
|
||||||
|
}).compileComponents()
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(RunWorkflowConfirmDialogComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
component.workflows = [{ id: 5, name: 'Manual workflow' } as any]
|
||||||
|
fixture.detectChanges()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should disable confirm until a workflow is selected and not force by default', () => {
|
||||||
|
expect(component.confirmButtonEnabled).toBeFalsy()
|
||||||
|
expect(component.force).toBeFalsy()
|
||||||
|
|
||||||
|
component.workflowId = 5
|
||||||
|
component.workflowSelected()
|
||||||
|
expect(component.confirmButtonEnabled).toBeTruthy()
|
||||||
|
|
||||||
|
component.workflowId = null
|
||||||
|
component.workflowSelected()
|
||||||
|
expect(component.confirmButtonEnabled).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render an option per workflow', () => {
|
||||||
|
const options = fixture.nativeElement.querySelectorAll(
|
||||||
|
'#runWorkflowSelect option'
|
||||||
|
)
|
||||||
|
// placeholder option + one per workflow
|
||||||
|
expect(options).toHaveLength(2)
|
||||||
|
expect(options[1].textContent).toContain('Manual workflow')
|
||||||
|
})
|
||||||
|
})
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
import { Component } from '@angular/core'
|
||||||
|
import { FormsModule } from '@angular/forms'
|
||||||
|
import { Workflow } from 'src/app/data/workflow'
|
||||||
|
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-run-workflow-confirm-dialog',
|
||||||
|
templateUrl: './run-workflow-confirm-dialog.component.html',
|
||||||
|
imports: [FormsModule],
|
||||||
|
})
|
||||||
|
export class RunWorkflowConfirmDialogComponent extends ConfirmDialogComponent {
|
||||||
|
public workflows: Workflow[] = []
|
||||||
|
|
||||||
|
public workflowId: number = null
|
||||||
|
|
||||||
|
// i.e. ignore the workflow's trigger filters
|
||||||
|
public force: boolean = false
|
||||||
|
|
||||||
|
override confirmButtonEnabled = false
|
||||||
|
|
||||||
|
public workflowSelected(): void {
|
||||||
|
this.confirmButtonEnabled = !!this.workflowId
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
<div class="modal-header">
|
||||||
|
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
@if (messageBold) {
|
||||||
|
<p class="text-break"><b>{{messageBold}}</b></p>
|
||||||
|
}
|
||||||
|
@if (message) {
|
||||||
|
<p class="text-break" [innerHTML]="message"></p>
|
||||||
|
}
|
||||||
|
<pngx-input-select
|
||||||
|
[items]="templates()"
|
||||||
|
[(ngModel)]="template"
|
||||||
|
[allowNull]="true"
|
||||||
|
title="OCR template"
|
||||||
|
i18n-title
|
||||||
|
placeholder="All matching templates"
|
||||||
|
i18n-placeholder
|
||||||
|
></pngx-input-select>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()">
|
||||||
|
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled()">
|
||||||
|
{{btnCaption}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { of } from 'rxjs'
|
||||||
|
import { MATCH_NONE } from 'src/app/data/matching-model'
|
||||||
|
import { OcrTemplate } from 'src/app/data/ocr-template'
|
||||||
|
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
|
||||||
|
import { ZoneOcrConfirmDialogComponent } from './zone-ocr-confirm-dialog.component'
|
||||||
|
|
||||||
|
const templates: OcrTemplate[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Invoices',
|
||||||
|
match: '',
|
||||||
|
matching_algorithm: MATCH_NONE,
|
||||||
|
sample_document: null,
|
||||||
|
source_width: 0,
|
||||||
|
source_height: 0,
|
||||||
|
enabled: true,
|
||||||
|
zones: [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('ZoneOcrConfirmDialogComponent', () => {
|
||||||
|
let component: ZoneOcrConfirmDialogComponent
|
||||||
|
let fixture: ComponentFixture<ZoneOcrConfirmDialogComponent>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
NgbActiveModal,
|
||||||
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
],
|
||||||
|
imports: [ZoneOcrConfirmDialogComponent],
|
||||||
|
})
|
||||||
|
jest
|
||||||
|
.spyOn(TestBed.inject(OcrTemplateService), 'listAll')
|
||||||
|
.mockReturnValue(of({ count: 1, all: [1], results: templates }))
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ZoneOcrConfirmDialogComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
fixture.detectChanges()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should offer templates and default to all matching templates', () => {
|
||||||
|
expect(component.templates()).toEqual(templates)
|
||||||
|
expect(component.template).toBeNull()
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('OCR template')
|
||||||
|
})
|
||||||
|
})
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
import { Component, OnInit, inject, signal } from '@angular/core'
|
||||||
|
import { FormsModule } from '@angular/forms'
|
||||||
|
import { takeUntil } from 'rxjs'
|
||||||
|
import { OcrTemplate } from 'src/app/data/ocr-template'
|
||||||
|
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
|
||||||
|
import { SelectComponent } from '../../input/select/select.component'
|
||||||
|
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-zone-ocr-confirm-dialog',
|
||||||
|
templateUrl: './zone-ocr-confirm-dialog.component.html',
|
||||||
|
imports: [FormsModule, SelectComponent],
|
||||||
|
})
|
||||||
|
export class ZoneOcrConfirmDialogComponent
|
||||||
|
extends ConfirmDialogComponent
|
||||||
|
implements OnInit
|
||||||
|
{
|
||||||
|
private readonly ocrTemplateService = inject(OcrTemplateService)
|
||||||
|
|
||||||
|
public readonly templates = signal<OcrTemplate[]>([])
|
||||||
|
|
||||||
|
/** The template to run, or null for every template that matches. */
|
||||||
|
template: number | null = null
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.ocrTemplateService
|
||||||
|
.listAll()
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe((r) => this.templates.set(r.results))
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -164,7 +164,7 @@
|
|||||||
<pngx-input-text i18n-title title="Filter path" formControlName="filter_path" horizontal="true" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case-normalized." [error]="error?.filter_path"></pngx-input-text>
|
<pngx-input-text i18n-title title="Filter path" formControlName="filter_path" horizontal="true" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case-normalized." [error]="error?.filter_path"></pngx-input-text>
|
||||||
<pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules()" horizontal="true" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select>
|
<pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules()" horizontal="true" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select>
|
||||||
}
|
}
|
||||||
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
|
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled || formGroup.get('type').value === WorkflowTriggerType.Manual) {
|
||||||
<pngx-input-select i18n-title title="Content matching algorithm" horizontal="true" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
<pngx-input-select i18n-title title="Content matching algorithm" horizontal="true" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
||||||
@if (matchingPatternRequired(formGroup)) {
|
@if (matchingPatternRequired(formGroup)) {
|
||||||
<pngx-input-text i18n-title title="Content matching pattern" horizontal="true" formControlName="match" [error]="error?.match"></pngx-input-text>
|
<pngx-input-text i18n-title title="Content matching pattern" horizontal="true" formControlName="match" [error]="error?.match"></pngx-input-text>
|
||||||
@@ -175,7 +175,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
|
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled || formGroup.get('type').value === WorkflowTriggerType.Manual) {
|
||||||
<div class="row mt-3">
|
<div class="row mt-3">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="trigger-filters mb-3">
|
<div class="trigger-filters mb-3">
|
||||||
|
|||||||
+4
@@ -123,6 +123,10 @@ export const WORKFLOW_TYPE_OPTIONS = [
|
|||||||
id: WorkflowTriggerType.Scheduled,
|
id: WorkflowTriggerType.Scheduled,
|
||||||
name: $localize`Scheduled`,
|
name: $localize`Scheduled`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: WorkflowTriggerType.Manual,
|
||||||
|
name: $localize`Manual`,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export const WORKFLOW_ACTION_OPTIONS = [
|
export const WORKFLOW_ACTION_OPTIONS = [
|
||||||
|
|||||||
@@ -36,11 +36,11 @@
|
|||||||
[appendTo]="appendTo"
|
[appendTo]="appendTo"
|
||||||
[loading]="loading()"
|
[loading]="loading()"
|
||||||
[typeahead]="documentsInput$"
|
[typeahead]="documentsInput$"
|
||||||
(mousedown)="$event.stopImmediatePropagation()"
|
(mousedown)="appendTo && $event.stopImmediatePropagation()"
|
||||||
(change)="onChange(selectedDocumentIDs)">
|
(change)="onChange(selectedDocumentIDs)">
|
||||||
<ng-template ng-label-tmp let-document="item">
|
<ng-template ng-label-tmp let-document="item">
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
@if (!disabled) {
|
@if (!disabled && multiple) {
|
||||||
<button class="btn p-0 lh-1" (click)="unselect(document)" (mousedown)="$event.stopImmediatePropagation()" type="button" title="Remove link" i18n-title><pngx-bs-icon name="x"></pngx-bs-icon></button>
|
<button class="btn p-0 lh-1" (click)="unselect(document)" (mousedown)="$event.stopImmediatePropagation()" type="button" title="Remove link" i18n-title><pngx-bs-icon name="x"></pngx-bs-icon></button>
|
||||||
}
|
}
|
||||||
@if (document.title) {
|
@if (document.title) {
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ export class DocumentLinkComponent
|
|||||||
@Input()
|
@Input()
|
||||||
placeholder: string = $localize`Search for documents`
|
placeholder: string = $localize`Search for documents`
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
multiple: boolean = true
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parent for ng-select dropdown, needed to prevent close on click.
|
* Parent for ng-select dropdown, needed to prevent close on click.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ describe('SelectComponent', () => {
|
|||||||
let createNewVal
|
let createNewVal
|
||||||
component.createNew.subscribe((v) => (createNewVal = v))
|
component.createNew.subscribe((v) => (createNewVal = v))
|
||||||
expect(component.allowCreateNew).toBeTruthy()
|
expect(component.allowCreateNew).toBeTruthy()
|
||||||
|
const closeSpy = jest.spyOn(component.ngSelectComponent, 'close')
|
||||||
component.onSearch({ term: 'foo' })
|
component.onSearch({ term: 'foo' })
|
||||||
component.addItem(undefined)
|
component.addItem(undefined)
|
||||||
expect(createNewVal).toEqual('foo')
|
expect(createNewVal).toEqual('foo')
|
||||||
@@ -105,6 +106,8 @@ describe('SelectComponent', () => {
|
|||||||
component.onSearch({ term: 'baz' })
|
component.onSearch({ term: 'baz' })
|
||||||
component.clickNew()
|
component.clickNew()
|
||||||
expect(createNewVal).toEqual('baz')
|
expect(createNewVal).toEqual('baz')
|
||||||
|
// dropdown should close when creating a new item so it isn't left open behind the modal
|
||||||
|
expect(closeSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should search items by independent normalized terms', () => {
|
it('should search items by independent normalized terms', () => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
forwardRef,
|
forwardRef,
|
||||||
Input,
|
Input,
|
||||||
Output,
|
Output,
|
||||||
|
ViewChild,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import {
|
import {
|
||||||
FormsModule,
|
FormsModule,
|
||||||
@@ -11,7 +12,7 @@ import {
|
|||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
import { RouterModule } from '@angular/router'
|
import { RouterModule } from '@angular/router'
|
||||||
import { NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select'
|
||||||
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
||||||
import { matchesSearchText } from 'src/app/utils/text-search'
|
import { matchesSearchText } from 'src/app/utils/text-search'
|
||||||
import { AbstractInputComponent } from '../abstract-input'
|
import { AbstractInputComponent } from '../abstract-input'
|
||||||
@@ -124,6 +125,9 @@ export class SelectComponent extends AbstractInputComponent<number | string> {
|
|||||||
@Output()
|
@Output()
|
||||||
filterDocuments = new EventEmitter<any[]>()
|
filterDocuments = new EventEmitter<any[]>()
|
||||||
|
|
||||||
|
@ViewChild(NgSelectComponent)
|
||||||
|
ngSelectComponent: NgSelectComponent
|
||||||
|
|
||||||
public addItemRef: (name) => void
|
public addItemRef: (name) => void
|
||||||
|
|
||||||
private _lastSearchTerm: string
|
private _lastSearchTerm: string
|
||||||
@@ -150,11 +154,13 @@ export class SelectComponent extends AbstractInputComponent<number | string> {
|
|||||||
if (name) this.createNew.next(name)
|
if (name) this.createNew.next(name)
|
||||||
else this.createNew.next(this._lastSearchTerm)
|
else this.createNew.next(this._lastSearchTerm)
|
||||||
this.clearLastSearchTerm()
|
this.clearLastSearchTerm()
|
||||||
|
this.ngSelectComponent?.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
clickNew() {
|
clickNew() {
|
||||||
this.createNew.next(this._lastSearchTerm)
|
this.createNew.next(this._lastSearchTerm)
|
||||||
this.clearLastSearchTerm()
|
this.clearLastSearchTerm()
|
||||||
|
this.ngSelectComponent?.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
clearLastSearchTerm() {
|
clearLastSearchTerm() {
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
<div class="row pt-3 pb-2 align-items-center">
|
<div class="row pt-3 pb-2 align-items-center">
|
||||||
<div class="col-md text-truncate">
|
<div class="col-md text-truncate">
|
||||||
<h3 class="d-flex align-items-center mb-2 mb-md-1" style="line-height: 1.4">
|
<h3 class="d-flex align-items-center mb-2 mb-md-1" style="line-height: 1.4">
|
||||||
<span class="text-truncate">{{title()}}</span>
|
<span class="d-flex align-items-center mw-100">
|
||||||
@if (id()) {
|
<span class="text-truncate">{{title()}}</span>
|
||||||
<button type="button" class="badge border-0 bg-primary text-primary-text-contrast ms-3 small fs-normal" (click)="copyID()" title="Copy ID" i18n-title>
|
@if (id()) {
|
||||||
@if (copied()) {
|
<button type="button" class="badge border-0 bg-primary text-primary-text-contrast ms-3 small fs-normal flex-shrink-0 text-nowrap" (click)="copyID()" title="Copy ID" i18n-title>
|
||||||
<pngx-bs-icon width="1em" height="1em" name="clipboard-check" class="me-1"></pngx-bs-icon><ng-container i18n>Copied!</ng-container>
|
@if (copied()) {
|
||||||
} @else {
|
<pngx-bs-icon width="1em" height="1em" name="clipboard-check" class="me-1"></pngx-bs-icon><ng-container i18n>Copied!</ng-container>
|
||||||
ID: {{id()}}
|
} @else {
|
||||||
}
|
ID: {{id()}}
|
||||||
</button>
|
}
|
||||||
}
|
</button>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
@if (subTitle()) {
|
@if (subTitle()) {
|
||||||
<span class="page-subtitle h6 mb-0 mt-1 fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle()}}</span>
|
<span class="page-subtitle h6 mb-0 mt-1 fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle()}}</span>
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-4
@@ -62,13 +62,13 @@
|
|||||||
} @else {
|
} @else {
|
||||||
@switch (field) {
|
@switch (field) {
|
||||||
@case (DisplayField.ADDED) {
|
@case (DisplayField.ADDED) {
|
||||||
<a routerLink="/documents/{{doc.id}}" class="btn-link text-dark text-decoration-none py-2 py-md-3" title="Open document" i18n-title>{{doc.added | customDate}}</a>
|
<a routerLink="/documents/{{doc.id}}" (click)="activateViewForNavigation()" class="btn-link text-dark text-decoration-none py-2 py-md-3" title="Open document" i18n-title>{{doc.added | customDate}}</a>
|
||||||
}
|
}
|
||||||
@case (DisplayField.CREATED) {
|
@case (DisplayField.CREATED) {
|
||||||
<a routerLink="/documents/{{doc.id}}" class="btn-link text-dark text-decoration-none py-2 py-md-3" title="Open document" i18n-title>{{doc.created | customDate}}</a>
|
<a routerLink="/documents/{{doc.id}}" (click)="activateViewForNavigation()" class="btn-link text-dark text-decoration-none py-2 py-md-3" title="Open document" i18n-title>{{doc.created | customDate}}</a>
|
||||||
}
|
}
|
||||||
@case (DisplayField.TITLE) {
|
@case (DisplayField.TITLE) {
|
||||||
<a routerLink="/documents/{{doc.id}}" title="Open document" i18n-title class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.title | documentTitle}}</a>
|
<a routerLink="/documents/{{doc.id}}" (click)="activateViewForNavigation()" title="Open document" i18n-title class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.title | documentTitle}}</a>
|
||||||
}
|
}
|
||||||
@case (DisplayField.CORRESPONDENT) {
|
@case (DisplayField.CORRESPONDENT) {
|
||||||
@if (doc.correspondent) {
|
@if (doc.correspondent) {
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
}
|
}
|
||||||
@case (DisplayField.NOTES) {
|
@case (DisplayField.NOTES) {
|
||||||
@if (doc.notes.length) {
|
@if (doc.notes.length) {
|
||||||
<a routerLink="/documents/{{doc.id}}/notes" class="btn btn-sm p-0">
|
<a routerLink="/documents/{{doc.id}}/notes" (click)="activateViewForNavigation()" class="btn btn-sm p-0">
|
||||||
<span class="badge rounded-pill bg-light border text-primary">
|
<span class="badge rounded-pill bg-light border text-primary">
|
||||||
<pngx-bs-icon width="1.2em" height="1.2em" class="ms-1 me-1" name="chat-left-text"></pngx-bs-icon>
|
<pngx-bs-icon width="1.2em" height="1.2em" class="ms-1 me-1" name="chat-left-text"></pngx-bs-icon>
|
||||||
{{doc.notes.length}}</span>
|
{{doc.notes.length}}</span>
|
||||||
@@ -150,6 +150,7 @@
|
|||||||
<pngx-document-card-small
|
<pngx-document-card-small
|
||||||
class="p-0"
|
class="p-0"
|
||||||
[ngStyle]="{ opacity: show() ? 1 : 1 - (i * 1/documents().length) }"
|
[ngStyle]="{ opacity: show() ? 1 : 1 - (i * 1/documents().length) }"
|
||||||
|
(click)="activateViewForNavigation()"
|
||||||
(dblClickDocument)="openDocumentDetail(d)"
|
(dblClickDocument)="openDocumentDetail(d)"
|
||||||
[document]="d"
|
[document]="d"
|
||||||
[displayFields]="displayFields()"
|
[displayFields]="displayFields()"
|
||||||
@@ -173,6 +174,7 @@
|
|||||||
}
|
}
|
||||||
@for (d of documents(); track d.id; let i = $index) {
|
@for (d of documents(); track d.id; let i = $index) {
|
||||||
<pngx-document-card-large
|
<pngx-document-card-large
|
||||||
|
(click)="activateViewForNavigation()"
|
||||||
(dblClickDocument)="openDocumentDetail(d)"
|
(dblClickDocument)="openDocumentDetail(d)"
|
||||||
[document]="d"
|
[document]="d"
|
||||||
[ngStyle]="{ opacity: show() ? 1 : 1 - (i * 1/documents().length) }"
|
[ngStyle]="{ opacity: show() ? 1 : 1 - (i * 1/documents().length) }"
|
||||||
|
|||||||
+35
-1
@@ -16,7 +16,11 @@ import { routes } from 'src/app/app-routing.module'
|
|||||||
import { CustomFieldDisplayComponent } from 'src/app/components/common/custom-field-display/custom-field-display.component'
|
import { CustomFieldDisplayComponent } from 'src/app/components/common/custom-field-display/custom-field-display.component'
|
||||||
import { PreviewPopupComponent } from 'src/app/components/common/preview-popup/preview-popup.component'
|
import { PreviewPopupComponent } from 'src/app/components/common/preview-popup/preview-popup.component'
|
||||||
import { CustomFieldDataType } from 'src/app/data/custom-field'
|
import { CustomFieldDataType } from 'src/app/data/custom-field'
|
||||||
import { DisplayField, DisplayMode } from 'src/app/data/document'
|
import {
|
||||||
|
DEFAULT_DASHBOARD_VIEW_PAGE_SIZE,
|
||||||
|
DisplayField,
|
||||||
|
DisplayMode,
|
||||||
|
} from 'src/app/data/document'
|
||||||
import {
|
import {
|
||||||
FILTER_CORRESPONDENT,
|
FILTER_CORRESPONDENT,
|
||||||
FILTER_DOCUMENT_TYPE,
|
FILTER_DOCUMENT_TYPE,
|
||||||
@@ -277,10 +281,40 @@ describe('SavedViewWidgetComponent', () => {
|
|||||||
|
|
||||||
it('should navigate to document', () => {
|
it('should navigate to document', () => {
|
||||||
const routerSpy = jest.spyOn(router, 'navigate')
|
const routerSpy = jest.spyOn(router, 'navigate')
|
||||||
|
const activateSpy = jest.spyOn(
|
||||||
|
documentListViewService,
|
||||||
|
'activateSavedViewWithDocuments'
|
||||||
|
)
|
||||||
component.openDocumentDetail(documentResults[0])
|
component.openDocumentDetail(documentResults[0])
|
||||||
|
expect(activateSpy).toHaveBeenCalled()
|
||||||
expect(routerSpy).toHaveBeenCalledWith(['documents', documentResults[0].id])
|
expect(routerSpy).toHaveBeenCalledWith(['documents', documentResults[0].id])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should hand its documents to the list service when opening a document', () => {
|
||||||
|
jest.spyOn(documentService, 'listFiltered').mockReturnValue(
|
||||||
|
of({
|
||||||
|
all: [2, 3],
|
||||||
|
count: 25,
|
||||||
|
results: documentResults,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
jest.spyOn(router, 'navigateByUrl').mockResolvedValue(true)
|
||||||
|
const activateSpy = jest
|
||||||
|
.spyOn(documentListViewService, 'activateSavedViewWithDocuments')
|
||||||
|
.mockImplementation(() => {})
|
||||||
|
component.ngOnInit()
|
||||||
|
fixture.detectChanges()
|
||||||
|
fixture.debugElement
|
||||||
|
.query(By.css('a[title="Open document"]'))
|
||||||
|
.nativeElement.click()
|
||||||
|
expect(activateSpy).toHaveBeenCalledWith(savedView, documentResults, 25, 20)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fall back to the default dashboard page size', () => {
|
||||||
|
component.savedView = { ...savedView, page_size: undefined }
|
||||||
|
expect(component.pageSize).toEqual(DEFAULT_DASHBOARD_VIEW_PAGE_SIZE)
|
||||||
|
})
|
||||||
|
|
||||||
it('should navigate via quickfilter on click tag', () => {
|
it('should navigate via quickfilter on click tag', () => {
|
||||||
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
|
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
|
||||||
component.clickTag(11, new MouseEvent('click'))
|
component.clickTag(11, new MouseEvent('click'))
|
||||||
|
|||||||
+19
-2
@@ -132,7 +132,7 @@ export class SavedViewWidgetComponent
|
|||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.placeholderRows = Array.from(
|
this.placeholderRows = Array.from(
|
||||||
{
|
{
|
||||||
length: this.savedView?.page_size ?? DEFAULT_DASHBOARD_VIEW_PAGE_SIZE,
|
length: this.pageSize,
|
||||||
},
|
},
|
||||||
(_, index) => index
|
(_, index) => index
|
||||||
)
|
)
|
||||||
@@ -187,7 +187,7 @@ export class SavedViewWidgetComponent
|
|||||||
this.documentService
|
this.documentService
|
||||||
.listFiltered(
|
.listFiltered(
|
||||||
1,
|
1,
|
||||||
this.savedView?.page_size ?? DEFAULT_DASHBOARD_VIEW_PAGE_SIZE,
|
this.pageSize,
|
||||||
this.savedView.sort_field,
|
this.savedView.sort_field,
|
||||||
this.savedView.sort_reverse,
|
this.savedView.sort_reverse,
|
||||||
this.savedView.filter_rules,
|
this.savedView.filter_rules,
|
||||||
@@ -292,7 +292,24 @@ export class SavedViewWidgetComponent
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get pageSize(): number {
|
||||||
|
return this.savedView?.page_size ?? DEFAULT_DASHBOARD_VIEW_PAGE_SIZE
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pass the widget's documents to the list service to enable next / previous.
|
||||||
|
*/
|
||||||
|
activateViewForNavigation() {
|
||||||
|
this.documentListViewService.activateSavedViewWithDocuments(
|
||||||
|
this.savedView,
|
||||||
|
this.documents(),
|
||||||
|
this.count(),
|
||||||
|
this.pageSize
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
openDocumentDetail(document: Document) {
|
openDocumentDetail(document: Document) {
|
||||||
|
this.activateViewForNavigation()
|
||||||
this.router.navigate(['documents', document.id])
|
this.router.navigate(['documents', document.id])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,12 @@
|
|||||||
<pngx-bs-icon width="1em" height="1em" name="arrow-counterclockwise" class="me-2"></pngx-bs-icon><span i18n>Reprocess</span>
|
<pngx-bs-icon width="1em" height="1em" name="arrow-counterclockwise" class="me-2"></pngx-bs-icon><span i18n>Reprocess</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
@if (manualWorkflows().length > 0) {
|
||||||
|
<button ngbDropdownItem (click)="runWorkflow()" [disabled]="!userCanEdit || !userIsOwner">
|
||||||
|
<pngx-bs-icon width="1em" height="1em" name="play-circle" class="me-2"></pngx-bs-icon><span i18n>Run workflow</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
<button ngbDropdownItem (click)="printDocument()" [hidden]="useNativePdfViewer || isMobile">
|
<button ngbDropdownItem (click)="printDocument()" [hidden]="useNativePdfViewer || isMobile">
|
||||||
<pngx-bs-icon width="1em" height="1em" name="printer" class="me-2"></pngx-bs-icon><span i18n>Print</span>
|
<pngx-bs-icon width="1em" height="1em" name="printer" class="me-2"></pngx-bs-icon><span i18n>Print</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -61,6 +67,22 @@
|
|||||||
<pngx-bs-icon name="pencil" class="me-2"></pngx-bs-icon><ng-container i18n>PDF Editor</ng-container>
|
<pngx-bs-icon name="pencil" class="me-2"></pngx-bs-icon><ng-container i18n>PDF Editor</ng-container>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div class="d-flex" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.OcrTemplate }">
|
||||||
|
<button ngbDropdownItem (click)="runZoneOcr()" [disabled]="!userCanEdit || networkActive()">
|
||||||
|
<pngx-bs-icon width="1em" height="1em" name="bounding-box-circles" class="me-2"></pngx-bs-icon><span i18n>Run Zone OCR</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
ngbDropdownItem
|
||||||
|
class="w-auto flex-shrink-0 px-2"
|
||||||
|
(click)="runZoneOcrWithOptions()"
|
||||||
|
[disabled]="!userCanEdit || networkActive()"
|
||||||
|
title="Choose OCR template"
|
||||||
|
i18n-title
|
||||||
|
>
|
||||||
|
<pngx-bs-icon width="1em" height="1em" name="sliders2"></pngx-bs-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (userIsOwner && (requiresPassword || password)) {
|
@if (userIsOwner && (requiresPassword || password)) {
|
||||||
<button ngbDropdownItem (click)="removePassword()" [disabled]="!password">
|
<button ngbDropdownItem (click)="removePassword()" [disabled]="!password">
|
||||||
<pngx-bs-icon name="unlock" class="me-2"></pngx-bs-icon><ng-container i18n>Remove Password</ng-container>
|
<pngx-bs-icon name="unlock" class="me-2"></pngx-bs-icon><ng-container i18n>Remove Password</ng-container>
|
||||||
@@ -90,7 +112,7 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6 col-xl-5 mb-4">
|
<div class="col-md-6 col-xl-5 mb-4">
|
||||||
|
|
||||||
<form [formGroup]='documentForm' (ngSubmit)="save()">
|
<form [formGroup]='documentForm' (ngSubmit)="save()" [attr.inert]="zoneOcrRunning() ? '' : null" [attr.aria-busy]="zoneOcrRunning()">
|
||||||
|
|
||||||
<div class="btn-toolbar mb-1 pb-3 gap-2 row-gap-2 border-bottom">
|
<div class="btn-toolbar mb-1 pb-3 gap-2 row-gap-2 border-bottom">
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
|
|||||||
@@ -940,6 +940,20 @@ describe('DocumentDetailComponent', () => {
|
|||||||
expect(navigateSpy).toHaveBeenCalledWith(['view', 77])
|
expect(navigateSpy).toHaveBeenCalledWith(['view', 77])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should close back to the dashboard even if a saved view is active', () => {
|
||||||
|
initNormally()
|
||||||
|
jest
|
||||||
|
.spyOn(componentRouterService, 'getComponentURLBefore')
|
||||||
|
.mockReturnValue('dashboard')
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'activeSavedViewId', 'get')
|
||||||
|
.mockReturnValue(77)
|
||||||
|
const navigateSpy = jest.spyOn(router, 'navigate')
|
||||||
|
component.close()
|
||||||
|
expect(navigateSpy).toHaveBeenCalledWith(['dashboard'])
|
||||||
|
expect(navigateSpy).not.toHaveBeenCalledWith(['view', 77])
|
||||||
|
})
|
||||||
|
|
||||||
it('should not close if e.g. user-cancelled', () => {
|
it('should not close if e.g. user-cancelled', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
jest.spyOn(openDocumentsService, 'closeDocument').mockReturnValue(of(false))
|
jest.spyOn(openDocumentsService, 'closeDocument').mockReturnValue(of(false))
|
||||||
@@ -1016,6 +1030,27 @@ describe('DocumentDetailComponent', () => {
|
|||||||
expect(modalCloseSpy).toHaveBeenCalled()
|
expect(modalCloseSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should run a workflow on the document, confirm and close modal', () => {
|
||||||
|
initNormally()
|
||||||
|
const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit')
|
||||||
|
bulkEditSpy.mockReturnValue(of(true))
|
||||||
|
let openModal: NgbModalRef
|
||||||
|
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
||||||
|
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
|
component.runWorkflow()
|
||||||
|
const modalCloseSpy = jest.spyOn(openModal, 'close')
|
||||||
|
openModal.componentInstance.workflowId = 5
|
||||||
|
openModal.componentInstance.force = true
|
||||||
|
openModal.componentInstance.confirmClicked.next()
|
||||||
|
expect(bulkEditSpy).toHaveBeenCalledWith(
|
||||||
|
{ documents: [doc.id] },
|
||||||
|
'run_workflow',
|
||||||
|
{ workflow: 5, filter_documents: false }
|
||||||
|
)
|
||||||
|
expect(toastSpy).toHaveBeenCalled()
|
||||||
|
expect(modalCloseSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('should pass remote OCR choice when reprocessing', () => {
|
it('should pass remote OCR choice when reprocessing', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
|
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
|
||||||
@@ -1895,6 +1930,203 @@ describe('DocumentDetailComponent', () => {
|
|||||||
expect(toastSpy).toHaveBeenCalledWith('Document reloaded.')
|
expect(toastSpy).toHaveBeenCalledWith('Document reloaded.')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should run a chosen zone OCR template on the selected version and reload', () => {
|
||||||
|
component.documentId.set(doc.id)
|
||||||
|
component.document.set({ ...doc, versions: docVersions })
|
||||||
|
component.selectedVersionId.set(10)
|
||||||
|
jest.spyOn(documentService, 'runZoneOcr').mockReturnValue(
|
||||||
|
of({
|
||||||
|
results: [
|
||||||
|
{ zone: 'Total', value: '102.10' },
|
||||||
|
{ zone: 'Due', value: '' },
|
||||||
|
],
|
||||||
|
} as any)
|
||||||
|
)
|
||||||
|
const loadSpy = jest
|
||||||
|
.spyOn(component as any, 'loadDocument')
|
||||||
|
.mockImplementation(() => {})
|
||||||
|
const notifySpy = jest.spyOn(component.docChangeNotifier, 'next')
|
||||||
|
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
|
|
||||||
|
component.runZoneOcrWithOptions()
|
||||||
|
const dialog = modalSpy.mock.results[0].value.componentInstance
|
||||||
|
expect(dialog.message).toBe(
|
||||||
|
'Fields filled by the template will be overwritten.'
|
||||||
|
)
|
||||||
|
dialog.template = 3
|
||||||
|
dialog.confirmClicked.emit()
|
||||||
|
|
||||||
|
expect(documentService.runZoneOcr).toHaveBeenCalledWith(doc.id, 3, 10)
|
||||||
|
expect(toastSpy).toHaveBeenCalledWith(
|
||||||
|
'Filled 1 of 2 fields. No value found for: Due'
|
||||||
|
)
|
||||||
|
expect(notifySpy).toHaveBeenCalledWith(doc.id)
|
||||||
|
expect(loadSpy).toHaveBeenCalledWith(doc.id, true, expect.any(Function))
|
||||||
|
expect(component.zoneOcrRunning()).toBe(true)
|
||||||
|
loadSpy.mock.calls[0][2]()
|
||||||
|
expect(component.zoneOcrRunning()).toBe(false)
|
||||||
|
expect(component.networkActive()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should summarize zone OCR results', () => {
|
||||||
|
component.documentId.set(doc.id)
|
||||||
|
component.document.set({ ...doc, versions: docVersions })
|
||||||
|
component.selectedVersionId.set(12)
|
||||||
|
jest.spyOn(component as any, 'loadDocument').mockImplementation(() => {})
|
||||||
|
const runSpy = jest.spyOn(documentService, 'runZoneOcr')
|
||||||
|
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
|
|
||||||
|
runSpy.mockReturnValueOnce(of({ status: 'completed', results: [] }))
|
||||||
|
component.runZoneOcr()
|
||||||
|
expect(modalSpy).not.toHaveBeenCalled()
|
||||||
|
expect(runSpy).toHaveBeenCalledWith(doc.id, null, null)
|
||||||
|
expect(toastSpy).toHaveBeenLastCalledWith(
|
||||||
|
'Zone OCR found no fields to fill.'
|
||||||
|
)
|
||||||
|
|
||||||
|
component.networkActive.set(false)
|
||||||
|
runSpy.mockReturnValueOnce(
|
||||||
|
of({
|
||||||
|
status: 'completed',
|
||||||
|
results: [
|
||||||
|
{ template: 'T', zone: 'Total', custom_field: 'Amount', value: 0 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
component.runZoneOcr()
|
||||||
|
expect(toastSpy).toHaveBeenLastCalledWith('Filled 1 of 1 fields.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should confirm before running zone OCR over unsaved changes', () => {
|
||||||
|
component.documentId.set(doc.id)
|
||||||
|
component.document.set({ ...doc, __changedFields: ['title'] })
|
||||||
|
component.documentForm.patchValue({ title: 'Unsaved title' })
|
||||||
|
const runSpy = jest.spyOn(documentService, 'runZoneOcr')
|
||||||
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
|
|
||||||
|
component.runZoneOcr()
|
||||||
|
|
||||||
|
const modal = modalSpy.mock.results[0].value
|
||||||
|
expect(modal.componentInstance.message).toBe(
|
||||||
|
'Running Zone OCR will discard them.'
|
||||||
|
)
|
||||||
|
expect(runSpy).not.toHaveBeenCalled()
|
||||||
|
modal.componentInstance.cancel()
|
||||||
|
expect(runSpy).not.toHaveBeenCalled()
|
||||||
|
expect(component.documentForm.get('title').value).toBe('Unsaved title')
|
||||||
|
expect(component.zoneOcrRunning()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should warn about unsaved changes in the zone OCR options dialog', () => {
|
||||||
|
component.documentId.set(doc.id)
|
||||||
|
component.document.set({ ...doc, __changedFields: ['title'] })
|
||||||
|
const runSpy = jest.spyOn(documentService, 'runZoneOcr')
|
||||||
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
|
|
||||||
|
component.runZoneOcrWithOptions()
|
||||||
|
|
||||||
|
const dialog = modalSpy.mock.results[0].value.componentInstance
|
||||||
|
expect(dialog.message).toBe('Running Zone OCR will discard them.')
|
||||||
|
expect(dialog.btnClass).toBe('btn-warning')
|
||||||
|
dialog.cancel()
|
||||||
|
expect(runSpy).not.toHaveBeenCalled()
|
||||||
|
component.networkActive.set(true)
|
||||||
|
component.runZoneOcrWithOptions()
|
||||||
|
expect(modalSpy).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not allow saving or a second zone OCR run while one is running', () => {
|
||||||
|
component.documentId.set(doc.id)
|
||||||
|
component.document.set({
|
||||||
|
...doc,
|
||||||
|
versions: docVersions,
|
||||||
|
__changedFields: ['title'],
|
||||||
|
})
|
||||||
|
component.selectedVersionId.set(doc.id)
|
||||||
|
const pending = new Subject<any>()
|
||||||
|
const runSpy = jest
|
||||||
|
.spyOn(documentService, 'runZoneOcr')
|
||||||
|
.mockReturnValue(pending)
|
||||||
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
|
|
||||||
|
component.runZoneOcr()
|
||||||
|
modalSpy.mock.results[0].value.componentInstance.confirmClicked.emit()
|
||||||
|
|
||||||
|
expect(runSpy).toHaveBeenCalledWith(doc.id, null, doc.id)
|
||||||
|
expect(component.zoneOcrRunning()).toBe(true)
|
||||||
|
expect(component.networkActive()).toBe(true)
|
||||||
|
const saveSpy = jest.spyOn(documentService, 'patch')
|
||||||
|
component.save()
|
||||||
|
expect(saveSpy).not.toHaveBeenCalled()
|
||||||
|
component.runZoneOcr()
|
||||||
|
expect(modalSpy).toHaveBeenCalledTimes(1)
|
||||||
|
expect(runSpy).toHaveBeenCalledTimes(1)
|
||||||
|
pending.error(new Error('failed'))
|
||||||
|
expect(component.zoneOcrRunning()).toBe(false)
|
||||||
|
expect(component.networkActive()).toBe(false)
|
||||||
|
expect(component.document().__changedFields).toEqual(['title'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not reload after zone OCR if another document is open', () => {
|
||||||
|
component.documentId.set(doc.id)
|
||||||
|
component.document.set({ ...doc, __changedFields: [] })
|
||||||
|
const pending = new Subject<any>()
|
||||||
|
jest.spyOn(documentService, 'runZoneOcr').mockReturnValue(pending)
|
||||||
|
const loadSpy = jest.spyOn(component as any, 'loadDocument')
|
||||||
|
|
||||||
|
component.runZoneOcr()
|
||||||
|
component.documentId.set(99)
|
||||||
|
pending.next({ results: [] })
|
||||||
|
|
||||||
|
expect(loadSpy).not.toHaveBeenCalled()
|
||||||
|
expect(component.zoneOcrRunning()).toBe(false)
|
||||||
|
pending.complete()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should keep unsaved changes when zone OCR is skipped', () => {
|
||||||
|
component.documentId.set(doc.id)
|
||||||
|
component.document.set({ ...doc, __changedFields: ['title'] })
|
||||||
|
component.documentForm.patchValue({ title: 'Unsaved title' })
|
||||||
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
|
jest
|
||||||
|
.spyOn(documentService, 'runZoneOcr')
|
||||||
|
.mockReturnValue(of({ status: 'skipped', results: [] }))
|
||||||
|
const loadSpy = jest.spyOn(component as any, 'loadDocument')
|
||||||
|
const infoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
|
|
||||||
|
component.runZoneOcr()
|
||||||
|
modalSpy.mock.results[0].value.componentInstance.confirm()
|
||||||
|
|
||||||
|
expect(loadSpy).not.toHaveBeenCalled()
|
||||||
|
expect(component.documentForm.get('title').value).toBe('Unsaved title')
|
||||||
|
expect(component.zoneOcrRunning()).toBe(false)
|
||||||
|
expect(component.networkActive()).toBe(false)
|
||||||
|
expect(infoSpy).toHaveBeenCalledWith(
|
||||||
|
'No OCR template applies to this document.'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show an error when zone OCR fails', () => {
|
||||||
|
component.document.set(doc)
|
||||||
|
jest
|
||||||
|
.spyOn(documentService, 'runZoneOcr')
|
||||||
|
.mockReturnValue(throwError(() => new Error('nope')))
|
||||||
|
const loadSpy = jest.spyOn(component as any, 'loadDocument')
|
||||||
|
const errorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
|
||||||
|
component.runZoneOcr()
|
||||||
|
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith(
|
||||||
|
'Error running zone OCR.',
|
||||||
|
expect.any(Error)
|
||||||
|
)
|
||||||
|
expect(loadSpy).not.toHaveBeenCalled()
|
||||||
|
expect(component.zoneOcrRunning()).toBe(false)
|
||||||
|
expect(component.networkActive()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('should change preview element by render type', () => {
|
it('should change preview element by render type', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
component.document.update((document) => ({
|
component.document.update((document) => ({
|
||||||
|
|||||||
@@ -64,10 +64,13 @@ import {
|
|||||||
FILTER_STORAGE_PATH,
|
FILTER_STORAGE_PATH,
|
||||||
} from 'src/app/data/filter-rule-type'
|
} from 'src/app/data/filter-rule-type'
|
||||||
import { ObjectWithId } from 'src/app/data/object-with-id'
|
import { ObjectWithId } from 'src/app/data/object-with-id'
|
||||||
|
import { OcrZoneRunResult } from 'src/app/data/ocr-template'
|
||||||
import { StoragePath } from 'src/app/data/storage-path'
|
import { StoragePath } from 'src/app/data/storage-path'
|
||||||
import { Tag } from 'src/app/data/tag'
|
import { Tag } from 'src/app/data/tag'
|
||||||
import { SETTINGS_KEYS, SuggestionSource } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS, SuggestionSource } from 'src/app/data/ui-settings'
|
||||||
import { User } from 'src/app/data/user'
|
import { User } from 'src/app/data/user'
|
||||||
|
import { Workflow } from 'src/app/data/workflow'
|
||||||
|
import { WorkflowTriggerType } from 'src/app/data/workflow-trigger'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
||||||
@@ -93,6 +96,7 @@ import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
|||||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||||
import { TagService } from 'src/app/services/rest/tag.service'
|
import { TagService } from 'src/app/services/rest/tag.service'
|
||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
|
import { WorkflowService } from 'src/app/services/rest/workflow.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { TrashService } from 'src/app/services/trash.service'
|
import { TrashService } from 'src/app/services/trash.service'
|
||||||
@@ -104,6 +108,8 @@ import { DocumentDetailFieldID } from '../admin/settings/settings.component'
|
|||||||
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
||||||
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-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 { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
|
||||||
|
import { RunWorkflowConfirmDialogComponent } from '../common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component'
|
||||||
|
import { ZoneOcrConfirmDialogComponent } from '../common/confirm-dialog/zone-ocr-confirm-dialog/zone-ocr-confirm-dialog.component'
|
||||||
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.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 { 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'
|
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||||
@@ -233,6 +239,7 @@ export class DocumentDetailComponent
|
|||||||
private componentRouterService = inject(ComponentRouterService)
|
private componentRouterService = inject(ComponentRouterService)
|
||||||
private deviceDetectorService = inject(DeviceDetectorService)
|
private deviceDetectorService = inject(DeviceDetectorService)
|
||||||
private savedViewService = inject(SavedViewService)
|
private savedViewService = inject(SavedViewService)
|
||||||
|
private readonly workflowService = inject(WorkflowService)
|
||||||
private readonly websocketStatusService = inject(WebsocketStatusService)
|
private readonly websocketStatusService = inject(WebsocketStatusService)
|
||||||
private readonly useNativePdfViewerSetting = this.settings.getSignal<boolean>(
|
private readonly useNativePdfViewerSetting = this.settings.getSignal<boolean>(
|
||||||
SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER
|
SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER
|
||||||
@@ -266,6 +273,7 @@ export class DocumentDetailComponent
|
|||||||
readonly error = signal<any>(undefined)
|
readonly error = signal<any>(undefined)
|
||||||
|
|
||||||
readonly networkActive = signal(false)
|
readonly networkActive = signal(false)
|
||||||
|
readonly zoneOcrRunning = signal(false)
|
||||||
|
|
||||||
readonly documentId = signal<number>(undefined)
|
readonly documentId = signal<number>(undefined)
|
||||||
readonly document = signal<Document>(undefined)
|
readonly document = signal<Document>(undefined)
|
||||||
@@ -295,6 +303,7 @@ export class DocumentDetailComponent
|
|||||||
readonly correspondents = signal<Correspondent[]>(undefined)
|
readonly correspondents = signal<Correspondent[]>(undefined)
|
||||||
readonly documentTypes = signal<DocumentType[]>(undefined)
|
readonly documentTypes = signal<DocumentType[]>(undefined)
|
||||||
readonly storagePaths = signal<StoragePath[]>(undefined)
|
readonly storagePaths = signal<StoragePath[]>(undefined)
|
||||||
|
readonly manualWorkflows = signal<Workflow[]>([])
|
||||||
|
|
||||||
documentForm: FormGroup = new FormGroup({
|
documentForm: FormGroup = new FormGroup({
|
||||||
title: new FormControl(''),
|
title: new FormControl(''),
|
||||||
@@ -543,7 +552,11 @@ export class DocumentDetailComponent
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadDocument(documentId: number, forceRemote: boolean = false): void {
|
private loadDocument(
|
||||||
|
documentId: number,
|
||||||
|
forceRemote: boolean = false,
|
||||||
|
onComplete?: () => void
|
||||||
|
): void {
|
||||||
let redirectedToRoot = false
|
let redirectedToRoot = false
|
||||||
this.closeIncomingUpdateModal()
|
this.closeIncomingUpdateModal()
|
||||||
this.pendingIncomingUpdate = null
|
this.pendingIncomingUpdate = null
|
||||||
@@ -600,7 +613,8 @@ export class DocumentDetailComponent
|
|||||||
}),
|
}),
|
||||||
first(),
|
first(),
|
||||||
takeUntil(this.unsubscribeNotifier),
|
takeUntil(this.unsubscribeNotifier),
|
||||||
takeUntil(this.docChangeNotifier)
|
takeUntil(this.docChangeNotifier),
|
||||||
|
finalize(() => onComplete?.())
|
||||||
)
|
)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (doc) => {
|
next: (doc) => {
|
||||||
@@ -836,6 +850,20 @@ export class DocumentDetailComponent
|
|||||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((result) => this.users.set(result.results))
|
.subscribe((result) => this.users.set(result.results))
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.Workflow
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.workflowService
|
||||||
|
.list(1, 100000, null, null, {
|
||||||
|
trigger_type: WorkflowTriggerType.Manual,
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe((result) => this.manualWorkflows.set(result.results))
|
||||||
|
}
|
||||||
|
|
||||||
this.getCustomFields()
|
this.getCustomFields()
|
||||||
|
|
||||||
@@ -1334,6 +1362,7 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
save(close: boolean = false, savedCallback: () => void = null) {
|
save(close: boolean = false, savedCallback: () => void = null) {
|
||||||
|
if (this.zoneOcrRunning()) return
|
||||||
this.networkActive.set(true)
|
this.networkActive.set(true)
|
||||||
;(document.activeElement as HTMLElement)?.dispatchEvent(new Event('change'))
|
;(document.activeElement as HTMLElement)?.dispatchEvent(new Event('change'))
|
||||||
this.documentsService
|
this.documentsService
|
||||||
@@ -1454,7 +1483,12 @@ export class DocumentDetailComponent
|
|||||||
.subscribe((closed) => {
|
.subscribe((closed) => {
|
||||||
if (!closed) return
|
if (!closed) return
|
||||||
if (closedCallback) closedCallback()
|
if (closedCallback) closedCallback()
|
||||||
if (this.documentListViewService.activeSavedViewId) {
|
if (
|
||||||
|
this.componentRouterService.getComponentURLBefore() === 'dashboard'
|
||||||
|
) {
|
||||||
|
// e.g. opened from a dashboard widget
|
||||||
|
this.router.navigate(['dashboard'])
|
||||||
|
} else if (this.documentListViewService.activeSavedViewId) {
|
||||||
this.router.navigate([
|
this.router.navigate([
|
||||||
'view',
|
'view',
|
||||||
this.documentListViewService.activeSavedViewId,
|
this.documentListViewService.activeSavedViewId,
|
||||||
@@ -1554,6 +1588,146 @@ export class DocumentDetailComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runWorkflow() {
|
||||||
|
const modal = this.modalService.open(RunWorkflowConfirmDialogComponent, {
|
||||||
|
backdrop: 'static',
|
||||||
|
})
|
||||||
|
modal.componentInstance.title = $localize`Run workflow`
|
||||||
|
modal.componentInstance.message = $localize`Run a workflow on "${this.document().title}".`
|
||||||
|
modal.componentInstance.btnCaption = $localize`Run`
|
||||||
|
modal.componentInstance.workflows = this.manualWorkflows()
|
||||||
|
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||||
|
modal.componentInstance.buttonsEnabled.set(false)
|
||||||
|
this.documentsService
|
||||||
|
.bulkEdit({ documents: [this.document().id] }, 'run_workflow', {
|
||||||
|
workflow: modal.componentInstance.workflowId,
|
||||||
|
filter_documents: !modal.componentInstance.force,
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.toastService.showInfo(
|
||||||
|
$localize`Workflow will run on "${this.document().title}" in the background.`
|
||||||
|
)
|
||||||
|
modal.close()
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
modal.componentInstance.buttonsEnabled.set(true)
|
||||||
|
this.toastService.showError(
|
||||||
|
$localize`Error executing operation`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs every template that matches, confirming only over unsaved changes. */
|
||||||
|
runZoneOcr() {
|
||||||
|
if (this.networkActive()) return
|
||||||
|
const documentId = this.documentId()
|
||||||
|
const versionId = this.getSelectedNonLatestVersionId()
|
||||||
|
if (!this.hasLocalEdits(this.document())) {
|
||||||
|
this.executeZoneOcr(documentId, null, versionId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const modal = this.modalService.open(ConfirmDialogComponent, {
|
||||||
|
backdrop: 'static',
|
||||||
|
})
|
||||||
|
modal.componentInstance.title = $localize`Run Zone OCR`
|
||||||
|
modal.componentInstance.messageBold = $localize`You have unsaved changes.`
|
||||||
|
modal.componentInstance.message = $localize`Running Zone OCR will discard them.`
|
||||||
|
modal.componentInstance.btnClass = 'btn-warning'
|
||||||
|
modal.componentInstance.btnCaption = $localize`Run Zone OCR`
|
||||||
|
modal.componentInstance.confirmClicked
|
||||||
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe(() => {
|
||||||
|
modal.close()
|
||||||
|
if (this.documentId() === documentId && !this.networkActive()) {
|
||||||
|
this.executeZoneOcr(documentId, null, versionId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the dialog to pick which template to run. */
|
||||||
|
runZoneOcrWithOptions() {
|
||||||
|
if (this.networkActive()) return
|
||||||
|
const documentId = this.documentId()
|
||||||
|
const versionId = this.getSelectedNonLatestVersionId()
|
||||||
|
const modal = this.modalService.open(ZoneOcrConfirmDialogComponent, {
|
||||||
|
backdrop: 'static',
|
||||||
|
})
|
||||||
|
const dialog = modal.componentInstance as ZoneOcrConfirmDialogComponent
|
||||||
|
dialog.title = $localize`Run Zone OCR`
|
||||||
|
dialog.btnCaption = $localize`Run Zone OCR`
|
||||||
|
if (this.hasLocalEdits(this.document())) {
|
||||||
|
dialog.messageBold = $localize`You have unsaved changes.`
|
||||||
|
dialog.message = $localize`Running Zone OCR will discard them.`
|
||||||
|
dialog.btnClass = 'btn-warning'
|
||||||
|
} else {
|
||||||
|
dialog.message = $localize`Fields filled by the template will be overwritten.`
|
||||||
|
}
|
||||||
|
dialog.confirmClicked
|
||||||
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe(() => {
|
||||||
|
modal.close()
|
||||||
|
if (this.documentId() === documentId && !this.networkActive()) {
|
||||||
|
this.executeZoneOcr(documentId, dialog.template, versionId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private executeZoneOcr(
|
||||||
|
documentId: number,
|
||||||
|
templateId: number | null,
|
||||||
|
versionId: number | null
|
||||||
|
) {
|
||||||
|
this.zoneOcrRunning.set(true)
|
||||||
|
this.networkActive.set(true)
|
||||||
|
this.documentsService
|
||||||
|
.runZoneOcr(documentId, templateId, versionId)
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
if (
|
||||||
|
this.documentId() !== documentId ||
|
||||||
|
response.status === 'skipped'
|
||||||
|
) {
|
||||||
|
this.finishZoneOcr()
|
||||||
|
if (response.status === 'skipped') {
|
||||||
|
this.toastService.showInfo(
|
||||||
|
$localize`No OCR template applies to this document.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.toastService.showInfo(this.zoneOcrSummary(response.results))
|
||||||
|
this.docChangeNotifier.next(documentId)
|
||||||
|
this.loadDocument(documentId, true, () => this.finishZoneOcr())
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
this.finishZoneOcr()
|
||||||
|
this.toastService.showError($localize`Error running zone OCR.`, error)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private zoneOcrSummary(results: OcrZoneRunResult[]): string {
|
||||||
|
if (!results.length) return $localize`Zone OCR found no fields to fill.`
|
||||||
|
const missed = results
|
||||||
|
.filter((result) => !`${result.value ?? ''}`.trim())
|
||||||
|
.map((result) => result.zone)
|
||||||
|
const filled = results.length - missed.length
|
||||||
|
return missed.length
|
||||||
|
? $localize`Filled ${filled} of ${results.length} fields. No value found for: ${missed.join(', ')}`
|
||||||
|
: $localize`Filled ${filled} of ${results.length} fields.`
|
||||||
|
}
|
||||||
|
|
||||||
|
private finishZoneOcr() {
|
||||||
|
this.zoneOcrRunning.set(false)
|
||||||
|
this.networkActive.set(false)
|
||||||
|
this.flushPendingIncomingUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
private getSelectedNonLatestVersionId(): number | null {
|
private getSelectedNonLatestVersionId(): number | null {
|
||||||
const versions = this.document()?.versions ?? []
|
const versions = this.document()?.versions ?? []
|
||||||
if (!versions.length || !this.selectedVersionId()) {
|
if (!versions.length || !this.selectedVersionId()) {
|
||||||
|
|||||||
@@ -98,6 +98,16 @@
|
|||||||
<button ngbDropdownItem (click)="mergeSelectedAsVersions()" [disabled]="!userOwnsAll || !userCanEditAll || !userCanDelete || list.allSelected || list.selectedCount < 2">
|
<button ngbDropdownItem (click)="mergeSelectedAsVersions()" [disabled]="!userOwnsAll || !userCanEditAll || !userCanDelete || list.allSelected || list.selectedCount < 2">
|
||||||
<pngx-bs-icon name="journal-bookmark-fill" class="me-2"></pngx-bs-icon><ng-container i18n>Merge as versions</ng-container>
|
<pngx-bs-icon name="journal-bookmark-fill" class="me-2"></pngx-bs-icon><ng-container i18n>Merge as versions</ng-container>
|
||||||
</button>
|
</button>
|
||||||
|
@if (manualWorkflows.length > 0) {
|
||||||
|
<button ngbDropdownItem (click)="runWorkflowSelected()" [disabled]="!canRunWorkflow">
|
||||||
|
<pngx-bs-icon name="play-circle" class="me-2"></pngx-bs-icon><ng-container i18n>Run workflow</ng-container>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.OcrTemplate)) {
|
||||||
|
<button ngbDropdownItem (click)="runZoneOcrSelected()" [disabled]="!userCanEditAll">
|
||||||
|
<pngx-bs-icon name="bounding-box-circles" class="me-2"></pngx-bs-icon><ng-container i18n>Run Zone OCR</ng-container>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+219
-56
@@ -17,6 +17,7 @@ import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
|
|||||||
import { Results } from 'src/app/data/results'
|
import { Results } from 'src/app/data/results'
|
||||||
import { StoragePath } from 'src/app/data/storage-path'
|
import { StoragePath } from 'src/app/data/storage-path'
|
||||||
import { Tag } from 'src/app/data/tag'
|
import { Tag } from 'src/app/data/tag'
|
||||||
|
import { WorkflowTriggerType } from 'src/app/data/workflow-trigger'
|
||||||
import { FilterPipe } from 'src/app/pipes/filter.pipe'
|
import { FilterPipe } from 'src/app/pipes/filter.pipe'
|
||||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +37,7 @@ import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.
|
|||||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||||
import { TagService } from 'src/app/services/rest/tag.service'
|
import { TagService } from 'src/app/services/rest/tag.service'
|
||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
|
import { WorkflowService } from 'src/app/services/rest/workflow.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { TrashService } from 'src/app/services/trash.service'
|
import { TrashService } from 'src/app/services/trash.service'
|
||||||
@@ -83,6 +85,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
let customFieldsService: CustomFieldsService
|
let customFieldsService: CustomFieldsService
|
||||||
let httpTestingController: HttpTestingController
|
let httpTestingController: HttpTestingController
|
||||||
let shareLinkBundleService: ShareLinkBundleService
|
let shareLinkBundleService: ShareLinkBundleService
|
||||||
|
let workflowService: WorkflowService
|
||||||
let router: Router
|
let router: Router
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -173,6 +176,22 @@ describe('BulkEditorComponent', () => {
|
|||||||
delete: jest.fn(),
|
delete: jest.fn(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: WorkflowService,
|
||||||
|
useValue: {
|
||||||
|
list: jest.fn(() =>
|
||||||
|
of({
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
name: 'Manual workflow',
|
||||||
|
triggers: [{ type: WorkflowTriggerType.Manual }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: Router,
|
provide: Router,
|
||||||
useValue: { navigate: jest.fn().mockResolvedValue(true) },
|
useValue: { navigate: jest.fn().mockResolvedValue(true) },
|
||||||
@@ -195,6 +214,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
customFieldsService = TestBed.inject(CustomFieldsService)
|
customFieldsService = TestBed.inject(CustomFieldsService)
|
||||||
httpTestingController = TestBed.inject(HttpTestingController)
|
httpTestingController = TestBed.inject(HttpTestingController)
|
||||||
shareLinkBundleService = TestBed.inject(ShareLinkBundleService)
|
shareLinkBundleService = TestBed.inject(ShareLinkBundleService)
|
||||||
|
workflowService = TestBed.inject(WorkflowService)
|
||||||
router = TestBed.inject(Router)
|
router = TestBed.inject(Router)
|
||||||
|
|
||||||
fixture = TestBed.createComponent(BulkEditorComponent)
|
fixture = TestBed.createComponent(BulkEditorComponent)
|
||||||
@@ -559,18 +579,19 @@ describe('BulkEditorComponent', () => {
|
|||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||||
)
|
)
|
||||||
req.flush(true)
|
req.flush({ result: 'OK', task_id: 'queued-task' })
|
||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
|
from_webui: true,
|
||||||
documents: [3, 4],
|
documents: [3, 4],
|
||||||
method: 'modify_tags',
|
method: 'modify_tags',
|
||||||
parameters: { add_tags: [101], remove_tags: [] },
|
parameters: { add_tags: [101], remove_tags: [] },
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.expectNone(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
) // list reload
|
)
|
||||||
httpTestingController.match(
|
httpTestingController.expectNone(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
) // listAllFilteredIds
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should execute modify tags bulk operation for all filtered documents', () => {
|
it('should execute modify tags bulk operation for all filtered documents', () => {
|
||||||
@@ -607,17 +628,18 @@ describe('BulkEditorComponent', () => {
|
|||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||||
)
|
)
|
||||||
req.flush(true)
|
req.flush({ result: 'OK', task_id: 'queued-task' })
|
||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
|
from_webui: true,
|
||||||
all: true,
|
all: true,
|
||||||
filters: { title_search: 'apple' },
|
filters: { title_search: 'apple' },
|
||||||
excluded_documents: [4],
|
excluded_documents: [4],
|
||||||
method: 'modify_tags',
|
method: 'modify_tags',
|
||||||
parameters: { add_tags: [101], remove_tags: [] },
|
parameters: { add_tags: [101], remove_tags: [] },
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.expectNone(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
) // list reload
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should execute modify tags bulk operation with confirmation dialog if enabled', () => {
|
it('should execute modify tags bulk operation with confirmation dialog if enabled', () => {
|
||||||
@@ -644,6 +666,51 @@ describe('BulkEditorComponent', () => {
|
|||||||
httpTestingController
|
httpTestingController
|
||||||
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
||||||
.flush(true)
|
.flush(true)
|
||||||
|
httpTestingController.expectNone(
|
||||||
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
|
)
|
||||||
|
httpTestingController.expectNone(
|
||||||
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should load manual workflows on init', () => {
|
||||||
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(workflowService.list).toHaveBeenCalledWith(1, 100000, null, null, {
|
||||||
|
trigger_type: WorkflowTriggerType.Manual,
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
expect(component.manualWorkflows).toHaveLength(1)
|
||||||
|
expect(component.manualWorkflows[0].id).toEqual(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should run a workflow on the selection via the run workflow dialog', () => {
|
||||||
|
let modal: NgbModalRef
|
||||||
|
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||||
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'documents', 'get')
|
||||||
|
.mockReturnValue([{ id: 3 }, { id: 4 }])
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'selected', 'get')
|
||||||
|
.mockReturnValue(new Set([3, 4]))
|
||||||
|
fixture.detectChanges()
|
||||||
|
component.runWorkflowSelected()
|
||||||
|
expect(modal).not.toBeUndefined()
|
||||||
|
expect(modal.componentInstance.workflows).toEqual(component.manualWorkflows)
|
||||||
|
modal.componentInstance.workflowId = 5
|
||||||
|
modal.componentInstance.force = true
|
||||||
|
modal.componentInstance.confirm()
|
||||||
|
const req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||||
|
)
|
||||||
|
expect(req.request.body.method).toEqual('run_workflow')
|
||||||
|
expect(req.request.body.parameters).toEqual({
|
||||||
|
workflow: 5,
|
||||||
|
filter_documents: false,
|
||||||
|
})
|
||||||
|
req.flush(true)
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
@@ -729,18 +796,19 @@ describe('BulkEditorComponent', () => {
|
|||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||||
)
|
)
|
||||||
req.flush(true)
|
req.flush({ result: 'OK', task_id: 'queued-task' })
|
||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
|
from_webui: true,
|
||||||
documents: [3, 4],
|
documents: [3, 4],
|
||||||
method: 'set_correspondent',
|
method: 'set_correspondent',
|
||||||
parameters: { correspondent: 101 },
|
parameters: { correspondent: 101 },
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.expectNone(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
) // list reload
|
)
|
||||||
httpTestingController.match(
|
httpTestingController.expectNone(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
) // listAllFilteredIds
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should execute modify correspondent bulk operation with confirmation dialog if enabled', () => {
|
it('should execute modify correspondent bulk operation with confirmation dialog if enabled', () => {
|
||||||
@@ -767,12 +835,12 @@ describe('BulkEditorComponent', () => {
|
|||||||
httpTestingController
|
httpTestingController
|
||||||
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
||||||
.flush(true)
|
.flush(true)
|
||||||
httpTestingController.match(
|
httpTestingController.expectNone(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
) // list reload
|
)
|
||||||
httpTestingController.match(
|
httpTestingController.expectNone(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
) // listAllFilteredIds
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should set modal dialog text accordingly for correspondent edit confirmation', () => {
|
it('should set modal dialog text accordingly for correspondent edit confirmation', () => {
|
||||||
@@ -827,18 +895,19 @@ describe('BulkEditorComponent', () => {
|
|||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||||
)
|
)
|
||||||
req.flush(true)
|
req.flush({ result: 'OK', task_id: 'queued-task' })
|
||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
|
from_webui: true,
|
||||||
documents: [3, 4],
|
documents: [3, 4],
|
||||||
method: 'set_document_type',
|
method: 'set_document_type',
|
||||||
parameters: { document_type: 101 },
|
parameters: { document_type: 101 },
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.expectNone(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
) // list reload
|
)
|
||||||
httpTestingController.match(
|
httpTestingController.expectNone(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
) // listAllFilteredIds
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should execute modify document type bulk operation with confirmation dialog if enabled', () => {
|
it('should execute modify document type bulk operation with confirmation dialog if enabled', () => {
|
||||||
@@ -865,12 +934,12 @@ describe('BulkEditorComponent', () => {
|
|||||||
httpTestingController
|
httpTestingController
|
||||||
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
||||||
.flush(true)
|
.flush(true)
|
||||||
httpTestingController.match(
|
httpTestingController.expectNone(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
) // list reload
|
)
|
||||||
httpTestingController.match(
|
httpTestingController.expectNone(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
) // listAllFilteredIds
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should set modal dialog text accordingly for document type edit confirmation', () => {
|
it('should set modal dialog text accordingly for document type edit confirmation', () => {
|
||||||
@@ -925,18 +994,19 @@ describe('BulkEditorComponent', () => {
|
|||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||||
)
|
)
|
||||||
req.flush(true)
|
req.flush({ result: 'OK', task_id: 'queued-task' })
|
||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
|
from_webui: true,
|
||||||
documents: [3, 4],
|
documents: [3, 4],
|
||||||
method: 'set_storage_path',
|
method: 'set_storage_path',
|
||||||
parameters: { storage_path: 101 },
|
parameters: { storage_path: 101 },
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.expectNone(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
) // list reload
|
)
|
||||||
httpTestingController.match(
|
httpTestingController.expectNone(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
) // listAllFilteredIds
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should execute modify storage path bulk operation with confirmation dialog if enabled', () => {
|
it('should execute modify storage path bulk operation with confirmation dialog if enabled', () => {
|
||||||
@@ -963,12 +1033,12 @@ describe('BulkEditorComponent', () => {
|
|||||||
httpTestingController
|
httpTestingController
|
||||||
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
||||||
.flush(true)
|
.flush(true)
|
||||||
httpTestingController.match(
|
httpTestingController.expectNone(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
) // list reload
|
)
|
||||||
httpTestingController.match(
|
httpTestingController.expectNone(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
) // listAllFilteredIds
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should set modal dialog text accordingly for storage path edit confirmation', () => {
|
it('should set modal dialog text accordingly for storage path edit confirmation', () => {
|
||||||
@@ -1023,18 +1093,19 @@ describe('BulkEditorComponent', () => {
|
|||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||||
)
|
)
|
||||||
req.flush(true)
|
req.flush({ result: 'OK', task_id: 'queued-task' })
|
||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
|
from_webui: true,
|
||||||
documents: [3, 4],
|
documents: [3, 4],
|
||||||
method: 'modify_custom_fields',
|
method: 'modify_custom_fields',
|
||||||
parameters: { add_custom_fields: [101], remove_custom_fields: [102] },
|
parameters: { add_custom_fields: [101], remove_custom_fields: [102] },
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.expectNone(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
) // list reload
|
)
|
||||||
httpTestingController.match(
|
httpTestingController.expectNone(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
) // listAllFilteredIds
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should execute modify custom fields bulk operation with confirmation dialog if enabled', () => {
|
it('should execute modify custom fields bulk operation with confirmation dialog if enabled', () => {
|
||||||
@@ -1061,12 +1132,12 @@ describe('BulkEditorComponent', () => {
|
|||||||
httpTestingController
|
httpTestingController
|
||||||
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
||||||
.flush(true)
|
.flush(true)
|
||||||
httpTestingController.match(
|
httpTestingController.expectNone(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
) // list reload
|
)
|
||||||
httpTestingController.match(
|
httpTestingController.expectNone(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
) // listAllFilteredIds
|
)
|
||||||
|
|
||||||
// coverage for modal messages
|
// coverage for modal messages
|
||||||
component.setCustomFields({
|
component.setCustomFields({
|
||||||
@@ -1285,6 +1356,53 @@ describe('BulkEditorComponent', () => {
|
|||||||
) // listAllFilteredIds
|
) // listAllFilteredIds
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should support run zone ocr', () => {
|
||||||
|
let modal: NgbModalRef
|
||||||
|
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||||
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'documents', 'get')
|
||||||
|
.mockReturnValue([{ id: 3 }, { id: 4 }])
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'selected', 'get')
|
||||||
|
.mockReturnValue(new Set([3, 4]))
|
||||||
|
jest
|
||||||
|
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
|
||||||
|
.mockReturnValue(true)
|
||||||
|
fixture.detectChanges()
|
||||||
|
component.runZoneOcrSelected()
|
||||||
|
expect(modal).not.toBeUndefined()
|
||||||
|
modal.componentInstance.confirm()
|
||||||
|
let req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/run_zone_ocr/`
|
||||||
|
)
|
||||||
|
req.flush(true)
|
||||||
|
expect(req.request.body).toEqual({
|
||||||
|
documents: [3, 4],
|
||||||
|
})
|
||||||
|
|
||||||
|
component.runZoneOcrSelected()
|
||||||
|
modal.componentInstance.template = 3
|
||||||
|
modal.componentInstance.confirm()
|
||||||
|
req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/run_zone_ocr/`
|
||||||
|
)
|
||||||
|
req.flush(true)
|
||||||
|
expect(req.request.body).toEqual({
|
||||||
|
documents: [3, 4],
|
||||||
|
template: 3,
|
||||||
|
})
|
||||||
|
httpTestingController.match(
|
||||||
|
`${environment.apiBaseUrl}ocr_templates/?page=1&page_size=100000`
|
||||||
|
) // template list
|
||||||
|
httpTestingController.match(
|
||||||
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
|
) // list reload
|
||||||
|
httpTestingController.match(
|
||||||
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
|
) // listAllFilteredIds
|
||||||
|
})
|
||||||
|
|
||||||
it('should support rotate', () => {
|
it('should support rotate', () => {
|
||||||
let modal: NgbModalRef
|
let modal: NgbModalRef
|
||||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||||
@@ -1540,6 +1658,47 @@ describe('BulkEditorComponent', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each([true, false])(
|
||||||
|
'shows queued feedback and gates the task link on permission (%s)',
|
||||||
|
(canViewTasks) => {
|
||||||
|
jest
|
||||||
|
.spyOn(permissionsService, 'currentUserCan')
|
||||||
|
.mockReturnValue(canViewTasks)
|
||||||
|
const queued = jest.spyOn(toastService, 'show')
|
||||||
|
let modal: NgbModalRef
|
||||||
|
modalService.activeInstances.subscribe((instances) => {
|
||||||
|
if (instances.length) modal = instances[0]
|
||||||
|
})
|
||||||
|
const reload = jest.spyOn(documentListViewService, 'reload')
|
||||||
|
component.setPermissions()
|
||||||
|
const close = jest.spyOn(modal, 'close')
|
||||||
|
modal.componentInstance.confirmClicked.emit({
|
||||||
|
permissions: {},
|
||||||
|
merge: true,
|
||||||
|
})
|
||||||
|
const req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||||
|
)
|
||||||
|
req.flush({ result: 'OK', task_id: 'queued-operation' })
|
||||||
|
expect(close).toHaveBeenCalledTimes(1)
|
||||||
|
const notification = queued.mock.calls.at(-1)[0]
|
||||||
|
expect(notification.content).toBe('Bulk operation queued.')
|
||||||
|
if (canViewTasks) {
|
||||||
|
expect(notification.actionName).toBe('View task')
|
||||||
|
notification.action()
|
||||||
|
expect(router.navigate).toHaveBeenCalledWith(['/tasks'], {
|
||||||
|
queryParams: { task_id: 'queued-operation' },
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
expect(notification.action).toBeUndefined()
|
||||||
|
expect(notification.actionName).toBeUndefined()
|
||||||
|
}
|
||||||
|
expect(reload).not.toHaveBeenCalled()
|
||||||
|
expect(documentListViewService.selected.size).toBe(0)
|
||||||
|
expect(documentListViewService.allSelected).toBe(false)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
it('should support bulk permissions update', () => {
|
it('should support bulk permissions update', () => {
|
||||||
let modal: NgbModalRef
|
let modal: NgbModalRef
|
||||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||||
@@ -1572,8 +1731,9 @@ describe('BulkEditorComponent', () => {
|
|||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||||
)
|
)
|
||||||
req.flush(true)
|
req.flush({ result: 'OK', task_id: 'queued-task' })
|
||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
|
from_webui: true,
|
||||||
documents: [3, 4],
|
documents: [3, 4],
|
||||||
method: 'set_permissions',
|
method: 'set_permissions',
|
||||||
parameters: {
|
parameters: {
|
||||||
@@ -1581,12 +1741,12 @@ describe('BulkEditorComponent', () => {
|
|||||||
merge: true,
|
merge: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.expectNone(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
) // list reload
|
)
|
||||||
httpTestingController.match(
|
httpTestingController.expectNone(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
) // listAllFilteredIds
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not attempt to retrieve objects if user does not have permissions', () => {
|
it('should not attempt to retrieve objects if user does not have permissions', () => {
|
||||||
@@ -1887,15 +2047,18 @@ describe('BulkEditorComponent', () => {
|
|||||||
expect(toastServiceShowErrorSpy).toHaveBeenCalled()
|
expect(toastServiceShowErrorSpy).toHaveBeenCalled()
|
||||||
expect(listReloadSpy).not.toHaveBeenCalled()
|
expect(listReloadSpy).not.toHaveBeenCalled()
|
||||||
|
|
||||||
modal.componentInstance.succeeded.emit()
|
modal.componentInstance.succeeded.emit({
|
||||||
expect(toastServiceShowInfoSpy).toHaveBeenCalled()
|
result: 'OK',
|
||||||
expect(listReloadSpy).toHaveBeenCalled()
|
task_id: 'custom-fields-task',
|
||||||
httpTestingController.match(
|
})
|
||||||
|
expect(toastServiceShowInfoSpy).not.toHaveBeenCalled()
|
||||||
|
expect(listReloadSpy).not.toHaveBeenCalled()
|
||||||
|
httpTestingController.expectNone(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
) // list reload
|
)
|
||||||
httpTestingController.match(
|
httpTestingController.expectNone(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
) // listAllFilteredIds
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should create share link bundle and enable manage callback', () => {
|
it('should create share link bundle and enable manage callback', () => {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { CustomField } from 'src/app/data/custom-field'
|
|||||||
import { MatchingModel } from 'src/app/data/matching-model'
|
import { MatchingModel } from 'src/app/data/matching-model'
|
||||||
import { SelectionDataItem } from 'src/app/data/results'
|
import { SelectionDataItem } from 'src/app/data/results'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
|
import { Workflow } from 'src/app/data/workflow'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||||
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
||||||
@@ -53,6 +54,7 @@ import {
|
|||||||
} from 'src/app/services/rest/document.service'
|
} from 'src/app/services/rest/document.service'
|
||||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||||
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
||||||
|
import { WorkflowService } from 'src/app/services/rest/workflow.service'
|
||||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||||
import { TagService } from 'src/app/services/rest/tag.service'
|
import { TagService } from 'src/app/services/rest/tag.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
@@ -64,6 +66,8 @@ import { MergeAsVersionsConfirmDialogComponent } from '../../common/confirm-dial
|
|||||||
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
||||||
import { ReprocessConfirmDialogComponent } from '../../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
|
import { ReprocessConfirmDialogComponent } from '../../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
|
||||||
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
||||||
|
import { RunWorkflowConfirmDialogComponent } from '../../common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component'
|
||||||
|
import { ZoneOcrConfirmDialogComponent } from '../../common/confirm-dialog/zone-ocr-confirm-dialog/zone-ocr-confirm-dialog.component'
|
||||||
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||||
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||||
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||||
@@ -81,6 +85,7 @@ import { PermissionsDialogComponent } from '../../common/permissions-dialog/perm
|
|||||||
import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component'
|
import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component'
|
||||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||||
import { CustomFieldsBulkEditDialogComponent } from './custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component'
|
import { CustomFieldsBulkEditDialogComponent } from './custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component'
|
||||||
|
import { WorkflowTriggerType } from 'src/app/data/workflow-trigger'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-bulk-editor',
|
selector: 'pngx-bulk-editor',
|
||||||
@@ -104,18 +109,21 @@ export class BulkEditorComponent
|
|||||||
private correspondentService = inject(CorrespondentService)
|
private correspondentService = inject(CorrespondentService)
|
||||||
list = inject(DocumentListViewService)
|
list = inject(DocumentListViewService)
|
||||||
private documentService = inject(DocumentService)
|
private documentService = inject(DocumentService)
|
||||||
|
private trashService = inject(TrashService)
|
||||||
private modalService = inject(NgbModal)
|
private modalService = inject(NgbModal)
|
||||||
private openDocumentService = inject(OpenDocumentsService)
|
private openDocumentService = inject(OpenDocumentsService)
|
||||||
private settings = inject(SettingsService)
|
private settings = inject(SettingsService)
|
||||||
private toastService = inject(ToastService)
|
private toastService = inject(ToastService)
|
||||||
private trashService = inject(TrashService)
|
|
||||||
private storagePathService = inject(StoragePathService)
|
private storagePathService = inject(StoragePathService)
|
||||||
private customFieldService = inject(CustomFieldsService)
|
private customFieldService = inject(CustomFieldsService)
|
||||||
public readonly permissionService = inject(PermissionsService)
|
public readonly permissionService = inject(PermissionsService)
|
||||||
private savedViewService = inject(SavedViewService)
|
private savedViewService = inject(SavedViewService)
|
||||||
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
|
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
|
||||||
|
private readonly workflowService = inject(WorkflowService)
|
||||||
private readonly router = inject(Router)
|
private readonly router = inject(Router)
|
||||||
|
|
||||||
|
public manualWorkflows: Workflow[] = []
|
||||||
|
|
||||||
tagSelectionModel = new FilterableDropdownSelectionModel(true)
|
tagSelectionModel = new FilterableDropdownSelectionModel(true)
|
||||||
correspondentSelectionModel = new FilterableDropdownSelectionModel()
|
correspondentSelectionModel = new FilterableDropdownSelectionModel()
|
||||||
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
|
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
|
||||||
@@ -258,6 +266,20 @@ export class BulkEditorComponent
|
|||||||
(result) => (this.customFieldsSelectionModel.items = result.results)
|
(result) => (this.customFieldsSelectionModel.items = result.results)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
this.permissionService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.Workflow
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.workflowService
|
||||||
|
.list(1, 100000, null, null, {
|
||||||
|
trigger_type: WorkflowTriggerType.Manual,
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
.pipe(first())
|
||||||
|
.subscribe((result) => (this.manualWorkflows = result.results))
|
||||||
|
}
|
||||||
|
|
||||||
this.downloadForm
|
this.downloadForm
|
||||||
.get('downloadFileTypeArchive')
|
.get('downloadFileTypeArchive')
|
||||||
@@ -286,6 +308,28 @@ export class BulkEditorComponent
|
|||||||
this.unsubscribeNotifier.complete()
|
this.unsubscribeNotifier.complete()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get canRunWorkflow(): boolean {
|
||||||
|
return this.userCanEditAll && this.manualWorkflows.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
runWorkflowSelected() {
|
||||||
|
const modal = this.modalService.open(RunWorkflowConfirmDialogComponent, {
|
||||||
|
backdrop: 'static',
|
||||||
|
})
|
||||||
|
modal.componentInstance.title = $localize`Run workflow`
|
||||||
|
modal.componentInstance.message = $localize`Run a workflow on ${this.getSelectionSize()} selected document(s).`
|
||||||
|
modal.componentInstance.btnCaption = $localize`Run`
|
||||||
|
modal.componentInstance.workflows = this.manualWorkflows
|
||||||
|
modal.componentInstance.confirmClicked
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.executeBulkEditMethod(modal, 'run_workflow', {
|
||||||
|
workflow: modal.componentInstance.workflowId,
|
||||||
|
filter_documents: !modal.componentInstance.force,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private executeBulkEditMethod(
|
private executeBulkEditMethod(
|
||||||
modal: NgbModalRef,
|
modal: NgbModalRef,
|
||||||
method: DocumentBulkEditMethod,
|
method: DocumentBulkEditMethod,
|
||||||
@@ -299,11 +343,33 @@ export class BulkEditorComponent
|
|||||||
.bulkEdit(overrideSelection ?? this.getSelectionQuery(), method, args)
|
.bulkEdit(overrideSelection ?? this.getSelectionQuery(), method, args)
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => this.handleOperationSuccess(modal),
|
next: ({ task_id }) => this.handleOperationQueued(modal, task_id),
|
||||||
error: (error) => this.handleOperationError(modal, error),
|
error: (error) => this.handleOperationError(modal, error),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleOperationQueued(modal: NgbModalRef, taskId: string) {
|
||||||
|
modal?.close()
|
||||||
|
this.list.selectNone()
|
||||||
|
const canViewTasks = this.permissionService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.PaperlessTask
|
||||||
|
)
|
||||||
|
this.toastService.show({
|
||||||
|
content: $localize`Bulk operation queued.`,
|
||||||
|
delay: 5000,
|
||||||
|
...(canViewTasks
|
||||||
|
? {
|
||||||
|
actionName: $localize`View task`,
|
||||||
|
action: () =>
|
||||||
|
this.router.navigate(['/tasks'], {
|
||||||
|
queryParams: { task_id: taskId },
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private executeDocumentAction(
|
private executeDocumentAction(
|
||||||
modal: NgbModalRef,
|
modal: NgbModalRef,
|
||||||
request: Observable<any>,
|
request: Observable<any>,
|
||||||
@@ -966,6 +1032,30 @@ export class BulkEditorComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runZoneOcrSelected() {
|
||||||
|
const modal = this.modalService.open(ZoneOcrConfirmDialogComponent, {
|
||||||
|
backdrop: 'static',
|
||||||
|
})
|
||||||
|
const dialog = modal.componentInstance as ZoneOcrConfirmDialogComponent
|
||||||
|
dialog.title = $localize`Run zone OCR confirm`
|
||||||
|
dialog.messageBold = $localize`This operation will run zone OCR on ${this.getSelectionSize()} selected document(s).`
|
||||||
|
dialog.message = $localize`Fields filled by the template will be overwritten.`
|
||||||
|
dialog.btnClass = 'btn-warning'
|
||||||
|
dialog.btnCaption = $localize`Proceed`
|
||||||
|
dialog.confirmClicked
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe(() => {
|
||||||
|
dialog.buttonsEnabled.set(false)
|
||||||
|
this.executeDocumentAction(
|
||||||
|
modal,
|
||||||
|
this.documentService.runZoneOcrDocuments(
|
||||||
|
this.getSelectionQuery(),
|
||||||
|
dialog.template
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
setPermissions() {
|
setPermissions() {
|
||||||
let modal = this.modalService.open(PermissionsDialogComponent, {
|
let modal = this.modalService.open(PermissionsDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
@@ -1088,14 +1178,9 @@ export class BulkEditorComponent
|
|||||||
|
|
||||||
dialog.selection = this.getSelectionQuery()
|
dialog.selection = this.getSelectionQuery()
|
||||||
dialog.selectionCount = this.getSelectionSize()
|
dialog.selectionCount = this.getSelectionSize()
|
||||||
dialog.succeeded.subscribe((result) => {
|
dialog.succeeded.subscribe(({ task_id }) =>
|
||||||
this.toastService.showSuccess($localize`Custom fields updated.`)
|
this.handleOperationQueued(null, task_id)
|
||||||
this.list.reload()
|
)
|
||||||
this.list.reduceSelectionToFilter()
|
|
||||||
this.list.selected.forEach((id) => {
|
|
||||||
this.openDocumentService.refreshDocument(id)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
dialog.failed.subscribe((error) => {
|
dialog.failed.subscribe((error) => {
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
$localize`Error updating custom fields.`,
|
$localize`Error updating custom fields.`,
|
||||||
|
|||||||
+1
-1
@@ -59,7 +59,7 @@ describe('CustomFieldsBulkEditDialogComponent', () => {
|
|||||||
it('should emit succeeded event and close modal on successful save', () => {
|
it('should emit succeeded event and close modal on successful save', () => {
|
||||||
const editSpy = jest
|
const editSpy = jest
|
||||||
.spyOn(documentService, 'bulkEdit')
|
.spyOn(documentService, 'bulkEdit')
|
||||||
.mockReturnValue(of('Success'))
|
.mockReturnValue(of({ result: 'OK', task_id: 'custom-fields-task' }))
|
||||||
const successSpy = jest.spyOn(component.succeeded, 'emit')
|
const successSpy = jest.spyOn(component.succeeded, 'emit')
|
||||||
|
|
||||||
component.selection = [1, 2]
|
component.selection = [1, 2]
|
||||||
|
|||||||
+7
-3
@@ -16,6 +16,7 @@ import { NumberComponent } from 'src/app/components/common/input/number/number.c
|
|||||||
import { SelectComponent } from 'src/app/components/common/input/select/select.component'
|
import { SelectComponent } from 'src/app/components/common/input/select/select.component'
|
||||||
import { TextComponent } from 'src/app/components/common/input/text/text.component'
|
import { TextComponent } from 'src/app/components/common/input/text/text.component'
|
||||||
import { UrlComponent } from 'src/app/components/common/input/url/url.component'
|
import { UrlComponent } from 'src/app/components/common/input/url/url.component'
|
||||||
|
import { BulkOperationResponse } from 'src/app/data/bulk-operation'
|
||||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||||
import {
|
import {
|
||||||
DocumentSelectionQuery,
|
DocumentSelectionQuery,
|
||||||
@@ -49,7 +50,7 @@ export class CustomFieldsBulkEditDialogComponent {
|
|||||||
CustomFieldDataType = CustomFieldDataType
|
CustomFieldDataType = CustomFieldDataType
|
||||||
|
|
||||||
@Output()
|
@Output()
|
||||||
succeeded = new EventEmitter()
|
succeeded = new EventEmitter<BulkOperationResponse>()
|
||||||
|
|
||||||
@Output()
|
@Output()
|
||||||
failed = new EventEmitter()
|
failed = new EventEmitter()
|
||||||
@@ -103,6 +104,8 @@ export class CustomFieldsBulkEditDialogComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public save() {
|
public save() {
|
||||||
|
if (this.networkActive) return
|
||||||
|
this.networkActive = true
|
||||||
this.documentService
|
this.documentService
|
||||||
.bulkEdit(this.selection, 'modify_custom_fields', {
|
.bulkEdit(this.selection, 'modify_custom_fields', {
|
||||||
add_custom_fields: this.form.value,
|
add_custom_fields: this.form.value,
|
||||||
@@ -110,11 +113,12 @@ export class CustomFieldsBulkEditDialogComponent {
|
|||||||
})
|
})
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: (response) => {
|
||||||
this.activeModal.close()
|
this.activeModal.close()
|
||||||
this.succeeded.emit()
|
this.succeeded.emit(response)
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
|
this.networkActive = false
|
||||||
this.failed.emit(error)
|
this.failed.emit(error)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,26 +2,47 @@
|
|||||||
<div>
|
<div>
|
||||||
<form [formGroup]="noteForm" class="needs-validation mt-3" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Note }" novalidate>
|
<form [formGroup]="noteForm" class="needs-validation mt-3" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Note }" novalidate>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<textarea class="form-control form-control-sm" [class.is-invalid]="newNoteError" rows="3" formControlName="newNote" placeholder="Enter note" i18n-placeholder (keydown)="noteFormKeydown($event)" required></textarea>
|
<textarea class="form-control form-control-sm" [class.is-invalid]="newNoteError" [class.d-none]="noteView() !== NoteViewMode.Edit" [attr.aria-invalid]="newNoteError" aria-label="Note" i18n-aria-label aria-describedby="noteFeedback" rows="3" formControlName="newNote" placeholder="Enter note (Markdown is supported)" i18n-placeholder (keydown)="noteFormKeydown($event)" required></textarea>
|
||||||
<div class="invalid-feedback" i18n>
|
@if (noteView() === NoteViewMode.Preview) {
|
||||||
Please enter a note.
|
<section class="form-control form-control-sm note-preview" aria-label="Note preview" i18n-aria-label>
|
||||||
</div>
|
@if (noteForm.get('newNote').value) {
|
||||||
|
<div class="note-markdown text-break text-wrap" [innerHTML]="noteForm.get('newNote').value | markdown"></div>
|
||||||
|
} @else {
|
||||||
|
<span class="text-muted" i18n>Nothing to preview</span>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
<div id="noteFeedback" class="invalid-feedback" i18n>
|
||||||
|
Please enter a note.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group mt-2 d-flex justify-content-end align-items-center">
|
<div class="form-group mt-2 d-flex justify-content-between align-items-center">
|
||||||
@if (networkActive()) {
|
@if (networkActive()) {
|
||||||
<div class="spinner-border spinner-border-sm fw-normal me-auto" role="status"></div>
|
<div class="spinner-border spinner-border-sm fw-normal me-auto" role="status"></div>
|
||||||
}
|
}
|
||||||
|
<fieldset class="btn-group btn-group-sm" aria-label="Note input mode" i18n-aria-label>
|
||||||
|
<input type="radio" class="btn-check" id="noteViewEdit" name="noteViewMode" [checked]="noteView() === NoteViewMode.Edit" (change)="noteView.set(NoteViewMode.Edit)">
|
||||||
|
<label class="btn btn-outline-secondary" for="noteViewEdit" i18n>Edit</label>
|
||||||
|
<input type="radio" class="btn-check" id="noteViewPreview" name="noteViewMode" [checked]="noteView() === NoteViewMode.Preview" (change)="noteView.set(NoteViewMode.Preview)">
|
||||||
|
<label class="btn btn-outline-secondary" for="noteViewPreview" i18n>Preview</label>
|
||||||
|
</fieldset>
|
||||||
<button type="button" class="btn btn-primary btn-sm" [disabled]="networkActive() || addDisabled()" (click)="addNote()" i18n>Add note</button>
|
<button type="button" class="btn btn-primary btn-sm" [disabled]="networkActive() || addDisabled()" (click)="addNote()" i18n>Add note</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<hr>
|
<hr>
|
||||||
@for (note of notes(); track note) {
|
@for (note of notes(); track note.id) {
|
||||||
<div class="card border mb-3">
|
<div class="card border mb-3">
|
||||||
<div class="card-body text-dark">
|
<div class="card-body text-dark">
|
||||||
<p class="card-text">{{note.note}}</p>
|
@if (note.format === NoteFormat.Markdown) {
|
||||||
|
<div class="card-text note-markdown text-break text-wrap" [innerHTML]="note.note | markdown"></div>
|
||||||
|
} @else {
|
||||||
|
<p class="card-text">{{note.note}}</p>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex card-footer small bg-light text-primary justify-content-between align-items-center">
|
<div class="d-flex card-footer small bg-light text-primary justify-content-between align-items-center">
|
||||||
<span>{{displayName(note)}} - {{ note.created | customDate}}</span>
|
<span>
|
||||||
|
{{displayName(note)}} - {{ note.created | customDate}}
|
||||||
|
</span>
|
||||||
<button type="button" class="btn btn-link btn-sm p-0 fade" title="Delete note" i18n-title (click)="deleteNote(note.id)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Note }">
|
<button type="button" class="btn btn-link btn-sm p-0 fade" title="Delete note" i18n-title (click)="deleteNote(note.id)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Note }">
|
||||||
<pngx-bs-icon name="trash"></pngx-bs-icon>
|
<pngx-bs-icon name="trash"></pngx-bs-icon>
|
||||||
<span class="visually-hidden" i18n>Delete note</span>
|
<span class="visually-hidden" i18n>Delete note</span>
|
||||||
|
|||||||
@@ -5,3 +5,42 @@
|
|||||||
.card:hover .fade {
|
.card:hover .fade {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.note-preview {
|
||||||
|
min-height: calc(1.5em * 3 + 0.5rem + var(--bs-border-width) * 2); // textfield height with 3 rows
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-markdown ::ng-deep {
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: var(--bs-tertiary-bg);
|
||||||
|
border-radius: var(--bs-border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
padding-left: 0.75rem;
|
||||||
|
border-left: 0.25rem solid var(--bs-border-color);
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
> :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'
|
|||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { By } from '@angular/platform-browser'
|
import { By } from '@angular/platform-browser'
|
||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import { DocumentNote } from 'src/app/data/document-note'
|
import { DocumentNote, NoteFormat } from 'src/app/data/document-note'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||||
@@ -129,6 +129,66 @@ describe('DocumentNotesComponent', () => {
|
|||||||
expect(fixture.debugElement.nativeElement.textContent).toContain('user3')
|
expect(fixture.debugElement.nativeElement.textContent).toContain('user3')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should render notes according to their format', () => {
|
||||||
|
component.notes.set([
|
||||||
|
{ id: 31, note: '**x**', format: NoteFormat.Markdown },
|
||||||
|
{
|
||||||
|
id: 32,
|
||||||
|
note: '<script>alert(1)</script>',
|
||||||
|
format: NoteFormat.Markdown,
|
||||||
|
},
|
||||||
|
{ id: 33, note: '**x**', format: NoteFormat.Plain },
|
||||||
|
])
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
const noteCards = fixture.debugElement.queryAll(By.css('.card-text'))
|
||||||
|
expect(noteCards[0].query(By.css('strong')).nativeElement.textContent).toBe(
|
||||||
|
'x'
|
||||||
|
)
|
||||||
|
expect(noteCards[1].query(By.css('script'))).toBeNull()
|
||||||
|
expect(noteCards[2].query(By.css('strong'))).toBeNull()
|
||||||
|
expect(noteCards[2].nativeElement.textContent).toBe('**x**')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should toggle between edit and preview', () => {
|
||||||
|
const radios = fixture.debugElement.queryAll(
|
||||||
|
By.css('.btn-group input[type="radio"]')
|
||||||
|
)
|
||||||
|
radios[1].triggerEventHandler('change')
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(
|
||||||
|
fixture.debugElement.query(By.css('.note-preview')).nativeElement
|
||||||
|
.textContent
|
||||||
|
).toContain('Nothing to preview')
|
||||||
|
|
||||||
|
component.noteForm.get('newNote').setValue('**bold**')
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(
|
||||||
|
fixture.debugElement.query(By.css('.note-preview strong'))
|
||||||
|
).not.toBeNull()
|
||||||
|
expect(
|
||||||
|
fixture.debugElement.query(By.css('textarea')).nativeElement.classList
|
||||||
|
).toContain('d-none')
|
||||||
|
|
||||||
|
radios[0].triggerEventHandler('change')
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(fixture.debugElement.query(By.css('.note-preview'))).toBeNull()
|
||||||
|
expect(
|
||||||
|
fixture.debugElement.query(By.css('textarea')).nativeElement.classList
|
||||||
|
).not.toContain('d-none')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return to edit mode after saving', () => {
|
||||||
|
component.documentId.set(12)
|
||||||
|
component.noteForm.get('newNote').setValue('Preview note')
|
||||||
|
fixture.debugElement
|
||||||
|
.queryAll(By.css('.btn-group input[type="radio"]'))[1]
|
||||||
|
.triggerEventHandler('change')
|
||||||
|
jest.spyOn(notesService, 'addNote').mockReturnValueOnce(of(notes))
|
||||||
|
component.addNote()
|
||||||
|
expect(component.noteView()).toBe('edit')
|
||||||
|
})
|
||||||
|
|
||||||
it('should handle note user display in all situations', () => {
|
it('should handle note user display in all situations', () => {
|
||||||
expect(
|
expect(
|
||||||
component.displayName({
|
component.displayName({
|
||||||
@@ -161,7 +221,7 @@ describe('DocumentNotesComponent', () => {
|
|||||||
const addSpy = jest.spyOn(notesService, 'addNote')
|
const addSpy = jest.spyOn(notesService, 'addNote')
|
||||||
addSpy.mockReturnValueOnce(throwError(() => new Error('error saving note')))
|
addSpy.mockReturnValueOnce(throwError(() => new Error('error saving note')))
|
||||||
const toastsSpy = jest.spyOn(toastService, 'showError')
|
const toastsSpy = jest.spyOn(toastService, 'showError')
|
||||||
const addButton = fixture.debugElement.query(By.css('button'))
|
const addButton = fixture.debugElement.query(By.css('.btn-primary'))
|
||||||
addButton.triggerEventHandler('click')
|
addButton.triggerEventHandler('click')
|
||||||
expect(addSpy).toHaveBeenCalledWith(12, note)
|
expect(addSpy).toHaveBeenCalledWith(12, note)
|
||||||
expect(toastsSpy).toHaveBeenCalled()
|
expect(toastsSpy).toHaveBeenCalled()
|
||||||
@@ -190,7 +250,9 @@ describe('DocumentNotesComponent', () => {
|
|||||||
component.documentId.set(12)
|
component.documentId.set(12)
|
||||||
component.notes.set(notes)
|
component.notes.set(notes)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
const deleteButton = fixture.debugElement.queryAll(By.css('button'))[1] // 0 is add button
|
const deleteButton = fixture.debugElement.query(
|
||||||
|
By.css('.card-footer button')
|
||||||
|
)
|
||||||
const deleteSpy = jest.spyOn(notesService, 'deleteNote')
|
const deleteSpy = jest.spyOn(notesService, 'deleteNote')
|
||||||
const toastsSpy = jest.spyOn(toastService, 'showError')
|
const toastsSpy = jest.spyOn(toastService, 'showError')
|
||||||
deleteSpy.mockReturnValueOnce(
|
deleteSpy.mockReturnValueOnce(
|
||||||
|
|||||||
@@ -14,15 +14,21 @@ import {
|
|||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
||||||
import { DocumentNote } from 'src/app/data/document-note'
|
import { DocumentNote, NoteFormat } from 'src/app/data/document-note'
|
||||||
import { User } from 'src/app/data/user'
|
import { User } from 'src/app/data/user'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
|
import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'
|
||||||
import { DocumentNotesService } from 'src/app/services/rest/document-notes.service'
|
import { DocumentNotesService } from 'src/app/services/rest/document-notes.service'
|
||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
||||||
|
|
||||||
|
enum NoteViewMode {
|
||||||
|
Edit = 'edit',
|
||||||
|
Preview = 'preview',
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-document-notes',
|
selector: 'pngx-document-notes',
|
||||||
templateUrl: './document-notes.component.html',
|
templateUrl: './document-notes.component.html',
|
||||||
@@ -30,6 +36,7 @@ import { ComponentWithPermissions } from '../with-permissions/with-permissions.c
|
|||||||
imports: [
|
imports: [
|
||||||
IfPermissionsDirective,
|
IfPermissionsDirective,
|
||||||
CustomDatePipe,
|
CustomDatePipe,
|
||||||
|
MarkdownPipe,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
BootstrapIconComponent,
|
BootstrapIconComponent,
|
||||||
@@ -43,6 +50,9 @@ export class DocumentNotesComponent extends ComponentWithPermissions {
|
|||||||
readonly notes = model<DocumentNote[]>([])
|
readonly notes = model<DocumentNote[]>([])
|
||||||
readonly addDisabled = input(false)
|
readonly addDisabled = input(false)
|
||||||
readonly networkActive = signal(false)
|
readonly networkActive = signal(false)
|
||||||
|
protected readonly NoteViewMode = NoteViewMode
|
||||||
|
protected readonly NoteFormat = NoteFormat
|
||||||
|
protected readonly noteView = signal<NoteViewMode>(NoteViewMode.Edit)
|
||||||
|
|
||||||
noteForm: FormGroup = new FormGroup({
|
noteForm: FormGroup = new FormGroup({
|
||||||
newNote: new FormControl(''),
|
newNote: new FormControl(''),
|
||||||
@@ -75,6 +85,7 @@ export class DocumentNotesComponent extends ComponentWithPermissions {
|
|||||||
next: (result) => {
|
next: (result) => {
|
||||||
this.notes.set(result)
|
this.notes.set(result)
|
||||||
this.noteForm.get('newNote').reset()
|
this.noteForm.get('newNote').reset()
|
||||||
|
this.noteView.set(NoteViewMode.Edit)
|
||||||
this.networkActive.set(false)
|
this.networkActive.set(false)
|
||||||
this.updated.emit(this.notes())
|
this.updated.emit(this.notes())
|
||||||
},
|
},
|
||||||
|
|||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
@if (zones.length === 0) {
|
||||||
|
<p class="text-muted" i18n>No zones yet. Drag anywhere on the sample document to create one.</p>
|
||||||
|
} @else {
|
||||||
|
<div class="list-group">
|
||||||
|
@for (zone of zones; track $index; let i = $index) {
|
||||||
|
<div class="list-group-item list-group-item-action d-flex align-items-center gap-3 position-relative">
|
||||||
|
<span class="p-2 rounded flex-shrink-0" [style.background-color]="zoneColor(i)" aria-hidden="true"></span>
|
||||||
|
<div class="flex-grow-1 text-truncate">
|
||||||
|
<button type="button" class="btn btn-link p-0 fw-semibold text-reset text-decoration-none text-truncate mw-100 stretched-link" (click)="zoneSelected.emit(i)">{{ zone.name }}</button>
|
||||||
|
@if (!hasField(zone)) {
|
||||||
|
<div class="small text-warning text-truncate">
|
||||||
|
<pngx-bs-icon name="exclamation-triangle" class="me-1" aria-hidden="true"></pngx-bs-icon><span i18n>No field selected</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="small text-muted text-truncate">
|
||||||
|
{{ getZoneTargetName(zone) }}
|
||||||
|
@if (isCombined(zone)) {
|
||||||
|
<pngx-bs-icon name="braces" class="ms-1" title="Combined with other zones" i18n-title></pngx-bs-icon>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<small class="text-muted text-nowrap">
|
||||||
|
@if (zone.page === -1) {
|
||||||
|
<ng-container i18n>Last page</ng-container>
|
||||||
|
} @else {
|
||||||
|
<ng-container i18n>Page {{ zone.page ?? 1 }}</ng-container>
|
||||||
|
}
|
||||||
|
</small>
|
||||||
|
<button type="button" class="btn btn-sm btn-link text-danger position-relative z-2" (click)="zoneRemoved.emit(i)" title="Delete zone" i18n-title aria-label="Delete zone" i18n-aria-label>
|
||||||
|
<pngx-bs-icon name="trash"></pngx-bs-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
||||||
|
import { CustomField } from 'src/app/data/custom-field'
|
||||||
|
import {
|
||||||
|
OcrTemplateZone,
|
||||||
|
OcrZoneTarget,
|
||||||
|
OcrZoneTransform,
|
||||||
|
} 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: OcrZoneTarget.CustomField,
|
||||||
|
custom_field: 7,
|
||||||
|
x: 10,
|
||||||
|
y: 20,
|
||||||
|
width: 30,
|
||||||
|
height: 40,
|
||||||
|
page: 1,
|
||||||
|
ocr_language: 'eng',
|
||||||
|
transform: OcrZoneTransform.Strip,
|
||||||
|
validation_regex: '',
|
||||||
|
order: 0,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OcrTemplateEditorZoneListComponent', () => {
|
||||||
|
let fixture: ComponentFixture<OcrTemplateEditorZoneListComponent>
|
||||||
|
let component: OcrTemplateEditorZoneListComponent
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [OcrTemplateEditorZoneListComponent, BootstrapIconComponent],
|
||||||
|
}).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 yet')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders zone name, target, and page', () => {
|
||||||
|
component.zones = [zone(), zone({ name: 'Zone 2', page: -1 })]
|
||||||
|
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('Page 1')
|
||||||
|
expect(text).toContain('Last page')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks zones that share a field', () => {
|
||||||
|
component.zones = [
|
||||||
|
zone(),
|
||||||
|
zone({ name: 'Zone 2' }),
|
||||||
|
zone({ name: 'Zone 3', custom_field: 8 }),
|
||||||
|
]
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(component.isCombined(component.zones[0])).toBe(true)
|
||||||
|
expect(component.isCombined(component.zones[2])).toBe(false)
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelectorAll(
|
||||||
|
'[title="Combined with other zones"]'
|
||||||
|
)
|
||||||
|
).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('warns only for zones without a field and clears the warning when assigned', () => {
|
||||||
|
component.zones = [
|
||||||
|
zone({ custom_field: null }),
|
||||||
|
zone({ target: OcrZoneTarget.Title, custom_field: null }),
|
||||||
|
zone(),
|
||||||
|
]
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
const warnings = () =>
|
||||||
|
fixture.nativeElement.querySelectorAll('.text-warning')
|
||||||
|
expect(warnings()).toHaveLength(1)
|
||||||
|
expect(warnings()[0].textContent).toContain('No field selected')
|
||||||
|
|
||||||
|
component.zones[0].custom_field = 7
|
||||||
|
fixture.componentRef.setInput('zones', [...component.zones])
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(warnings()).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not label an unknown built-in target', () => {
|
||||||
|
expect(
|
||||||
|
component.getZoneTargetName(zone({ target: 'unknown' as OcrZoneTarget }))
|
||||||
|
).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
import { Component, EventEmitter, Input, Output } from '@angular/core'
|
||||||
|
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
||||||
|
import { CustomField } from 'src/app/data/custom-field'
|
||||||
|
import {
|
||||||
|
getZoneFieldKey,
|
||||||
|
OCR_BUILTIN_TARGETS,
|
||||||
|
OcrTemplateZone,
|
||||||
|
OcrZoneTarget,
|
||||||
|
} from 'src/app/data/ocr-template'
|
||||||
|
import { getZoneColor } from '../zone-geometry'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-ocr-template-zone-list',
|
||||||
|
imports: [BootstrapIconComponent],
|
||||||
|
templateUrl: './ocr-template-editor-zone-list.component.html',
|
||||||
|
})
|
||||||
|
export class OcrTemplateEditorZoneListComponent {
|
||||||
|
@Input() zones: OcrTemplateZone[] = []
|
||||||
|
@Input() customFields: CustomField[] = []
|
||||||
|
|
||||||
|
@Output() public readonly zoneSelected = new EventEmitter<number>()
|
||||||
|
@Output() public readonly zoneRemoved = new EventEmitter<number>()
|
||||||
|
|
||||||
|
public readonly zoneColor = getZoneColor
|
||||||
|
|
||||||
|
hasField(zone: OcrTemplateZone): boolean {
|
||||||
|
return getZoneFieldKey(zone) !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
isCombined(zone: OcrTemplateZone): boolean {
|
||||||
|
const key = getZoneFieldKey(zone)
|
||||||
|
return (
|
||||||
|
key !== null &&
|
||||||
|
this.zones.filter((z) => getZoneFieldKey(z) === key).length > 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
getZoneTargetName(zone: OcrTemplateZone): string {
|
||||||
|
if (zone.target === OcrZoneTarget.CustomField) {
|
||||||
|
return (
|
||||||
|
this.customFields.find((field) => field.id === zone.custom_field)
|
||||||
|
?.name ?? ''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
OCR_BUILTIN_TARGETS.find((target) => target.id === zone.target)?.name ??
|
||||||
|
''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+313
@@ -0,0 +1,313 @@
|
|||||||
|
<div class="modal-header">
|
||||||
|
<h4 class="modal-title" id="ocr-template-editor-title">{{ dialogTitle }}</h4>
|
||||||
|
@if (template.id) {
|
||||||
|
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{ template.id }}</span>
|
||||||
|
}
|
||||||
|
<button type="button" class="btn-close" (click)="activeModal.dismiss()" [disabled]="saving()" aria-label="Close" i18n-aria-label></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body d-flex flex-column">
|
||||||
|
<div class="row gx-3 editor-layout flex-grow-1 flex-lg-nowrap" [attr.inert]="saving() ? '' : null" [attr.aria-busy]="saving()">
|
||||||
|
<div class="col-lg-4 mb-3 mb-lg-0 overflow-auto">
|
||||||
|
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTab" class="nav-underline flex-nowrap flex-md-wrap overflow-auto">
|
||||||
|
<li ngbNavItem="template">
|
||||||
|
<a ngbNavLink i18n>Template</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.matching_algorithm" [items]="matchingAlgorithms" title="Matching algorithm" i18n-title></pngx-input-select>
|
||||||
|
@if (patternRequired) {
|
||||||
|
<pngx-input-text [(ngModel)]="template.match" title="Matching pattern" i18n-title></pngx-input-text>
|
||||||
|
<pngx-input-check [(ngModel)]="template.is_insensitive" title="Case insensitive" i18n-title></pngx-input-check>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div *pngxIfOwner="object">
|
||||||
|
<hr />
|
||||||
|
<pngx-permissions-form [users]="users()" [accordion]="true" [(ngModel)]="permissionsForm"></pngx-permissions-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</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>
|
||||||
|
@if (selectedZone; as zone) {
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
|
<button type="button" class="btn btn-sm btn-link px-0 me-auto" (click)="deselectZone()">
|
||||||
|
<pngx-bs-icon name="arrow-left" class="me-1"></pngx-bs-icon><ng-container i18n>All zones</ng-container>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary" (click)="testZone()" [disabled]="!previewDocId || zoneTesting">
|
||||||
|
@if (zoneTesting) {
|
||||||
|
<span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span>
|
||||||
|
} @else {
|
||||||
|
<pngx-bs-icon name="play-fill" class="me-1"></pngx-bs-icon>
|
||||||
|
}
|
||||||
|
<ng-container i18n>Test</ng-container>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger" (click)="removeZone(selectedZoneIndex)" title="Delete zone" i18n-title aria-label="Delete zone" i18n-aria-label>
|
||||||
|
<pngx-bs-icon name="trash"></pngx-bs-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (zoneTestResult; as result) {
|
||||||
|
@if (result.error) {
|
||||||
|
<div class="alert alert-warning py-2 mb-3 small" role="alert">{{ result.error }}</div>
|
||||||
|
} @else {
|
||||||
|
<dl class="row small border rounded mx-0 py-2 mb-3">
|
||||||
|
<dt class="col-4" i18n>OCR text</dt>
|
||||||
|
<dd class="col-8">
|
||||||
|
@if (result.raw_text) {
|
||||||
|
<code>{{ result.raw_text }}</code>
|
||||||
|
} @else {
|
||||||
|
<span class="text-muted" i18n>Nothing detected</span>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
<dt class="col-4" i18n>Value</dt>
|
||||||
|
<dd class="col-8" [class.mb-0]="!result.regex">
|
||||||
|
@if (result.value) {
|
||||||
|
<code>{{ result.value }}</code>
|
||||||
|
} @else {
|
||||||
|
<span class="text-muted" i18n>Empty</span>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
@if (result.regex) {
|
||||||
|
<dt class="col-4" i18n>Validation</dt>
|
||||||
|
<dd class="col-8 mb-0">
|
||||||
|
@if (result.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>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<pngx-input-text [ngModel]="zone.name" (ngModelChange)="renameZone(zone, $event)" title="Name" i18n-title></pngx-input-text>
|
||||||
|
|
||||||
|
<pngx-input-select
|
||||||
|
[items]="fieldItems()"
|
||||||
|
[ngModel]="zoneFieldValue(zone)"
|
||||||
|
(ngModelChange)="setZoneField(zone, $event)"
|
||||||
|
(createNew)="createField($event)"
|
||||||
|
title="Field"
|
||||||
|
i18n-title
|
||||||
|
placeholder="Choose a field"
|
||||||
|
i18n-placeholder
|
||||||
|
hint="Where the extracted value is written."
|
||||||
|
i18n-hint
|
||||||
|
></pngx-input-select>
|
||||||
|
|
||||||
|
@if (isFieldShared(zone)) {
|
||||||
|
<pngx-input-text
|
||||||
|
[ngModel]="getCombineFormat(zone)"
|
||||||
|
(ngModelChange)="setCombineFormat(zone, $event)"
|
||||||
|
[placeholder]="defaultCombineFormat(zone)"
|
||||||
|
title="Combined value"
|
||||||
|
i18n-title
|
||||||
|
hint="Several zones write to this field. Set the format or leave empty to join them with spaces."
|
||||||
|
i18n-hint
|
||||||
|
></pngx-input-text>
|
||||||
|
<div class="d-flex flex-wrap gap-1 mt-n2 mb-3">
|
||||||
|
@for (z of zonesForField(zone); track z) {
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary font-monospace" (click)="insertCombineToken(zone, z)">{{ '{' + z.name + '}' }}</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="row align-items-end">
|
||||||
|
<div class="col">
|
||||||
|
@if (!isLastPageZone(zone)) {
|
||||||
|
<pngx-input-number [(ngModel)]="zone.page" [showAdd]="false" title="Page" i18n-title></pngx-input-number>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<pngx-input-switch [ngModel]="isLastPageZone(zone)" (ngModelChange)="setLastPageZone(zone, $event)" [horizontal]="true" title="Last page" i18n-title></pngx-input-switch>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 class="border-top pt-3 mt-1 mb-3" i18n>Processing</h6>
|
||||||
|
|
||||||
|
<pngx-input-select
|
||||||
|
[items]="languageOptions"
|
||||||
|
[multiple]="true"
|
||||||
|
[ngModel]="ocrLanguageArray(zone)"
|
||||||
|
(ngModelChange)="setOcrLanguages(zone, $event)"
|
||||||
|
title="OCR language"
|
||||||
|
i18n-title
|
||||||
|
placeholder="Select languages"
|
||||||
|
i18n-placeholder
|
||||||
|
></pngx-input-select>
|
||||||
|
|
||||||
|
<pngx-input-select [items]="transformOptions" [(ngModel)]="zone.transform" title="Transform" i18n-title></pngx-input-select>
|
||||||
|
|
||||||
|
@if (zone.transform === OcrZoneTransform.Date) {
|
||||||
|
<pngx-input-text
|
||||||
|
[(ngModel)]="zone.date_format"
|
||||||
|
title="Date format"
|
||||||
|
i18n-title
|
||||||
|
placeholder="Auto-detect"
|
||||||
|
i18n-placeholder
|
||||||
|
hint="Python date codes, e.g. %d.%m.%Y. Leave empty to detect the format automatically."
|
||||||
|
i18n-hint
|
||||||
|
></pngx-input-text>
|
||||||
|
}
|
||||||
|
|
||||||
|
<pngx-input-text [(ngModel)]="zone.validation_regex" title="Validation regex" i18n-title placeholder="\d{2}\.\d{2}\.\d{4}" hint="The value must match this pattern to be applied." i18n-hint></pngx-input-text>
|
||||||
|
} @else {
|
||||||
|
<pngx-ocr-template-zone-list
|
||||||
|
[zones]="template.zones"
|
||||||
|
[customFields]="customFields()"
|
||||||
|
(zoneSelected)="selectZone($event)"
|
||||||
|
(zoneRemoved)="removeZone($event)"
|
||||||
|
></pngx-ocr-template-zone-list>
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div [ngbNavOutlet]="nav" class="mt-3"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-8 d-flex flex-column">
|
||||||
|
<div class="d-flex flex-wrap gap-2 mb-2 flex-shrink-0">
|
||||||
|
<pngx-input-document-link
|
||||||
|
class="flex-grow-1"
|
||||||
|
[ngModel]="sampleDocumentIds"
|
||||||
|
(ngModelChange)="setSampleDocument($event)"
|
||||||
|
[minimal]="true"
|
||||||
|
[multiple]="false"
|
||||||
|
placeholder="Search for a sample document"
|
||||||
|
i18n-placeholder
|
||||||
|
></pngx-input-document-link>
|
||||||
|
@if (previewDocId) {
|
||||||
|
<div class="input-group w-auto flex-nowrap">
|
||||||
|
<input class="form-control flex-grow-0 preview-page-input" type="number" min="1" [max]="previewPageCount()" [(ngModel)]="previewPageDisplay" [disabled]="!pageLoaded()" aria-label="Page" i18n-aria-label />
|
||||||
|
<span class="input-group-text" i18n>of {{ previewPageCount() ?? '?' }}</span>
|
||||||
|
</div>
|
||||||
|
<fieldset class="input-group w-auto flex-nowrap" aria-label="Zoom" i18n-aria-label>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" aria-label="Zoom out" i18n-aria-label [disabled]="!pageLoaded() || zoom <= 0.5" (click)="zoomOut()"><pngx-bs-icon name="dash"></pngx-bs-icon></button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary preview-zoom" title="Reset zoom" i18n-title [disabled]="!pageLoaded()" (click)="resetZoom()">{{ zoom | percent: '1.0-0' }}</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" aria-label="Zoom in" i18n-aria-label [disabled]="!pageLoaded() || zoom >= 4" (click)="zoomIn()"><pngx-bs-icon name="plus"></pngx-bs-icon></button>
|
||||||
|
</fieldset>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 overflow-hidden">
|
||||||
|
<div class="h-100 overflow-auto border" [attr.aria-busy]="previewDocId && !pageLoaded()">
|
||||||
|
@if (pageFailed()) {
|
||||||
|
<div class="py-5 px-3 text-center" role="alert">
|
||||||
|
<pngx-bs-icon name="exclamation-triangle" width="32" height="32" class="text-warning"></pngx-bs-icon>
|
||||||
|
<p class="my-3" i18n>Could not load this page.</p>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" (click)="setSampleDocument(sampleDocumentIds)"><pngx-bs-icon name="arrow-clockwise" class="me-1"></pngx-bs-icon><span i18n>Retry</span></button>
|
||||||
|
</div>
|
||||||
|
} @else if (previewDocId) {
|
||||||
|
<div class="position-relative" [class.d-none]="!pageLoaded()" [style.width.%]="zoom * 100">
|
||||||
|
<img
|
||||||
|
#pageImage
|
||||||
|
[src]="pageImageUrl"
|
||||||
|
(load)="onImageLoad()"
|
||||||
|
(error)="onImageError()"
|
||||||
|
alt="Sample document page"
|
||||||
|
i18n-alt
|
||||||
|
class="d-block w-100"
|
||||||
|
crossorigin="use-credentials"
|
||||||
|
/>
|
||||||
|
@if (pageLoaded()) {
|
||||||
|
<svg
|
||||||
|
#zoneOverlay
|
||||||
|
class="zone-overlay position-absolute top-0 start-0 w-100 h-100"
|
||||||
|
[attr.viewBox]="overlayViewBox()"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
[style.cursor]="overlayCursor"
|
||||||
|
(pointerdown)="onOverlayPointerDown($event)"
|
||||||
|
(pointermove)="onOverlayPointerMove($event)"
|
||||||
|
(pointerup)="onOverlayPointerUp($event)"
|
||||||
|
(pointercancel)="onOverlayPointerCancel($event)"
|
||||||
|
(lostpointercapture)="onOverlayPointerCancel($event)"
|
||||||
|
aria-label="Zone overlay" i18n-aria-label
|
||||||
|
>
|
||||||
|
@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]="zoneColor(i)"
|
||||||
|
></rect>
|
||||||
|
<text
|
||||||
|
class="zone-label fw-semibold pe-none"
|
||||||
|
[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>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (!pageLoaded()) {
|
||||||
|
<div class="p-5 d-flex justify-content-center align-items-center">
|
||||||
|
<output class="spinner-border">
|
||||||
|
<span class="visually-hidden" i18n>Loading page...</span>
|
||||||
|
</output>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<div class="py-5 px-3 text-center text-muted">
|
||||||
|
<pngx-bs-icon name="file-text" width="32" height="32"></pngx-bs-icon>
|
||||||
|
<p class="mt-3 mb-0" i18n>No sample document selected.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" (click)="activeModal.dismiss()" [disabled]="saving()" i18n>Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" (click)="save()" [disabled]="saving() || !isDirty">
|
||||||
|
@if (saving()) {
|
||||||
|
<span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span>
|
||||||
|
}
|
||||||
|
<span i18n>Save</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
.modal-body {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-layout {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-page-input {
|
||||||
|
width: 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-zoom {
|
||||||
|
min-width: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-overlay {
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-rect,
|
||||||
|
.zone-drawing-rect {
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-rect {
|
||||||
|
fill-opacity: 0.2;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-rect-selected {
|
||||||
|
stroke-width: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-label {
|
||||||
|
font-family: var(--bs-font-sans-serif);
|
||||||
|
paint-order: stroke;
|
||||||
|
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: var(--bs-primary);
|
||||||
|
fill-opacity: 0.2;
|
||||||
|
stroke: var(--bs-primary);
|
||||||
|
stroke-dasharray: 5 5;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
+875
@@ -0,0 +1,875 @@
|
|||||||
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
|
import { ElementRef } from '@angular/core'
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
import { By } from '@angular/platform-browser'
|
||||||
|
import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
||||||
|
import { of, Subject, throwError } from 'rxjs'
|
||||||
|
import { PermissionsFormComponent } from 'src/app/components/common/input/permissions/permissions-form/permissions-form.component'
|
||||||
|
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||||
|
import { Document } from 'src/app/data/document'
|
||||||
|
import {
|
||||||
|
MATCH_ANY,
|
||||||
|
MATCH_AUTO,
|
||||||
|
MATCH_LITERAL,
|
||||||
|
MATCH_NONE,
|
||||||
|
} from 'src/app/data/matching-model'
|
||||||
|
import {
|
||||||
|
OcrTemplate,
|
||||||
|
OcrTemplateZone,
|
||||||
|
OcrZoneTarget,
|
||||||
|
OcrZoneTestResult,
|
||||||
|
OcrZoneTransform,
|
||||||
|
} from 'src/app/data/ocr-template'
|
||||||
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
|
import { User } from 'src/app/data/user'
|
||||||
|
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||||
|
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||||
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
|
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
|
||||||
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { OcrTemplateEditorComponent } from './ocr-template-editor.component'
|
||||||
|
|
||||||
|
const customFields: CustomField[] = [
|
||||||
|
{ id: 1, name: 'Invoice number', data_type: CustomFieldDataType.String },
|
||||||
|
{ id: 2, name: 'Status', data_type: CustomFieldDataType.Select },
|
||||||
|
]
|
||||||
|
|
||||||
|
function zone(overrides: Partial<OcrTemplateZone> = {}): OcrTemplateZone {
|
||||||
|
return {
|
||||||
|
name: 'Invoice number',
|
||||||
|
target: OcrZoneTarget.CustomField,
|
||||||
|
custom_field: 1,
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
width: 200,
|
||||||
|
height: 100,
|
||||||
|
page: 1,
|
||||||
|
ocr_language: 'eng',
|
||||||
|
transform: OcrZoneTransform.Strip,
|
||||||
|
validation_regex: '',
|
||||||
|
order: 0,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointer(x: number, y: number, pointerId = 1): PointerEvent {
|
||||||
|
return Object.assign(
|
||||||
|
new MouseEvent('pointermove', { clientX: x, clientY: y, button: 0 }),
|
||||||
|
{ pointerId }
|
||||||
|
) as PointerEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OcrTemplateEditorComponent', () => {
|
||||||
|
let component: OcrTemplateEditorComponent
|
||||||
|
let fixture: ComponentFixture<OcrTemplateEditorComponent>
|
||||||
|
let documentService: DocumentService
|
||||||
|
let templateService: OcrTemplateService
|
||||||
|
let modalService: NgbModal
|
||||||
|
const activeModal = { close: jest.fn(), dismiss: jest.fn() }
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [BootstrapIconComponent, OcrTemplateEditorComponent],
|
||||||
|
providers: [
|
||||||
|
{ provide: NgbActiveModal, useValue: activeModal },
|
||||||
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
jest
|
||||||
|
.spyOn(TestBed.inject(CustomFieldsService), 'listAll')
|
||||||
|
.mockReturnValue(of({ count: 2, all: [1, 2], results: customFields }))
|
||||||
|
jest
|
||||||
|
.spyOn(TestBed.inject(UserService), 'listAll')
|
||||||
|
.mockReturnValue(of({ count: 0, all: [], results: [] }))
|
||||||
|
TestBed.inject(PermissionsService).initialize([], { id: 1 } as User)
|
||||||
|
documentService = TestBed.inject(DocumentService)
|
||||||
|
jest
|
||||||
|
.spyOn(documentService, 'get')
|
||||||
|
.mockReturnValue(of({ id: 5, page_count: 3 }))
|
||||||
|
templateService = TestBed.inject(OcrTemplateService)
|
||||||
|
modalService = TestBed.inject(NgbModal)
|
||||||
|
activeModal.close.mockReset()
|
||||||
|
fixture = TestBed.createComponent(OcrTemplateEditorComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
})
|
||||||
|
|
||||||
|
function prepareOverlay() {
|
||||||
|
const image = document.createElement('img')
|
||||||
|
Object.defineProperties(image, {
|
||||||
|
naturalWidth: { value: 1000 },
|
||||||
|
naturalHeight: { value: 1000 },
|
||||||
|
clientWidth: { value: 1000 },
|
||||||
|
})
|
||||||
|
const overlay = document.createElementNS(
|
||||||
|
'http://www.w3.org/2000/svg',
|
||||||
|
'svg'
|
||||||
|
)
|
||||||
|
Object.assign(overlay, {
|
||||||
|
getScreenCTM: () => ({ inverse: () => ({}) }),
|
||||||
|
createSVGPoint: () => ({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
matrixTransform() {
|
||||||
|
return { x: this.x, y: this.y }
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
setPointerCapture: jest.fn(),
|
||||||
|
hasPointerCapture: () => true,
|
||||||
|
releasePointerCapture: jest.fn(),
|
||||||
|
})
|
||||||
|
component.imageRef = new ElementRef(image)
|
||||||
|
component.overlayRef = new ElementRef(overlay)
|
||||||
|
component.pageLoaded.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldDialog() {
|
||||||
|
const dialog = {
|
||||||
|
dialogMode: { set: jest.fn() },
|
||||||
|
object: null,
|
||||||
|
succeeded: new Subject<CustomField>(),
|
||||||
|
failed: new Subject(),
|
||||||
|
}
|
||||||
|
jest
|
||||||
|
.spyOn(modalService, 'open')
|
||||||
|
.mockReturnValue({ componentInstance: dialog } as any)
|
||||||
|
return dialog
|
||||||
|
}
|
||||||
|
|
||||||
|
it('updates screen-sized overlay units when the preview image resizes', () => {
|
||||||
|
let resize: ResizeObserverCallback
|
||||||
|
const observer = {
|
||||||
|
observe: jest.fn(),
|
||||||
|
unobserve: jest.fn(),
|
||||||
|
disconnect: jest.fn(),
|
||||||
|
}
|
||||||
|
const resizeObserver = jest
|
||||||
|
.spyOn(globalThis, 'ResizeObserver')
|
||||||
|
.mockImplementation((callback) => {
|
||||||
|
resize = callback
|
||||||
|
return observer
|
||||||
|
})
|
||||||
|
const image = document.createElement('img')
|
||||||
|
const ref = new ElementRef(image)
|
||||||
|
component.pageImage = ref
|
||||||
|
expect(observer.observe).toHaveBeenCalledWith(image)
|
||||||
|
resize([], observer)
|
||||||
|
expect(component.overlayUnitSize(8)).toBe(8)
|
||||||
|
|
||||||
|
Object.defineProperties(image, {
|
||||||
|
naturalWidth: { value: 1000 },
|
||||||
|
clientWidth: { value: 500 },
|
||||||
|
})
|
||||||
|
resize([], observer)
|
||||||
|
expect(component.overlayUnitSize(8)).toBe(16)
|
||||||
|
expect(component.overlayFontSize()).toBe(24)
|
||||||
|
|
||||||
|
component.pageImage = undefined
|
||||||
|
expect(observer.disconnect).toHaveBeenCalledTimes(1)
|
||||||
|
fixture.destroy()
|
||||||
|
expect(observer.disconnect).toHaveBeenCalledTimes(2)
|
||||||
|
resizeObserver.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders loaded image dimensions, zone labels, and selection handles', () => {
|
||||||
|
component.object = {
|
||||||
|
...component.template,
|
||||||
|
id: 3,
|
||||||
|
sample_document: 5,
|
||||||
|
zones: [zone()],
|
||||||
|
}
|
||||||
|
component.selectedZoneIndex = 0
|
||||||
|
fixture.detectChanges()
|
||||||
|
const image = fixture.nativeElement.querySelector('img')
|
||||||
|
Object.defineProperties(image, {
|
||||||
|
naturalWidth: { value: 1000 },
|
||||||
|
naturalHeight: { value: 1500 },
|
||||||
|
})
|
||||||
|
image.dispatchEvent(new Event('load'))
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(component.template).toMatchObject({
|
||||||
|
source_width: 1000,
|
||||||
|
source_height: 1500,
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement
|
||||||
|
.querySelector('.zone-overlay')
|
||||||
|
.getAttribute('viewBox')
|
||||||
|
).toBe('0 0 1000 1500')
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('.zone-label').textContent
|
||||||
|
).toContain('Invoice number')
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelectorAll('.zone-resize-handle')
|
||||||
|
).toHaveLength(8)
|
||||||
|
expect(component.zoneLabel(zone({ name: '' }), 2)).toBe('Zone 3')
|
||||||
|
expect(component.zoneLabelY({ x: 0, y: 0, w: 100, h: 100 })).toBe(14)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers the supported language and transform choices', () => {
|
||||||
|
expect(component.languageOptions.map((option) => option.id)).toContain(
|
||||||
|
'eng'
|
||||||
|
)
|
||||||
|
expect(component.transformOptions.map((option) => option.id)).toEqual(
|
||||||
|
Object.values(OcrZoneTransform)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamps page navigation and supports one-based page input', () => {
|
||||||
|
component.setSampleDocument([5])
|
||||||
|
component.previewPageDisplay = 2
|
||||||
|
expect(component.previewPage).toBe(1)
|
||||||
|
expect(component.previewPageDisplay).toBe(2)
|
||||||
|
expect(component.pageImageUrl).toContain('/5/1/')
|
||||||
|
component.pageLoaded.set(true)
|
||||||
|
component.goToPage(1)
|
||||||
|
expect(component.pageLoaded()).toBe(true)
|
||||||
|
component.goToPage(Infinity)
|
||||||
|
component.goToPage(NaN)
|
||||||
|
expect(component.previewPage).toBe(1)
|
||||||
|
component.goToPage(99)
|
||||||
|
expect(component.previewPage).toBe(2)
|
||||||
|
expect(component.pageLoaded()).toBe(false)
|
||||||
|
component.goToPage(-5)
|
||||||
|
expect(component.previewPage).toBe(0)
|
||||||
|
component.previewPageCount.set(null)
|
||||||
|
component.goToPage(4.7)
|
||||||
|
expect(component.previewPage).toBe(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('limits zoom and resets it to the original size', () => {
|
||||||
|
for (let i = 0; i < 20; i++) component.zoomIn()
|
||||||
|
expect(component.zoom).toBe(4)
|
||||||
|
for (let i = 0; i < 20; i++) component.zoomOut()
|
||||||
|
expect(component.zoom).toBe(0.5)
|
||||||
|
component.resetZoom()
|
||||||
|
expect(component.zoom).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves last-page zones to the last page and can pin them to a page', () => {
|
||||||
|
component.setSampleDocument([5])
|
||||||
|
const selected = zone()
|
||||||
|
component.template.zones = [selected]
|
||||||
|
expect(component.isLastPageZone(selected)).toBe(false)
|
||||||
|
component.setLastPageZone(selected, true)
|
||||||
|
expect(selected.page).toBe(-1)
|
||||||
|
expect(component.isLastPageZone(selected)).toBe(true)
|
||||||
|
component.selectZone(0)
|
||||||
|
expect(component.previewPage).toBe(2)
|
||||||
|
component.setLastPageZone(selected, false)
|
||||||
|
expect(selected.page).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores page-count errors from a previous sample document', () => {
|
||||||
|
const pending = new Subject<Document>()
|
||||||
|
jest
|
||||||
|
.spyOn(documentService, 'get')
|
||||||
|
.mockReturnValueOnce(pending)
|
||||||
|
.mockReturnValueOnce(of({ id: 6 }))
|
||||||
|
component.setSampleDocument([5])
|
||||||
|
component.setSampleDocument([6])
|
||||||
|
pending.error(new Error('Old document failed'))
|
||||||
|
expect(component.pageFailed()).toBe(false)
|
||||||
|
expect(component.previewPageCount()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores non-primary, unloaded, and saving pointer gestures', () => {
|
||||||
|
prepareOverlay()
|
||||||
|
component.onOverlayPointerDown(
|
||||||
|
new MouseEvent('pointerdown', { button: 2 }) as PointerEvent
|
||||||
|
)
|
||||||
|
expect(component.drawingRect()).toBeNull()
|
||||||
|
component.pageLoaded.set(false)
|
||||||
|
component.onOverlayPointerDown(pointer(100, 100))
|
||||||
|
expect(component.drawingRect()).toBeNull()
|
||||||
|
component.pageLoaded.set(true)
|
||||||
|
component.saving.set(true)
|
||||||
|
component.onOverlayPointerDown(pointer(100, 100))
|
||||||
|
expect(component.drawingRect()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores pointer events when the SVG coordinate transform is unavailable', () => {
|
||||||
|
component.onOverlayPointerDown(pointer(100, 100))
|
||||||
|
expect(component.drawingRect()).toBeNull()
|
||||||
|
prepareOverlay()
|
||||||
|
jest
|
||||||
|
.spyOn(component.overlayRef.nativeElement, 'getScreenCTM')
|
||||||
|
.mockReturnValue(null)
|
||||||
|
component.onOverlayPointerDown(pointer(100, 100))
|
||||||
|
component.onOverlayPointerMove(pointer(200, 200))
|
||||||
|
expect(component.drawingRect()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates a drawing only for its captured pointer', () => {
|
||||||
|
prepareOverlay()
|
||||||
|
component.onOverlayPointerDown(pointer(100, 100))
|
||||||
|
component.onOverlayPointerDown(pointer(500, 500, 2))
|
||||||
|
component.onOverlayPointerMove(pointer(900, 900, 2))
|
||||||
|
expect(component.drawingRect()).toEqual({ x: 100, y: 100, w: 0, h: 0 })
|
||||||
|
component.onOverlayPointerMove(pointer(250, 200))
|
||||||
|
expect(component.drawingRect()).toEqual({ x: 100, y: 100, w: 150, h: 100 })
|
||||||
|
component.onOverlayPointerUp(pointer(250, 200))
|
||||||
|
expect(component.template.zones[0]).toMatchObject({
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
width: 150,
|
||||||
|
height: 100,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resizes a selected zone using its corner handle', () => {
|
||||||
|
prepareOverlay()
|
||||||
|
const selected = zone()
|
||||||
|
component.template.zones = [selected]
|
||||||
|
component.selectZone(0)
|
||||||
|
component.onOverlayPointerDown(pointer(100, 100))
|
||||||
|
component.onOverlayPointerMove(pointer(50, 75))
|
||||||
|
component.onOverlayPointerUp(pointer(60, 80))
|
||||||
|
expect(selected).toMatchObject({ x: 60, y: 80, width: 240, height: 120 })
|
||||||
|
expect(component.template.zones).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the appropriate cursor over handles, zones, and empty space', () => {
|
||||||
|
prepareOverlay()
|
||||||
|
component.template.zones = [zone()]
|
||||||
|
component.onOverlayPointerMove(pointer(150, 150))
|
||||||
|
expect(component.overlayCursor).toBe('move')
|
||||||
|
component.selectZone(0)
|
||||||
|
component.onOverlayPointerMove(pointer(100, 100))
|
||||||
|
expect(component.overlayCursor).toBe('nw-resize')
|
||||||
|
component.onOverlayPointerMove(pointer(500, 500))
|
||||||
|
expect(component.overlayCursor).toBe('crosshair')
|
||||||
|
component.template.zones[0].page = 2
|
||||||
|
component.onOverlayPointerMove(pointer(100, 100))
|
||||||
|
expect(component.zoneDisplayRect(0)).toBeNull()
|
||||||
|
expect(component.overlayCursor).toBe('crosshair')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancels only the captured pointer and tolerates an already-lost capture', () => {
|
||||||
|
prepareOverlay()
|
||||||
|
component.onOverlayPointerCancel(pointer(100, 100))
|
||||||
|
component.onOverlayPointerDown(pointer(100, 100))
|
||||||
|
component.onOverlayPointerCancel(pointer(150, 150, 2))
|
||||||
|
expect(component.drawingRect()).not.toBeNull()
|
||||||
|
jest
|
||||||
|
.spyOn(component.overlayRef.nativeElement, 'hasPointerCapture')
|
||||||
|
.mockReturnValue(false)
|
||||||
|
component.onOverlayPointerCancel(pointer(150, 150))
|
||||||
|
expect(component.drawingRect()).toBeNull()
|
||||||
|
expect(
|
||||||
|
component.overlayRef.nativeElement.releasePointerCapture
|
||||||
|
).not.toHaveBeenCalled()
|
||||||
|
expect(component.template.zones).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves the selected zone when an earlier or later zone is removed', () => {
|
||||||
|
component.template.zones = [zone(), zone(), zone(), zone()]
|
||||||
|
component.selectZone(1)
|
||||||
|
const selected = component.selectedZone
|
||||||
|
component.removeZone(3)
|
||||||
|
expect(component.selectedZone).toBe(selected)
|
||||||
|
component.removeZone(0)
|
||||||
|
expect(component.selectedZoneIndex).toBe(0)
|
||||||
|
expect(component.selectedZone).toBe(selected)
|
||||||
|
component.selectZone(99)
|
||||||
|
expect(component.selectedZone).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not test zones without a selection or a sample document', () => {
|
||||||
|
const test = jest.spyOn(templateService, 'testZone')
|
||||||
|
component.testZone()
|
||||||
|
component.template.zones = [zone()]
|
||||||
|
component.selectZone(0)
|
||||||
|
component.testZone()
|
||||||
|
expect(test).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
response: { error: { error: 'OCR unavailable' } },
|
||||||
|
message: 'OCR unavailable',
|
||||||
|
},
|
||||||
|
{ response: new Error('Network failure'), message: 'Test failed' },
|
||||||
|
])('shows zone-test errors: $message', ({ response, message }) => {
|
||||||
|
component.template.zones = [zone({ page: undefined })]
|
||||||
|
component.selectZone(0)
|
||||||
|
component.setSampleDocument([5])
|
||||||
|
jest
|
||||||
|
.spyOn(templateService, 'testZone')
|
||||||
|
.mockReturnValue(throwError(() => response))
|
||||||
|
component.testZone()
|
||||||
|
expect(component.zoneTestResult).toEqual({ error: message })
|
||||||
|
expect(component.zoneTesting).toBe(false)
|
||||||
|
expect(templateService.testZone).toHaveBeenCalledWith(
|
||||||
|
5,
|
||||||
|
expect.objectContaining({ page: 1 })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('switches between built-in targets, custom fields, and an unassigned field', () => {
|
||||||
|
const selected = zone()
|
||||||
|
expect(component.zoneFieldValue(selected)).toBe(1)
|
||||||
|
component.setZoneField(selected, OcrZoneTarget.Title)
|
||||||
|
expect(component.zoneFieldValue(selected)).toBe(OcrZoneTarget.Title)
|
||||||
|
expect(selected.custom_field).toBeNull()
|
||||||
|
component.setZoneField(selected, 7)
|
||||||
|
expect(selected).toMatchObject({
|
||||||
|
target: OcrZoneTarget.CustomField,
|
||||||
|
custom_field: 7,
|
||||||
|
})
|
||||||
|
component.setZoneField(selected, null)
|
||||||
|
expect(component.zoneFieldValue(selected)).toBeNull()
|
||||||
|
expect(component.zonesForField(selected)).toEqual([])
|
||||||
|
component.setCombineFormat(selected, 'Ignored')
|
||||||
|
expect(component.template.combine_formats).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates combine formats when absent and leaves implicit formats alone on rename', () => {
|
||||||
|
const selected = zone({ target: OcrZoneTarget.Title, custom_field: null })
|
||||||
|
component.template.combine_formats = undefined
|
||||||
|
component.renameZone(selected, 'Renamed')
|
||||||
|
expect(selected.name).toBe('Renamed')
|
||||||
|
expect(component.getCombineFormat(selected)).toBe('')
|
||||||
|
component.setCombineFormat(selected, '{Renamed} ')
|
||||||
|
component.insertCombineToken(selected, selected)
|
||||||
|
expect(component.template.combine_formats).toEqual({
|
||||||
|
title: '{Renamed} {Renamed}',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves templates without combine formats', () => {
|
||||||
|
component.template.combine_formats = undefined
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(component.isDirty).toBe(false)
|
||||||
|
jest
|
||||||
|
.spyOn(templateService, 'create')
|
||||||
|
.mockReturnValue(of(component.template))
|
||||||
|
component.save()
|
||||||
|
expect(component.template.combine_formats).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not assign unsupported custom field types and surfaces creation failures', () => {
|
||||||
|
component.template.zones = [zone({ custom_field: null })]
|
||||||
|
component.selectZone(0)
|
||||||
|
const dialog = fieldDialog()
|
||||||
|
const error = jest.spyOn(TestBed.inject(ToastService), 'showError')
|
||||||
|
component.createField('Status')
|
||||||
|
expect(dialog.object).toEqual({ name: 'Status' })
|
||||||
|
dialog.succeeded.next(customFields[1])
|
||||||
|
expect(component.selectedZone.custom_field).toBeNull()
|
||||||
|
const failure = new Error('Could not save')
|
||||||
|
dialog.failed.next(failure)
|
||||||
|
expect(error).toHaveBeenCalledWith('Error saving field.', failure)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refreshes field choices when a field is created without a selected zone', () => {
|
||||||
|
const dialog = fieldDialog()
|
||||||
|
component.createField('Total')
|
||||||
|
dialog.succeeded.next(customFields[0])
|
||||||
|
expect(component.customFields()).toEqual(customFields)
|
||||||
|
expect(component.selectedZone).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should edit a copy of the template and load its sample document', () => {
|
||||||
|
const original: OcrTemplate = {
|
||||||
|
...component.template,
|
||||||
|
id: 3,
|
||||||
|
name: 'Invoices',
|
||||||
|
match: 'Northwind',
|
||||||
|
matching_algorithm: MATCH_LITERAL,
|
||||||
|
sample_document: 5,
|
||||||
|
zones: [zone()],
|
||||||
|
combine_formats: { '1': '{Invoice number}' },
|
||||||
|
}
|
||||||
|
component.object = original
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(component.dialogTitle).toEqual('Edit OCR Template')
|
||||||
|
expect(component.previewDocId).toEqual(5)
|
||||||
|
expect(component.previewPageCount()).toEqual(3)
|
||||||
|
expect(component.pageImageUrl).toContain('document-page-image/5/0/')
|
||||||
|
expect(component.template.match).toEqual('Northwind')
|
||||||
|
expect(component.isDirty).toBeFalsy()
|
||||||
|
|
||||||
|
component.template.zones[0].x = 500
|
||||||
|
component.template.combine_formats['1'] = 'Edited'
|
||||||
|
expect(original.zones[0].x).toEqual(100)
|
||||||
|
expect(original.combine_formats['1']).toEqual('{Invoice number}')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should offer every matching algorithm except automatic', () => {
|
||||||
|
expect(component.matchingAlgorithms.map((a) => a.id)).not.toContain(
|
||||||
|
MATCH_AUTO
|
||||||
|
)
|
||||||
|
expect(component.template.matching_algorithm).toEqual(MATCH_ANY)
|
||||||
|
expect(component.patternRequired).toBeTruthy()
|
||||||
|
|
||||||
|
component.template.matching_algorithm = MATCH_NONE
|
||||||
|
expect(component.patternRequired).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should initialize new template permissions from saved defaults', () => {
|
||||||
|
const defaults = {
|
||||||
|
[SETTINGS_KEYS.DEFAULT_PERMS_OWNER]: 1,
|
||||||
|
[SETTINGS_KEYS.DEFAULT_PERMS_VIEW_USERS]: [2],
|
||||||
|
[SETTINGS_KEYS.DEFAULT_PERMS_VIEW_GROUPS]: [3],
|
||||||
|
[SETTINGS_KEYS.DEFAULT_PERMS_EDIT_USERS]: [],
|
||||||
|
[SETTINGS_KEYS.DEFAULT_PERMS_EDIT_GROUPS]: [4],
|
||||||
|
}
|
||||||
|
jest
|
||||||
|
.spyOn(TestBed.inject(SettingsService), 'get')
|
||||||
|
.mockImplementation((key) => defaults[key])
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(component.permissionsForm).toEqual({
|
||||||
|
owner: 1,
|
||||||
|
set_permissions: {
|
||||||
|
view: { users: [2], groups: [3] },
|
||||||
|
change: { users: [], groups: [4] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
fixture.debugElement.query(By.directive(PermissionsFormComponent))
|
||||||
|
).toBeTruthy()
|
||||||
|
expect(component.isDirty).toBe(false)
|
||||||
|
const create = jest
|
||||||
|
.spyOn(templateService, 'create')
|
||||||
|
.mockReturnValue(of({ ...component.template, id: 3 }))
|
||||||
|
component.save()
|
||||||
|
expect(create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining(component.permissionsForm)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should copy existing permissions and track changes without mutating the list item', () => {
|
||||||
|
component.object = {
|
||||||
|
...component.template,
|
||||||
|
id: 3,
|
||||||
|
owner: 1,
|
||||||
|
permissions: {
|
||||||
|
view: { users: [2], groups: [] },
|
||||||
|
change: { users: [], groups: [3] },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(component.permissionsForm.owner).toBe(1)
|
||||||
|
expect(component.isDirty).toBe(false)
|
||||||
|
component.permissionsForm.set_permissions.view.users.push(4)
|
||||||
|
expect(component.isDirty).toBe(true)
|
||||||
|
expect(component.object.permissions.view.users).toEqual([2])
|
||||||
|
const update = jest
|
||||||
|
.spyOn(templateService, 'update')
|
||||||
|
.mockReturnValue(of(component.template))
|
||||||
|
component.save()
|
||||||
|
expect(update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
owner: 1,
|
||||||
|
set_permissions: {
|
||||||
|
view: { users: [2, 4], groups: [] },
|
||||||
|
change: { users: [], groups: [3] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should hide sharing controls and omit permission changes for a shared editor', () => {
|
||||||
|
component.object = {
|
||||||
|
...component.template,
|
||||||
|
id: 3,
|
||||||
|
owner: 2,
|
||||||
|
user_can_change: true,
|
||||||
|
}
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(
|
||||||
|
fixture.debugElement.query(By.directive(PermissionsFormComponent))
|
||||||
|
).toBeNull()
|
||||||
|
component.permissionsForm = {
|
||||||
|
owner: 1,
|
||||||
|
set_permissions: { view: { users: [1] } },
|
||||||
|
}
|
||||||
|
const update = jest
|
||||||
|
.spyOn(templateService, 'update')
|
||||||
|
.mockReturnValue(of(component.template))
|
||||||
|
component.save()
|
||||||
|
expect(update.mock.calls[0][0].owner).toBe(2)
|
||||||
|
expect(update.mock.calls[0][0]).not.toHaveProperty('set_permissions')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should keep only the last picked sample document', () => {
|
||||||
|
component.setSampleDocument([5, 6])
|
||||||
|
expect(component.sampleDocumentIds).toEqual([6])
|
||||||
|
|
||||||
|
component.setSampleDocument([])
|
||||||
|
expect(component.previewDocId).toBeNull()
|
||||||
|
expect(component.pageImageUrl).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should ignore a stale sample document response', () => {
|
||||||
|
const pending = new Subject<Document>()
|
||||||
|
jest.spyOn(documentService, 'get').mockReturnValueOnce(pending)
|
||||||
|
component.setSampleDocument([5])
|
||||||
|
component.setSampleDocument([6])
|
||||||
|
pending.next({ id: 5, page_count: 99 })
|
||||||
|
|
||||||
|
expect(component.previewPageCount()).toEqual(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show a failed page and retry it', () => {
|
||||||
|
jest
|
||||||
|
.spyOn(documentService, 'get')
|
||||||
|
.mockReturnValueOnce(throwError(() => new Error('offline')))
|
||||||
|
component.setSampleDocument([5])
|
||||||
|
expect(component.pageFailed()).toBeTruthy()
|
||||||
|
|
||||||
|
component.setSampleDocument(component.sampleDocumentIds)
|
||||||
|
expect(component.pageFailed()).toBeFalsy()
|
||||||
|
|
||||||
|
component.onImageError()
|
||||||
|
expect(component.pageFailed()).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support create and update', () => {
|
||||||
|
const createSpy = jest
|
||||||
|
.spyOn(templateService, 'create')
|
||||||
|
.mockReturnValue(of({ ...component.template, id: 3 }))
|
||||||
|
const updateSpy = jest
|
||||||
|
.spyOn(templateService, 'update')
|
||||||
|
.mockReturnValue(of(component.template))
|
||||||
|
const succeededSpy = jest.spyOn(component.succeeded, 'emit')
|
||||||
|
|
||||||
|
component.save()
|
||||||
|
expect(createSpy).toHaveBeenCalled()
|
||||||
|
expect(succeededSpy).toHaveBeenCalledWith({ ...component.template, id: 3 })
|
||||||
|
expect(activeModal.close).toHaveBeenCalled()
|
||||||
|
|
||||||
|
component.saving.set(false)
|
||||||
|
component.template.id = 3
|
||||||
|
component.save()
|
||||||
|
expect(updateSpy).toHaveBeenCalledWith(component.template)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should emit failed and allow editing again after a failed save', () => {
|
||||||
|
prepareOverlay()
|
||||||
|
component.template.zones = [zone()]
|
||||||
|
const pending = new Subject<OcrTemplate>()
|
||||||
|
jest.spyOn(templateService, 'create').mockReturnValue(pending)
|
||||||
|
const failedSpy = jest.spyOn(component.failed, 'emit')
|
||||||
|
component.onOverlayPointerDown(pointer(150, 150))
|
||||||
|
|
||||||
|
component.save()
|
||||||
|
component.save()
|
||||||
|
expect(templateService.create).toHaveBeenCalledTimes(1)
|
||||||
|
component.onOverlayPointerMove(pointer(250, 250))
|
||||||
|
component.onOverlayPointerUp(pointer(250, 250))
|
||||||
|
expect(component.template.zones[0].x).toEqual(100)
|
||||||
|
|
||||||
|
pending.error(new Error('Save failed'))
|
||||||
|
expect(failedSpy).toHaveBeenCalled()
|
||||||
|
expect(component.saving()).toBeFalsy()
|
||||||
|
component.onOverlayPointerDown(pointer(150, 150))
|
||||||
|
component.onOverlayPointerUp(pointer(250, 250))
|
||||||
|
expect(component.template.zones[0].x).toEqual(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should track unsaved changes', () => {
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(component.isDirty).toBeFalsy()
|
||||||
|
|
||||||
|
component.template.combine_formats = { '1': '' }
|
||||||
|
expect(component.isDirty).toBeFalsy()
|
||||||
|
|
||||||
|
component.template.name = 'Invoices'
|
||||||
|
expect(component.isDirty).toBeTruthy()
|
||||||
|
component.template.name = ''
|
||||||
|
expect(component.isDirty).toBeFalsy()
|
||||||
|
|
||||||
|
component.setSampleDocument([5])
|
||||||
|
expect(component.isDirty).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should draw a zone with no field in empty space', () => {
|
||||||
|
prepareOverlay()
|
||||||
|
component.onOverlayPointerDown(pointer(100, 100))
|
||||||
|
component.onOverlayPointerUp(pointer(1200, 250))
|
||||||
|
|
||||||
|
expect(component.template.zones[0]).toMatchObject({
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
width: 900,
|
||||||
|
height: 150,
|
||||||
|
custom_field: null,
|
||||||
|
})
|
||||||
|
expect(component.selectedZoneIndex).toEqual(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clear the selection on a click in empty space', () => {
|
||||||
|
prepareOverlay()
|
||||||
|
component.template.zones = [zone()]
|
||||||
|
component.selectZone(0)
|
||||||
|
component.onOverlayPointerDown(pointer(600, 600))
|
||||||
|
component.onOverlayPointerUp(pointer(602, 601))
|
||||||
|
|
||||||
|
expect(component.template.zones).toHaveLength(1)
|
||||||
|
expect(component.selectedZoneIndex).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should move a zone when dragging on it', () => {
|
||||||
|
prepareOverlay()
|
||||||
|
component.template.zones = [zone()]
|
||||||
|
component.onOverlayPointerDown(pointer(150, 150))
|
||||||
|
component.onOverlayPointerUp(pointer(250, 200))
|
||||||
|
|
||||||
|
expect(component.template.zones).toHaveLength(1)
|
||||||
|
expect(component.template.zones[0]).toMatchObject({ x: 200, y: 150 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should cancel a drawing on escape and ignore other pointers', () => {
|
||||||
|
prepareOverlay()
|
||||||
|
component.onOverlayPointerDown(pointer(100, 100))
|
||||||
|
component.onOverlayPointerUp(pointer(300, 200, 2))
|
||||||
|
expect(component.drawingRect()).not.toBeNull()
|
||||||
|
|
||||||
|
component.cancelDrawing()
|
||||||
|
component.onOverlayPointerUp(pointer(300, 200))
|
||||||
|
expect(component.template.zones).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should open a zone in the zones tab and go back to the list', () => {
|
||||||
|
component.template.zones = [zone(), zone()]
|
||||||
|
component.selectZone(1)
|
||||||
|
expect(component.activeTab).toEqual('zones')
|
||||||
|
expect(component.selectedZone).toBe(component.template.zones[1])
|
||||||
|
|
||||||
|
component.deselectZone()
|
||||||
|
expect(component.selectedZone).toBeNull()
|
||||||
|
|
||||||
|
component.selectZone(0)
|
||||||
|
component.removeZone(0)
|
||||||
|
expect(component.template.zones).toHaveLength(1)
|
||||||
|
expect(component.selectedZone).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should only offer custom fields OCR can write to', () => {
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(component.fieldItems().map((item) => item.id)).toEqual([
|
||||||
|
OcrZoneTarget.Title,
|
||||||
|
OcrZoneTarget.Asn,
|
||||||
|
OcrZoneTarget.Created,
|
||||||
|
1,
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should create a custom field and assign it to the zone', () => {
|
||||||
|
const succeeded = new Subject<CustomField>()
|
||||||
|
const dialog = {
|
||||||
|
dialogMode: { set: jest.fn() },
|
||||||
|
object: null,
|
||||||
|
succeeded,
|
||||||
|
failed: new Subject(),
|
||||||
|
}
|
||||||
|
jest
|
||||||
|
.spyOn(modalService, 'open')
|
||||||
|
.mockReturnValue({ componentInstance: dialog } as any)
|
||||||
|
component.template.zones = [zone({ custom_field: null })]
|
||||||
|
component.selectZone(0)
|
||||||
|
|
||||||
|
component.createField()
|
||||||
|
expect(dialog.object).toEqual({ name: 'Invoice number' })
|
||||||
|
succeeded.next(customFields[0])
|
||||||
|
|
||||||
|
expect(component.template.zones[0].custom_field).toEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should build and prune combine formats', () => {
|
||||||
|
const first = zone({ name: 'First' })
|
||||||
|
const second = zone({ name: 'Second' })
|
||||||
|
const other = zone({ name: 'Other', custom_field: 2 })
|
||||||
|
component.template.zones = [first, second, other]
|
||||||
|
component.template.combine_formats = { '2': '{Other}' }
|
||||||
|
|
||||||
|
expect(component.isFieldShared(first)).toBeTruthy()
|
||||||
|
expect(component.isFieldShared(other)).toBeFalsy()
|
||||||
|
expect(component.defaultCombineFormat(first)).toEqual('{First} {Second}')
|
||||||
|
|
||||||
|
component.insertCombineToken(first, second)
|
||||||
|
component.insertCombineToken(first, first)
|
||||||
|
expect(component.getCombineFormat(second)).toEqual('{Second} {First}')
|
||||||
|
|
||||||
|
jest.spyOn(templateService, 'create').mockReturnValue(of(null))
|
||||||
|
component.save()
|
||||||
|
expect(component.template.combine_formats).toEqual({
|
||||||
|
'1': '{Second} {First}',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update exact tokens when a zone is renamed', () => {
|
||||||
|
const first = zone({ name: 'First [value]' })
|
||||||
|
component.template.zones = [first, zone({ name: 'Second' })]
|
||||||
|
component.template.combine_formats = {
|
||||||
|
'1': 'First [value]: {First [value]} / { First [value] } / {Second} / {First [value] extra}',
|
||||||
|
'2': '{First [value]}',
|
||||||
|
}
|
||||||
|
|
||||||
|
component.renameZone(first, '')
|
||||||
|
component.renameZone(first, 'Invoice number')
|
||||||
|
|
||||||
|
expect(first.name).toEqual('Invoice number')
|
||||||
|
expect(component.template.combine_formats).toEqual({
|
||||||
|
'1': 'First [value]: {Invoice number} / {Invoice number} / {Second} / {First [value] extra}',
|
||||||
|
'2': '{First [value]}',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should keep the language array stable until the value changes', () => {
|
||||||
|
const selected = zone({ ocr_language: 'deu+eng' })
|
||||||
|
const languages = component.ocrLanguageArray(selected)
|
||||||
|
expect(languages).toEqual(['deu', 'eng'])
|
||||||
|
expect(component.ocrLanguageArray(selected)).toBe(languages)
|
||||||
|
|
||||||
|
component.setOcrLanguages(selected, ['fra'])
|
||||||
|
expect(selected.ocr_language).toEqual('fra')
|
||||||
|
expect(component.ocrLanguageArray(selected)).toEqual(['fra'])
|
||||||
|
|
||||||
|
component.setOcrLanguages(selected, null)
|
||||||
|
expect(component.ocrLanguageArray(selected)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should hide a test result once the zone changes', () => {
|
||||||
|
const selected = zone()
|
||||||
|
component.template.zones = [selected]
|
||||||
|
component.selectZone(0)
|
||||||
|
component.setSampleDocument([5])
|
||||||
|
jest
|
||||||
|
.spyOn(templateService, 'testZone')
|
||||||
|
.mockReturnValue(of({ value: '123' }))
|
||||||
|
|
||||||
|
component.testZone()
|
||||||
|
expect(component.zoneTestResult.value).toEqual('123')
|
||||||
|
|
||||||
|
selected.transform = OcrZoneTransform.Numeric
|
||||||
|
expect(component.zoneTestResult).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should drop a pending test when the zone or sample document changes', () => {
|
||||||
|
const pending = new Subject<OcrZoneTestResult>()
|
||||||
|
component.template.zones = [zone(), zone()]
|
||||||
|
component.selectZone(0)
|
||||||
|
component.setSampleDocument([5])
|
||||||
|
jest.spyOn(templateService, 'testZone').mockReturnValue(pending)
|
||||||
|
|
||||||
|
component.testZone()
|
||||||
|
expect(component.zoneTesting).toBeTruthy()
|
||||||
|
component.selectZone(1)
|
||||||
|
pending.next({ value: 'Old zone' })
|
||||||
|
expect(component.zoneTestResult).toBeNull()
|
||||||
|
|
||||||
|
component.testZone()
|
||||||
|
component.setSampleDocument([])
|
||||||
|
pending.next({ value: 'Old document' })
|
||||||
|
expect(component.zoneTestResult).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
+963
@@ -0,0 +1,963 @@
|
|||||||
|
import { CommonModule } from '@angular/common'
|
||||||
|
import {
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
ElementRef,
|
||||||
|
EventEmitter,
|
||||||
|
HostListener,
|
||||||
|
inject,
|
||||||
|
Input,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit,
|
||||||
|
Output,
|
||||||
|
signal,
|
||||||
|
ViewChild,
|
||||||
|
} from '@angular/core'
|
||||||
|
import { FormsModule } from '@angular/forms'
|
||||||
|
import {
|
||||||
|
NgbActiveModal,
|
||||||
|
NgbModal,
|
||||||
|
NgbNavModule,
|
||||||
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
||||||
|
import { Subject, takeUntil } from 'rxjs'
|
||||||
|
import { CustomFieldEditDialogComponent } from 'src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||||
|
import { EditDialogMode } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
||||||
|
import { DocumentLinkComponent } from 'src/app/components/common/input/document-link/document-link.component'
|
||||||
|
import { CheckComponent } from 'src/app/components/common/input/check/check.component'
|
||||||
|
import { NumberComponent } from 'src/app/components/common/input/number/number.component'
|
||||||
|
import {
|
||||||
|
PermissionsFormComponent,
|
||||||
|
PermissionsFormObject,
|
||||||
|
} from 'src/app/components/common/input/permissions/permissions-form/permissions-form.component'
|
||||||
|
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 { CustomField } from 'src/app/data/custom-field'
|
||||||
|
import {
|
||||||
|
MATCH_ANY,
|
||||||
|
MATCH_AUTO,
|
||||||
|
MATCH_NONE,
|
||||||
|
MATCHING_ALGORITHMS,
|
||||||
|
} from 'src/app/data/matching-model'
|
||||||
|
import {
|
||||||
|
DEFAULT_OCR_ZONE_LANGUAGE,
|
||||||
|
getZoneFieldKey,
|
||||||
|
OCR_BUILTIN_TARGETS,
|
||||||
|
OCR_SUPPORTED_FIELD_TYPES,
|
||||||
|
OcrTemplate,
|
||||||
|
OcrTemplateZone,
|
||||||
|
OcrZoneTarget,
|
||||||
|
OcrZoneTestResult,
|
||||||
|
OcrZoneTransform,
|
||||||
|
ZoneTestRequest,
|
||||||
|
} from 'src/app/data/ocr-template'
|
||||||
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
|
import { User } from 'src/app/data/user'
|
||||||
|
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
||||||
|
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||||
|
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||||
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
|
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
|
||||||
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
|
import { SettingsService } from 'src/app/services/settings.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,
|
||||||
|
getResizeHandles,
|
||||||
|
getZoneColor,
|
||||||
|
getZoneDisplayRect,
|
||||||
|
getZonePage,
|
||||||
|
HANDLE_SIZE,
|
||||||
|
isZoneOnPage,
|
||||||
|
MIN_ZONE_SIZE,
|
||||||
|
MoveStart,
|
||||||
|
moveZone,
|
||||||
|
Point,
|
||||||
|
ResizeHandle,
|
||||||
|
resizeZone,
|
||||||
|
} from './zone-geometry'
|
||||||
|
|
||||||
|
type ActiveTab = 'template' | 'zones'
|
||||||
|
type ZoneFieldSelection = OcrZoneTarget | number | null
|
||||||
|
type OverlayInteraction =
|
||||||
|
| { kind: 'idle' }
|
||||||
|
| { kind: 'drawing'; pointerId: number; rect: DrawingRect }
|
||||||
|
| { kind: 'moving'; pointerId: number; zoneIndex: number; start: MoveStart }
|
||||||
|
| {
|
||||||
|
kind: 'resizing'
|
||||||
|
pointerId: number
|
||||||
|
zoneIndex: number
|
||||||
|
handle: ResizeHandle
|
||||||
|
}
|
||||||
|
interface ZoneTest {
|
||||||
|
zone: OcrTemplateZone
|
||||||
|
/** The sample document and zone settings the result was produced from. */
|
||||||
|
key: string
|
||||||
|
result: OcrZoneTestResult | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ZONE_TRANSFORM_OPTIONS = [
|
||||||
|
{ id: OcrZoneTransform.None, name: $localize`None` },
|
||||||
|
{ id: OcrZoneTransform.Strip, name: $localize`Strip whitespace` },
|
||||||
|
{ id: OcrZoneTransform.Uppercase, name: $localize`Uppercase` },
|
||||||
|
{ id: OcrZoneTransform.Lowercase, name: $localize`Lowercase` },
|
||||||
|
{ id: OcrZoneTransform.Numeric, name: $localize`Numeric only` },
|
||||||
|
{
|
||||||
|
id: OcrZoneTransform.StripPunctuation,
|
||||||
|
name: $localize`Remove leading/trailing punctuation`,
|
||||||
|
},
|
||||||
|
{ id: OcrZoneTransform.Date, name: $localize`Parse date` },
|
||||||
|
{ id: OcrZoneTransform.QrCode, name: $localize`Read QR/barcode` },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const ZONE_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` },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TEMPLATE_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
|
||||||
|
(algorithm) => algorithm.id !== MATCH_AUTO
|
||||||
|
)
|
||||||
|
|
||||||
|
const NO_OVERLAY_INTERACTION: OverlayInteraction = { kind: 'idle' }
|
||||||
|
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',
|
||||||
|
imports: [
|
||||||
|
DocumentLinkComponent,
|
||||||
|
NumberComponent,
|
||||||
|
TextComponent,
|
||||||
|
CheckComponent,
|
||||||
|
SelectComponent,
|
||||||
|
SwitchComponent,
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
NgbNavModule,
|
||||||
|
BootstrapIconComponent,
|
||||||
|
OcrTemplateEditorZoneListComponent,
|
||||||
|
PermissionsFormComponent,
|
||||||
|
IfOwnerDirective,
|
||||||
|
],
|
||||||
|
templateUrl: './ocr-template-editor.component.html',
|
||||||
|
styleUrls: ['./ocr-template-editor.component.scss'],
|
||||||
|
})
|
||||||
|
export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||||
|
public readonly activeModal = inject(NgbActiveModal)
|
||||||
|
private readonly templateService = inject(OcrTemplateService)
|
||||||
|
private readonly customFieldsService = inject(CustomFieldsService)
|
||||||
|
private readonly documentService = inject(DocumentService)
|
||||||
|
private readonly toastService = inject(ToastService)
|
||||||
|
private readonly modalService = inject(NgbModal)
|
||||||
|
private readonly userService = inject(UserService)
|
||||||
|
private readonly settingsService = inject(SettingsService)
|
||||||
|
private readonly permissionsService = inject(PermissionsService)
|
||||||
|
private readonly destroy$ = new Subject<void>()
|
||||||
|
private readonly cancelZoneTest$ = new Subject<void>()
|
||||||
|
|
||||||
|
@Input() object: OcrTemplate
|
||||||
|
@Output() public readonly succeeded = new EventEmitter<OcrTemplate>()
|
||||||
|
@Output() public readonly failed = new EventEmitter()
|
||||||
|
|
||||||
|
@ViewChild('zoneOverlay') overlayRef: ElementRef<SVGSVGElement>
|
||||||
|
imageRef: ElementRef<HTMLImageElement>
|
||||||
|
private previewResizeObserver: ResizeObserver | null = null
|
||||||
|
private readonly overlayScale = signal(1)
|
||||||
|
|
||||||
|
@ViewChild('pageImage')
|
||||||
|
set pageImage(ref: ElementRef<HTMLImageElement> | undefined) {
|
||||||
|
this.previewResizeObserver?.disconnect()
|
||||||
|
this.imageRef = ref
|
||||||
|
if (!ref) return
|
||||||
|
this.previewResizeObserver = new ResizeObserver(() => {
|
||||||
|
const image = ref.nativeElement
|
||||||
|
if (image.naturalWidth && image.clientWidth) {
|
||||||
|
this.overlayScale.set(image.naturalWidth / image.clientWidth)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.previewResizeObserver.observe(ref.nativeElement)
|
||||||
|
}
|
||||||
|
|
||||||
|
template: OcrTemplate = {
|
||||||
|
id: null,
|
||||||
|
name: '',
|
||||||
|
match: '',
|
||||||
|
matching_algorithm: MATCH_ANY,
|
||||||
|
is_insensitive: true,
|
||||||
|
sample_document: null,
|
||||||
|
source_width: 0,
|
||||||
|
source_height: 0,
|
||||||
|
enabled: true,
|
||||||
|
combine_formats: {},
|
||||||
|
zones: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly customFields = signal<CustomField[]>([])
|
||||||
|
public readonly fieldItems = computed<
|
||||||
|
{ id: ZoneFieldSelection; name: string }[]
|
||||||
|
>(() => [
|
||||||
|
...OCR_BUILTIN_TARGETS,
|
||||||
|
...this.customFields()
|
||||||
|
.filter((field) => OCR_SUPPORTED_FIELD_TYPES.includes(field.data_type))
|
||||||
|
.map((field) => ({ id: field.id, name: field.name })),
|
||||||
|
])
|
||||||
|
public readonly users = signal<User[]>([])
|
||||||
|
permissionsForm: PermissionsFormObject = {}
|
||||||
|
public readonly OcrZoneTransform = OcrZoneTransform
|
||||||
|
public readonly saving = signal(false)
|
||||||
|
|
||||||
|
/** Bound to the document picker, which works with id arrays. */
|
||||||
|
sampleDocumentIds: number[] = []
|
||||||
|
previewPage = 0
|
||||||
|
public readonly previewPageCount = signal<number | null>(null)
|
||||||
|
public readonly pageLoaded = signal(false)
|
||||||
|
public readonly pageFailed = signal(false)
|
||||||
|
zoom = 1
|
||||||
|
|
||||||
|
get previewDocId(): number | null {
|
||||||
|
return this.sampleDocumentIds[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
get pageImageUrl(): string | null {
|
||||||
|
return this.previewDocId
|
||||||
|
? this.templateService.getPageImageUrl(
|
||||||
|
this.previewDocId,
|
||||||
|
this.previewPage
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
get transformOptions() {
|
||||||
|
return ZONE_TRANSFORM_OPTIONS
|
||||||
|
}
|
||||||
|
|
||||||
|
get languageOptions() {
|
||||||
|
return ZONE_LANGUAGE_OPTIONS
|
||||||
|
}
|
||||||
|
|
||||||
|
get matchingAlgorithms() {
|
||||||
|
return TEMPLATE_MATCHING_ALGORITHMS
|
||||||
|
}
|
||||||
|
|
||||||
|
get patternRequired(): boolean {
|
||||||
|
return (
|
||||||
|
this.template.matching_algorithm !== MATCH_AUTO &&
|
||||||
|
this.template.matching_algorithm !== MATCH_NONE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public get previewPageDisplay(): number {
|
||||||
|
return this.previewPage + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
public set previewPageDisplay(value: number) {
|
||||||
|
this.goToPage(value - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
activeTab: ActiveTab = 'template'
|
||||||
|
|
||||||
|
selectedZoneIndex: number | null = null
|
||||||
|
private overlayInteraction: OverlayInteraction = NO_OVERLAY_INTERACTION
|
||||||
|
overlayCursor = 'crosshair'
|
||||||
|
|
||||||
|
private readonly zoneTest = signal<ZoneTest | null>(null)
|
||||||
|
|
||||||
|
get zoneTestResult(): OcrZoneTestResult | null {
|
||||||
|
return this.isCurrentZoneTest() ? this.zoneTest().result : null
|
||||||
|
}
|
||||||
|
|
||||||
|
get zoneTesting(): boolean {
|
||||||
|
return this.isCurrentZoneTest() && this.zoneTest().result === null
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedZone(): OcrTemplateZone | null {
|
||||||
|
return this.selectedZoneIndex !== null
|
||||||
|
? (this.template.zones[this.selectedZoneIndex] ?? null)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
private initialState = ''
|
||||||
|
|
||||||
|
get isDirty(): boolean {
|
||||||
|
return this.editorState() !== this.initialState
|
||||||
|
}
|
||||||
|
|
||||||
|
private editorState(): string {
|
||||||
|
const formats = Object.entries(this.template.combine_formats ?? {}).filter(
|
||||||
|
([, format]) => format
|
||||||
|
)
|
||||||
|
return JSON.stringify({
|
||||||
|
...this.template,
|
||||||
|
combine_formats: Object.fromEntries(formats),
|
||||||
|
sample_document: this.previewDocId ?? null,
|
||||||
|
permissions_form: this.permissionsForm,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
get dialogTitle(): string {
|
||||||
|
return this.template.id
|
||||||
|
? $localize`Edit OCR Template`
|
||||||
|
: $localize`New OCR Template`
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.userService
|
||||||
|
.listAll()
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe((r) => this.users.set(r.results))
|
||||||
|
this.customFieldsService
|
||||||
|
.listAll()
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe((r) => {
|
||||||
|
this.customFields.set(r.results)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (this.object) {
|
||||||
|
this.template = {
|
||||||
|
...this.object,
|
||||||
|
zones: this.object.zones.map((zone) => ({ ...zone })),
|
||||||
|
combine_formats: { ...this.object.combine_formats },
|
||||||
|
}
|
||||||
|
if (this.template.sample_document) {
|
||||||
|
this.setSampleDocument([this.template.sample_document])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const permissions = this.object?.permissions
|
||||||
|
this.permissionsForm = this.object
|
||||||
|
? {
|
||||||
|
owner: this.object.owner,
|
||||||
|
set_permissions: {
|
||||||
|
view: {
|
||||||
|
users: [...(permissions?.view.users ?? [])],
|
||||||
|
groups: [...(permissions?.view.groups ?? [])],
|
||||||
|
},
|
||||||
|
change: {
|
||||||
|
users: [...(permissions?.change.users ?? [])],
|
||||||
|
groups: [...(permissions?.change.groups ?? [])],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
owner: this.settingsService.get(SETTINGS_KEYS.DEFAULT_PERMS_OWNER),
|
||||||
|
set_permissions: {
|
||||||
|
view: {
|
||||||
|
users: this.settingsService.get(
|
||||||
|
SETTINGS_KEYS.DEFAULT_PERMS_VIEW_USERS
|
||||||
|
),
|
||||||
|
groups: this.settingsService.get(
|
||||||
|
SETTINGS_KEYS.DEFAULT_PERMS_VIEW_GROUPS
|
||||||
|
),
|
||||||
|
},
|
||||||
|
change: {
|
||||||
|
users: this.settingsService.get(
|
||||||
|
SETTINGS_KEYS.DEFAULT_PERMS_EDIT_USERS
|
||||||
|
),
|
||||||
|
groups: this.settingsService.get(
|
||||||
|
SETTINGS_KEYS.DEFAULT_PERMS_EDIT_GROUPS
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
this.initialState = this.editorState()
|
||||||
|
}
|
||||||
|
|
||||||
|
setSampleDocument(ids: number[]) {
|
||||||
|
const id = ids.at(-1)
|
||||||
|
this.sampleDocumentIds = id ? [id] : []
|
||||||
|
this.previewPage = 0
|
||||||
|
this.previewPageCount.set(null)
|
||||||
|
this.resetPage()
|
||||||
|
if (!id) return
|
||||||
|
this.documentService
|
||||||
|
.get(id)
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe({
|
||||||
|
next: (doc) => {
|
||||||
|
if (this.previewDocId !== id) return
|
||||||
|
this.previewPageCount.set(doc.page_count ?? null)
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
if (this.previewDocId === id) this.pageFailed.set(true)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetPage() {
|
||||||
|
this.stopOverlayInteraction()
|
||||||
|
this.clearZoneTest()
|
||||||
|
this.pageLoaded.set(false)
|
||||||
|
this.pageFailed.set(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
goToPage(page: number) {
|
||||||
|
if (!Number.isFinite(page)) return
|
||||||
|
const pageCount = this.previewPageCount()
|
||||||
|
const max = pageCount ? pageCount - 1 : page
|
||||||
|
const clamped = Math.max(0, Math.min(Math.trunc(page), max))
|
||||||
|
if (clamped === this.previewPage) return
|
||||||
|
this.previewPage = clamped
|
||||||
|
this.resetPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
isLastPageZone(zone: OcrTemplateZone): boolean {
|
||||||
|
return zone.page === -1
|
||||||
|
}
|
||||||
|
|
||||||
|
setLastPageZone(zone: OcrTemplateZone, lastPage: boolean) {
|
||||||
|
zone.page = lastPage ? -1 : this.previewPage + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private isOnCurrentPage(zone: OcrTemplateZone): boolean {
|
||||||
|
return isZoneOnPage(zone, this.previewPage, this.previewPageCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
onImageLoad() {
|
||||||
|
this.pageLoaded.set(true)
|
||||||
|
const img = this.imageRef.nativeElement
|
||||||
|
this.template.source_width = img.naturalWidth
|
||||||
|
this.template.source_height = img.naturalHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
onImageError() {
|
||||||
|
this.stopOverlayInteraction()
|
||||||
|
this.pageFailed.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('document:keydown.escape')
|
||||||
|
cancelDrawing() {
|
||||||
|
this.stopOverlayInteraction()
|
||||||
|
}
|
||||||
|
|
||||||
|
onOverlayPointerDown(event: PointerEvent) {
|
||||||
|
if (this.saving()) return
|
||||||
|
if (event.button !== 0 || this.overlayInteraction.kind !== 'idle') return
|
||||||
|
const point = this.svgPointFromEvent(event)
|
||||||
|
if (!this.pageLoaded() || !point) return
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
if (this.selectedZoneIndex !== null) {
|
||||||
|
const handle = this.handleAt(point, this.selectedZoneIndex)
|
||||||
|
if (handle) {
|
||||||
|
this.overlayInteraction = {
|
||||||
|
kind: 'resizing',
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
zoneIndex: this.selectedZoneIndex,
|
||||||
|
handle,
|
||||||
|
}
|
||||||
|
this.overlayRef.nativeElement.setPointerCapture(event.pointerId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clickedIdx = this.zoneIndexAt(point)
|
||||||
|
if (clickedIdx !== null) {
|
||||||
|
this.selectZone(clickedIdx)
|
||||||
|
const zone = this.template.zones[clickedIdx]
|
||||||
|
this.overlayInteraction = {
|
||||||
|
kind: 'moving',
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
zoneIndex: clickedIdx,
|
||||||
|
start: {
|
||||||
|
mouseX: point.x,
|
||||||
|
mouseY: point.y,
|
||||||
|
zoneX: zone.x,
|
||||||
|
zoneY: zone.y,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
this.overlayRef.nativeElement.setPointerCapture(event.pointerId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty space: start drawing a new zone. A click without a drag just clears
|
||||||
|
// the selection (see onOverlayPointerUp).
|
||||||
|
this.clearZoneTest()
|
||||||
|
this.selectedZoneIndex = null
|
||||||
|
this.overlayInteraction = {
|
||||||
|
kind: 'drawing',
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
rect: {
|
||||||
|
startX: point.x,
|
||||||
|
startY: point.y,
|
||||||
|
endX: point.x,
|
||||||
|
endY: point.y,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
this.overlayRef.nativeElement.setPointerCapture(event.pointerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
onOverlayPointerMove(event: PointerEvent) {
|
||||||
|
if (
|
||||||
|
this.overlayInteraction.kind !== 'idle' &&
|
||||||
|
this.overlayInteraction.pointerId !== event.pointerId
|
||||||
|
)
|
||||||
|
return
|
||||||
|
const point = this.svgPointFromEvent(event)
|
||||||
|
if (!point) return
|
||||||
|
|
||||||
|
if (this.overlayInteraction.kind === 'resizing') {
|
||||||
|
resizeZone(
|
||||||
|
this.template.zones[this.overlayInteraction.zoneIndex],
|
||||||
|
this.overlayInteraction.handle,
|
||||||
|
point,
|
||||||
|
this.imageNaturalSize()
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.overlayInteraction.kind === 'moving') {
|
||||||
|
moveZone(
|
||||||
|
this.template.zones[this.overlayInteraction.zoneIndex],
|
||||||
|
point,
|
||||||
|
this.overlayInteraction.start,
|
||||||
|
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.handleAt(point, this.selectedZoneIndex)
|
||||||
|
if (handle) {
|
||||||
|
this.overlayCursor = RESIZE_CURSOR[handle]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.overlayCursor = this.zoneIndexAt(point) !== null ? 'move' : 'crosshair'
|
||||||
|
}
|
||||||
|
|
||||||
|
onOverlayPointerUp(event: PointerEvent) {
|
||||||
|
if (this.overlayInteraction.kind === 'idle') return
|
||||||
|
if (this.overlayInteraction.pointerId !== event.pointerId) return
|
||||||
|
this.onOverlayPointerMove(event)
|
||||||
|
const interaction = this.overlayInteraction
|
||||||
|
this.stopOverlayInteraction()
|
||||||
|
if (interaction.kind !== 'drawing') return
|
||||||
|
|
||||||
|
const rect = this.sourceRectFromDrawing(interaction.rect)
|
||||||
|
|
||||||
|
// Ignore tiny accidental clicks.
|
||||||
|
if (rect.w < MIN_ZONE_SIZE || rect.h < MIN_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: OcrZoneTarget.CustomField,
|
||||||
|
custom_field: null,
|
||||||
|
x: rect.x,
|
||||||
|
y: rect.y,
|
||||||
|
width: rect.w,
|
||||||
|
height: rect.h,
|
||||||
|
page: this.previewPageDisplay,
|
||||||
|
ocr_language: DEFAULT_OCR_ZONE_LANGUAGE,
|
||||||
|
transform: OcrZoneTransform.Strip,
|
||||||
|
date_format: '',
|
||||||
|
validation_regex: '',
|
||||||
|
order: this.template.zones.length,
|
||||||
|
zone_source_width: imageSize.width,
|
||||||
|
zone_source_height: imageSize.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onOverlayPointerCancel(event: PointerEvent) {
|
||||||
|
if (this.overlayInteraction.kind === 'idle') return
|
||||||
|
if (this.overlayInteraction.pointerId !== event.pointerId) return
|
||||||
|
this.stopOverlayInteraction()
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopOverlayInteraction() {
|
||||||
|
const interaction = this.overlayInteraction
|
||||||
|
this.overlayInteraction = NO_OVERLAY_INTERACTION
|
||||||
|
this.overlayCursor = 'crosshair'
|
||||||
|
const overlay = this.overlayRef?.nativeElement
|
||||||
|
if (
|
||||||
|
interaction.kind !== 'idle' &&
|
||||||
|
overlay?.hasPointerCapture(interaction.pointerId)
|
||||||
|
) {
|
||||||
|
overlay.releasePointerCapture(interaction.pointerId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawingRect(): DisplayRect | null {
|
||||||
|
return this.overlayInteraction.kind === 'drawing'
|
||||||
|
? this.displayRectFromDrawing(this.overlayInteraction.rect)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The zone's rectangle on the overlay, or null if it's on another page. */
|
||||||
|
zoneDisplayRect(zoneIndex: number): DisplayRect | null {
|
||||||
|
const zone = this.template.zones[zoneIndex]
|
||||||
|
if (!this.isOnCurrentPage(zone)) return null
|
||||||
|
return getZoneDisplayRect(zone, this.imageNaturalSize())
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleAt(point: Point, zoneIndex: number): ResizeHandle | null {
|
||||||
|
const rect = this.zoneDisplayRect(zoneIndex)
|
||||||
|
return rect ? findHandleAt(point, rect, this.overlayHandleSize()) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
private zoneIndexAt(point: Point): number | null {
|
||||||
|
return findZoneAt(
|
||||||
|
point,
|
||||||
|
this.template.zones,
|
||||||
|
this.previewPage,
|
||||||
|
this.previewPageCount(),
|
||||||
|
this.imageNaturalSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
overlayViewBox(): string {
|
||||||
|
const imageSize = this.imageNaturalSize()
|
||||||
|
return `0 0 ${imageSize.width} ${imageSize.height}`
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly zoneColor = getZoneColor
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly resizeHandles = getResizeHandles
|
||||||
|
|
||||||
|
overlayHandleSize(): number {
|
||||||
|
return this.overlayUnitSize(HANDLE_SIZE)
|
||||||
|
}
|
||||||
|
|
||||||
|
overlayFontSize(): number {
|
||||||
|
return this.overlayUnitSize(12)
|
||||||
|
}
|
||||||
|
|
||||||
|
overlayUnitSize(screenPixels: number): number {
|
||||||
|
return screenPixels * this.overlayScale()
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
const size = this.imageNaturalSize()
|
||||||
|
return {
|
||||||
|
x: Math.max(0, Math.min(svgPoint.x, size.width)),
|
||||||
|
y: Math.max(0, Math.min(svgPoint.y, size.height)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.stopOverlayInteraction()
|
||||||
|
this.clearZoneTest()
|
||||||
|
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 = 'zones'
|
||||||
|
this.clearZoneTest()
|
||||||
|
const zone = this.template.zones[index]
|
||||||
|
if (zone) {
|
||||||
|
this.goToPage(this.zonePage(zone) - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deselectZone() {
|
||||||
|
this.selectedZoneIndex = null
|
||||||
|
this.clearZoneTest()
|
||||||
|
}
|
||||||
|
|
||||||
|
testZone() {
|
||||||
|
const zone = this.selectedZone
|
||||||
|
if (!zone || !this.previewDocId) return
|
||||||
|
this.clearZoneTest()
|
||||||
|
const test: ZoneTest = { zone, key: this.zoneTestKey(zone), result: null }
|
||||||
|
this.zoneTest.set(test)
|
||||||
|
this.templateService
|
||||||
|
.testZone(this.previewDocId, this.zoneTestRequest(zone))
|
||||||
|
.pipe(takeUntil(this.destroy$), takeUntil(this.cancelZoneTest$))
|
||||||
|
.subscribe({
|
||||||
|
next: (res) => this.zoneTest.set({ ...test, result: res }),
|
||||||
|
error: (err) =>
|
||||||
|
this.zoneTest.set({
|
||||||
|
...test,
|
||||||
|
result: { error: err.error?.error || $localize`Test failed` },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearZoneTest() {
|
||||||
|
this.cancelZoneTest$.next()
|
||||||
|
this.zoneTest.set(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A result is stale once its zone, settings or sample document change. */
|
||||||
|
private isCurrentZoneTest(): boolean {
|
||||||
|
const test = this.zoneTest()
|
||||||
|
return (
|
||||||
|
test?.zone === this.selectedZone &&
|
||||||
|
test.key === this.zoneTestKey(test.zone)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private zoneTestKey(zone: OcrTemplateZone): string {
|
||||||
|
return JSON.stringify([this.previewDocId, this.zoneTestRequest(zone)])
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
save() {
|
||||||
|
if (this.saving()) return
|
||||||
|
this.stopOverlayInteraction()
|
||||||
|
this.saving.set(true)
|
||||||
|
this.pruneCombineFormats()
|
||||||
|
this.template.sample_document = this.previewDocId
|
||||||
|
const payload = this.permissionsService.currentUserOwnsObject(this.object)
|
||||||
|
? { ...this.template, ...this.permissionsForm }
|
||||||
|
: this.template
|
||||||
|
const obs = this.template.id
|
||||||
|
? this.templateService.update(payload)
|
||||||
|
: this.templateService.create(payload)
|
||||||
|
|
||||||
|
obs.pipe(takeUntil(this.destroy$)).subscribe({
|
||||||
|
next: (saved) => {
|
||||||
|
this.activeModal.close()
|
||||||
|
this.succeeded.emit(saved)
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.saving.set(false)
|
||||||
|
this.failed.emit(e)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ng-select needs a stable array between change detection runs, so the
|
||||||
|
// split language list is cached per zone until ocr_language changes.
|
||||||
|
private readonly ocrLanguageCache = new WeakMap<
|
||||||
|
OcrTemplateZone,
|
||||||
|
{ language: string; languages: string[] }
|
||||||
|
>()
|
||||||
|
|
||||||
|
ocrLanguageArray(zone: OcrTemplateZone): string[] {
|
||||||
|
const language = zone.ocr_language || ''
|
||||||
|
const cached = this.ocrLanguageCache.get(zone)
|
||||||
|
if (cached?.language === language) return cached.languages
|
||||||
|
const languages = language.split('+').filter(Boolean)
|
||||||
|
this.ocrLanguageCache.set(zone, { language, languages })
|
||||||
|
return languages
|
||||||
|
}
|
||||||
|
|
||||||
|
setOcrLanguages(zone: OcrTemplateZone, langs: string[]) {
|
||||||
|
zone.ocr_language = (langs ?? []).join('+')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The field select's value: a built-in target, or a custom field id. */
|
||||||
|
zoneFieldValue(zone: OcrTemplateZone): ZoneFieldSelection {
|
||||||
|
return zone.target === OcrZoneTarget.CustomField
|
||||||
|
? zone.custom_field
|
||||||
|
: zone.target
|
||||||
|
}
|
||||||
|
|
||||||
|
setZoneField(zone: OcrTemplateZone, value: ZoneFieldSelection) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
zone.target = value
|
||||||
|
zone.custom_field = null
|
||||||
|
} else {
|
||||||
|
zone.target = OcrZoneTarget.CustomField
|
||||||
|
zone.custom_field = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
zonesForField(zone: OcrTemplateZone): OcrTemplateZone[] {
|
||||||
|
const key = getZoneFieldKey(zone)
|
||||||
|
if (!key) return []
|
||||||
|
return this.template.zones.filter((z) => getZoneFieldKey(z) === key)
|
||||||
|
}
|
||||||
|
|
||||||
|
isFieldShared(zone: OcrTemplateZone): boolean {
|
||||||
|
return this.zonesForField(zone).length > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
renameZone(zone: OcrTemplateZone, name: string) {
|
||||||
|
const oldName = zone.name.trim()
|
||||||
|
const format = this.getCombineFormat(zone)
|
||||||
|
if (format) {
|
||||||
|
this.setCombineFormat(
|
||||||
|
zone,
|
||||||
|
format.replace(/\{([^{}]*)\}/g, (token, tokenName: string) =>
|
||||||
|
tokenName.trim() === oldName ? `{${name}}` : token
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
zone.name = name
|
||||||
|
}
|
||||||
|
|
||||||
|
getCombineFormat(zone: OcrTemplateZone): string {
|
||||||
|
return this.template.combine_formats?.[getZoneFieldKey(zone)] ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
setCombineFormat(zone: OcrTemplateZone, value: string) {
|
||||||
|
const key = getZoneFieldKey(zone)
|
||||||
|
if (!key) return
|
||||||
|
this.template.combine_formats ??= {}
|
||||||
|
this.template.combine_formats[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultCombineFormat(zone: OcrTemplateZone): string {
|
||||||
|
return this.zonesForField(zone)
|
||||||
|
.map((z) => `{${z.name}}`)
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
insertCombineToken(zone: OcrTemplateZone, tokenZone: OcrTemplateZone) {
|
||||||
|
const current = this.getCombineFormat(zone)
|
||||||
|
const sep = current && !current.endsWith(' ') ? ' ' : ''
|
||||||
|
this.setCombineFormat(zone, `${current}${sep}{${tokenZone.name}}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop formats that are empty or whose field no longer has several zones. */
|
||||||
|
private pruneCombineFormats() {
|
||||||
|
const formats = this.template.combine_formats
|
||||||
|
if (!formats) return
|
||||||
|
const keys = this.template.zones.map(getZoneFieldKey)
|
||||||
|
for (const [key, format] of Object.entries(formats)) {
|
||||||
|
if (!format || keys.filter((k) => k === key).length < 2) {
|
||||||
|
delete formats[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createField(name?: string) {
|
||||||
|
const zone = this.selectedZone
|
||||||
|
const modal = this.modalService.open(CustomFieldEditDialogComponent)
|
||||||
|
modal.componentInstance.dialogMode.set(EditDialogMode.CREATE)
|
||||||
|
modal.componentInstance.object = { name: name || zone?.name }
|
||||||
|
modal.componentInstance.succeeded
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe((field: CustomField) => {
|
||||||
|
this.toastService.showSuccess($localize`Saved field "${field.name}".`)
|
||||||
|
this.customFieldsService.clearCache()
|
||||||
|
this.customFieldsService
|
||||||
|
.listAll()
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe((r) => {
|
||||||
|
if (zone && OCR_SUPPORTED_FIELD_TYPES.includes(field.data_type)) {
|
||||||
|
this.setZoneField(zone, field.id)
|
||||||
|
}
|
||||||
|
this.customFields.set(r.results)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
modal.componentInstance.failed
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe((e) => {
|
||||||
|
this.toastService.showError($localize`Error saving field.`, e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.previewResizeObserver?.disconnect()
|
||||||
|
this.cancelZoneTest$.complete()
|
||||||
|
this.destroy$.next()
|
||||||
|
this.destroy$.complete()
|
||||||
|
}
|
||||||
|
}
|
||||||
+197
@@ -0,0 +1,197 @@
|
|||||||
|
import {
|
||||||
|
OcrTemplateZone,
|
||||||
|
OcrZoneTarget,
|
||||||
|
OcrZoneTransform,
|
||||||
|
} from 'src/app/data/ocr-template'
|
||||||
|
import {
|
||||||
|
findHandleAt,
|
||||||
|
findZoneAt,
|
||||||
|
getResizeHandles,
|
||||||
|
getZoneDisplayRect,
|
||||||
|
getZonePage,
|
||||||
|
isZoneOnPage,
|
||||||
|
moveZone,
|
||||||
|
resizeZone,
|
||||||
|
} from './zone-geometry'
|
||||||
|
|
||||||
|
function zone(overrides: Partial<OcrTemplateZone> = {}): OcrTemplateZone {
|
||||||
|
return {
|
||||||
|
name: 'Zone',
|
||||||
|
target: OcrZoneTarget.CustomField,
|
||||||
|
custom_field: 1,
|
||||||
|
x: 100,
|
||||||
|
y: 200,
|
||||||
|
width: 300,
|
||||||
|
height: 400,
|
||||||
|
page: 1,
|
||||||
|
ocr_language: 'eng',
|
||||||
|
transform: OcrZoneTransform.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('uses natural image coordinates when source dimensions are unset', () => {
|
||||||
|
expect(
|
||||||
|
getZoneDisplayRect(zone({ x: 100, y: 200, width: 300, height: 400 }), {
|
||||||
|
width: 1000,
|
||||||
|
height: 2000,
|
||||||
|
})
|
||||||
|
).toEqual({ x: 100, y: 200, w: 300, h: 400 })
|
||||||
|
})
|
||||||
|
|
||||||
|
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: 750 }
|
||||||
|
)
|
||||||
|
).toEqual({ x: 50, y: 75, w: 50, h: 75 })
|
||||||
|
})
|
||||||
|
|
||||||
|
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 })
|
||||||
|
).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()
|
||||||
|
expect(findHandleAt({ x: 19, y: 20 }, rect, 8)).toBeNull()
|
||||||
|
expect(findHandleAt({ x: 19, y: 20 }, rect, 10)).toBe('nw')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns no zone when the point is outside every rectangle', () => {
|
||||||
|
expect(
|
||||||
|
findZoneAt({ x: 900, y: 900 }, [zone()], 0, 1, {
|
||||||
|
width: 1000,
|
||||||
|
height: 1000,
|
||||||
|
})
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resizes north-west edges while keeping the opposite corner fixed', () => {
|
||||||
|
const z = zone({ x: 100, y: 200, width: 300, height: 400 })
|
||||||
|
resizeZone(z, 'nw', { x: 50, y: 100 }, { width: 1000, height: 1000 })
|
||||||
|
expect(z).toMatchObject({ x: 50, y: 100, width: 350, height: 500 })
|
||||||
|
resizeZone(z, 'nw', { x: 900, y: 900 }, { width: 1000, height: 1000 })
|
||||||
|
expect(z).toMatchObject({ x: 390, y: 590, width: 10, height: 10 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamps resized north-west edges to the source image bounds', () => {
|
||||||
|
const z = zone({
|
||||||
|
x: 50,
|
||||||
|
y: 50,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
zone_source_width: 200,
|
||||||
|
zone_source_height: 300,
|
||||||
|
})
|
||||||
|
resizeZone(z, 'nw', { x: -50, y: -50 }, { width: 400, height: 600 })
|
||||||
|
expect(z).toMatchObject({ x: 0, y: 0, width: 150, height: 150 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the rendered handle positions for hit testing', () => {
|
||||||
|
const rect = { x: 10, y: 20, w: 100, h: 200 }
|
||||||
|
const handles = getResizeHandles(rect)
|
||||||
|
|
||||||
|
expect(handles).toEqual([
|
||||||
|
{ handle: 'nw', x: 10, y: 20 },
|
||||||
|
{ handle: 'n', x: 60, y: 20 },
|
||||||
|
{ handle: 'ne', x: 110, y: 20 },
|
||||||
|
{ handle: 'w', x: 10, y: 120 },
|
||||||
|
{ handle: 'e', x: 110, y: 120 },
|
||||||
|
{ handle: 'sw', x: 10, y: 220 },
|
||||||
|
{ handle: 's', x: 60, y: 220 },
|
||||||
|
{ handle: 'se', x: 110, y: 220 },
|
||||||
|
])
|
||||||
|
for (const marker of handles) {
|
||||||
|
expect(findHandleAt(marker, rect)).toBe(marker.handle)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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 }
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
zone_source_width: 200,
|
||||||
|
zone_source_height: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
resizeZone(z, 'se', { x: 500, y: 500 }, { width: 500, height: 500 })
|
||||||
|
|
||||||
|
expect(z.width).toBe(150)
|
||||||
|
expect(z.height).toBe(150)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves zones in their original source coordinates on a different-size preview', () => {
|
||||||
|
const z = zone({
|
||||||
|
x: 50,
|
||||||
|
y: 50,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
zone_source_width: 1000,
|
||||||
|
zone_source_height: 2000,
|
||||||
|
})
|
||||||
|
|
||||||
|
moveZone(
|
||||||
|
z,
|
||||||
|
{ x: 75, y: 75 },
|
||||||
|
{ mouseX: 25, mouseY: 25, zoneX: 50, zoneY: 50 },
|
||||||
|
{ width: 500, height: 500 }
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(z.x).toBe(150)
|
||||||
|
expect(z.y).toBe(250)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
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 interface ResizeHandleMarker extends Point {
|
||||||
|
handle: ResizeHandle
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HANDLE_SIZE = 8
|
||||||
|
export const MIN_ZONE_SIZE = 10
|
||||||
|
|
||||||
|
const ZONE_COLORS = [
|
||||||
|
'#4f8ff7',
|
||||||
|
'#ff6b6b',
|
||||||
|
'#51cf66',
|
||||||
|
'#ffd43b',
|
||||||
|
'#cc5de8',
|
||||||
|
'#ff922b',
|
||||||
|
'#20c997',
|
||||||
|
'#e599f7',
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Outline color for a zone, shared by the preview overlay and the zone list. */
|
||||||
|
export function getZoneColor(index: number): string {
|
||||||
|
return ZONE_COLORS[index % ZONE_COLORS.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getZonePage(
|
||||||
|
zone: OcrTemplateZone,
|
||||||
|
previewPage: number,
|
||||||
|
previewPageCount: number | null
|
||||||
|
): number {
|
||||||
|
const page = zone.page ?? 1
|
||||||
|
if (page === -1) return previewPageCount ?? previewPage + 1
|
||||||
|
return Math.max(page, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isZoneOnPage(
|
||||||
|
zone: OcrTemplateZone,
|
||||||
|
previewPage: number,
|
||||||
|
previewPageCount: number | null
|
||||||
|
): boolean {
|
||||||
|
return getZonePage(zone, previewPage, previewPageCount) === previewPage + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
imageSize: Dimensions
|
||||||
|
): DisplayRect {
|
||||||
|
const sourceSize = getZoneSourceSize(zone, imageSize)
|
||||||
|
// The SVG viewBox uses the preview image's natural dimensions.
|
||||||
|
const scaleX = imageSize.width / sourceSize.width
|
||||||
|
const scaleY = imageSize.height / sourceSize.height
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: zone.x * scaleX,
|
||||||
|
y: zone.y * scaleY,
|
||||||
|
w: zone.width * scaleX,
|
||||||
|
h: zone.height * scaleY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getResizeHandles(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 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findHandleAt(
|
||||||
|
point: Point,
|
||||||
|
rect: DisplayRect,
|
||||||
|
handleSize = HANDLE_SIZE
|
||||||
|
): ResizeHandle | null {
|
||||||
|
return (
|
||||||
|
getResizeHandles(rect).find(
|
||||||
|
({ x, y }) =>
|
||||||
|
Math.abs(point.x - x) <= handleSize &&
|
||||||
|
Math.abs(point.y - y) <= handleSize
|
||||||
|
)?.handle ?? null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findZoneAt(
|
||||||
|
point: Point,
|
||||||
|
zones: OcrTemplateZone[],
|
||||||
|
previewPage: number,
|
||||||
|
previewPageCount: number | null,
|
||||||
|
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, 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,
|
||||||
|
imageSize: Dimensions
|
||||||
|
) {
|
||||||
|
const sourceSize = getZoneSourceSize(zone, imageSize)
|
||||||
|
const scaleX = sourceSize.width / imageSize.width
|
||||||
|
const scaleY = sourceSize.height / imageSize.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,
|
||||||
|
imageSize: Dimensions
|
||||||
|
) {
|
||||||
|
const sourceSize = getZoneSourceSize(zone, imageSize)
|
||||||
|
const scaleX = sourceSize.width / imageSize.width
|
||||||
|
const scaleY = sourceSize.height / imageSize.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.max(min, Math.min(value, max))
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<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)="editTemplate()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.OcrTemplate }">
|
||||||
|
<pngx-bs-icon name="plus-circle" class="me-1"></pngx-bs-icon><ng-container i18n>Add 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>Matching</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">
|
||||||
|
<output class="spinner-border spinner-border-sm me-2"></output>
|
||||||
|
<ng-container i18n>Loading...</ng-container>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
|
||||||
|
@for (template of templates(); track template.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(template)" [disabled]="!userCanEdit(template)">{{template.name}}</button></div>
|
||||||
|
<div class="col d-flex align-items-center d-none d-sm-flex">{{getMatching(template)}}</div>
|
||||||
|
<div class="col d-flex align-items-center d-none d-sm-flex"><code>{{template.zones.length}}</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]="template.id+'_enable'" [(ngModel)]="template.enabled" (change)="toggleTemplateEnabled(template)" [disabled]="!userCanEdit(template)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.OcrTemplate }">
|
||||||
|
<label class="form-check-label cursor-pointer" [for]="template.id+'_enable'">
|
||||||
|
<code> @if(template.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{{template.id}}" (click)="$event.stopPropagation()" ngbDropdownToggle>
|
||||||
|
<pngx-bs-icon name="three-dots-vertical"></pngx-bs-icon>
|
||||||
|
</button>
|
||||||
|
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile{{template.id}}">
|
||||||
|
<button (click)="editTemplate(template)" [disabled]="!userCanEdit(template)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.OcrTemplate }" ngbDropdownItem i18n>Edit</button>
|
||||||
|
<button (click)="deleteTemplate(template)" [disabled]="!permissionsService.currentUserOwnsObject(template)" *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 }" [disabled]="!userCanEdit(template)" class="btn btn-sm btn-outline-secondary" type="button" (click)="editTemplate(template)">
|
||||||
|
<pngx-bs-icon width="1em" height="1em" name="pencil" class="me-1"></pngx-bs-icon><ng-container i18n>Edit</ng-container>
|
||||||
|
</button>
|
||||||
|
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.OcrTemplate }" [disabled]="!permissionsService.currentUserOwnsObject(template)" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteTemplate(template)">
|
||||||
|
<pngx-bs-icon width="1em" height="1em" name="trash" class="me-1"></pngx-bs-icon><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>
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
import { By } from '@angular/platform-browser'
|
||||||
|
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { of, throwError } from 'rxjs'
|
||||||
|
import { MATCH_LITERAL, MATCH_NONE } from 'src/app/data/matching-model'
|
||||||
|
import { OcrTemplate } from 'src/app/data/ocr-template'
|
||||||
|
import { User } from 'src/app/data/user'
|
||||||
|
import { DirtyFormGuard } from 'src/app/guards/dirty-form.guard'
|
||||||
|
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||||
|
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
|
||||||
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||||
|
import { OcrTemplateEditorComponent } from './ocr-template-editor/ocr-template-editor.component'
|
||||||
|
import { OcrTemplatesComponent } from './ocr-templates.component'
|
||||||
|
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
||||||
|
|
||||||
|
const templates: OcrTemplate[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Invoices',
|
||||||
|
match: 'Northwind',
|
||||||
|
matching_algorithm: MATCH_LITERAL,
|
||||||
|
sample_document: null,
|
||||||
|
source_width: 0,
|
||||||
|
source_height: 0,
|
||||||
|
enabled: true,
|
||||||
|
zones: [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('OcrTemplatesComponent', () => {
|
||||||
|
let component: OcrTemplatesComponent
|
||||||
|
let fixture: ComponentFixture<OcrTemplatesComponent>
|
||||||
|
let ocrTemplateService: OcrTemplateService
|
||||||
|
let modalService: NgbModal
|
||||||
|
let toastService: ToastService
|
||||||
|
let modal: NgbModalRef
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [OcrTemplatesComponent, BootstrapIconComponent],
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
TestBed.inject(PermissionsService).initialize(
|
||||||
|
[
|
||||||
|
'view_ocrtemplate',
|
||||||
|
'add_ocrtemplate',
|
||||||
|
'change_ocrtemplate',
|
||||||
|
'delete_ocrtemplate',
|
||||||
|
],
|
||||||
|
{ id: 1, groups: [], is_superuser: false } as User
|
||||||
|
)
|
||||||
|
jest
|
||||||
|
.spyOn(TestBed.inject(UserService), 'listAll')
|
||||||
|
.mockReturnValue(of({ count: 0, all: [], results: [] }))
|
||||||
|
|
||||||
|
ocrTemplateService = TestBed.inject(OcrTemplateService)
|
||||||
|
jest
|
||||||
|
.spyOn(ocrTemplateService, 'listAll')
|
||||||
|
.mockReturnValue(of({ count: 1, all: [1], results: templates }))
|
||||||
|
modalService = TestBed.inject(NgbModal)
|
||||||
|
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||||
|
toastService = TestBed.inject(ToastService)
|
||||||
|
fixture = TestBed.createComponent(OcrTemplatesComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
fixture.detectChanges()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => modalService.dismissAll())
|
||||||
|
|
||||||
|
it('should show templates with their matching', () => {
|
||||||
|
const text = fixture.nativeElement.textContent
|
||||||
|
expect(text).toContain('Invoices')
|
||||||
|
expect(text).toContain('Exact match: Northwind')
|
||||||
|
|
||||||
|
expect(
|
||||||
|
component.getMatching({ ...templates[0], matching_algorithm: MATCH_NONE })
|
||||||
|
).toEqual('None')
|
||||||
|
expect(component.getMatching({ ...templates[0], match: '' })).toEqual('-')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
name: 'owned',
|
||||||
|
owner: 1,
|
||||||
|
user_can_change: true,
|
||||||
|
editable: true,
|
||||||
|
deletable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'unowned',
|
||||||
|
owner: null,
|
||||||
|
user_can_change: true,
|
||||||
|
editable: true,
|
||||||
|
deletable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'view-only shared',
|
||||||
|
owner: 2,
|
||||||
|
user_can_change: false,
|
||||||
|
editable: false,
|
||||||
|
deletable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'editable shared',
|
||||||
|
owner: 2,
|
||||||
|
user_can_change: true,
|
||||||
|
editable: true,
|
||||||
|
deletable: false,
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
'should respect permissions for $name templates',
|
||||||
|
async ({ owner, user_can_change, editable, deletable }) => {
|
||||||
|
const template = { ...templates[0], owner, user_can_change }
|
||||||
|
component.templates.set([template])
|
||||||
|
fixture.detectChanges()
|
||||||
|
await fixture.whenStable()
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.querySelector('.text-start').disabled).toBe(
|
||||||
|
!editable
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('.form-switch input').disabled
|
||||||
|
).toBe(!editable)
|
||||||
|
const buttons = Array.from<HTMLButtonElement>(
|
||||||
|
fixture.nativeElement.querySelectorAll('button')
|
||||||
|
)
|
||||||
|
for (const button of buttons.filter(
|
||||||
|
(button) => button.textContent.trim() === 'Edit'
|
||||||
|
)) {
|
||||||
|
expect(button.disabled).toBe(!editable)
|
||||||
|
}
|
||||||
|
for (const button of buttons.filter(
|
||||||
|
(button) => button.textContent.trim() === 'Delete'
|
||||||
|
)) {
|
||||||
|
expect(button.disabled).toBe(!deletable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
it('should allow a superuser to manage another users template', () => {
|
||||||
|
TestBed.inject(PermissionsService).initialize([], {
|
||||||
|
id: 1,
|
||||||
|
is_superuser: true,
|
||||||
|
} as User)
|
||||||
|
component.templates.set([
|
||||||
|
{ ...templates[0], owner: 2, user_can_change: false },
|
||||||
|
])
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(fixture.nativeElement.querySelector('.text-start').disabled).toBe(
|
||||||
|
false
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('.btn-outline-danger').disabled
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not open mutation dialogs or patch a view-only template', () => {
|
||||||
|
const template = { ...templates[0], owner: 2, user_can_change: false }
|
||||||
|
const open = jest.spyOn(modalService, 'open')
|
||||||
|
const patch = jest.spyOn(ocrTemplateService, 'patch')
|
||||||
|
component.editTemplate(template)
|
||||||
|
component.toggleTemplateEnabled(template)
|
||||||
|
component.deleteTemplate(template)
|
||||||
|
expect(open).not.toHaveBeenCalled()
|
||||||
|
expect(patch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should require global edit permission even for an owned template', () => {
|
||||||
|
TestBed.inject(PermissionsService).initialize(['view_ocrtemplate'], {
|
||||||
|
id: 1,
|
||||||
|
} as User)
|
||||||
|
fixture.destroy()
|
||||||
|
fixture = TestBed.createComponent(OcrTemplatesComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(fixture.nativeElement.querySelector('.text-start').disabled).toBe(
|
||||||
|
true
|
||||||
|
)
|
||||||
|
expect(fixture.nativeElement.querySelector('.form-switch input')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support create, show notification on error / success', () => {
|
||||||
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
|
const reloadSpy = jest.spyOn(component, 'reload')
|
||||||
|
|
||||||
|
fixture.debugElement
|
||||||
|
.queryAll(By.css('pngx-page-header button'))
|
||||||
|
.find((button) => button.nativeElement.textContent.includes('Add'))
|
||||||
|
.triggerEventHandler('click')
|
||||||
|
|
||||||
|
const editor = modal.componentInstance as OcrTemplateEditorComponent
|
||||||
|
expect(editor).toBeInstanceOf(OcrTemplateEditorComponent)
|
||||||
|
expect(editor.object).toBeUndefined()
|
||||||
|
|
||||||
|
editor.failed.emit({ error: 'error creating template' })
|
||||||
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
|
expect(reloadSpy).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
editor.succeeded.emit(templates[0])
|
||||||
|
expect(toastSuccessSpy).toHaveBeenCalled()
|
||||||
|
expect(reloadSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should pass the template to the editor', () => {
|
||||||
|
component.editTemplate(templates[0])
|
||||||
|
expect(modal.componentInstance.object).toBe(templates[0])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should confirm closing the editor with unsaved changes', async () => {
|
||||||
|
const confirmSpy = jest
|
||||||
|
.spyOn(TestBed.inject(DirtyFormGuard), 'confirmChanges')
|
||||||
|
.mockReturnValue(of(false))
|
||||||
|
component.editTemplate()
|
||||||
|
const editor = modal.componentInstance as OcrTemplateEditorComponent
|
||||||
|
editor.ngOnInit()
|
||||||
|
|
||||||
|
modal.dismiss()
|
||||||
|
await fixture.whenStable()
|
||||||
|
expect(confirmSpy).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
component.editTemplate()
|
||||||
|
const dirtyEditor = modal.componentInstance as OcrTemplateEditorComponent
|
||||||
|
dirtyEditor.ngOnInit()
|
||||||
|
dirtyEditor.template.name = 'Changed'
|
||||||
|
modal.dismiss()
|
||||||
|
await fixture.whenStable()
|
||||||
|
expect(confirmSpy).toHaveBeenCalled()
|
||||||
|
expect(modalService.hasOpenModals()).toBe(true)
|
||||||
|
|
||||||
|
dirtyEditor.saving.set(true)
|
||||||
|
confirmSpy.mockClear()
|
||||||
|
modal.dismiss()
|
||||||
|
await fixture.whenStable()
|
||||||
|
expect(confirmSpy).not.toHaveBeenCalled()
|
||||||
|
expect(modalService.hasOpenModals()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should only send the enabled flag when toggling', () => {
|
||||||
|
const patchSpy = jest
|
||||||
|
.spyOn(ocrTemplateService, 'patch')
|
||||||
|
.mockReturnValueOnce(throwError(() => new Error('error toggling')))
|
||||||
|
.mockReturnValueOnce(of(templates[0]))
|
||||||
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
|
const reloadSpy = jest.spyOn(component, 'reload')
|
||||||
|
|
||||||
|
const toggle = fixture.debugElement.query(By.css('.form-switch input'))
|
||||||
|
toggle.nativeElement.click()
|
||||||
|
expect(patchSpy).toHaveBeenCalledWith({ id: 1, enabled: false })
|
||||||
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
|
expect(reloadSpy).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
component.toggleTemplateEnabled(templates[0])
|
||||||
|
expect(toastSuccessSpy).toHaveBeenCalled()
|
||||||
|
expect(reloadSpy).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support delete, show notification on error / success', () => {
|
||||||
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
|
const deleteSpy = jest.spyOn(ocrTemplateService, 'delete')
|
||||||
|
const reloadSpy = jest.spyOn(component, 'reload')
|
||||||
|
|
||||||
|
component.deleteTemplate(templates[0])
|
||||||
|
const dialog = modal.componentInstance as ConfirmDialogComponent
|
||||||
|
|
||||||
|
deleteSpy.mockReturnValueOnce(throwError(() => new Error('error deleting')))
|
||||||
|
dialog.confirmClicked.emit()
|
||||||
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
|
expect(dialog.buttonsEnabled()).toBe(true)
|
||||||
|
expect(reloadSpy).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
deleteSpy.mockReturnValueOnce(of(true))
|
||||||
|
dialog.confirmClicked.emit()
|
||||||
|
expect(toastSuccessSpy).toHaveBeenCalled()
|
||||||
|
expect(reloadSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([true, false])(
|
||||||
|
'reports the enabled state %s after the update succeeds',
|
||||||
|
(enabled) => {
|
||||||
|
const template = { ...templates[0], enabled }
|
||||||
|
jest.spyOn(ocrTemplateService, 'patch').mockReturnValue(of(template))
|
||||||
|
const toast = jest.spyOn(toastService, 'showSuccess')
|
||||||
|
component.toggleTemplateEnabled(template)
|
||||||
|
expect(toast).toHaveBeenCalledWith(
|
||||||
|
`${enabled ? 'Enabled' : 'Disabled'} OCR template "${template.name}"`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { Component, OnInit, inject, signal } from '@angular/core'
|
||||||
|
import { FormsModule } from '@angular/forms'
|
||||||
|
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
||||||
|
import { firstValueFrom, takeUntil, tap } from 'rxjs'
|
||||||
|
import { MATCH_NONE, MATCHING_ALGORITHMS } from 'src/app/data/matching-model'
|
||||||
|
import { OcrTemplate } from 'src/app/data/ocr-template'
|
||||||
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
|
import { DirtyFormGuard } from 'src/app/guards/dirty-form.guard'
|
||||||
|
import {
|
||||||
|
PermissionAction,
|
||||||
|
PermissionType,
|
||||||
|
PermissionsService,
|
||||||
|
} from 'src/app/services/permissions.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'
|
||||||
|
import { OcrTemplateEditorComponent } from './ocr-template-editor/ocr-template-editor.component'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-ocr-templates',
|
||||||
|
templateUrl: './ocr-templates.component.html',
|
||||||
|
imports: [
|
||||||
|
PageHeaderComponent,
|
||||||
|
IfPermissionsDirective,
|
||||||
|
FormsModule,
|
||||||
|
NgbDropdownModule,
|
||||||
|
BootstrapIconComponent,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class OcrTemplatesComponent
|
||||||
|
extends LoadingComponentWithPermissions
|
||||||
|
implements OnInit
|
||||||
|
{
|
||||||
|
private readonly ocrTemplateService = inject(OcrTemplateService)
|
||||||
|
public readonly permissionsService = inject(PermissionsService)
|
||||||
|
private readonly modalService = inject(NgbModal)
|
||||||
|
private readonly dirtyFormGuard = inject(DirtyFormGuard)
|
||||||
|
private readonly toastService = inject(ToastService)
|
||||||
|
|
||||||
|
public readonly templates = signal<OcrTemplate[]>([])
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
reload() {
|
||||||
|
this.loading.set(true)
|
||||||
|
this.ocrTemplateService
|
||||||
|
.listAll()
|
||||||
|
.pipe(
|
||||||
|
takeUntil(this.unsubscribeNotifier),
|
||||||
|
tap((r) => this.templates.set(r.results))
|
||||||
|
)
|
||||||
|
.subscribe(() => {
|
||||||
|
this.show.set(true)
|
||||||
|
this.loading.set(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
getMatching(template: OcrTemplate): string {
|
||||||
|
if (template.matching_algorithm === MATCH_NONE) {
|
||||||
|
return $localize`None`
|
||||||
|
}
|
||||||
|
if (!template.match) {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
const algorithm = MATCHING_ALGORITHMS.find(
|
||||||
|
(a) => a.id === template.matching_algorithm
|
||||||
|
)
|
||||||
|
return `${algorithm?.shortName}: ${template.match}`
|
||||||
|
}
|
||||||
|
|
||||||
|
editTemplate(template?: OcrTemplate) {
|
||||||
|
if (template && !this.userCanEdit(template)) return
|
||||||
|
const modal = this.modalService.open(OcrTemplateEditorComponent, {
|
||||||
|
backdrop: 'static',
|
||||||
|
keyboard: false,
|
||||||
|
size: 'xxl',
|
||||||
|
fullscreen: 'lg',
|
||||||
|
scrollable: true,
|
||||||
|
ariaLabelledBy: 'ocr-template-editor-title',
|
||||||
|
beforeDismiss: () => {
|
||||||
|
const editor: OcrTemplateEditorComponent = modal.componentInstance
|
||||||
|
if (editor.saving()) return false
|
||||||
|
if (!editor.isDirty) return true
|
||||||
|
return firstValueFrom(
|
||||||
|
this.dirtyFormGuard.confirmChanges(undefined, $localize`Close`)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
modal.componentInstance.object = template
|
||||||
|
modal.componentInstance.succeeded
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe((saved: OcrTemplate) => {
|
||||||
|
this.toastService.showSuccess(
|
||||||
|
$localize`Saved OCR template "${saved.name}".`
|
||||||
|
)
|
||||||
|
this.ocrTemplateService.clearCache()
|
||||||
|
this.reload()
|
||||||
|
})
|
||||||
|
modal.componentInstance.failed
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe((e) => {
|
||||||
|
this.toastService.showError($localize`Error saving OCR template.`, e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleTemplateEnabled(template: OcrTemplate) {
|
||||||
|
if (!this.userCanEdit(template)) return
|
||||||
|
this.ocrTemplateService
|
||||||
|
.patch({ id: template.id, enabled: template.enabled } as OcrTemplate)
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.toastService.showSuccess(
|
||||||
|
template.enabled
|
||||||
|
? $localize`Enabled OCR template "${template.name}"`
|
||||||
|
: $localize`Disabled OCR template "${template.name}"`
|
||||||
|
)
|
||||||
|
this.reload()
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.toastService.showError(
|
||||||
|
$localize`Error toggling OCR template "${template.name}".`,
|
||||||
|
e
|
||||||
|
)
|
||||||
|
this.reload()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteTemplate(template: OcrTemplate) {
|
||||||
|
if (
|
||||||
|
!this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.Delete,
|
||||||
|
PermissionType.OcrTemplate
|
||||||
|
) ||
|
||||||
|
!this.permissionsService.currentUserOwnsObject(template)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
const modal = this.modalService.open(ConfirmDialogComponent, {
|
||||||
|
backdrop: 'static',
|
||||||
|
})
|
||||||
|
modal.componentInstance.title = $localize`Confirm delete OCR template`
|
||||||
|
modal.componentInstance.messageBold = $localize`This operation will permanently delete this OCR template.`
|
||||||
|
modal.componentInstance.message = $localize`This operation cannot be undone.`
|
||||||
|
modal.componentInstance.btnClass = 'btn-danger'
|
||||||
|
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||||
|
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||||
|
modal.componentInstance.buttonsEnabled.set(false)
|
||||||
|
this.ocrTemplateService.delete(template).subscribe({
|
||||||
|
next: () => {
|
||||||
|
modal.close()
|
||||||
|
this.toastService.showSuccess(
|
||||||
|
$localize`Deleted OCR template "${template.name}".`
|
||||||
|
)
|
||||||
|
this.ocrTemplateService.clearCache()
|
||||||
|
this.reload()
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
modal.componentInstance.buttonsEnabled.set(true)
|
||||||
|
this.toastService.showError(
|
||||||
|
$localize`Error deleting OCR template "${template.name}".`,
|
||||||
|
e
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
userCanEdit(template: OcrTemplate): boolean {
|
||||||
|
return (
|
||||||
|
this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.Change,
|
||||||
|
PermissionType.OcrTemplate
|
||||||
|
) &&
|
||||||
|
this.permissionsService.currentUserHasObjectPermissions(
|
||||||
|
PermissionAction.Change,
|
||||||
|
template
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { PaperlessTaskStatus } from './paperless-task'
|
||||||
|
|
||||||
|
export interface BulkOperationResponse {
|
||||||
|
result: string
|
||||||
|
task_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BulkOperationStatus {
|
||||||
|
task_id: string
|
||||||
|
operation: string
|
||||||
|
status: PaperlessTaskStatus
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
import { ObjectWithId } from './object-with-id'
|
import { ObjectWithId } from './object-with-id'
|
||||||
import { User } from './user'
|
import { User } from './user'
|
||||||
|
|
||||||
|
export enum NoteFormat {
|
||||||
|
Plain = 'plain',
|
||||||
|
Markdown = 'markdown',
|
||||||
|
}
|
||||||
|
|
||||||
export interface DocumentNote extends ObjectWithId {
|
export interface DocumentNote extends ObjectWithId {
|
||||||
created?: Date
|
created?: Date
|
||||||
note?: string
|
note?: string
|
||||||
user?: User
|
user?: User
|
||||||
|
format?: NoteFormat
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { CustomFieldDataType } from './custom-field'
|
||||||
|
import { MatchingModel } from './matching-model'
|
||||||
|
import { ObjectWithPermissions } from './object-with-permissions'
|
||||||
|
|
||||||
|
export enum OcrZoneTarget {
|
||||||
|
CustomField = 'custom_field',
|
||||||
|
Title = 'title',
|
||||||
|
Asn = 'asn',
|
||||||
|
Created = 'created',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum OcrZoneTransform {
|
||||||
|
None = 'none',
|
||||||
|
Strip = 'strip',
|
||||||
|
Uppercase = 'uppercase',
|
||||||
|
Lowercase = 'lowercase',
|
||||||
|
Numeric = 'numeric',
|
||||||
|
StripPunctuation = 'strip_punctuation',
|
||||||
|
Date = 'date',
|
||||||
|
QrCode = 'qr_code',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_OCR_ZONE_LANGUAGE = 'eng'
|
||||||
|
|
||||||
|
/** The field a zone writes to, keyed by OcrTemplate.combine_formats. */
|
||||||
|
export function getZoneFieldKey(zone: OcrTemplateZone): string | null {
|
||||||
|
if (zone.target !== OcrZoneTarget.CustomField) return zone.target
|
||||||
|
return zone.custom_field === null ? null : String(zone.custom_field)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Custom field types zone OCR can write to. See models.py OCR_SUPPORTED_FIELD_TYPES. */
|
||||||
|
export const OCR_SUPPORTED_FIELD_TYPES: CustomFieldDataType[] = [
|
||||||
|
CustomFieldDataType.String,
|
||||||
|
CustomFieldDataType.Url,
|
||||||
|
CustomFieldDataType.Date,
|
||||||
|
CustomFieldDataType.Integer,
|
||||||
|
CustomFieldDataType.Float,
|
||||||
|
CustomFieldDataType.Monetary,
|
||||||
|
CustomFieldDataType.LongText,
|
||||||
|
CustomFieldDataType.Boolean,
|
||||||
|
]
|
||||||
|
|
||||||
|
export const OCR_BUILTIN_TARGETS = [
|
||||||
|
{ id: OcrZoneTarget.Title, name: $localize`Title` },
|
||||||
|
{ id: OcrZoneTarget.Asn, name: $localize`Archive serial number` },
|
||||||
|
{ id: OcrZoneTarget.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 interface OcrTemplate extends MatchingModel, ObjectWithPermissions {
|
||||||
|
name: string
|
||||||
|
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 | boolean | null
|
||||||
|
reason?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OcrZoneRunResponse {
|
||||||
|
status: 'completed' | 'skipped'
|
||||||
|
reason?: 'no_template'
|
||||||
|
results: OcrZoneRunResult[]
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ export enum HideableSidebarItemID {
|
|||||||
SavedViews = 'saved_views',
|
SavedViews = 'saved_views',
|
||||||
ShareLinks = 'share_links',
|
ShareLinks = 'share_links',
|
||||||
Workflows = 'workflows',
|
Workflows = 'workflows',
|
||||||
|
OcrTemplates = 'ocr_templates',
|
||||||
Mail = 'mail',
|
Mail = 'mail',
|
||||||
Documentation = 'documentation',
|
Documentation = 'documentation',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export enum WorkflowTriggerType {
|
|||||||
DocumentAdded = 2,
|
DocumentAdded = 2,
|
||||||
DocumentUpdated = 3,
|
DocumentUpdated = 3,
|
||||||
Scheduled = 4,
|
Scheduled = 4,
|
||||||
|
Manual = 5,
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ScheduleDateField {
|
export enum ScheduleDateField {
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
|||||||
import { DirtyCheckGuard } from '@ngneat/dirty-check-forms'
|
import { DirtyCheckGuard } from '@ngneat/dirty-check-forms'
|
||||||
import { Observable, Subject } from 'rxjs'
|
import { Observable, Subject } from 'rxjs'
|
||||||
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
|
||||||
|
import { ActivatedRouteSnapshot } from '@angular/router'
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class DirtyFormGuard extends DirtyCheckGuard {
|
export class DirtyFormGuard extends DirtyCheckGuard {
|
||||||
private modalService = inject(NgbModal)
|
private modalService = inject(NgbModal)
|
||||||
|
|
||||||
confirmChanges(): Observable<boolean> {
|
confirmChanges(
|
||||||
|
_currentRoute?: ActivatedRouteSnapshot,
|
||||||
|
leaveLabel: string = $localize`Leave page`
|
||||||
|
): Observable<boolean> {
|
||||||
let modal = this.modalService.open(ConfirmDialogComponent, {
|
let modal = this.modalService.open(ConfirmDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
})
|
})
|
||||||
@@ -16,7 +20,7 @@ export class DirtyFormGuard extends DirtyCheckGuard {
|
|||||||
modal.componentInstance.messageBold = $localize`You have unsaved changes.`
|
modal.componentInstance.messageBold = $localize`You have unsaved changes.`
|
||||||
modal.componentInstance.message = $localize`Are you sure you want to leave?`
|
modal.componentInstance.message = $localize`Are you sure you want to leave?`
|
||||||
modal.componentInstance.btnClass = 'btn-warning'
|
modal.componentInstance.btnClass = 'btn-warning'
|
||||||
modal.componentInstance.btnCaption = $localize`Leave page`
|
modal.componentInstance.btnCaption = leaveLabel
|
||||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled.set(false)
|
modal.componentInstance.buttonsEnabled.set(false)
|
||||||
modal.close()
|
modal.close()
|
||||||
|
|||||||
@@ -124,6 +124,42 @@ describe('DocumentListViewService', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each([false, true])(
|
||||||
|
'preserves the current route on a background refresh (saved view: %s)',
|
||||||
|
(savedView) => {
|
||||||
|
if (savedView) documentListViewService.activateSavedView(view)
|
||||||
|
const navigate = jest.mocked(router.navigate)
|
||||||
|
navigate.mockClear()
|
||||||
|
documentListViewService.reload(null, false)
|
||||||
|
httpTestingController
|
||||||
|
.expectOne((req) => req.url === `${environment.apiBaseUrl}documents/`)
|
||||||
|
.flush(full_results)
|
||||||
|
expect(documentListViewService.documents).toEqual(full_results.results)
|
||||||
|
expect(navigate).not.toHaveBeenCalled()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
it('preserves the route when a background refresh falls back to the first page', () => {
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'saveDocumentListView')
|
||||||
|
.mockImplementation(() => {})
|
||||||
|
documentListViewService.currentPage = 2
|
||||||
|
httpTestingController
|
||||||
|
.expectOne((req) => req.url === `${environment.apiBaseUrl}documents/`)
|
||||||
|
.flush(full_results)
|
||||||
|
const navigate = jest.mocked(router.navigate)
|
||||||
|
navigate.mockClear()
|
||||||
|
documentListViewService.reload(null, false)
|
||||||
|
httpTestingController
|
||||||
|
.expectOne((req) => req.url === `${environment.apiBaseUrl}documents/`)
|
||||||
|
.flush('Not found', { status: 404, statusText: 'Not found' })
|
||||||
|
httpTestingController
|
||||||
|
.expectOne((req) => req.url === `${environment.apiBaseUrl}documents/`)
|
||||||
|
.flush(full_results)
|
||||||
|
expect(documentListViewService.currentPage).toBe(1)
|
||||||
|
expect(navigate).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('should reload the list', () => {
|
it('should reload the list', () => {
|
||||||
expect(documentListViewService.currentPage).toEqual(1)
|
expect(documentListViewService.currentPage).toEqual(1)
|
||||||
documentListViewService.reload()
|
documentListViewService.reload()
|
||||||
@@ -386,6 +422,46 @@ describe('DocumentListViewService', () => {
|
|||||||
documentListViewService.activateSavedView(null)
|
documentListViewService.activateSavedView(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should support activating saved view with pre-fetched documents', () => {
|
||||||
|
const routerSpy = jest.spyOn(router, 'navigate')
|
||||||
|
routerSpy.mockClear()
|
||||||
|
documentListViewService.activateSavedViewWithDocuments(
|
||||||
|
view,
|
||||||
|
documents.slice(0, 2),
|
||||||
|
documents.length,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
// no navigation, no reload
|
||||||
|
expect(routerSpy).not.toHaveBeenCalled()
|
||||||
|
httpTestingController.expectNone(
|
||||||
|
(req) => req.url === `${environment.apiBaseUrl}documents/`
|
||||||
|
)
|
||||||
|
expect(documentListViewService.activeSavedViewId).toEqual(view.id)
|
||||||
|
expect(documentListViewService.activeSavedViewTitle).toEqual(view.name)
|
||||||
|
expect(documentListViewService.documents).toEqual(documents.slice(0, 2))
|
||||||
|
expect(documentListViewService.collectionSize).toEqual(documents.length)
|
||||||
|
expect(documentListViewService.currentPage).toEqual(1)
|
||||||
|
expect(documentListViewService.pageSize).toEqual(2)
|
||||||
|
expect(documentListViewService.hasNext(documents[0].id)).toBeTruthy()
|
||||||
|
|
||||||
|
let nextDocId: number
|
||||||
|
documentListViewService
|
||||||
|
.getNext(documents[1].id)
|
||||||
|
.subscribe((docId) => (nextDocId = docId))
|
||||||
|
// crossing the page uses the view's sort + filters and the given page size
|
||||||
|
// (the currentPage setter reloads too, so the first request is cancelled)
|
||||||
|
const reqs = httpTestingController.match(
|
||||||
|
`${environment.apiBaseUrl}documents/?page=2&page_size=2&ordering=-added&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
|
||||||
|
)
|
||||||
|
expect(reqs.length).toBeGreaterThan(0)
|
||||||
|
reqs[reqs.length - 1].flush({
|
||||||
|
count: documents.length,
|
||||||
|
results: documents.slice(2, 4),
|
||||||
|
})
|
||||||
|
expect(nextDocId).toEqual(documents[2].id)
|
||||||
|
documentListViewService.activateSavedView(null)
|
||||||
|
})
|
||||||
|
|
||||||
it('should support navigating next / previous', () => {
|
it('should support navigating next / previous', () => {
|
||||||
documentListViewService.setFilterRules([])
|
documentListViewService.setFilterRules([])
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
|
|||||||
@@ -270,11 +270,28 @@ export class DocumentListViewService {
|
|||||||
this.currentPage = viewState.currentPage
|
this.currentPage = viewState.currentPage
|
||||||
}
|
}
|
||||||
|
|
||||||
loadSavedView(view: SavedView, closeCurrentView: boolean = false) {
|
/**
|
||||||
if (closeCurrentView) {
|
* Activate a saved view using documents that were already fetched, e.g. by a
|
||||||
this._activeSavedViewId = null
|
* dashboard widget. Does not navigate or reload.
|
||||||
}
|
*/
|
||||||
|
activateSavedViewWithDocuments(
|
||||||
|
view: SavedView,
|
||||||
|
documents: Document[],
|
||||||
|
collectionSize: number,
|
||||||
|
pageSize: number
|
||||||
|
) {
|
||||||
|
this.cancelPending()
|
||||||
|
this.rangeSelectionAnchorIndex = this.lastRangeSelectionToIndex = null
|
||||||
|
this._activeSavedViewId = view.id
|
||||||
|
this.applySavedViewConfig(view)
|
||||||
|
this.activeListViewState.pageSize = pageSize // match the page size the documents were fetched with
|
||||||
|
this.activeListViewState.currentPage = 1
|
||||||
|
this.activeListViewState.documents = documents
|
||||||
|
this.activeListViewState.collectionSize = collectionSize
|
||||||
|
this.markChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
private applySavedViewConfig(view: SavedView) {
|
||||||
this.activeListViewState.filterRules = cloneFilterRules(view.filter_rules)
|
this.activeListViewState.filterRules = cloneFilterRules(view.filter_rules)
|
||||||
this.activeListViewState.sortField = view.sort_field
|
this.activeListViewState.sortField = view.sort_field
|
||||||
this.activeListViewState.sortReverse = view.sort_reverse
|
this.activeListViewState.sortReverse = view.sort_reverse
|
||||||
@@ -284,6 +301,14 @@ export class DocumentListViewService {
|
|||||||
this.activeListViewState.displayMode = view.display_mode
|
this.activeListViewState.displayMode = view.display_mode
|
||||||
this.activeListViewState.pageSize = view.page_size
|
this.activeListViewState.pageSize = view.page_size
|
||||||
this.activeListViewState.displayFields = view.display_fields
|
this.activeListViewState.displayFields = view.display_fields
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSavedView(view: SavedView, closeCurrentView: boolean = false) {
|
||||||
|
if (closeCurrentView) {
|
||||||
|
this._activeSavedViewId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
this.applySavedViewConfig(view)
|
||||||
|
|
||||||
this.reduceSelectionToFilter()
|
this.reduceSelectionToFilter()
|
||||||
this.markChanged()
|
this.markChanged()
|
||||||
@@ -355,7 +380,7 @@ export class DocumentListViewService {
|
|||||||
queryParams: paramsFromViewState(activeListViewState),
|
queryParams: paramsFromViewState(activeListViewState),
|
||||||
replaceUrl: !this.router.routerState.snapshot.url.includes('?'), // in case navigating from params-less /documents
|
replaceUrl: !this.router.routerState.snapshot.url.includes('?'), // in case navigating from params-less /documents
|
||||||
})
|
})
|
||||||
} else if (this._activeSavedViewId) {
|
} else if (updateQueryParams && this._activeSavedViewId) {
|
||||||
this.router.navigate([], {
|
this.router.navigate([], {
|
||||||
queryParams: paramsFromViewState(activeListViewState, true),
|
queryParams: paramsFromViewState(activeListViewState, true),
|
||||||
queryParamsHandling: 'merge',
|
queryParamsHandling: 'merge',
|
||||||
@@ -374,7 +399,7 @@ export class DocumentListViewService {
|
|||||||
// this happens when applying a filter: the current page might not be available anymore due to the reduced result set.
|
// this happens when applying a filter: the current page might not be available anymore due to the reduced result set.
|
||||||
activeListViewState.currentPage = 1
|
activeListViewState.currentPage = 1
|
||||||
this.markChanged()
|
this.markChanged()
|
||||||
this.reload()
|
this.reload(onFinish, updateQueryParams)
|
||||||
} else if (
|
} else if (
|
||||||
activeListViewState.sortField.indexOf('custom_field') === 0 &&
|
activeListViewState.sortField.indexOf('custom_field') === 0 &&
|
||||||
!this.settings
|
!this.settings
|
||||||
|
|||||||
@@ -283,6 +283,10 @@ describe('PermissionsService', () => {
|
|||||||
'view_workflow',
|
'view_workflow',
|
||||||
'change_workflow',
|
'change_workflow',
|
||||||
'delete_workflow',
|
'delete_workflow',
|
||||||
|
'add_ocrtemplate',
|
||||||
|
'view_ocrtemplate',
|
||||||
|
'change_ocrtemplate',
|
||||||
|
'delete_ocrtemplate',
|
||||||
'add_customfield',
|
'add_customfield',
|
||||||
'view_customfield',
|
'view_customfield',
|
||||||
'change_customfield',
|
'change_customfield',
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export enum PermissionType {
|
|||||||
ShareLinkBundle = '%s_sharelinkbundle',
|
ShareLinkBundle = '%s_sharelinkbundle',
|
||||||
CustomField = '%s_customfield',
|
CustomField = '%s_customfield',
|
||||||
Workflow = '%s_workflow',
|
Workflow = '%s_workflow',
|
||||||
|
OcrTemplate = '%s_ocrtemplate',
|
||||||
ProcessedMail = '%s_processedmail',
|
ProcessedMail = '%s_processedmail',
|
||||||
GlobalStatistics = '%s_global_statistics',
|
GlobalStatistics = '%s_global_statistics',
|
||||||
SystemMonitoring = '%s_system_monitoring',
|
SystemMonitoring = '%s_system_monitoring',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { HttpTestingController } from '@angular/common/http/testing'
|
import { HttpTestingController } from '@angular/common/http/testing'
|
||||||
import { TestBed } from '@angular/core/testing'
|
import { TestBed } from '@angular/core/testing'
|
||||||
import { Subscription } from 'rxjs'
|
import { Subscription } from 'rxjs'
|
||||||
|
import { NoteFormat } from 'src/app/data/document-note'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
|
import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
|
||||||
import { DocumentNotesService } from './document-notes.service'
|
import { DocumentNotesService } from './document-notes.service'
|
||||||
@@ -50,6 +51,7 @@ describe(`Additional service tests for DocumentNotesService`, () => {
|
|||||||
expect(req.request.method).toEqual('POST')
|
expect(req.request.method).toEqual('POST')
|
||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
note: content,
|
note: content,
|
||||||
|
format: NoteFormat.Markdown,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { HttpParams } from '@angular/common/http'
|
import { HttpParams } from '@angular/common/http'
|
||||||
import { Injectable } from '@angular/core'
|
import { Injectable } from '@angular/core'
|
||||||
import { Observable } from 'rxjs'
|
import { Observable } from 'rxjs'
|
||||||
import { DocumentNote } from 'src/app/data/document-note'
|
import { DocumentNote, NoteFormat } from 'src/app/data/document-note'
|
||||||
import { AbstractPaperlessService } from './abstract-paperless-service'
|
import { AbstractPaperlessService } from './abstract-paperless-service'
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
@@ -22,6 +22,7 @@ export class DocumentNotesService extends AbstractPaperlessService<DocumentNote>
|
|||||||
addNote(id: number, note: string): Observable<DocumentNote[]> {
|
addNote(id: number, note: string): Observable<DocumentNote[]> {
|
||||||
return this.http.post<DocumentNote[]>(this.getResourceUrl(id, 'notes'), {
|
return this.http.post<DocumentNote[]>(this.getResourceUrl(id, 'notes'), {
|
||||||
note: note,
|
note: note,
|
||||||
|
format: NoteFormat.Markdown,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ describe(`DocumentService`, () => {
|
|||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('POST')
|
expect(req.request.method).toEqual('POST')
|
||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
|
from_webui: true,
|
||||||
documents: ids,
|
documents: ids,
|
||||||
method,
|
method,
|
||||||
parameters,
|
parameters,
|
||||||
@@ -271,6 +272,7 @@ describe(`DocumentService`, () => {
|
|||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('POST')
|
expect(req.request.method).toEqual('POST')
|
||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
|
from_webui: true,
|
||||||
all: true,
|
all: true,
|
||||||
filters: { title__icontains: 'apple' },
|
filters: { title__icontains: 'apple' },
|
||||||
excluded_documents: [2, 3],
|
excluded_documents: [2, 3],
|
||||||
@@ -563,6 +565,20 @@ describe(`DocumentService`, () => {
|
|||||||
|
|
||||||
req.flush('task-id')
|
req.flush('task-id')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should call appropriate api endpoint for running zone OCR', () => {
|
||||||
|
const url = `${environment.apiBaseUrl}${endpoint}/${documents[0].id}/run-zone-ocr/`
|
||||||
|
subscription = service.runZoneOcr(documents[0].id).subscribe()
|
||||||
|
let req = httpTestingController.expectOne(url)
|
||||||
|
expect(req.request.method).toEqual('POST')
|
||||||
|
expect(req.request.body).toEqual({})
|
||||||
|
req.flush({ status: 'completed', results: [] })
|
||||||
|
|
||||||
|
subscription = service.runZoneOcr(documents[0].id, 3, 10).subscribe()
|
||||||
|
req = httpTestingController.expectOne(`${url}?version=10`)
|
||||||
|
expect(req.request.body).toEqual({ template: 3 })
|
||||||
|
req.flush({ status: 'completed', results: [] })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should construct sort fields respecting permissions', () => {
|
it('should construct sort fields respecting permissions', () => {
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import {
|
|||||||
import { DocumentMetadata } from 'src/app/data/document-metadata'
|
import { DocumentMetadata } from 'src/app/data/document-metadata'
|
||||||
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
|
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
|
||||||
import { FilterRule } from 'src/app/data/filter-rule'
|
import { FilterRule } from 'src/app/data/filter-rule'
|
||||||
|
import { OcrZoneRunResponse } from 'src/app/data/ocr-template'
|
||||||
import { Results, SelectionData } from 'src/app/data/results'
|
import { Results, SelectionData } from 'src/app/data/results'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
|
import { BulkOperationResponse } from '../../data/bulk-operation'
|
||||||
import { queryParamsFromFilterRules } from '../../utils/query-params'
|
import { queryParamsFromFilterRules } from '../../utils/query-params'
|
||||||
import {
|
import {
|
||||||
PermissionAction,
|
PermissionAction,
|
||||||
@@ -38,6 +40,7 @@ export type DocumentBulkEditMethod =
|
|||||||
| 'modify_tags'
|
| 'modify_tags'
|
||||||
| 'modify_custom_fields'
|
| 'modify_custom_fields'
|
||||||
| 'set_permissions'
|
| 'set_permissions'
|
||||||
|
| 'run_workflow'
|
||||||
|
|
||||||
export interface MergeDocumentsRequest {
|
export interface MergeDocumentsRequest {
|
||||||
metadata_document_id?: number
|
metadata_document_id?: number
|
||||||
@@ -337,11 +340,15 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
|||||||
method: DocumentBulkEditMethod,
|
method: DocumentBulkEditMethod,
|
||||||
args: any
|
args: any
|
||||||
) {
|
) {
|
||||||
return this.http.post(this.getResourceUrl(null, 'bulk_edit'), {
|
return this.http.post<BulkOperationResponse>(
|
||||||
...selection,
|
this.getResourceUrl(null, 'bulk_edit'),
|
||||||
method: method,
|
{
|
||||||
parameters: args,
|
...selection,
|
||||||
})
|
method: method,
|
||||||
|
from_webui: true,
|
||||||
|
parameters: args,
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteDocuments(selection: DocumentSelectionQuery) {
|
deleteDocuments(selection: DocumentSelectionQuery) {
|
||||||
@@ -360,6 +367,28 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runZoneOcrDocuments(
|
||||||
|
selection: DocumentSelectionQuery,
|
||||||
|
templateId: number | null = null
|
||||||
|
) {
|
||||||
|
return this.http.post(this.getResourceUrl(null, 'run_zone_ocr'), {
|
||||||
|
...selection,
|
||||||
|
...(templateId ? { template: templateId } : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
runZoneOcr(
|
||||||
|
id: number,
|
||||||
|
templateId: number | null = null,
|
||||||
|
versionId: number | null = null
|
||||||
|
): Observable<OcrZoneRunResponse> {
|
||||||
|
return this.http.post<OcrZoneRunResponse>(
|
||||||
|
this.getResourceUrl(id, 'run-zone-ocr'),
|
||||||
|
templateId ? { template: templateId } : {},
|
||||||
|
{ params: versionId ? { version: versionId.toString() } : {} }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
rotateDocuments(
|
rotateDocuments(
|
||||||
selection: DocumentSelectionQuery,
|
selection: DocumentSelectionQuery,
|
||||||
degrees: number,
|
degrees: number,
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { provideHttpClient } from '@angular/common/http'
|
||||||
|
import {
|
||||||
|
HttpTestingController,
|
||||||
|
provideHttpClientTesting,
|
||||||
|
} from '@angular/common/http/testing'
|
||||||
|
import { TestBed } from '@angular/core/testing'
|
||||||
|
import { OcrZoneTransform, ZoneTestRequest } from 'src/app/data/ocr-template'
|
||||||
|
import { environment } from 'src/environments/environment'
|
||||||
|
import { OcrTemplateService } from './ocr-template.service'
|
||||||
|
|
||||||
|
describe('OcrTemplateService', () => {
|
||||||
|
let service: OcrTemplateService
|
||||||
|
let http: HttpTestingController
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||||
|
})
|
||||||
|
service = TestBed.inject(OcrTemplateService)
|
||||||
|
http = TestBed.inject(HttpTestingController)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => http.verify())
|
||||||
|
|
||||||
|
it('builds page image URLs with zero-based page numbers', () => {
|
||||||
|
expect(service.getPageImageUrl(12, 0)).toBe(
|
||||||
|
`${environment.apiBaseUrl}ocr_templates/document-page-image/12/0/`
|
||||||
|
)
|
||||||
|
expect(service.getPageImageUrl(12, 2)).toBe(
|
||||||
|
`${environment.apiBaseUrl}ocr_templates/document-page-image/12/2/`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('posts a zone test and returns the extraction result', () => {
|
||||||
|
const zone: ZoneTestRequest = {
|
||||||
|
name: 'Total',
|
||||||
|
x: 10,
|
||||||
|
y: 20,
|
||||||
|
width: 100,
|
||||||
|
height: 50,
|
||||||
|
page: -1,
|
||||||
|
ocr_language: 'eng',
|
||||||
|
transform: OcrZoneTransform.Numeric,
|
||||||
|
validation_regex: '\\d+',
|
||||||
|
zone_source_width: 1000,
|
||||||
|
zone_source_height: 1500,
|
||||||
|
}
|
||||||
|
const result = {
|
||||||
|
raw_text: 'Total: 123',
|
||||||
|
value: '123',
|
||||||
|
regex: '\\d+',
|
||||||
|
regex_match: true,
|
||||||
|
}
|
||||||
|
const next = jest.fn()
|
||||||
|
service.testZone(12, zone).subscribe(next)
|
||||||
|
const request = http.expectOne(
|
||||||
|
`${environment.apiBaseUrl}ocr_templates/test-zone/`
|
||||||
|
)
|
||||||
|
expect(request.request.method).toBe('POST')
|
||||||
|
expect(request.request.body).toEqual({ document: 12, zone })
|
||||||
|
request.flush(result)
|
||||||
|
expect(next).toHaveBeenCalledWith(result)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes zone-test failures to the caller', () => {
|
||||||
|
const error = jest.fn()
|
||||||
|
service.testZone(12, {} as ZoneTestRequest).subscribe({ error })
|
||||||
|
http
|
||||||
|
.expectOne(`${environment.apiBaseUrl}ocr_templates/test-zone/`)
|
||||||
|
.flush(
|
||||||
|
{ error: 'Invalid zone' },
|
||||||
|
{ status: 400, statusText: 'Bad Request' }
|
||||||
|
)
|
||||||
|
expect(error).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ status: 400, error: { error: 'Invalid zone' } })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Injectable } from '@angular/core'
|
||||||
|
import { Observable } from 'rxjs'
|
||||||
|
import {
|
||||||
|
OcrTemplate,
|
||||||
|
OcrZoneTestResult,
|
||||||
|
ZoneTestRequest,
|
||||||
|
} from '../../data/ocr-template'
|
||||||
|
import { AbstractPaperlessService } from './abstract-paperless-service'
|
||||||
|
|
||||||
|
@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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -438,6 +438,20 @@ describe('ConsumerStatusService', () => {
|
|||||||
expect(updated).toBeTruthy()
|
expect(updated).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('forwards bulk operation completion events', () => {
|
||||||
|
const received = jest.fn()
|
||||||
|
websocketStatusService.onBulkOperationStatus().subscribe(received)
|
||||||
|
const data = {
|
||||||
|
task_id: 'bulk-task',
|
||||||
|
operation: 'set_permissions',
|
||||||
|
status: 'success',
|
||||||
|
}
|
||||||
|
websocketStatusService.connect()
|
||||||
|
server.send({ type: WebsocketStatusType.BULK_OPERATION_STATUS, data })
|
||||||
|
websocketStatusService.disconnect()
|
||||||
|
expect(received).toHaveBeenCalledWith(data)
|
||||||
|
})
|
||||||
|
|
||||||
it('should ignore keep-alive heartbeat messages from the server', () => {
|
it('should ignore keep-alive heartbeat messages from the server', () => {
|
||||||
let updated = false
|
let updated = false
|
||||||
let deleted = false
|
let deleted = false
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, inject, signal } from '@angular/core'
|
import { Injectable, inject, signal } from '@angular/core'
|
||||||
import { Subject } from 'rxjs'
|
import { Subject } from 'rxjs'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
|
import { BulkOperationStatus } from '../data/bulk-operation'
|
||||||
import { User } from '../data/user'
|
import { User } from '../data/user'
|
||||||
import { WebsocketDocumentUpdatedMessage } from '../data/websocket-document-updated-message'
|
import { WebsocketDocumentUpdatedMessage } from '../data/websocket-document-updated-message'
|
||||||
import { WebsocketDocumentsDeletedMessage } from '../data/websocket-documents-deleted-message'
|
import { WebsocketDocumentsDeletedMessage } from '../data/websocket-documents-deleted-message'
|
||||||
@@ -12,6 +13,7 @@ export enum WebsocketStatusType {
|
|||||||
DOCUMENTS_DELETED = 'documents_deleted',
|
DOCUMENTS_DELETED = 'documents_deleted',
|
||||||
DOCUMENT_UPDATED = 'document_updated',
|
DOCUMENT_UPDATED = 'document_updated',
|
||||||
HEARTBEAT = 'heartbeat',
|
HEARTBEAT = 'heartbeat',
|
||||||
|
BULK_OPERATION_STATUS = 'bulk_operation_status',
|
||||||
}
|
}
|
||||||
|
|
||||||
// see ProgressStatusOptions in src/documents/plugins/helpers.py
|
// see ProgressStatusOptions in src/documents/plugins/helpers.py
|
||||||
@@ -116,6 +118,13 @@ export class WebsocketStatusService {
|
|||||||
private readonly documentDeletedSubject = new Subject<boolean>()
|
private readonly documentDeletedSubject = new Subject<boolean>()
|
||||||
private readonly documentUpdatedSubject =
|
private readonly documentUpdatedSubject =
|
||||||
new Subject<WebsocketDocumentUpdatedMessage>()
|
new Subject<WebsocketDocumentUpdatedMessage>()
|
||||||
|
private readonly bulkOperationStatusSubject =
|
||||||
|
new Subject<BulkOperationStatus>()
|
||||||
|
|
||||||
|
onBulkOperationStatus() {
|
||||||
|
return this.bulkOperationStatusSubject.asObservable()
|
||||||
|
}
|
||||||
|
|
||||||
private readonly connectionStatusSubject = new Subject<boolean>()
|
private readonly connectionStatusSubject = new Subject<boolean>()
|
||||||
|
|
||||||
private get(taskId: string, filename?: string) {
|
private get(taskId: string, filename?: string) {
|
||||||
@@ -192,6 +201,7 @@ export class WebsocketStatusService {
|
|||||||
| WebsocketProgressMessage
|
| WebsocketProgressMessage
|
||||||
| WebsocketDocumentsDeletedMessage
|
| WebsocketDocumentsDeletedMessage
|
||||||
| WebsocketDocumentUpdatedMessage
|
| WebsocketDocumentUpdatedMessage
|
||||||
|
| BulkOperationStatus
|
||||||
} = JSON.parse(ev.data)
|
} = JSON.parse(ev.data)
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@@ -209,6 +219,12 @@ export class WebsocketStatusService {
|
|||||||
this.handleProgressUpdate(messageData as WebsocketProgressMessage)
|
this.handleProgressUpdate(messageData as WebsocketProgressMessage)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case WebsocketStatusType.BULK_OPERATION_STATUS:
|
||||||
|
this.bulkOperationStatusSubject.next(
|
||||||
|
messageData as BulkOperationStatus
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
case WebsocketStatusType.HEARTBEAT:
|
case WebsocketStatusType.HEARTBEAT:
|
||||||
// keep-alive from the server, see paperless.consumers.StatusConsumer
|
// keep-alive from the server, see paperless.consumers.StatusConsumer
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -691,6 +691,12 @@ table.table {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
.modal-xxl {
|
||||||
|
--bs-modal-width: min(1440px, calc(100vw - 2 * var(--bs-modal-margin)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.toast {
|
.toast {
|
||||||
--bs-toast-max-width: var(--pngx-toast-max-width);
|
--bs-toast-max-width: var(--pngx-toast-max-width);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from guardian.admin import GuardedModelAdmin
|
from guardian.admin import GuardedModelAdmin
|
||||||
from treenode.admin import TreeNodeModelAdmin
|
|
||||||
|
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
from documents.models import CustomField
|
from documents.models import CustomField
|
||||||
@@ -29,7 +28,7 @@ class CorrespondentAdmin(GuardedModelAdmin):
|
|||||||
list_editable = ("match", "matching_algorithm")
|
list_editable = ("match", "matching_algorithm")
|
||||||
|
|
||||||
|
|
||||||
class TagAdmin(GuardedModelAdmin, TreeNodeModelAdmin):
|
class TagAdmin(GuardedModelAdmin):
|
||||||
list_display = ("name", "color", "match", "matching_algorithm")
|
list_display = ("name", "color", "match", "matching_algorithm")
|
||||||
list_filter = ("matching_algorithm",)
|
list_filter = ("matching_algorithm",)
|
||||||
list_editable = ("color", "match", "matching_algorithm")
|
list_editable = ("color", "match", "matching_algorithm")
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class DocumentsConfig(AppConfig):
|
|||||||
from documents.signals.handlers import add_to_index
|
from documents.signals.handlers import add_to_index
|
||||||
from documents.signals.handlers import run_workflows_added
|
from documents.signals.handlers import run_workflows_added
|
||||||
from documents.signals.handlers import run_workflows_updated
|
from documents.signals.handlers import run_workflows_updated
|
||||||
|
from documents.signals.handlers import run_zone_ocr_extraction
|
||||||
from documents.signals.handlers import send_websocket_document_updated
|
from documents.signals.handlers import send_websocket_document_updated
|
||||||
from documents.signals.handlers import set_correspondent
|
from documents.signals.handlers import set_correspondent
|
||||||
from documents.signals.handlers import set_document_type
|
from documents.signals.handlers import set_document_type
|
||||||
@@ -27,8 +28,10 @@ class DocumentsConfig(AppConfig):
|
|||||||
document_consumption_finished.connect(set_tags)
|
document_consumption_finished.connect(set_tags)
|
||||||
document_consumption_finished.connect(set_storage_path)
|
document_consumption_finished.connect(set_storage_path)
|
||||||
document_consumption_finished.connect(run_workflows_added)
|
document_consumption_finished.connect(run_workflows_added)
|
||||||
|
document_consumption_finished.connect(run_zone_ocr_extraction)
|
||||||
document_consumption_finished.connect(add_to_index)
|
document_consumption_finished.connect(add_to_index)
|
||||||
document_consumption_finished.connect(add_or_update_document_in_llm_index)
|
document_consumption_finished.connect(add_or_update_document_in_llm_index)
|
||||||
|
|
||||||
document_updated.connect(run_workflows_updated)
|
document_updated.connect(run_workflows_updated)
|
||||||
document_updated.connect(send_websocket_document_updated)
|
document_updated.connect(send_websocket_document_updated)
|
||||||
document_updated.connect(add_or_update_document_in_llm_index)
|
document_updated.connect(add_or_update_document_in_llm_index)
|
||||||
|
|||||||
+101
-49
@@ -33,6 +33,7 @@ from documents.models import CustomField
|
|||||||
from documents.models import CustomFieldInstance
|
from documents.models import CustomFieldInstance
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
|
from documents.models import OcrTemplate
|
||||||
from documents.models import PaperlessTask
|
from documents.models import PaperlessTask
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
@@ -41,13 +42,16 @@ from documents.permissions import set_permissions_for_objects
|
|||||||
from documents.plugins.helpers import DocumentsStatusManager
|
from documents.plugins.helpers import DocumentsStatusManager
|
||||||
from documents.tasks import bulk_update_documents
|
from documents.tasks import bulk_update_documents
|
||||||
from documents.tasks import consume_file
|
from documents.tasks import consume_file
|
||||||
|
from documents.tasks import extract_zones
|
||||||
from documents.tasks import remove_document_from_index
|
from documents.tasks import remove_document_from_index
|
||||||
|
from documents.tasks import run_workflows_for_documents
|
||||||
from documents.tasks import update_document_content_maybe_archive_file
|
from documents.tasks import update_document_content_maybe_archive_file
|
||||||
from documents.versioning import get_latest_version_for_root
|
from documents.versioning import get_latest_version_for_root
|
||||||
from documents.versioning import get_root_document
|
from documents.versioning import get_root_document
|
||||||
from documents.versioning import versions_newest_first
|
from documents.versioning import versions_newest_first
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
@@ -131,9 +135,24 @@ def _scratch_path(name: str) -> Path:
|
|||||||
return Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR)) / name
|
return Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR)) / name
|
||||||
|
|
||||||
|
|
||||||
|
def _finish_metadata_edit(
|
||||||
|
document_ids: list[int],
|
||||||
|
update_callback: Callable[[list[int]], None] | None,
|
||||||
|
) -> None:
|
||||||
|
if update_callback is not None:
|
||||||
|
update_callback(document_ids)
|
||||||
|
else:
|
||||||
|
bulk_update_documents.apply_async(
|
||||||
|
kwargs={"document_ids": document_ids},
|
||||||
|
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def set_correspondent(
|
def set_correspondent(
|
||||||
doc_ids: list[int],
|
doc_ids: list[int],
|
||||||
correspondent: Correspondent,
|
correspondent: Correspondent,
|
||||||
|
*,
|
||||||
|
update_callback: Callable[[list[int]], None] | None = None,
|
||||||
) -> Literal["OK"]:
|
) -> Literal["OK"]:
|
||||||
if correspondent:
|
if correspondent:
|
||||||
correspondent = Correspondent.objects.only("pk").get(id=correspondent)
|
correspondent = Correspondent.objects.only("pk").get(id=correspondent)
|
||||||
@@ -146,15 +165,17 @@ def set_correspondent(
|
|||||||
affected_docs = list(qs.values_list("pk", flat=True))
|
affected_docs = list(qs.values_list("pk", flat=True))
|
||||||
qs.update(correspondent=correspondent)
|
qs.update(correspondent=correspondent)
|
||||||
|
|
||||||
bulk_update_documents.apply_async(
|
_finish_metadata_edit(affected_docs, update_callback)
|
||||||
kwargs={"document_ids": affected_docs},
|
|
||||||
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
|
|
||||||
)
|
|
||||||
|
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
|
|
||||||
def set_storage_path(doc_ids: list[int], storage_path: StoragePath) -> Literal["OK"]:
|
def set_storage_path(
|
||||||
|
doc_ids: list[int],
|
||||||
|
storage_path: StoragePath,
|
||||||
|
*,
|
||||||
|
update_callback: Callable[[list[int]], None] | None = None,
|
||||||
|
) -> Literal["OK"]:
|
||||||
if storage_path:
|
if storage_path:
|
||||||
storage_path = StoragePath.objects.only("pk").get(id=storage_path)
|
storage_path = StoragePath.objects.only("pk").get(id=storage_path)
|
||||||
|
|
||||||
@@ -168,15 +189,17 @@ def set_storage_path(doc_ids: list[int], storage_path: StoragePath) -> Literal["
|
|||||||
affected_docs = list(qs.values_list("pk", flat=True))
|
affected_docs = list(qs.values_list("pk", flat=True))
|
||||||
qs.update(storage_path=storage_path)
|
qs.update(storage_path=storage_path)
|
||||||
|
|
||||||
bulk_update_documents.apply_async(
|
_finish_metadata_edit(affected_docs, update_callback)
|
||||||
kwargs={"document_ids": affected_docs},
|
|
||||||
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
|
|
||||||
)
|
|
||||||
|
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
|
|
||||||
def set_document_type(doc_ids: list[int], document_type: DocumentType) -> Literal["OK"]:
|
def set_document_type(
|
||||||
|
doc_ids: list[int],
|
||||||
|
document_type: DocumentType,
|
||||||
|
*,
|
||||||
|
update_callback: Callable[[list[int]], None] | None = None,
|
||||||
|
) -> Literal["OK"]:
|
||||||
if document_type:
|
if document_type:
|
||||||
document_type = DocumentType.objects.only("pk").get(id=document_type)
|
document_type = DocumentType.objects.only("pk").get(id=document_type)
|
||||||
|
|
||||||
@@ -188,15 +211,17 @@ def set_document_type(doc_ids: list[int], document_type: DocumentType) -> Litera
|
|||||||
affected_docs = list(qs.values_list("pk", flat=True))
|
affected_docs = list(qs.values_list("pk", flat=True))
|
||||||
qs.update(document_type=document_type)
|
qs.update(document_type=document_type)
|
||||||
|
|
||||||
bulk_update_documents.apply_async(
|
_finish_metadata_edit(affected_docs, update_callback)
|
||||||
kwargs={"document_ids": affected_docs},
|
|
||||||
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
|
|
||||||
)
|
|
||||||
|
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
|
|
||||||
def add_tag(doc_ids: list[int], tag: int) -> Literal["OK"]:
|
def add_tag(
|
||||||
|
doc_ids: list[int],
|
||||||
|
tag: int,
|
||||||
|
*,
|
||||||
|
update_callback: Callable[[list[int]], None] | None = None,
|
||||||
|
) -> Literal["OK"]:
|
||||||
tag_obj = Tag.objects.get(pk=tag)
|
tag_obj = Tag.objects.get(pk=tag)
|
||||||
tags_to_add = [tag_obj, *tag_obj.get_ancestors()]
|
tags_to_add = [tag_obj, *tag_obj.get_ancestors()]
|
||||||
|
|
||||||
@@ -217,15 +242,17 @@ def add_tag(doc_ids: list[int], tag: int) -> Literal["OK"]:
|
|||||||
DocumentTagRelationship.objects.bulk_create(to_create)
|
DocumentTagRelationship.objects.bulk_create(to_create)
|
||||||
|
|
||||||
if affected_docs:
|
if affected_docs:
|
||||||
bulk_update_documents.apply_async(
|
_finish_metadata_edit(list(affected_docs), update_callback)
|
||||||
kwargs={"document_ids": list(affected_docs)},
|
|
||||||
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
|
|
||||||
)
|
|
||||||
|
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
|
|
||||||
def remove_tag(doc_ids: list[int], tag: int) -> Literal["OK"]:
|
def remove_tag(
|
||||||
|
doc_ids: list[int],
|
||||||
|
tag: int,
|
||||||
|
*,
|
||||||
|
update_callback: Callable[[list[int]], None] | None = None,
|
||||||
|
) -> Literal["OK"]:
|
||||||
tag_obj = Tag.objects.get(pk=tag)
|
tag_obj = Tag.objects.get(pk=tag)
|
||||||
tag_ids = [tag_obj.id, *tag_obj.get_descendants_pks()]
|
tag_ids = [tag_obj.id, *tag_obj.get_descendants_pks()]
|
||||||
|
|
||||||
@@ -238,10 +265,7 @@ def remove_tag(doc_ids: list[int], tag: int) -> Literal["OK"]:
|
|||||||
qs.delete()
|
qs.delete()
|
||||||
|
|
||||||
if affected_docs:
|
if affected_docs:
|
||||||
bulk_update_documents.apply_async(
|
_finish_metadata_edit(affected_docs, update_callback)
|
||||||
kwargs={"document_ids": affected_docs},
|
|
||||||
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
|
|
||||||
)
|
|
||||||
|
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
@@ -250,24 +274,20 @@ def modify_tags(
|
|||||||
doc_ids: list[int],
|
doc_ids: list[int],
|
||||||
add_tags: list[int],
|
add_tags: list[int],
|
||||||
remove_tags: list[int],
|
remove_tags: list[int],
|
||||||
|
*,
|
||||||
|
update_callback: Callable[[list[int]], None] | None = None,
|
||||||
) -> Literal["OK"]:
|
) -> Literal["OK"]:
|
||||||
qs = Document.objects.filter(id__in=doc_ids).only("pk")
|
qs = Document.objects.filter(id__in=doc_ids).only("pk")
|
||||||
affected_docs = list(qs.values_list("pk", flat=True))
|
affected_docs = list(qs.values_list("pk", flat=True))
|
||||||
DocumentTagRelationship = Document.tags.through
|
DocumentTagRelationship = Document.tags.through
|
||||||
|
|
||||||
# add with all ancestors
|
hierarchy = Tag.get_hierarchy()
|
||||||
expanded_add_tags: set[int] = set()
|
expanded_add_tags = hierarchy.with_ancestors(
|
||||||
add_tag_objects = Tag.objects.filter(pk__in=add_tags)
|
Tag.objects.filter(pk__in=add_tags).values_list("pk", flat=True),
|
||||||
for t in add_tag_objects:
|
)
|
||||||
expanded_add_tags.add(int(t.id))
|
expanded_remove_tags = hierarchy.with_descendants(
|
||||||
expanded_add_tags.update(int(pk) for pk in t.get_ancestors_pks())
|
Tag.objects.filter(pk__in=remove_tags).values_list("pk", flat=True),
|
||||||
|
)
|
||||||
# remove with all descendants
|
|
||||||
expanded_remove_tags: set[int] = set()
|
|
||||||
remove_tag_objects = Tag.objects.filter(pk__in=remove_tags)
|
|
||||||
for t in remove_tag_objects:
|
|
||||||
expanded_remove_tags.add(int(t.id))
|
|
||||||
expanded_remove_tags.update(int(pk) for pk in t.get_descendants_pks())
|
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
if expanded_remove_tags:
|
if expanded_remove_tags:
|
||||||
@@ -299,10 +319,7 @@ def modify_tags(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if affected_docs:
|
if affected_docs:
|
||||||
bulk_update_documents.apply_async(
|
_finish_metadata_edit(affected_docs, update_callback)
|
||||||
kwargs={"document_ids": affected_docs},
|
|
||||||
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
|
|
||||||
)
|
|
||||||
|
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
@@ -311,6 +328,8 @@ def modify_custom_fields(
|
|||||||
doc_ids: list[int],
|
doc_ids: list[int],
|
||||||
add_custom_fields: list[int] | dict,
|
add_custom_fields: list[int] | dict,
|
||||||
remove_custom_fields: list[int],
|
remove_custom_fields: list[int],
|
||||||
|
*,
|
||||||
|
update_callback: Callable[[list[int]], None] | None = None,
|
||||||
) -> Literal["OK"]:
|
) -> Literal["OK"]:
|
||||||
qs = Document.objects.filter(id__in=doc_ids).only("pk")
|
qs = Document.objects.filter(id__in=doc_ids).only("pk")
|
||||||
affected_docs = list(qs.values_list("pk", flat=True))
|
affected_docs = list(qs.values_list("pk", flat=True))
|
||||||
@@ -373,10 +392,7 @@ def modify_custom_fields(
|
|||||||
field_id__in=remove_custom_fields,
|
field_id__in=remove_custom_fields,
|
||||||
).hard_delete()
|
).hard_delete()
|
||||||
|
|
||||||
bulk_update_documents.apply_async(
|
_finish_metadata_edit(affected_docs, update_callback)
|
||||||
kwargs={"document_ids": affected_docs},
|
|
||||||
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
|
|
||||||
)
|
|
||||||
|
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
@@ -451,12 +467,32 @@ def reprocess(doc_ids: list[int], *, remote_ocr: bool = False) -> Literal["OK"]:
|
|||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
|
|
||||||
|
def run_workflow(
|
||||||
|
doc_ids: list[int],
|
||||||
|
workflow: int,
|
||||||
|
*,
|
||||||
|
filter_documents: bool = True,
|
||||||
|
) -> Literal["OK"]:
|
||||||
|
"""
|
||||||
|
Manually run a single workflow against the given documents.
|
||||||
|
|
||||||
|
Done asynchronously via a Celery task.
|
||||||
|
"""
|
||||||
|
run_workflows_for_documents.delay(
|
||||||
|
document_ids=doc_ids,
|
||||||
|
workflow_id=workflow,
|
||||||
|
filter_documents=filter_documents,
|
||||||
|
)
|
||||||
|
return "OK"
|
||||||
|
|
||||||
|
|
||||||
def set_permissions(
|
def set_permissions(
|
||||||
doc_ids: list[int],
|
doc_ids: list[int],
|
||||||
set_permissions: PermissionsSpec,
|
set_permissions: PermissionsSpec,
|
||||||
*,
|
*,
|
||||||
owner: User | None = None,
|
owner: User | None = None,
|
||||||
merge: bool = False,
|
merge: bool = False,
|
||||||
|
update_callback: Callable[[list[int]], None] | None = None,
|
||||||
) -> Literal["OK"]:
|
) -> Literal["OK"]:
|
||||||
qs = Document.objects.filter(id__in=doc_ids).select_related("owner")
|
qs = Document.objects.filter(id__in=doc_ids).select_related("owner")
|
||||||
|
|
||||||
@@ -474,10 +510,7 @@ def set_permissions(
|
|||||||
merge=merge,
|
merge=merge,
|
||||||
)
|
)
|
||||||
|
|
||||||
bulk_update_documents.apply_async(
|
_finish_metadata_edit(affected_docs, update_callback)
|
||||||
kwargs={"document_ids": affected_docs},
|
|
||||||
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
|
|
||||||
)
|
|
||||||
|
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
@@ -1197,3 +1230,22 @@ def remove_doclink(
|
|||||||
target_doc_field_instance.value.remove(document.id)
|
target_doc_field_instance.value.remove(document.id)
|
||||||
target_doc_field_instance.save()
|
target_doc_field_instance.save()
|
||||||
Document.objects.filter(id=target_doc_id).update(modified=timezone.now())
|
Document.objects.filter(id=target_doc_id).update(modified=timezone.now())
|
||||||
|
|
||||||
|
|
||||||
|
def run_zone_ocr(
|
||||||
|
doc_ids: list[int],
|
||||||
|
*,
|
||||||
|
template: OcrTemplate | None = None,
|
||||||
|
) -> Literal["OK"]:
|
||||||
|
"""
|
||||||
|
Queue zone OCR for the given documents, with the given template or with the
|
||||||
|
templates that match each document's content when its task runs.
|
||||||
|
"""
|
||||||
|
template_id = template.pk if template is not None else None
|
||||||
|
for document_id in doc_ids:
|
||||||
|
extract_zones.apply_async(
|
||||||
|
kwargs={"document_id": document_id, "template_id": template_id},
|
||||||
|
headers={"trigger_source": PaperlessTask.TriggerSource.MANUAL},
|
||||||
|
)
|
||||||
|
|
||||||
|
return "OK"
|
||||||
Loaded 100 of 163 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user