Compare commits

..
Author SHA1 Message Date
shamoon 87b2178dae Merge branch 'dev' into feature/ocr-templates 2026-07-10 00:47:08 -07:00
shamoon f24ed315d2 Use SVG, get rid of all the manual redraw stuff 2026-06-29 21:10:27 -07:00
shamoon 665a724221 Use a real canvas interaction model
[skip ci]
2026-06-28 12:55:13 -07:00
shamoon 7a4cddebbe Fix some permissions stuff, show error on toggle 2026-06-28 12:37:11 -07:00
shamoon 1b7c0af22e Get rid of absurd alert 2026-06-28 12:36:54 -07:00
shamoon 20a855444b Proper data types
[skip ci]
2026-06-28 12:33:18 -07:00
shamoon c3a3939387 Pull out the zone list into a sub-component 2026-06-27 22:45:13 -07:00
shamoon a1fad8309f Extract the geometry stuff a bit 2026-06-27 22:29:41 -07:00
shamoon 613b528b7e Remove afterviewinit, fix page change, custom date format per zone 2026-06-27 22:19:01 -07:00
Christoph Schlaepfershamoondependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>stumpylogGitHub Actions <41898282+github-actions[bot]@users.noreply.github.com>
bf73b5b1d1 Feature: OCR Templates (#13043)
[skip ci]

Signed-off-by: dependabot[bot] <support@github.com>
Co-Authored-By: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-Authored-By: stumpylog <797416+stumpylog@users.noreply.github.com>
Co-Authored-By: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-Authored-By: shamoon <4887959+shamoon@users.noreply.github.com>
2026-06-23 07:32:58 -07:00
249 changed files with 68024 additions and 102273 deletions
-1
View File
@@ -26,7 +26,6 @@ jobs:
check-name: 'Merge and Push Manifest'
repo-token: ${{ secrets.GITHUB_TOKEN }}
wait-interval: 60
checks-discovery-timeout: 1800
build-release:
name: Build Release
needs: wait-for-docker
+4 -4
View File
@@ -38,7 +38,7 @@ repos:
- json
# See https://github.com/prettier/prettier/issues/15742 for the fork reason
- repo: https://github.com/rbubley/mirrors-prettier
rev: 'v3.9.4'
rev: 'v3.8.4'
hooks:
- id: prettier
types_or:
@@ -46,16 +46,16 @@ repos:
- ts
- markdown
additional_dependencies:
- prettier@3.9.4
- prettier@3.8.3
- 'prettier-plugin-organize-imports@4.3.0'
# Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.20
rev: v0.15.17
hooks:
- id: ruff-check
- id: ruff-format
- repo: https://github.com/tox-dev/pyproject-fmt
rev: "v2.25.1"
rev: "v2.24.1"
hooks:
- id: pyproject-fmt
additional_dependencies: [tomli]
+1 -5
View File
@@ -30,7 +30,7 @@ RUN set -eux \
# Purpose: Installs s6-overlay and rootfs
# Comments:
# - Don't leave anything extra in here either
FROM ghcr.io/astral-sh/uv:0.11.28-python3.12-trixie-slim AS s6-overlay-base
FROM ghcr.io/astral-sh/uv:0.11.19-python3.12-trixie-slim AS s6-overlay-base
WORKDIR /usr/src/s6
@@ -238,10 +238,6 @@ RUN set -eux \
&& chown --from root:root --changes --recursive paperless:paperless /usr/src/paperless \
&& echo "Making fontconfig cache writable for arbitrary container UIDs" \
&& chmod 1777 /var/cache/fontconfig \
&& echo "Making /run world-writable for rootless operation" \
&& chmod 1777 /run \
&& echo "Removing setuid from s6-overlay-suexec for rootless compat" \
&& chmod u-s /command/s6-overlay-suexec \
&& echo "Collecting static files" \
&& PAPERLESS_SECRET_KEY=build-time-dummy s6-setuidgid paperless python3 manage.py collectstatic --clear --no-input --link \
&& PAPERLESS_SECRET_KEY=build-time-dummy s6-setuidgid paperless python3 manage.py compilemessages \
+3 -3
View File
@@ -4,7 +4,7 @@
# correct networking for the tests
services:
gotenberg:
image: docker.io/gotenberg/gotenberg:8.34
image: docker.io/gotenberg/gotenberg:8.33
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.9
image: docker.io/greenmail/standalone:2.1.8
hostname: greenmail
container_name: greenmail
environment:
@@ -35,7 +35,7 @@ services:
- "3143:3143" # IMAP
restart: unless-stopped
nginx:
image: docker.io/nginx:1.31.2-alpine
image: docker.io/nginx:1.31.1-alpine
hostname: nginx
container_name: nginx
ports:
@@ -72,7 +72,7 @@ services:
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg:
image: docker.io/gotenberg/gotenberg:8.34
image: docker.io/gotenberg/gotenberg:8.33
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.34
image: docker.io/gotenberg/gotenberg:8.33
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.34
image: docker.io/gotenberg/gotenberg:8.33
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.
-143
View File
File diff suppressed because it is too large Load Diff
+9 -11
View File
@@ -1159,21 +1159,19 @@ still perform some basic text pre-processing before matching.
#### [`PAPERLESS_DATE_PARSER_LANGUAGES=<lang>`](#PAPERLESS_DATE_PARSER_LANGUAGES) {#PAPERLESS_DATE_PARSER_LANGUAGES}
: Specifies which language Paperless should use when parsing dates from documents.
Specifies which language Paperless should use when parsing dates from documents.
: This should be a language code supported by the dateparser library,
for example: "en", or a combination such as "en+de".
Locales are also supported (e.g., "en-AU").
Multiple languages can be combined using "+", for example: "en+de" or "en-AU+de".
For valid values, refer to the list of supported languages and locales in the [dateparser documentation](https://dateparser.readthedocs.io/en/latest/supported_locales.html).
: Set this to match the languages in which most of your documents are written.
This should be a language code supported by the dateparser library,
for example: "en", or a combination such as "en+de".
Locales are also supported (e.g., "en-AU").
Multiple languages can be combined using "+", for example: "en+de" or "en-AU+de".
For valid values, refer to the list of supported languages and locales in the [dateparser documentation](https://dateparser.readthedocs.io/en/latest/supported_locales.html).
Set this to match the languages in which most of your documents are written.
If not set, Paperless will attempt to infer the language(s) from the OCR configuration (`PAPERLESS_OCR_LANGUAGE`).
!!! note
This format differs from the `PAPERLESS_OCR_LANGUAGE` setting, which uses ISO 639-2 codes (3 letters, e.g., "eng+deu" for Tesseract OCR).
!!! note
This format differs from the `PAPERLESS_OCR_LANGUAGE` setting, which uses ISO 639-2 codes (3 letters, e.g., "eng+deu" for Tesseract OCR).
#### [`PAPERLESS_EMAIL_TASK_CRON=<cron expression>`](#PAPERLESS_EMAIL_TASK_CRON) {#PAPERLESS_EMAIL_TASK_CRON}
-8
View File
@@ -326,11 +326,3 @@ behind a reverse proxy may need to set
[`PAPERLESS_TRUSTED_PROXIES`](configuration.md#PAPERLESS_TRUSTED_PROXIES),
[`PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER`](configuration.md#PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER),
or both, to avoid `403 Forbidden` errors on login.
## Database Migrations
Some integer fields have been changed to smaller types to reduce database size. If you have any `MailRule` records with a `maximum_age` greater than 32767, they will be clamped to 32767 during the migration to avoid errors during migration.
### Action Required
No user action is required. The migration will automatically clamp any `MailRule.maximum_age` values greater than 32767 to 32767 during the migration process.
+1 -1
View File
@@ -142,7 +142,7 @@ a [superuser](usage.md#superusers) account.
It is not possible to run the container rootless if additional languages are specified via `PAPERLESS_OCR_LANGUAGES`.
If you want to run Paperless as a rootless container, set `user:` in `docker-compose.yml` to the UID and GID of your host user (use `id -u` and `id -g` to find these values). The container process starts directly as that user with no internal privilege remapping. Any UID and GID is supported:
If you want to run Paperless as a rootless container, set `user:` in `docker-compose.yml` to the UID and GID of your host user (use `id -u` and `id -g` to find these values). The container process starts directly as that user with no internal privilege remapping:
```yaml
webserver:
+2 -2
View File
@@ -458,8 +458,8 @@ For related metadata such as tags, correspondents, document types, and storage p
### Password reset
In order to enable the password reset feature you will need to setup an SMTP backend, see
[`PAPERLESS_EMAIL_HOST`](configuration.md#PAPERLESS_EMAIL_HOST). You should also set
[`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) and / or its corresponding configuration settings.
[`PAPERLESS_EMAIL_HOST`](configuration.md#PAPERLESS_EMAIL_HOST). If your installation does not have
[`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) set, the reset link included in emails will use the server host.
### Two-factor authentication
+4 -4
View File
@@ -16,7 +16,7 @@ classifiers = [
dependencies = [
"azure-ai-documentintelligence>=1.0.2",
"babel>=2.17",
"bleach~=6.4.0",
"bleach~=6.3.0",
"celery[redis]~=5.6.2",
"channels~=4.2",
"channels-redis~=4.2",
@@ -56,7 +56,7 @@ dependencies = [
"llama-index-embeddings-openai-like>=0.2.2",
"llama-index-llms-ollama>=0.9.1",
"llama-index-llms-openai-like>=0.7.1",
"nltk~=3.10.0",
"nltk~=3.9.1",
"ocrmypdf~=17.4.2",
"openai>=2.32",
"pathvalidate~=3.3.1",
@@ -101,11 +101,11 @@ dev = [
{ include-group = "testing" },
]
docs = [
"zensical>=0.0.47",
"zensical>=0.0.43",
]
lint = [
"prek~=0.3.10",
"ruff~=0.15.20",
"ruff~=0.15.15",
]
testing = [
"daphne",
-10
View File
@@ -79,16 +79,6 @@
"glob": "{pdf.worker.min.mjs,pdf.min.mjs}",
"input": "node_modules/pdfjs-dist/legacy/build/",
"output": "/assets/js/"
},
{
"glob": "**/*",
"input": "node_modules/pdfjs-dist/wasm/",
"output": "/assets/wasm/"
},
{
"glob": "**/*",
"input": "node_modules/pdfjs-dist/iccs/",
"output": "/assets/iccs/"
}
],
"styles": [
+321 -373
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -18,6 +18,7 @@
"@angular/forms": "~22.0.5",
"@angular/localize": "~22.0.5",
"@angular/platform-browser": "~22.0.5",
"@angular/platform-browser-dynamic": "~22.0.5",
"@angular/router": "~22.0.5",
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
"@ng-select/ng-select": "^23.2.0",
+1846 -1297
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -13,6 +13,8 @@ import { DocumentDetailComponent } from './components/document-detail/document-d
import { DocumentListComponent } from './components/document-list/document-list.component'
import { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.component'
import { MailComponent } from './components/manage/mail/mail.component'
import { OcrTemplateEditorComponent } from './components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component'
import { OcrTemplatesComponent } from './components/manage/ocr-templates/ocr-templates.component'
import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component'
import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
import { NotFoundComponent } from './components/not-found/not-found.component'
@@ -274,6 +276,42 @@ export const routes: Routes = [
componentName: 'WorkflowsComponent',
},
},
{
path: 'ocr-templates',
component: OcrTemplatesComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.OcrTemplate,
},
componentName: 'OcrTemplatesComponent',
},
},
{
path: 'ocr-templates/new',
component: OcrTemplateEditorComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.Add,
type: PermissionType.OcrTemplate,
},
componentName: 'OcrTemplateEditorComponent',
},
},
{
path: 'ocr-templates/:id',
component: OcrTemplateEditorComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.Change,
type: PermissionType.OcrTemplate,
},
componentName: 'OcrTemplateEditorComponent',
},
},
{
path: 'mail',
component: MailComponent,
@@ -5,9 +5,8 @@
i18n-info>
<div class="input-group input-group-sm align-items-center">
<div class="input-group input-group-sm me-3">
<label class="input-group-text text-muted" for="logLineLimit" i18n>Show</label>
<span class="input-group-text text-muted" i18n>Show</span>
<input
id="logLineLimit"
class="form-control"
type="number"
min="100"
@@ -18,7 +17,7 @@
<span class="input-group-text text-muted" i18n>lines</span>
</div>
<div class="form-check form-switch mt-1">
<input id="autoRefreshSwitch" class="form-check-input" type="checkbox" role="switch" [ngModel]="autoRefreshEnabled()" (ngModelChange)="autoRefreshEnabled.set($event)">
<input class="form-check-input" type="checkbox" role="switch" [ngModel]="autoRefreshEnabled()" (ngModelChange)="autoRefreshEnabled.set($event)">
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
</div>
</div>
@@ -100,7 +100,9 @@ export class LogsComponent
parsed.some((log, idx) => {
const current = this.logs()[idx]
return (
current?.message !== log.message || current?.level !== log.level
!current ||
current.message !== log.message ||
current.level !== log.level
)
})
if (hasChanges) {
@@ -37,7 +37,7 @@
<form [formGroup]="settingsForm" (ngSubmit)="saveSettings()">
<ul ngbNav #nav="ngbNav" (navChange)="onNavChange($event)" [activeId]="activeNavID()" (activeIdChange)="activeNavID.set($event)" class="nav-tabs">
<ul ngbNav #nav="ngbNav" (navChange)="onNavChange($event)" [(activeId)]="activeNavID" class="nav-tabs">
<li [ngbNavItem]="SettingsNavIDs.General">
<a ngbNavLink i18n>General</a>
<ng-template ngbNavContent>
@@ -150,10 +150,10 @@
<div class="row mb-3">
<div class="col-md-3 col-form-label pt-0">
<label for="searchLink" i18n>Full search links to</label>
<span i18n>Full search links to</span>
</div>
<div class="col mb-3">
<select id="searchLink" class="form-select" formControlName="searchLink">
<select class="form-select" formControlName="searchLink">
<option [ngValue]="GlobalSearchType.TITLE_CONTENT" i18n>Title and content search</option>
<option [ngValue]="GlobalSearchType.ADVANCED" i18n>Advanced search</option>
</select>
@@ -199,10 +199,10 @@
<h5 i18n>Documents</h5>
<div class="row mb-3">
<div class="col-md-3 col-form-label pt-0">
<label for="documentListItemPerPage" i18n>Items per page</label>
<span i18n>Items per page</span>
</div>
<div class="col">
<select id="documentListItemPerPage" class="form-select" formControlName="documentListItemPerPage">
<select class="form-select" formControlName="documentListItemPerPage">
<option [ngValue]="10">10</option>
<option [ngValue]="25">25</option>
<option [ngValue]="50">50</option>
@@ -274,10 +274,10 @@
<h5 class="mt-3" i18n>PDF Editor</h5>
<div class="row">
<div class="col-md-3 col-form-label pt-0">
<label for="pdfEditorDefaultEditMode" i18n>Default editing mode</label>
<span i18n>Default editing mode</span>
</div>
<div class="col">
<select id="pdfEditorDefaultEditMode" class="form-select" formControlName="pdfEditorDefaultEditMode">
<select class="form-select" formControlName="pdfEditorDefaultEditMode">
<option [ngValue]="PdfEditorEditMode.Create" i18n>Create new document(s)</option>
<option [ngValue]="PdfEditorEditMode.Update" i18n>Add document version</option>
</select>
@@ -242,7 +242,7 @@ describe('SettingsComponent', () => {
activatedRoute.snapshot.fragment = '#notifications'
const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor')
component.ngOnInit()
expect(component.activeNavID()).toEqual(4) // Notifications
expect(component.activeNavID).toEqual(4) // Notifications
component.ngAfterViewInit()
expect(scrollSpy).toHaveBeenCalledWith('#notifications')
})
@@ -399,17 +399,17 @@ describe('SettingsComponent', () => {
completeSetup()
const field = 'storage_path'
expect(
component.settingsForm.get('documentDetailsHiddenFields').value
).toHaveLength(0)
component.settingsForm.get('documentDetailsHiddenFields').value.length
).toEqual(0)
component.toggleDocumentDetailField(field, false)
expect(
component.settingsForm.get('documentDetailsHiddenFields').value
).toHaveLength(1)
component.settingsForm.get('documentDetailsHiddenFields').value.length
).toEqual(1)
expect(component.isDocumentDetailFieldShown(field)).toBeFalsy()
component.toggleDocumentDetailField(field, true)
expect(
component.settingsForm.get('documentDetailsHiddenFields').value
).toHaveLength(0)
component.settingsForm.get('documentDetailsHiddenFields').value.length
).toEqual(0)
expect(component.isDocumentDetailFieldShown(field)).toBeTruthy()
})
})
@@ -142,7 +142,7 @@ export class SettingsComponent
private systemStatusService = inject(SystemStatusService)
private savedViewsService = inject(SavedViewService)
readonly activeNavID = signal<number>(undefined)
activeNavID: number
settingsForm = new FormGroup({
bulkEditConfirmationDialogs: new FormControl(null),
@@ -283,7 +283,7 @@ export class SettingsComponent
(navID) => navID.toLowerCase() == section
)
if (navIDKey) {
this.activeNavID.set(SettingsNavIDs[navIDKey])
this.activeNavID = SettingsNavIDs[navIDKey]
}
}
})
@@ -386,7 +386,7 @@ export class SettingsComponent
.navigate(['settings', foundNavIDkey.toLowerCase()])
.then((navigated) => {
if (!navigated && this.isDirty) {
this.activeNavID.set(navChangeEvent.activeId)
this.activeNavID = navChangeEvent.activeId
} else if (navigated && this.isDirty) {
this.initialize()
}
@@ -15,7 +15,7 @@
<i-bs name="check2-all" class="me-1"></i-bs><ng-container i18n>Dismiss all</ng-container>
</button>
<div class="form-check form-switch mb-0 ms-2">
<input id="autoRefreshSwitch" class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled">
<input class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled">
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
</div>
</div>
@@ -93,8 +93,7 @@
<i-bs width="1em" height="1em" name="x"></i-bs>
</button>
}
<label class="visually-hidden" for="taskSearch" i18n>Search tasks</label>
<input #filterInput id="taskSearch" class="form-control form-control-sm" type="text"
<input #filterInput class="form-control form-control-sm" type="text"
(keyup)="filterInputKeyup($event)"
[(ngModel)]="filterText">
</div>
@@ -212,7 +211,7 @@
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
</button>
</td>
<td class="actions-col">
<td scope="row" class="actions-col">
<div class="btn-group" role="group">
<button class="btn btn-sm btn-outline-secondary" (click)="dismissTask(task); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }">
<i-bs name="check" class="me-1"></i-bs><ng-container i18n>Dismiss</ng-container>
@@ -150,8 +150,7 @@
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
popoverClass="popover-slim">
<i-bs class="me-2" name="file-text"></i-bs><span>{{d.title | documentTitle}}</span>
<span class="close flex-column justify-content-center"
(click)="$event.preventDefault(); $event.stopPropagation(); closeDocument(d)">
<span class="close flex-column justify-content-center" (click)="closeDocument(d); $event.preventDefault()">
<i-bs name="x"></i-bs>
</span>
</a>
@@ -244,6 +243,14 @@
<i-bs class="me-2" name="boxes"></i-bs><span><ng-container i18n>Workflows</ng-container></span>
</a>
</li>
<li class="nav-item app-link"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.OcrTemplate }">
<a class="nav-link" routerLink="ocr-templates" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="OCR Templates" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="file-earmark-break"></i-bs><span><ng-container i18n>OCR Templates</ng-container></span>
</a>
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
tourAnchor="tour.mail">
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
@@ -6,7 +6,7 @@
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
<div class="chat-container bg-light p-2">
<div class="chat-messages font-monospace small">
@for (message of messages(); track message) {
@for (message of messages; track message) {
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'">
<div class="p-2 m-2" [class.bg-body]="message.role === 'user'">
<span>
@@ -29,19 +29,16 @@
</div>
<form class="chat-input">
<label class="visually-hidden" for="chatInput" i18n>Message</label>
<div class="input-group">
<input
#chatInput
id="chatInput"
class="form-control form-control-sm" name="chatInput" type="text"
[placeholder]="placeholder"
[disabled]="loading()"
[ngModel]="input()"
(ngModelChange)="input.set($event)"
[disabled]="loading"
[(ngModel)]="input"
(keydown)="searchInputKeyDown($event)"
/>
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading()">Send</button>
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading">Send</button>
</div>
</form>
</div>
@@ -56,53 +56,44 @@ describe('ChatComponent', () => {
it('should update documentId on initialization', () => {
jest.spyOn(router, 'url', 'get').mockReturnValue('/documents/123')
component.ngOnInit()
expect(component.documentId()).toBe(123)
expect(component.documentId).toBe(123)
})
it('should update documentId on navigation', () => {
component.ngOnInit()
routerEvents$.next(new NavigationEnd(1, '/documents/456', '/documents/456'))
expect(component.documentId()).toBe(456)
expect(component.documentId).toBe(456)
})
it('should return correct placeholder based on documentId', () => {
component.documentId.set(123)
component.documentId = 123
expect(component.placeholder).toBe('Ask a question about this document...')
component.documentId.set(undefined)
component.documentId = undefined
expect(component.placeholder).toBe('Ask a question about a document...')
})
it('should send a message and render the streaming response', async () => {
component.input.set('Hello')
it('should send a message and handle streaming response', () => {
component.input = 'Hello'
component.sendMessage()
expect(component.messages()).toHaveLength(2)
expect(component.messages()[0].content).toBe('Hello')
expect(component.loading()).toBe(true)
expect(component.messages.length).toBe(2)
expect(component.messages[0].content).toBe('Hello')
expect(component.loading).toBe(true)
mockStream$.next('Hi')
expect(component.messages()[1].content).toBe('H')
expect(component.messages[1].content).toBe('H')
mockStream$.next('Hi there')
// advance time to process the typewriter effect
await jest.runAllTimersAsync()
await fixture.whenStable()
expect(component.messages()[1].content).toBe('Hi there')
expect(
fixture.nativeElement.querySelector('.chat-messages').textContent
).toContain('Hi there')
jest.advanceTimersByTime(1000)
expect(component.messages[1].content).toBe('Hi there')
mockStream$.complete()
await jest.runAllTimersAsync()
await fixture.whenStable()
expect(component.loading()).toBe(false)
expect(component.messages()[1].isStreaming).toBe(false)
expect(fixture.nativeElement.querySelector('#chatInput').disabled).toBe(
false
)
expect(component.loading).toBe(false)
expect(component.messages[1].isStreaming).toBe(false)
})
it('should parse references from the metadata trailer without showing it', () => {
component.input.set('Hello')
component.input = 'Hello'
component.sendMessage()
mockStream$.next(
@@ -110,14 +101,14 @@ describe('ChatComponent', () => {
)
jest.advanceTimersByTime(1000)
expect(component.messages()[1].content).toBe('Hi there')
expect(component.messages()[1].references).toEqual([
expect(component.messages[1].content).toBe('Hi there')
expect(component.messages[1].references).toEqual([
{ id: 42, title: 'Bread Recipe' },
])
})
it('should render document reference links under assistant messages', () => {
component.input.set('Hello')
component.input = 'Hello'
component.sendMessage()
mockStream$.next(
@@ -132,12 +123,12 @@ describe('ChatComponent', () => {
})
it('should remove delimiter fragments that were already streamed', () => {
component.input.set('Hello')
component.input = 'Hello'
component.sendMessage()
mockStream$.next(`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`)
jest.advanceTimersByTime(1000)
expect(component.messages()[1].content).toBe(
expect(component.messages[1].content).toBe(
`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`
)
@@ -146,28 +137,28 @@ describe('ChatComponent', () => {
)
jest.advanceTimersByTime(1000)
expect(component.messages()[1].content).toBe('Hi there')
expect(component.messages()[1].references).toEqual([
expect(component.messages[1].content).toBe('Hi there')
expect(component.messages[1].references).toEqual([
{ id: 42, title: 'Bread Recipe' },
])
})
it('should handle errors during streaming', () => {
component.input.set('Hello')
component.input = 'Hello'
component.sendMessage()
mockStream$.error('Error')
expect(component.messages()[1].content).toContain(
expect(component.messages[1].content).toContain(
'⚠️ Error receiving response.'
)
expect(component.loading()).toBe(false)
expect(component.loading).toBe(false)
})
it('should enqueue typewriter chunks correctly', () => {
const message = { content: '', role: 'assistant', isStreaming: true }
component.enqueueTypewriter(null, message as any) // coverage for null
component.enqueueTypewriter('Hello', message as any)
expect(component['typewriterBuffer']).toHaveLength(4)
expect(component['typewriterBuffer'].length).toBe(4)
})
it('should scroll to bottom after sending a message', () => {
@@ -175,7 +166,7 @@ describe('ChatComponent', () => {
ChatComponent.prototype as any,
'scrollToBottom'
)
component.input.set('Test')
component.input = 'Test'
component.sendMessage()
expect(scrollSpy).toHaveBeenCalled()
})
@@ -1,11 +1,4 @@
import {
Component,
ElementRef,
inject,
OnInit,
signal,
ViewChild,
} from '@angular/core'
import { Component, ElementRef, inject, OnInit, ViewChild } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NavigationEnd, Router, RouterModule } from '@angular/router'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
@@ -30,10 +23,10 @@ import {
styleUrl: './chat.component.scss',
})
export class ChatComponent implements OnInit {
readonly messages = signal<ChatMessage[]>([])
readonly loading = signal(false)
readonly input = signal('')
readonly documentId = signal<number>(undefined)
public messages: ChatMessage[] = []
public loading = false
public input: string = ''
public documentId!: number
private chatService: ChatService = inject(ChatService)
private router: Router = inject(Router)
@@ -45,7 +38,7 @@ export class ChatComponent implements OnInit {
private typewriterActive = false
public get placeholder(): string {
return this.documentId()
return this.documentId
? $localize`Ask a question about this document...`
: $localize`Ask a question about a document...`
}
@@ -64,14 +57,14 @@ export class ChatComponent implements OnInit {
private updateDocumentId(url: string): void {
const docIdRe = url.match(/^\/documents\/(\d+)/)
this.documentId.set(docIdRe ? +docIdRe[1] : undefined)
this.documentId = docIdRe ? +docIdRe[1] : undefined
}
sendMessage(): void {
if (!this.input().trim()) return
if (!this.input.trim()) return
const userMessage: ChatMessage = { role: 'user', content: this.input() }
this.messages.update((messages) => [...messages, userMessage])
const userMessage: ChatMessage = { role: 'user', content: this.input }
this.messages.push(userMessage)
this.scrollToBottom()
const assistantMessage: ChatMessage = {
@@ -79,12 +72,12 @@ export class ChatComponent implements OnInit {
content: '',
isStreaming: true,
}
this.messages.update((messages) => [...messages, assistantMessage])
this.loading.set(true)
this.messages.push(assistantMessage)
this.loading = true
let lastVisibleContent = ''
this.chatService.streamChat(this.documentId(), this.input()).subscribe({
this.chatService.streamChat(this.documentId, this.input).subscribe({
next: (chunk) => {
const nextResponse = parseChatResponse(chunk)
@@ -100,30 +93,26 @@ export class ChatComponent implements OnInit {
}
assistantMessage.references = nextResponse.references
this.notifyMessagesChanged()
},
error: () => {
assistantMessage.content += '\n\n⚠️ Error receiving response.'
assistantMessage.isStreaming = false
this.notifyMessagesChanged()
this.loading.set(false)
this.loading = false
},
complete: () => {
assistantMessage.isStreaming = false
this.notifyMessagesChanged()
this.loading.set(false)
this.loading = false
this.scrollToBottom()
},
})
this.input.set('')
this.input = ''
}
private resetTypewriter(message: ChatMessage, content: string): void {
this.typewriterBuffer = []
this.typewriterActive = false
message.content = content
this.notifyMessagesChanged()
this.scrollToBottom()
}
@@ -146,16 +135,11 @@ export class ChatComponent implements OnInit {
const nextChar = this.typewriterBuffer.shift()
message.content += nextChar
this.notifyMessagesChanged()
this.scrollToBottom()
setTimeout(() => this.playTypewriter(message), 10) // 10ms per character
}
private notifyMessagesChanged(): void {
this.messages.update((messages) => [...messages])
}
private scrollToBottom(): void {
setTimeout(() => {
this.scrollAnchor?.nativeElement?.scrollIntoView({ behavior: 'smooth' })
@@ -1,12 +1,5 @@
import { CurrencyPipe, getLocaleCurrencyCode, SlicePipe } from '@angular/common'
import {
ChangeDetectorRef,
Component,
inject,
Input,
LOCALE_ID,
OnInit,
} from '@angular/core'
import { Component, inject, Input, LOCALE_ID, OnInit } from '@angular/core'
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
import { takeUntil } from 'rxjs'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
@@ -29,7 +22,6 @@ export class CustomFieldDisplayComponent
{
private customFieldService = inject(CustomFieldsService)
private documentService = inject(DocumentService)
private changeDetector = inject(ChangeDetectorRef)
CustomFieldDataType = CustomFieldDataType
@@ -82,7 +74,6 @@ export class CustomFieldDisplayComponent
this.customFieldService.listAll().subscribe((r) => {
this.customFields = r.results
this.init()
this.changeDetector.markForCheck()
})
}
@@ -120,7 +111,6 @@ export class CustomFieldDisplayComponent
this.docLinkDocuments = this.value
.map((id) => result.results.find((d) => d.id === id))
.filter((d) => d)
this.changeDetector.markForCheck()
})
}
@@ -95,8 +95,8 @@ describe('CustomFieldsDropdownComponent', () => {
it('should support update unused fields', () => {
component.existingFields = [{ field: fields[0].id } as any]
component['updateUnusedFields']()
expect(component['unusedFields']().length).toEqual(1)
expect(component['unusedFields']()[0].name).toEqual('Field 2')
expect(component['unusedFields'].length).toEqual(1)
expect(component['unusedFields'][0].name).toEqual('Field 2')
})
it('should support getting data type label', () => {
@@ -8,7 +8,6 @@ import {
ViewChild,
ViewChildren,
inject,
signal,
} from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
@@ -66,11 +65,11 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
@ViewChildren('button') buttons: QueryList<ElementRef>
private customFields: CustomField[] = []
private readonly unusedFields = signal<CustomField[]>([])
private unusedFields: CustomField[] = []
private keyboardIndex: number
public get filteredFields(): CustomField[] {
return this.unusedFields().filter(
return this.unusedFields.filter(
(f) => !this.filterText || matchesSearchText(f.name, this.filterText)
)
}
@@ -100,10 +99,8 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
}
private updateUnusedFields() {
this.unusedFields.set(
this.customFields.filter(
(f) => !this.existingFields?.find((e) => e.field === f.id)
)
this.unusedFields = this.customFields.filter(
(f) => !this.existingFields?.find((e) => e.field === f.id)
)
}
@@ -77,7 +77,7 @@
<div class="input-group input-group-sm">
<ng-select #fieldSelects
class="paperless-input-select"
[items]="customFields()"
[items]="customFields"
[(ngModel)]="atom.field"
[disabled]="disabled"
bindLabel="name"
@@ -79,7 +79,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
})
it('should initialize custom fields on creation', () => {
expect(component.customFields()).toEqual(customFields)
expect(component.customFields).toEqual(customFields)
})
it('should add an expression when opened if queries are empty', () => {
@@ -101,7 +101,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
data_type: CustomFieldDataType.String,
extra_data: {},
}
component.customFields.set([field])
component.customFields = [field]
const operators = component.getOperatorsForField(1)
expect(operators.length).toEqual(
[
@@ -138,7 +138,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
],
},
}
component.customFields.set([field])
component.customFields = [field]
const options = component.getSelectOptionsForField(1)
expect(options).toEqual([
{ label: 'Option 1', id: 'abc-123' },
@@ -6,7 +6,6 @@ import {
Input,
Output,
QueryList,
signal,
ViewChild,
ViewChildren,
} from '@angular/core'
@@ -279,7 +278,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
@Output()
selectionModelChange = new EventEmitter<CustomFieldQueriesModel>()
readonly customFields = signal<CustomField[]>([])
customFields: CustomField[] = []
public readonly today: string = new Date().toLocaleDateString('en-CA')
@@ -326,12 +325,12 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
.listAll()
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((result) => {
this.customFields.set(result.results)
this.customFields = result.results
})
}
public getCustomFieldByID(id: number): CustomField {
return this.customFields().find((field) => field.id === id)
return this.customFields.find((field) => field.id === id)
}
public addAtom(expression: CustomFieldQueryExpression) {
@@ -354,7 +353,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
getOperatorsForField(
fieldID: number
): Array<{ value: string; label: string }> {
const field = this.customFields().find((field) => field.id === fieldID)
const field = this.customFields.find((field) => field.id === fieldID)
const groups: CustomFieldQueryOperatorGroups[] = field
? CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE[field.data_type]
: [CustomFieldQueryOperatorGroups.Basic]
@@ -370,7 +369,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
getSelectOptionsForField(
fieldID: number
): Array<{ label: string; id: string }> {
const field = this.customFields().find((field) => field.id === fieldID)
const field = this.customFields.find((field) => field.id === fieldID)
if (field) {
return field.extra_data['select_options']
}
@@ -22,11 +22,11 @@
<ng-template>
<div class="card mb-2">
<div class="card-body p-2">
@if (testLoading()) {
@if (testLoading) {
<ng-container [ngTemplateOutlet]="loadingTemplate"></ng-container>
} @else if (testResult()) {
<code>{{testResult()}}</code>
} @else if (testFailed()) {
} @else if (testResult) {
<code>{{testResult}}</code>
} @else if (testFailed) {
<div class="text-danger" i18n>Path test failed</div>
} @else {
<div class="text-muted small" i18n>No document selected</div>
@@ -42,7 +42,7 @@
[compareWith]="compareDocuments"
[trackByFn]="trackByFn"
[minTermLength]="2"
[loading]="loading()"
[loading]="loading"
[typeahead]="documentsInput$"
(change)="testPath($event)">
<ng-template #loadingTemplate ng-loadingspinner-tmp>
@@ -58,17 +58,17 @@ describe('StoragePathEditDialogComponent', () => {
fixture.detectChanges()
component.testPath({ id: 1 })
expect(testSpy).toHaveBeenCalledWith('test/{{title}}', 1)
expect(component.testResult()).toBe('test/abc123')
expect(component.testFailed()).toBeFalsy()
expect(component.testResult).toBe('test/abc123')
expect(component.testFailed).toBeFalsy()
// test failed
testSpy.mockReturnValueOnce(of(''))
component.testPath({ id: 1 })
expect(component.testResult()).toBeNull()
expect(component.testFailed()).toBeTruthy()
expect(component.testResult).toBeNull()
expect(component.testFailed).toBeTruthy()
component.testPath(null)
expect(component.testResult()).toBeNull()
expect(component.testResult).toBeNull()
})
it('should compare two documents by id', () => {
@@ -1,5 +1,5 @@
import { AsyncPipe, NgTemplateOutlet } from '@angular/common'
import { Component, OnDestroy, inject, signal } from '@angular/core'
import { Component, OnDestroy, inject } from '@angular/core'
import {
FormControl,
FormGroup,
@@ -65,9 +65,9 @@ export class StoragePathEditDialogComponent
public documentsInput$ = new Subject<string>()
public foundDocuments$: Observable<Document[]>
private testDocument: Document
readonly testResult = signal<string>(undefined)
readonly testFailed = signal(false)
readonly testLoading = signal(false)
public testResult: string
public testFailed: boolean = false
public testLoading = false
constructor() {
super()
@@ -99,22 +99,22 @@ export class StoragePathEditDialogComponent
public testPath(document: Document) {
if (!document) {
this.testResult.set(null)
this.testResult = null
return
}
this.testDocument = document
this.testLoading.set(true)
this.testLoading = true
;(this.service as StoragePathService)
.testPath(this.objectForm.get('path').value, document.id)
.subscribe((result) => {
if (result?.length) {
this.testResult.set(result)
this.testFailed.set(false)
this.testResult = result
this.testFailed = false
} else {
this.testResult.set(null)
this.testFailed.set(true)
this.testResult = null
this.testFailed = true
}
this.testLoading.set(false)
this.testLoading = false
})
}
@@ -12,7 +12,7 @@
<pngx-input-color i18n-title title="Color" formControlName="color" [error]="error?.color"></pngx-input-color>
<pngx-input-select i18n-title title="Parent" formControlName="parent" [items]="tags()" [allowNull]="true" [error]="error?.parent"></pngx-input-select>
<pngx-input-select i18n-title title="Parent" formControlName="parent" [items]="tags" [allowNull]="true" [error]="error?.parent"></pngx-input-select>
<pngx-input-check i18n-title title="Inbox tag" formControlName="is_inbox_tag" i18n-hint hint="Inbox tags are automatically assigned to all consumed documents."></pngx-input-check>
<pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
@@ -1,4 +1,4 @@
import { Component, inject, signal } from '@angular/core'
import { Component, inject } from '@angular/core'
import {
FormControl,
FormGroup,
@@ -35,7 +35,7 @@ import { TextComponent } from '../../input/text/text.component'
],
})
export class TagEditDialogComponent extends EditDialogComponent<Tag> {
readonly tags = signal<Tag[]>([])
tags: Tag[]
constructor() {
super()
@@ -43,7 +43,7 @@ export class TagEditDialogComponent extends EditDialogComponent<Tag> {
this.userService = inject(UserService)
this.settingsService = inject(SettingsService)
this.service.listAll().subscribe((result) => {
this.tags.set(result.results)
this.tags = result.results
})
}
@@ -180,7 +180,7 @@
<div class="col">
<div class="trigger-filters mb-3">
<div class="d-flex align-items-center">
<span class="form-label mb-0" i18n>Advanced Filters</span>
<label class="form-label mb-0" i18n>Advanced Filters</label>
<button
type="button"
class="btn btn-sm btn-outline-primary ms-auto"
@@ -401,7 +401,7 @@ describe('WorkflowEditDialogComponent', () => {
component.addFilter(triggerGroup as FormGroup)
const filters = component.getFiltersFormArray(triggerGroup as FormGroup)
expect(filters).toHaveLength(3)
expect(filters.length).toBe(3)
filters.at(0).get('values').setValue([1])
filters.at(1).get('values').setValue([2, 3])
@@ -672,7 +672,7 @@ describe('WorkflowEditDialogComponent', () => {
} as any
const filters = component['buildFiltersFormArray'](trigger)
expect(filters).toHaveLength(0)
expect(filters.length).toBe(0)
component.filterDefinitions = originalDefinitions
})
@@ -739,7 +739,7 @@ describe('WorkflowEditDialogComponent', () => {
component.ngOnInit()
const triggerGroup = component.triggerFields.at(0) as FormGroup
const filters = component.getFiltersFormArray(triggerGroup)
expect(filters).toHaveLength(13)
expect(filters.length).toBe(13)
const customFieldFilter = filters.at(12) as FormGroup
expect(customFieldFilter.get('type').value).toBe(
TriggerFilterType.CustomFieldQuery
@@ -969,12 +969,12 @@ describe('WorkflowEditDialogComponent', () => {
component.addFilter(triggerGroup)
component.removeFilter(triggerGroup, 0)
expect(component.getFiltersFormArray(triggerGroup)).toHaveLength(0)
expect(component.getFiltersFormArray(triggerGroup).length).toBe(0)
component.addFilter(triggerGroup)
const filterArrayAfterAdd = component.getFiltersFormArray(triggerGroup)
filterArrayAfterAdd.at(0).get('type').setValue(TriggerFilterType.TagsAll)
expect(component.getFiltersFormArray(triggerGroup)).toHaveLength(1)
expect(component.getFiltersFormArray(triggerGroup).length).toBe(1)
})
it('should remove selected custom field from the form group', () => {
@@ -933,7 +933,7 @@ export class WorkflowEditDialogComponent
getFilterSelectItems(type: TriggerFilterType) {
const definition = this.getFilterDefinition(type)
if (definition?.inputType !== 'select') {
if (!definition || definition.inputType !== 'select') {
return []
}
@@ -34,30 +34,32 @@
</div>
@if (selectionModel.items) {
<cdk-virtual-scroll-viewport class="items" [itemSize]="FILTERABLE_BUTTON_HEIGHT_PX" #buttonsViewport [style.height.px]="scrollViewportHeight">
<div *cdkVirtualFor="let item of filteredItems; trackBy: trackByItem; let i = index">
<pngx-toggleable-dropdown-button
[item]="item"
[hideCount]="hideCount(item)"
[opacifyCount]="!editing"
[state]="selectionModel.get(item.id)"
[count]="getUpdatedDocumentCount(item.id)"
(toggled)="selectionModel.toggle(item.id)"
(exclude)="excludeClicked(item.id)"
[disabled]="disabled">
</pngx-toggleable-dropdown-button>
<div *cdkVirtualFor="let item of selectionModel.items | filter: filterText:'name'; trackBy: trackByItem; let i = index">
@if (allowSelectNone || item.id) {
<pngx-toggleable-dropdown-button
[item]="item"
[hideCount]="hideCount(item)"
[opacifyCount]="!editing"
[state]="selectionModel.get(item.id)"
[count]="getUpdatedDocumentCount(item.id)"
(toggled)="selectionModel.toggle(item.id)"
(exclude)="excludeClicked(item.id)"
[disabled]="disabled">
</pngx-toggleable-dropdown-button>
}
</div>
</cdk-virtual-scroll-viewport>
}
@if (editing) {
@if (filteredItems.length === 0 && createRef !== undefined) {
@if ((selectionModel.items | filter: filterText:'name').length === 0 && createRef !== undefined) {
<button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled">
<small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small>
<i-bs width="1.5em" height="1em" name="plus"></i-bs>
</button>
}
@if (filteredItems.length > 0) {
<button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="applyClicked()" [disabled]="!modelIsDirty() || disabled">
<small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty()}" i18n>Apply</small>
@if ((selectionModel.items | filter: filterText:'name').length > 0) {
<button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="applyClicked()" [disabled]="!modelIsDirty || disabled">
<small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty}" i18n>Apply</small>
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
</button>
}
@@ -221,7 +221,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
.dispatchEvent(new MouseEvent('click')) // open
selectionModel.toggle(items[0].id)
fixture.detectChanges()
expect(component.modelIsDirty()).toBeTruthy()
expect(component.modelIsDirty).toBeTruthy()
let applyResult: ChangedItems
const closeSpy = jest.spyOn(component.dropdown, 'close')
component.apply.subscribe((result) => (applyResult = result))
@@ -244,7 +244,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
.dispatchEvent(new MouseEvent('click')) // open
selectionModel.toggle(items[0].id)
fixture.detectChanges()
expect(component.modelIsDirty()).toBeTruthy()
expect(component.modelIsDirty).toBeTruthy()
let applyResult: ChangedItems
component.apply.subscribe((result) => (applyResult = result))
component.dropdown.close()
@@ -265,9 +265,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(document.activeElement).toEqual(
component.listFilterTextInput.nativeElement
)
expect(component.buttonsViewport.getRenderedRange().end).toEqual(
items.length
) // all selectable items shown
expect(component.buttonsViewport.getRenderedRange().end).toEqual(3) // all items shown
component.filterText = 'Tag2'
fixture.detectChanges()
@@ -280,29 +278,6 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(component.filterText).toHaveLength(0)
})
it('should omit disallowed null items from the virtual scroll viewport', async () => {
component.selectionModel.items = items
component.icon = 'tag-fill'
fixture.nativeElement
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
await wait(100)
fixture.detectChanges()
component.buttonsViewport?.checkViewportSize()
fixture.detectChanges()
expect(component.filteredItems).toEqual(items)
expect(component.scrollViewportHeight).toEqual(
items.length * component.FILTERABLE_BUTTON_HEIGHT_PX
)
expect(
component.buttonsViewport.elementRef.nativeElement.querySelectorAll(
'.cdk-virtual-scroll-content-wrapper > div'
)
).toHaveLength(items.length)
})
it('should toggle & close on enter inside filter field if 1 item remains', async () => {
component.selectionModel.items = items
component.icon = 'tag-fill'
@@ -12,7 +12,6 @@ import {
Output,
ViewChild,
inject,
signal,
} from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
@@ -661,6 +660,7 @@ export class FilterableDropdownSelectionModel {
imports: [
ClearableBadgeComponent,
ToggleableDropdownButtonComponent,
FilterPipe,
FormsModule,
ReactiveFormsModule,
NgxBootstrapIconsModule,
@@ -796,27 +796,22 @@ export class FilterableDropdownComponent
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
}
readonly modelIsDirty = signal(false)
modelIsDirty: boolean = false
private keyboardIndex: number
public get filteredItems(): MatchingModel[] {
return this.filterPipe
.transform(this.items, this.filterText, 'name')
.filter((item) => this.allowSelectNone || Boolean(item.id))
}
public get scrollViewportHeight(): number {
return Math.min(
this.filteredItems.length * this.FILTERABLE_BUTTON_HEIGHT_PX,
400
)
const filteredLength = this.filterPipe.transform(
this.items,
this.filterText
).length
return Math.min(filteredLength * this.FILTERABLE_BUTTON_HEIGHT_PX, 400)
}
constructor() {
super()
this.selectionModelChange.subscribe((updatedModel) => {
this.modelIsDirty.set(updatedModel.isDirty())
this.modelIsDirty = updatedModel.isDirty()
})
}
@@ -863,7 +858,7 @@ export class FilterableDropdownComponent
}, 0)
if (this.editing) {
this.selectionModel.reset()
this.modelIsDirty.set(false)
this.modelIsDirty = false
}
this.selectionModel.singleSelect =
this.editing && !this.selectionModel.manyToOne
@@ -882,7 +877,7 @@ export class FilterableDropdownComponent
}
listFilterEnter(): void {
const filtered = this.filteredItems
let filtered = this.filterPipe.transform(this.items, this.filterText)
if (filtered.length == 1) {
this.selectionModel.toggle(filtered[0].id)
setTimeout(() => {
@@ -5,7 +5,6 @@ import {
inject,
Input,
Output,
signal,
} from '@angular/core'
import {
FormsModule,
@@ -64,11 +63,11 @@ export class CustomFieldsValuesComponent extends AbstractInputComponent<Object>
super()
customFieldsService.listAll().subscribe((items) => {
this.fields.set(items.results)
this.fields = items.results
})
}
private readonly fields = signal<CustomField[]>([])
private fields: CustomField[]
private _selectedFields: number[]
@@ -91,6 +90,6 @@ export class CustomFieldsValuesComponent extends AbstractInputComponent<Object>
public removeSelectedField: EventEmitter<number> = new EventEmitter<number>()
public getCustomField(id: number): CustomField {
return this.fields().find((field) => field.id === id)
return this.fields.find((field) => field.id === id)
}
}
@@ -33,7 +33,7 @@
[compareWith]="compareDocuments"
[trackByFn]="trackByFn"
[minTermLength]="2"
[loading]="loading()"
[loading]="loading"
[typeahead]="documentsInput$"
(mousedown)="$event.stopImmediatePropagation()"
(change)="onChange(selectedDocumentIDs)">
@@ -2,11 +2,7 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NG_VALUE_ACCESSOR } from '@angular/forms'
import { By } from '@angular/platform-browser'
import { provideRouter } from '@angular/router'
import { NgSelectComponent } from '@ng-select/ng-select'
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { of, Subject, throwError } from 'rxjs'
import { of, throwError } from 'rxjs'
import { FILTER_SIMPLE_TITLE } from 'src/app/data/filter-rule-type'
import { DocumentService } from 'src/app/services/rest/document.service'
import { DocumentLinkComponent } from './document-link.component'
@@ -37,11 +33,10 @@ describe('DocumentLinkComponent', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [DocumentLinkComponent, NgxBootstrapIconsModule.pick(allIcons)],
imports: [DocumentLinkComponent],
providers: [
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
provideRouter([]),
],
})
documentService = TestBed.inject(DocumentService)
@@ -65,25 +60,6 @@ describe('DocumentLinkComponent', () => {
expect(getSpy).toHaveBeenCalled()
})
it('should render loading and selected documents after async updates', async () => {
const result$ = new Subject<any>()
jest.spyOn(documentService, 'getFew').mockReturnValue(result$)
component.writeValue([1])
await fixture.whenStable()
const select = fixture.debugElement.query(By.directive(NgSelectComponent))
.componentInstance as NgSelectComponent
expect(select.loading()).toBe(true)
result$.next({ count: 1, all: [1], results: [documents[0]] })
result$.complete()
await fixture.whenStable()
expect(select.loading()).toBe(false)
expect(fixture.nativeElement.textContent).toContain(documents[0].title)
})
it('shoud maintain ordering of selected documents', () => {
const getSpy = jest.spyOn(documentService, 'getFew')
getSpy.mockImplementation((ids) => {
@@ -6,7 +6,6 @@ import {
Input,
OnDestroy,
OnInit,
signal,
} from '@angular/core'
import {
FormsModule,
@@ -64,7 +63,7 @@ export class DocumentLinkComponent
documentsInput$ = new Subject<string>()
foundDocuments$: Observable<Document[]>
readonly loading = signal(false)
loading = false
selectedDocuments: Document[] = []
private unsubscribeNotifier: Subject<any> = new Subject()
@@ -94,12 +93,12 @@ export class DocumentLinkComponent
this.selectedDocuments = []
super.writeValue([])
} else {
this.loading.set(true)
this.loading = true
this.documentsService
.getFew(documentIDs, { fields: 'id,title' })
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((documentResults) => {
this.loading.set(false)
this.loading = false
this.selectedDocuments = documentIDs.map(
(id) => documentResults.results.find((d) => d.id === id) ?? {}
)
@@ -114,7 +113,7 @@ export class DocumentLinkComponent
this.documentsInput$.pipe(
distinctUntilChanged(),
takeUntil(this.unsubscribeNotifier),
tap(() => this.loading.set(true)),
tap(() => (this.loading = true)),
switchMap((title) =>
this.documentsService
.listFiltered(
@@ -134,7 +133,7 @@ export class DocumentLinkComponent
)
),
catchError(() => of([])), // empty on error
tap(() => this.loading.set(false))
tap(() => (this.loading = false))
)
)
)
@@ -37,7 +37,6 @@ export class NumberComponent extends AbstractInputComponent<number> {
this.documentService.getNextAsn().subscribe((nextAsn) => {
this.value = nextAsn
this.onChange(this.value)
this.changeDetector.markForCheck()
})
}
@@ -157,8 +157,8 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
private removeChildren(tagIDs: number[], tag: Tag) {
if (tag.children?.length) {
const childIDs = new Set(tag.children.map((child) => child.id))
tagIDs = tagIDs.filter((id) => !childIDs.has(id))
const childIDs = tag.children.map((child) => child.id)
tagIDs = tagIDs.filter((id) => !childIDs.includes(id))
for (const child of tag.children) {
tagIDs = this.removeChildren(tagIDs, child)
}
@@ -14,7 +14,7 @@ import { environment } from 'src/environments/environment'
})
export class PageHeaderComponent {
private titleService = inject(Title)
private readonly clipboard = inject(Clipboard)
private clipboard = inject(Clipboard)
readonly id = input<number>(undefined)
readonly subTitle = input('')
@@ -49,7 +49,8 @@ describe('PngxPdfViewerComponent', () => {
new URL('assets/js/pdf.worker.min.mjs', document.baseURI).toString()
)
const isVisible = (component as any).findController.onIsPageVisible as
(() => boolean) | undefined
| (() => boolean)
| undefined
expect(isVisible?.()).toBe(true)
expect(loadSpy).toHaveBeenCalledWith(
expect.objectContaining({ numPages: 1 })
@@ -60,7 +61,6 @@ describe('PngxPdfViewerComponent', () => {
it('resolves the worker source relative to the document base URI', async () => {
setBaseHref('/paperless/')
const getDocumentSpy = jest.spyOn(pdfjs, 'getDocument')
await initComponent()
@@ -70,13 +70,6 @@ describe('PngxPdfViewerComponent', () => {
expect(pdfjs.GlobalWorkerOptions.workerSrc).toContain(
'/paperless/assets/js/pdf.worker.min.mjs'
)
expect(getDocumentSpy).toHaveBeenCalledWith({
url: 'test.pdf',
password: undefined,
withCredentials: true,
wasmUrl: expect.stringContaining('/paperless/assets/wasm/'),
iccUrl: expect.stringContaining('/paperless/assets/iccs/'),
})
})
it('initializes single-page viewer and disables text layer', async () => {
@@ -138,18 +131,6 @@ describe('PngxPdfViewerComponent', () => {
expect(applyScaleSpy).toHaveBeenCalled()
})
it('does not reset the viewer when it is already on the requested page', async () => {
await initComponent()
const viewer = (component as any).pdfViewer as PDFViewer
const currentPageSpy = jest.spyOn(viewer, 'currentPageNumber', 'set')
component.page = viewer.currentPageNumber
;(component as any).applyViewerState()
expect(currentPageSpy).not.toHaveBeenCalled()
})
it('dispatches find when search query changes after render', async () => {
await initComponent()
@@ -174,12 +174,10 @@ export class PngxPdfViewerComponent
'assets/js/pdf.worker.min.mjs',
this.document.baseURI
).toString()
const initOptions = {
let initOptions = {
url: this.src,
password: this.password,
withCredentials: true,
wasmUrl: new URL('assets/wasm/', this.document.baseURI).toString(),
iccUrl: new URL('assets/iccs/', this.document.baseURI).toString(),
}
this.loadingTask = getDocument(initOptions)
try {
@@ -256,9 +254,7 @@ export class PngxPdfViewerComponent
Math.max(Math.trunc(this.page), 1),
this.pdfViewer.pagesCount
)
if (nextPage !== this.pdfViewer.currentPageNumber) {
this.pdfViewer.currentPageNumber = nextPage
}
this.pdfViewer.currentPageNumber = nextPage
}
if (this.page === this.lastViewerPage) {
this.lastViewerPage = undefined
@@ -68,7 +68,7 @@
[(ngModel)]="selectionModel.includeUsers"
[disabled]="disabled"
[clearable]="false"
[items]="users()"
[items]="users"
bindLabel="username"
multiple="true"
bindValue="id"
@@ -1,12 +1,5 @@
import { NgClass } from '@angular/common'
import {
Component,
EventEmitter,
Input,
Output,
inject,
signal,
} from '@angular/core'
import { Component, EventEmitter, Input, Output, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectComponent } from '@ng-select/ng-select'
@@ -82,7 +75,7 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
@Output()
ownerFilterSet = new EventEmitter<PermissionsSelectionModel>()
readonly users = signal<User[]>([])
users: User[]
hideUnowned: boolean
@@ -109,7 +102,7 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
.listAll()
.pipe(first())
.subscribe({
next: (result) => this.users.set(result.results),
next: (result) => (this.users = result.results),
})
}
}
@@ -57,15 +57,15 @@
</div>
</div>
<div class="col-12 col-md-6">
@if (socialAccounts().length > 0) {
@if (socialAccounts?.length > 0) {
<div class="mb-3">
<p i18n>Connected social accounts</p>
<ul class="list-group">
@for (account of socialAccounts(); track account.id) {
@for (account of socialAccounts; track account.id) {
<li class="list-group-item"
ngbPopover="Set a password before disconnecting social account."
i18n-ngbPopover
[disablePopover]="hasUsablePassword()"
[disablePopover]="hasUsablePassword"
triggers="mouseenter:mouseleave">
{{account.name}} ({{account.provider}})
<pngx-confirm-button
@@ -75,7 +75,7 @@
i18n-title
buttonClasses="btn-outline-danger btn-sm ms-2 align-baseline"
iconName="trash"
[disabled]="!hasUsablePassword()"
[disabled]="!hasUsablePassword"
(confirm)="disconnectSocialAccount(account.id)">
</pngx-confirm-button>
</li>
@@ -84,11 +84,11 @@
<div class="form-text text-muted text-end fst-italic" i18n>Warning: disconnecting social accounts cannot be undone</div>
</div>
}
@if (socialAccountProviders().length > 0) {
@if (socialAccountProviders?.length > 0) {
<div class="mb-3">
<p i18n>Connect new social account</p>
<div class="list-group">
@for (provider of socialAccountProviders(); track provider.name) {
@for (provider of socialAccountProviders; track provider.name) {
<a class="list-group-item list-group-item-action text-primary d-flex align-items-center" href="{{ provider.login_url }}" rel="noopener noreferrer">
{{provider.name}}<i-bs class="pb-1 ms-2" name="box-arrow-up-right"></i-bs>
</a>
@@ -96,7 +96,7 @@
</div>
</div>
}
@if (!isTotpEnabled()) {
@if (!isTotpEnabled) {
<div ngbAccordion>
<div ngbAccordionItem>
<h2 ngbAccordionHeader>
@@ -105,10 +105,10 @@
<div ngbAccordionCollapse>
<div ngbAccordionBody>
<ng-template>
@if (totpSettingsLoading()) {
@if (totpSettingsLoading) {
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>
} @else if (totpSettings()) {
} @else if (totpSettings) {
<figure class="figure">
@if (qrSvgDataUrl) {
<img class="bg-white d-inline-block" [src]="qrSvgDataUrl" alt="Authenticator QR code">
@@ -116,14 +116,14 @@
<figcaption class="figure-caption text-end mt-2" i18n>Scan the QR code with your authenticator app and then enter the code below</figcaption>
</figure>
<p>
<ng-container i18n>Authenticator secret</ng-container>: <code>{{totpSettings().secret}}</code>.
<ng-container i18n>Authenticator secret</ng-container>: <code>{{totpSettings.secret}}</code>.
<ng-container i18n>You can store this secret and use it to reinstall your authenticator app at a later time.</ng-container>
</p>
<div class="input-group mb-3">
<input type="text" class="form-control" formControlName="totp_code" placeholder="Code" i18n-placeholder>
<button type="button" class="btn btn-primary ml-auto" (click)="activateTotp()" [disabled]="totpLoading()">
<button type="button" class="btn btn-primary ml-auto" (click)="activateTotp()" [disabled]="totpLoading">
<ng-container i18n>Enable</ng-container>
@if (totpLoading()) {
@if (totpLoading) {
<div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>
}
@@ -137,18 +137,18 @@
</div>
} @else {
<label class="d-block mb-2" i18n>Two-factor Authentication</label>
@if (recoveryCodes()) {
@if (recoveryCodes) {
<div class="alert alert-warning" role="alert">
<i-bs name="exclamation-triangle" class="me-1"></i-bs><ng-container i18n>Recovery codes will not be shown again, make sure to save them.</ng-container>
</div>
<div class="d-flex flex-row align-items-start mb-3">
<ul class="list-group w-50">
@for (code of recoveryCodes(); track code; let i = $index) {
@for (code of recoveryCodes; track code; let i = $index) {
@if (i % 2 === 0) {
<li class="list-group-item d-flex justify-content-around align-items-center">
<code>{{code}}</code>
@if (recoveryCodes()[i + 1]) {
<code>{{recoveryCodes()[i + 1]}}</code>
@if (recoveryCodes[i + 1]) {
<code>{{recoveryCodes[i + 1]}}</code>
}
</li>
}
@@ -171,7 +171,7 @@
i18n-title
buttonClasses="btn-outline-danger btn-sm"
iconName="trash"
[disabled]="totpLoading()"
[disabled]="totpLoading"
(confirm)="deactivateTotp()">
</pngx-confirm-button>
}
@@ -10,7 +10,7 @@ import {
NgbPopoverModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { NEVER, of, Subject, throwError } from 'rxjs'
import { of, throwError } from 'rxjs'
import { ProfileService } from 'src/app/services/profile.service'
import { ToastService } from 'src/app/services/toast.service'
import * as navUtils from 'src/app/utils/navigation'
@@ -60,10 +60,6 @@ describe('ProfileEditDialogComponent', () => {
providers: [NgbActiveModal, provideHttpClient(withInterceptorsFromDi())],
})
profileService = TestBed.inject(ProfileService)
jest.spyOn(profileService, 'get').mockReturnValue(NEVER)
jest
.spyOn(profileService, 'getSocialAccountProviders')
.mockReturnValue(of([]))
toastService = TestBed.inject(ToastService)
clipboard = TestBed.inject(Clipboard)
fixture = TestBed.createComponent(ProfileEditDialogComponent)
@@ -159,7 +155,7 @@ describe('ProfileEditDialogComponent', () => {
'getSocialAccountProviders'
)
getProvidersSpy.mockReturnValue(of(socialAccountProviders))
component.hasUsablePassword.set(true)
component.hasUsablePassword = true
component.ngOnInit()
component.form.get('password').patchValue('new*pass')
component.onPasswordKeyUp({
@@ -272,27 +268,6 @@ describe('ProfileEditDialogComponent', () => {
expect(getProvidersSpy).toHaveBeenCalled()
})
it('should render social account providers after an async update', async () => {
const providers$ = new Subject<typeof socialAccountProviders>()
jest.spyOn(profileService, 'get').mockReturnValue(of(profile))
jest
.spyOn(profileService, 'getSocialAccountProviders')
.mockReturnValue(providers$)
component.ngOnInit()
await fixture.whenStable()
expect(
fixture.nativeElement.querySelector('a[href="https://example.com"]')
).toBeNull()
providers$.next(socialAccountProviders)
await fixture.whenStable()
expect(
fixture.nativeElement.querySelector('a[href="https://example.com"]')
).not.toBeNull()
})
it('should remove disconnected social account from component, show error if needed', () => {
const disconnectSpy = jest.spyOn(profileService, 'disconnectSocialAccount')
const getSpy = jest.spyOn(profileService, 'get')
@@ -301,7 +276,7 @@ describe('ProfileEditDialogComponent', () => {
const errorSpy = jest.spyOn(toastService, 'showError')
expect(component.socialAccounts()).toContainEqual(socialAccount)
expect(component.socialAccounts).toContainEqual(socialAccount)
// fail first
disconnectSpy.mockReturnValueOnce(
@@ -314,7 +289,7 @@ describe('ProfileEditDialogComponent', () => {
disconnectSpy.mockReturnValue(of(socialAccount.id))
component.disconnectSocialAccount(socialAccount.id)
expect(disconnectSpy).toHaveBeenCalled()
expect(component.socialAccounts()).not.toContainEqual(socialAccount)
expect(component.socialAccounts).not.toContainEqual(socialAccount)
})
it('should get totp settings', () => {
@@ -335,7 +310,7 @@ describe('ProfileEditDialogComponent', () => {
getSpy.mockReturnValue(of(settings))
component.gettotpSettings()
expect(getSpy).toHaveBeenCalled()
expect(component.totpSettings()).toEqual(settings)
expect(component.totpSettings).toEqual(settings)
})
it('should activate totp', () => {
@@ -344,15 +319,15 @@ describe('ProfileEditDialogComponent', () => {
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const error = new Error('failed to activate totp')
activateSpy.mockReturnValueOnce(throwError(() => error))
component.totpSettings.set({
component.totpSettings = {
url: 'http://localhost/',
qr_svg: 'svg',
secret: 'secret',
})
}
component.form.get('totp_code').patchValue('123456')
component.activateTotp()
expect(activateSpy).toHaveBeenCalledWith(
component.totpSettings().secret,
component.totpSettings.secret,
component.form.get('totp_code').value
)
expect(toastErrorSpy).toHaveBeenCalled()
@@ -366,8 +341,8 @@ describe('ProfileEditDialogComponent', () => {
)
component.activateTotp()
expect(toastInfoSpy).toHaveBeenCalled()
expect(component.isTotpEnabled()).toBeTruthy()
expect(component.recoveryCodes()).toEqual(['1', '2', '3'])
expect(component.isTotpEnabled).toBeTruthy()
expect(component.recoveryCodes).toEqual(['1', '2', '3'])
})
it('should deactivate totp', () => {
@@ -387,13 +362,13 @@ describe('ProfileEditDialogComponent', () => {
deactivateSpy.mockReturnValueOnce(of(true))
component.deactivateTotp()
expect(toastInfoSpy).toHaveBeenCalled()
expect(component.isTotpEnabled()).toBeFalsy()
expect(component.isTotpEnabled).toBeFalsy()
})
it('should copy recovery codes', () => {
jest.useFakeTimers()
const copySpy = jest.spyOn(clipboard, 'copy')
component.recoveryCodes.set(['1', '2', '3'])
component.recoveryCodes = ['1', '2', '3']
component.copyRecoveryCodes()
expect(copySpy).toHaveBeenCalledWith('1\n2\n3')
jest.advanceTimersByTime(3000)
@@ -56,14 +56,6 @@ export class ProfileEditDialogComponent
readonly showEmailConfirm = signal(false)
readonly copied = signal(false)
readonly codesCopied = signal(false)
readonly hasUsablePassword = signal(false)
readonly isTotpEnabled = signal(false)
readonly totpSettings = signal<TotpSettings>(undefined)
readonly totpSettingsLoading = signal(false)
readonly totpLoading = signal(false)
readonly recoveryCodes = signal<string[]>(undefined)
readonly socialAccounts = signal<SocialAccount[]>([])
readonly socialAccountProviders = signal<SocialAccountProvider[]>([])
public form = new FormGroup({
email: new FormControl(''),
@@ -80,15 +72,25 @@ export class ProfileEditDialogComponent
private newPassword: string
private passwordConfirm: string
public hasUsablePassword: boolean = false
private currentEmail: string
private newEmail: string
private emailConfirm: string
public isTotpEnabled: boolean = false
public totpSettings: TotpSettings
public totpSettingsLoading: boolean = false
public totpLoading: boolean = false
public recoveryCodes: string[]
public socialAccounts: SocialAccount[] = []
public socialAccountProviders: SocialAccountProvider[] = []
get qrSvgDataUrl(): string | null {
if (!this.totpSettings()?.qr_svg) {
if (!this.totpSettings?.qr_svg) {
return null
}
return `data:image/svg+xml;utf8,${encodeURIComponent(this.totpSettings().qr_svg)}`
return `data:image/svg+xml;utf8,${encodeURIComponent(this.totpSettings.qr_svg)}`
}
ngOnInit(): void {
@@ -105,20 +107,20 @@ export class ProfileEditDialogComponent
this.onEmailChange()
})
this.currentPassword = profile.password
this.hasUsablePassword.set(profile.has_usable_password)
this.hasUsablePassword = profile.has_usable_password
this.form.get('password').valueChanges.subscribe((newPassword) => {
this.newPassword = newPassword
this.onPasswordChange()
})
this.socialAccounts.set(profile.social_accounts ?? [])
this.isTotpEnabled.set(profile.is_mfa_enabled)
this.socialAccounts = profile.social_accounts
this.isTotpEnabled = profile.is_mfa_enabled
})
this.profileService
.getSocialAccountProviders()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((providers) => {
this.socialAccountProviders.set(providers ?? [])
this.socialAccountProviders = providers
})
}
@@ -257,9 +259,7 @@ export class ProfileEditDialogComponent
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (id: number) => {
this.socialAccounts.update((accounts) =>
accounts.filter((account) => account.id != id)
)
this.socialAccounts = this.socialAccounts.filter((a) => a.id != id)
},
error: (error) => {
this.toastService.showError(
@@ -271,39 +271,36 @@ export class ProfileEditDialogComponent
}
public gettotpSettings(): void {
this.totpSettingsLoading.set(true)
this.totpSettingsLoading = true
this.profileService
.getTotpSettings()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (totpSettings) => {
this.totpSettingsLoading.set(false)
this.totpSettings.set(totpSettings)
this.totpSettingsLoading = false
this.totpSettings = totpSettings
},
error: (error) => {
this.toastService.showError(
$localize`Error fetching TOTP settings`,
error
)
this.totpSettingsLoading.set(false)
this.totpSettingsLoading = false
},
})
}
public activateTotp(): void {
this.totpLoading.set(true)
this.totpLoading = true
this.form.get('totp_code').disable()
this.profileService
.activateTotp(
this.totpSettings().secret,
this.form.get('totp_code').value
)
.activateTotp(this.totpSettings.secret, this.form.get('totp_code').value)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (activationResponse) => {
this.totpLoading.set(false)
this.isTotpEnabled.set(activationResponse.success)
this.recoveryCodes.set(activationResponse.recovery_codes)
this.totpLoading = false
this.isTotpEnabled = activationResponse.success
this.recoveryCodes = activationResponse.recovery_codes
this.form.get('totp_code').enable()
if (activationResponse.success) {
this.toastService.showInfo($localize`TOTP activated successfully`)
@@ -312,7 +309,7 @@ export class ProfileEditDialogComponent
}
},
error: (error) => {
this.totpLoading.set(false)
this.totpLoading = false
this.form.get('totp_code').enable()
this.toastService.showError($localize`Error activating TOTP`, error)
},
@@ -320,15 +317,15 @@ export class ProfileEditDialogComponent
}
public deactivateTotp(): void {
this.totpLoading.set(true)
this.totpLoading = true
this.profileService
.deactivateTotp()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (success) => {
this.totpLoading.set(false)
this.isTotpEnabled.set(!success)
this.recoveryCodes.set(null)
this.totpLoading = false
this.isTotpEnabled = !success
this.recoveryCodes = null
if (success) {
this.toastService.showInfo($localize`TOTP deactivated successfully`)
} else {
@@ -336,14 +333,14 @@ export class ProfileEditDialogComponent
}
},
error: (error) => {
this.totpLoading.set(false)
this.totpLoading = false
this.toastService.showError($localize`Error deactivating TOTP`, error)
},
})
}
public copyRecoveryCodes(): void {
this.clipboard.copy(this.recoveryCodes().join('\n'))
this.clipboard.copy(this.recoveryCodes.join('\n'))
this.codesCopied.set(true)
setTimeout(() => {
this.codesCopied.set(false)
@@ -65,9 +65,8 @@
<dd class="col-sm-8"><code>{{ createdBundle.slug }}</code></dd>
<dt class="col-sm-4" i18n>Link</dt>
<dd class="col-sm-8">
<label class="visually-hidden" for="shareBundleLink" i18n>Share link</label>
<div class="input-group input-group-sm">
<input id="shareBundleLink" class="form-control" type="text" [value]="getShareUrl(createdBundle)" readonly>
<input class="form-control" type="text" [value]="getShareUrl(createdBundle)" readonly>
<button
class="btn btn-outline-primary"
type="button"
@@ -14,9 +14,7 @@
}
<div content class="wrapper fade" [class.show]="show()">
@if (error()) {
<div class="alert alert-danger mb-0" role="alert"><ng-container i18n>Error while loading documents</ng-container>: {{error()}}</div>
} @else if (displayMode() === DisplayMode.TABLE) {
@if (displayMode() === DisplayMode.TABLE) {
<table class="table table-hover mb-0 mt-n2 align-middle">
<thead>
<tr>
@@ -1,10 +1,6 @@
import { DragDropModule } from '@angular/cdk/drag-drop'
import { DatePipe } from '@angular/common'
import {
HttpErrorResponse,
provideHttpClient,
withInterceptorsFromDi,
} from '@angular/common/http'
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'
@@ -12,7 +8,7 @@ import { Router } from '@angular/router'
import { RouterTestingModule } from '@angular/router/testing'
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { Subject, of, throwError } from 'rxjs'
import { Subject, of } from 'rxjs'
import { routes } from 'src/app/app-routing.module'
import { CustomFieldDisplayComponent } from 'src/app/components/common/custom-field-display/custom-field-display.component'
import { PreviewPopupComponent } from 'src/app/components/common/preview-popup/preview-popup.component'
@@ -234,27 +230,6 @@ describe('SavedViewWidgetComponent', () => {
expect(component.documents()).toEqual(documentResults)
})
it('should show an error if documents fail to load', () => {
jest.spyOn(documentService, 'listFiltered').mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
error: { added__date__lte: ['Enter a valid date.'] },
status: 400,
})
)
)
component.reload()
fixture.detectChanges()
expect(component.loading()).toBe(false)
expect(component.error()).toEqual('Added: Enter a valid date.')
expect(fixture.debugElement.nativeElement.textContent).toContain(
'Error while loading documents: Added: Enter a valid date.'
)
})
it('should reload on document consumption finished', () => {
const fileStatusSubject = new Subject<FileStatus>()
jest
@@ -125,8 +125,6 @@ export class SavedViewWidgetComponent
readonly count = signal<number>(null)
readonly error = signal<string | null>(null)
placeholderRows: number[] = []
ngOnInit(): void {
@@ -182,7 +180,6 @@ export class SavedViewWidgetComponent
reload() {
this.loading.set(this.documents().length == 0)
this.error.set(null)
this.show.set(true)
this.documentService
.listFiltered(
@@ -194,40 +191,12 @@ export class SavedViewWidgetComponent
{ truncate_content: true }
)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (result) => {
this.documents.set(result.results)
this.count.set(result.count)
this.savedViewService.setDocumentCount(this.savedView, result.count)
this.loading.set(false)
this.show.set(true)
},
error: (error) => {
this.documents.set([])
this.count.set(null)
let errorMessage
if (
typeof error.error === 'object' &&
Object.keys(error.error).length > 0
) {
errorMessage = Object.keys(error.error)
.map((fieldName) => {
const fieldNameBase = fieldName.split('__')[0]
const fieldError: Array<string> = error.error[fieldName]
return `${
this.documentService.sortFields.find(
(f) => f.field?.split('__')[0] == fieldNameBase
)?.name ?? fieldNameBase
}: ${fieldError[0]}`
})
.join(', ')
} else {
errorMessage = error.error
}
this.error.set(errorMessage)
this.loading.set(false)
this.show.set(true)
},
.subscribe((result) => {
this.documents.set(result.results)
this.count.set(result.count)
this.savedViewService.setDocumentCount(this.savedView, result.count)
this.loading.set(false)
this.show.set(true)
})
}
@@ -1 +1 @@
<p i18n>Searching document with asn {{asn()}}</p>
<p i18n>Searching document with asn {{asn}}</p>
@@ -1,4 +1,4 @@
import { Component, OnInit, inject, signal } from '@angular/core'
import { Component, OnInit, inject } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { FILTER_ASN } from '../../data/filter-rule-type'
import { DocumentService } from '../../services/rest/document.service'
@@ -13,13 +13,13 @@ export class DocumentAsnComponent implements OnInit {
private route = inject(ActivatedRoute)
private router = inject(Router)
readonly asn = signal<string>(undefined)
asn: string
ngOnInit(): void {
this.route.paramMap.subscribe((paramMap) => {
this.asn.set(paramMap.get('id'))
this.asn = paramMap.get('id')
this.documentsService
.listAllFilteredIds([{ rule_type: FILTER_ASN, value: this.asn() }])
.listAllFilteredIds([{ rule_type: FILTER_ASN, value: this.asn }])
.subscribe((documentId) => {
if (documentId.length == 1) {
this.router.navigate(['documents', documentId[0]])
@@ -82,6 +82,23 @@
<i-bs name="pencil" class="me-1"></i-bs><ng-container i18n>PDF Editor</ng-container>
</button>
<button
ngbDropdownItem
(click)="runZoneOcr()"
[disabled]="!userCanEdit || !document?.document_type"
*pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.OcrTemplate }"
>
<i-bs width="1em" height="1em" name="file-earmark-ruled" class="me-1"></i-bs><span i18n>Run Zone OCR</span>
</button>
<button
ngbDropdownItem
(click)="createOcrTemplate()"
*pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.OcrTemplate }"
>
<i-bs width="1em" height="1em" name="file-earmark-medical" class="me-1"></i-bs><span i18n>Create OCR Template</span>
</button>
@if (userIsOwner && (requiresPassword || password)) {
<button ngbDropdownItem (click)="removePassword()" [disabled]="!password">
<i-bs name="unlock" class="me-1"></i-bs><ng-container i18n>Remove Password</ng-container>
@@ -315,7 +332,7 @@
<td>{{metadata()?.original_filename}}</td>
</tr>
<tr>
<td i18n>Original SHA256 checksum</td>
<td i18n>Original MD5 checksum</td>
<td>{{metadata()?.original_checksum}}</td>
</tr>
<tr>
@@ -328,7 +345,7 @@
</tr>
@if (metadata()?.has_archive_version) {
<tr>
<td i18n>Archive SHA256 checksum</td>
<td i18n>Archive MD5 checksum</td>
<td>{{metadata()?.archive_checksum}}</td>
</tr>
}
@@ -482,7 +499,9 @@
</pngx-pdf-viewer>
</div>
} @else {
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%"></object>
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%">
<span>Preview is unavailable.</span>
</object>
}
}
@case (ContentRenderType.Text) {
@@ -503,7 +522,9 @@
}
}
@case (ContentRenderType.Other) {
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%"></object>
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%">
<span>Preview is unavailable.</span>
</object>
}
}
@if (requiresPassword) {
@@ -510,7 +510,7 @@ describe('DocumentDetailComponent', () => {
const deleteButtons = fixture.debugElement.queryAll(
By.css('pngx-confirm-button')
)
expect(deleteButtons).toHaveLength(1)
expect(deleteButtons.length).toEqual(1)
})
it('should fall back to details tab when duplicates tab is active but no duplicates', () => {
@@ -1429,6 +1429,48 @@ export class DocumentDetailComponent
})
}
runZoneOcr() {
this.documentsService.runZoneOcr(this.document.id).subscribe({
next: (res) => {
const results = res.results ?? []
if (results.length) {
const failed = results.filter(
(r) =>
r.value === null ||
r.value === undefined ||
`${r.value}`.trim() === ''
)
const filled = results.length - failed.length
let msg = $localize`Filled ${filled} of ${results.length} fields`
if (failed.length) {
const names = failed.map((r) => r.zone).join(', ')
msg = `${msg}. ${$localize`Failed to match zones: ${names}`}`
}
this.toastService.showInfo(msg)
} else {
this.toastService.showInfo(
$localize`Zone OCR ran but no results extracted.`
)
}
this.documentsService
.get(this.documentId)
.subscribe((doc) => this.updateComponent(doc))
},
error: (error) => {
this.toastService.showError($localize`Zone OCR failed`, error)
},
})
}
createOcrTemplate() {
this.router.navigate(['/ocr-templates', 'new'], {
queryParams: {
document_type: this.document.document_type,
sample_document: this.document.id,
},
})
}
private getSelectedNonLatestVersionId(): number | null {
const versions = this.document()?.versions ?? []
if (!versions.length || !this.selectedVersionId()) {
@@ -1,6 +1,6 @@
@if (loading()) {
<div class="d-flex">
<output class="spinner-border spinner-border-sm fw-normal"></output>
<output class="spinner-border spinner-border-sm fw-normal" role="status"></output>
</div>
} @else {
<ul class="list-group">
@@ -26,11 +26,11 @@ import { UserService } from 'src/app/services/rest/user.service'
],
})
export class DocumentHistoryComponent implements OnInit {
private readonly documentService = inject(DocumentService)
private readonly correspondentService = inject(CorrespondentService)
private readonly storagePathService = inject(StoragePathService)
private readonly documentTypeService = inject(DocumentTypeService)
private readonly userService = inject(UserService)
private documentService = inject(DocumentService)
private correspondentService = inject(CorrespondentService)
private storagePathService = inject(StoragePathService)
private documentTypeService = inject(DocumentTypeService)
private userService = inject(UserService)
public AuditLogAction = AuditLogAction
@@ -70,7 +70,7 @@ export class DocumentHistoryComponent implements OnInit {
return cached
}
const idInt = Number.parseInt(id, 10)
const idInt = parseInt(id, 10)
const fallback$ = of(id)
let result$: Observable<string>
@@ -5,11 +5,10 @@
</button>
<div class="dropdown-menu shadow" ngbDropdownMenu>
<div class="px-3 py-2 mb-2">
@if (versionUploadState() === UploadState.Idle) {
@if (versionUploadState === UploadState.Idle) {
<div class="input-group input-group-sm mb-2">
<label class="input-group-text" for="newVersionLabel" i18n>Label</label>
<span class="input-group-text" i18n>Label</span>
<input
id="newVersionLabel"
class="form-control"
type="text"
[(ngModel)]="newVersionLabel"
@@ -32,7 +31,7 @@
<i-bs name="file-earmark-plus"></i-bs><span class="ps-1" i18n>Add new version</span>
</button>
} @else {
@switch (versionUploadState()) {
@switch (versionUploadState) {
@case (UploadState.Uploading) {
<div class="small text-muted mt-1 d-flex align-items-center">
<output class="spinner-border spinner-border-sm me-2" aria-hidden="true"></output>
@@ -50,8 +49,8 @@
<span i18n>Version upload failed.</span>
<button type="button" class="btn btn-link btn-sm p-0 ms-2" (click)="clearVersionUploadStatus()" i18n>Dismiss</button>
</div>
@if (versionUploadError()) {
<div class="small text-muted mt-1">{{ versionUploadError() }}</div>
@if (versionUploadError) {
<div class="small text-muted mt-1">{{ versionUploadError }}</div>
}
}
}
@@ -71,19 +70,15 @@
}
</div>
<div class="d-flex flex-column">
@if (isEditingVersion(version.id)) {
<label class="visually-hidden" for="versionLabel{{ version.id }}" i18n>Version label</label>
}
<div class="input-group input-group-sm mb-1">
@if (isEditingVersion(version.id)) {
<input
id="versionLabel{{ version.id }}"
class="form-control"
type="text"
[(ngModel)]="versionLabelDraft"
i18n-placeholder
placeholder="Version label"
[disabled]="savingVersionLabelId() !== null"
[disabled]="savingVersionLabelId !== null"
(keydown.enter)="submitEditedVersionLabel(version, $event)"
(keydown.escape)="cancelEditingVersion($event)"
(click)="$event.stopPropagation()"
@@ -101,7 +96,7 @@
<button
type="button"
class="btn btn-outline-secondary"
[disabled]="savingVersionLabelId() !== null"
[disabled]="savingVersionLabelId !== null"
(click)="isEditingVersion(version.id) ? submitEditedVersionLabel(version, $event) : beginEditingVersion(version, $event)"
>
@if (isEditingVersion(version.id)) {
@@ -205,7 +205,7 @@ describe('DocumentVersionDropdownComponent', () => {
{ id: 3, is_root: true, checksum: 'aaaa' },
{ id: 10, is_root: false, checksum: 'bbbb', version_label: 'Updated' },
])
expect(component.savingVersionLabelId()).toBeNull()
expect(component.savingVersionLabelId).toBeNull()
})
it('saveVersionLabel should show error toast on failure', () => {
@@ -218,7 +218,7 @@ describe('DocumentVersionDropdownComponent', () => {
'Error updating version label',
error
)
expect(component.savingVersionLabelId()).toBeNull()
expect(component.savingVersionLabelId).toBeNull()
})
it('onVersionFileSelected should upload and update versions after websocket success', () => {
@@ -252,11 +252,11 @@ describe('DocumentVersionDropdownComponent', () => {
expect(versionsEmitSpy).toHaveBeenCalledWith(versions)
expect(selectedEmitSpy).toHaveBeenCalledWith(20)
expect(component.newVersionLabel).toEqual('')
expect(component.versionUploadState()).toEqual(UploadState.Idle)
expect(component.versionUploadError()).toBeNull()
expect(component.versionUploadState).toEqual(UploadState.Idle)
expect(component.versionUploadError).toBeNull()
})
it('onVersionFileSelected should render failed state after websocket failure', async () => {
it('onVersionFileSelected should set failed state after websocket failure', () => {
const file = new File(['test'], 'new-version.pdf', {
type: 'application/pdf',
})
@@ -266,11 +266,9 @@ describe('DocumentVersionDropdownComponent', () => {
component.onVersionFileSelected({ target: input } as Event)
failed$.next({ taskId: 'task-1', message: 'processing failed' })
await fixture.whenStable()
expect(component.versionUploadState()).toEqual(UploadState.Failed)
expect(component.versionUploadError()).toEqual('processing failed')
expect(fixture.nativeElement.textContent).toContain('processing failed')
expect(component.versionUploadState).toEqual(UploadState.Failed)
expect(component.versionUploadError).toEqual('processing failed')
expect(documentService.getVersions).not.toHaveBeenCalled()
})
@@ -284,8 +282,8 @@ describe('DocumentVersionDropdownComponent', () => {
component.onVersionFileSelected({ target: input } as Event)
expect(component.versionUploadState()).toEqual(UploadState.Failed)
expect(component.versionUploadError()).toEqual('Missing task ID.')
expect(component.versionUploadState).toEqual(UploadState.Failed)
expect(component.versionUploadError).toEqual('Missing task ID.')
expect(documentService.getVersions).not.toHaveBeenCalled()
})
@@ -300,8 +298,8 @@ describe('DocumentVersionDropdownComponent', () => {
component.onVersionFileSelected({ target: input } as Event)
expect(component.versionUploadState()).toEqual(UploadState.Failed)
expect(component.versionUploadError()).toEqual('upload failed')
expect(component.versionUploadState).toEqual(UploadState.Failed)
expect(component.versionUploadError).toEqual('upload failed')
expect(toastService.showError).toHaveBeenCalledWith(
'Error uploading new version',
error
@@ -309,8 +307,8 @@ describe('DocumentVersionDropdownComponent', () => {
})
it('ngOnChanges should clear upload status on document switch', () => {
component.versionUploadState.set(UploadState.Failed)
component.versionUploadError.set('something failed')
component.versionUploadState = UploadState.Failed
component.versionUploadError = 'something failed'
component.editingVersionId = 10
component.versionLabelDraft = 'draft'
@@ -318,8 +316,8 @@ describe('DocumentVersionDropdownComponent', () => {
documentId: new SimpleChange(3, 4, false),
})
expect(component.versionUploadState()).toEqual(UploadState.Idle)
expect(component.versionUploadError()).toBeNull()
expect(component.versionUploadState).toEqual(UploadState.Idle)
expect(component.versionUploadError).toBeNull()
expect(component.editingVersionId).toBeNull()
expect(component.versionLabelDraft).toEqual('')
})
@@ -7,7 +7,6 @@ import {
OnChanges,
OnDestroy,
Output,
signal,
SimpleChanges,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
@@ -60,9 +59,9 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
@Output() versionsUpdated = new EventEmitter<DocumentVersionInfo[]>()
newVersionLabel: string = ''
readonly versionUploadState = signal(UploadState.Idle)
readonly versionUploadError = signal<string | null>(null)
readonly savingVersionLabelId = signal<number | null>(null)
versionUploadState: UploadState = UploadState.Idle
versionUploadError: string | null = null
savingVersionLabelId: number | null = null
editingVersionId: number | null = null
versionLabelDraft: string = ''
@@ -102,7 +101,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
beginEditingVersion(version: DocumentVersionInfo, event?: Event): void {
event?.preventDefault()
event?.stopPropagation()
if (!this.canEditLabels || this.savingVersionLabelId() !== null) return
if (!this.canEditLabels || this.savingVersionLabelId !== null) return
this.editingVersionId = version.id
this.versionLabelDraft = version.version_label ?? ''
}
@@ -117,7 +116,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
submitEditedVersionLabel(version: DocumentVersionInfo, event?: Event): void {
event?.preventDefault()
event?.stopPropagation()
if (this.savingVersionLabelId() !== null) return
if (this.savingVersionLabelId !== null) return
const nextLabel = this.versionLabelDraft?.trim() || null
const currentLabel = version.version_label?.trim() || null
if (nextLabel === currentLabel) {
@@ -159,15 +158,15 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
}
saveVersionLabel(versionId: number, versionLabel: string | null): void {
if (this.savingVersionLabelId() !== null) return
this.savingVersionLabelId.set(versionId)
if (this.savingVersionLabelId !== null) return
this.savingVersionLabelId = versionId
this.documentsService
.updateVersionLabel(this.documentId, versionId, versionLabel)
.pipe(
first(),
finalize(() => {
if (this.savingVersionLabelId() === versionId) {
this.savingVersionLabelId.set(null)
if (this.savingVersionLabelId === versionId) {
this.savingVersionLabelId = null
}
}),
takeUntil(this.destroy$)
@@ -200,8 +199,8 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
const file = input.files[0]
input.value = ''
const label = this.newVersionLabel?.trim()
this.versionUploadState.set(UploadState.Uploading)
this.versionUploadError.set(null)
this.versionUploadState = UploadState.Uploading
this.versionUploadError = null
this.documentsService
.uploadVersion(uploadDocumentId, file, label)
.pipe(
@@ -211,7 +210,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
$localize`Uploading new version. Processing will happen in the background.`
)
this.newVersionLabel = ''
this.versionUploadState.set(UploadState.Processing)
this.versionUploadState = UploadState.Processing
}),
map((taskId) =>
typeof taskId === 'string'
@@ -220,8 +219,8 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
),
switchMap((taskId) => {
if (!taskId) {
this.versionUploadState.set(UploadState.Failed)
this.versionUploadError.set($localize`Missing task ID.`)
this.versionUploadState = UploadState.Failed
this.versionUploadError = $localize`Missing task ID.`
return of(null)
}
return merge(
@@ -241,10 +240,9 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
switchMap((result) => {
if (result?.state !== 'success') {
if (result?.state === 'failed') {
this.versionUploadState.set(UploadState.Failed)
this.versionUploadError.set(
this.versionUploadState = UploadState.Failed
this.versionUploadError =
result.message || $localize`Upload failed.`
)
}
return of(null)
}
@@ -266,10 +264,8 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
},
error: (error) => {
if (uploadDocumentId !== this.documentId) return
this.versionUploadState.set(UploadState.Failed)
this.versionUploadError.set(
error?.message || $localize`Upload failed.`
)
this.versionUploadState = UploadState.Failed
this.versionUploadError = error?.message || $localize`Upload failed.`
this.toastService.showError(
$localize`Error uploading new version`,
error
@@ -279,7 +275,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
}
clearVersionUploadStatus(): void {
this.versionUploadState.set(UploadState.Idle)
this.versionUploadError.set(null)
this.versionUploadState = UploadState.Idle
this.versionUploadError = null
}
}
@@ -10,7 +10,7 @@
[createRef]="createTag.bind(this)"
(opened)="openTagsDropdown()"
[(selectionModel)]="tagSelectionModel"
[documentCounts]="tagDocumentCounts()"
[documentCounts]="tagDocumentCounts"
(apply)="setTags($event)"
shortcutKey="t">
</pngx-filterable-dropdown>
@@ -24,7 +24,7 @@
[createRef]="createCorrespondent.bind(this)"
(opened)="openCorrespondentDropdown()"
[(selectionModel)]="correspondentSelectionModel"
[documentCounts]="correspondentDocumentCounts()"
[documentCounts]="correspondentDocumentCounts"
(apply)="setCorrespondents($event)"
shortcutKey="y">
</pngx-filterable-dropdown>
@@ -38,7 +38,7 @@
[createRef]="createDocumentType.bind(this)"
(opened)="openDocumentTypeDropdown()"
[(selectionModel)]="documentTypeSelectionModel"
[documentCounts]="documentTypeDocumentCounts()"
[documentCounts]="documentTypeDocumentCounts"
(apply)="setDocumentTypes($event)"
shortcutKey="u">
</pngx-filterable-dropdown>
@@ -52,7 +52,7 @@
[createRef]="createStoragePath.bind(this)"
(opened)="openStoragePathDropdown()"
[(selectionModel)]="storagePathsSelectionModel"
[documentCounts]="storagePathDocumentCounts()"
[documentCounts]="storagePathDocumentCounts"
(apply)="setStoragePaths($event)"
shortcutKey="i">
</pngx-filterable-dropdown>
@@ -66,7 +66,7 @@
[createRef]="createCustomField.bind(this)"
(opened)="openCustomFieldsDropdown()"
[(selectionModel)]="customFieldsSelectionModel"
[documentCounts]="customFieldDocumentCounts()"
[documentCounts]="customFieldDocumentCounts"
extraButtonTitle="Set values"
i18n-extraButtonTitle
(extraButton)="setCustomFieldValues($event)"
@@ -95,6 +95,9 @@
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.allSelected || list.selectedCount < 2">
<i-bs name="journals" class="me-1"></i-bs><ng-container i18n>Merge</ng-container>
</button>
<button ngbDropdownItem (click)="runZoneOcrSelected()" [disabled]="!userCanEditAll || list.allSelected">
<i-bs name="file-earmark-ruled" class="me-1"></i-bs><ng-container i18n>Run Zone OCR</ng-container>
</button>
</div>
</div>
</div>
@@ -103,13 +106,13 @@
class="btn btn-sm btn-outline-primary"
id="dropdownSend"
ngbDropdownToggle
[disabled]="disabled || !canSendSelection"
[disabled]="disabled || !list.hasSelection || list.allSelected"
>
<i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container>
</div>
</button>
<div ngbDropdownMenu aria-labelledby="dropdownSend" class="shadow">
<button ngbDropdownItem (click)="createShareLinkBundle()" [disabled]="!canSendSelection">
<button ngbDropdownItem (click)="createShareLinkBundle()" [disabled]="list.allSelected">
<i-bs name="link" class="me-1"></i-bs><ng-container i18n>Create a share link bundle</ng-container>
</button>
<button ngbDropdownItem (click)="manageShareLinkBundles()">
@@ -117,18 +120,18 @@
</button>
<div class="dropdown-divider"></div>
@if (emailEnabled) {
<button ngbDropdownItem (click)="emailSelected()" [disabled]="!canSendSelection">
<button ngbDropdownItem (click)="emailSelected()" [disabled]="list.allSelected">
<i-bs name="envelope" class="me-1"></i-bs><ng-container i18n>Email</ng-container>
</button>
}
</div>
</div>
<div class="btn-group btn-group-sm">
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload()" (click)="downloadSelected()">
@if (!awaitingDownload()) {
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()">
@if (!awaitingDownload) {
<i-bs name="arrow-down"></i-bs>
}
@if (awaitingDownload()) {
@if (awaitingDownload) {
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Preparing download...</span>
</div>
@@ -208,50 +208,6 @@ describe('BulkEditorComponent', () => {
expect(component.tagSelectionModel.selectionSize()).toEqual(1)
})
it('should allow sending an all-filtered selection that fits on the current page', () => {
jest
.spyOn(documentListViewService, 'hasSelection', 'get')
.mockReturnValue(true)
jest
.spyOn(documentListViewService, 'allSelected', 'get')
.mockReturnValue(true)
jest
.spyOn(documentListViewService, 'selectedCount', 'get')
.mockReturnValue(5)
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([1, 2, 3, 4, 5]))
fixture.detectChanges()
expect(component.canSendSelection).toBe(true)
expect(
fixture.debugElement.query(By.css('#dropdownSend')).nativeElement.disabled
).toBe(false)
})
it('should prevent sending an all-filtered selection spanning multiple pages', () => {
jest
.spyOn(documentListViewService, 'hasSelection', 'get')
.mockReturnValue(true)
jest
.spyOn(documentListViewService, 'allSelected', 'get')
.mockReturnValue(true)
jest
.spyOn(documentListViewService, 'selectedCount', 'get')
.mockReturnValue(6)
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([1, 2, 3, 4, 5]))
fixture.detectChanges()
expect(component.canSendSelection).toBe(false)
expect(
fixture.debugElement.query(By.css('#dropdownSend')).nativeElement.disabled
).toBe(true)
})
it('should apply selection data to correspondents menu', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
fixture.detectChanges()
@@ -347,7 +303,7 @@ describe('BulkEditorComponent', () => {
component.openDocumentTypeDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.documentTypeDocumentCounts()).toEqual(
expect(component.documentTypeDocumentCounts).toEqual(
selectionData.selected_document_types
)
})
@@ -364,7 +320,7 @@ describe('BulkEditorComponent', () => {
component.openCorrespondentDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.correspondentDocumentCounts()).toEqual(
expect(component.correspondentDocumentCounts).toEqual(
selectionData.selected_correspondents
)
})
@@ -381,7 +337,7 @@ describe('BulkEditorComponent', () => {
component.openStoragePathDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.storagePathDocumentCounts()).toEqual(
expect(component.storagePathDocumentCounts).toEqual(
selectionData.selected_storage_paths
)
})
@@ -398,7 +354,7 @@ describe('BulkEditorComponent', () => {
component.openCustomFieldsDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.customFieldDocumentCounts()).toEqual(
expect(component.customFieldDocumentCounts).toEqual(
selectionData.selected_custom_fields
)
})
@@ -1641,7 +1597,6 @@ describe('BulkEditorComponent', () => {
expect(modal.componentInstance.customFields.length).toEqual(2)
expect(modal.componentInstance.fieldsToAddIds).toEqual([1, 2])
expect(modal.componentInstance.selection).toEqual({ documents: [3, 4] })
expect(modal.componentInstance.selectionCount).toEqual(2)
expect(modal.componentInstance.documents).toEqual([3, 4])
modal.componentInstance.failed.emit()
@@ -1,11 +1,4 @@
import {
Component,
inject,
Input,
OnDestroy,
OnInit,
signal,
} from '@angular/core'
import { Component, inject, Input, OnDestroy, OnInit } from '@angular/core'
import {
FormControl,
FormGroup,
@@ -19,7 +12,15 @@ import {
} from '@ng-bootstrap/ng-bootstrap'
import { saveAs } from 'file-saver'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { first, map, Observable, Subject, switchMap, takeUntil } from 'rxjs'
import {
first,
forkJoin,
map,
Observable,
Subject,
switchMap,
takeUntil,
} from 'rxjs'
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
import { CustomField } from 'src/app/data/custom-field'
import { MatchingModel } from 'src/app/data/matching-model'
@@ -108,12 +109,12 @@ export class BulkEditorComponent
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
storagePathsSelectionModel = new FilterableDropdownSelectionModel()
customFieldsSelectionModel = new FilterableDropdownSelectionModel(true)
readonly tagDocumentCounts = signal<SelectionDataItem[]>(undefined)
readonly correspondentDocumentCounts = signal<SelectionDataItem[]>(undefined)
readonly documentTypeDocumentCounts = signal<SelectionDataItem[]>(undefined)
readonly storagePathDocumentCounts = signal<SelectionDataItem[]>(undefined)
readonly customFieldDocumentCounts = signal<SelectionDataItem[]>(undefined)
readonly awaitingDownload = signal(false)
tagDocumentCounts: SelectionDataItem[]
correspondentDocumentCounts: SelectionDataItem[]
documentTypeDocumentCounts: SelectionDataItem[]
storagePathDocumentCounts: SelectionDataItem[]
customFieldDocumentCounts: SelectionDataItem[]
awaitingDownload: boolean
unsubscribeNotifier: Subject<any> = new Subject()
@@ -372,8 +373,8 @@ export class BulkEditorComponent
openTagsDropdown() {
if (this.list.allSelected) {
const selectionData = this.list.selectionData
this.tagDocumentCounts.set(selectionData?.selected_tags ?? [])
this.applySelectionData(this.tagDocumentCounts(), this.tagSelectionModel)
this.tagDocumentCounts = selectionData?.selected_tags ?? []
this.applySelectionData(this.tagDocumentCounts, this.tagSelectionModel)
return
}
@@ -381,7 +382,7 @@ export class BulkEditorComponent
.getSelectionData(Array.from(this.list.selected))
.pipe(first())
.subscribe((s) => {
this.tagDocumentCounts.set(s.selected_tags)
this.tagDocumentCounts = s.selected_tags
this.applySelectionData(s.selected_tags, this.tagSelectionModel)
})
}
@@ -389,11 +390,10 @@ export class BulkEditorComponent
openDocumentTypeDropdown() {
if (this.list.allSelected) {
const selectionData = this.list.selectionData
this.documentTypeDocumentCounts.set(
this.documentTypeDocumentCounts =
selectionData?.selected_document_types ?? []
)
this.applySelectionData(
this.documentTypeDocumentCounts(),
this.documentTypeDocumentCounts,
this.documentTypeSelectionModel
)
return
@@ -403,7 +403,7 @@ export class BulkEditorComponent
.getSelectionData(Array.from(this.list.selected))
.pipe(first())
.subscribe((s) => {
this.documentTypeDocumentCounts.set(s.selected_document_types)
this.documentTypeDocumentCounts = s.selected_document_types
this.applySelectionData(
s.selected_document_types,
this.documentTypeSelectionModel
@@ -414,11 +414,10 @@ export class BulkEditorComponent
openCorrespondentDropdown() {
if (this.list.allSelected) {
const selectionData = this.list.selectionData
this.correspondentDocumentCounts.set(
this.correspondentDocumentCounts =
selectionData?.selected_correspondents ?? []
)
this.applySelectionData(
this.correspondentDocumentCounts(),
this.correspondentDocumentCounts,
this.correspondentSelectionModel
)
return
@@ -428,7 +427,7 @@ export class BulkEditorComponent
.getSelectionData(Array.from(this.list.selected))
.pipe(first())
.subscribe((s) => {
this.correspondentDocumentCounts.set(s.selected_correspondents)
this.correspondentDocumentCounts = s.selected_correspondents
this.applySelectionData(
s.selected_correspondents,
this.correspondentSelectionModel
@@ -439,11 +438,10 @@ export class BulkEditorComponent
openStoragePathDropdown() {
if (this.list.allSelected) {
const selectionData = this.list.selectionData
this.storagePathDocumentCounts.set(
this.storagePathDocumentCounts =
selectionData?.selected_storage_paths ?? []
)
this.applySelectionData(
this.storagePathDocumentCounts(),
this.storagePathDocumentCounts,
this.storagePathsSelectionModel
)
return
@@ -453,7 +451,7 @@ export class BulkEditorComponent
.getSelectionData(Array.from(this.list.selected))
.pipe(first())
.subscribe((s) => {
this.storagePathDocumentCounts.set(s.selected_storage_paths)
this.storagePathDocumentCounts = s.selected_storage_paths
this.applySelectionData(
s.selected_storage_paths,
this.storagePathsSelectionModel
@@ -464,11 +462,10 @@ export class BulkEditorComponent
openCustomFieldsDropdown() {
if (this.list.allSelected) {
const selectionData = this.list.selectionData
this.customFieldDocumentCounts.set(
this.customFieldDocumentCounts =
selectionData?.selected_custom_fields ?? []
)
this.applySelectionData(
this.customFieldDocumentCounts(),
this.customFieldDocumentCounts,
this.customFieldsSelectionModel
)
return
@@ -478,7 +475,7 @@ export class BulkEditorComponent
.getSelectionData(Array.from(this.list.selected))
.pipe(first())
.subscribe((s) => {
this.customFieldDocumentCounts.set(s.selected_custom_fields)
this.customFieldDocumentCounts = s.selected_custom_fields
this.applySelectionData(
s.selected_custom_fields,
this.customFieldsSelectionModel
@@ -887,7 +884,7 @@ export class BulkEditorComponent
}
downloadSelected() {
this.awaitingDownload.set(true)
this.awaitingDownload = true
let downloadFileType: string =
this.downloadForm.get('downloadFileTypeArchive').value &&
this.downloadForm.get('downloadFileTypeOriginals').value
@@ -904,7 +901,7 @@ export class BulkEditorComponent
.pipe(first())
.subscribe((result: any) => {
saveAs(result, 'documents.zip')
this.awaitingDownload.set(false)
this.awaitingDownload = false
})
}
@@ -928,6 +925,27 @@ export class BulkEditorComponent
})
}
runZoneOcrSelected() {
const ids = Array.from(this.list.selected)
if (!ids.length) return
const modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Run Zone OCR`
modal.componentInstance.messageBold = $localize`Run zone OCR on ${this.getSelectionSize()} selected document(s)?`
modal.componentInstance.message = $localize`Each document's type template (if it has one) is applied, overwriting the mapped fields.`
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
modal.componentInstance.buttonsEnabled = false
this.executeDocumentAction(
modal,
forkJoin(ids.map((id) => this.documentService.runZoneOcr(id)))
)
})
}
setPermissions() {
let modal = this.modalService.open(PermissionsDialogComponent, {
backdrop: 'static',
@@ -1020,7 +1038,6 @@ export class BulkEditorComponent
)
dialog.selection = this.getSelectionQuery()
dialog.selectionCount = this.getSelectionSize()
dialog.succeeded.subscribe((result) => {
this.toastService.showInfo($localize`Custom fields updated.`)
this.list.reload()
@@ -1041,14 +1058,6 @@ export class BulkEditorComponent
return this.settings.get(SETTINGS_KEYS.EMAIL_ENABLED)
}
public get canSendSelection(): boolean {
return (
this.list.hasSelection &&
(!this.list.allSelected ||
this.list.selectedCount === this.list.selected.size)
)
}
createShareLinkBundle() {
const modal = this.modalService.open(ShareLinkBundleDialogComponent, {
backdrop: 'static',
@@ -1,9 +1,9 @@
<form [formGroup]="form" (ngSubmit)="save()" autocomplete="off">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title" i8n>{
documentCount,
documents.length,
plural,
=1 {Set custom fields for 1 document} other {Set custom fields for {{documentCount}} documents}
=1 {Set custom fields for 1 document} other {Set custom fields for {{documents.length}} documents}
}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
</button>
@@ -42,20 +42,6 @@ describe('CustomFieldsBulkEditDialogComponent', () => {
expect(component.form.contains('2')).toBeTruthy()
})
it('should render the document count for a filtered selection', () => {
component.selection = {
all: true,
filters: { title__icontains: 'invoice' },
}
component.selectionCount = 42
fixture.detectChanges()
expect(component.documents).toEqual([])
expect(
fixture.nativeElement.querySelector('.modal-title').textContent
).toContain('Set custom fields for 42 documents')
})
it('should emit succeeded event and close modal on successful save', () => {
const editSpy = jest
.spyOn(documentService, 'bulkEdit')
@@ -81,14 +81,8 @@ export class CustomFieldsBulkEditDialogComponent {
public selection: DocumentSelectionQuery = { documents: [] }
public selectionCount: number
public get documents(): number[] {
return this.selection.documents ?? []
}
public get documentCount(): number {
return this.selectionCount ?? this.documents.length
return this.selection.documents
}
initForm() {
@@ -2,11 +2,7 @@
<div class="row g-0">
<div class="col-md-2 doc-img-container rounded-start" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit()">
@if (document()) {
@if (priority()) {
<img [ngSrc]="getThumbUrl()" fill priority class="card-img doc-img border-end rounded-start" [class.inverted]="getIsThumbInverted()" alt="{{ document().title | documentTitle }} thumbnail" i18n-alt>
} @else {
<img [ngSrc]="getThumbUrl()" fill class="card-img doc-img border-end rounded-start" [class.inverted]="getIsThumbInverted()" alt="{{ document().title | documentTitle }} thumbnail" i18n-alt>
}
<img [src]="getThumbUrl()" class="card-img doc-img border-end rounded-start" [class.inverted]="getIsThumbInverted()">
<div class="border-end border-bottom bg-light document-card-check">
<div class="form-check">
@@ -88,21 +88,6 @@ describe('DocumentCardLargeComponent', () => {
expect(fixture.nativeElement.textContent).toContain('8 pages')
})
it('should lazy load the thumbnail', () => {
const thumbnail: HTMLImageElement =
fixture.nativeElement.querySelector('img.doc-img')
expect(thumbnail.getAttribute('loading')).toEqual('lazy')
})
it('should prioritize the thumbnail when requested', () => {
fixture.componentRef.setInput('priority', true)
fixture.detectChanges()
const thumbnail: HTMLImageElement =
fixture.nativeElement.querySelector('img.doc-img')
expect(thumbnail.getAttribute('loading')).toEqual('eager')
expect(thumbnail.getAttribute('fetchpriority')).toEqual('high')
})
it('should trim content', () => {
expect(component.contentTrimmed).toHaveLength(503) // includes ...
})
@@ -1,4 +1,4 @@
import { AsyncPipe, NgOptimizedImage } from '@angular/common'
import { AsyncPipe } from '@angular/common'
import {
AfterViewInit,
Component,
@@ -46,7 +46,6 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
TagComponent,
CustomFieldDisplayComponent,
AsyncPipe,
NgOptimizedImage,
UsernamePipe,
CorrespondentNamePipe,
DocumentTypeNamePipe,
@@ -66,7 +65,6 @@ export class DocumentCardLargeComponent
private documentService = inject(DocumentService)
settingsService = inject(SettingsService)
readonly selected = input(false)
readonly priority = input(false)
readonly displayFields = input<string[]>(
DEFAULT_DISPLAY_FIELDS.map((f) => f.id)
)
@@ -2,11 +2,7 @@
<div class="card h-100 shadow-sm document-card" [class.placeholder-glow]="!document()" [class.card-selected]="selected()" (mouseleave)="mouseLeaveCard()">
<div class="border-bottom doc-img-container rounded-top" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit(this)">
@if (document()) {
@if (priority()) {
<img class="card-img doc-img" [class.inverted]="getIsThumbInverted()" [ngSrc]="getThumbUrl()" fill priority alt="{{ document().title | documentTitle }} thumbnail" i18n-alt>
} @else {
<img class="card-img doc-img" [class.inverted]="getIsThumbInverted()" [ngSrc]="getThumbUrl()" fill alt="{{ document().title | documentTitle }} thumbnail" i18n-alt>
}
<img class="card-img doc-img" [class.inverted]="getIsThumbInverted()" [src]="getThumbUrl()">
<div class="border-end border-bottom bg-light py-1 px-2 document-card-check">
<div class="form-check">
@@ -22,14 +22,10 @@
}
}
.doc-img-container {
position: relative;
height: 180px;
}
.doc-img {
object-fit: cover;
object-position: top left;
height: 180px;
}
.document-card-check {
@@ -61,21 +61,6 @@ describe('DocumentCardSmallComponent', () => {
expect(fixture.nativeElement.textContent).toContain('12 pages')
})
it('should lazy load the thumbnail', () => {
const thumbnail: HTMLImageElement =
fixture.nativeElement.querySelector('img.doc-img')
expect(thumbnail.getAttribute('loading')).toEqual('lazy')
})
it('should prioritize the thumbnail when requested', () => {
fixture.componentRef.setInput('priority', true)
fixture.detectChanges()
const thumbnail: HTMLImageElement =
fixture.nativeElement.querySelector('img.doc-img')
expect(thumbnail.getAttribute('loading')).toEqual('eager')
expect(thumbnail.getAttribute('fetchpriority')).toEqual('high')
})
it('should display a document, limit tags to 5', () => {
expect(fixture.nativeElement.textContent).toContain('Document 10')
expect(
@@ -1,4 +1,4 @@
import { AsyncPipe, NgOptimizedImage } from '@angular/common'
import { AsyncPipe } from '@angular/common'
import {
AfterViewInit,
Component,
@@ -46,7 +46,6 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
TagComponent,
CustomFieldDisplayComponent,
AsyncPipe,
NgOptimizedImage,
UsernamePipe,
CorrespondentNamePipe,
DocumentTypeNamePipe,
@@ -66,7 +65,6 @@ export class DocumentCardSmallComponent
private documentService = inject(DocumentService)
settingsService = inject(SettingsService)
readonly selected = input(false)
readonly priority = input(false)
readonly document = input<Document>(undefined)
readonly displayFields = input<string[]>(
DEFAULT_DISPLAY_FIELDS.map((f) => f.id)
@@ -115,7 +115,7 @@
</pngx-page-header>
<div class="row sticky-top py-3 mt-n2 mt-md-n3 bg-body">
<pngx-filter-editor [hidden]="isBulkEditing" [disabled]="isBulkEditing" [filterRules]="list.filterRules" (filterRulesChange)="onFilterRulesChange($event)" (resetFilterRules)="onFilterRulesReset($event)" [unmodifiedFilterRules]="unmodifiedFilterRules()" [selectionData]="list.selectionData" #filterEditor></pngx-filter-editor>
<pngx-filter-editor [hidden]="isBulkEditing" [disabled]="isBulkEditing" [filterRules]="list.filterRules" (filterRulesChange)="onFilterRulesChange($event)" (resetFilterRules)="onFilterRulesReset($event)" [unmodifiedFilterRules]="unmodifiedFilterRules" [selectionData]="list.selectionData" #filterEditor></pngx-filter-editor>
<pngx-bulk-editor [hidden]="!isBulkEditing" [disabled]="!isBulkEditing"></pngx-bulk-editor>
</div>
@@ -164,10 +164,9 @@
} @else {
@if (list.displayMode === DisplayMode.LARGE_CARDS) {
<div>
@for (d of list.documents; track d.id; let i = $index) {
@for (d of list.documents; track d.id) {
<pngx-document-card-large
[selected]="list.isSelected(d)"
[priority]="i < 2"
(toggleSelected)="toggleSelected(d, $event)"
(dblClickDocument)="openDocumentDetail(d)"
[document]="d"
@@ -399,10 +398,9 @@
}
@if (list.displayMode === DisplayMode.SMALL_CARDS) {
<div class="row row-cols-paperless-cards">
@for (d of list.documents; track d.id; let i = $index) {
@for (d of list.documents; track d.id) {
<pngx-document-card-small class="p-0"
[selected]="list.isSelected(d)"
[priority]="i < 6"
(toggleSelected)="toggleSelected(d, $event)"
(dblClickDocument)="openDocumentDetail(d)"
[document]="d"
@@ -5,7 +5,6 @@ import {
OnDestroy,
OnInit,
QueryList,
signal,
ViewChild,
ViewChildren,
} from '@angular/core'
@@ -150,7 +149,7 @@ export class DocumentListComponent
)
}
readonly unmodifiedFilterRules = signal<FilterRule[]>([])
unmodifiedFilterRules: FilterRule[] = []
private unmodifiedSavedView: SavedView
private activeSavedView: SavedView | null = null
@@ -300,7 +299,7 @@ export class DocumentListComponent
this.savedViewService.setDocumentCount(view, this.list.collectionSize)
})
this.updateDisplayCustomFields()
this.unmodifiedFilterRules.set(view.filter_rules)
this.unmodifiedFilterRules = view.filter_rules
})
this.route.queryParamMap
@@ -317,7 +316,7 @@ export class DocumentListComponent
this.activeSavedView = null
this.list.activateSavedView(null)
this.list.loadFromQueryParams(queryParams)
this.unmodifiedFilterRules.set([])
this.unmodifiedFilterRules = []
}
})
@@ -416,7 +415,7 @@ export class DocumentListComponent
this.toastService.showInfo(
$localize`View "${this.list.activeSavedViewTitle}" saved successfully.`
)
this.unmodifiedFilterRules.set(this.list.filterRules)
this.unmodifiedFilterRules = this.list.filterRules
},
error: (err) => {
this.toastService.showError(
@@ -80,7 +80,7 @@
shortcutKey="i"></pngx-filterable-dropdown>
}
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.CustomField) && customFields().length > 0) {
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.CustomField) && customFields.length > 0) {
<pngx-custom-fields-query-dropdown class="flex-fill fade" [class.show]="show()" title="Custom fields" icon="ui-radios" i18n-title
[(selectionModel)]="customFieldQueriesModel"
(selectionModelChange)="updateRules()"
@@ -328,7 +328,7 @@ describe('FilterEditorComponent', () => {
})
it('should ingest legacy text filter rules for doc title + content', () => {
expect(component.textFilter).toBeNull()
expect(component.textFilter).toEqual(null)
component.filterRules = [
{
rule_type: FILTER_TITLE_CONTENT,
@@ -1,6 +1,5 @@
import {
AfterViewInit,
ChangeDetectorRef,
Component,
ElementRef,
EventEmitter,
@@ -10,7 +9,6 @@ import {
Output,
ViewChild,
inject,
signal,
} from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import {
@@ -351,7 +349,7 @@ export class FilterEditorComponent
@ViewChild('textFilterInput')
textFilterInput: ElementRef
readonly customFields = signal<CustomField[]>([])
customFields: CustomField[] = []
tagDocumentCounts: SelectionDataItem[]
correspondentDocumentCounts: SelectionDataItem[]
@@ -516,7 +514,6 @@ export class FilterEditorComponent
this.documentService.get(this._moreLikeId).subscribe((result) => {
this._moreLikeDoc = result
this._textFilter = result.title
this.changeDetector.markForCheck()
})
break
case FILTER_CREATED_AFTER:
@@ -1165,7 +1162,6 @@ export class FilterEditorComponent
private loadingCountTotal: number = 0
private loadingCount: number = 0
private readonly changeDetector = inject(ChangeDetectorRef)
private maybeCompleteLoading() {
this.loadingCount++
@@ -1233,7 +1229,7 @@ export class FilterEditorComponent
) {
this.loadingCountTotal++
this.customFieldService.listAll().subscribe((result) => {
this.customFields.set(result.results)
this.customFields = result.results
this.maybeCompleteLoading()
})
}
@@ -1,18 +1,18 @@
<div class="row mb-3">
<div class="col mb-2 mb-xl-0">
<div class="form-inline d-flex align-items-center">
<label class="text-muted me-2 mb-0" for="managementNameFilter" i18n>Filter by:</label>
<input id="managementNameFilter" class="form-control form-control-sm flex-fill w-auto" type="text" autofocus [(ngModel)]="nameFilter" (keyup)="onNameFilterKeyUp($event)" placeholder="Name" i18n-placeholder>
<label class="text-muted me-2 mb-0" i18n>Filter by:</label>
<input class="form-control form-control-sm flex-fill w-auto" type="text" autofocus [(ngModel)]="nameFilter" (keyup)="onNameFilterKeyUp($event)" placeholder="Name" i18n-placeholder>
</div>
</div>
<div class="col-auto mb-2 mb-xl-0">
<div class="form-inline d-flex align-items-center">
<div class="input-group input-group-sm w-auto d-none d-md-flex">
<label class="input-group-text border-0" for="managementPageSize" i18n>Show:</label>
<span class="input-group-text border-0" i18n>Show:</span>
</div>
<div class="input-group input-group-sm w-auto me-3">
<select id="managementPageSize" class="form-select form-select-sm small" [(ngModel)]="pageSize">
<select class="form-select form-select-sm small" [(ngModel)]="pageSize">
<option [ngValue]="25">25</option>
<option [ngValue]="50">50</option>
<option [ngValue]="100">100</option>
@@ -83,12 +83,12 @@ describe('TagListComponent', () => {
]
component['_nameFilter'] = null // Simulate empty name filter
const filtered = component.filterData(tags as any)
expect(filtered).toHaveLength(2)
expect(filtered.length).toBe(2)
expect(filtered.find((t) => t.id === 2)).toBeUndefined()
component['_nameFilter'] = 'Tag2' // Simulate non-empty name filter
const filteredWithName = component.filterData(tags as any)
expect(filteredWithName).toHaveLength(2)
expect(filteredWithName.length).toBe(2)
expect(filteredWithName.find((t) => t.id === 2)).toBeUndefined()
expect(
filteredWithName
@@ -26,7 +26,7 @@
<th scope="col" style="width: 40px;">
<div class="form-check m-0 ms-2 me-n2">
<input type="checkbox" class="form-check-input" id="all-objects" [ngModel]="toggleAllEnabled()" (ngModelChange)="toggleAllEnabled.set($event)" [disabled]="processedMails().length === 0" (click)="toggleAll($event); $event.stopPropagation();">
<label class="form-check-label" for="all-objects"><span class="visually-hidden" i18n>Select all processed email messages</span></label>
<label class="form-check-label" for="all-objects"></label>
</div>
</th>
<th scope="col" i18n>Subject</th>
@@ -5,6 +5,6 @@
pre {
white-space: pre-wrap;
overflow-wrap: break-word;
word-break: break-word;
}
}
@@ -0,0 +1,34 @@
@if (zones.length === 0) {
<p class="text-muted" i18n>
No zones defined. Load a document preview and draw rectangles to add zones.
</p>
}
<div class="list-group">
@for (zone of zones; track $index; let i = $index) {
<div
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center"
[style.box-shadow]="selectedZoneIndex === i ? 'inset 3px 0 0 0 var(--bs-primary)' : null"
>
<div class="flex-grow-1" role="button" style="cursor: pointer;" (click)="zoneSelected.emit(i)">
<div>
<strong [class.text-primary]="selectedZoneIndex === i">
{{ zone.name }}
</strong>
</div>
<div class="small text-muted">
{{ getZoneTargetName(zone) }} - {{ zone.width }}x{{ zone.height }}px
<ng-container i18n>p.</ng-container>{{ zonePage(zone) }}
</div>
</div>
<div class="btn-group">
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="zoneSelected.emit(i)" title="Edit" i18n-title>
<i-bs name="pencil"></i-bs>
</button>
<button class="btn btn-sm btn-outline-danger" type="button" (click)="zoneRemoved.emit(i)" title="Delete" i18n-title>
<i-bs name="trash"></i-bs>
</button>
</div>
</div>
}
</div>
@@ -0,0 +1,72 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { CustomField } from 'src/app/data/custom-field'
import { OcrTemplateZone } from 'src/app/data/ocr-template'
import { OcrTemplateEditorZoneListComponent } from './ocr-template-editor-zone-list.component'
function zone(overrides: Partial<OcrTemplateZone> = {}): OcrTemplateZone {
return {
name: 'Zone 1',
target: 'custom_field',
custom_field: 7,
x: 10,
y: 20,
width: 30,
height: 40,
page: 1,
ocr_language: 'eng',
transform: 'strip',
validation_regex: '',
order: 0,
...overrides,
}
}
describe('OcrTemplateEditorZoneListComponent', () => {
let fixture: ComponentFixture<OcrTemplateEditorZoneListComponent>
let component: OcrTemplateEditorZoneListComponent
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
OcrTemplateEditorZoneListComponent,
NgxBootstrapIconsModule.pick(allIcons),
],
}).compileComponents()
fixture = TestBed.createComponent(OcrTemplateEditorZoneListComponent)
component = fixture.componentInstance
})
it('shows empty state when no zones are defined', () => {
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('No zones defined')
})
it('renders zone target, size, and page', () => {
component.zones = [zone()]
component.customFields = [{ id: 7, name: 'Invoice Number' } as CustomField]
fixture.detectChanges()
const text = fixture.nativeElement.textContent
expect(text).toContain('Zone 1')
expect(text).toContain('Invoice Number')
expect(text).toContain('30x40px')
expect(text).toContain('p.1')
})
it('emits select and remove events', () => {
component.zones = [zone()]
const selectSpy = jest.spyOn(component.zoneSelected, 'emit')
const removeSpy = jest.spyOn(component.zoneRemoved, 'emit')
fixture.detectChanges()
const buttons = fixture.nativeElement.querySelectorAll('button')
buttons[0].click()
buttons[1].click()
expect(selectSpy).toHaveBeenCalledWith(0)
expect(removeSpy).toHaveBeenCalledWith(0)
})
})
@@ -0,0 +1,41 @@
import { Component, EventEmitter, Input, Output } from '@angular/core'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { CustomField } from 'src/app/data/custom-field'
import { OCR_BUILTIN_TARGETS, OcrTemplateZone } from 'src/app/data/ocr-template'
import { getZonePage } from '../zone-geometry'
@Component({
selector: 'pngx-ocr-template-zone-list',
imports: [NgxBootstrapIconsModule],
templateUrl: './ocr-template-editor-zone-list.component.html',
})
export class OcrTemplateEditorZoneListComponent {
@Input() zones: OcrTemplateZone[] = []
@Input() selectedZoneIndex: number | null = null
@Input() previewPage = 0
@Input() previewPageCount: number | null = null
@Input() customFields: CustomField[] = []
@Output() zoneSelected = new EventEmitter<number>()
@Output() zoneRemoved = new EventEmitter<number>()
zonePage(zone: OcrTemplateZone): number {
return getZonePage(zone, this.previewPage, this.previewPageCount)
}
getZoneTargetName(zone: OcrTemplateZone): string {
const target = zone.target || 'custom_field'
if (target === 'custom_field') {
return zone.custom_field
? this.getCustomFieldName(zone.custom_field)
: $localize`(no field)`
}
return OCR_BUILTIN_TARGETS.find((t) => t.id === target)?.name ?? target
}
private getCustomFieldName(id: number): string {
return (
this.customFields.find((field) => field.id === id)?.name ?? `Field #${id}`
)
}
}
@@ -0,0 +1,442 @@
<pngx-page-header [title]="pageTitle" [id]="template.id">
<div class="input-group input-group-sm me-5 align-items-center">
<div class="input-group-text">
<i-bs name="file-text"></i-bs>
</div>
<input
type="text"
class="form-control"
[(ngModel)]="previewDocModel"
[ngbTypeahead]="searchDocuments"
[inputFormatter]="documentFormatter"
[resultFormatter]="documentFormatter"
(selectItem)="onPreviewDocSelected($event)"
[editable]="false"
placeholder="Search documents by title..."
i18n-placeholder
/>
</div>
<div class="d-flex align-items-center flex-wrap gap-2">
<div class="input-group input-group-sm ms-2 d-none d-md-flex">
<div class="input-group-text" i18n>Page</div>
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewPageCount" [(ngModel)]="previewPageDisplay" />
<div class="input-group-text" i18n>of {{previewPageCount}}</div>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Previous" (click)="prevPage()" [disabled]="!pageImageUrl || previewPage <= 0">
<i-bs width="1.2em" height="1.2em" name="arrow-left"></i-bs>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Next" (click)="nextPage()" [disabled]="!pageImageUrl || previewPage >= (previewPageCount ?? 1) - 1">
<i-bs width="1.2em" height="1.2em" name="arrow-right"></i-bs>
</button>
<div class="input-group input-group-sm">
<button class="btn btn-outline-secondary" (click)="zoomOut()" i18n>-</button>
<span class="input-group-text">{{ zoom * 100 | number: '1.0-0' }}%</span>
<button class="btn btn-outline-secondary" (click)="zoomIn()" i18n>+</button>
</div>
</div>
</pngx-page-header>
<div class="row">
<div class="col-md-4">
<div class="btn-toolbar mb-1 border-bottom">
<div class="btn-group pb-3">
<a routerLink="/ocr-templates" class="btn btn-sm btn-outline-secondary">
<i-bs width="1.2em" height="1.2em" name="x"></i-bs>
<span class="ms-1" i18n>Close</span>
</a>
</div>
<div class="btn-group ms-auto pb-3">
<button class="btn btn-sm btn-primary" (click)="save()" [disabled]="saving">
@if (saving) {
<span class="spinner-border spinner-border-sm me-1"></span>
}
<span i18n>Save</span>
</button>
</div>
</div>
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTab" class="nav-underline flex-nowrap flex-md-wrap overflow-auto">
<li ngbNavItem="settings">
<a ngbNavLink i18n>Settings</a>
<ng-template ngbNavContent>
<div class="row mb-3">
<div class="col-9">
<pngx-input-text [(ngModel)]="template.name" title="Template name" i18n-title></pngx-input-text>
</div>
<div class="col-3">
<pngx-input-switch [(ngModel)]="template.enabled" title="Enabled" i18n-title></pngx-input-switch>
</div>
</div>
<pngx-input-select [(ngModel)]="template.document_type" [items]="documentTypes" bindLabel="name" bindValue="id" title="Document type" i18n-title></pngx-input-select>
<small class="text-muted" i18n>
Draw rectangles on the preview to define extraction zones. Use the
page controls above the preview to add zones on different pages.
</small>
</ng-template>
</li>
<li ngbNavItem="zones">
<a ngbNavLink><ng-container i18n>Zones</ng-container> <span class="badge bg-primary ms-2">{{ template.zones.length }}</span></a>
<ng-template ngbNavContent>
<pngx-ocr-template-zone-list
[zones]="template.zones"
[selectedZoneIndex]="selectedZoneIndex"
[previewPage]="previewPage"
[previewPageCount]="previewPageCount"
[customFields]="customFields"
(zoneSelected)="selectZone($event)"
(zoneRemoved)="removeZone($event)"
></pngx-ocr-template-zone-list>
</ng-template>
</li>
<li ngbNavItem="zone">
<a ngbNavLink i18n>Zone</a>
<ng-template ngbNavContent>
@if (selectedZone; as zone) {
<div class="d-flex justify-content-between align-items-center mb-3">
<strong>{{ zone.name }}</strong>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-primary" (click)="save()" [disabled]="saving">
@if (saving) {
<span class="spinner-border spinner-border-sm me-1"></span>
}
<span i18n>Save</span>
</button>
<button class="btn btn-sm btn-outline-danger" (click)="deleteSelectedZone()">
<i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Delete zone</ng-container>
</button>
</div>
</div>
<div class="mb-3">
<label class="form-label" i18n>Zone Name</label>
<input
type="text"
class="form-control"
[(ngModel)]="zone.name"
/>
</div>
<div class="mb-3">
<label class="form-label" i18n>Page</label>
<input
type="number"
class="form-control"
[(ngModel)]="zone.page"
min="-1"
/>
<small class="text-muted" i18n>Page this zone is on. Use -1 for the last page. Set automatically when you draw it.</small>
</div>
<div class="mb-3">
<label class="form-label" i18n>Field</label>
<div class="input-group">
<select class="form-select" [ngModel]="zoneFieldValue(zone)" (ngModelChange)="setZoneField(zone, $event)">
<optgroup label="Built-in fields" i18n-label>
@for (t of builtinTargets; track t.id) {
<option [ngValue]="t.id">{{ t.name }}</option>
}
</optgroup>
<optgroup label="Custom fields" i18n-label>
@for (cf of customFields; track cf.id) {
<option [ngValue]="cf.id">{{ cf.name }} ({{ cf.data_type }})</option>
}
</optgroup>
</select>
<button
class="btn btn-outline-secondary"
type="button"
(click)="openQuickCreate(selectedZoneIndex)"
title="Create new custom field"
i18n-title
>
<i-bs name="plus"></i-bs>
</button>
</div>
<small class="text-muted" i18n>Write the extracted value to a custom field, or to a built-in field (Title, ASN, Date created).</small>
</div>
@if (isFieldShared(zone)) {
<div class="card mb-3 border-info">
<div class="card-body">
<h6 class="card-title d-flex align-items-center gap-2">
<i-bs name="braces"></i-bs>
<span i18n>Combine zones into this field</span>
</h6>
<p class="small text-muted mb-2" i18n>
More than one zone writes to this field. Build the combined
value below: click a zone to insert its token, and type any
separators or literal text between tokens.
</p>
<div class="d-flex flex-wrap gap-1 mb-2">
@for (z of zonesForField(zone); track $index) {
<button
type="button"
class="btn btn-sm btn-outline-info"
(click)="insertCombineToken(zone, z)"
title="Insert token"
i18n-title
>
+ {{ z.name || 'Zone' }}
</button>
}
</div>
<input
type="text"
class="form-control font-monospace"
[ngModel]="getCombineFormat(zone)"
(ngModelChange)="setCombineFormat(zone, $event)"
placeholder="{Zone 1} - {Zone 2}"
/>
<small class="text-muted" i18n>
Tokens are matched by zone name. An empty zone leaves its
token blank and the stray separator is trimmed. Leave empty
to just join the zones in order with a space.
</small>
</div>
</div>
}
@if (showQuickCreate) {
<div class="card mb-3 border-primary">
<div class="card-body">
<h6 class="card-title" i18n>Create Custom Field</h6>
<div class="mb-2">
<label class="form-label small" i18n>Field Name</label>
<input type="text" class="form-control form-control-sm"
[(ngModel)]="quickCreateName" placeholder="e.g. Invoice Number" />
</div>
<div class="mb-2">
<label class="form-label small" i18n>Field Type</label>
<select class="form-select form-select-sm" [(ngModel)]="quickCreateType">
@for (t of quickCreateTypes; track t.id) {
<option [ngValue]="t.id">{{ t.name }}</option>
}
</select>
</div>
<div class="d-flex gap-2">
<button class="btn btn-primary btn-sm" (click)="submitQuickCreate()"
[disabled]="!quickCreateName.trim()" i18n>
Create & Assign
</button>
<button class="btn btn-outline-secondary btn-sm" (click)="cancelQuickCreate()" i18n>
Cancel
</button>
</div>
</div>
</div>
}
<div class="mb-3">
<label class="form-label" i18n>OCR Language</label>
<ng-select
[items]="ocrLanguageOptions"
bindLabel="name"
bindValue="id"
[multiple]="true"
[closeOnSelect]="false"
[ngModel]="ocrLanguageArray(zone)"
(ngModelChange)="setOcrLanguages(zone, $event)"
placeholder="Select languages"
i18n-placeholder
></ng-select>
</div>
<div class="mb-3">
<label class="form-label" i18n>Transform</label>
<select class="form-select" [(ngModel)]="zone.transform">
@for (opt of transformOptions; track opt.id) {
<option [ngValue]="opt.id">{{ opt.name }}</option>
}
</select>
</div>
@if (zone.transform === dateTransform) {
<div class="mb-3">
<label class="form-label" i18n>Date format</label>
<select class="form-select" [ngModel]="dateFormatChoice(zone)" (ngModelChange)="setDateFormatChoice(zone, $event)">
@for (opt of dateFormatOptions; track opt.id) {
<option [ngValue]="opt.id">{{ opt.name }}</option>
}
<option [ngValue]="customDateFormatChoice" i18n>Custom...</option>
</select>
@if (usesCustomDateFormat(zone)) {
<div class="input-group mt-2">
<input type="text" class="form-control font-monospace" [(ngModel)]="zone.date_format" placeholder="%d.%m.%Y" />
<button class="btn btn-outline-secondary" type="button" [ngbPopover]="dateFmtHelp" [autoClose]="true" title="Date format help" i18n-title>
<i-bs name="question-circle"></i-bs>
</button>
</div>
<ng-template #dateFmtHelp>
<p class="mb-1" i18n>Python date codes:</p>
<ul class="mb-1 ps-3">
<li><code>%d</code> <ng-container i18n>day (01-31)</ng-container></li>
<li><code>%m</code> <ng-container i18n>month (01-12)</ng-container></li>
<li><code>%Y</code> <ng-container i18n>year, 4-digit</ng-container></li>
<li><code>%y</code> <ng-container i18n>year, 2-digit</ng-container></li>
<li><code>%b</code> <ng-container i18n>month name (Jan)</ng-container></li>
</ul>
<span i18n>Example:</span> <code>%d.%m.%Y</code> -> 03.03.2026
</ng-template>
}
</div>
}
<div class="mb-3">
<label class="form-label" i18n>Validation Regex</label>
<input
type="text"
class="form-control font-monospace"
[(ngModel)]="zone.validation_regex"
placeholder="e.g. \d{2}\.\d{2}\.\d{4}"
>
</div>
<div class="text-muted small">
{{ zone.x }}, {{ zone.y }} - {{ zone.width }}x{{ zone.height }}px
</div>
<hr class="my-3" />
<h6 i18n>Test</h6>
@if (!previewDocId) {
<p class="text-muted small mb-0" i18n>
Load a document in the Settings tab to test this zone.
</p>
} @else {
<button class="btn btn-sm btn-outline-secondary" (click)="testZone()" [disabled]="zoneTesting">
@if (zoneTesting) {
<span class="spinner-border spinner-border-sm me-1"></span>
}
<span i18n>Test this zone</span>
</button>
@if (zoneTestResult) {
@if (zoneTestResult.error) {
<div class="alert alert-warning py-2 mt-2 mb-0 small">{{ zoneTestResult.error }}</div>
} @else {
<dl class="row small mt-2 mb-0">
<dt class="col-sm-4" i18n>OCR text</dt>
<dd class="col-sm-8"><code>{{ zoneTestResult.raw_text || '(nothing detected)' }}</code></dd>
<dt class="col-sm-4" i18n>Value</dt>
<dd class="col-sm-8"><code>{{ zoneTestResult.value || '(empty)' }}</code></dd>
@if (zoneTestResult.regex) {
<dt class="col-sm-4" i18n>Validation</dt>
<dd class="col-sm-8">
@if (zoneTestResult.regex_match) {
<span class="badge bg-success" i18n>Regex matches</span>
} @else {
<span class="badge bg-danger" i18n>Regex does not match</span>
}
</dd>
}
</dl>
}
}
}
} @else {
<p class="text-muted" i18n>
Select a zone from the Zones tab, or draw a rectangle on the document to create one.
</p>
}
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav" class="mt-3"></div>
</div>
<!-- Right column: Document preview with zone overlay -->
<div class="col-md-8">
@if (pageImageUrl) {
<div class="zone-preview-scroll border">
<div class="zone-preview-stage" [style.width.%]="zoom * 100">
<img
#pageImage
[src]="pageImageUrl"
(load)="onImageLoad()"
class="zone-preview-image"
[style.visibility]="imageLoaded ? 'visible' : 'hidden'"
crossorigin="use-credentials"
/>
@if (imageLoaded) {
<svg
#zoneOverlay
class="zone-overlay"
[attr.viewBox]="overlayViewBox()"
preserveAspectRatio="none"
[style.cursor]="overlayCursor"
(mousedown)="onOverlayMouseDown($event)"
(mousemove)="onOverlayMouseMove($event)"
(mouseup)="onOverlayMouseUp($event)"
>
@for (zone of template.zones; track $index; let i = $index) {
@if (zoneDisplayRect(i); as rect) {
<g>
<rect
class="zone-rect"
[class.zone-rect-selected]="selectedZoneIndex === i"
[attr.x]="rect.x"
[attr.y]="rect.y"
[attr.width]="rect.w"
[attr.height]="rect.h"
[attr.stroke]="zoneColor(i)"
[attr.fill]="zoneFill(i)"
></rect>
<text
class="zone-label"
[attr.x]="rect.x + overlayUnitSize(6)"
[attr.y]="zoneLabelY(rect)"
[attr.font-size]="overlayFontSize()"
[attr.fill]="zoneColor(i)"
>{{ zoneLabel(zone, i) }}</text>
@if (selectedZoneIndex === i) {
@for (handle of resizeHandles(rect); track handle.handle) {
<rect
class="zone-resize-handle"
[attr.x]="handle.x - overlayHandleSize() / 2"
[attr.y]="handle.y - overlayHandleSize() / 2"
[attr.width]="overlayHandleSize()"
[attr.height]="overlayHandleSize()"
[attr.fill]="zoneColor(i)"
></rect>
}
}
</g>
}
}
@if (drawingRect(); as rect) {
<rect
class="zone-drawing-rect"
[attr.x]="rect.x"
[attr.y]="rect.y"
[attr.width]="rect.w"
[attr.height]="rect.h"
></rect>
}
</svg>
}
@if (!imageLoaded) {
<div class="d-flex justify-content-center p-5">
<div class="spinner-border" role="status">
<span class="visually-hidden" i18n>Loading page...</span>
</div>
</div>
}
</div>
</div>
} @else {
<div class="border rounded p-5 text-center text-muted">
<i-bs name="file-earmark-image" width="48" height="48"></i-bs>
<p class="mt-3" i18n>
Enter a document ID and click "Load" to preview a page and draw extraction zones.
</p>
</div>
}
</div>
</div>
@@ -0,0 +1,63 @@
:host {
display: block;
}
.zone-preview-scroll {
max-height: 78vh;
overflow: auto;
}
.zone-preview-stage {
display: inline-block;
position: relative;
}
.zone-preview-image {
display: block;
width: 100%;
}
.zone-overlay {
height: 100%;
inset: 0;
position: absolute;
touch-action: none;
width: 100%;
}
.zone-rect,
.zone-drawing-rect {
vector-effect: non-scaling-stroke;
}
.zone-rect {
stroke-width: 2;
}
.zone-rect-selected {
stroke-width: 3;
}
.zone-label {
font-family: var(--bs-font-sans-serif);
font-weight: 600;
paint-order: stroke;
pointer-events: none;
stroke: #fff;
stroke-linejoin: round;
stroke-width: 4px;
vector-effect: non-scaling-stroke;
}
.zone-resize-handle {
stroke: #fff;
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.zone-drawing-rect {
fill: rgba(105, 219, 124, 0.25);
stroke: #69db7c;
stroke-dasharray: 5 5;
stroke-width: 2;
}

Some files were not shown because too many files have changed in this diff Show More