mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-11-02 04:55:36 +00:00
Compare commits
94
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
@@ -8,8 +8,7 @@ on:
|
||||
- 'translations**'
|
||||
workflow_dispatch:
|
||||
concurrency:
|
||||
# Let dev/main commits finish, PR and feature-branch runs supersede older ones
|
||||
group: backend-${{ github.event.pull_request.number || ((github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && github.run_id) || github.ref }}
|
||||
group: backend-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
env:
|
||||
DEFAULT_UV_VERSION: "0.13.x"
|
||||
|
||||
@@ -13,8 +13,7 @@ on:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
concurrency:
|
||||
# Let dev/main commits finish, PR and feature-branch runs supersede older ones
|
||||
group: docker-${{ github.event.pull_request.number || ((github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && github.run_id) || github.ref }}
|
||||
group: docker-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
|
||||
@@ -6,8 +6,7 @@ on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
concurrency:
|
||||
# Let dev/main commits finish, PR and feature-branch runs supersede older ones
|
||||
group: docs-${{ github.event.pull_request.number || ((github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && github.run_id) || github.ref }}
|
||||
group: docs-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -8,8 +8,7 @@ on:
|
||||
- 'translations**'
|
||||
workflow_dispatch:
|
||||
concurrency:
|
||||
# Let dev/main commits finish, PR and feature-branch runs supersede older ones
|
||||
group: frontend-${{ github.event.pull_request.number || ((github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && github.run_id) || github.ref }}
|
||||
group: frontend-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
permissions: {}
|
||||
jobs:
|
||||
@@ -192,7 +191,7 @@ jobs:
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
contents: read
|
||||
container: mcr.microsoft.com/playwright:v1.64.1-resolute
|
||||
container: mcr.microsoft.com/playwright:v1.62.1-resolute
|
||||
env:
|
||||
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
|
||||
|
||||
@@ -7,8 +7,7 @@ on:
|
||||
branches-ignore:
|
||||
- 'translations**'
|
||||
concurrency:
|
||||
# Let dev/main commits finish, PR and feature-branch runs supersede older ones
|
||||
group: lint-${{ github.event.pull_request.number || ((github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && github.run_id) || github.ref }}
|
||||
group: lint-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -8,8 +8,7 @@ on:
|
||||
- 'translations**'
|
||||
workflow_dispatch:
|
||||
concurrency:
|
||||
# Let dev/main commits finish, PR and feature-branch runs supersede older ones
|
||||
group: static-analysis-${{ github.event.pull_request.number || ((github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && github.run_id) || github.ref }}
|
||||
group: static-analysis-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# correct networking for the tests
|
||||
services:
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.38
|
||||
image: docker.io/gotenberg/gotenberg:8.37
|
||||
hostname: gotenberg
|
||||
container_name: gotenberg
|
||||
network_mode: host
|
||||
@@ -24,7 +24,7 @@ services:
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
greenmail:
|
||||
image: docker.io/greenmail/standalone:2.1.15
|
||||
image: docker.io/greenmail/standalone:2.1.14
|
||||
hostname: greenmail
|
||||
container_name: greenmail
|
||||
environment:
|
||||
@@ -35,7 +35,7 @@ services:
|
||||
- "3143:3143" # IMAP
|
||||
restart: unless-stopped
|
||||
nginx:
|
||||
image: docker.io/nginx:1.31.7-alpine
|
||||
image: docker.io/nginx:1.31.6-alpine
|
||||
hostname: nginx
|
||||
container_name: nginx
|
||||
ports:
|
||||
|
||||
@@ -71,7 +71,7 @@ services:
|
||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.38
|
||||
image: docker.io/gotenberg/gotenberg:8.37
|
||||
restart: unless-stopped
|
||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||
# want to allow external content like tracking pixels or even javascript.
|
||||
|
||||
@@ -67,7 +67,7 @@ services:
|
||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.38
|
||||
image: docker.io/gotenberg/gotenberg:8.37
|
||||
restart: unless-stopped
|
||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||
# want to allow external content like tracking pixels or even javascript.
|
||||
|
||||
@@ -56,7 +56,7 @@ services:
|
||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.38
|
||||
image: docker.io/gotenberg/gotenberg:8.37
|
||||
restart: unless-stopped
|
||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||
# want to allow external content like tracking pixels or even javascript.
|
||||
|
||||
@@ -10,11 +10,9 @@ if [[ -n "${PAPERLESS_CONSUMER_DISABLE}" ]]; then
|
||||
else
|
||||
cd ${PAPERLESS_SRC_DIR}
|
||||
|
||||
# Django system checks already ran once in init-system-checks, skip repeating them
|
||||
|
||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||
exec python3 manage.py document_consumer --skip-checks
|
||||
exec python3 manage.py document_consumer
|
||||
else
|
||||
exec s6-setuidgid paperless python3 manage.py document_consumer --skip-checks
|
||||
exec s6-setuidgid paperless python3 manage.py document_consumer
|
||||
fi
|
||||
fi
|
||||
@@ -3,9 +3,8 @@
|
||||
|
||||
cd ${PAPERLESS_SRC_DIR}
|
||||
|
||||
# Django system checks already ran once in init-system-checks, skip repeating them
|
||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||
exec /usr/local/bin/celery --skip-checks --app paperless beat --loglevel INFO
|
||||
exec /usr/local/bin/celery --app paperless beat --loglevel INFO
|
||||
else
|
||||
exec s6-setuidgid paperless /usr/local/bin/celery --skip-checks --app paperless beat --loglevel INFO
|
||||
exec s6-setuidgid paperless /usr/local/bin/celery --app paperless beat --loglevel INFO
|
||||
fi
|
||||
@@ -3,9 +3,8 @@
|
||||
|
||||
cd ${PAPERLESS_SRC_DIR}
|
||||
|
||||
# Django system checks already ran once in init-system-checks, skip repeating them
|
||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||
exec /usr/local/bin/celery --skip-checks --app paperless worker --loglevel INFO --without-mingle --without-gossip
|
||||
exec /usr/local/bin/celery --app paperless worker --loglevel INFO --without-mingle --without-gossip
|
||||
else
|
||||
exec s6-setuidgid paperless /usr/local/bin/celery --skip-checks --app paperless worker --loglevel INFO --without-mingle --without-gossip
|
||||
exec s6-setuidgid paperless /usr/local/bin/celery --app paperless worker --loglevel INFO --without-mingle --without-gossip
|
||||
fi
|
||||
@@ -1,29 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## paperless-ngx 3.4.1
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix the id\_\_ schemae in the right place [@shamoon](https://github.com/shamoon) ([#14604](https://github.com/paperless-ngx/paperless-ngx/pull/14604))
|
||||
- Fix: drop redundant change perms checks on cards open button [@shamoon](https://github.com/shamoon) ([#14601](https://github.com/paperless-ngx/paperless-ngx/pull/14601))
|
||||
- Tweak/fix: make new pdf controls mostly hidden [@shamoon](https://github.com/shamoon) ([#14595](https://github.com/paperless-ngx/paperless-ngx/pull/14595))
|
||||
- Tweak/fix: move preview button in doc list [@shamoon](https://github.com/shamoon) ([#14594](https://github.com/paperless-ngx/paperless-ngx/pull/14594))
|
||||
- Fix: checksum light mode display and cf dropdown mobile position [@shamoon](https://github.com/shamoon) ([#14590](https://github.com/paperless-ngx/paperless-ngx/pull/14590))
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>6 changes</summary>
|
||||
|
||||
- Fix the id\_\_ schemae in the right place [@shamoon](https://github.com/shamoon) ([#14604](https://github.com/paperless-ngx/paperless-ngx/pull/14604))
|
||||
- Fix: drop redundant change perms checks on cards open button [@shamoon](https://github.com/shamoon) ([#14601](https://github.com/paperless-ngx/paperless-ngx/pull/14601))
|
||||
- Chore: Improve idle memory via more lazy imports [@stumpylog](https://github.com/stumpylog) ([#14599](https://github.com/paperless-ngx/paperless-ngx/pull/14599))
|
||||
- Tweak/fix: make new pdf controls mostly hidden [@shamoon](https://github.com/shamoon) ([#14595](https://github.com/paperless-ngx/paperless-ngx/pull/14595))
|
||||
- Tweak/fix: move preview button in doc list [@shamoon](https://github.com/shamoon) ([#14594](https://github.com/paperless-ngx/paperless-ngx/pull/14594))
|
||||
- Fix: checksum light mode display and cf dropdown mobile position [@shamoon](https://github.com/shamoon) ([#14590](https://github.com/paperless-ngx/paperless-ngx/pull/14590))
|
||||
|
||||
</details>
|
||||
|
||||
## paperless-ngx 3.4.0
|
||||
|
||||
### Features / Enhancements
|
||||
|
||||
+79
-1
@@ -109,7 +109,7 @@ Think of versions as **file history** for a document.
|
||||
Paperless-ngx includes management lists for tags, correspondents, document types
|
||||
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,
|
||||
workflows and more from the management sections.
|
||||
workflows, [OCR templates](#ocr-templates) and more from the management sections.
|
||||
|
||||
### Nested Tags
|
||||
|
||||
@@ -795,6 +795,84 @@ 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
|
||||
- `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.
|
||||
|
||||
The bulk editor's **Run Zone OCR** action opens the same dialog for the selected documents. Bulk runs 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
|
||||
|
||||
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
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "paperless-ngx"
|
||||
version = "3.4.1"
|
||||
version = "3.4.0"
|
||||
description = """\
|
||||
A community-supported supercharged document management system: scan, index and archive all your physical documents\
|
||||
"""
|
||||
|
||||
@@ -43,7 +43,6 @@ def configure_environment(instance_root: Path) -> None:
|
||||
|
||||
|
||||
def seed_database() -> None:
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.management import call_command
|
||||
from django.utils import timezone
|
||||
@@ -64,18 +63,11 @@ def seed_database() -> None:
|
||||
username="playwright",
|
||||
password="playwright", # NOSONAR
|
||||
)
|
||||
viewer = User.objects.create_user(
|
||||
User.objects.create_user(
|
||||
username="viewer",
|
||||
password="viewer", # NOSONAR
|
||||
)
|
||||
|
||||
viewer.user_permissions.add(
|
||||
Permission.objects.get(
|
||||
content_type__app_label="documents",
|
||||
codename="view_uisettings",
|
||||
),
|
||||
)
|
||||
|
||||
inbox = Tag.objects.create(name="Inbox", is_inbox_tag=True, owner=admin)
|
||||
quick_filter = Tag.objects.create(name="Another Sample Tag", owner=admin)
|
||||
Tag.objects.create(name="TagWithPartial", owner=admin)
|
||||
|
||||
@@ -80,21 +80,12 @@ test('should show a list of notes', async ({ page }) => {
|
||||
|
||||
test('should support quick filters', async ({ page }) => {
|
||||
await page.goto('/documents/1/details')
|
||||
// filter button is available before tags have loaded
|
||||
const selectedTags = page.locator('pngx-input-tags .ng-value pngx-tag')
|
||||
await expect(selectedTags).toHaveCount(2)
|
||||
await expect(
|
||||
selectedTags.getByText('Another Sample Tag', { exact: true })
|
||||
).toBeVisible()
|
||||
await expect(selectedTags.getByText('Inbox', { exact: true })).toBeVisible()
|
||||
await page
|
||||
.getByRole('button', { name: 'Filter documents with these Tags' })
|
||||
.click()
|
||||
await expect(page).toHaveURL((url) => {
|
||||
const tags = url.searchParams.get('tags__id__all')?.split(',').sort()
|
||||
return url.pathname === '/documents' && tags?.join(',') === '1,2'
|
||||
})
|
||||
await expect(page.locator('pngx-document-list')).toHaveText(/one document/i)
|
||||
await expect(page).toHaveURL(
|
||||
/tags__id__all=2,1&sort=created&reverse=1&page=1/
|
||||
)
|
||||
})
|
||||
|
||||
test('should finish reloading the preview after a remote document update', async ({
|
||||
|
||||
@@ -6,15 +6,8 @@ test.use({
|
||||
},
|
||||
})
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const settingsLoaded = page.waitForResponse((response) =>
|
||||
response.url().endsWith('/api/ui_settings/')
|
||||
)
|
||||
await page.goto('/dashboard')
|
||||
expect((await settingsLoaded).status()).toBe(200)
|
||||
})
|
||||
|
||||
test('should not allow user to edit settings', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
await expect(page.getByRole('link', { name: 'Settings' })).not.toBeAttached()
|
||||
await page.goto('/settings')
|
||||
await expect(page.locator('body')).toHaveText(
|
||||
@@ -23,6 +16,7 @@ test('should not allow user to edit settings', async ({ page }) => {
|
||||
})
|
||||
|
||||
test('should not allow user to view documents', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
await expect(
|
||||
page.locator('nav').getByRole('link', { name: 'Documents' })
|
||||
).not.toBeAttached()
|
||||
@@ -37,6 +31,7 @@ test('should not allow user to view documents', async ({ page }) => {
|
||||
})
|
||||
|
||||
test('should not allow user to view correspondents', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Attributes' })
|
||||
).not.toBeAttached()
|
||||
@@ -47,6 +42,7 @@ test('should not allow user to view correspondents', async ({ page }) => {
|
||||
})
|
||||
|
||||
test('should not allow user to view tags', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Attributes' })
|
||||
).not.toBeAttached()
|
||||
@@ -57,6 +53,7 @@ test('should not allow user to view tags', async ({ page }) => {
|
||||
})
|
||||
|
||||
test('should not allow user to view document types', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Attributes' })
|
||||
).not.toBeAttached()
|
||||
@@ -67,6 +64,7 @@ test('should not allow user to view document types', async ({ page }) => {
|
||||
})
|
||||
|
||||
test('should not allow user to view storage paths', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Attributes' })
|
||||
).not.toBeAttached()
|
||||
@@ -77,6 +75,7 @@ test('should not allow user to view storage paths', async ({ page }) => {
|
||||
})
|
||||
|
||||
test('should not allow user to view logs', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
await expect(page.getByRole('link', { name: 'Logs' })).not.toBeAttached()
|
||||
await page.goto('/logs')
|
||||
await expect(page.locator('body')).toHaveText(
|
||||
@@ -85,6 +84,7 @@ test('should not allow user to view logs', async ({ page }) => {
|
||||
})
|
||||
|
||||
test('should not allow user to view tasks', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
await expect(page.getByRole('link', { name: 'Tasks' })).not.toBeAttached()
|
||||
await page.goto('/tasks')
|
||||
await expect(page.locator('body')).toHaveText(
|
||||
|
||||
+70
-70
@@ -5,14 +5,14 @@
|
||||
<trans-unit id="ngb.alert.close" datatype="html">
|
||||
<source>Close</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.3_@angular+core@22.2.3_@angular+_2a970977f09c8afdd1f9933e219c8db5/node_modules/src/alert/alert.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.2_@angular+core@22.2.2_@angular+_56804c6e9d763769999573a32671b982/node_modules/src/alert/alert.ts</context>
|
||||
<context context-type="linenumber">38,39</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.carousel.slide-number" datatype="html">
|
||||
<source> Slide <x id="INTERPOLATION" equiv-text="source?: NgbSli"/> of <x id="INTERPOLATION_1" equiv-text="s._cycleToSelec"/> </source>
|
||||
<source> Slide <x id="INTERPOLATION" equiv-text="xt(source?: Ngb"/> of <x id="INTERPOLATION_1" equiv-text="this._cycleToSe"/> </source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.3_@angular+core@22.2.3_@angular+_2a970977f09c8afdd1f9933e219c8db5/node_modules/src/carousel/carousel.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.2_@angular+core@22.2.2_@angular+_56804c6e9d763769999573a32671b982/node_modules/src/carousel/carousel.ts</context>
|
||||
<context context-type="linenumber">386,393</context>
|
||||
</context-group>
|
||||
<note priority="1" from="description">Currently selected slide number read by screen reader</note>
|
||||
@@ -20,219 +20,219 @@
|
||||
<trans-unit id="ngb.carousel.previous" datatype="html">
|
||||
<source>Previous</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.3_@angular+core@22.2.3_@angular+_2a970977f09c8afdd1f9933e219c8db5/node_modules/src/carousel/carousel.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.2_@angular+core@22.2.2_@angular+_56804c6e9d763769999573a32671b982/node_modules/src/carousel/carousel.ts</context>
|
||||
<context context-type="linenumber">425,426</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.carousel.next" datatype="html">
|
||||
<source>Next</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.3_@angular+core@22.2.3_@angular+_2a970977f09c8afdd1f9933e219c8db5/node_modules/src/carousel/carousel.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.2_@angular+core@22.2.2_@angular+_56804c6e9d763769999573a32671b982/node_modules/src/carousel/carousel.ts</context>
|
||||
<context context-type="linenumber">452,453</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.datepicker.select-month" datatype="html">
|
||||
<source>Select month</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.3_@angular+core@22.2.3_@angular+_2a970977f09c8afdd1f9933e219c8db5/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.2_@angular+core@22.2.2_@angular+_56804c6e9d763769999573a32671b982/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||
<context context-type="linenumber">52</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.3_@angular+core@22.2.3_@angular+_2a970977f09c8afdd1f9933e219c8db5/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.2_@angular+core@22.2.2_@angular+_56804c6e9d763769999573a32671b982/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||
<context context-type="linenumber">52</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.datepicker.select-year" datatype="html">
|
||||
<source>Select year</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.3_@angular+core@22.2.3_@angular+_2a970977f09c8afdd1f9933e219c8db5/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.2_@angular+core@22.2.2_@angular+_56804c6e9d763769999573a32671b982/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||
<context context-type="linenumber">52</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.3_@angular+core@22.2.3_@angular+_2a970977f09c8afdd1f9933e219c8db5/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.2_@angular+core@22.2.2_@angular+_56804c6e9d763769999573a32671b982/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||
<context context-type="linenumber">52</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.datepicker.previous-month" datatype="html">
|
||||
<source>Previous month</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.3_@angular+core@22.2.3_@angular+_2a970977f09c8afdd1f9933e219c8db5/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.2_@angular+core@22.2.2_@angular+_56804c6e9d763769999573a32671b982/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||
<context context-type="linenumber">73,76</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.3_@angular+core@22.2.3_@angular+_2a970977f09c8afdd1f9933e219c8db5/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.2_@angular+core@22.2.2_@angular+_56804c6e9d763769999573a32671b982/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||
<context context-type="linenumber">73,76</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.datepicker.next-month" datatype="html">
|
||||
<source>Next month</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.3_@angular+core@22.2.3_@angular+_2a970977f09c8afdd1f9933e219c8db5/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.2_@angular+core@22.2.2_@angular+_56804c6e9d763769999573a32671b982/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||
<context context-type="linenumber">73,74</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.3_@angular+core@22.2.3_@angular+_2a970977f09c8afdd1f9933e219c8db5/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.2_@angular+core@22.2.2_@angular+_56804c6e9d763769999573a32671b982/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||
<context context-type="linenumber">73,74</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.pagination.first" datatype="html">
|
||||
<source>««</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.3_@angular+core@22.2.3_@angular+_2a970977f09c8afdd1f9933e219c8db5/node_modules/src/pagination/pagination.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.2_@angular+core@22.2.2_@angular+_56804c6e9d763769999573a32671b982/node_modules/src/pagination/pagination.ts</context>
|
||||
<context context-type="linenumber">277,278</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.pagination.previous" datatype="html">
|
||||
<source>«</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.3_@angular+core@22.2.3_@angular+_2a970977f09c8afdd1f9933e219c8db5/node_modules/src/pagination/pagination.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.2_@angular+core@22.2.2_@angular+_56804c6e9d763769999573a32671b982/node_modules/src/pagination/pagination.ts</context>
|
||||
<context context-type="linenumber">277,278</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.pagination.next" datatype="html">
|
||||
<source>»</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.3_@angular+core@22.2.3_@angular+_2a970977f09c8afdd1f9933e219c8db5/node_modules/src/pagination/pagination.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.2_@angular+core@22.2.2_@angular+_56804c6e9d763769999573a32671b982/node_modules/src/pagination/pagination.ts</context>
|
||||
<context context-type="linenumber">277</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.pagination.last" datatype="html">
|
||||
<source>»»</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.3_@angular+core@22.2.3_@angular+_2a970977f09c8afdd1f9933e219c8db5/node_modules/src/pagination/pagination.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.2_@angular+core@22.2.2_@angular+_56804c6e9d763769999573a32671b982/node_modules/src/pagination/pagination.ts</context>
|
||||
<context context-type="linenumber">277</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.pagination.first-aria" datatype="html">
|
||||
<source>First</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.3_@angular+core@22.2.3_@angular+_2a970977f09c8afdd1f9933e219c8db5/node_modules/src/pagination/pagination.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.2_@angular+core@22.2.2_@angular+_56804c6e9d763769999573a32671b982/node_modules/src/pagination/pagination.ts</context>
|
||||
<context context-type="linenumber">277,278</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.pagination.previous-aria" datatype="html">
|
||||
<source>Previous</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.3_@angular+core@22.2.3_@angular+_2a970977f09c8afdd1f9933e219c8db5/node_modules/src/pagination/pagination.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.2_@angular+core@22.2.2_@angular+_56804c6e9d763769999573a32671b982/node_modules/src/pagination/pagination.ts</context>
|
||||
<context context-type="linenumber">277,278</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.pagination.next-aria" datatype="html">
|
||||
<source>Next</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.3_@angular+core@22.2.3_@angular+_2a970977f09c8afdd1f9933e219c8db5/node_modules/src/pagination/pagination.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.2_@angular+core@22.2.2_@angular+_56804c6e9d763769999573a32671b982/node_modules/src/pagination/pagination.ts</context>
|
||||
<context context-type="linenumber">277,278</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.pagination.last-aria" datatype="html">
|
||||
<source>Last</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.3_@angular+core@22.2.3_@angular+_2a970977f09c8afdd1f9933e219c8db5/node_modules/src/pagination/pagination.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.2_@angular+core@22.2.2_@angular+_56804c6e9d763769999573a32671b982/node_modules/src/pagination/pagination.ts</context>
|
||||
<context context-type="linenumber">277,278</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.progressbar.value" datatype="html">
|
||||
<source><x id="INTERPOLATION"/></source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.3_@angular+core@22.2.3_@angular+_2a970977f09c8afdd1f9933e219c8db5/node_modules/src/progressbar/progressbar.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.2_@angular+core@22.2.2_@angular+_56804c6e9d763769999573a32671b982/node_modules/src/progressbar/progressbar.ts</context>
|
||||
<context context-type="linenumber">39,40</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.HH" datatype="html">
|
||||
<source>HH</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.3_@angular+core@22.2.3_@angular+_2a970977f09c8afdd1f9933e219c8db5/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.2_@angular+core@22.2.2_@angular+_56804c6e9d763769999573a32671b982/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="linenumber">442</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.hours" datatype="html">
|
||||
<source>Hours</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.3_@angular+core@22.2.3_@angular+_2a970977f09c8afdd1f9933e219c8db5/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.2_@angular+core@22.2.2_@angular+_56804c6e9d763769999573a32671b982/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="linenumber">442</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.MM" datatype="html">
|
||||
<source>MM</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.3_@angular+core@22.2.3_@angular+_2a970977f09c8afdd1f9933e219c8db5/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.2_@angular+core@22.2.2_@angular+_56804c6e9d763769999573a32671b982/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="linenumber">442</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.minutes" datatype="html">
|
||||
<source>Minutes</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.3_@angular+core@22.2.3_@angular+_2a970977f09c8afdd1f9933e219c8db5/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.2_@angular+core@22.2.2_@angular+_56804c6e9d763769999573a32671b982/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="linenumber">442</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.increment-hours" datatype="html">
|
||||
<source>Increment hours</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.3_@angular+core@22.2.3_@angular+_2a970977f09c8afdd1f9933e219c8db5/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.2_@angular+core@22.2.2_@angular+_56804c6e9d763769999573a32671b982/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="linenumber">442</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.decrement-hours" datatype="html">
|
||||
<source>Decrement hours</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.3_@angular+core@22.2.3_@angular+_2a970977f09c8afdd1f9933e219c8db5/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.2_@angular+core@22.2.2_@angular+_56804c6e9d763769999573a32671b982/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="linenumber">442</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.increment-minutes" datatype="html">
|
||||
<source>Increment minutes</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.3_@angular+core@22.2.3_@angular+_2a970977f09c8afdd1f9933e219c8db5/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.2_@angular+core@22.2.2_@angular+_56804c6e9d763769999573a32671b982/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="linenumber">442</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.decrement-minutes" datatype="html">
|
||||
<source>Decrement minutes</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.3_@angular+core@22.2.3_@angular+_2a970977f09c8afdd1f9933e219c8db5/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.2_@angular+core@22.2.2_@angular+_56804c6e9d763769999573a32671b982/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="linenumber">442</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.SS" datatype="html">
|
||||
<source>SS</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.3_@angular+core@22.2.3_@angular+_2a970977f09c8afdd1f9933e219c8db5/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.2_@angular+core@22.2.2_@angular+_56804c6e9d763769999573a32671b982/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="linenumber">442</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.seconds" datatype="html">
|
||||
<source>Seconds</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.3_@angular+core@22.2.3_@angular+_2a970977f09c8afdd1f9933e219c8db5/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.2_@angular+core@22.2.2_@angular+_56804c6e9d763769999573a32671b982/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="linenumber">442</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.increment-seconds" datatype="html">
|
||||
<source>Increment seconds</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.3_@angular+core@22.2.3_@angular+_2a970977f09c8afdd1f9933e219c8db5/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.2_@angular+core@22.2.2_@angular+_56804c6e9d763769999573a32671b982/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="linenumber">442</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.decrement-seconds" datatype="html">
|
||||
<source>Decrement seconds</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.3_@angular+core@22.2.3_@angular+_2a970977f09c8afdd1f9933e219c8db5/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.2_@angular+core@22.2.2_@angular+_56804c6e9d763769999573a32671b982/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="linenumber">442</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.PM" datatype="html">
|
||||
<source><x id="INTERPOLATION"/></source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.3_@angular+core@22.2.3_@angular+_2a970977f09c8afdd1f9933e219c8db5/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.2_@angular+core@22.2.2_@angular+_56804c6e9d763769999573a32671b982/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="linenumber">442</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.toast.close-aria" datatype="html">
|
||||
<source>Close</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.3_@angular+core@22.2.3_@angular+_2a970977f09c8afdd1f9933e219c8db5/node_modules/src/toast/toast.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.2.2_@angular+core@22.2.2_@angular+_56804c6e9d763769999573a32671b982/node_modules/src/toast/toast.ts</context>
|
||||
<context context-type="linenumber">80</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
@@ -1402,7 +1402,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">240,241</context>
|
||||
<context context-type="linenumber">239,240</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/document.ts</context>
|
||||
@@ -1733,7 +1733,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">249,250</context>
|
||||
<context context-type="linenumber">248,249</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
|
||||
@@ -1768,7 +1768,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">258,259</context>
|
||||
<context context-type="linenumber">257,258</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
|
||||
@@ -1811,7 +1811,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">222,223</context>
|
||||
<context context-type="linenumber">221,222</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
|
||||
@@ -2289,7 +2289,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">267,268</context>
|
||||
<context context-type="linenumber">266,267</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-bundle-list/share-link-bundle-list.component.html</context>
|
||||
@@ -4115,7 +4115,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">391</context>
|
||||
<context context-type="linenumber">388</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7515883357904500238" datatype="html">
|
||||
@@ -4389,7 +4389,7 @@
|
||||
<source>Create new field</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.html</context>
|
||||
<context context-type="linenumber">19</context>
|
||||
<context context-type="linenumber">20</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6973528734330066202" datatype="html">
|
||||
@@ -4786,7 +4786,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">276,277</context>
|
||||
<context context-type="linenumber">275,276</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/document.ts</context>
|
||||
@@ -6604,7 +6604,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">219,220</context>
|
||||
<context context-type="linenumber">218,219</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||
@@ -8410,7 +8410,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">321</context>
|
||||
<context context-type="linenumber">320</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="78870852467682010" datatype="html">
|
||||
@@ -8425,7 +8425,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">363</context>
|
||||
<context context-type="linenumber">360</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="157572966557284263" datatype="html">
|
||||
@@ -8440,7 +8440,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">370</context>
|
||||
<context context-type="linenumber">367</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="883965278435032344" datatype="html">
|
||||
@@ -8458,7 +8458,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">391</context>
|
||||
<context context-type="linenumber">388</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="872092479747931526" datatype="html">
|
||||
@@ -9919,7 +9919,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">339</context>
|
||||
<context context-type="linenumber">336</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="106713086593101376" datatype="html">
|
||||
@@ -10234,21 +10234,21 @@
|
||||
<source>Sort by title</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">214</context>
|
||||
<context context-type="linenumber">213</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6232673011753681091" datatype="html">
|
||||
<source>Sort by owner</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">227</context>
|
||||
<context context-type="linenumber">226</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3715596725146409911" datatype="html">
|
||||
<source>Owner</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">231,232</context>
|
||||
<context context-type="linenumber">230,231</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/document.ts</context>
|
||||
@@ -10263,49 +10263,49 @@
|
||||
<source>Sort by notes</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">236</context>
|
||||
<context context-type="linenumber">235</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5499001829734502606" datatype="html">
|
||||
<source>Sort by document type</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">245</context>
|
||||
<context context-type="linenumber">244</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6213829731736042759" datatype="html">
|
||||
<source>Sort by storage path</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">254</context>
|
||||
<context context-type="linenumber">253</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3406167410329973166" datatype="html">
|
||||
<source>Sort by created date</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">263</context>
|
||||
<context context-type="linenumber">262</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3769035778779263084" datatype="html">
|
||||
<source>Sort by added date</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">272</context>
|
||||
<context context-type="linenumber">271</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4874754501044009042" datatype="html">
|
||||
<source>Sort by number of pages</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">281</context>
|
||||
<context context-type="linenumber">280</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3817498941817715969" datatype="html">
|
||||
<source>Pages</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">285,286</context>
|
||||
<context context-type="linenumber">284,285</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/document.ts</context>
|
||||
@@ -10324,28 +10324,28 @@
|
||||
<source> Shared </source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">289</context>
|
||||
<context context-type="linenumber">288</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5083658411133224968" datatype="html">
|
||||
<source>Sort by <x id="INTERPOLATION" equiv-text="{{getDisplayCustomFieldTitle(field_id)}}"/></source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">295,296</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">327</context>
|
||||
<context context-type="linenumber">294,295</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2179847500064178686" datatype="html">
|
||||
<source>Edit document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">335</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">329</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4512084577073831437" datatype="html">
|
||||
|
||||
+24
-24
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paperless-ngx-ui",
|
||||
"version": "3.4.1",
|
||||
"version": "3.4.0",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"ng": "ng",
|
||||
@@ -15,14 +15,14 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^22.2.4",
|
||||
"@angular/common": "~22.2.3",
|
||||
"@angular/compiler": "~22.2.3",
|
||||
"@angular/core": "~22.2.3",
|
||||
"@angular/forms": "~22.2.3",
|
||||
"@angular/localize": "~22.2.3",
|
||||
"@angular/platform-browser": "~22.2.3",
|
||||
"@angular/router": "~22.2.3",
|
||||
"@angular/cdk": "^22.2.2",
|
||||
"@angular/common": "~22.2.2",
|
||||
"@angular/compiler": "~22.2.2",
|
||||
"@angular/core": "~22.2.2",
|
||||
"@angular/forms": "~22.2.2",
|
||||
"@angular/localize": "~22.2.2",
|
||||
"@angular/platform-browser": "~22.2.2",
|
||||
"@angular/router": "~22.2.2",
|
||||
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
||||
"@ng-select/ng-select": "~24.2.1",
|
||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||
@@ -30,7 +30,7 @@
|
||||
"bootstrap": "^5.3.8",
|
||||
"file-saver": "^2.0.5",
|
||||
"lodash-es": "^4.18.1",
|
||||
"marked": "~18.1.1",
|
||||
"marked": "~18.0.14",
|
||||
"mime-names": "^1.0.0",
|
||||
"ngx-bootstrap-icons": "^1.9.3",
|
||||
"ngx-color": "^10.1.0",
|
||||
@@ -38,37 +38,37 @@
|
||||
"ngx-device-detector": "^12.0.0",
|
||||
"ngx-ui-tour-ng-bootstrap": "^19.0.0",
|
||||
"normalize-diacritics": "^5.0.0",
|
||||
"pdfjs-dist": "^6.4.299",
|
||||
"pdfjs-dist": "^6.3.289",
|
||||
"rxjs": "^7.8.2",
|
||||
"tslib": "^2.8.1",
|
||||
"utif": "^3.1.0",
|
||||
"uuid": "^14.0.3"
|
||||
"uuid": "^14.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-builders/jest": "^22.0.1",
|
||||
"@angular-devkit/core": "^22.2.4",
|
||||
"@angular-devkit/schematics": "^22.2.4",
|
||||
"@angular-devkit/core": "^22.2.2",
|
||||
"@angular-devkit/schematics": "^22.2.2",
|
||||
"@angular-eslint/builder": "22.6.0",
|
||||
"@angular-eslint/eslint-plugin": "22.6.0",
|
||||
"@angular-eslint/eslint-plugin-template": "22.6.0",
|
||||
"@angular-eslint/schematics": "22.6.0",
|
||||
"@angular-eslint/template-parser": "22.6.0",
|
||||
"@angular/build": "22.2.4",
|
||||
"@angular/cli": "22.2.4",
|
||||
"@angular/compiler-cli": "~22.2.3",
|
||||
"@playwright/test": "^1.64.1",
|
||||
"@angular/build": "22.2.2",
|
||||
"@angular/cli": "22.2.2",
|
||||
"@angular/compiler-cli": "~22.2.2",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^26.6.5",
|
||||
"@typescript-eslint/eslint-plugin": "^8.72.0",
|
||||
"@typescript-eslint/parser": "^8.72.0",
|
||||
"@typescript-eslint/utils": "^8.72.0",
|
||||
"eslint": "^10.13.0",
|
||||
"@types/node": "^26.6.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.70.1",
|
||||
"@typescript-eslint/parser": "^8.70.1",
|
||||
"@typescript-eslint/utils": "^8.70.1",
|
||||
"eslint": "^10.11.0",
|
||||
"jest": "30.5.2",
|
||||
"jest-environment-jsdom": "^30.5.2",
|
||||
"jest-junit": "^17.0.0",
|
||||
"jest-preset-angular": "^17.0.3",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"prettier": "^3.9.10",
|
||||
"prettier": "^3.9.9",
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"ts-node": "~10.9.2",
|
||||
"typescript": "^6.0.3"
|
||||
|
||||
Generated
+652
-698
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 { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.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 { ShareLinksComponent } from './components/manage/share-links/share-links.component'
|
||||
import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
|
||||
@@ -275,6 +276,18 @@ export const routes: Routes = [
|
||||
componentName: 'WorkflowsComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'ocr-templates',
|
||||
component: OcrTemplatesComponent,
|
||||
canActivate: [PermissionsGuard],
|
||||
data: {
|
||||
requiredPermission: {
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.OcrTemplate,
|
||||
},
|
||||
componentName: 'OcrTemplatesComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'mail',
|
||||
component: MailComponent,
|
||||
|
||||
@@ -113,6 +113,7 @@ const sidebarItemLabels: Record<HideableSidebarItemID, string> = {
|
||||
[HideableSidebarItemID.SavedViews]: $localize`Saved Views`,
|
||||
[HideableSidebarItemID.ShareLinks]: $localize`Share Links`,
|
||||
[HideableSidebarItemID.Workflows]: $localize`Workflows`,
|
||||
[HideableSidebarItemID.OcrTemplates]: $localize`OCR Templates`,
|
||||
[HideableSidebarItemID.Mail]: $localize`Mail`,
|
||||
[HideableSidebarItemID.Documentation]: $localize`Documentation`,
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
</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">
|
||||
<i-bs class="me-2" name="bounding-box-circles"></i-bs><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 }"
|
||||
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"
|
||||
|
||||
@@ -295,6 +295,7 @@ describe('AppFrameComponent', () => {
|
||||
HideableSidebarItemID.Dashboard,
|
||||
HideableSidebarItemID.Workflows,
|
||||
HideableSidebarItemID.ShareLinks,
|
||||
HideableSidebarItemID.OcrTemplates,
|
||||
])
|
||||
fixture.detectChanges()
|
||||
|
||||
@@ -310,6 +311,10 @@ describe('AppFrameComponent', () => {
|
||||
fixture.nativeElement.querySelector('[routerLink="share-links"]')
|
||||
.parentElement.classList
|
||||
).toContain('d-none')
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[routerLink="ocr-templates"]')
|
||||
.parentElement.classList
|
||||
).toContain('d-none')
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[routerLink="mail"]').parentElement
|
||||
.classList
|
||||
@@ -322,12 +327,21 @@ describe('AppFrameComponent', () => {
|
||||
])
|
||||
settingsService.sidebarHiddenItemsEditing.set([
|
||||
HideableSidebarItemID.Dashboard,
|
||||
HideableSidebarItemID.OcrTemplates,
|
||||
])
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(
|
||||
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(
|
||||
fixture.nativeElement.querySelector('[routerLink="dashboard"]')
|
||||
.parentElement.classList
|
||||
|
||||
+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))
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -11,12 +11,14 @@
|
||||
</div>
|
||||
@for (field of filteredFields; track field.id) {
|
||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addField(field)" #button>
|
||||
<div class="d-flex">{{field.name}} <small class="ms-auto text-muted">{{getDataTypeLabel(field.data_type)}}</small></div>
|
||||
<small class="d-flex">{{field.name}} <small class="ms-auto text-muted">{{getDataTypeLabel(field.data_type)}}</small></small>
|
||||
</button>
|
||||
}
|
||||
@if (!filterText?.length || filteredFields.length === 0) {
|
||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="createField(filterText)" [disabled]="!canCreateFields" #button>
|
||||
<i-bs width=".9em" height=".9em" name="asterisk" class="me-1"></i-bs><ng-container i18n>Create new field</ng-container>
|
||||
<small>
|
||||
<i-bs width=".9em" height=".9em" name="asterisk" class="me-1"></i-bs><ng-container i18n>Create new field</ng-container>
|
||||
</small>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
+7
@@ -1,3 +1,10 @@
|
||||
.custom-fields-dropdown {
|
||||
min-width: 300px;
|
||||
|
||||
// correct position on mobile
|
||||
@media (max-width: 575.98px) {
|
||||
&.show {
|
||||
margin-left: -245px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@
|
||||
(change)="onChange(selectedDocumentIDs)">
|
||||
<ng-template ng-label-tmp let-document="item">
|
||||
<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><i-bs name="x"></i-bs></button>
|
||||
}
|
||||
@if (document.title) {
|
||||
|
||||
@@ -81,6 +81,9 @@ export class DocumentLinkComponent
|
||||
@Input()
|
||||
placeholder: string = $localize`Search for documents`
|
||||
|
||||
@Input()
|
||||
multiple: boolean = true
|
||||
|
||||
/**
|
||||
* Parent for ng-select dropdown, needed to prevent close on click.
|
||||
*/
|
||||
|
||||
@@ -61,6 +61,22 @@
|
||||
<i-bs name="pencil" class="me-2"></i-bs><ng-container i18n>PDF Editor</ng-container>
|
||||
</button>
|
||||
|
||||
<div class="d-flex" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.OcrTemplate }">
|
||||
<button ngbDropdownItem (click)="runZoneOcr()" [disabled]="!userCanEdit || networkActive()">
|
||||
<i-bs width="1em" height="1em" name="bounding-box-circles" class="me-2"></i-bs><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
|
||||
>
|
||||
<i-bs width="1em" height="1em" name="sliders2"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (userIsOwner && (requiresPassword || password)) {
|
||||
<button ngbDropdownItem (click)="removePassword()" [disabled]="!password">
|
||||
<i-bs name="unlock" class="me-2"></i-bs><ng-container i18n>Remove Password</ng-container>
|
||||
@@ -90,7 +106,7 @@
|
||||
<div class="row">
|
||||
<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-group">
|
||||
@@ -308,7 +324,7 @@
|
||||
</tr>
|
||||
<tr class="align-middle">
|
||||
<td i18n>Original SHA256 checksum</td>
|
||||
<td><span class="badge bg-light text-dark font-monospace text-wrap text-break text-start">{{metadata()?.original_checksum}}</span></td>
|
||||
<td><span class="badge bg-light font-monospace text-wrap text-break text-start">{{metadata()?.original_checksum}}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td i18n>Original file size</td>
|
||||
@@ -465,7 +481,7 @@
|
||||
@switch (archiveContentRenderType) {
|
||||
@case (ContentRenderType.PDF) {
|
||||
@if (!useNativePdfViewer) {
|
||||
<div class="preview-sticky pdf-viewer-container" tabindex="-1">
|
||||
<div class="preview-sticky pdf-viewer-container">
|
||||
<pngx-pdf-viewer
|
||||
[src]="pdfSource()"
|
||||
[sourceRevision]="previewRevision()"
|
||||
@@ -477,7 +493,7 @@
|
||||
(loadError)="onError($event)"
|
||||
(afterLoadComplete)="pdfPreviewLoaded($event)">
|
||||
</pngx-pdf-viewer>
|
||||
<div class="preview-controls position-absolute bottom-0 start-50 translate-middle-x z-1 mb-3 d-flex gap-2 p-1 bg-body border rounded shadow">
|
||||
<div class="position-absolute bottom-0 start-50 translate-middle-x z-1 mb-3 d-flex gap-2 p-1 bg-body border rounded shadow">
|
||||
@if (previewNumPages()) {
|
||||
<div class="input-group input-group-sm w-auto flex-nowrap">
|
||||
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages()" [ngModelOptions]="{standalone: true}" [ngModel]="previewCurrentPage()" (ngModelChange)="previewCurrentPage.set($event)" aria-label="Page" i18n-aria-label />
|
||||
@@ -485,7 +501,7 @@
|
||||
</div>
|
||||
}
|
||||
<div class="input-group input-group-sm w-auto flex-nowrap">
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="decreaseZoom()" aria-label="Zoom out" i18n-aria-label>
|
||||
<button class="btn btn-outline-secondary" (click)="decreaseZoom()" aria-label="Zoom out" i18n-aria-label>
|
||||
<i-bs width="1em" height="1em" name="dash"></i-bs>
|
||||
</button>
|
||||
<select class="form-select" (change)="setZoom($event.target.value)" [ngModelOptions]="{standalone: true}" [ngModel]="currentZoom" aria-label="Zoom" i18n-aria-label>
|
||||
@@ -495,7 +511,7 @@
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="increaseZoom()" aria-label="Zoom in" i18n-aria-label>
|
||||
<button class="btn btn-outline-secondary" (click)="increaseZoom()" aria-label="Zoom in" i18n-aria-label>
|
||||
<i-bs width="1em" height="1em" name="plus"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -12,22 +12,6 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.preview-controls {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
&:hover .preview-controls,
|
||||
&:focus-within .preview-controls {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-group .dropdown-toggle-split {
|
||||
|
||||
@@ -1900,6 +1900,196 @@ describe('DocumentDetailComponent', () => {
|
||||
expect(toastSpy).toHaveBeenCalledWith('Document reloaded.')
|
||||
})
|
||||
|
||||
it('should run zone OCR, report results and reload the document', () => {
|
||||
component.documentId.set(doc.id)
|
||||
component.document.set(doc)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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, __changedFields: ['title'] })
|
||||
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)
|
||||
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', () => {
|
||||
initNormally()
|
||||
component.document.update((document) => ({
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
FILTER_STORAGE_PATH,
|
||||
} from 'src/app/data/filter-rule-type'
|
||||
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 { Tag } from 'src/app/data/tag'
|
||||
import { SETTINGS_KEYS, SuggestionSource } from 'src/app/data/ui-settings'
|
||||
@@ -104,6 +105,7 @@ import { DocumentDetailFieldID } from '../admin/settings/settings.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 { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-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 { 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'
|
||||
@@ -266,6 +268,7 @@ export class DocumentDetailComponent
|
||||
readonly error = signal<any>(undefined)
|
||||
|
||||
readonly networkActive = signal(false)
|
||||
readonly zoneOcrRunning = signal(false)
|
||||
|
||||
readonly documentId = signal<number>(undefined)
|
||||
readonly document = signal<Document>(undefined)
|
||||
@@ -543,7 +546,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
|
||||
this.closeIncomingUpdateModal()
|
||||
this.pendingIncomingUpdate = null
|
||||
@@ -600,7 +607,8 @@ export class DocumentDetailComponent
|
||||
}),
|
||||
first(),
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
takeUntil(this.docChangeNotifier)
|
||||
takeUntil(this.docChangeNotifier),
|
||||
finalize(() => onComplete?.())
|
||||
)
|
||||
.subscribe({
|
||||
next: (doc) => {
|
||||
@@ -1334,6 +1342,7 @@ export class DocumentDetailComponent
|
||||
}
|
||||
|
||||
save(close: boolean = false, savedCallback: () => void = null) {
|
||||
if (this.zoneOcrRunning()) return
|
||||
this.networkActive.set(true)
|
||||
;(document.activeElement as HTMLElement)?.dispatchEvent(new Event('change'))
|
||||
this.documentsService
|
||||
@@ -1554,6 +1563,107 @@ export class DocumentDetailComponent
|
||||
})
|
||||
}
|
||||
|
||||
/** Runs every template that matches, confirming only over unsaved changes. */
|
||||
runZoneOcr() {
|
||||
if (this.networkActive()) return
|
||||
const documentId = this.documentId()
|
||||
if (!this.hasLocalEdits(this.document())) {
|
||||
this.executeZoneOcr(documentId, null)
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Opens the dialog to pick which template to run. */
|
||||
runZoneOcrWithOptions() {
|
||||
if (this.networkActive()) return
|
||||
const documentId = this.documentId()
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private executeZoneOcr(documentId: number, templateId: number | null) {
|
||||
this.zoneOcrRunning.set(true)
|
||||
this.networkActive.set(true)
|
||||
this.documentsService
|
||||
.runZoneOcr(documentId, templateId)
|
||||
.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 {
|
||||
const versions = this.document()?.versions ?? []
|
||||
if (!versions.length || !this.selectedVersionId()) {
|
||||
|
||||
-2
@@ -47,8 +47,6 @@
|
||||
<span>{{ change.key | titlecase }}</span>:
|
||||
@if (change.key === 'content') {
|
||||
<code class="text-primary">{{ change.value[1]?.substring(0,100) }}...</code>
|
||||
} @else if (change.key === 'tags') {
|
||||
<code class="text-primary">{{ getTagNames($any(change.value[1])) | async }}</code>
|
||||
} @else {
|
||||
<code class="text-primary">{{ getPrettyName(change.key, change.value[1]) | async }}</code>
|
||||
}
|
||||
|
||||
-34
@@ -12,7 +12,6 @@ import { CorrespondentService } from 'src/app/services/rest/correspondent.servic
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { DocumentHistoryComponent } from './document-history.component'
|
||||
|
||||
@@ -175,39 +174,6 @@ describe('DocumentHistoryComponent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should render bulk tag history with names and preserve missing tag IDs', () => {
|
||||
const getCachedSpy = jest
|
||||
.spyOn(TestBed.inject(TagService), 'getCached')
|
||||
.mockImplementation((id) =>
|
||||
of(id === 1 ? { id: 1, name: 'Invoice' } : null)
|
||||
)
|
||||
component.entries.set([
|
||||
{
|
||||
id: 1,
|
||||
action: AuditLogAction.Update,
|
||||
timestamp: '2021-01-01T00:00:00Z',
|
||||
remote_addr: null,
|
||||
changes: JSON.parse('{"tags":[[],[1,2345]]}'),
|
||||
},
|
||||
])
|
||||
component.loading.set(false)
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(fixture.nativeElement.querySelector('code').textContent).toBe(
|
||||
'Invoice, 2345'
|
||||
)
|
||||
fixture.detectChanges()
|
||||
expect(getCachedSpy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should handle removal of all tags without a lookup', () => {
|
||||
const getCachedSpy = jest.spyOn(TestBed.inject(TagService), 'getCached')
|
||||
component.getTagNames([]).subscribe((result) => {
|
||||
expect(result).toBe('')
|
||||
})
|
||||
expect(getCachedSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('getPrettyName should return the value as is for other types', () => {
|
||||
const id = '123'
|
||||
component.getPrettyName('other', id).subscribe((result) => {
|
||||
|
||||
+1
-27
@@ -2,7 +2,7 @@ import { AsyncPipe, KeyValuePipe, TitleCasePipe } from '@angular/common'
|
||||
import { Component, Input, OnInit, inject, signal } from '@angular/core'
|
||||
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { Observable, first, forkJoin, map, of, shareReplay } from 'rxjs'
|
||||
import { Observable, first, map, of, shareReplay } from 'rxjs'
|
||||
import { AuditLogAction, AuditLogEntry } from 'src/app/data/auditlog-entry'
|
||||
import { DataType } from 'src/app/data/datatype'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
@@ -10,7 +10,6 @@ import { CorrespondentService } from 'src/app/services/rest/correspondent.servic
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
|
||||
@Component({
|
||||
@@ -31,7 +30,6 @@ export class DocumentHistoryComponent implements OnInit {
|
||||
private readonly correspondentService = inject(CorrespondentService)
|
||||
private readonly storagePathService = inject(StoragePathService)
|
||||
private readonly documentTypeService = inject(DocumentTypeService)
|
||||
private readonly tagService = inject(TagService)
|
||||
private readonly userService = inject(UserService)
|
||||
|
||||
public AuditLogAction = AuditLogAction
|
||||
@@ -65,30 +63,6 @@ export class DocumentHistoryComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
getTagNames(tagIds: number[]): Observable<string> {
|
||||
const cacheKey = `tags:${JSON.stringify(tagIds)}`
|
||||
const cachedNames = this.prettyNameCache.get(cacheKey)
|
||||
if (cachedNames) {
|
||||
return cachedNames
|
||||
}
|
||||
|
||||
const tagNames$ = tagIds.length
|
||||
? forkJoin(
|
||||
tagIds.map((tagId) =>
|
||||
this.tagService.getCached(tagId).pipe(
|
||||
first(),
|
||||
map((tag) => tag?.name ?? String(tagId))
|
||||
)
|
||||
)
|
||||
).pipe(map((names) => names.join(', ')))
|
||||
: of('')
|
||||
const names$ = tagNames$.pipe(
|
||||
shareReplay({ bufferSize: 1, refCount: true })
|
||||
)
|
||||
this.prettyNameCache.set(cacheKey, names$)
|
||||
return names$
|
||||
}
|
||||
|
||||
getPrettyName(type: DataType | string, id: string): Observable<string> {
|
||||
const cacheKey = `${type}:${id}`
|
||||
const cached = this.prettyNameCache.get(cacheKey)
|
||||
|
||||
@@ -98,6 +98,11 @@
|
||||
<button ngbDropdownItem (click)="mergeSelectedAsVersions()" [disabled]="!userOwnsAll || !userCanEditAll || !userCanDelete || list.allSelected || list.selectedCount < 2">
|
||||
<i-bs name="journal-bookmark-fill" class="me-2"></i-bs><ng-container i18n>Merge as versions</ng-container>
|
||||
</button>
|
||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.OcrTemplate)) {
|
||||
<button ngbDropdownItem (click)="runZoneOcrSelected()" [disabled]="!userCanEditAll">
|
||||
<i-bs name="bounding-box-circles" class="me-2"></i-bs><ng-container i18n>Run Zone OCR</ng-container>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1286,6 +1286,53 @@ describe('BulkEditorComponent', () => {
|
||||
) // 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', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||
|
||||
@@ -64,6 +64,7 @@ import { MergeAsVersionsConfirmDialogComponent } from '../../common/confirm-dial
|
||||
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 { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-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 { 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'
|
||||
@@ -966,6 +967,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() {
|
||||
let modal = this.modalService.open(PermissionsDialogComponent, {
|
||||
backdrop: 'static',
|
||||
|
||||
+1
-1
@@ -72,7 +72,7 @@
|
||||
<a class="btn btn-sm btn-outline-secondary" (click)="clickMoreLike.emit()">
|
||||
<i-bs name="diagram-3" class="me-1"></i-bs><span class="d-none d-md-inline" i18n>More like this</span>
|
||||
</a>
|
||||
<a routerLink="/documents/{{document().id}}" class="btn btn-sm btn-outline-secondary">
|
||||
<a routerLink="/documents/{{document().id}}" class="btn btn-sm btn-outline-secondary" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
||||
<i-bs name="file-earmark-richtext" class="me-1"></i-bs><span class="d-none d-md-inline" i18n>Open</span>
|
||||
</a>
|
||||
<pngx-preview-popup [document]="document()" #popupPreview>
|
||||
|
||||
+2
@@ -10,6 +10,7 @@ import {
|
||||
NgbTooltipModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
||||
import { IsNumberPipe } from 'src/app/pipes/is-number.pipe'
|
||||
@@ -51,6 +52,7 @@ describe('DocumentCardLargeComponent', () => {
|
||||
DocumentCardLargeComponent,
|
||||
DocumentTitlePipe,
|
||||
CustomDatePipe,
|
||||
IfPermissionsDirective,
|
||||
SafeUrlPipe,
|
||||
IsNumberPipe,
|
||||
PreviewPopupComponent,
|
||||
|
||||
+2
@@ -20,6 +20,7 @@ import {
|
||||
Document,
|
||||
} from 'src/app/data/document'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { CorrespondentNamePipe } from 'src/app/pipes/correspondent-name.pipe'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
||||
@@ -50,6 +51,7 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
|
||||
CorrespondentNamePipe,
|
||||
DocumentTypeNamePipe,
|
||||
StoragePathNamePipe,
|
||||
IfPermissionsDirective,
|
||||
CustomDatePipe,
|
||||
RouterModule,
|
||||
NgbTooltipModule,
|
||||
|
||||
+1
-1
@@ -144,7 +144,7 @@
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="btn-group w-100">
|
||||
@if (document()) {
|
||||
<a routerLink="/documents/{{document().id}}" class="btn btn-sm btn-outline-secondary" title="Open" i18n-title>
|
||||
<a routerLink="/documents/{{document().id}}" class="btn btn-sm btn-outline-secondary" title="Open" i18n-title *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }" i18n-title>
|
||||
<i-bs name="file-earmark-richtext"></i-bs>
|
||||
</a>
|
||||
<pngx-preview-popup [document]="document()" #popupPreview>
|
||||
|
||||
+2
@@ -20,6 +20,7 @@ import {
|
||||
Document,
|
||||
} from 'src/app/data/document'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { CorrespondentNamePipe } from 'src/app/pipes/correspondent-name.pipe'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
||||
@@ -50,6 +51,7 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
|
||||
CorrespondentNamePipe,
|
||||
DocumentTypeNamePipe,
|
||||
StoragePathNamePipe,
|
||||
IfPermissionsDirective,
|
||||
CustomDatePipe,
|
||||
RouterModule,
|
||||
NgbTooltipModule,
|
||||
|
||||
@@ -208,7 +208,6 @@
|
||||
i18n>Correspondent</th>
|
||||
}
|
||||
@if (activeDisplayFields.includes(DisplayField.TITLE)) {
|
||||
<th style="width: 2rem"><!-- Preview --></th>
|
||||
<th class="cursor-pointer"
|
||||
pngxSortable="title"
|
||||
title="Sort by title" i18n-title
|
||||
@@ -322,17 +321,15 @@
|
||||
}
|
||||
</td>
|
||||
}
|
||||
@if (activeDisplayFields.includes(DisplayField.TITLE)) {
|
||||
<td class="pe-0 text-end align-baseline" (mouseleave)="popupPreview.close()">
|
||||
<pngx-preview-popup [document]="d" linkClasses="btn btn-sm btn-link text-secondary px-1" linkTitle="Preview document" (click)="$event.stopPropagation()" i18n-linkTitle #popupPreview>
|
||||
<i-bs name="eye"></i-bs>
|
||||
</pngx-preview-popup>
|
||||
</td>
|
||||
}
|
||||
@if (activeDisplayFields.includes(DisplayField.TITLE) || activeDisplayFields.includes(DisplayField.TAGS)) {
|
||||
<td width="30%" class="align-baseline">
|
||||
<td width="30%">
|
||||
@if (activeDisplayFields.includes(DisplayField.TITLE)) {
|
||||
<a routerLink="/documents/{{d.id}}" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a>
|
||||
<div class="d-inline-block" (mouseleave)="popupPreview.close()">
|
||||
<a routerLink="/documents/{{d.id}}" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a>
|
||||
<pngx-preview-popup [document]="d" linkClasses="btn btn-sm btn-link text-secondary" linkTitle="Preview document" (click)="$event.stopPropagation()" i18n-linkTitle #popupPreview>
|
||||
<i-bs name="eye"></i-bs>
|
||||
</pngx-preview-popup>
|
||||
</div>
|
||||
}
|
||||
@if (activeDisplayFields.includes(DisplayField.TAGS)) {
|
||||
@for (tagID of d.tags; track tagID) {
|
||||
|
||||
+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">
|
||||
<i-bs name="exclamation-triangle" class="me-1" aria-hidden="true"></i-bs><span i18n>No field selected</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="small text-muted text-truncate">
|
||||
{{ getZoneTargetName(zone) }}
|
||||
@if (isCombined(zone)) {
|
||||
<i-bs name="braces" class="ms-1" title="Combined with other zones" i18n-title></i-bs>
|
||||
}
|
||||
</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>
|
||||
<i-bs name="trash"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
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,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(OcrTemplateEditorZoneListComponent)
|
||||
component = fixture.componentInstance
|
||||
})
|
||||
|
||||
it('shows empty state when no zones are defined', () => {
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('No zones 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 { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
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: [NgxBootstrapIconsModule],
|
||||
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()">
|
||||
<i-bs name="arrow-left" class="me-1"></i-bs><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 {
|
||||
<i-bs name="play-fill" class="me-1"></i-bs>
|
||||
}
|
||||
<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>
|
||||
<i-bs name="trash"></i-bs>
|
||||
</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()"><i-bs name="dash"></i-bs></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()"><i-bs name="plus"></i-bs></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">
|
||||
<i-bs name="exclamation-triangle" width="32" height="32" class="text-warning"></i-bs>
|
||||
<p class="my-3" i18n>Could not load this page.</p>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" (click)="setSampleDocument(sampleDocumentIds)"><i-bs name="arrow-clockwise" class="me-1"></i-bs><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">
|
||||
<i-bs name="file-text" width="32" height="32"></i-bs>
|
||||
<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;
|
||||
}
|
||||
+878
@@ -0,0 +1,878 @@
|
||||
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 { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
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: [
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
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 { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
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,
|
||||
NgxBootstrapIconsModule,
|
||||
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 }">
|
||||
<i-bs name="plus-circle" class="me-1"></i-bs><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>
|
||||
<i-bs name="three-dots-vertical"></i-bs>
|
||||
</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)">
|
||||
<i-bs width="1em" height="1em" name="pencil" class="me-1"></i-bs><ng-container i18n>Edit</ng-container>
|
||||
</button>
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.OcrTemplate }" [disabled]="!permissionsService.currentUserOwnsObject(template)" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteTemplate(template)">
|
||||
<i-bs width="1em" height="1em" name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
@if (!loading() && templates().length === 0) {
|
||||
<li class="list-group-item" [class.show]="show()" i18n>No OCR templates defined.</li>
|
||||
}
|
||||
</ul>
|
||||
@@ -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 { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
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'
|
||||
|
||||
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: [NgxBootstrapIconsModule.pick(allIcons), OcrTemplatesComponent],
|
||||
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 { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
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,
|
||||
NgxBootstrapIconsModule,
|
||||
],
|
||||
})
|
||||
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,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[]
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export interface SystemStatus {
|
||||
status: SystemStatusItemStatus
|
||||
error?: string
|
||||
migration_status: {
|
||||
latest_migration: string | null
|
||||
latest_migration: string
|
||||
unapplied_migrations: string[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export enum HideableSidebarItemID {
|
||||
SavedViews = 'saved_views',
|
||||
ShareLinks = 'share_links',
|
||||
Workflows = 'workflows',
|
||||
OcrTemplates = 'ocr_templates',
|
||||
Mail = 'mail',
|
||||
Documentation = 'documentation',
|
||||
}
|
||||
|
||||
@@ -3,12 +3,16 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { DirtyCheckGuard } from '@ngneat/dirty-check-forms'
|
||||
import { Observable, Subject } from 'rxjs'
|
||||
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
|
||||
import { ActivatedRouteSnapshot } from '@angular/router'
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DirtyFormGuard extends DirtyCheckGuard {
|
||||
private modalService = inject(NgbModal)
|
||||
|
||||
confirmChanges(): Observable<boolean> {
|
||||
confirmChanges(
|
||||
_currentRoute?: ActivatedRouteSnapshot,
|
||||
leaveLabel: string = $localize`Leave page`
|
||||
): Observable<boolean> {
|
||||
let modal = this.modalService.open(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
@@ -16,7 +20,7 @@ export class DirtyFormGuard extends DirtyCheckGuard {
|
||||
modal.componentInstance.messageBold = $localize`You have unsaved changes.`
|
||||
modal.componentInstance.message = $localize`Are you sure you want to leave?`
|
||||
modal.componentInstance.btnClass = 'btn-warning'
|
||||
modal.componentInstance.btnCaption = $localize`Leave page`
|
||||
modal.componentInstance.btnCaption = leaveLabel
|
||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
modal.close()
|
||||
|
||||
@@ -283,6 +283,10 @@ describe('PermissionsService', () => {
|
||||
'view_workflow',
|
||||
'change_workflow',
|
||||
'delete_workflow',
|
||||
'add_ocrtemplate',
|
||||
'view_ocrtemplate',
|
||||
'change_ocrtemplate',
|
||||
'delete_ocrtemplate',
|
||||
'add_customfield',
|
||||
'view_customfield',
|
||||
'change_customfield',
|
||||
|
||||
@@ -29,6 +29,7 @@ export enum PermissionType {
|
||||
ShareLinkBundle = '%s_sharelinkbundle',
|
||||
CustomField = '%s_customfield',
|
||||
Workflow = '%s_workflow',
|
||||
OcrTemplate = '%s_ocrtemplate',
|
||||
ProcessedMail = '%s_processedmail',
|
||||
GlobalStatistics = '%s_global_statistics',
|
||||
SystemMonitoring = '%s_system_monitoring',
|
||||
|
||||
@@ -563,6 +563,20 @@ describe(`DocumentService`, () => {
|
||||
|
||||
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).subscribe()
|
||||
req = httpTestingController.expectOne(url)
|
||||
expect(req.request.body).toEqual({ template: 3 })
|
||||
req.flush({ status: 'completed', results: [] })
|
||||
})
|
||||
})
|
||||
|
||||
it('should construct sort fields respecting permissions', () => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { DocumentMetadata } from 'src/app/data/document-metadata'
|
||||
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
|
||||
import { FilterRule } from 'src/app/data/filter-rule'
|
||||
import { OcrZoneRunResponse } from 'src/app/data/ocr-template'
|
||||
import { Results, SelectionData } from 'src/app/data/results'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { queryParamsFromFilterRules } from '../../utils/query-params'
|
||||
@@ -360,6 +361,26 @@ 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
|
||||
): Observable<OcrZoneRunResponse> {
|
||||
return this.http.post<OcrZoneRunResponse>(
|
||||
this.getResourceUrl(id, 'run-zone-ocr'),
|
||||
templateId ? { template: templateId } : {}
|
||||
)
|
||||
}
|
||||
|
||||
rotateDocuments(
|
||||
selection: DocumentSelectionQuery,
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export const environment = {
|
||||
apiVersion: '10', // match src/paperless/settings.py
|
||||
appTitle: DEFAULT_APP_TITLE,
|
||||
tag: 'prod',
|
||||
version: '3.4.1',
|
||||
version: '3.4.0',
|
||||
webSocketHost: window.location.host,
|
||||
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
||||
webSocketBaseUrl: base_url.pathname + 'ws/',
|
||||
|
||||
@@ -11397,22 +11397,22 @@
|
||||
</context-group>
|
||||
<target state="needs-translation">Sort by <x id="INTERPOLATION" equiv-text="{{getDisplayCustomFieldTitle(field_id)}}"/></target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="needs-translation">Preview document</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="2179847500064178686" datatype="html">
|
||||
<source>Edit document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">331</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="translated">Wysig dokument</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">329</context>
|
||||
</context-group>
|
||||
<target state="needs-translation">Preview document</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="4512084577073831437" datatype="html">
|
||||
<source>Reset filters / selection</source>
|
||||
<context-group purpose="location">
|
||||
|
||||
@@ -11397,22 +11397,22 @@
|
||||
</context-group>
|
||||
<target state="needs-translation">Sort by <x id="INTERPOLATION" equiv-text="{{getDisplayCustomFieldTitle(field_id)}}"/></target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="needs-translation">Preview document</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="2179847500064178686" datatype="html">
|
||||
<source>Edit document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">331</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="needs-translation">Edit document</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">329</context>
|
||||
</context-group>
|
||||
<target state="needs-translation">Preview document</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="4512084577073831437" datatype="html">
|
||||
<source>Reset filters / selection</source>
|
||||
<context-group purpose="location">
|
||||
|
||||
@@ -11397,22 +11397,22 @@
|
||||
</context-group>
|
||||
<target state="needs-translation">Sort by <x id="INTERPOLATION" equiv-text="{{getDisplayCustomFieldTitle(field_id)}}"/></target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="needs-translation">Preview document</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="2179847500064178686" datatype="html">
|
||||
<source>Edit document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">331</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="translated">تعديل المستند</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">329</context>
|
||||
</context-group>
|
||||
<target state="needs-translation">Preview document</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="4512084577073831437" datatype="html">
|
||||
<source>Reset filters / selection</source>
|
||||
<context-group purpose="location">
|
||||
|
||||
@@ -11397,22 +11397,22 @@
|
||||
</context-group>
|
||||
<target state="needs-translation">Sort by <x id="INTERPOLATION" equiv-text="{{getDisplayCustomFieldTitle(field_id)}}"/></target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="needs-translation">Preview document</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="2179847500064178686" datatype="html">
|
||||
<source>Edit document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">331</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="translated">Рэдагаваць дакумент</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">329</context>
|
||||
</context-group>
|
||||
<target state="needs-translation">Preview document</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="4512084577073831437" datatype="html">
|
||||
<source>Reset filters / selection</source>
|
||||
<context-group purpose="location">
|
||||
|
||||
@@ -11397,22 +11397,22 @@
|
||||
</context-group>
|
||||
<target state="translated">Сортирай по: <x id="INTERPOLATION" equiv-text="{{getDisplayCustomFieldTitle(field_id)}}"/></target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="translated">Визуализация на документа</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="2179847500064178686" datatype="html">
|
||||
<source>Edit document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">331</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="translated">Редактиране на документа</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">329</context>
|
||||
</context-group>
|
||||
<target state="translated">Визуализация на документа</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="4512084577073831437" datatype="html">
|
||||
<source>Reset filters / selection</source>
|
||||
<context-group purpose="location">
|
||||
|
||||
@@ -11397,22 +11397,22 @@
|
||||
</context-group>
|
||||
<target state="translated">Ordena per <x id="INTERPOLATION" equiv-text="{{getDisplayCustomFieldTitle(field_id)}}"/></target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="translated">Previsualitza document</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="2179847500064178686" datatype="html">
|
||||
<source>Edit document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">331</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="translated">Edita document</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">329</context>
|
||||
</context-group>
|
||||
<target state="translated">Previsualitza document</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="4512084577073831437" datatype="html">
|
||||
<source>Reset filters / selection</source>
|
||||
<context-group purpose="location">
|
||||
|
||||
@@ -11396,22 +11396,22 @@
|
||||
</context-group>
|
||||
<target state="translated">Seřadit podle <x id="INTERPOLATION" equiv-text="{{getDisplayCustomFieldTitle(field_id)}}"/></target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="translated">Náhled dokumentu</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="2179847500064178686" datatype="html">
|
||||
<source>Edit document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">331</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="translated">Upravit dokument</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">329</context>
|
||||
</context-group>
|
||||
<target state="translated">Náhled dokumentu</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="4512084577073831437" datatype="html">
|
||||
<source>Reset filters / selection</source>
|
||||
<context-group purpose="location">
|
||||
|
||||
@@ -11397,22 +11397,22 @@
|
||||
</context-group>
|
||||
<target state="needs-translation">Sort by <x id="INTERPOLATION" equiv-text="{{getDisplayCustomFieldTitle(field_id)}}"/></target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="needs-translation">Preview document</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="2179847500064178686" datatype="html">
|
||||
<source>Edit document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">331</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="needs-translation">Edit document</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">329</context>
|
||||
</context-group>
|
||||
<target state="needs-translation">Preview document</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="4512084577073831437" datatype="html">
|
||||
<source>Reset filters / selection</source>
|
||||
<context-group purpose="location">
|
||||
|
||||
@@ -11396,22 +11396,22 @@
|
||||
</context-group>
|
||||
<target state="translated">Nach <x id="INTERPOLATION" equiv-text="{{getDisplayCustomFieldTitle(field_id)}}"/> sortieren</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="translated">Dokumentenvorschau</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="2179847500064178686" datatype="html">
|
||||
<source>Edit document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">331</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="translated">Dokument bearbeiten</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">329</context>
|
||||
</context-group>
|
||||
<target state="translated">Dokumentenvorschau</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="4512084577073831437" datatype="html">
|
||||
<source>Reset filters / selection</source>
|
||||
<context-group purpose="location">
|
||||
|
||||
@@ -4795,7 +4795,7 @@
|
||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||
<context context-type="linenumber">38</context>
|
||||
</context-group>
|
||||
<target state="translated">Datumsmodus</target>
|
||||
<target state="needs-translation">Date mode</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="7572800523712857519" datatype="html">
|
||||
<source>Fixed date</source>
|
||||
@@ -4807,7 +4807,7 @@
|
||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||
<context context-type="linenumber">40</context>
|
||||
</context-group>
|
||||
<target state="translated">Festes Datum</target>
|
||||
<target state="needs-translation">Fixed date</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="4108478337308251505" datatype="html">
|
||||
<source>Fixed</source>
|
||||
@@ -4815,7 +4815,7 @@
|
||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||
<context context-type="linenumber">41,42</context>
|
||||
</context-group>
|
||||
<target state="translated">Fixiert</target>
|
||||
<target state="needs-translation">Fixed</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="8283919074858117329" datatype="html">
|
||||
<source>Relative date</source>
|
||||
@@ -4827,7 +4827,7 @@
|
||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||
<context context-type="linenumber">44</context>
|
||||
</context-group>
|
||||
<target state="translated">Relatives Datum</target>
|
||||
<target state="needs-translation">Relative date</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="2731996625798485199" datatype="html">
|
||||
<source>Relative</source>
|
||||
@@ -4835,7 +4835,7 @@
|
||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||
<context context-type="linenumber">45,46</context>
|
||||
</context-group>
|
||||
<target state="translated">Relativ</target>
|
||||
<target state="needs-translation">Relative</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="6533918067030990396" datatype="html">
|
||||
<source>Amount</source>
|
||||
@@ -4843,7 +4843,7 @@
|
||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||
<context context-type="linenumber">51</context>
|
||||
</context-group>
|
||||
<target state="translated">Anzahl</target>
|
||||
<target state="needs-translation">Amount</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="8932917860978648376" datatype="html">
|
||||
<source>Unit</source>
|
||||
@@ -4851,7 +4851,7 @@
|
||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||
<context context-type="linenumber">56,57</context>
|
||||
</context-group>
|
||||
<target state="translated">Einheit</target>
|
||||
<target state="needs-translation">Unit</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="9138811584797805840" datatype="html">
|
||||
<source>Open date picker</source>
|
||||
@@ -4991,7 +4991,7 @@
|
||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||
<context context-type="linenumber">119,120</context>
|
||||
</context-group>
|
||||
<target state="translated">Operator</target>
|
||||
<target state="needs-translation">Operator</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="2328549728463836983" datatype="html">
|
||||
<source>Remove query</source>
|
||||
@@ -5051,7 +5051,7 @@
|
||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts</context>
|
||||
<context context-type="linenumber">330</context>
|
||||
</context-group>
|
||||
<target state="translated">vor Tagen</target>
|
||||
<target state="needs-translation">days ago</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="2871318661796659216" datatype="html">
|
||||
<source>weeks ago</source>
|
||||
@@ -5059,7 +5059,7 @@
|
||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts</context>
|
||||
<context context-type="linenumber">331</context>
|
||||
</context-group>
|
||||
<target state="translated">vor Wochen</target>
|
||||
<target state="needs-translation">weeks ago</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="6041340836190906216" datatype="html">
|
||||
<source>months ago</source>
|
||||
@@ -5067,7 +5067,7 @@
|
||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts</context>
|
||||
<context context-type="linenumber">332</context>
|
||||
</context-group>
|
||||
<target state="translated">vor Monaten</target>
|
||||
<target state="needs-translation">months ago</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="963494111451627204" datatype="html">
|
||||
<source>years ago</source>
|
||||
@@ -5075,7 +5075,7 @@
|
||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts</context>
|
||||
<context context-type="linenumber">333</context>
|
||||
</context-group>
|
||||
<target state="translated">vor Jahren</target>
|
||||
<target state="needs-translation">years ago</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="5555905617885007774" datatype="html">
|
||||
<source>days from now</source>
|
||||
@@ -5083,7 +5083,7 @@
|
||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts</context>
|
||||
<context context-type="linenumber">334</context>
|
||||
</context-group>
|
||||
<target state="translated">Tage von heute an</target>
|
||||
<target state="needs-translation">days from now</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="4298377021616940216" datatype="html">
|
||||
<source>weeks from now</source>
|
||||
@@ -5091,7 +5091,7 @@
|
||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts</context>
|
||||
<context context-type="linenumber">335</context>
|
||||
</context-group>
|
||||
<target state="translated">Wochen von heute an</target>
|
||||
<target state="needs-translation">weeks from now</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="2772874767134148305" datatype="html">
|
||||
<source>months from now</source>
|
||||
@@ -5099,7 +5099,7 @@
|
||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts</context>
|
||||
<context context-type="linenumber">336</context>
|
||||
</context-group>
|
||||
<target state="translated">Monate von heute an</target>
|
||||
<target state="needs-translation">months from now</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="6363533917780225967" datatype="html">
|
||||
<source>years from now</source>
|
||||
@@ -5107,7 +5107,7 @@
|
||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts</context>
|
||||
<context context-type="linenumber">337</context>
|
||||
</context-group>
|
||||
<target state="translated">Jahr von heute an</target>
|
||||
<target state="needs-translation">years from now</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="6630533861307510614" datatype="html">
|
||||
<source>Clear created relative date</source>
|
||||
@@ -11396,22 +11396,22 @@
|
||||
</context-group>
|
||||
<target state="final">Nach <x id="INTERPOLATION" equiv-text="{{getDisplayCustomFieldTitle(field_id)}}"/> sortieren</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html" approved="yes">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="final">Dokumentenvorschau</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="2179847500064178686" datatype="html" approved="yes">
|
||||
<source>Edit document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">331</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="final">Dokument bearbeiten</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html" approved="yes">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">329</context>
|
||||
</context-group>
|
||||
<target state="final">Dokumentenvorschau</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="4512084577073831437" datatype="html" approved="yes">
|
||||
<source>Reset filters / selection</source>
|
||||
<context-group purpose="location">
|
||||
|
||||
@@ -11397,22 +11397,22 @@
|
||||
</context-group>
|
||||
<target state="needs-translation">Sort by <x id="INTERPOLATION" equiv-text="{{getDisplayCustomFieldTitle(field_id)}}"/></target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="needs-translation">Preview document</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="2179847500064178686" datatype="html">
|
||||
<source>Edit document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">331</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="translated">Επεξεργασία εγγράφου</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">329</context>
|
||||
</context-group>
|
||||
<target state="needs-translation">Preview document</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="4512084577073831437" datatype="html">
|
||||
<source>Reset filters / selection</source>
|
||||
<context-group purpose="location">
|
||||
|
||||
@@ -11397,22 +11397,22 @@
|
||||
</context-group>
|
||||
<target state="translated">Ordenar por <x id="INTERPOLATION" equiv-text="{{getDisplayCustomFieldTitle(field_id)}}"/></target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="translated">Previsualizar documento</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="2179847500064178686" datatype="html">
|
||||
<source>Edit document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">331</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="translated">Editar documento</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">329</context>
|
||||
</context-group>
|
||||
<target state="translated">Previsualizar documento</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="4512084577073831437" datatype="html">
|
||||
<source>Reset filters / selection</source>
|
||||
<context-group purpose="location">
|
||||
|
||||
@@ -11397,22 +11397,22 @@
|
||||
</context-group>
|
||||
<target state="needs-translation">Sort by <x id="INTERPOLATION" equiv-text="{{getDisplayCustomFieldTitle(field_id)}}"/></target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="needs-translation">Preview document</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="2179847500064178686" datatype="html">
|
||||
<source>Edit document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">331</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="needs-translation">Edit document</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">329</context>
|
||||
</context-group>
|
||||
<target state="needs-translation">Preview document</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="4512084577073831437" datatype="html">
|
||||
<source>Reset filters / selection</source>
|
||||
<context-group purpose="location">
|
||||
|
||||
@@ -11397,22 +11397,22 @@
|
||||
</context-group>
|
||||
<target state="needs-translation">Sort by <x id="INTERPOLATION" equiv-text="{{getDisplayCustomFieldTitle(field_id)}}"/></target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="translated">سند پیش نمایش</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="2179847500064178686" datatype="html">
|
||||
<source>Edit document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">331</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="translated">سند ویرایش</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">329</context>
|
||||
</context-group>
|
||||
<target state="translated">سند پیش نمایش</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="4512084577073831437" datatype="html">
|
||||
<source>Reset filters / selection</source>
|
||||
<context-group purpose="location">
|
||||
|
||||
@@ -11397,22 +11397,22 @@
|
||||
</context-group>
|
||||
<target state="needs-translation">Sort by <x id="INTERPOLATION" equiv-text="{{getDisplayCustomFieldTitle(field_id)}}"/></target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="translated">Esikatsele asiakirja</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="2179847500064178686" datatype="html">
|
||||
<source>Edit document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">331</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="translated">Muokkaa asiakirjaa</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">329</context>
|
||||
</context-group>
|
||||
<target state="translated">Esikatsele asiakirja</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="4512084577073831437" datatype="html">
|
||||
<source>Reset filters / selection</source>
|
||||
<context-group purpose="location">
|
||||
|
||||
@@ -3719,7 +3719,7 @@
|
||||
<context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context>
|
||||
<context context-type="linenumber">163</context>
|
||||
</context-group>
|
||||
<target state="translated">Erreur lors de la suppression de l’utilisateur « <x id="PH" equiv-text="user.username"/> ».</target>
|
||||
<target state="translated">Erreur lors de la supression de l’utilisateur « <x id="PH" equiv-text="user.username"/> ».</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="5766640174051730159" datatype="html" approved="yes">
|
||||
<source>Saved group "<x id="PH" equiv-text="newGroup.name"/>".</source>
|
||||
@@ -3767,7 +3767,7 @@
|
||||
<context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context>
|
||||
<context context-type="linenumber">220</context>
|
||||
</context-group>
|
||||
<target state="translated">Erreur lors de la suppression du groupe « <x id="PH" equiv-text="group.name"/> ».</target>
|
||||
<target state="translated">Erreur lors de la supression du groupe « <x id="PH" equiv-text="group.name"/> ».</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="7931334600001636863" datatype="html" approved="yes">
|
||||
<source>by Paperless-ngx</source>
|
||||
@@ -6935,7 +6935,7 @@
|
||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
|
||||
<context context-type="linenumber">410</context>
|
||||
</context-group>
|
||||
<target state="translated">Destinataires du courriel</target>
|
||||
<target state="translated">Destinatires du courriel</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="7916910101279824329" datatype="html">
|
||||
<source>Attach document</source>
|
||||
@@ -10930,7 +10930,7 @@
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
|
||||
<context context-type="linenumber">8,9</context>
|
||||
</context-group>
|
||||
<target state="translated"><x id="INTERPOLATION" equiv-text="{{ document().title | documentTitle }}"/> miniature</target>
|
||||
<target state="translated"><x id="INTERPOLATION" equiv-text="{{ document().title | documentTitle }}"/> mignature</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="2784168796433474565" datatype="html" approved="yes">
|
||||
<source>Filter by tag</source>
|
||||
@@ -11396,22 +11396,22 @@
|
||||
</context-group>
|
||||
<target state="translated">Trier par <x id="INTERPOLATION" equiv-text="{{getDisplayCustomFieldTitle(field_id)}}"/></target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="translated">Prévisualiser le document</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="2179847500064178686" datatype="html" approved="yes">
|
||||
<source>Edit document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">331</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="final">Modifier le document</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">329</context>
|
||||
</context-group>
|
||||
<target state="translated">Prévisualiser le document</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="4512084577073831437" datatype="html">
|
||||
<source>Reset filters / selection</source>
|
||||
<context-group purpose="location">
|
||||
|
||||
@@ -11396,22 +11396,22 @@
|
||||
</context-group>
|
||||
<target state="translated">מיון לפי <x id="INTERPOLATION" equiv-text="{{getDisplayCustomFieldTitle(field_id)}}"/></target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="translated">תצוגה מקדימה של המסמך</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="2179847500064178686" datatype="html">
|
||||
<source>Edit document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">331</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="translated">עריכת מסמך</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">329</context>
|
||||
</context-group>
|
||||
<target state="translated">תצוגה מקדימה של המסמך</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="4512084577073831437" datatype="html">
|
||||
<source>Reset filters / selection</source>
|
||||
<context-group purpose="location">
|
||||
|
||||
@@ -11397,22 +11397,22 @@
|
||||
</context-group>
|
||||
<target state="needs-translation">Sort by <x id="INTERPOLATION" equiv-text="{{getDisplayCustomFieldTitle(field_id)}}"/></target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="needs-translation">Preview document</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="2179847500064178686" datatype="html">
|
||||
<source>Edit document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">331</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="needs-translation">Edit document</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">329</context>
|
||||
</context-group>
|
||||
<target state="needs-translation">Preview document</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="4512084577073831437" datatype="html">
|
||||
<source>Reset filters / selection</source>
|
||||
<context-group purpose="location">
|
||||
|
||||
@@ -11397,22 +11397,22 @@
|
||||
</context-group>
|
||||
<target state="translated">Sortiraj po <x id="INTERPOLATION" equiv-text="{{getDisplayCustomFieldTitle(field_id)}}"/></target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="translated">Pregledaj dokument</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="2179847500064178686" datatype="html">
|
||||
<source>Edit document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">331</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="translated">Uredi dokument</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">329</context>
|
||||
</context-group>
|
||||
<target state="translated">Pregledaj dokument</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="4512084577073831437" datatype="html">
|
||||
<source>Reset filters / selection</source>
|
||||
<context-group purpose="location">
|
||||
|
||||
@@ -11397,22 +11397,22 @@
|
||||
</context-group>
|
||||
<target state="translated">Rendezés <x id="INTERPOLATION" equiv-text="{{getDisplayCustomFieldTitle(field_id)}}"/> szerint</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="translated">Dokumentum előnézete</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="2179847500064178686" datatype="html">
|
||||
<source>Edit document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">331</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="translated">Dokumentum szerkesztése</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">329</context>
|
||||
</context-group>
|
||||
<target state="translated">Dokumentum előnézete</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="4512084577073831437" datatype="html">
|
||||
<source>Reset filters / selection</source>
|
||||
<context-group purpose="location">
|
||||
|
||||
@@ -11396,22 +11396,22 @@
|
||||
</context-group>
|
||||
<target state="translated">Urutkan berdasarkan <x id="INTERPOLATION" equiv-text="{{getDisplayCustomFieldTitle(field_id)}}"/></target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="translated">Pratinjau dokumen</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="2179847500064178686" datatype="html">
|
||||
<source>Edit document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">331</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="translated">Edit dokumen</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">329</context>
|
||||
</context-group>
|
||||
<target state="translated">Pratinjau dokumen</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="4512084577073831437" datatype="html">
|
||||
<source>Reset filters / selection</source>
|
||||
<context-group purpose="location">
|
||||
|
||||
@@ -11397,22 +11397,22 @@
|
||||
</context-group>
|
||||
<target state="translated">Ordina per <x id="INTERPOLATION" equiv-text="{{getDisplayCustomFieldTitle(field_id)}}"/></target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="translated">Anteprima documento</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="2179847500064178686" datatype="html">
|
||||
<source>Edit document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">331</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="translated">Modifica documento</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">329</context>
|
||||
</context-group>
|
||||
<target state="translated">Anteprima documento</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="4512084577073831437" datatype="html">
|
||||
<source>Reset filters / selection</source>
|
||||
<context-group purpose="location">
|
||||
|
||||
@@ -11396,22 +11396,22 @@
|
||||
</context-group>
|
||||
<target state="translated"><x id="INTERPOLATION" equiv-text="{{getDisplayCustomFieldTitle(field_id)}}"/> でソート</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="translated">ドキュメントのプレビュー</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="2179847500064178686" datatype="html">
|
||||
<source>Edit document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">331</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="translated">ドキュメントを編集</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">329</context>
|
||||
</context-group>
|
||||
<target state="translated">ドキュメントのプレビュー</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="4512084577073831437" datatype="html">
|
||||
<source>Reset filters / selection</source>
|
||||
<context-group purpose="location">
|
||||
|
||||
@@ -11397,22 +11397,22 @@
|
||||
</context-group>
|
||||
<target state="needs-translation">Sort by <x id="INTERPOLATION" equiv-text="{{getDisplayCustomFieldTitle(field_id)}}"/></target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="translated">문서 미리보기</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="2179847500064178686" datatype="html">
|
||||
<source>Edit document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">331</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="translated">문서 수정</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">329</context>
|
||||
</context-group>
|
||||
<target state="translated">문서 미리보기</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="4512084577073831437" datatype="html">
|
||||
<source>Reset filters / selection</source>
|
||||
<context-group purpose="location">
|
||||
|
||||
@@ -11397,22 +11397,22 @@
|
||||
</context-group>
|
||||
<target state="needs-translation">Sort by <x id="INTERPOLATION" equiv-text="{{getDisplayCustomFieldTitle(field_id)}}"/></target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="needs-translation">Preview document</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="2179847500064178686" datatype="html">
|
||||
<source>Edit document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">331</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="translated">Dokument editéieren</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">329</context>
|
||||
</context-group>
|
||||
<target state="needs-translation">Preview document</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="4512084577073831437" datatype="html">
|
||||
<source>Reset filters / selection</source>
|
||||
<context-group purpose="location">
|
||||
|
||||
@@ -11397,22 +11397,22 @@
|
||||
</context-group>
|
||||
<target state="needs-translation">Sort by <x id="INTERPOLATION" equiv-text="{{getDisplayCustomFieldTitle(field_id)}}"/></target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="needs-translation">Preview document</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="2179847500064178686" datatype="html">
|
||||
<source>Edit document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">331</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="needs-translation">Edit document</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">329</context>
|
||||
</context-group>
|
||||
<target state="needs-translation">Preview document</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="4512084577073831437" datatype="html">
|
||||
<source>Reset filters / selection</source>
|
||||
<context-group purpose="location">
|
||||
|
||||
@@ -11397,22 +11397,22 @@
|
||||
</context-group>
|
||||
<target state="needs-translation">Sort by <x id="INTERPOLATION" equiv-text="{{getDisplayCustomFieldTitle(field_id)}}"/></target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="needs-translation">Preview document</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="2179847500064178686" datatype="html">
|
||||
<source>Edit document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">331</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
<target state="translated">Rediģēt dokumentu</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">329</context>
|
||||
</context-group>
|
||||
<target state="needs-translation">Preview document</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="4512084577073831437" datatype="html">
|
||||
<source>Reset filters / selection</source>
|
||||
<context-group purpose="location">
|
||||
|
||||
+1504
-1503
File diff suppressed because it is too large.
Load diff
Loaded 100 of 222 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user