mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-27 14:55:19 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a830a731bf | ||
|
|
7d8147c21d |
No files matched your search
+1
-79
@@ -109,7 +109,7 @@ Think of versions as **file history** for a document.
|
||||
Paperless-ngx includes management lists for tags, correspondents, document types
|
||||
and more. These areas allow you to view, add, edit, delete and manage permissions
|
||||
for these objects. You can also manage saved views, mail accounts, mail rules,
|
||||
workflows, [OCR templates](#ocr-templates) and more from the management sections.
|
||||
workflows and more from the management sections.
|
||||
|
||||
### Nested Tags
|
||||
|
||||
@@ -794,84 +794,6 @@ The following custom field types are supported:
|
||||
- `Document Link`: reference(s) to other document(s) displayed as links, automatically creates a symmetrical link in reverse
|
||||
- `Select`: a pre-defined list of strings from which the user can choose
|
||||
|
||||
## OCR Templates {#ocr-templates}
|
||||
|
||||
OCR templates read text from fixed areas ("zones") of a document and write it to a
|
||||
[custom field](#custom-fields), or to the document's title, archive serial number or created date. They are
|
||||
useful for documents that always share the same layout, such as bills from a particular utility. They allow you
|
||||
to draw a zone around the account number once, and every new bill gets its account number filled in.
|
||||
|
||||
Like tags or document types, each template has a [matching algorithm](advanced_usage.md#matching) that
|
||||
decides which documents it automatically applies to (or not), based on their content.
|
||||
|
||||
### Creating a template
|
||||
|
||||
1. Open **OCR Templates** under "Manage" and choose **Add Template**.
|
||||
2. Give the template a name and set up its matching, for example **Any word** with the utility's name. Choose
|
||||
**None** for a template that should only ever be run manually.
|
||||
3. Search for a sample document with the layout the template is for. Its pages are shown next to the template
|
||||
settings.
|
||||
4. Draw zones on the document. Click a zone to select and edit it.
|
||||
5. Choose the field each zone fills.
|
||||
6. Use **Test** to run OCR on the selected zone of the sample document and see the text it finds and the value
|
||||
that would be written.
|
||||
|
||||
Zones are positioned relative to the sample document's page, so templates work best when the documents they
|
||||
match have an identical layout. Keep the matching specific enough that it only catches documents with that
|
||||
layout.
|
||||
|
||||
### Zone settings
|
||||
|
||||
- **Page**: the page the zone is on. Turn on **Last page** for zones that are always on the final page,
|
||||
regardless of how many pages a document has.
|
||||
- **OCR language**: the Tesseract languages used to read the zone. Languages other than the defaults must be
|
||||
installed, see [`PAPERLESS_OCR_LANGUAGES`](configuration.md#PAPERLESS_OCR_LANGUAGES).
|
||||
- **Transform**: post-processing applied to the text, such as stripping whitespace, keeping only numbers,
|
||||
parsing a date or reading a QR code or barcode instead of text.
|
||||
- **Date format**: for the "Parse date" transform, the format of the date using
|
||||
[Python date codes](https://docs.python.org/3/library/datetime.html#format-codes), e.g. `%d.%m.%Y`.
|
||||
Leave it empty to detect the format automatically.
|
||||
- **Validation regex**: a regular expression the whole value must match. Values that do not match are not
|
||||
written.
|
||||
|
||||
Zones can fill custom fields of type text, long text, URL, date, integer, number, monetary or boolean.
|
||||
Document link and select fields are not supported.
|
||||
|
||||
### Combining zones
|
||||
|
||||
When several zones fill the same field, a **Combined value** setting appears for that field. Use it to arrange
|
||||
the zones' values with `{Zone name}` tokens and any text in between, for example `{Start} - {End}`. If it
|
||||
is left empty, the values are joined with spaces in zone order.
|
||||
|
||||
### When templates run
|
||||
|
||||
When a document is added, every enabled template that matches its content runs automatically. A template is
|
||||
only used for a document if the document's owner can view the template, so users' templates apply to their own
|
||||
documents, and unowned templates apply to everyone's. Templates run in name order, so if two templates fill the
|
||||
same field, the later one wins.
|
||||
|
||||
Turn off **Enabled** to pause a template without deleting it. Disabled templates never run automatically, but
|
||||
can still be run manually.
|
||||
|
||||
### Running templates manually
|
||||
|
||||
To run templates on documents that are already in Paperless-ngx, open the **Actions** menu on the document
|
||||
details page:
|
||||
|
||||
- **Run Zone OCR** runs every enabled template that matches the document.
|
||||
- The button next to it opens a dialog where you can choose one template to run
|
||||
instead. A template you choose runs even if it does not match the document or is disabled.
|
||||
|
||||
The bulk editor's **Run Zone OCR** action opens the same dialog for the selected documents. Bulk runs are
|
||||
processed in the background.
|
||||
|
||||
!!! warning
|
||||
|
||||
Zone OCR overwrites the fields it fills. A zone that finds no value leaves its field unchanged.
|
||||
|
||||
Managing OCR templates requires the OCR template permissions. Running zone OCR on documents requires
|
||||
permission to change them, and choosing a template requires permission to view it.
|
||||
|
||||
## PDF Actions
|
||||
|
||||
Paperless-ngx supports basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files). When viewing an individual document you can
|
||||
|
||||
@@ -13,7 +13,6 @@ import { DocumentDetailComponent } from './components/document-detail/document-d
|
||||
import { DocumentListComponent } from './components/document-list/document-list.component'
|
||||
import { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.component'
|
||||
import { MailComponent } from './components/manage/mail/mail.component'
|
||||
import { OcrTemplatesComponent } from './components/manage/ocr-templates/ocr-templates.component'
|
||||
import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component'
|
||||
import { ShareLinksComponent } from './components/manage/share-links/share-links.component'
|
||||
import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
|
||||
@@ -276,18 +275,6 @@ export const routes: Routes = [
|
||||
componentName: 'WorkflowsComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'ocr-templates',
|
||||
component: OcrTemplatesComponent,
|
||||
canActivate: [PermissionsGuard],
|
||||
data: {
|
||||
requiredPermission: {
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.OcrTemplate,
|
||||
},
|
||||
componentName: 'OcrTemplatesComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'mail',
|
||||
component: MailComponent,
|
||||
|
||||
@@ -113,7 +113,6 @@ const sidebarItemLabels: Record<HideableSidebarItemID, string> = {
|
||||
[HideableSidebarItemID.SavedViews]: $localize`Saved Views`,
|
||||
[HideableSidebarItemID.ShareLinks]: $localize`Share Links`,
|
||||
[HideableSidebarItemID.Workflows]: $localize`Workflows`,
|
||||
[HideableSidebarItemID.OcrTemplates]: $localize`OCR Templates`,
|
||||
[HideableSidebarItemID.Mail]: $localize`Mail`,
|
||||
[HideableSidebarItemID.Documentation]: $localize`Documentation`,
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
PaperlessTaskTriggerSource,
|
||||
PaperlessTaskType,
|
||||
} from 'src/app/data/paperless-task'
|
||||
import { Results } from 'src/app/data/results'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
@@ -153,11 +152,6 @@ const tasks: PaperlessTask[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const paginatedTasks: Results<PaperlessTask> = {
|
||||
count: tasks.length,
|
||||
results: tasks,
|
||||
}
|
||||
|
||||
const sectionCountResponse = {
|
||||
all: 7,
|
||||
needs_attention: 2,
|
||||
@@ -204,7 +198,7 @@ describe('TasksComponent', () => {
|
||||
}).compileComponents()
|
||||
|
||||
tasksService = TestBed.inject(TasksService)
|
||||
reloadSpy = jest.spyOn(tasksService, 'reload')
|
||||
reloadSpy = jest.spyOn(tasksService, 'reload').mockImplementation(() => {})
|
||||
httpTestingController = TestBed.inject(HttpTestingController)
|
||||
modalService = TestBed.inject(NgbModal)
|
||||
router = TestBed.inject(Router)
|
||||
@@ -214,15 +208,6 @@ describe('TasksComponent', () => {
|
||||
jest.useFakeTimers()
|
||||
fixture.detectChanges()
|
||||
|
||||
httpTestingController
|
||||
.expectOne(
|
||||
(req) =>
|
||||
req.url === `${environment.apiBaseUrl}tasks/` &&
|
||||
req.params.get('acknowledged') === 'false' &&
|
||||
req.params.get('page_size') === '1000'
|
||||
)
|
||||
.flush(paginatedTasks)
|
||||
|
||||
httpTestingController
|
||||
.expectOne(
|
||||
(req) =>
|
||||
|
||||
@@ -289,17 +289,6 @@
|
||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Workflows" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Workflows, $event)"></pngx-input-switch>
|
||||
}
|
||||
</li>
|
||||
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.OcrTemplates) && !settingsService.organizingSidebarItems()"
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.OcrTemplate }">
|
||||
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.OcrTemplates)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="ocr-templates" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="OCR Templates" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="bounding-box-circles"></i-bs><span class="nav-link-label"><ng-container i18n>OCR Templates</ng-container></span>
|
||||
</a>
|
||||
@if (settingsService.organizingSidebarItems()) {
|
||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="OCR Templates" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.OcrTemplates)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.OcrTemplates, $event)"></pngx-input-switch>
|
||||
}
|
||||
</li>
|
||||
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail) && !settingsService.organizingSidebarItems()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
||||
tourAnchor="tour.mail">
|
||||
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
|
||||
@@ -355,11 +344,11 @@
|
||||
ngbPopover="Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="list-task"></i-bs><span class="nav-link-label"><ng-container i18n>Tasks</ng-container></span>
|
||||
@if (tasksService.needsAttentionTasks.length > 0 && !slimSidebarEnabled) {
|
||||
<span class="badge bg-danger ms-2 d-inline flex-shrink-0">{{tasksService.needsAttentionTasks.length}}</span>
|
||||
@if (tasksService.needsAttentionTasksCount > 0 && !slimSidebarEnabled) {
|
||||
<span class="badge bg-danger ms-2 d-inline flex-shrink-0">{{tasksService.needsAttentionTasksCount}}</span>
|
||||
}
|
||||
@if (tasksService.needsAttentionTasks.length > 0 && slimSidebarEnabled) {
|
||||
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.needsAttentionTasks.length}}</span>
|
||||
@if (tasksService.needsAttentionTasksCount > 0 && slimSidebarEnabled) {
|
||||
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.needsAttentionTasksCount}}</span>
|
||||
}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -295,7 +295,6 @@ describe('AppFrameComponent', () => {
|
||||
HideableSidebarItemID.Dashboard,
|
||||
HideableSidebarItemID.Workflows,
|
||||
HideableSidebarItemID.ShareLinks,
|
||||
HideableSidebarItemID.OcrTemplates,
|
||||
])
|
||||
fixture.detectChanges()
|
||||
|
||||
@@ -311,10 +310,6 @@ describe('AppFrameComponent', () => {
|
||||
fixture.nativeElement.querySelector('[routerLink="share-links"]')
|
||||
.parentElement.classList
|
||||
).toContain('d-none')
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[routerLink="ocr-templates"]')
|
||||
.parentElement.classList
|
||||
).toContain('d-none')
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[routerLink="mail"]').parentElement
|
||||
.classList
|
||||
@@ -327,21 +322,12 @@ describe('AppFrameComponent', () => {
|
||||
])
|
||||
settingsService.sidebarHiddenItemsEditing.set([
|
||||
HideableSidebarItemID.Dashboard,
|
||||
HideableSidebarItemID.OcrTemplates,
|
||||
])
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelectorAll('pngx-input-switch').length
|
||||
).toBe(7)
|
||||
const ocrTemplatesLink = fixture.nativeElement.querySelector(
|
||||
'[routerLink="ocr-templates"]'
|
||||
)
|
||||
expect(ocrTemplatesLink.parentElement.classList).not.toContain('d-none')
|
||||
expect(ocrTemplatesLink.classList).toContain('opacity-50')
|
||||
expect(
|
||||
ocrTemplatesLink.parentElement.querySelector('pngx-input-switch')
|
||||
).not.toBeNull()
|
||||
).toBe(6)
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[routerLink="dashboard"]')
|
||||
.parentElement.classList
|
||||
@@ -602,8 +588,8 @@ describe('AppFrameComponent', () => {
|
||||
|
||||
it('should show tasks badge for needs-attention tasks', () => {
|
||||
jest
|
||||
.spyOn(tasksService, 'needsAttentionTasks', 'get')
|
||||
.mockReturnValue([{} as any, {} as any])
|
||||
.spyOn(tasksService, 'needsAttentionTasksCount', 'get')
|
||||
.mockReturnValue(2)
|
||||
|
||||
fixture.detectChanges()
|
||||
|
||||
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (messageBold) {
|
||||
<p class="text-break"><b>{{messageBold}}</b></p>
|
||||
}
|
||||
@if (message) {
|
||||
<p class="text-break" [innerHTML]="message"></p>
|
||||
}
|
||||
<pngx-input-select
|
||||
[items]="templates()"
|
||||
[(ngModel)]="template"
|
||||
[allowNull]="true"
|
||||
title="OCR template"
|
||||
i18n-title
|
||||
placeholder="All matching templates"
|
||||
i18n-placeholder
|
||||
></pngx-input-select>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()">
|
||||
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
||||
</button>
|
||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled()">
|
||||
{{btnCaption}}
|
||||
</button>
|
||||
</div>
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { of } from 'rxjs'
|
||||
import { MATCH_NONE } from 'src/app/data/matching-model'
|
||||
import { OcrTemplate } from 'src/app/data/ocr-template'
|
||||
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
|
||||
import { ZoneOcrConfirmDialogComponent } from './zone-ocr-confirm-dialog.component'
|
||||
|
||||
const templates: OcrTemplate[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Invoices',
|
||||
match: '',
|
||||
matching_algorithm: MATCH_NONE,
|
||||
sample_document: null,
|
||||
source_width: 0,
|
||||
source_height: 0,
|
||||
enabled: true,
|
||||
zones: [],
|
||||
},
|
||||
]
|
||||
|
||||
describe('ZoneOcrConfirmDialogComponent', () => {
|
||||
let component: ZoneOcrConfirmDialogComponent
|
||||
let fixture: ComponentFixture<ZoneOcrConfirmDialogComponent>
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
NgbActiveModal,
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
imports: [ZoneOcrConfirmDialogComponent],
|
||||
})
|
||||
jest
|
||||
.spyOn(TestBed.inject(OcrTemplateService), 'listAll')
|
||||
.mockReturnValue(of({ count: 1, all: [1], results: templates }))
|
||||
|
||||
fixture = TestBed.createComponent(ZoneOcrConfirmDialogComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should offer templates and default to all matching templates', () => {
|
||||
expect(component.templates()).toEqual(templates)
|
||||
expect(component.template).toBeNull()
|
||||
expect(fixture.nativeElement.textContent).toContain('OCR template')
|
||||
})
|
||||
})
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { takeUntil } from 'rxjs'
|
||||
import { OcrTemplate } from 'src/app/data/ocr-template'
|
||||
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
|
||||
import { SelectComponent } from '../../input/select/select.component'
|
||||
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-zone-ocr-confirm-dialog',
|
||||
templateUrl: './zone-ocr-confirm-dialog.component.html',
|
||||
imports: [FormsModule, SelectComponent],
|
||||
})
|
||||
export class ZoneOcrConfirmDialogComponent
|
||||
extends ConfirmDialogComponent
|
||||
implements OnInit
|
||||
{
|
||||
private readonly ocrTemplateService = inject(OcrTemplateService)
|
||||
|
||||
public readonly templates = signal<OcrTemplate[]>([])
|
||||
|
||||
/** The template to run, or null for every template that matches. */
|
||||
template: number | null = null
|
||||
|
||||
ngOnInit() {
|
||||
this.ocrTemplateService
|
||||
.listAll()
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((r) => this.templates.set(r.results))
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@
|
||||
(change)="onChange(selectedDocumentIDs)">
|
||||
<ng-template ng-label-tmp let-document="item">
|
||||
<div class="d-flex align-items-center">
|
||||
@if (!disabled && multiple) {
|
||||
@if (!disabled) {
|
||||
<button class="btn p-0 lh-1" (click)="unselect(document)" (mousedown)="$event.stopImmediatePropagation()" type="button" title="Remove link" i18n-title><i-bs name="x"></i-bs></button>
|
||||
}
|
||||
@if (document.title) {
|
||||
|
||||
@@ -81,9 +81,6 @@ export class DocumentLinkComponent
|
||||
@Input()
|
||||
placeholder: string = $localize`Search for documents`
|
||||
|
||||
@Input()
|
||||
multiple: boolean = true
|
||||
|
||||
/**
|
||||
* Parent for ng-select dropdown, needed to prevent close on click.
|
||||
*/
|
||||
|
||||
@@ -61,22 +61,6 @@
|
||||
<i-bs name="pencil" class="me-2"></i-bs><ng-container i18n>PDF Editor</ng-container>
|
||||
</button>
|
||||
|
||||
<div class="d-flex" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.OcrTemplate }">
|
||||
<button ngbDropdownItem (click)="runZoneOcr()" [disabled]="!userCanEdit || networkActive()">
|
||||
<i-bs width="1em" height="1em" name="bounding-box-circles" class="me-2"></i-bs><span i18n>Run Zone OCR</span>
|
||||
</button>
|
||||
<button
|
||||
ngbDropdownItem
|
||||
class="w-auto flex-shrink-0 px-2"
|
||||
(click)="runZoneOcrWithOptions()"
|
||||
[disabled]="!userCanEdit || networkActive()"
|
||||
title="Choose OCR template"
|
||||
i18n-title
|
||||
>
|
||||
<i-bs width="1em" height="1em" name="sliders2"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (userIsOwner && (requiresPassword || password)) {
|
||||
<button ngbDropdownItem (click)="removePassword()" [disabled]="!password">
|
||||
<i-bs name="unlock" class="me-2"></i-bs><ng-container i18n>Remove Password</ng-container>
|
||||
@@ -106,7 +90,7 @@
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-xl-5 mb-4">
|
||||
|
||||
<form [formGroup]='documentForm' (ngSubmit)="save()" [attr.inert]="zoneOcrRunning() ? '' : null" [attr.aria-busy]="zoneOcrRunning()">
|
||||
<form [formGroup]='documentForm' (ngSubmit)="save()">
|
||||
|
||||
<div class="btn-toolbar mb-1 pb-3 gap-2 row-gap-2 border-bottom">
|
||||
<div class="btn-group">
|
||||
|
||||
@@ -1900,196 +1900,6 @@ describe('DocumentDetailComponent', () => {
|
||||
expect(toastSpy).toHaveBeenCalledWith('Document reloaded.')
|
||||
})
|
||||
|
||||
it('should run zone OCR, report results and reload the document', () => {
|
||||
component.documentId.set(doc.id)
|
||||
component.document.set(doc)
|
||||
jest.spyOn(documentService, 'runZoneOcr').mockReturnValue(
|
||||
of({
|
||||
results: [
|
||||
{ zone: 'Total', value: '102.10' },
|
||||
{ zone: 'Due', value: '' },
|
||||
],
|
||||
} as any)
|
||||
)
|
||||
const loadSpy = jest
|
||||
.spyOn(component as any, 'loadDocument')
|
||||
.mockImplementation(() => {})
|
||||
const notifySpy = jest.spyOn(component.docChangeNotifier, 'next')
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const modalSpy = jest.spyOn(modalService, 'open')
|
||||
|
||||
component.runZoneOcrWithOptions()
|
||||
const dialog = modalSpy.mock.results[0].value.componentInstance
|
||||
expect(dialog.message).toBe(
|
||||
'Fields filled by the template will be overwritten.'
|
||||
)
|
||||
dialog.template = 3
|
||||
dialog.confirmClicked.emit()
|
||||
|
||||
expect(documentService.runZoneOcr).toHaveBeenCalledWith(doc.id, 3)
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
'Filled 1 of 2 fields. No value found for: Due'
|
||||
)
|
||||
expect(notifySpy).toHaveBeenCalledWith(doc.id)
|
||||
expect(loadSpy).toHaveBeenCalledWith(doc.id, true, expect.any(Function))
|
||||
expect(component.zoneOcrRunning()).toBe(true)
|
||||
loadSpy.mock.calls[0][2]()
|
||||
expect(component.zoneOcrRunning()).toBe(false)
|
||||
expect(component.networkActive()).toBe(false)
|
||||
})
|
||||
|
||||
it('should summarize zone OCR results', () => {
|
||||
component.documentId.set(doc.id)
|
||||
component.document.set(doc)
|
||||
jest.spyOn(component as any, 'loadDocument').mockImplementation(() => {})
|
||||
const runSpy = jest.spyOn(documentService, 'runZoneOcr')
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const modalSpy = jest.spyOn(modalService, 'open')
|
||||
|
||||
runSpy.mockReturnValueOnce(of({ status: 'completed', results: [] }))
|
||||
component.runZoneOcr()
|
||||
expect(modalSpy).not.toHaveBeenCalled()
|
||||
expect(runSpy).toHaveBeenCalledWith(doc.id, null)
|
||||
expect(toastSpy).toHaveBeenLastCalledWith(
|
||||
'Zone OCR found no fields to fill.'
|
||||
)
|
||||
|
||||
component.networkActive.set(false)
|
||||
runSpy.mockReturnValueOnce(
|
||||
of({
|
||||
status: 'completed',
|
||||
results: [
|
||||
{ template: 'T', zone: 'Total', custom_field: 'Amount', value: 0 },
|
||||
],
|
||||
})
|
||||
)
|
||||
component.runZoneOcr()
|
||||
expect(toastSpy).toHaveBeenLastCalledWith('Filled 1 of 1 fields.')
|
||||
})
|
||||
|
||||
it('should confirm before running zone OCR over unsaved changes', () => {
|
||||
component.documentId.set(doc.id)
|
||||
component.document.set({ ...doc, __changedFields: ['title'] })
|
||||
component.documentForm.patchValue({ title: 'Unsaved title' })
|
||||
const runSpy = jest.spyOn(documentService, 'runZoneOcr')
|
||||
const modalSpy = jest.spyOn(modalService, 'open')
|
||||
|
||||
component.runZoneOcr()
|
||||
|
||||
const modal = modalSpy.mock.results[0].value
|
||||
expect(modal.componentInstance.message).toBe(
|
||||
'Running Zone OCR will discard them.'
|
||||
)
|
||||
expect(runSpy).not.toHaveBeenCalled()
|
||||
modal.componentInstance.cancel()
|
||||
expect(runSpy).not.toHaveBeenCalled()
|
||||
expect(component.documentForm.get('title').value).toBe('Unsaved title')
|
||||
expect(component.zoneOcrRunning()).toBe(false)
|
||||
})
|
||||
|
||||
it('should warn about unsaved changes in the zone OCR options dialog', () => {
|
||||
component.documentId.set(doc.id)
|
||||
component.document.set({ ...doc, __changedFields: ['title'] })
|
||||
const runSpy = jest.spyOn(documentService, 'runZoneOcr')
|
||||
const modalSpy = jest.spyOn(modalService, 'open')
|
||||
|
||||
component.runZoneOcrWithOptions()
|
||||
|
||||
const dialog = modalSpy.mock.results[0].value.componentInstance
|
||||
expect(dialog.message).toBe('Running Zone OCR will discard them.')
|
||||
expect(dialog.btnClass).toBe('btn-warning')
|
||||
dialog.cancel()
|
||||
expect(runSpy).not.toHaveBeenCalled()
|
||||
component.networkActive.set(true)
|
||||
component.runZoneOcrWithOptions()
|
||||
expect(modalSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not allow saving or a second zone OCR run while one is running', () => {
|
||||
component.documentId.set(doc.id)
|
||||
component.document.set({ ...doc, __changedFields: ['title'] })
|
||||
const pending = new Subject<any>()
|
||||
const runSpy = jest
|
||||
.spyOn(documentService, 'runZoneOcr')
|
||||
.mockReturnValue(pending)
|
||||
const modalSpy = jest.spyOn(modalService, 'open')
|
||||
|
||||
component.runZoneOcr()
|
||||
modalSpy.mock.results[0].value.componentInstance.confirmClicked.emit()
|
||||
|
||||
expect(runSpy).toHaveBeenCalledWith(doc.id, null)
|
||||
expect(component.zoneOcrRunning()).toBe(true)
|
||||
expect(component.networkActive()).toBe(true)
|
||||
const saveSpy = jest.spyOn(documentService, 'patch')
|
||||
component.save()
|
||||
expect(saveSpy).not.toHaveBeenCalled()
|
||||
component.runZoneOcr()
|
||||
expect(modalSpy).toHaveBeenCalledTimes(1)
|
||||
expect(runSpy).toHaveBeenCalledTimes(1)
|
||||
pending.error(new Error('failed'))
|
||||
expect(component.zoneOcrRunning()).toBe(false)
|
||||
expect(component.networkActive()).toBe(false)
|
||||
expect(component.document().__changedFields).toEqual(['title'])
|
||||
})
|
||||
|
||||
it('should not reload after zone OCR if another document is open', () => {
|
||||
component.documentId.set(doc.id)
|
||||
component.document.set({ ...doc, __changedFields: [] })
|
||||
const pending = new Subject<any>()
|
||||
jest.spyOn(documentService, 'runZoneOcr').mockReturnValue(pending)
|
||||
const loadSpy = jest.spyOn(component as any, 'loadDocument')
|
||||
|
||||
component.runZoneOcr()
|
||||
component.documentId.set(99)
|
||||
pending.next({ results: [] })
|
||||
|
||||
expect(loadSpy).not.toHaveBeenCalled()
|
||||
expect(component.zoneOcrRunning()).toBe(false)
|
||||
pending.complete()
|
||||
})
|
||||
|
||||
it('should keep unsaved changes when zone OCR is skipped', () => {
|
||||
component.documentId.set(doc.id)
|
||||
component.document.set({ ...doc, __changedFields: ['title'] })
|
||||
component.documentForm.patchValue({ title: 'Unsaved title' })
|
||||
const modalSpy = jest.spyOn(modalService, 'open')
|
||||
jest
|
||||
.spyOn(documentService, 'runZoneOcr')
|
||||
.mockReturnValue(of({ status: 'skipped', results: [] }))
|
||||
const loadSpy = jest.spyOn(component as any, 'loadDocument')
|
||||
const infoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
|
||||
component.runZoneOcr()
|
||||
modalSpy.mock.results[0].value.componentInstance.confirm()
|
||||
|
||||
expect(loadSpy).not.toHaveBeenCalled()
|
||||
expect(component.documentForm.get('title').value).toBe('Unsaved title')
|
||||
expect(component.zoneOcrRunning()).toBe(false)
|
||||
expect(component.networkActive()).toBe(false)
|
||||
expect(infoSpy).toHaveBeenCalledWith(
|
||||
'No OCR template applies to this document.'
|
||||
)
|
||||
})
|
||||
|
||||
it('should show an error when zone OCR fails', () => {
|
||||
component.document.set(doc)
|
||||
jest
|
||||
.spyOn(documentService, 'runZoneOcr')
|
||||
.mockReturnValue(throwError(() => new Error('nope')))
|
||||
const loadSpy = jest.spyOn(component as any, 'loadDocument')
|
||||
const errorSpy = jest.spyOn(toastService, 'showError')
|
||||
|
||||
component.runZoneOcr()
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Error running zone OCR.',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(loadSpy).not.toHaveBeenCalled()
|
||||
expect(component.zoneOcrRunning()).toBe(false)
|
||||
expect(component.networkActive()).toBe(false)
|
||||
})
|
||||
|
||||
it('should change preview element by render type', () => {
|
||||
initNormally()
|
||||
component.document.update((document) => ({
|
||||
|
||||
@@ -64,7 +64,6 @@ import {
|
||||
FILTER_STORAGE_PATH,
|
||||
} from 'src/app/data/filter-rule-type'
|
||||
import { ObjectWithId } from 'src/app/data/object-with-id'
|
||||
import { OcrZoneRunResult } from 'src/app/data/ocr-template'
|
||||
import { StoragePath } from 'src/app/data/storage-path'
|
||||
import { Tag } from 'src/app/data/tag'
|
||||
import { SETTINGS_KEYS, SuggestionSource } from 'src/app/data/ui-settings'
|
||||
@@ -105,7 +104,6 @@ import { DocumentDetailFieldID } from '../admin/settings/settings.component'
|
||||
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
||||
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
|
||||
import { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
|
||||
import { ZoneOcrConfirmDialogComponent } from '../common/confirm-dialog/zone-ocr-confirm-dialog/zone-ocr-confirm-dialog.component'
|
||||
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
|
||||
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||
@@ -268,7 +266,6 @@ export class DocumentDetailComponent
|
||||
readonly error = signal<any>(undefined)
|
||||
|
||||
readonly networkActive = signal(false)
|
||||
readonly zoneOcrRunning = signal(false)
|
||||
|
||||
readonly documentId = signal<number>(undefined)
|
||||
readonly document = signal<Document>(undefined)
|
||||
@@ -546,11 +543,7 @@ export class DocumentDetailComponent
|
||||
)
|
||||
}
|
||||
|
||||
private loadDocument(
|
||||
documentId: number,
|
||||
forceRemote: boolean = false,
|
||||
onComplete?: () => void
|
||||
): void {
|
||||
private loadDocument(documentId: number, forceRemote: boolean = false): void {
|
||||
let redirectedToRoot = false
|
||||
this.closeIncomingUpdateModal()
|
||||
this.pendingIncomingUpdate = null
|
||||
@@ -607,8 +600,7 @@ export class DocumentDetailComponent
|
||||
}),
|
||||
first(),
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
takeUntil(this.docChangeNotifier),
|
||||
finalize(() => onComplete?.())
|
||||
takeUntil(this.docChangeNotifier)
|
||||
)
|
||||
.subscribe({
|
||||
next: (doc) => {
|
||||
@@ -1342,7 +1334,6 @@ export class DocumentDetailComponent
|
||||
}
|
||||
|
||||
save(close: boolean = false, savedCallback: () => void = null) {
|
||||
if (this.zoneOcrRunning()) return
|
||||
this.networkActive.set(true)
|
||||
;(document.activeElement as HTMLElement)?.dispatchEvent(new Event('change'))
|
||||
this.documentsService
|
||||
@@ -1563,107 +1554,6 @@ export class DocumentDetailComponent
|
||||
})
|
||||
}
|
||||
|
||||
/** Runs every template that matches, confirming only over unsaved changes. */
|
||||
runZoneOcr() {
|
||||
if (this.networkActive()) return
|
||||
const documentId = this.documentId()
|
||||
if (!this.hasLocalEdits(this.document())) {
|
||||
this.executeZoneOcr(documentId, null)
|
||||
return
|
||||
}
|
||||
const modal = this.modalService.open(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.title = $localize`Run Zone OCR`
|
||||
modal.componentInstance.messageBold = $localize`You have unsaved changes.`
|
||||
modal.componentInstance.message = $localize`Running Zone OCR will discard them.`
|
||||
modal.componentInstance.btnClass = 'btn-warning'
|
||||
modal.componentInstance.btnCaption = $localize`Run Zone OCR`
|
||||
modal.componentInstance.confirmClicked
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
modal.close()
|
||||
if (this.documentId() === documentId && !this.networkActive()) {
|
||||
this.executeZoneOcr(documentId, null)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Opens the dialog to pick which template to run. */
|
||||
runZoneOcrWithOptions() {
|
||||
if (this.networkActive()) return
|
||||
const documentId = this.documentId()
|
||||
const modal = this.modalService.open(ZoneOcrConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
const dialog = modal.componentInstance as ZoneOcrConfirmDialogComponent
|
||||
dialog.title = $localize`Run Zone OCR`
|
||||
dialog.btnCaption = $localize`Run Zone OCR`
|
||||
if (this.hasLocalEdits(this.document())) {
|
||||
dialog.messageBold = $localize`You have unsaved changes.`
|
||||
dialog.message = $localize`Running Zone OCR will discard them.`
|
||||
dialog.btnClass = 'btn-warning'
|
||||
} else {
|
||||
dialog.message = $localize`Fields filled by the template will be overwritten.`
|
||||
}
|
||||
dialog.confirmClicked
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
modal.close()
|
||||
if (this.documentId() === documentId && !this.networkActive()) {
|
||||
this.executeZoneOcr(documentId, dialog.template)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private executeZoneOcr(documentId: number, templateId: number | null) {
|
||||
this.zoneOcrRunning.set(true)
|
||||
this.networkActive.set(true)
|
||||
this.documentsService
|
||||
.runZoneOcr(documentId, templateId)
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
if (
|
||||
this.documentId() !== documentId ||
|
||||
response.status === 'skipped'
|
||||
) {
|
||||
this.finishZoneOcr()
|
||||
if (response.status === 'skipped') {
|
||||
this.toastService.showInfo(
|
||||
$localize`No OCR template applies to this document.`
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
this.toastService.showInfo(this.zoneOcrSummary(response.results))
|
||||
this.docChangeNotifier.next(documentId)
|
||||
this.loadDocument(documentId, true, () => this.finishZoneOcr())
|
||||
},
|
||||
error: (error) => {
|
||||
this.finishZoneOcr()
|
||||
this.toastService.showError($localize`Error running zone OCR.`, error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private zoneOcrSummary(results: OcrZoneRunResult[]): string {
|
||||
if (!results.length) return $localize`Zone OCR found no fields to fill.`
|
||||
const missed = results
|
||||
.filter((result) => !`${result.value ?? ''}`.trim())
|
||||
.map((result) => result.zone)
|
||||
const filled = results.length - missed.length
|
||||
return missed.length
|
||||
? $localize`Filled ${filled} of ${results.length} fields. No value found for: ${missed.join(', ')}`
|
||||
: $localize`Filled ${filled} of ${results.length} fields.`
|
||||
}
|
||||
|
||||
private finishZoneOcr() {
|
||||
this.zoneOcrRunning.set(false)
|
||||
this.networkActive.set(false)
|
||||
this.flushPendingIncomingUpdate()
|
||||
}
|
||||
|
||||
private getSelectedNonLatestVersionId(): number | null {
|
||||
const versions = this.document()?.versions ?? []
|
||||
if (!versions.length || !this.selectedVersionId()) {
|
||||
|
||||
@@ -98,11 +98,6 @@
|
||||
<button ngbDropdownItem (click)="mergeSelectedAsVersions()" [disabled]="!userOwnsAll || !userCanEditAll || !userCanDelete || list.allSelected || list.selectedCount < 2">
|
||||
<i-bs name="journal-bookmark-fill" class="me-2"></i-bs><ng-container i18n>Merge as versions</ng-container>
|
||||
</button>
|
||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.OcrTemplate)) {
|
||||
<button ngbDropdownItem (click)="runZoneOcrSelected()" [disabled]="!userCanEditAll">
|
||||
<i-bs name="bounding-box-circles" class="me-2"></i-bs><ng-container i18n>Run Zone OCR</ng-container>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1286,53 +1286,6 @@ describe('BulkEditorComponent', () => {
|
||||
) // listAllFilteredIds
|
||||
})
|
||||
|
||||
it('should support run zone ocr', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
jest
|
||||
.spyOn(documentListViewService, 'documents', 'get')
|
||||
.mockReturnValue([{ id: 3 }, { id: 4 }])
|
||||
jest
|
||||
.spyOn(documentListViewService, 'selected', 'get')
|
||||
.mockReturnValue(new Set([3, 4]))
|
||||
jest
|
||||
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
|
||||
.mockReturnValue(true)
|
||||
fixture.detectChanges()
|
||||
component.runZoneOcrSelected()
|
||||
expect(modal).not.toBeUndefined()
|
||||
modal.componentInstance.confirm()
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/run_zone_ocr/`
|
||||
)
|
||||
req.flush(true)
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [3, 4],
|
||||
})
|
||||
|
||||
component.runZoneOcrSelected()
|
||||
modal.componentInstance.template = 3
|
||||
modal.componentInstance.confirm()
|
||||
req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/run_zone_ocr/`
|
||||
)
|
||||
req.flush(true)
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [3, 4],
|
||||
template: 3,
|
||||
})
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}ocr_templates/?page=1&page_size=100000`
|
||||
) // template list
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
) // list reload
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||
) // listAllFilteredIds
|
||||
})
|
||||
|
||||
it('should support rotate', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||
|
||||
@@ -64,7 +64,6 @@ import { MergeAsVersionsConfirmDialogComponent } from '../../common/confirm-dial
|
||||
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
||||
import { ReprocessConfirmDialogComponent } from '../../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
|
||||
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
||||
import { ZoneOcrConfirmDialogComponent } from '../../common/confirm-dialog/zone-ocr-confirm-dialog/zone-ocr-confirm-dialog.component'
|
||||
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||
@@ -967,30 +966,6 @@ export class BulkEditorComponent
|
||||
})
|
||||
}
|
||||
|
||||
runZoneOcrSelected() {
|
||||
const modal = this.modalService.open(ZoneOcrConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
const dialog = modal.componentInstance as ZoneOcrConfirmDialogComponent
|
||||
dialog.title = $localize`Run zone OCR confirm`
|
||||
dialog.messageBold = $localize`This operation will run zone OCR on ${this.getSelectionSize()} selected document(s).`
|
||||
dialog.message = $localize`Fields filled by the template will be overwritten.`
|
||||
dialog.btnClass = 'btn-warning'
|
||||
dialog.btnCaption = $localize`Proceed`
|
||||
dialog.confirmClicked
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
dialog.buttonsEnabled.set(false)
|
||||
this.executeDocumentAction(
|
||||
modal,
|
||||
this.documentService.runZoneOcrDocuments(
|
||||
this.getSelectionQuery(),
|
||||
dialog.template
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
setPermissions() {
|
||||
let modal = this.modalService.open(PermissionsDialogComponent, {
|
||||
backdrop: 'static',
|
||||
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
@if (zones.length === 0) {
|
||||
<p class="text-muted" i18n>No zones yet. Drag anywhere on the sample document to create one.</p>
|
||||
} @else {
|
||||
<div class="list-group">
|
||||
@for (zone of zones; track $index; let i = $index) {
|
||||
<div class="list-group-item list-group-item-action d-flex align-items-center gap-3 position-relative">
|
||||
<span class="p-2 rounded flex-shrink-0" [style.background-color]="zoneColor(i)" aria-hidden="true"></span>
|
||||
<div class="flex-grow-1 text-truncate">
|
||||
<button type="button" class="btn btn-link p-0 fw-semibold text-reset text-decoration-none text-truncate mw-100 stretched-link" (click)="zoneSelected.emit(i)">{{ zone.name }}</button>
|
||||
@if (!hasField(zone)) {
|
||||
<div class="small text-warning text-truncate">
|
||||
<i-bs name="exclamation-triangle" class="me-1" aria-hidden="true"></i-bs><span i18n>No field selected</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="small text-muted text-truncate">
|
||||
{{ getZoneTargetName(zone) }}
|
||||
@if (isCombined(zone)) {
|
||||
<i-bs name="braces" class="ms-1" title="Combined with other zones" i18n-title></i-bs>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<small class="text-muted text-nowrap">
|
||||
@if (zone.page === -1) {
|
||||
<ng-container i18n>Last page</ng-container>
|
||||
} @else {
|
||||
<ng-container i18n>Page {{ zone.page ?? 1 }}</ng-container>
|
||||
}
|
||||
</small>
|
||||
<button type="button" class="btn btn-sm btn-link text-danger position-relative z-2" (click)="zoneRemoved.emit(i)" title="Delete zone" i18n-title aria-label="Delete zone" i18n-aria-label>
|
||||
<i-bs name="trash"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
-118
@@ -1,118 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { CustomField } from 'src/app/data/custom-field'
|
||||
import {
|
||||
OcrTemplateZone,
|
||||
OcrZoneTarget,
|
||||
OcrZoneTransform,
|
||||
} from 'src/app/data/ocr-template'
|
||||
import { OcrTemplateEditorZoneListComponent } from './ocr-template-editor-zone-list.component'
|
||||
|
||||
function zone(overrides: Partial<OcrTemplateZone> = {}): OcrTemplateZone {
|
||||
return {
|
||||
name: 'Zone 1',
|
||||
target: OcrZoneTarget.CustomField,
|
||||
custom_field: 7,
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 30,
|
||||
height: 40,
|
||||
page: 1,
|
||||
ocr_language: 'eng',
|
||||
transform: OcrZoneTransform.Strip,
|
||||
validation_regex: '',
|
||||
order: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('OcrTemplateEditorZoneListComponent', () => {
|
||||
let fixture: ComponentFixture<OcrTemplateEditorZoneListComponent>
|
||||
let component: OcrTemplateEditorZoneListComponent
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
OcrTemplateEditorZoneListComponent,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(OcrTemplateEditorZoneListComponent)
|
||||
component = fixture.componentInstance
|
||||
})
|
||||
|
||||
it('shows empty state when no zones are defined', () => {
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('No zones yet')
|
||||
})
|
||||
|
||||
it('renders zone name, target, and page', () => {
|
||||
component.zones = [zone(), zone({ name: 'Zone 2', page: -1 })]
|
||||
component.customFields = [{ id: 7, name: 'Invoice Number' } as CustomField]
|
||||
fixture.detectChanges()
|
||||
|
||||
const text = fixture.nativeElement.textContent
|
||||
expect(text).toContain('Zone 1')
|
||||
expect(text).toContain('Invoice Number')
|
||||
expect(text).toContain('Page 1')
|
||||
expect(text).toContain('Last page')
|
||||
})
|
||||
|
||||
it('marks zones that share a field', () => {
|
||||
component.zones = [
|
||||
zone(),
|
||||
zone({ name: 'Zone 2' }),
|
||||
zone({ name: 'Zone 3', custom_field: 8 }),
|
||||
]
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(component.isCombined(component.zones[0])).toBe(true)
|
||||
expect(component.isCombined(component.zones[2])).toBe(false)
|
||||
expect(
|
||||
fixture.nativeElement.querySelectorAll(
|
||||
'[title="Combined with other zones"]'
|
||||
)
|
||||
).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('warns only for zones without a field and clears the warning when assigned', () => {
|
||||
component.zones = [
|
||||
zone({ custom_field: null }),
|
||||
zone({ target: OcrZoneTarget.Title, custom_field: null }),
|
||||
zone(),
|
||||
]
|
||||
fixture.detectChanges()
|
||||
|
||||
const warnings = () =>
|
||||
fixture.nativeElement.querySelectorAll('.text-warning')
|
||||
expect(warnings()).toHaveLength(1)
|
||||
expect(warnings()[0].textContent).toContain('No field selected')
|
||||
|
||||
component.zones[0].custom_field = 7
|
||||
fixture.componentRef.setInput('zones', [...component.zones])
|
||||
fixture.detectChanges()
|
||||
expect(warnings()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('emits select and remove events', () => {
|
||||
component.zones = [zone()]
|
||||
const selectSpy = jest.spyOn(component.zoneSelected, 'emit')
|
||||
const removeSpy = jest.spyOn(component.zoneRemoved, 'emit')
|
||||
fixture.detectChanges()
|
||||
|
||||
const buttons = fixture.nativeElement.querySelectorAll('button')
|
||||
buttons[0].click()
|
||||
buttons[1].click()
|
||||
|
||||
expect(selectSpy).toHaveBeenCalledWith(0)
|
||||
expect(removeSpy).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
it('does not label an unknown built-in target', () => {
|
||||
expect(
|
||||
component.getZoneTargetName(zone({ target: 'unknown' as OcrZoneTarget }))
|
||||
).toBe('')
|
||||
})
|
||||
})
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { CustomField } from 'src/app/data/custom-field'
|
||||
import {
|
||||
getZoneFieldKey,
|
||||
OCR_BUILTIN_TARGETS,
|
||||
OcrTemplateZone,
|
||||
OcrZoneTarget,
|
||||
} from 'src/app/data/ocr-template'
|
||||
import { getZoneColor } from '../zone-geometry'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-ocr-template-zone-list',
|
||||
imports: [NgxBootstrapIconsModule],
|
||||
templateUrl: './ocr-template-editor-zone-list.component.html',
|
||||
})
|
||||
export class OcrTemplateEditorZoneListComponent {
|
||||
@Input() zones: OcrTemplateZone[] = []
|
||||
@Input() customFields: CustomField[] = []
|
||||
|
||||
@Output() public readonly zoneSelected = new EventEmitter<number>()
|
||||
@Output() public readonly zoneRemoved = new EventEmitter<number>()
|
||||
|
||||
public readonly zoneColor = getZoneColor
|
||||
|
||||
hasField(zone: OcrTemplateZone): boolean {
|
||||
return getZoneFieldKey(zone) !== null
|
||||
}
|
||||
|
||||
isCombined(zone: OcrTemplateZone): boolean {
|
||||
const key = getZoneFieldKey(zone)
|
||||
return (
|
||||
key !== null &&
|
||||
this.zones.filter((z) => getZoneFieldKey(z) === key).length > 1
|
||||
)
|
||||
}
|
||||
|
||||
getZoneTargetName(zone: OcrTemplateZone): string {
|
||||
if (zone.target === OcrZoneTarget.CustomField) {
|
||||
return (
|
||||
this.customFields.find((field) => field.id === zone.custom_field)
|
||||
?.name ?? ''
|
||||
)
|
||||
}
|
||||
return (
|
||||
OCR_BUILTIN_TARGETS.find((target) => target.id === zone.target)?.name ??
|
||||
''
|
||||
)
|
||||
}
|
||||
}
|
||||
-313
@@ -1,313 +0,0 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="ocr-template-editor-title">{{ dialogTitle }}</h4>
|
||||
@if (template.id) {
|
||||
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{ template.id }}</span>
|
||||
}
|
||||
<button type="button" class="btn-close" (click)="activeModal.dismiss()" [disabled]="saving()" aria-label="Close" i18n-aria-label></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body d-flex flex-column">
|
||||
<div class="row gx-3 editor-layout flex-grow-1 flex-lg-nowrap" [attr.inert]="saving() ? '' : null" [attr.aria-busy]="saving()">
|
||||
<div class="col-lg-4 mb-3 mb-lg-0 overflow-auto">
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTab" class="nav-underline flex-nowrap flex-md-wrap overflow-auto">
|
||||
<li ngbNavItem="template">
|
||||
<a ngbNavLink i18n>Template</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="row mb-3">
|
||||
<div class="col-9">
|
||||
<pngx-input-text [(ngModel)]="template.name" title="Template name" i18n-title></pngx-input-text>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<pngx-input-switch [(ngModel)]="template.enabled" title="Enabled" i18n-title></pngx-input-switch>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pngx-input-select [(ngModel)]="template.matching_algorithm" [items]="matchingAlgorithms" title="Matching algorithm" i18n-title></pngx-input-select>
|
||||
@if (patternRequired) {
|
||||
<pngx-input-text [(ngModel)]="template.match" title="Matching pattern" i18n-title></pngx-input-text>
|
||||
<pngx-input-check [(ngModel)]="template.is_insensitive" title="Case insensitive" i18n-title></pngx-input-check>
|
||||
}
|
||||
|
||||
<div *pngxIfOwner="object">
|
||||
<hr />
|
||||
<pngx-permissions-form [users]="users()" [accordion]="true" [(ngModel)]="permissionsForm"></pngx-permissions-form>
|
||||
</div>
|
||||
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li ngbNavItem="zones">
|
||||
<a ngbNavLink><ng-container i18n>Zones</ng-container> <span class="badge bg-primary ms-2">{{ template.zones.length }}</span></a>
|
||||
<ng-template ngbNavContent>
|
||||
@if (selectedZone; as zone) {
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<button type="button" class="btn btn-sm btn-link px-0 me-auto" (click)="deselectZone()">
|
||||
<i-bs name="arrow-left" class="me-1"></i-bs><ng-container i18n>All zones</ng-container>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="testZone()" [disabled]="!previewDocId || zoneTesting">
|
||||
@if (zoneTesting) {
|
||||
<span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span>
|
||||
} @else {
|
||||
<i-bs name="play-fill" class="me-1"></i-bs>
|
||||
}
|
||||
<ng-container i18n>Test</ng-container>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" (click)="removeZone(selectedZoneIndex)" title="Delete zone" i18n-title aria-label="Delete zone" i18n-aria-label>
|
||||
<i-bs name="trash"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (zoneTestResult; as result) {
|
||||
@if (result.error) {
|
||||
<div class="alert alert-warning py-2 mb-3 small" role="alert">{{ result.error }}</div>
|
||||
} @else {
|
||||
<dl class="row small border rounded mx-0 py-2 mb-3">
|
||||
<dt class="col-4" i18n>OCR text</dt>
|
||||
<dd class="col-8">
|
||||
@if (result.raw_text) {
|
||||
<code>{{ result.raw_text }}</code>
|
||||
} @else {
|
||||
<span class="text-muted" i18n>Nothing detected</span>
|
||||
}
|
||||
</dd>
|
||||
<dt class="col-4" i18n>Value</dt>
|
||||
<dd class="col-8" [class.mb-0]="!result.regex">
|
||||
@if (result.value) {
|
||||
<code>{{ result.value }}</code>
|
||||
} @else {
|
||||
<span class="text-muted" i18n>Empty</span>
|
||||
}
|
||||
</dd>
|
||||
@if (result.regex) {
|
||||
<dt class="col-4" i18n>Validation</dt>
|
||||
<dd class="col-8 mb-0">
|
||||
@if (result.regex_match) {
|
||||
<span class="badge bg-success" i18n>Regex matches</span>
|
||||
} @else {
|
||||
<span class="badge bg-danger" i18n>Regex does not match</span>
|
||||
}
|
||||
</dd>
|
||||
}
|
||||
</dl>
|
||||
}
|
||||
}
|
||||
|
||||
<pngx-input-text [ngModel]="zone.name" (ngModelChange)="renameZone(zone, $event)" title="Name" i18n-title></pngx-input-text>
|
||||
|
||||
<pngx-input-select
|
||||
[items]="fieldItems()"
|
||||
[ngModel]="zoneFieldValue(zone)"
|
||||
(ngModelChange)="setZoneField(zone, $event)"
|
||||
(createNew)="createField($event)"
|
||||
title="Field"
|
||||
i18n-title
|
||||
placeholder="Choose a field"
|
||||
i18n-placeholder
|
||||
hint="Where the extracted value is written."
|
||||
i18n-hint
|
||||
></pngx-input-select>
|
||||
|
||||
@if (isFieldShared(zone)) {
|
||||
<pngx-input-text
|
||||
[ngModel]="getCombineFormat(zone)"
|
||||
(ngModelChange)="setCombineFormat(zone, $event)"
|
||||
[placeholder]="defaultCombineFormat(zone)"
|
||||
title="Combined value"
|
||||
i18n-title
|
||||
hint="Several zones write to this field. Set the format or leave empty to join them with spaces."
|
||||
i18n-hint
|
||||
></pngx-input-text>
|
||||
<div class="d-flex flex-wrap gap-1 mt-n2 mb-3">
|
||||
@for (z of zonesForField(zone); track z) {
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary font-monospace" (click)="insertCombineToken(zone, z)">{{ '{' + z.name + '}' }}</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="row align-items-end">
|
||||
<div class="col">
|
||||
@if (!isLastPageZone(zone)) {
|
||||
<pngx-input-number [(ngModel)]="zone.page" [showAdd]="false" title="Page" i18n-title></pngx-input-number>
|
||||
}
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<pngx-input-switch [ngModel]="isLastPageZone(zone)" (ngModelChange)="setLastPageZone(zone, $event)" [horizontal]="true" title="Last page" i18n-title></pngx-input-switch>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6 class="border-top pt-3 mt-1 mb-3" i18n>Processing</h6>
|
||||
|
||||
<pngx-input-select
|
||||
[items]="languageOptions"
|
||||
[multiple]="true"
|
||||
[ngModel]="ocrLanguageArray(zone)"
|
||||
(ngModelChange)="setOcrLanguages(zone, $event)"
|
||||
title="OCR language"
|
||||
i18n-title
|
||||
placeholder="Select languages"
|
||||
i18n-placeholder
|
||||
></pngx-input-select>
|
||||
|
||||
<pngx-input-select [items]="transformOptions" [(ngModel)]="zone.transform" title="Transform" i18n-title></pngx-input-select>
|
||||
|
||||
@if (zone.transform === OcrZoneTransform.Date) {
|
||||
<pngx-input-text
|
||||
[(ngModel)]="zone.date_format"
|
||||
title="Date format"
|
||||
i18n-title
|
||||
placeholder="Auto-detect"
|
||||
i18n-placeholder
|
||||
hint="Python date codes, e.g. %d.%m.%Y. Leave empty to detect the format automatically."
|
||||
i18n-hint
|
||||
></pngx-input-text>
|
||||
}
|
||||
|
||||
<pngx-input-text [(ngModel)]="zone.validation_regex" title="Validation regex" i18n-title placeholder="\d{2}\.\d{2}\.\d{4}" hint="The value must match this pattern to be applied." i18n-hint></pngx-input-text>
|
||||
} @else {
|
||||
<pngx-ocr-template-zone-list
|
||||
[zones]="template.zones"
|
||||
[customFields]="customFields()"
|
||||
(zoneSelected)="selectZone($event)"
|
||||
(zoneRemoved)="removeZone($event)"
|
||||
></pngx-ocr-template-zone-list>
|
||||
}
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div [ngbNavOutlet]="nav" class="mt-3"></div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-8 d-flex flex-column">
|
||||
<div class="d-flex flex-wrap gap-2 mb-2 flex-shrink-0">
|
||||
<pngx-input-document-link
|
||||
class="flex-grow-1"
|
||||
[ngModel]="sampleDocumentIds"
|
||||
(ngModelChange)="setSampleDocument($event)"
|
||||
[minimal]="true"
|
||||
[multiple]="false"
|
||||
placeholder="Search for a sample document"
|
||||
i18n-placeholder
|
||||
></pngx-input-document-link>
|
||||
@if (previewDocId) {
|
||||
<div class="input-group w-auto flex-nowrap">
|
||||
<input class="form-control flex-grow-0 preview-page-input" type="number" min="1" [max]="previewPageCount()" [(ngModel)]="previewPageDisplay" [disabled]="!pageLoaded()" aria-label="Page" i18n-aria-label />
|
||||
<span class="input-group-text" i18n>of {{ previewPageCount() ?? '?' }}</span>
|
||||
</div>
|
||||
<fieldset class="input-group w-auto flex-nowrap" aria-label="Zoom" i18n-aria-label>
|
||||
<button type="button" class="btn btn-outline-secondary" aria-label="Zoom out" i18n-aria-label [disabled]="!pageLoaded() || zoom <= 0.5" (click)="zoomOut()"><i-bs name="dash"></i-bs></button>
|
||||
<button type="button" class="btn btn-outline-secondary preview-zoom" title="Reset zoom" i18n-title [disabled]="!pageLoaded()" (click)="resetZoom()">{{ zoom | percent: '1.0-0' }}</button>
|
||||
<button type="button" class="btn btn-outline-secondary" aria-label="Zoom in" i18n-aria-label [disabled]="!pageLoaded() || zoom >= 4" (click)="zoomIn()"><i-bs name="plus"></i-bs></button>
|
||||
</fieldset>
|
||||
}
|
||||
</div>
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<div class="h-100 overflow-auto border" [attr.aria-busy]="previewDocId && !pageLoaded()">
|
||||
@if (pageFailed()) {
|
||||
<div class="py-5 px-3 text-center" role="alert">
|
||||
<i-bs name="exclamation-triangle" width="32" height="32" class="text-warning"></i-bs>
|
||||
<p class="my-3" i18n>Could not load this page.</p>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" (click)="setSampleDocument(sampleDocumentIds)"><i-bs name="arrow-clockwise" class="me-1"></i-bs><span i18n>Retry</span></button>
|
||||
</div>
|
||||
} @else if (previewDocId) {
|
||||
<div class="position-relative" [class.d-none]="!pageLoaded()" [style.width.%]="zoom * 100">
|
||||
<img
|
||||
#pageImage
|
||||
[src]="pageImageUrl"
|
||||
(load)="onImageLoad()"
|
||||
(error)="onImageError()"
|
||||
alt="Sample document page"
|
||||
i18n-alt
|
||||
class="d-block w-100"
|
||||
crossorigin="use-credentials"
|
||||
/>
|
||||
@if (pageLoaded()) {
|
||||
<svg
|
||||
#zoneOverlay
|
||||
class="zone-overlay position-absolute top-0 start-0 w-100 h-100"
|
||||
[attr.viewBox]="overlayViewBox()"
|
||||
preserveAspectRatio="none"
|
||||
[style.cursor]="overlayCursor"
|
||||
(pointerdown)="onOverlayPointerDown($event)"
|
||||
(pointermove)="onOverlayPointerMove($event)"
|
||||
(pointerup)="onOverlayPointerUp($event)"
|
||||
(pointercancel)="onOverlayPointerCancel($event)"
|
||||
(lostpointercapture)="onOverlayPointerCancel($event)"
|
||||
aria-label="Zone overlay" i18n-aria-label
|
||||
>
|
||||
@for (zone of template.zones; track $index; let i = $index) {
|
||||
@if (zoneDisplayRect(i); as rect) {
|
||||
<g>
|
||||
<rect
|
||||
class="zone-rect"
|
||||
[class.zone-rect-selected]="selectedZoneIndex === i"
|
||||
[attr.x]="rect.x"
|
||||
[attr.y]="rect.y"
|
||||
[attr.width]="rect.w"
|
||||
[attr.height]="rect.h"
|
||||
[attr.stroke]="zoneColor(i)"
|
||||
[attr.fill]="zoneColor(i)"
|
||||
></rect>
|
||||
<text
|
||||
class="zone-label fw-semibold pe-none"
|
||||
[attr.x]="rect.x + overlayUnitSize(6)"
|
||||
[attr.y]="zoneLabelY(rect)"
|
||||
[attr.font-size]="overlayFontSize()"
|
||||
[attr.fill]="zoneColor(i)"
|
||||
>{{ zoneLabel(zone, i) }}</text>
|
||||
|
||||
@if (selectedZoneIndex === i) {
|
||||
@for (handle of resizeHandles(rect); track handle.handle) {
|
||||
<rect
|
||||
class="zone-resize-handle"
|
||||
[attr.x]="handle.x - overlayHandleSize() / 2"
|
||||
[attr.y]="handle.y - overlayHandleSize() / 2"
|
||||
[attr.width]="overlayHandleSize()"
|
||||
[attr.height]="overlayHandleSize()"
|
||||
[attr.fill]="zoneColor(i)"
|
||||
></rect>
|
||||
}
|
||||
}
|
||||
</g>
|
||||
}
|
||||
}
|
||||
|
||||
@if (drawingRect(); as rect) {
|
||||
<rect
|
||||
class="zone-drawing-rect"
|
||||
[attr.x]="rect.x"
|
||||
[attr.y]="rect.y"
|
||||
[attr.width]="rect.w"
|
||||
[attr.height]="rect.h"
|
||||
></rect>
|
||||
}
|
||||
</svg>
|
||||
}
|
||||
</div>
|
||||
@if (!pageLoaded()) {
|
||||
<div class="p-5 d-flex justify-content-center align-items-center">
|
||||
<output class="spinner-border">
|
||||
<span class="visually-hidden" i18n>Loading page...</span>
|
||||
</output>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="py-5 px-3 text-center text-muted">
|
||||
<i-bs name="file-text" width="32" height="32"></i-bs>
|
||||
<p class="mt-3 mb-0" i18n>No sample document selected.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" (click)="activeModal.dismiss()" [disabled]="saving()" i18n>Cancel</button>
|
||||
<button type="button" class="btn btn-primary" (click)="save()" [disabled]="saving() || !isDirty">
|
||||
@if (saving()) {
|
||||
<span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span>
|
||||
}
|
||||
<span i18n>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
-64
@@ -1,64 +0,0 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
.modal-body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-layout {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-page-input {
|
||||
width: 3.5rem;
|
||||
}
|
||||
|
||||
.preview-zoom {
|
||||
min-width: 4rem;
|
||||
}
|
||||
|
||||
.zone-overlay {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.zone-rect,
|
||||
.zone-drawing-rect {
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.zone-rect {
|
||||
fill-opacity: 0.2;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.zone-rect-selected {
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.zone-label {
|
||||
font-family: var(--bs-font-sans-serif);
|
||||
paint-order: stroke;
|
||||
stroke: #fff;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 4px;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.zone-resize-handle {
|
||||
stroke: #fff;
|
||||
stroke-width: 1;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.zone-drawing-rect {
|
||||
fill: var(--bs-primary);
|
||||
fill-opacity: 0.2;
|
||||
stroke: var(--bs-primary);
|
||||
stroke-dasharray: 5 5;
|
||||
stroke-width: 2;
|
||||
}
|
||||
-878
@@ -1,878 +0,0 @@
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { ElementRef } from '@angular/core'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { of, Subject, throwError } from 'rxjs'
|
||||
import { PermissionsFormComponent } from 'src/app/components/common/input/permissions/permissions-form/permissions-form.component'
|
||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
import { Document } from 'src/app/data/document'
|
||||
import {
|
||||
MATCH_ANY,
|
||||
MATCH_AUTO,
|
||||
MATCH_LITERAL,
|
||||
MATCH_NONE,
|
||||
} from 'src/app/data/matching-model'
|
||||
import {
|
||||
OcrTemplate,
|
||||
OcrTemplateZone,
|
||||
OcrZoneTarget,
|
||||
OcrZoneTestResult,
|
||||
OcrZoneTransform,
|
||||
} from 'src/app/data/ocr-template'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { User } from 'src/app/data/user'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { OcrTemplateEditorComponent } from './ocr-template-editor.component'
|
||||
|
||||
const customFields: CustomField[] = [
|
||||
{ id: 1, name: 'Invoice number', data_type: CustomFieldDataType.String },
|
||||
{ id: 2, name: 'Status', data_type: CustomFieldDataType.Select },
|
||||
]
|
||||
|
||||
function zone(overrides: Partial<OcrTemplateZone> = {}): OcrTemplateZone {
|
||||
return {
|
||||
name: 'Invoice number',
|
||||
target: OcrZoneTarget.CustomField,
|
||||
custom_field: 1,
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 100,
|
||||
page: 1,
|
||||
ocr_language: 'eng',
|
||||
transform: OcrZoneTransform.Strip,
|
||||
validation_regex: '',
|
||||
order: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function pointer(x: number, y: number, pointerId = 1): PointerEvent {
|
||||
return Object.assign(
|
||||
new MouseEvent('pointermove', { clientX: x, clientY: y, button: 0 }),
|
||||
{ pointerId }
|
||||
) as PointerEvent
|
||||
}
|
||||
|
||||
describe('OcrTemplateEditorComponent', () => {
|
||||
let component: OcrTemplateEditorComponent
|
||||
let fixture: ComponentFixture<OcrTemplateEditorComponent>
|
||||
let documentService: DocumentService
|
||||
let templateService: OcrTemplateService
|
||||
let modalService: NgbModal
|
||||
const activeModal = { close: jest.fn(), dismiss: jest.fn() }
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
OcrTemplateEditorComponent,
|
||||
],
|
||||
providers: [
|
||||
{ provide: NgbActiveModal, useValue: activeModal },
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
})
|
||||
jest
|
||||
.spyOn(TestBed.inject(CustomFieldsService), 'listAll')
|
||||
.mockReturnValue(of({ count: 2, all: [1, 2], results: customFields }))
|
||||
jest
|
||||
.spyOn(TestBed.inject(UserService), 'listAll')
|
||||
.mockReturnValue(of({ count: 0, all: [], results: [] }))
|
||||
TestBed.inject(PermissionsService).initialize([], { id: 1 } as User)
|
||||
documentService = TestBed.inject(DocumentService)
|
||||
jest
|
||||
.spyOn(documentService, 'get')
|
||||
.mockReturnValue(of({ id: 5, page_count: 3 }))
|
||||
templateService = TestBed.inject(OcrTemplateService)
|
||||
modalService = TestBed.inject(NgbModal)
|
||||
activeModal.close.mockReset()
|
||||
fixture = TestBed.createComponent(OcrTemplateEditorComponent)
|
||||
component = fixture.componentInstance
|
||||
})
|
||||
|
||||
function prepareOverlay() {
|
||||
const image = document.createElement('img')
|
||||
Object.defineProperties(image, {
|
||||
naturalWidth: { value: 1000 },
|
||||
naturalHeight: { value: 1000 },
|
||||
clientWidth: { value: 1000 },
|
||||
})
|
||||
const overlay = document.createElementNS(
|
||||
'http://www.w3.org/2000/svg',
|
||||
'svg'
|
||||
)
|
||||
Object.assign(overlay, {
|
||||
getScreenCTM: () => ({ inverse: () => ({}) }),
|
||||
createSVGPoint: () => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
matrixTransform() {
|
||||
return { x: this.x, y: this.y }
|
||||
},
|
||||
}),
|
||||
setPointerCapture: jest.fn(),
|
||||
hasPointerCapture: () => true,
|
||||
releasePointerCapture: jest.fn(),
|
||||
})
|
||||
component.imageRef = new ElementRef(image)
|
||||
component.overlayRef = new ElementRef(overlay)
|
||||
component.pageLoaded.set(true)
|
||||
}
|
||||
|
||||
function fieldDialog() {
|
||||
const dialog = {
|
||||
dialogMode: { set: jest.fn() },
|
||||
object: null,
|
||||
succeeded: new Subject<CustomField>(),
|
||||
failed: new Subject(),
|
||||
}
|
||||
jest
|
||||
.spyOn(modalService, 'open')
|
||||
.mockReturnValue({ componentInstance: dialog } as any)
|
||||
return dialog
|
||||
}
|
||||
|
||||
it('updates screen-sized overlay units when the preview image resizes', () => {
|
||||
let resize: ResizeObserverCallback
|
||||
const observer = {
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
}
|
||||
const resizeObserver = jest
|
||||
.spyOn(globalThis, 'ResizeObserver')
|
||||
.mockImplementation((callback) => {
|
||||
resize = callback
|
||||
return observer
|
||||
})
|
||||
const image = document.createElement('img')
|
||||
const ref = new ElementRef(image)
|
||||
component.pageImage = ref
|
||||
expect(observer.observe).toHaveBeenCalledWith(image)
|
||||
resize([], observer)
|
||||
expect(component.overlayUnitSize(8)).toBe(8)
|
||||
|
||||
Object.defineProperties(image, {
|
||||
naturalWidth: { value: 1000 },
|
||||
clientWidth: { value: 500 },
|
||||
})
|
||||
resize([], observer)
|
||||
expect(component.overlayUnitSize(8)).toBe(16)
|
||||
expect(component.overlayFontSize()).toBe(24)
|
||||
|
||||
component.pageImage = undefined
|
||||
expect(observer.disconnect).toHaveBeenCalledTimes(1)
|
||||
fixture.destroy()
|
||||
expect(observer.disconnect).toHaveBeenCalledTimes(2)
|
||||
resizeObserver.mockRestore()
|
||||
})
|
||||
|
||||
it('renders loaded image dimensions, zone labels, and selection handles', () => {
|
||||
component.object = {
|
||||
...component.template,
|
||||
id: 3,
|
||||
sample_document: 5,
|
||||
zones: [zone()],
|
||||
}
|
||||
component.selectedZoneIndex = 0
|
||||
fixture.detectChanges()
|
||||
const image = fixture.nativeElement.querySelector('img')
|
||||
Object.defineProperties(image, {
|
||||
naturalWidth: { value: 1000 },
|
||||
naturalHeight: { value: 1500 },
|
||||
})
|
||||
image.dispatchEvent(new Event('load'))
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(component.template).toMatchObject({
|
||||
source_width: 1000,
|
||||
source_height: 1500,
|
||||
})
|
||||
expect(
|
||||
fixture.nativeElement
|
||||
.querySelector('.zone-overlay')
|
||||
.getAttribute('viewBox')
|
||||
).toBe('0 0 1000 1500')
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.zone-label').textContent
|
||||
).toContain('Invoice number')
|
||||
expect(
|
||||
fixture.nativeElement.querySelectorAll('.zone-resize-handle')
|
||||
).toHaveLength(8)
|
||||
expect(component.zoneLabel(zone({ name: '' }), 2)).toBe('Zone 3')
|
||||
expect(component.zoneLabelY({ x: 0, y: 0, w: 100, h: 100 })).toBe(14)
|
||||
})
|
||||
|
||||
it('offers the supported language and transform choices', () => {
|
||||
expect(component.languageOptions.map((option) => option.id)).toContain(
|
||||
'eng'
|
||||
)
|
||||
expect(component.transformOptions.map((option) => option.id)).toEqual(
|
||||
Object.values(OcrZoneTransform)
|
||||
)
|
||||
})
|
||||
|
||||
it('clamps page navigation and supports one-based page input', () => {
|
||||
component.setSampleDocument([5])
|
||||
component.previewPageDisplay = 2
|
||||
expect(component.previewPage).toBe(1)
|
||||
expect(component.previewPageDisplay).toBe(2)
|
||||
expect(component.pageImageUrl).toContain('/5/1/')
|
||||
component.pageLoaded.set(true)
|
||||
component.goToPage(1)
|
||||
expect(component.pageLoaded()).toBe(true)
|
||||
component.goToPage(Infinity)
|
||||
component.goToPage(NaN)
|
||||
expect(component.previewPage).toBe(1)
|
||||
component.goToPage(99)
|
||||
expect(component.previewPage).toBe(2)
|
||||
expect(component.pageLoaded()).toBe(false)
|
||||
component.goToPage(-5)
|
||||
expect(component.previewPage).toBe(0)
|
||||
component.previewPageCount.set(null)
|
||||
component.goToPage(4.7)
|
||||
expect(component.previewPage).toBe(4)
|
||||
})
|
||||
|
||||
it('limits zoom and resets it to the original size', () => {
|
||||
for (let i = 0; i < 20; i++) component.zoomIn()
|
||||
expect(component.zoom).toBe(4)
|
||||
for (let i = 0; i < 20; i++) component.zoomOut()
|
||||
expect(component.zoom).toBe(0.5)
|
||||
component.resetZoom()
|
||||
expect(component.zoom).toBe(1)
|
||||
})
|
||||
|
||||
it('moves last-page zones to the last page and can pin them to a page', () => {
|
||||
component.setSampleDocument([5])
|
||||
const selected = zone()
|
||||
component.template.zones = [selected]
|
||||
expect(component.isLastPageZone(selected)).toBe(false)
|
||||
component.setLastPageZone(selected, true)
|
||||
expect(selected.page).toBe(-1)
|
||||
expect(component.isLastPageZone(selected)).toBe(true)
|
||||
component.selectZone(0)
|
||||
expect(component.previewPage).toBe(2)
|
||||
component.setLastPageZone(selected, false)
|
||||
expect(selected.page).toBe(3)
|
||||
})
|
||||
|
||||
it('ignores page-count errors from a previous sample document', () => {
|
||||
const pending = new Subject<Document>()
|
||||
jest
|
||||
.spyOn(documentService, 'get')
|
||||
.mockReturnValueOnce(pending)
|
||||
.mockReturnValueOnce(of({ id: 6 }))
|
||||
component.setSampleDocument([5])
|
||||
component.setSampleDocument([6])
|
||||
pending.error(new Error('Old document failed'))
|
||||
expect(component.pageFailed()).toBe(false)
|
||||
expect(component.previewPageCount()).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores non-primary, unloaded, and saving pointer gestures', () => {
|
||||
prepareOverlay()
|
||||
component.onOverlayPointerDown(
|
||||
new MouseEvent('pointerdown', { button: 2 }) as PointerEvent
|
||||
)
|
||||
expect(component.drawingRect()).toBeNull()
|
||||
component.pageLoaded.set(false)
|
||||
component.onOverlayPointerDown(pointer(100, 100))
|
||||
expect(component.drawingRect()).toBeNull()
|
||||
component.pageLoaded.set(true)
|
||||
component.saving.set(true)
|
||||
component.onOverlayPointerDown(pointer(100, 100))
|
||||
expect(component.drawingRect()).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores pointer events when the SVG coordinate transform is unavailable', () => {
|
||||
component.onOverlayPointerDown(pointer(100, 100))
|
||||
expect(component.drawingRect()).toBeNull()
|
||||
prepareOverlay()
|
||||
jest
|
||||
.spyOn(component.overlayRef.nativeElement, 'getScreenCTM')
|
||||
.mockReturnValue(null)
|
||||
component.onOverlayPointerDown(pointer(100, 100))
|
||||
component.onOverlayPointerMove(pointer(200, 200))
|
||||
expect(component.drawingRect()).toBeNull()
|
||||
})
|
||||
|
||||
it('updates a drawing only for its captured pointer', () => {
|
||||
prepareOverlay()
|
||||
component.onOverlayPointerDown(pointer(100, 100))
|
||||
component.onOverlayPointerDown(pointer(500, 500, 2))
|
||||
component.onOverlayPointerMove(pointer(900, 900, 2))
|
||||
expect(component.drawingRect()).toEqual({ x: 100, y: 100, w: 0, h: 0 })
|
||||
component.onOverlayPointerMove(pointer(250, 200))
|
||||
expect(component.drawingRect()).toEqual({ x: 100, y: 100, w: 150, h: 100 })
|
||||
component.onOverlayPointerUp(pointer(250, 200))
|
||||
expect(component.template.zones[0]).toMatchObject({
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 150,
|
||||
height: 100,
|
||||
})
|
||||
})
|
||||
|
||||
it('resizes a selected zone using its corner handle', () => {
|
||||
prepareOverlay()
|
||||
const selected = zone()
|
||||
component.template.zones = [selected]
|
||||
component.selectZone(0)
|
||||
component.onOverlayPointerDown(pointer(100, 100))
|
||||
component.onOverlayPointerMove(pointer(50, 75))
|
||||
component.onOverlayPointerUp(pointer(60, 80))
|
||||
expect(selected).toMatchObject({ x: 60, y: 80, width: 240, height: 120 })
|
||||
expect(component.template.zones).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows the appropriate cursor over handles, zones, and empty space', () => {
|
||||
prepareOverlay()
|
||||
component.template.zones = [zone()]
|
||||
component.onOverlayPointerMove(pointer(150, 150))
|
||||
expect(component.overlayCursor).toBe('move')
|
||||
component.selectZone(0)
|
||||
component.onOverlayPointerMove(pointer(100, 100))
|
||||
expect(component.overlayCursor).toBe('nw-resize')
|
||||
component.onOverlayPointerMove(pointer(500, 500))
|
||||
expect(component.overlayCursor).toBe('crosshair')
|
||||
component.template.zones[0].page = 2
|
||||
component.onOverlayPointerMove(pointer(100, 100))
|
||||
expect(component.zoneDisplayRect(0)).toBeNull()
|
||||
expect(component.overlayCursor).toBe('crosshair')
|
||||
})
|
||||
|
||||
it('cancels only the captured pointer and tolerates an already-lost capture', () => {
|
||||
prepareOverlay()
|
||||
component.onOverlayPointerCancel(pointer(100, 100))
|
||||
component.onOverlayPointerDown(pointer(100, 100))
|
||||
component.onOverlayPointerCancel(pointer(150, 150, 2))
|
||||
expect(component.drawingRect()).not.toBeNull()
|
||||
jest
|
||||
.spyOn(component.overlayRef.nativeElement, 'hasPointerCapture')
|
||||
.mockReturnValue(false)
|
||||
component.onOverlayPointerCancel(pointer(150, 150))
|
||||
expect(component.drawingRect()).toBeNull()
|
||||
expect(
|
||||
component.overlayRef.nativeElement.releasePointerCapture
|
||||
).not.toHaveBeenCalled()
|
||||
expect(component.template.zones).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves the selected zone when an earlier or later zone is removed', () => {
|
||||
component.template.zones = [zone(), zone(), zone(), zone()]
|
||||
component.selectZone(1)
|
||||
const selected = component.selectedZone
|
||||
component.removeZone(3)
|
||||
expect(component.selectedZone).toBe(selected)
|
||||
component.removeZone(0)
|
||||
expect(component.selectedZoneIndex).toBe(0)
|
||||
expect(component.selectedZone).toBe(selected)
|
||||
component.selectZone(99)
|
||||
expect(component.selectedZone).toBeNull()
|
||||
})
|
||||
|
||||
it('does not test zones without a selection or a sample document', () => {
|
||||
const test = jest.spyOn(templateService, 'testZone')
|
||||
component.testZone()
|
||||
component.template.zones = [zone()]
|
||||
component.selectZone(0)
|
||||
component.testZone()
|
||||
expect(test).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
response: { error: { error: 'OCR unavailable' } },
|
||||
message: 'OCR unavailable',
|
||||
},
|
||||
{ response: new Error('Network failure'), message: 'Test failed' },
|
||||
])('shows zone-test errors: $message', ({ response, message }) => {
|
||||
component.template.zones = [zone({ page: undefined })]
|
||||
component.selectZone(0)
|
||||
component.setSampleDocument([5])
|
||||
jest
|
||||
.spyOn(templateService, 'testZone')
|
||||
.mockReturnValue(throwError(() => response))
|
||||
component.testZone()
|
||||
expect(component.zoneTestResult).toEqual({ error: message })
|
||||
expect(component.zoneTesting).toBe(false)
|
||||
expect(templateService.testZone).toHaveBeenCalledWith(
|
||||
5,
|
||||
expect.objectContaining({ page: 1 })
|
||||
)
|
||||
})
|
||||
|
||||
it('switches between built-in targets, custom fields, and an unassigned field', () => {
|
||||
const selected = zone()
|
||||
expect(component.zoneFieldValue(selected)).toBe(1)
|
||||
component.setZoneField(selected, OcrZoneTarget.Title)
|
||||
expect(component.zoneFieldValue(selected)).toBe(OcrZoneTarget.Title)
|
||||
expect(selected.custom_field).toBeNull()
|
||||
component.setZoneField(selected, 7)
|
||||
expect(selected).toMatchObject({
|
||||
target: OcrZoneTarget.CustomField,
|
||||
custom_field: 7,
|
||||
})
|
||||
component.setZoneField(selected, null)
|
||||
expect(component.zoneFieldValue(selected)).toBeNull()
|
||||
expect(component.zonesForField(selected)).toEqual([])
|
||||
component.setCombineFormat(selected, 'Ignored')
|
||||
expect(component.template.combine_formats).toEqual({})
|
||||
})
|
||||
|
||||
it('creates combine formats when absent and leaves implicit formats alone on rename', () => {
|
||||
const selected = zone({ target: OcrZoneTarget.Title, custom_field: null })
|
||||
component.template.combine_formats = undefined
|
||||
component.renameZone(selected, 'Renamed')
|
||||
expect(selected.name).toBe('Renamed')
|
||||
expect(component.getCombineFormat(selected)).toBe('')
|
||||
component.setCombineFormat(selected, '{Renamed} ')
|
||||
component.insertCombineToken(selected, selected)
|
||||
expect(component.template.combine_formats).toEqual({
|
||||
title: '{Renamed} {Renamed}',
|
||||
})
|
||||
})
|
||||
|
||||
it('saves templates without combine formats', () => {
|
||||
component.template.combine_formats = undefined
|
||||
fixture.detectChanges()
|
||||
expect(component.isDirty).toBe(false)
|
||||
jest
|
||||
.spyOn(templateService, 'create')
|
||||
.mockReturnValue(of(component.template))
|
||||
component.save()
|
||||
expect(component.template.combine_formats).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not assign unsupported custom field types and surfaces creation failures', () => {
|
||||
component.template.zones = [zone({ custom_field: null })]
|
||||
component.selectZone(0)
|
||||
const dialog = fieldDialog()
|
||||
const error = jest.spyOn(TestBed.inject(ToastService), 'showError')
|
||||
component.createField('Status')
|
||||
expect(dialog.object).toEqual({ name: 'Status' })
|
||||
dialog.succeeded.next(customFields[1])
|
||||
expect(component.selectedZone.custom_field).toBeNull()
|
||||
const failure = new Error('Could not save')
|
||||
dialog.failed.next(failure)
|
||||
expect(error).toHaveBeenCalledWith('Error saving field.', failure)
|
||||
})
|
||||
|
||||
it('refreshes field choices when a field is created without a selected zone', () => {
|
||||
const dialog = fieldDialog()
|
||||
component.createField('Total')
|
||||
dialog.succeeded.next(customFields[0])
|
||||
expect(component.customFields()).toEqual(customFields)
|
||||
expect(component.selectedZone).toBeNull()
|
||||
})
|
||||
|
||||
it('should edit a copy of the template and load its sample document', () => {
|
||||
const original: OcrTemplate = {
|
||||
...component.template,
|
||||
id: 3,
|
||||
name: 'Invoices',
|
||||
match: 'Northwind',
|
||||
matching_algorithm: MATCH_LITERAL,
|
||||
sample_document: 5,
|
||||
zones: [zone()],
|
||||
combine_formats: { '1': '{Invoice number}' },
|
||||
}
|
||||
component.object = original
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(component.dialogTitle).toEqual('Edit OCR Template')
|
||||
expect(component.previewDocId).toEqual(5)
|
||||
expect(component.previewPageCount()).toEqual(3)
|
||||
expect(component.pageImageUrl).toContain('document-page-image/5/0/')
|
||||
expect(component.template.match).toEqual('Northwind')
|
||||
expect(component.isDirty).toBeFalsy()
|
||||
|
||||
component.template.zones[0].x = 500
|
||||
component.template.combine_formats['1'] = 'Edited'
|
||||
expect(original.zones[0].x).toEqual(100)
|
||||
expect(original.combine_formats['1']).toEqual('{Invoice number}')
|
||||
})
|
||||
|
||||
it('should offer every matching algorithm except automatic', () => {
|
||||
expect(component.matchingAlgorithms.map((a) => a.id)).not.toContain(
|
||||
MATCH_AUTO
|
||||
)
|
||||
expect(component.template.matching_algorithm).toEqual(MATCH_ANY)
|
||||
expect(component.patternRequired).toBeTruthy()
|
||||
|
||||
component.template.matching_algorithm = MATCH_NONE
|
||||
expect(component.patternRequired).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should initialize new template permissions from saved defaults', () => {
|
||||
const defaults = {
|
||||
[SETTINGS_KEYS.DEFAULT_PERMS_OWNER]: 1,
|
||||
[SETTINGS_KEYS.DEFAULT_PERMS_VIEW_USERS]: [2],
|
||||
[SETTINGS_KEYS.DEFAULT_PERMS_VIEW_GROUPS]: [3],
|
||||
[SETTINGS_KEYS.DEFAULT_PERMS_EDIT_USERS]: [],
|
||||
[SETTINGS_KEYS.DEFAULT_PERMS_EDIT_GROUPS]: [4],
|
||||
}
|
||||
jest
|
||||
.spyOn(TestBed.inject(SettingsService), 'get')
|
||||
.mockImplementation((key) => defaults[key])
|
||||
fixture.detectChanges()
|
||||
expect(component.permissionsForm).toEqual({
|
||||
owner: 1,
|
||||
set_permissions: {
|
||||
view: { users: [2], groups: [3] },
|
||||
change: { users: [], groups: [4] },
|
||||
},
|
||||
})
|
||||
expect(
|
||||
fixture.debugElement.query(By.directive(PermissionsFormComponent))
|
||||
).toBeTruthy()
|
||||
expect(component.isDirty).toBe(false)
|
||||
const create = jest
|
||||
.spyOn(templateService, 'create')
|
||||
.mockReturnValue(of({ ...component.template, id: 3 }))
|
||||
component.save()
|
||||
expect(create).toHaveBeenCalledWith(
|
||||
expect.objectContaining(component.permissionsForm)
|
||||
)
|
||||
})
|
||||
|
||||
it('should copy existing permissions and track changes without mutating the list item', () => {
|
||||
component.object = {
|
||||
...component.template,
|
||||
id: 3,
|
||||
owner: 1,
|
||||
permissions: {
|
||||
view: { users: [2], groups: [] },
|
||||
change: { users: [], groups: [3] },
|
||||
},
|
||||
}
|
||||
fixture.detectChanges()
|
||||
expect(component.permissionsForm.owner).toBe(1)
|
||||
expect(component.isDirty).toBe(false)
|
||||
component.permissionsForm.set_permissions.view.users.push(4)
|
||||
expect(component.isDirty).toBe(true)
|
||||
expect(component.object.permissions.view.users).toEqual([2])
|
||||
const update = jest
|
||||
.spyOn(templateService, 'update')
|
||||
.mockReturnValue(of(component.template))
|
||||
component.save()
|
||||
expect(update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
owner: 1,
|
||||
set_permissions: {
|
||||
view: { users: [2, 4], groups: [] },
|
||||
change: { users: [], groups: [3] },
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should hide sharing controls and omit permission changes for a shared editor', () => {
|
||||
component.object = {
|
||||
...component.template,
|
||||
id: 3,
|
||||
owner: 2,
|
||||
user_can_change: true,
|
||||
}
|
||||
fixture.detectChanges()
|
||||
expect(
|
||||
fixture.debugElement.query(By.directive(PermissionsFormComponent))
|
||||
).toBeNull()
|
||||
component.permissionsForm = {
|
||||
owner: 1,
|
||||
set_permissions: { view: { users: [1] } },
|
||||
}
|
||||
const update = jest
|
||||
.spyOn(templateService, 'update')
|
||||
.mockReturnValue(of(component.template))
|
||||
component.save()
|
||||
expect(update.mock.calls[0][0].owner).toBe(2)
|
||||
expect(update.mock.calls[0][0]).not.toHaveProperty('set_permissions')
|
||||
})
|
||||
|
||||
it('should keep only the last picked sample document', () => {
|
||||
component.setSampleDocument([5, 6])
|
||||
expect(component.sampleDocumentIds).toEqual([6])
|
||||
|
||||
component.setSampleDocument([])
|
||||
expect(component.previewDocId).toBeNull()
|
||||
expect(component.pageImageUrl).toBeNull()
|
||||
})
|
||||
|
||||
it('should ignore a stale sample document response', () => {
|
||||
const pending = new Subject<Document>()
|
||||
jest.spyOn(documentService, 'get').mockReturnValueOnce(pending)
|
||||
component.setSampleDocument([5])
|
||||
component.setSampleDocument([6])
|
||||
pending.next({ id: 5, page_count: 99 })
|
||||
|
||||
expect(component.previewPageCount()).toEqual(3)
|
||||
})
|
||||
|
||||
it('should show a failed page and retry it', () => {
|
||||
jest
|
||||
.spyOn(documentService, 'get')
|
||||
.mockReturnValueOnce(throwError(() => new Error('offline')))
|
||||
component.setSampleDocument([5])
|
||||
expect(component.pageFailed()).toBeTruthy()
|
||||
|
||||
component.setSampleDocument(component.sampleDocumentIds)
|
||||
expect(component.pageFailed()).toBeFalsy()
|
||||
|
||||
component.onImageError()
|
||||
expect(component.pageFailed()).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should support create and update', () => {
|
||||
const createSpy = jest
|
||||
.spyOn(templateService, 'create')
|
||||
.mockReturnValue(of({ ...component.template, id: 3 }))
|
||||
const updateSpy = jest
|
||||
.spyOn(templateService, 'update')
|
||||
.mockReturnValue(of(component.template))
|
||||
const succeededSpy = jest.spyOn(component.succeeded, 'emit')
|
||||
|
||||
component.save()
|
||||
expect(createSpy).toHaveBeenCalled()
|
||||
expect(succeededSpy).toHaveBeenCalledWith({ ...component.template, id: 3 })
|
||||
expect(activeModal.close).toHaveBeenCalled()
|
||||
|
||||
component.saving.set(false)
|
||||
component.template.id = 3
|
||||
component.save()
|
||||
expect(updateSpy).toHaveBeenCalledWith(component.template)
|
||||
})
|
||||
|
||||
it('should emit failed and allow editing again after a failed save', () => {
|
||||
prepareOverlay()
|
||||
component.template.zones = [zone()]
|
||||
const pending = new Subject<OcrTemplate>()
|
||||
jest.spyOn(templateService, 'create').mockReturnValue(pending)
|
||||
const failedSpy = jest.spyOn(component.failed, 'emit')
|
||||
component.onOverlayPointerDown(pointer(150, 150))
|
||||
|
||||
component.save()
|
||||
component.save()
|
||||
expect(templateService.create).toHaveBeenCalledTimes(1)
|
||||
component.onOverlayPointerMove(pointer(250, 250))
|
||||
component.onOverlayPointerUp(pointer(250, 250))
|
||||
expect(component.template.zones[0].x).toEqual(100)
|
||||
|
||||
pending.error(new Error('Save failed'))
|
||||
expect(failedSpy).toHaveBeenCalled()
|
||||
expect(component.saving()).toBeFalsy()
|
||||
component.onOverlayPointerDown(pointer(150, 150))
|
||||
component.onOverlayPointerUp(pointer(250, 250))
|
||||
expect(component.template.zones[0].x).toEqual(200)
|
||||
})
|
||||
|
||||
it('should track unsaved changes', () => {
|
||||
fixture.detectChanges()
|
||||
expect(component.isDirty).toBeFalsy()
|
||||
|
||||
component.template.combine_formats = { '1': '' }
|
||||
expect(component.isDirty).toBeFalsy()
|
||||
|
||||
component.template.name = 'Invoices'
|
||||
expect(component.isDirty).toBeTruthy()
|
||||
component.template.name = ''
|
||||
expect(component.isDirty).toBeFalsy()
|
||||
|
||||
component.setSampleDocument([5])
|
||||
expect(component.isDirty).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should draw a zone with no field in empty space', () => {
|
||||
prepareOverlay()
|
||||
component.onOverlayPointerDown(pointer(100, 100))
|
||||
component.onOverlayPointerUp(pointer(1200, 250))
|
||||
|
||||
expect(component.template.zones[0]).toMatchObject({
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 900,
|
||||
height: 150,
|
||||
custom_field: null,
|
||||
})
|
||||
expect(component.selectedZoneIndex).toEqual(0)
|
||||
})
|
||||
|
||||
it('should clear the selection on a click in empty space', () => {
|
||||
prepareOverlay()
|
||||
component.template.zones = [zone()]
|
||||
component.selectZone(0)
|
||||
component.onOverlayPointerDown(pointer(600, 600))
|
||||
component.onOverlayPointerUp(pointer(602, 601))
|
||||
|
||||
expect(component.template.zones).toHaveLength(1)
|
||||
expect(component.selectedZoneIndex).toBeNull()
|
||||
})
|
||||
|
||||
it('should move a zone when dragging on it', () => {
|
||||
prepareOverlay()
|
||||
component.template.zones = [zone()]
|
||||
component.onOverlayPointerDown(pointer(150, 150))
|
||||
component.onOverlayPointerUp(pointer(250, 200))
|
||||
|
||||
expect(component.template.zones).toHaveLength(1)
|
||||
expect(component.template.zones[0]).toMatchObject({ x: 200, y: 150 })
|
||||
})
|
||||
|
||||
it('should cancel a drawing on escape and ignore other pointers', () => {
|
||||
prepareOverlay()
|
||||
component.onOverlayPointerDown(pointer(100, 100))
|
||||
component.onOverlayPointerUp(pointer(300, 200, 2))
|
||||
expect(component.drawingRect()).not.toBeNull()
|
||||
|
||||
component.cancelDrawing()
|
||||
component.onOverlayPointerUp(pointer(300, 200))
|
||||
expect(component.template.zones).toEqual([])
|
||||
})
|
||||
|
||||
it('should open a zone in the zones tab and go back to the list', () => {
|
||||
component.template.zones = [zone(), zone()]
|
||||
component.selectZone(1)
|
||||
expect(component.activeTab).toEqual('zones')
|
||||
expect(component.selectedZone).toBe(component.template.zones[1])
|
||||
|
||||
component.deselectZone()
|
||||
expect(component.selectedZone).toBeNull()
|
||||
|
||||
component.selectZone(0)
|
||||
component.removeZone(0)
|
||||
expect(component.template.zones).toHaveLength(1)
|
||||
expect(component.selectedZone).toBeNull()
|
||||
})
|
||||
|
||||
it('should only offer custom fields OCR can write to', () => {
|
||||
fixture.detectChanges()
|
||||
expect(component.fieldItems().map((item) => item.id)).toEqual([
|
||||
OcrZoneTarget.Title,
|
||||
OcrZoneTarget.Asn,
|
||||
OcrZoneTarget.Created,
|
||||
1,
|
||||
])
|
||||
})
|
||||
|
||||
it('should create a custom field and assign it to the zone', () => {
|
||||
const succeeded = new Subject<CustomField>()
|
||||
const dialog = {
|
||||
dialogMode: { set: jest.fn() },
|
||||
object: null,
|
||||
succeeded,
|
||||
failed: new Subject(),
|
||||
}
|
||||
jest
|
||||
.spyOn(modalService, 'open')
|
||||
.mockReturnValue({ componentInstance: dialog } as any)
|
||||
component.template.zones = [zone({ custom_field: null })]
|
||||
component.selectZone(0)
|
||||
|
||||
component.createField()
|
||||
expect(dialog.object).toEqual({ name: 'Invoice number' })
|
||||
succeeded.next(customFields[0])
|
||||
|
||||
expect(component.template.zones[0].custom_field).toEqual(1)
|
||||
})
|
||||
|
||||
it('should build and prune combine formats', () => {
|
||||
const first = zone({ name: 'First' })
|
||||
const second = zone({ name: 'Second' })
|
||||
const other = zone({ name: 'Other', custom_field: 2 })
|
||||
component.template.zones = [first, second, other]
|
||||
component.template.combine_formats = { '2': '{Other}' }
|
||||
|
||||
expect(component.isFieldShared(first)).toBeTruthy()
|
||||
expect(component.isFieldShared(other)).toBeFalsy()
|
||||
expect(component.defaultCombineFormat(first)).toEqual('{First} {Second}')
|
||||
|
||||
component.insertCombineToken(first, second)
|
||||
component.insertCombineToken(first, first)
|
||||
expect(component.getCombineFormat(second)).toEqual('{Second} {First}')
|
||||
|
||||
jest.spyOn(templateService, 'create').mockReturnValue(of(null))
|
||||
component.save()
|
||||
expect(component.template.combine_formats).toEqual({
|
||||
'1': '{Second} {First}',
|
||||
})
|
||||
})
|
||||
|
||||
it('should update exact tokens when a zone is renamed', () => {
|
||||
const first = zone({ name: 'First [value]' })
|
||||
component.template.zones = [first, zone({ name: 'Second' })]
|
||||
component.template.combine_formats = {
|
||||
'1': 'First [value]: {First [value]} / { First [value] } / {Second} / {First [value] extra}',
|
||||
'2': '{First [value]}',
|
||||
}
|
||||
|
||||
component.renameZone(first, '')
|
||||
component.renameZone(first, 'Invoice number')
|
||||
|
||||
expect(first.name).toEqual('Invoice number')
|
||||
expect(component.template.combine_formats).toEqual({
|
||||
'1': 'First [value]: {Invoice number} / {Invoice number} / {Second} / {First [value] extra}',
|
||||
'2': '{First [value]}',
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep the language array stable until the value changes', () => {
|
||||
const selected = zone({ ocr_language: 'deu+eng' })
|
||||
const languages = component.ocrLanguageArray(selected)
|
||||
expect(languages).toEqual(['deu', 'eng'])
|
||||
expect(component.ocrLanguageArray(selected)).toBe(languages)
|
||||
|
||||
component.setOcrLanguages(selected, ['fra'])
|
||||
expect(selected.ocr_language).toEqual('fra')
|
||||
expect(component.ocrLanguageArray(selected)).toEqual(['fra'])
|
||||
|
||||
component.setOcrLanguages(selected, null)
|
||||
expect(component.ocrLanguageArray(selected)).toEqual([])
|
||||
})
|
||||
|
||||
it('should hide a test result once the zone changes', () => {
|
||||
const selected = zone()
|
||||
component.template.zones = [selected]
|
||||
component.selectZone(0)
|
||||
component.setSampleDocument([5])
|
||||
jest
|
||||
.spyOn(templateService, 'testZone')
|
||||
.mockReturnValue(of({ value: '123' }))
|
||||
|
||||
component.testZone()
|
||||
expect(component.zoneTestResult.value).toEqual('123')
|
||||
|
||||
selected.transform = OcrZoneTransform.Numeric
|
||||
expect(component.zoneTestResult).toBeNull()
|
||||
})
|
||||
|
||||
it('should drop a pending test when the zone or sample document changes', () => {
|
||||
const pending = new Subject<OcrZoneTestResult>()
|
||||
component.template.zones = [zone(), zone()]
|
||||
component.selectZone(0)
|
||||
component.setSampleDocument([5])
|
||||
jest.spyOn(templateService, 'testZone').mockReturnValue(pending)
|
||||
|
||||
component.testZone()
|
||||
expect(component.zoneTesting).toBeTruthy()
|
||||
component.selectZone(1)
|
||||
pending.next({ value: 'Old zone' })
|
||||
expect(component.zoneTestResult).toBeNull()
|
||||
|
||||
component.testZone()
|
||||
component.setSampleDocument([])
|
||||
pending.next({ value: 'Old document' })
|
||||
expect(component.zoneTestResult).toBeNull()
|
||||
})
|
||||
})
|
||||
-963
@@ -1,963 +0,0 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
HostListener,
|
||||
inject,
|
||||
Input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output,
|
||||
signal,
|
||||
ViewChild,
|
||||
} from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import {
|
||||
NgbActiveModal,
|
||||
NgbModal,
|
||||
NgbNavModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { Subject, takeUntil } from 'rxjs'
|
||||
import { CustomFieldEditDialogComponent } from 'src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||
import { EditDialogMode } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
||||
import { DocumentLinkComponent } from 'src/app/components/common/input/document-link/document-link.component'
|
||||
import { CheckComponent } from 'src/app/components/common/input/check/check.component'
|
||||
import { NumberComponent } from 'src/app/components/common/input/number/number.component'
|
||||
import {
|
||||
PermissionsFormComponent,
|
||||
PermissionsFormObject,
|
||||
} from 'src/app/components/common/input/permissions/permissions-form/permissions-form.component'
|
||||
import { SelectComponent } from 'src/app/components/common/input/select/select.component'
|
||||
import { SwitchComponent } from 'src/app/components/common/input/switch/switch.component'
|
||||
import { TextComponent } from 'src/app/components/common/input/text/text.component'
|
||||
import { CustomField } from 'src/app/data/custom-field'
|
||||
import {
|
||||
MATCH_ANY,
|
||||
MATCH_AUTO,
|
||||
MATCH_NONE,
|
||||
MATCHING_ALGORITHMS,
|
||||
} from 'src/app/data/matching-model'
|
||||
import {
|
||||
DEFAULT_OCR_ZONE_LANGUAGE,
|
||||
getZoneFieldKey,
|
||||
OCR_BUILTIN_TARGETS,
|
||||
OCR_SUPPORTED_FIELD_TYPES,
|
||||
OcrTemplate,
|
||||
OcrTemplateZone,
|
||||
OcrZoneTarget,
|
||||
OcrZoneTestResult,
|
||||
OcrZoneTransform,
|
||||
ZoneTestRequest,
|
||||
} from 'src/app/data/ocr-template'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { User } from 'src/app/data/user'
|
||||
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { OcrTemplateEditorZoneListComponent } from './ocr-template-editor-zone-list/ocr-template-editor-zone-list.component'
|
||||
import {
|
||||
DisplayRect,
|
||||
DrawingRect,
|
||||
findHandleAt,
|
||||
findZoneAt,
|
||||
getResizeHandles,
|
||||
getZoneColor,
|
||||
getZoneDisplayRect,
|
||||
getZonePage,
|
||||
HANDLE_SIZE,
|
||||
isZoneOnPage,
|
||||
MIN_ZONE_SIZE,
|
||||
MoveStart,
|
||||
moveZone,
|
||||
Point,
|
||||
ResizeHandle,
|
||||
resizeZone,
|
||||
} from './zone-geometry'
|
||||
|
||||
type ActiveTab = 'template' | 'zones'
|
||||
type ZoneFieldSelection = OcrZoneTarget | number | null
|
||||
type OverlayInteraction =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'drawing'; pointerId: number; rect: DrawingRect }
|
||||
| { kind: 'moving'; pointerId: number; zoneIndex: number; start: MoveStart }
|
||||
| {
|
||||
kind: 'resizing'
|
||||
pointerId: number
|
||||
zoneIndex: number
|
||||
handle: ResizeHandle
|
||||
}
|
||||
interface ZoneTest {
|
||||
zone: OcrTemplateZone
|
||||
/** The sample document and zone settings the result was produced from. */
|
||||
key: string
|
||||
result: OcrZoneTestResult | null
|
||||
}
|
||||
|
||||
export const ZONE_TRANSFORM_OPTIONS = [
|
||||
{ id: OcrZoneTransform.None, name: $localize`None` },
|
||||
{ id: OcrZoneTransform.Strip, name: $localize`Strip whitespace` },
|
||||
{ id: OcrZoneTransform.Uppercase, name: $localize`Uppercase` },
|
||||
{ id: OcrZoneTransform.Lowercase, name: $localize`Lowercase` },
|
||||
{ id: OcrZoneTransform.Numeric, name: $localize`Numeric only` },
|
||||
{
|
||||
id: OcrZoneTransform.StripPunctuation,
|
||||
name: $localize`Remove leading/trailing punctuation`,
|
||||
},
|
||||
{ id: OcrZoneTransform.Date, name: $localize`Parse date` },
|
||||
{ id: OcrZoneTransform.QrCode, name: $localize`Read QR/barcode` },
|
||||
]
|
||||
|
||||
export const ZONE_LANGUAGE_OPTIONS = [
|
||||
{ id: 'eng', name: $localize`English` },
|
||||
{ id: 'deu', name: $localize`German` },
|
||||
{ id: 'fra', name: $localize`French` },
|
||||
{ id: 'ita', name: $localize`Italian` },
|
||||
{ id: 'spa', name: $localize`Spanish` },
|
||||
{ id: 'por', name: $localize`Portuguese` },
|
||||
{ id: 'nld', name: $localize`Dutch` },
|
||||
]
|
||||
|
||||
const TEMPLATE_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
|
||||
(algorithm) => algorithm.id !== MATCH_AUTO
|
||||
)
|
||||
|
||||
const NO_OVERLAY_INTERACTION: OverlayInteraction = { kind: 'idle' }
|
||||
const RESIZE_CURSOR: Record<ResizeHandle, string> = {
|
||||
nw: 'nw-resize',
|
||||
ne: 'ne-resize',
|
||||
sw: 'sw-resize',
|
||||
se: 'se-resize',
|
||||
n: 'n-resize',
|
||||
s: 's-resize',
|
||||
w: 'w-resize',
|
||||
e: 'e-resize',
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-ocr-template-editor',
|
||||
imports: [
|
||||
DocumentLinkComponent,
|
||||
NumberComponent,
|
||||
TextComponent,
|
||||
CheckComponent,
|
||||
SelectComponent,
|
||||
SwitchComponent,
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgbNavModule,
|
||||
NgxBootstrapIconsModule,
|
||||
OcrTemplateEditorZoneListComponent,
|
||||
PermissionsFormComponent,
|
||||
IfOwnerDirective,
|
||||
],
|
||||
templateUrl: './ocr-template-editor.component.html',
|
||||
styleUrls: ['./ocr-template-editor.component.scss'],
|
||||
})
|
||||
export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
public readonly activeModal = inject(NgbActiveModal)
|
||||
private readonly templateService = inject(OcrTemplateService)
|
||||
private readonly customFieldsService = inject(CustomFieldsService)
|
||||
private readonly documentService = inject(DocumentService)
|
||||
private readonly toastService = inject(ToastService)
|
||||
private readonly modalService = inject(NgbModal)
|
||||
private readonly userService = inject(UserService)
|
||||
private readonly settingsService = inject(SettingsService)
|
||||
private readonly permissionsService = inject(PermissionsService)
|
||||
private readonly destroy$ = new Subject<void>()
|
||||
private readonly cancelZoneTest$ = new Subject<void>()
|
||||
|
||||
@Input() object: OcrTemplate
|
||||
@Output() public readonly succeeded = new EventEmitter<OcrTemplate>()
|
||||
@Output() public readonly failed = new EventEmitter()
|
||||
|
||||
@ViewChild('zoneOverlay') overlayRef: ElementRef<SVGSVGElement>
|
||||
imageRef: ElementRef<HTMLImageElement>
|
||||
private previewResizeObserver: ResizeObserver | null = null
|
||||
private readonly overlayScale = signal(1)
|
||||
|
||||
@ViewChild('pageImage')
|
||||
set pageImage(ref: ElementRef<HTMLImageElement> | undefined) {
|
||||
this.previewResizeObserver?.disconnect()
|
||||
this.imageRef = ref
|
||||
if (!ref) return
|
||||
this.previewResizeObserver = new ResizeObserver(() => {
|
||||
const image = ref.nativeElement
|
||||
if (image.naturalWidth && image.clientWidth) {
|
||||
this.overlayScale.set(image.naturalWidth / image.clientWidth)
|
||||
}
|
||||
})
|
||||
this.previewResizeObserver.observe(ref.nativeElement)
|
||||
}
|
||||
|
||||
template: OcrTemplate = {
|
||||
id: null,
|
||||
name: '',
|
||||
match: '',
|
||||
matching_algorithm: MATCH_ANY,
|
||||
is_insensitive: true,
|
||||
sample_document: null,
|
||||
source_width: 0,
|
||||
source_height: 0,
|
||||
enabled: true,
|
||||
combine_formats: {},
|
||||
zones: [],
|
||||
}
|
||||
|
||||
public readonly customFields = signal<CustomField[]>([])
|
||||
public readonly fieldItems = computed<
|
||||
{ id: ZoneFieldSelection; name: string }[]
|
||||
>(() => [
|
||||
...OCR_BUILTIN_TARGETS,
|
||||
...this.customFields()
|
||||
.filter((field) => OCR_SUPPORTED_FIELD_TYPES.includes(field.data_type))
|
||||
.map((field) => ({ id: field.id, name: field.name })),
|
||||
])
|
||||
public readonly users = signal<User[]>([])
|
||||
permissionsForm: PermissionsFormObject = {}
|
||||
public readonly OcrZoneTransform = OcrZoneTransform
|
||||
public readonly saving = signal(false)
|
||||
|
||||
/** Bound to the document picker, which works with id arrays. */
|
||||
sampleDocumentIds: number[] = []
|
||||
previewPage = 0
|
||||
public readonly previewPageCount = signal<number | null>(null)
|
||||
public readonly pageLoaded = signal(false)
|
||||
public readonly pageFailed = signal(false)
|
||||
zoom = 1
|
||||
|
||||
get previewDocId(): number | null {
|
||||
return this.sampleDocumentIds[0] ?? null
|
||||
}
|
||||
|
||||
get pageImageUrl(): string | null {
|
||||
return this.previewDocId
|
||||
? this.templateService.getPageImageUrl(
|
||||
this.previewDocId,
|
||||
this.previewPage
|
||||
)
|
||||
: null
|
||||
}
|
||||
|
||||
get transformOptions() {
|
||||
return ZONE_TRANSFORM_OPTIONS
|
||||
}
|
||||
|
||||
get languageOptions() {
|
||||
return ZONE_LANGUAGE_OPTIONS
|
||||
}
|
||||
|
||||
get matchingAlgorithms() {
|
||||
return TEMPLATE_MATCHING_ALGORITHMS
|
||||
}
|
||||
|
||||
get patternRequired(): boolean {
|
||||
return (
|
||||
this.template.matching_algorithm !== MATCH_AUTO &&
|
||||
this.template.matching_algorithm !== MATCH_NONE
|
||||
)
|
||||
}
|
||||
|
||||
public get previewPageDisplay(): number {
|
||||
return this.previewPage + 1
|
||||
}
|
||||
|
||||
public set previewPageDisplay(value: number) {
|
||||
this.goToPage(value - 1)
|
||||
}
|
||||
|
||||
activeTab: ActiveTab = 'template'
|
||||
|
||||
selectedZoneIndex: number | null = null
|
||||
private overlayInteraction: OverlayInteraction = NO_OVERLAY_INTERACTION
|
||||
overlayCursor = 'crosshair'
|
||||
|
||||
private readonly zoneTest = signal<ZoneTest | null>(null)
|
||||
|
||||
get zoneTestResult(): OcrZoneTestResult | null {
|
||||
return this.isCurrentZoneTest() ? this.zoneTest().result : null
|
||||
}
|
||||
|
||||
get zoneTesting(): boolean {
|
||||
return this.isCurrentZoneTest() && this.zoneTest().result === null
|
||||
}
|
||||
|
||||
get selectedZone(): OcrTemplateZone | null {
|
||||
return this.selectedZoneIndex !== null
|
||||
? (this.template.zones[this.selectedZoneIndex] ?? null)
|
||||
: null
|
||||
}
|
||||
|
||||
private initialState = ''
|
||||
|
||||
get isDirty(): boolean {
|
||||
return this.editorState() !== this.initialState
|
||||
}
|
||||
|
||||
private editorState(): string {
|
||||
const formats = Object.entries(this.template.combine_formats ?? {}).filter(
|
||||
([, format]) => format
|
||||
)
|
||||
return JSON.stringify({
|
||||
...this.template,
|
||||
combine_formats: Object.fromEntries(formats),
|
||||
sample_document: this.previewDocId ?? null,
|
||||
permissions_form: this.permissionsForm,
|
||||
})
|
||||
}
|
||||
|
||||
get dialogTitle(): string {
|
||||
return this.template.id
|
||||
? $localize`Edit OCR Template`
|
||||
: $localize`New OCR Template`
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.userService
|
||||
.listAll()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((r) => this.users.set(r.results))
|
||||
this.customFieldsService
|
||||
.listAll()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((r) => {
|
||||
this.customFields.set(r.results)
|
||||
})
|
||||
|
||||
if (this.object) {
|
||||
this.template = {
|
||||
...this.object,
|
||||
zones: this.object.zones.map((zone) => ({ ...zone })),
|
||||
combine_formats: { ...this.object.combine_formats },
|
||||
}
|
||||
if (this.template.sample_document) {
|
||||
this.setSampleDocument([this.template.sample_document])
|
||||
}
|
||||
}
|
||||
const permissions = this.object?.permissions
|
||||
this.permissionsForm = this.object
|
||||
? {
|
||||
owner: this.object.owner,
|
||||
set_permissions: {
|
||||
view: {
|
||||
users: [...(permissions?.view.users ?? [])],
|
||||
groups: [...(permissions?.view.groups ?? [])],
|
||||
},
|
||||
change: {
|
||||
users: [...(permissions?.change.users ?? [])],
|
||||
groups: [...(permissions?.change.groups ?? [])],
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
owner: this.settingsService.get(SETTINGS_KEYS.DEFAULT_PERMS_OWNER),
|
||||
set_permissions: {
|
||||
view: {
|
||||
users: this.settingsService.get(
|
||||
SETTINGS_KEYS.DEFAULT_PERMS_VIEW_USERS
|
||||
),
|
||||
groups: this.settingsService.get(
|
||||
SETTINGS_KEYS.DEFAULT_PERMS_VIEW_GROUPS
|
||||
),
|
||||
},
|
||||
change: {
|
||||
users: this.settingsService.get(
|
||||
SETTINGS_KEYS.DEFAULT_PERMS_EDIT_USERS
|
||||
),
|
||||
groups: this.settingsService.get(
|
||||
SETTINGS_KEYS.DEFAULT_PERMS_EDIT_GROUPS
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
this.initialState = this.editorState()
|
||||
}
|
||||
|
||||
setSampleDocument(ids: number[]) {
|
||||
const id = ids.at(-1)
|
||||
this.sampleDocumentIds = id ? [id] : []
|
||||
this.previewPage = 0
|
||||
this.previewPageCount.set(null)
|
||||
this.resetPage()
|
||||
if (!id) return
|
||||
this.documentService
|
||||
.get(id)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (doc) => {
|
||||
if (this.previewDocId !== id) return
|
||||
this.previewPageCount.set(doc.page_count ?? null)
|
||||
},
|
||||
error: () => {
|
||||
if (this.previewDocId === id) this.pageFailed.set(true)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private resetPage() {
|
||||
this.stopOverlayInteraction()
|
||||
this.clearZoneTest()
|
||||
this.pageLoaded.set(false)
|
||||
this.pageFailed.set(false)
|
||||
}
|
||||
|
||||
goToPage(page: number) {
|
||||
if (!Number.isFinite(page)) return
|
||||
const pageCount = this.previewPageCount()
|
||||
const max = pageCount ? pageCount - 1 : page
|
||||
const clamped = Math.max(0, Math.min(Math.trunc(page), max))
|
||||
if (clamped === this.previewPage) return
|
||||
this.previewPage = clamped
|
||||
this.resetPage()
|
||||
}
|
||||
|
||||
zoomIn() {
|
||||
this.zoom = Math.min(4, Math.round((this.zoom + 0.25) * 100) / 100)
|
||||
}
|
||||
|
||||
zoomOut() {
|
||||
this.zoom = Math.max(0.5, Math.round((this.zoom - 0.25) * 100) / 100)
|
||||
}
|
||||
|
||||
resetZoom() {
|
||||
this.zoom = 1
|
||||
}
|
||||
|
||||
zonePage(zone: OcrTemplateZone): number {
|
||||
return getZonePage(zone, this.previewPage, this.previewPageCount())
|
||||
}
|
||||
|
||||
isLastPageZone(zone: OcrTemplateZone): boolean {
|
||||
return zone.page === -1
|
||||
}
|
||||
|
||||
setLastPageZone(zone: OcrTemplateZone, lastPage: boolean) {
|
||||
zone.page = lastPage ? -1 : this.previewPage + 1
|
||||
}
|
||||
|
||||
private isOnCurrentPage(zone: OcrTemplateZone): boolean {
|
||||
return isZoneOnPage(zone, this.previewPage, this.previewPageCount())
|
||||
}
|
||||
|
||||
onImageLoad() {
|
||||
this.pageLoaded.set(true)
|
||||
const img = this.imageRef.nativeElement
|
||||
this.template.source_width = img.naturalWidth
|
||||
this.template.source_height = img.naturalHeight
|
||||
}
|
||||
|
||||
onImageError() {
|
||||
this.stopOverlayInteraction()
|
||||
this.pageFailed.set(true)
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
cancelDrawing() {
|
||||
this.stopOverlayInteraction()
|
||||
}
|
||||
|
||||
onOverlayPointerDown(event: PointerEvent) {
|
||||
if (this.saving()) return
|
||||
if (event.button !== 0 || this.overlayInteraction.kind !== 'idle') return
|
||||
const point = this.svgPointFromEvent(event)
|
||||
if (!this.pageLoaded() || !point) return
|
||||
event.preventDefault()
|
||||
|
||||
if (this.selectedZoneIndex !== null) {
|
||||
const handle = this.handleAt(point, this.selectedZoneIndex)
|
||||
if (handle) {
|
||||
this.overlayInteraction = {
|
||||
kind: 'resizing',
|
||||
pointerId: event.pointerId,
|
||||
zoneIndex: this.selectedZoneIndex,
|
||||
handle,
|
||||
}
|
||||
this.overlayRef.nativeElement.setPointerCapture(event.pointerId)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const clickedIdx = this.zoneIndexAt(point)
|
||||
if (clickedIdx !== null) {
|
||||
this.selectZone(clickedIdx)
|
||||
const zone = this.template.zones[clickedIdx]
|
||||
this.overlayInteraction = {
|
||||
kind: 'moving',
|
||||
pointerId: event.pointerId,
|
||||
zoneIndex: clickedIdx,
|
||||
start: {
|
||||
mouseX: point.x,
|
||||
mouseY: point.y,
|
||||
zoneX: zone.x,
|
||||
zoneY: zone.y,
|
||||
},
|
||||
}
|
||||
this.overlayRef.nativeElement.setPointerCapture(event.pointerId)
|
||||
return
|
||||
}
|
||||
|
||||
// Empty space: start drawing a new zone. A click without a drag just clears
|
||||
// the selection (see onOverlayPointerUp).
|
||||
this.clearZoneTest()
|
||||
this.selectedZoneIndex = null
|
||||
this.overlayInteraction = {
|
||||
kind: 'drawing',
|
||||
pointerId: event.pointerId,
|
||||
rect: {
|
||||
startX: point.x,
|
||||
startY: point.y,
|
||||
endX: point.x,
|
||||
endY: point.y,
|
||||
},
|
||||
}
|
||||
this.overlayRef.nativeElement.setPointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
onOverlayPointerMove(event: PointerEvent) {
|
||||
if (
|
||||
this.overlayInteraction.kind !== 'idle' &&
|
||||
this.overlayInteraction.pointerId !== event.pointerId
|
||||
)
|
||||
return
|
||||
const point = this.svgPointFromEvent(event)
|
||||
if (!point) return
|
||||
|
||||
if (this.overlayInteraction.kind === 'resizing') {
|
||||
resizeZone(
|
||||
this.template.zones[this.overlayInteraction.zoneIndex],
|
||||
this.overlayInteraction.handle,
|
||||
point,
|
||||
this.imageNaturalSize()
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.overlayInteraction.kind === 'moving') {
|
||||
moveZone(
|
||||
this.template.zones[this.overlayInteraction.zoneIndex],
|
||||
point,
|
||||
this.overlayInteraction.start,
|
||||
this.imageNaturalSize()
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.overlayInteraction.kind === 'drawing') {
|
||||
this.overlayInteraction.rect.endX = point.x
|
||||
this.overlayInteraction.rect.endY = point.y
|
||||
return
|
||||
}
|
||||
|
||||
this.updateOverlayCursor(point)
|
||||
}
|
||||
|
||||
private updateOverlayCursor(point: Point) {
|
||||
if (this.selectedZoneIndex !== null) {
|
||||
const handle = this.handleAt(point, this.selectedZoneIndex)
|
||||
if (handle) {
|
||||
this.overlayCursor = RESIZE_CURSOR[handle]
|
||||
return
|
||||
}
|
||||
}
|
||||
this.overlayCursor = this.zoneIndexAt(point) !== null ? 'move' : 'crosshair'
|
||||
}
|
||||
|
||||
onOverlayPointerUp(event: PointerEvent) {
|
||||
if (this.overlayInteraction.kind === 'idle') return
|
||||
if (this.overlayInteraction.pointerId !== event.pointerId) return
|
||||
this.onOverlayPointerMove(event)
|
||||
const interaction = this.overlayInteraction
|
||||
this.stopOverlayInteraction()
|
||||
if (interaction.kind !== 'drawing') return
|
||||
|
||||
const rect = this.sourceRectFromDrawing(interaction.rect)
|
||||
|
||||
// Ignore tiny accidental clicks.
|
||||
if (rect.w < MIN_ZONE_SIZE || rect.h < MIN_ZONE_SIZE) {
|
||||
return
|
||||
}
|
||||
|
||||
this.template.zones.push(this.createZoneFromRect(rect))
|
||||
this.selectZone(this.template.zones.length - 1)
|
||||
}
|
||||
|
||||
private createZoneFromRect(rect: DisplayRect): OcrTemplateZone {
|
||||
const imageSize = this.imageNaturalSize()
|
||||
return {
|
||||
name: `Zone ${this.template.zones.length + 1}`,
|
||||
target: OcrZoneTarget.CustomField,
|
||||
custom_field: null,
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.w,
|
||||
height: rect.h,
|
||||
page: this.previewPageDisplay,
|
||||
ocr_language: DEFAULT_OCR_ZONE_LANGUAGE,
|
||||
transform: OcrZoneTransform.Strip,
|
||||
date_format: '',
|
||||
validation_regex: '',
|
||||
order: this.template.zones.length,
|
||||
zone_source_width: imageSize.width,
|
||||
zone_source_height: imageSize.height,
|
||||
}
|
||||
}
|
||||
|
||||
onOverlayPointerCancel(event: PointerEvent) {
|
||||
if (this.overlayInteraction.kind === 'idle') return
|
||||
if (this.overlayInteraction.pointerId !== event.pointerId) return
|
||||
this.stopOverlayInteraction()
|
||||
}
|
||||
|
||||
private stopOverlayInteraction() {
|
||||
const interaction = this.overlayInteraction
|
||||
this.overlayInteraction = NO_OVERLAY_INTERACTION
|
||||
this.overlayCursor = 'crosshair'
|
||||
const overlay = this.overlayRef?.nativeElement
|
||||
if (
|
||||
interaction.kind !== 'idle' &&
|
||||
overlay?.hasPointerCapture(interaction.pointerId)
|
||||
) {
|
||||
overlay.releasePointerCapture(interaction.pointerId)
|
||||
}
|
||||
}
|
||||
|
||||
drawingRect(): DisplayRect | null {
|
||||
return this.overlayInteraction.kind === 'drawing'
|
||||
? this.displayRectFromDrawing(this.overlayInteraction.rect)
|
||||
: null
|
||||
}
|
||||
|
||||
/** The zone's rectangle on the overlay, or null if it's on another page. */
|
||||
zoneDisplayRect(zoneIndex: number): DisplayRect | null {
|
||||
const zone = this.template.zones[zoneIndex]
|
||||
if (!this.isOnCurrentPage(zone)) return null
|
||||
return getZoneDisplayRect(zone, this.imageNaturalSize())
|
||||
}
|
||||
|
||||
private handleAt(point: Point, zoneIndex: number): ResizeHandle | null {
|
||||
const rect = this.zoneDisplayRect(zoneIndex)
|
||||
return rect ? findHandleAt(point, rect, this.overlayHandleSize()) : null
|
||||
}
|
||||
|
||||
private zoneIndexAt(point: Point): number | null {
|
||||
return findZoneAt(
|
||||
point,
|
||||
this.template.zones,
|
||||
this.previewPage,
|
||||
this.previewPageCount(),
|
||||
this.imageNaturalSize()
|
||||
)
|
||||
}
|
||||
|
||||
overlayViewBox(): string {
|
||||
const imageSize = this.imageNaturalSize()
|
||||
return `0 0 ${imageSize.width} ${imageSize.height}`
|
||||
}
|
||||
|
||||
public readonly zoneColor = getZoneColor
|
||||
|
||||
zoneLabel(zone: OcrTemplateZone, index: number): string {
|
||||
return zone.name || `Zone ${index + 1}`
|
||||
}
|
||||
|
||||
zoneLabelY(rect: DisplayRect): number {
|
||||
return Math.max(this.overlayUnitSize(14), rect.y - this.overlayUnitSize(4))
|
||||
}
|
||||
|
||||
public readonly resizeHandles = getResizeHandles
|
||||
|
||||
overlayHandleSize(): number {
|
||||
return this.overlayUnitSize(HANDLE_SIZE)
|
||||
}
|
||||
|
||||
overlayFontSize(): number {
|
||||
return this.overlayUnitSize(12)
|
||||
}
|
||||
|
||||
overlayUnitSize(screenPixels: number): number {
|
||||
return screenPixels * this.overlayScale()
|
||||
}
|
||||
|
||||
private svgPointFromEvent(event: MouseEvent): Point | null {
|
||||
const svg = this.overlayRef?.nativeElement
|
||||
const matrix = svg?.getScreenCTM()
|
||||
if (!svg || !matrix) return null
|
||||
|
||||
const point = svg.createSVGPoint()
|
||||
point.x = event.clientX
|
||||
point.y = event.clientY
|
||||
|
||||
const svgPoint = point.matrixTransform(matrix.inverse())
|
||||
const size = this.imageNaturalSize()
|
||||
return {
|
||||
x: Math.max(0, Math.min(svgPoint.x, size.width)),
|
||||
y: Math.max(0, Math.min(svgPoint.y, size.height)),
|
||||
}
|
||||
}
|
||||
|
||||
private displayRectFromDrawing(rect: DrawingRect): DisplayRect {
|
||||
return {
|
||||
x: Math.min(rect.startX, rect.endX),
|
||||
y: Math.min(rect.startY, rect.endY),
|
||||
w: Math.abs(rect.endX - rect.startX),
|
||||
h: Math.abs(rect.endY - rect.startY),
|
||||
}
|
||||
}
|
||||
|
||||
private sourceRectFromDrawing(rect: DrawingRect): DisplayRect {
|
||||
const displayRect = this.displayRectFromDrawing(rect)
|
||||
return {
|
||||
x: Math.round(displayRect.x),
|
||||
y: Math.round(displayRect.y),
|
||||
w: Math.round(displayRect.w),
|
||||
h: Math.round(displayRect.h),
|
||||
}
|
||||
}
|
||||
|
||||
private imageNaturalSize() {
|
||||
const img = this.imageRef.nativeElement
|
||||
return { width: img.naturalWidth, height: img.naturalHeight }
|
||||
}
|
||||
|
||||
removeZone(index: number) {
|
||||
this.stopOverlayInteraction()
|
||||
this.clearZoneTest()
|
||||
this.template.zones.splice(index, 1)
|
||||
if (this.selectedZoneIndex === index) {
|
||||
this.selectedZoneIndex = null
|
||||
} else if (this.selectedZoneIndex > index) {
|
||||
this.selectedZoneIndex--
|
||||
}
|
||||
}
|
||||
|
||||
selectZone(index: number) {
|
||||
this.selectedZoneIndex = index
|
||||
this.activeTab = 'zones'
|
||||
this.clearZoneTest()
|
||||
const zone = this.template.zones[index]
|
||||
if (zone) {
|
||||
this.goToPage(this.zonePage(zone) - 1)
|
||||
}
|
||||
}
|
||||
|
||||
deselectZone() {
|
||||
this.selectedZoneIndex = null
|
||||
this.clearZoneTest()
|
||||
}
|
||||
|
||||
testZone() {
|
||||
const zone = this.selectedZone
|
||||
if (!zone || !this.previewDocId) return
|
||||
this.clearZoneTest()
|
||||
const test: ZoneTest = { zone, key: this.zoneTestKey(zone), result: null }
|
||||
this.zoneTest.set(test)
|
||||
this.templateService
|
||||
.testZone(this.previewDocId, this.zoneTestRequest(zone))
|
||||
.pipe(takeUntil(this.destroy$), takeUntil(this.cancelZoneTest$))
|
||||
.subscribe({
|
||||
next: (res) => this.zoneTest.set({ ...test, result: res }),
|
||||
error: (err) =>
|
||||
this.zoneTest.set({
|
||||
...test,
|
||||
result: { error: err.error?.error || $localize`Test failed` },
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
private clearZoneTest() {
|
||||
this.cancelZoneTest$.next()
|
||||
this.zoneTest.set(null)
|
||||
}
|
||||
|
||||
/** A result is stale once its zone, settings or sample document change. */
|
||||
private isCurrentZoneTest(): boolean {
|
||||
const test = this.zoneTest()
|
||||
return (
|
||||
test?.zone === this.selectedZone &&
|
||||
test.key === this.zoneTestKey(test.zone)
|
||||
)
|
||||
}
|
||||
|
||||
private zoneTestKey(zone: OcrTemplateZone): string {
|
||||
return JSON.stringify([this.previewDocId, this.zoneTestRequest(zone)])
|
||||
}
|
||||
|
||||
private zoneTestRequest(zone: OcrTemplateZone): ZoneTestRequest {
|
||||
return {
|
||||
name: zone.name,
|
||||
x: zone.x,
|
||||
y: zone.y,
|
||||
width: zone.width,
|
||||
height: zone.height,
|
||||
page: zone.page ?? 1,
|
||||
ocr_language: zone.ocr_language,
|
||||
transform: zone.transform,
|
||||
date_format: zone.date_format,
|
||||
validation_regex: zone.validation_regex,
|
||||
zone_source_width: zone.zone_source_width,
|
||||
zone_source_height: zone.zone_source_height,
|
||||
}
|
||||
}
|
||||
|
||||
save() {
|
||||
if (this.saving()) return
|
||||
this.stopOverlayInteraction()
|
||||
this.saving.set(true)
|
||||
this.pruneCombineFormats()
|
||||
this.template.sample_document = this.previewDocId
|
||||
const payload = this.permissionsService.currentUserOwnsObject(this.object)
|
||||
? { ...this.template, ...this.permissionsForm }
|
||||
: this.template
|
||||
const obs = this.template.id
|
||||
? this.templateService.update(payload)
|
||||
: this.templateService.create(payload)
|
||||
|
||||
obs.pipe(takeUntil(this.destroy$)).subscribe({
|
||||
next: (saved) => {
|
||||
this.activeModal.close()
|
||||
this.succeeded.emit(saved)
|
||||
},
|
||||
error: (e) => {
|
||||
this.saving.set(false)
|
||||
this.failed.emit(e)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ng-select needs a stable array between change detection runs, so the
|
||||
// split language list is cached per zone until ocr_language changes.
|
||||
private readonly ocrLanguageCache = new WeakMap<
|
||||
OcrTemplateZone,
|
||||
{ language: string; languages: string[] }
|
||||
>()
|
||||
|
||||
ocrLanguageArray(zone: OcrTemplateZone): string[] {
|
||||
const language = zone.ocr_language || ''
|
||||
const cached = this.ocrLanguageCache.get(zone)
|
||||
if (cached?.language === language) return cached.languages
|
||||
const languages = language.split('+').filter(Boolean)
|
||||
this.ocrLanguageCache.set(zone, { language, languages })
|
||||
return languages
|
||||
}
|
||||
|
||||
setOcrLanguages(zone: OcrTemplateZone, langs: string[]) {
|
||||
zone.ocr_language = (langs ?? []).join('+')
|
||||
}
|
||||
|
||||
/** The field select's value: a built-in target, or a custom field id. */
|
||||
zoneFieldValue(zone: OcrTemplateZone): ZoneFieldSelection {
|
||||
return zone.target === OcrZoneTarget.CustomField
|
||||
? zone.custom_field
|
||||
: zone.target
|
||||
}
|
||||
|
||||
setZoneField(zone: OcrTemplateZone, value: ZoneFieldSelection) {
|
||||
if (typeof value === 'string') {
|
||||
zone.target = value
|
||||
zone.custom_field = null
|
||||
} else {
|
||||
zone.target = OcrZoneTarget.CustomField
|
||||
zone.custom_field = value
|
||||
}
|
||||
}
|
||||
|
||||
zonesForField(zone: OcrTemplateZone): OcrTemplateZone[] {
|
||||
const key = getZoneFieldKey(zone)
|
||||
if (!key) return []
|
||||
return this.template.zones.filter((z) => getZoneFieldKey(z) === key)
|
||||
}
|
||||
|
||||
isFieldShared(zone: OcrTemplateZone): boolean {
|
||||
return this.zonesForField(zone).length > 1
|
||||
}
|
||||
|
||||
renameZone(zone: OcrTemplateZone, name: string) {
|
||||
const oldName = zone.name.trim()
|
||||
const format = this.getCombineFormat(zone)
|
||||
if (format) {
|
||||
this.setCombineFormat(
|
||||
zone,
|
||||
format.replace(/\{([^{}]*)\}/g, (token, tokenName: string) =>
|
||||
tokenName.trim() === oldName ? `{${name}}` : token
|
||||
)
|
||||
)
|
||||
}
|
||||
zone.name = name
|
||||
}
|
||||
|
||||
getCombineFormat(zone: OcrTemplateZone): string {
|
||||
return this.template.combine_formats?.[getZoneFieldKey(zone)] ?? ''
|
||||
}
|
||||
|
||||
setCombineFormat(zone: OcrTemplateZone, value: string) {
|
||||
const key = getZoneFieldKey(zone)
|
||||
if (!key) return
|
||||
this.template.combine_formats ??= {}
|
||||
this.template.combine_formats[key] = value
|
||||
}
|
||||
|
||||
defaultCombineFormat(zone: OcrTemplateZone): string {
|
||||
return this.zonesForField(zone)
|
||||
.map((z) => `{${z.name}}`)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
insertCombineToken(zone: OcrTemplateZone, tokenZone: OcrTemplateZone) {
|
||||
const current = this.getCombineFormat(zone)
|
||||
const sep = current && !current.endsWith(' ') ? ' ' : ''
|
||||
this.setCombineFormat(zone, `${current}${sep}{${tokenZone.name}}`)
|
||||
}
|
||||
|
||||
/** Drop formats that are empty or whose field no longer has several zones. */
|
||||
private pruneCombineFormats() {
|
||||
const formats = this.template.combine_formats
|
||||
if (!formats) return
|
||||
const keys = this.template.zones.map(getZoneFieldKey)
|
||||
for (const [key, format] of Object.entries(formats)) {
|
||||
if (!format || keys.filter((k) => k === key).length < 2) {
|
||||
delete formats[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createField(name?: string) {
|
||||
const zone = this.selectedZone
|
||||
const modal = this.modalService.open(CustomFieldEditDialogComponent)
|
||||
modal.componentInstance.dialogMode.set(EditDialogMode.CREATE)
|
||||
modal.componentInstance.object = { name: name || zone?.name }
|
||||
modal.componentInstance.succeeded
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((field: CustomField) => {
|
||||
this.toastService.showSuccess($localize`Saved field "${field.name}".`)
|
||||
this.customFieldsService.clearCache()
|
||||
this.customFieldsService
|
||||
.listAll()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((r) => {
|
||||
if (zone && OCR_SUPPORTED_FIELD_TYPES.includes(field.data_type)) {
|
||||
this.setZoneField(zone, field.id)
|
||||
}
|
||||
this.customFields.set(r.results)
|
||||
})
|
||||
})
|
||||
modal.componentInstance.failed
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((e) => {
|
||||
this.toastService.showError($localize`Error saving field.`, e)
|
||||
})
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.previewResizeObserver?.disconnect()
|
||||
this.cancelZoneTest$.complete()
|
||||
this.destroy$.next()
|
||||
this.destroy$.complete()
|
||||
}
|
||||
}
|
||||
-197
@@ -1,197 +0,0 @@
|
||||
import {
|
||||
OcrTemplateZone,
|
||||
OcrZoneTarget,
|
||||
OcrZoneTransform,
|
||||
} from 'src/app/data/ocr-template'
|
||||
import {
|
||||
findHandleAt,
|
||||
findZoneAt,
|
||||
getResizeHandles,
|
||||
getZoneDisplayRect,
|
||||
getZonePage,
|
||||
isZoneOnPage,
|
||||
moveZone,
|
||||
resizeZone,
|
||||
} from './zone-geometry'
|
||||
|
||||
function zone(overrides: Partial<OcrTemplateZone> = {}): OcrTemplateZone {
|
||||
return {
|
||||
name: 'Zone',
|
||||
target: OcrZoneTarget.CustomField,
|
||||
custom_field: 1,
|
||||
x: 100,
|
||||
y: 200,
|
||||
width: 300,
|
||||
height: 400,
|
||||
page: 1,
|
||||
ocr_language: 'eng',
|
||||
transform: OcrZoneTransform.Strip,
|
||||
validation_regex: '',
|
||||
order: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('OCR template editor geometry', () => {
|
||||
it('normalizes zone pages', () => {
|
||||
expect(getZonePage(zone({ page: 2 }), 0, 5)).toBe(2)
|
||||
expect(getZonePage(zone({ page: -1 }), 0, 5)).toBe(5)
|
||||
expect(getZonePage(zone({ page: -1 }), 2, null)).toBe(3)
|
||||
expect(getZonePage(zone({ page: 0 }), 0, 5)).toBe(1)
|
||||
expect(getZonePage(zone({ page: undefined }), 0, 5)).toBe(1)
|
||||
})
|
||||
|
||||
it('checks whether a zone is on the current preview page', () => {
|
||||
expect(isZoneOnPage(zone({ page: 2 }), 1, 5)).toBe(true)
|
||||
expect(isZoneOnPage(zone({ page: 2 }), 0, 5)).toBe(false)
|
||||
expect(isZoneOnPage(zone({ page: -1 }), 4, 5)).toBe(true)
|
||||
})
|
||||
|
||||
it('uses natural image coordinates when source dimensions are unset', () => {
|
||||
expect(
|
||||
getZoneDisplayRect(zone({ x: 100, y: 200, width: 300, height: 400 }), {
|
||||
width: 1000,
|
||||
height: 2000,
|
||||
})
|
||||
).toEqual({ x: 100, y: 200, w: 300, h: 400 })
|
||||
})
|
||||
|
||||
it('uses per-zone source dimensions when present', () => {
|
||||
expect(
|
||||
getZoneDisplayRect(
|
||||
zone({
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
zone_source_width: 1000,
|
||||
zone_source_height: 1000,
|
||||
}),
|
||||
{ width: 500, height: 750 }
|
||||
)
|
||||
).toEqual({ x: 50, y: 75, w: 50, h: 75 })
|
||||
})
|
||||
|
||||
it('finds zones from topmost to bottommost on the current page', () => {
|
||||
const zones = [
|
||||
zone({ name: 'first', x: 0, y: 0, width: 100, height: 100, page: 1 }),
|
||||
zone({ name: 'second', x: 0, y: 0, width: 50, height: 50, page: 1 }),
|
||||
zone({ name: 'third', x: 0, y: 0, width: 50, height: 50, page: 2 }),
|
||||
]
|
||||
|
||||
expect(
|
||||
findZoneAt({ x: 25, y: 25 }, zones, 0, 2, { width: 100, height: 100 })
|
||||
).toBe(1)
|
||||
})
|
||||
|
||||
it('finds resize handles around a display rect', () => {
|
||||
const rect = { x: 10, y: 20, w: 100, h: 200 }
|
||||
|
||||
expect(findHandleAt({ x: 10, y: 20 }, rect)).toBe('nw')
|
||||
expect(findHandleAt({ x: 110, y: 220 }, rect)).toBe('se')
|
||||
expect(findHandleAt({ x: 60, y: 20 }, rect)).toBe('n')
|
||||
expect(findHandleAt({ x: 90, y: 160 }, rect)).toBeNull()
|
||||
expect(findHandleAt({ x: 19, y: 20 }, rect, 8)).toBeNull()
|
||||
expect(findHandleAt({ x: 19, y: 20 }, rect, 10)).toBe('nw')
|
||||
})
|
||||
|
||||
it('returns no zone when the point is outside every rectangle', () => {
|
||||
expect(
|
||||
findZoneAt({ x: 900, y: 900 }, [zone()], 0, 1, {
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('resizes north-west edges while keeping the opposite corner fixed', () => {
|
||||
const z = zone({ x: 100, y: 200, width: 300, height: 400 })
|
||||
resizeZone(z, 'nw', { x: 50, y: 100 }, { width: 1000, height: 1000 })
|
||||
expect(z).toMatchObject({ x: 50, y: 100, width: 350, height: 500 })
|
||||
resizeZone(z, 'nw', { x: 900, y: 900 }, { width: 1000, height: 1000 })
|
||||
expect(z).toMatchObject({ x: 390, y: 590, width: 10, height: 10 })
|
||||
})
|
||||
|
||||
it('clamps resized north-west edges to the source image bounds', () => {
|
||||
const z = zone({
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
zone_source_width: 200,
|
||||
zone_source_height: 300,
|
||||
})
|
||||
resizeZone(z, 'nw', { x: -50, y: -50 }, { width: 400, height: 600 })
|
||||
expect(z).toMatchObject({ x: 0, y: 0, width: 150, height: 150 })
|
||||
})
|
||||
|
||||
it('uses the rendered handle positions for hit testing', () => {
|
||||
const rect = { x: 10, y: 20, w: 100, h: 200 }
|
||||
const handles = getResizeHandles(rect)
|
||||
|
||||
expect(handles).toEqual([
|
||||
{ handle: 'nw', x: 10, y: 20 },
|
||||
{ handle: 'n', x: 60, y: 20 },
|
||||
{ handle: 'ne', x: 110, y: 20 },
|
||||
{ handle: 'w', x: 10, y: 120 },
|
||||
{ handle: 'e', x: 110, y: 120 },
|
||||
{ handle: 'sw', x: 10, y: 220 },
|
||||
{ handle: 's', x: 60, y: 220 },
|
||||
{ handle: 'se', x: 110, y: 220 },
|
||||
])
|
||||
for (const marker of handles) {
|
||||
expect(findHandleAt(marker, rect)).toBe(marker.handle)
|
||||
}
|
||||
})
|
||||
|
||||
it('moves zones without leaving source image bounds', () => {
|
||||
const z = zone({ x: 50, y: 50, width: 100, height: 100 })
|
||||
|
||||
moveZone(
|
||||
z,
|
||||
{ x: 500, y: 500 },
|
||||
{ mouseX: 50, mouseY: 50, zoneX: 50, zoneY: 50 },
|
||||
{ width: 500, height: 500 }
|
||||
)
|
||||
|
||||
expect(z.x).toBe(400)
|
||||
expect(z.y).toBe(400)
|
||||
})
|
||||
|
||||
it('resizes zones without leaving source image bounds', () => {
|
||||
const z = zone({
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
zone_source_width: 200,
|
||||
zone_source_height: 200,
|
||||
})
|
||||
|
||||
resizeZone(z, 'se', { x: 500, y: 500 }, { width: 500, height: 500 })
|
||||
|
||||
expect(z.width).toBe(150)
|
||||
expect(z.height).toBe(150)
|
||||
})
|
||||
|
||||
it('moves zones in their original source coordinates on a different-size preview', () => {
|
||||
const z = zone({
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
zone_source_width: 1000,
|
||||
zone_source_height: 2000,
|
||||
})
|
||||
|
||||
moveZone(
|
||||
z,
|
||||
{ x: 75, y: 75 },
|
||||
{ mouseX: 25, mouseY: 25, zoneX: 50, zoneY: 50 },
|
||||
{ width: 500, height: 500 }
|
||||
)
|
||||
|
||||
expect(z.x).toBe(150)
|
||||
expect(z.y).toBe(250)
|
||||
})
|
||||
})
|
||||
@@ -1,204 +0,0 @@
|
||||
import { OcrTemplateZone } from 'src/app/data/ocr-template'
|
||||
|
||||
export interface DrawingRect {
|
||||
startX: number
|
||||
startY: number
|
||||
endX: number
|
||||
endY: number
|
||||
}
|
||||
|
||||
export interface Dimensions {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface Point {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface DisplayRect {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
export interface MoveStart {
|
||||
mouseX: number
|
||||
mouseY: number
|
||||
zoneX: number
|
||||
zoneY: number
|
||||
}
|
||||
|
||||
export type ResizeHandle = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
|
||||
|
||||
export interface ResizeHandleMarker extends Point {
|
||||
handle: ResizeHandle
|
||||
}
|
||||
|
||||
export const HANDLE_SIZE = 8
|
||||
export const MIN_ZONE_SIZE = 10
|
||||
|
||||
const ZONE_COLORS = [
|
||||
'#4f8ff7',
|
||||
'#ff6b6b',
|
||||
'#51cf66',
|
||||
'#ffd43b',
|
||||
'#cc5de8',
|
||||
'#ff922b',
|
||||
'#20c997',
|
||||
'#e599f7',
|
||||
]
|
||||
|
||||
/** Outline color for a zone, shared by the preview overlay and the zone list. */
|
||||
export function getZoneColor(index: number): string {
|
||||
return ZONE_COLORS[index % ZONE_COLORS.length]
|
||||
}
|
||||
|
||||
export function getZonePage(
|
||||
zone: OcrTemplateZone,
|
||||
previewPage: number,
|
||||
previewPageCount: number | null
|
||||
): number {
|
||||
const page = zone.page ?? 1
|
||||
if (page === -1) return previewPageCount ?? previewPage + 1
|
||||
return Math.max(page, 1)
|
||||
}
|
||||
|
||||
export function isZoneOnPage(
|
||||
zone: OcrTemplateZone,
|
||||
previewPage: number,
|
||||
previewPageCount: number | null
|
||||
): boolean {
|
||||
return getZonePage(zone, previewPage, previewPageCount) === previewPage + 1
|
||||
}
|
||||
|
||||
function getZoneSourceSize(
|
||||
zone: OcrTemplateZone,
|
||||
imageSize: Dimensions
|
||||
): Dimensions {
|
||||
return {
|
||||
width: zone.zone_source_width || imageSize.width,
|
||||
height: zone.zone_source_height || imageSize.height,
|
||||
}
|
||||
}
|
||||
|
||||
export function getZoneDisplayRect(
|
||||
zone: OcrTemplateZone,
|
||||
imageSize: Dimensions
|
||||
): DisplayRect {
|
||||
const sourceSize = getZoneSourceSize(zone, imageSize)
|
||||
// The SVG viewBox uses the preview image's natural dimensions.
|
||||
const scaleX = imageSize.width / sourceSize.width
|
||||
const scaleY = imageSize.height / sourceSize.height
|
||||
|
||||
return {
|
||||
x: zone.x * scaleX,
|
||||
y: zone.y * scaleY,
|
||||
w: zone.width * scaleX,
|
||||
h: zone.height * scaleY,
|
||||
}
|
||||
}
|
||||
|
||||
export function getResizeHandles(rect: DisplayRect): ResizeHandleMarker[] {
|
||||
return [
|
||||
{ handle: 'nw', x: rect.x, y: rect.y },
|
||||
{ handle: 'n', x: rect.x + rect.w / 2, y: rect.y },
|
||||
{ handle: 'ne', x: rect.x + rect.w, y: rect.y },
|
||||
{ handle: 'w', x: rect.x, y: rect.y + rect.h / 2 },
|
||||
{ handle: 'e', x: rect.x + rect.w, y: rect.y + rect.h / 2 },
|
||||
{ handle: 'sw', x: rect.x, y: rect.y + rect.h },
|
||||
{ handle: 's', x: rect.x + rect.w / 2, y: rect.y + rect.h },
|
||||
{ handle: 'se', x: rect.x + rect.w, y: rect.y + rect.h },
|
||||
]
|
||||
}
|
||||
|
||||
export function findHandleAt(
|
||||
point: Point,
|
||||
rect: DisplayRect,
|
||||
handleSize = HANDLE_SIZE
|
||||
): ResizeHandle | null {
|
||||
return (
|
||||
getResizeHandles(rect).find(
|
||||
({ x, y }) =>
|
||||
Math.abs(point.x - x) <= handleSize &&
|
||||
Math.abs(point.y - y) <= handleSize
|
||||
)?.handle ?? null
|
||||
)
|
||||
}
|
||||
|
||||
export function findZoneAt(
|
||||
point: Point,
|
||||
zones: OcrTemplateZone[],
|
||||
previewPage: number,
|
||||
previewPageCount: number | null,
|
||||
imageSize: Dimensions
|
||||
): number | null {
|
||||
for (let i = zones.length - 1; i >= 0; i--) {
|
||||
const zone = zones[i]
|
||||
if (!isZoneOnPage(zone, previewPage, previewPageCount)) continue
|
||||
const rect = getZoneDisplayRect(zone, imageSize)
|
||||
|
||||
if (
|
||||
point.x >= rect.x &&
|
||||
point.x <= rect.x + rect.w &&
|
||||
point.y >= rect.y &&
|
||||
point.y <= rect.y + rect.h
|
||||
) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function moveZone(
|
||||
zone: OcrTemplateZone,
|
||||
point: Point,
|
||||
moveStart: MoveStart,
|
||||
imageSize: Dimensions
|
||||
) {
|
||||
const sourceSize = getZoneSourceSize(zone, imageSize)
|
||||
const scaleX = sourceSize.width / imageSize.width
|
||||
const scaleY = sourceSize.height / imageSize.height
|
||||
const dx = Math.round((point.x - moveStart.mouseX) * scaleX)
|
||||
const dy = Math.round((point.y - moveStart.mouseY) * scaleY)
|
||||
|
||||
zone.x = clamp(moveStart.zoneX + dx, 0, sourceSize.width - zone.width)
|
||||
zone.y = clamp(moveStart.zoneY + dy, 0, sourceSize.height - zone.height)
|
||||
}
|
||||
|
||||
export function resizeZone(
|
||||
zone: OcrTemplateZone,
|
||||
handle: ResizeHandle,
|
||||
point: Point,
|
||||
imageSize: Dimensions
|
||||
) {
|
||||
const sourceSize = getZoneSourceSize(zone, imageSize)
|
||||
const scaleX = sourceSize.width / imageSize.width
|
||||
const scaleY = sourceSize.height / imageSize.height
|
||||
const imageX = clamp(Math.round(point.x * scaleX), 0, sourceSize.width)
|
||||
const imageY = clamp(Math.round(point.y * scaleY), 0, sourceSize.height)
|
||||
|
||||
if (handle.includes('w')) {
|
||||
const right = Math.min(zone.x + zone.width, sourceSize.width)
|
||||
zone.x = clamp(imageX, 0, right - MIN_ZONE_SIZE)
|
||||
zone.width = right - zone.x
|
||||
}
|
||||
if (handle.includes('e')) {
|
||||
zone.width = Math.max(MIN_ZONE_SIZE, imageX - zone.x)
|
||||
}
|
||||
if (handle.includes('n')) {
|
||||
const bottom = Math.min(zone.y + zone.height, sourceSize.height)
|
||||
zone.y = clamp(imageY, 0, bottom - MIN_ZONE_SIZE)
|
||||
zone.height = bottom - zone.y
|
||||
}
|
||||
if (handle.includes('s')) {
|
||||
zone.height = Math.max(MIN_ZONE_SIZE, imageY - zone.y)
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(value, max))
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
<pngx-page-header
|
||||
title="OCR Templates"
|
||||
i18n-title
|
||||
info="Define extraction zones on document types to automatically populate custom fields via OCR."
|
||||
i18n-info
|
||||
>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="editTemplate()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.OcrTemplate }">
|
||||
<i-bs name="plus-circle" class="me-1"></i-bs><ng-container i18n>Add Template</ng-container>
|
||||
</button>
|
||||
</pngx-page-header>
|
||||
|
||||
<ul class="list-group">
|
||||
|
||||
<li class="list-group-item">
|
||||
<div class="row">
|
||||
<div class="col" i18n>Name</div>
|
||||
<div class="col d-none d-sm-flex" i18n>Matching</div>
|
||||
<div class="col d-none d-sm-flex" i18n>Zones</div>
|
||||
<div class="col" i18n>Status</div>
|
||||
<div class="col" i18n>Actions</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@if (loading() && templates().length === 0) {
|
||||
<li class="list-group-item">
|
||||
<output class="spinner-border spinner-border-sm me-2"></output>
|
||||
<ng-container i18n>Loading...</ng-container>
|
||||
</li>
|
||||
}
|
||||
|
||||
@for (template of templates(); track template.id) {
|
||||
<li class="list-group-item">
|
||||
<div class="row fade" [class.show]="show()">
|
||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editTemplate(template)" [disabled]="!userCanEdit(template)">{{template.name}}</button></div>
|
||||
<div class="col d-flex align-items-center d-none d-sm-flex">{{getMatching(template)}}</div>
|
||||
<div class="col d-flex align-items-center d-none d-sm-flex"><code>{{template.zones.length}}</code></div>
|
||||
<div class="col d-flex align-items-center">
|
||||
<div class="form-check form-switch mb-0">
|
||||
<input type="checkbox" class="form-check-input cursor-pointer" [id]="template.id+'_enable'" [(ngModel)]="template.enabled" (change)="toggleTemplateEnabled(template)" [disabled]="!userCanEdit(template)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.OcrTemplate }">
|
||||
<label class="form-check-label cursor-pointer" [for]="template.id+'_enable'">
|
||||
<code> @if(template.enabled) { <ng-container i18n>Enabled</ng-container> } @else { <span i18n class="text-muted">Disabled</span> }</code>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
|
||||
<div class="btn-group d-block d-sm-none">
|
||||
<div ngbDropdown container="body" class="d-inline-block">
|
||||
<button type="button" class="btn btn-link" id="actionsMenuMobile{{template.id}}" (click)="$event.stopPropagation()" ngbDropdownToggle>
|
||||
<i-bs name="three-dots-vertical"></i-bs>
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile{{template.id}}">
|
||||
<button (click)="editTemplate(template)" [disabled]="!userCanEdit(template)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.OcrTemplate }" ngbDropdownItem i18n>Edit</button>
|
||||
<button (click)="deleteTemplate(template)" [disabled]="!permissionsService.currentUserOwnsObject(template)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.OcrTemplate }" ngbDropdownItem i18n>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-toolbar d-none d-sm-flex gap-2" role="toolbar">
|
||||
<div class="btn-group">
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.OcrTemplate }" [disabled]="!userCanEdit(template)" class="btn btn-sm btn-outline-secondary" type="button" (click)="editTemplate(template)">
|
||||
<i-bs width="1em" height="1em" name="pencil" class="me-1"></i-bs><ng-container i18n>Edit</ng-container>
|
||||
</button>
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.OcrTemplate }" [disabled]="!permissionsService.currentUserOwnsObject(template)" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteTemplate(template)">
|
||||
<i-bs width="1em" height="1em" name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
@if (!loading() && templates().length === 0) {
|
||||
<li class="list-group-item" [class.show]="show()" i18n>No OCR templates defined.</li>
|
||||
}
|
||||
</ul>
|
||||
@@ -1,300 +0,0 @@
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { MATCH_LITERAL, MATCH_NONE } from 'src/app/data/matching-model'
|
||||
import { OcrTemplate } from 'src/app/data/ocr-template'
|
||||
import { User } from 'src/app/data/user'
|
||||
import { DirtyFormGuard } from 'src/app/guards/dirty-form.guard'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { OcrTemplateEditorComponent } from './ocr-template-editor/ocr-template-editor.component'
|
||||
import { OcrTemplatesComponent } from './ocr-templates.component'
|
||||
|
||||
const templates: OcrTemplate[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Invoices',
|
||||
match: 'Northwind',
|
||||
matching_algorithm: MATCH_LITERAL,
|
||||
sample_document: null,
|
||||
source_width: 0,
|
||||
source_height: 0,
|
||||
enabled: true,
|
||||
zones: [],
|
||||
},
|
||||
]
|
||||
|
||||
describe('OcrTemplatesComponent', () => {
|
||||
let component: OcrTemplatesComponent
|
||||
let fixture: ComponentFixture<OcrTemplatesComponent>
|
||||
let ocrTemplateService: OcrTemplateService
|
||||
let modalService: NgbModal
|
||||
let toastService: ToastService
|
||||
let modal: NgbModalRef
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [NgxBootstrapIconsModule.pick(allIcons), OcrTemplatesComponent],
|
||||
providers: [
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
})
|
||||
|
||||
TestBed.inject(PermissionsService).initialize(
|
||||
[
|
||||
'view_ocrtemplate',
|
||||
'add_ocrtemplate',
|
||||
'change_ocrtemplate',
|
||||
'delete_ocrtemplate',
|
||||
],
|
||||
{ id: 1, groups: [], is_superuser: false } as User
|
||||
)
|
||||
jest
|
||||
.spyOn(TestBed.inject(UserService), 'listAll')
|
||||
.mockReturnValue(of({ count: 0, all: [], results: [] }))
|
||||
|
||||
ocrTemplateService = TestBed.inject(OcrTemplateService)
|
||||
jest
|
||||
.spyOn(ocrTemplateService, 'listAll')
|
||||
.mockReturnValue(of({ count: 1, all: [1], results: templates }))
|
||||
modalService = TestBed.inject(NgbModal)
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
toastService = TestBed.inject(ToastService)
|
||||
fixture = TestBed.createComponent(OcrTemplatesComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
afterEach(() => modalService.dismissAll())
|
||||
|
||||
it('should show templates with their matching', () => {
|
||||
const text = fixture.nativeElement.textContent
|
||||
expect(text).toContain('Invoices')
|
||||
expect(text).toContain('Exact match: Northwind')
|
||||
|
||||
expect(
|
||||
component.getMatching({ ...templates[0], matching_algorithm: MATCH_NONE })
|
||||
).toEqual('None')
|
||||
expect(component.getMatching({ ...templates[0], match: '' })).toEqual('-')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'owned',
|
||||
owner: 1,
|
||||
user_can_change: true,
|
||||
editable: true,
|
||||
deletable: true,
|
||||
},
|
||||
{
|
||||
name: 'unowned',
|
||||
owner: null,
|
||||
user_can_change: true,
|
||||
editable: true,
|
||||
deletable: true,
|
||||
},
|
||||
{
|
||||
name: 'view-only shared',
|
||||
owner: 2,
|
||||
user_can_change: false,
|
||||
editable: false,
|
||||
deletable: false,
|
||||
},
|
||||
{
|
||||
name: 'editable shared',
|
||||
owner: 2,
|
||||
user_can_change: true,
|
||||
editable: true,
|
||||
deletable: false,
|
||||
},
|
||||
])(
|
||||
'should respect permissions for $name templates',
|
||||
async ({ owner, user_can_change, editable, deletable }) => {
|
||||
const template = { ...templates[0], owner, user_can_change }
|
||||
component.templates.set([template])
|
||||
fixture.detectChanges()
|
||||
await fixture.whenStable()
|
||||
|
||||
expect(fixture.nativeElement.querySelector('.text-start').disabled).toBe(
|
||||
!editable
|
||||
)
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.form-switch input').disabled
|
||||
).toBe(!editable)
|
||||
const buttons = Array.from<HTMLButtonElement>(
|
||||
fixture.nativeElement.querySelectorAll('button')
|
||||
)
|
||||
for (const button of buttons.filter(
|
||||
(button) => button.textContent.trim() === 'Edit'
|
||||
)) {
|
||||
expect(button.disabled).toBe(!editable)
|
||||
}
|
||||
for (const button of buttons.filter(
|
||||
(button) => button.textContent.trim() === 'Delete'
|
||||
)) {
|
||||
expect(button.disabled).toBe(!deletable)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('should allow a superuser to manage another users template', () => {
|
||||
TestBed.inject(PermissionsService).initialize([], {
|
||||
id: 1,
|
||||
is_superuser: true,
|
||||
} as User)
|
||||
component.templates.set([
|
||||
{ ...templates[0], owner: 2, user_can_change: false },
|
||||
])
|
||||
fixture.detectChanges()
|
||||
expect(fixture.nativeElement.querySelector('.text-start').disabled).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.btn-outline-danger').disabled
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('should not open mutation dialogs or patch a view-only template', () => {
|
||||
const template = { ...templates[0], owner: 2, user_can_change: false }
|
||||
const open = jest.spyOn(modalService, 'open')
|
||||
const patch = jest.spyOn(ocrTemplateService, 'patch')
|
||||
component.editTemplate(template)
|
||||
component.toggleTemplateEnabled(template)
|
||||
component.deleteTemplate(template)
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
expect(patch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should require global edit permission even for an owned template', () => {
|
||||
TestBed.inject(PermissionsService).initialize(['view_ocrtemplate'], {
|
||||
id: 1,
|
||||
} as User)
|
||||
fixture.destroy()
|
||||
fixture = TestBed.createComponent(OcrTemplatesComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
expect(fixture.nativeElement.querySelector('.text-start').disabled).toBe(
|
||||
true
|
||||
)
|
||||
expect(fixture.nativeElement.querySelector('.form-switch input')).toBeNull()
|
||||
})
|
||||
|
||||
it('should support create, show notification on error / success', () => {
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
|
||||
const reloadSpy = jest.spyOn(component, 'reload')
|
||||
|
||||
fixture.debugElement
|
||||
.queryAll(By.css('pngx-page-header button'))
|
||||
.find((button) => button.nativeElement.textContent.includes('Add'))
|
||||
.triggerEventHandler('click')
|
||||
|
||||
const editor = modal.componentInstance as OcrTemplateEditorComponent
|
||||
expect(editor).toBeInstanceOf(OcrTemplateEditorComponent)
|
||||
expect(editor.object).toBeUndefined()
|
||||
|
||||
editor.failed.emit({ error: 'error creating template' })
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).not.toHaveBeenCalled()
|
||||
|
||||
editor.succeeded.emit(templates[0])
|
||||
expect(toastSuccessSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should pass the template to the editor', () => {
|
||||
component.editTemplate(templates[0])
|
||||
expect(modal.componentInstance.object).toBe(templates[0])
|
||||
})
|
||||
|
||||
it('should confirm closing the editor with unsaved changes', async () => {
|
||||
const confirmSpy = jest
|
||||
.spyOn(TestBed.inject(DirtyFormGuard), 'confirmChanges')
|
||||
.mockReturnValue(of(false))
|
||||
component.editTemplate()
|
||||
const editor = modal.componentInstance as OcrTemplateEditorComponent
|
||||
editor.ngOnInit()
|
||||
|
||||
modal.dismiss()
|
||||
await fixture.whenStable()
|
||||
expect(confirmSpy).not.toHaveBeenCalled()
|
||||
|
||||
component.editTemplate()
|
||||
const dirtyEditor = modal.componentInstance as OcrTemplateEditorComponent
|
||||
dirtyEditor.ngOnInit()
|
||||
dirtyEditor.template.name = 'Changed'
|
||||
modal.dismiss()
|
||||
await fixture.whenStable()
|
||||
expect(confirmSpy).toHaveBeenCalled()
|
||||
expect(modalService.hasOpenModals()).toBe(true)
|
||||
|
||||
dirtyEditor.saving.set(true)
|
||||
confirmSpy.mockClear()
|
||||
modal.dismiss()
|
||||
await fixture.whenStable()
|
||||
expect(confirmSpy).not.toHaveBeenCalled()
|
||||
expect(modalService.hasOpenModals()).toBe(true)
|
||||
})
|
||||
|
||||
it('should only send the enabled flag when toggling', () => {
|
||||
const patchSpy = jest
|
||||
.spyOn(ocrTemplateService, 'patch')
|
||||
.mockReturnValueOnce(throwError(() => new Error('error toggling')))
|
||||
.mockReturnValueOnce(of(templates[0]))
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
|
||||
const reloadSpy = jest.spyOn(component, 'reload')
|
||||
|
||||
const toggle = fixture.debugElement.query(By.css('.form-switch input'))
|
||||
toggle.nativeElement.click()
|
||||
expect(patchSpy).toHaveBeenCalledWith({ id: 1, enabled: false })
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
component.toggleTemplateEnabled(templates[0])
|
||||
expect(toastSuccessSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should support delete, show notification on error / success', () => {
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
|
||||
const deleteSpy = jest.spyOn(ocrTemplateService, 'delete')
|
||||
const reloadSpy = jest.spyOn(component, 'reload')
|
||||
|
||||
component.deleteTemplate(templates[0])
|
||||
const dialog = modal.componentInstance as ConfirmDialogComponent
|
||||
|
||||
deleteSpy.mockReturnValueOnce(throwError(() => new Error('error deleting')))
|
||||
dialog.confirmClicked.emit()
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(dialog.buttonsEnabled()).toBe(true)
|
||||
expect(reloadSpy).not.toHaveBeenCalled()
|
||||
|
||||
deleteSpy.mockReturnValueOnce(of(true))
|
||||
dialog.confirmClicked.emit()
|
||||
expect(toastSuccessSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([true, false])(
|
||||
'reports the enabled state %s after the update succeeds',
|
||||
(enabled) => {
|
||||
const template = { ...templates[0], enabled }
|
||||
jest.spyOn(ocrTemplateService, 'patch').mockReturnValue(of(template))
|
||||
const toast = jest.spyOn(toastService, 'showSuccess')
|
||||
component.toggleTemplateEnabled(template)
|
||||
expect(toast).toHaveBeenCalledWith(
|
||||
`${enabled ? 'Enabled' : 'Disabled'} OCR template "${template.name}"`
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,185 +0,0 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { firstValueFrom, takeUntil, tap } from 'rxjs'
|
||||
import { MATCH_NONE, MATCHING_ALGORITHMS } from 'src/app/data/matching-model'
|
||||
import { OcrTemplate } from 'src/app/data/ocr-template'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { DirtyFormGuard } from 'src/app/guards/dirty-form.guard'
|
||||
import {
|
||||
PermissionAction,
|
||||
PermissionType,
|
||||
PermissionsService,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||
import { OcrTemplateEditorComponent } from './ocr-template-editor/ocr-template-editor.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-ocr-templates',
|
||||
templateUrl: './ocr-templates.component.html',
|
||||
imports: [
|
||||
PageHeaderComponent,
|
||||
IfPermissionsDirective,
|
||||
FormsModule,
|
||||
NgbDropdownModule,
|
||||
NgxBootstrapIconsModule,
|
||||
],
|
||||
})
|
||||
export class OcrTemplatesComponent
|
||||
extends LoadingComponentWithPermissions
|
||||
implements OnInit
|
||||
{
|
||||
private readonly ocrTemplateService = inject(OcrTemplateService)
|
||||
public readonly permissionsService = inject(PermissionsService)
|
||||
private readonly modalService = inject(NgbModal)
|
||||
private readonly dirtyFormGuard = inject(DirtyFormGuard)
|
||||
private readonly toastService = inject(ToastService)
|
||||
|
||||
public readonly templates = signal<OcrTemplate[]>([])
|
||||
|
||||
ngOnInit() {
|
||||
this.reload()
|
||||
}
|
||||
|
||||
reload() {
|
||||
this.loading.set(true)
|
||||
this.ocrTemplateService
|
||||
.listAll()
|
||||
.pipe(
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
tap((r) => this.templates.set(r.results))
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.show.set(true)
|
||||
this.loading.set(false)
|
||||
})
|
||||
}
|
||||
|
||||
getMatching(template: OcrTemplate): string {
|
||||
if (template.matching_algorithm === MATCH_NONE) {
|
||||
return $localize`None`
|
||||
}
|
||||
if (!template.match) {
|
||||
return '-'
|
||||
}
|
||||
const algorithm = MATCHING_ALGORITHMS.find(
|
||||
(a) => a.id === template.matching_algorithm
|
||||
)
|
||||
return `${algorithm?.shortName}: ${template.match}`
|
||||
}
|
||||
|
||||
editTemplate(template?: OcrTemplate) {
|
||||
if (template && !this.userCanEdit(template)) return
|
||||
const modal = this.modalService.open(OcrTemplateEditorComponent, {
|
||||
backdrop: 'static',
|
||||
keyboard: false,
|
||||
size: 'xxl',
|
||||
fullscreen: 'lg',
|
||||
scrollable: true,
|
||||
ariaLabelledBy: 'ocr-template-editor-title',
|
||||
beforeDismiss: () => {
|
||||
const editor: OcrTemplateEditorComponent = modal.componentInstance
|
||||
if (editor.saving()) return false
|
||||
if (!editor.isDirty) return true
|
||||
return firstValueFrom(
|
||||
this.dirtyFormGuard.confirmChanges(undefined, $localize`Close`)
|
||||
)
|
||||
},
|
||||
})
|
||||
modal.componentInstance.object = template
|
||||
modal.componentInstance.succeeded
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((saved: OcrTemplate) => {
|
||||
this.toastService.showSuccess(
|
||||
$localize`Saved OCR template "${saved.name}".`
|
||||
)
|
||||
this.ocrTemplateService.clearCache()
|
||||
this.reload()
|
||||
})
|
||||
modal.componentInstance.failed
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((e) => {
|
||||
this.toastService.showError($localize`Error saving OCR template.`, e)
|
||||
})
|
||||
}
|
||||
|
||||
toggleTemplateEnabled(template: OcrTemplate) {
|
||||
if (!this.userCanEdit(template)) return
|
||||
this.ocrTemplateService
|
||||
.patch({ id: template.id, enabled: template.enabled } as OcrTemplate)
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastService.showSuccess(
|
||||
template.enabled
|
||||
? $localize`Enabled OCR template "${template.name}"`
|
||||
: $localize`Disabled OCR template "${template.name}"`
|
||||
)
|
||||
this.reload()
|
||||
},
|
||||
error: (e) => {
|
||||
this.toastService.showError(
|
||||
$localize`Error toggling OCR template "${template.name}".`,
|
||||
e
|
||||
)
|
||||
this.reload()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
deleteTemplate(template: OcrTemplate) {
|
||||
if (
|
||||
!this.permissionsService.currentUserCan(
|
||||
PermissionAction.Delete,
|
||||
PermissionType.OcrTemplate
|
||||
) ||
|
||||
!this.permissionsService.currentUserOwnsObject(template)
|
||||
)
|
||||
return
|
||||
const modal = this.modalService.open(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.title = $localize`Confirm delete OCR template`
|
||||
modal.componentInstance.messageBold = $localize`This operation will permanently delete this OCR template.`
|
||||
modal.componentInstance.message = $localize`This operation cannot be undone.`
|
||||
modal.componentInstance.btnClass = 'btn-danger'
|
||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
this.ocrTemplateService.delete(template).subscribe({
|
||||
next: () => {
|
||||
modal.close()
|
||||
this.toastService.showSuccess(
|
||||
$localize`Deleted OCR template "${template.name}".`
|
||||
)
|
||||
this.ocrTemplateService.clearCache()
|
||||
this.reload()
|
||||
},
|
||||
error: (e) => {
|
||||
modal.componentInstance.buttonsEnabled.set(true)
|
||||
this.toastService.showError(
|
||||
$localize`Error deleting OCR template "${template.name}".`,
|
||||
e
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
userCanEdit(template: OcrTemplate): boolean {
|
||||
return (
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.Change,
|
||||
PermissionType.OcrTemplate
|
||||
) &&
|
||||
this.permissionsService.currentUserHasObjectPermissions(
|
||||
PermissionAction.Change,
|
||||
template
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { CustomFieldDataType } from './custom-field'
|
||||
import { MatchingModel } from './matching-model'
|
||||
import { ObjectWithPermissions } from './object-with-permissions'
|
||||
|
||||
export enum OcrZoneTarget {
|
||||
CustomField = 'custom_field',
|
||||
Title = 'title',
|
||||
Asn = 'asn',
|
||||
Created = 'created',
|
||||
}
|
||||
|
||||
export enum OcrZoneTransform {
|
||||
None = 'none',
|
||||
Strip = 'strip',
|
||||
Uppercase = 'uppercase',
|
||||
Lowercase = 'lowercase',
|
||||
Numeric = 'numeric',
|
||||
StripPunctuation = 'strip_punctuation',
|
||||
Date = 'date',
|
||||
QrCode = 'qr_code',
|
||||
}
|
||||
|
||||
export const DEFAULT_OCR_ZONE_LANGUAGE = 'eng'
|
||||
|
||||
/** The field a zone writes to, keyed by OcrTemplate.combine_formats. */
|
||||
export function getZoneFieldKey(zone: OcrTemplateZone): string | null {
|
||||
if (zone.target !== OcrZoneTarget.CustomField) return zone.target
|
||||
return zone.custom_field === null ? null : String(zone.custom_field)
|
||||
}
|
||||
|
||||
/** Custom field types zone OCR can write to. See models.py OCR_SUPPORTED_FIELD_TYPES. */
|
||||
export const OCR_SUPPORTED_FIELD_TYPES: CustomFieldDataType[] = [
|
||||
CustomFieldDataType.String,
|
||||
CustomFieldDataType.Url,
|
||||
CustomFieldDataType.Date,
|
||||
CustomFieldDataType.Integer,
|
||||
CustomFieldDataType.Float,
|
||||
CustomFieldDataType.Monetary,
|
||||
CustomFieldDataType.LongText,
|
||||
CustomFieldDataType.Boolean,
|
||||
]
|
||||
|
||||
export const OCR_BUILTIN_TARGETS = [
|
||||
{ id: OcrZoneTarget.Title, name: $localize`Title` },
|
||||
{ id: OcrZoneTarget.Asn, name: $localize`Archive serial number` },
|
||||
{ id: OcrZoneTarget.Created, name: $localize`Date created` },
|
||||
]
|
||||
|
||||
export interface OcrTemplateZone {
|
||||
id?: number
|
||||
name: string
|
||||
target: OcrZoneTarget
|
||||
custom_field: number | null
|
||||
page?: number
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
ocr_language: string
|
||||
transform: OcrZoneTransform
|
||||
date_format?: string
|
||||
validation_regex: string
|
||||
order: number
|
||||
zone_source_width?: number
|
||||
zone_source_height?: number
|
||||
}
|
||||
|
||||
export interface OcrTemplate extends MatchingModel, ObjectWithPermissions {
|
||||
name: string
|
||||
sample_document: number | null
|
||||
source_width: number
|
||||
source_height: number
|
||||
enabled: boolean
|
||||
combine_formats?: Record<string, string>
|
||||
created?: string
|
||||
updated?: string
|
||||
zones: OcrTemplateZone[]
|
||||
}
|
||||
|
||||
export interface ZoneTestRequest {
|
||||
name: string
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
page: number
|
||||
ocr_language: string
|
||||
transform: OcrZoneTransform
|
||||
date_format?: string
|
||||
validation_regex: string
|
||||
zone_source_width?: number
|
||||
zone_source_height?: number
|
||||
}
|
||||
|
||||
export interface OcrZoneTestResult {
|
||||
raw_text?: string | null
|
||||
value?: string | null
|
||||
regex?: string
|
||||
regex_match?: boolean | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface OcrZoneRunResult {
|
||||
template: string
|
||||
zone: string
|
||||
custom_field: string
|
||||
value: string | number | boolean | null
|
||||
reason?: string | null
|
||||
}
|
||||
|
||||
export interface OcrZoneRunResponse {
|
||||
status: 'completed' | 'skipped'
|
||||
reason?: 'no_template'
|
||||
results: OcrZoneRunResult[]
|
||||
}
|
||||
@@ -35,7 +35,6 @@ export enum HideableSidebarItemID {
|
||||
SavedViews = 'saved_views',
|
||||
ShareLinks = 'share_links',
|
||||
Workflows = 'workflows',
|
||||
OcrTemplates = 'ocr_templates',
|
||||
Mail = 'mail',
|
||||
Documentation = 'documentation',
|
||||
}
|
||||
|
||||
@@ -3,16 +3,12 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { DirtyCheckGuard } from '@ngneat/dirty-check-forms'
|
||||
import { Observable, Subject } from 'rxjs'
|
||||
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
|
||||
import { ActivatedRouteSnapshot } from '@angular/router'
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DirtyFormGuard extends DirtyCheckGuard {
|
||||
private modalService = inject(NgbModal)
|
||||
|
||||
confirmChanges(
|
||||
_currentRoute?: ActivatedRouteSnapshot,
|
||||
leaveLabel: string = $localize`Leave page`
|
||||
): Observable<boolean> {
|
||||
confirmChanges(): Observable<boolean> {
|
||||
let modal = this.modalService.open(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
@@ -20,7 +16,7 @@ export class DirtyFormGuard extends DirtyCheckGuard {
|
||||
modal.componentInstance.messageBold = $localize`You have unsaved changes.`
|
||||
modal.componentInstance.message = $localize`Are you sure you want to leave?`
|
||||
modal.componentInstance.btnClass = 'btn-warning'
|
||||
modal.componentInstance.btnCaption = leaveLabel
|
||||
modal.componentInstance.btnCaption = $localize`Leave page`
|
||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
modal.close()
|
||||
|
||||
@@ -283,10 +283,6 @@ describe('PermissionsService', () => {
|
||||
'view_workflow',
|
||||
'change_workflow',
|
||||
'delete_workflow',
|
||||
'add_ocrtemplate',
|
||||
'view_ocrtemplate',
|
||||
'change_ocrtemplate',
|
||||
'delete_ocrtemplate',
|
||||
'add_customfield',
|
||||
'view_customfield',
|
||||
'change_customfield',
|
||||
|
||||
@@ -29,7 +29,6 @@ export enum PermissionType {
|
||||
ShareLinkBundle = '%s_sharelinkbundle',
|
||||
CustomField = '%s_customfield',
|
||||
Workflow = '%s_workflow',
|
||||
OcrTemplate = '%s_ocrtemplate',
|
||||
ProcessedMail = '%s_processedmail',
|
||||
GlobalStatistics = '%s_global_statistics',
|
||||
SystemMonitoring = '%s_system_monitoring',
|
||||
|
||||
@@ -563,20 +563,6 @@ describe(`DocumentService`, () => {
|
||||
|
||||
req.flush('task-id')
|
||||
})
|
||||
|
||||
it('should call appropriate api endpoint for running zone OCR', () => {
|
||||
const url = `${environment.apiBaseUrl}${endpoint}/${documents[0].id}/run-zone-ocr/`
|
||||
subscription = service.runZoneOcr(documents[0].id).subscribe()
|
||||
let req = httpTestingController.expectOne(url)
|
||||
expect(req.request.method).toEqual('POST')
|
||||
expect(req.request.body).toEqual({})
|
||||
req.flush({ status: 'completed', results: [] })
|
||||
|
||||
subscription = service.runZoneOcr(documents[0].id, 3).subscribe()
|
||||
req = httpTestingController.expectOne(url)
|
||||
expect(req.request.body).toEqual({ template: 3 })
|
||||
req.flush({ status: 'completed', results: [] })
|
||||
})
|
||||
})
|
||||
|
||||
it('should construct sort fields respecting permissions', () => {
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import { DocumentMetadata } from 'src/app/data/document-metadata'
|
||||
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
|
||||
import { FilterRule } from 'src/app/data/filter-rule'
|
||||
import { OcrZoneRunResponse } from 'src/app/data/ocr-template'
|
||||
import { Results, SelectionData } from 'src/app/data/results'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { queryParamsFromFilterRules } from '../../utils/query-params'
|
||||
@@ -361,26 +360,6 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
||||
})
|
||||
}
|
||||
|
||||
runZoneOcrDocuments(
|
||||
selection: DocumentSelectionQuery,
|
||||
templateId: number | null = null
|
||||
) {
|
||||
return this.http.post(this.getResourceUrl(null, 'run_zone_ocr'), {
|
||||
...selection,
|
||||
...(templateId ? { template: templateId } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
runZoneOcr(
|
||||
id: number,
|
||||
templateId: number | null = null
|
||||
): Observable<OcrZoneRunResponse> {
|
||||
return this.http.post<OcrZoneRunResponse>(
|
||||
this.getResourceUrl(id, 'run-zone-ocr'),
|
||||
templateId ? { template: templateId } : {}
|
||||
)
|
||||
}
|
||||
|
||||
rotateDocuments(
|
||||
selection: DocumentSelectionQuery,
|
||||
degrees: number,
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { provideHttpClient } from '@angular/common/http'
|
||||
import {
|
||||
HttpTestingController,
|
||||
provideHttpClientTesting,
|
||||
} from '@angular/common/http/testing'
|
||||
import { TestBed } from '@angular/core/testing'
|
||||
import { OcrZoneTransform, ZoneTestRequest } from 'src/app/data/ocr-template'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { OcrTemplateService } from './ocr-template.service'
|
||||
|
||||
describe('OcrTemplateService', () => {
|
||||
let service: OcrTemplateService
|
||||
let http: HttpTestingController
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||
})
|
||||
service = TestBed.inject(OcrTemplateService)
|
||||
http = TestBed.inject(HttpTestingController)
|
||||
})
|
||||
|
||||
afterEach(() => http.verify())
|
||||
|
||||
it('builds page image URLs with zero-based page numbers', () => {
|
||||
expect(service.getPageImageUrl(12, 0)).toBe(
|
||||
`${environment.apiBaseUrl}ocr_templates/document-page-image/12/0/`
|
||||
)
|
||||
expect(service.getPageImageUrl(12, 2)).toBe(
|
||||
`${environment.apiBaseUrl}ocr_templates/document-page-image/12/2/`
|
||||
)
|
||||
})
|
||||
|
||||
it('posts a zone test and returns the extraction result', () => {
|
||||
const zone: ZoneTestRequest = {
|
||||
name: 'Total',
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 100,
|
||||
height: 50,
|
||||
page: -1,
|
||||
ocr_language: 'eng',
|
||||
transform: OcrZoneTransform.Numeric,
|
||||
validation_regex: '\\d+',
|
||||
zone_source_width: 1000,
|
||||
zone_source_height: 1500,
|
||||
}
|
||||
const result = {
|
||||
raw_text: 'Total: 123',
|
||||
value: '123',
|
||||
regex: '\\d+',
|
||||
regex_match: true,
|
||||
}
|
||||
const next = jest.fn()
|
||||
service.testZone(12, zone).subscribe(next)
|
||||
const request = http.expectOne(
|
||||
`${environment.apiBaseUrl}ocr_templates/test-zone/`
|
||||
)
|
||||
expect(request.request.method).toBe('POST')
|
||||
expect(request.request.body).toEqual({ document: 12, zone })
|
||||
request.flush(result)
|
||||
expect(next).toHaveBeenCalledWith(result)
|
||||
})
|
||||
|
||||
it('passes zone-test failures to the caller', () => {
|
||||
const error = jest.fn()
|
||||
service.testZone(12, {} as ZoneTestRequest).subscribe({ error })
|
||||
http
|
||||
.expectOne(`${environment.apiBaseUrl}ocr_templates/test-zone/`)
|
||||
.flush(
|
||||
{ error: 'Invalid zone' },
|
||||
{ status: 400, statusText: 'Bad Request' }
|
||||
)
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 400, error: { error: 'Invalid zone' } })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { Observable } from 'rxjs'
|
||||
import {
|
||||
OcrTemplate,
|
||||
OcrZoneTestResult,
|
||||
ZoneTestRequest,
|
||||
} from '../../data/ocr-template'
|
||||
import { AbstractPaperlessService } from './abstract-paperless-service'
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class OcrTemplateService extends AbstractPaperlessService<OcrTemplate> {
|
||||
constructor() {
|
||||
super()
|
||||
this.resourceName = 'ocr_templates'
|
||||
}
|
||||
|
||||
getPageImageUrl(docId: number, page: number): string {
|
||||
return `${this.baseUrl}${this.resourceName}/document-page-image/${docId}/${page}/`
|
||||
}
|
||||
|
||||
testZone(
|
||||
docId: number,
|
||||
zone: ZoneTestRequest
|
||||
): Observable<OcrZoneTestResult> {
|
||||
return this.http.post<OcrZoneTestResult>(
|
||||
`${this.baseUrl}${this.resourceName}/test-zone/`,
|
||||
{ document: docId, zone }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,7 @@ import {
|
||||
} from '@angular/common/http/testing'
|
||||
import { TestBed } from '@angular/core/testing'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import {
|
||||
PaperlessTaskStatus,
|
||||
PaperlessTaskTriggerSource,
|
||||
PaperlessTaskType,
|
||||
} from '../data/paperless-task'
|
||||
import { PaperlessTaskType } from '../data/paperless-task'
|
||||
import { TasksService } from './tasks.service'
|
||||
|
||||
describe('TasksService', () => {
|
||||
@@ -38,23 +34,23 @@ describe('TasksService', () => {
|
||||
httpTestingController.verify()
|
||||
})
|
||||
|
||||
it('calls tasks api endpoint on reload', () => {
|
||||
it('calls tasks status counts api endpoint on reload', () => {
|
||||
tasksService.reload()
|
||||
const req = httpTestingController.expectOne(
|
||||
(req: HttpRequest<unknown>) =>
|
||||
req.url === `${environment.apiBaseUrl}tasks/` &&
|
||||
req.params.get('acknowledged') === 'false' &&
|
||||
req.params.get('page_size') === '1000'
|
||||
req.url === `${environment.apiBaseUrl}tasks/status_counts/` &&
|
||||
req.params.get('acknowledged') === 'false'
|
||||
)
|
||||
expect(req.request.method).toEqual('GET')
|
||||
req.flush({ count: 0, results: [] })
|
||||
req.flush({ all: 5, needs_attention: 3, in_progress: 1, completed: 1 })
|
||||
expect(tasksService.needsAttentionTasksCount).toEqual(3)
|
||||
})
|
||||
|
||||
it('cancels an in-progress reload when reloading again', () => {
|
||||
tasksService.reload()
|
||||
const staleReload = httpTestingController.expectOne(
|
||||
(req: HttpRequest<unknown>) =>
|
||||
req.url === `${environment.apiBaseUrl}tasks/`
|
||||
req.url === `${environment.apiBaseUrl}tasks/status_counts/`
|
||||
)
|
||||
tasksService.reload()
|
||||
|
||||
@@ -62,9 +58,9 @@ describe('TasksService', () => {
|
||||
httpTestingController
|
||||
.expectOne(
|
||||
(req: HttpRequest<unknown>) =>
|
||||
req.url === `${environment.apiBaseUrl}tasks/`
|
||||
req.url === `${environment.apiBaseUrl}tasks/status_counts/`
|
||||
)
|
||||
.flush({ count: 0, results: [] })
|
||||
.flush({ all: 0, needs_attention: 0, in_progress: 0, completed: 0 })
|
||||
})
|
||||
|
||||
it('continues reloading after a reload request fails', () => {
|
||||
@@ -72,7 +68,7 @@ describe('TasksService', () => {
|
||||
httpTestingController
|
||||
.expectOne(
|
||||
(req: HttpRequest<unknown>) =>
|
||||
req.url === `${environment.apiBaseUrl}tasks/`
|
||||
req.url === `${environment.apiBaseUrl}tasks/status_counts/`
|
||||
)
|
||||
.flush('error', { status: 500, statusText: 'error' })
|
||||
|
||||
@@ -82,16 +78,16 @@ describe('TasksService', () => {
|
||||
httpTestingController
|
||||
.expectOne(
|
||||
(req: HttpRequest<unknown>) =>
|
||||
req.url === `${environment.apiBaseUrl}tasks/`
|
||||
req.url === `${environment.apiBaseUrl}tasks/status_counts/`
|
||||
)
|
||||
.flush({ count: 0, results: [] })
|
||||
.flush({ all: 0, needs_attention: 0, in_progress: 0, completed: 0 })
|
||||
})
|
||||
|
||||
it('reloads after dismissing a task while a reload is already in progress', () => {
|
||||
tasksService.reload()
|
||||
const staleReload = httpTestingController.expectOne(
|
||||
(req: HttpRequest<unknown>) =>
|
||||
req.url === `${environment.apiBaseUrl}tasks/` &&
|
||||
req.url === `${environment.apiBaseUrl}tasks/status_counts/` &&
|
||||
req.params.get('acknowledged') === 'false'
|
||||
)
|
||||
|
||||
@@ -104,12 +100,12 @@ describe('TasksService', () => {
|
||||
httpTestingController
|
||||
.expectOne(
|
||||
(req: HttpRequest<unknown>) =>
|
||||
req.url === `${environment.apiBaseUrl}tasks/` &&
|
||||
req.url === `${environment.apiBaseUrl}tasks/status_counts/` &&
|
||||
req.params.get('acknowledged') === 'false'
|
||||
)
|
||||
.flush({ count: 0, results: [] })
|
||||
.flush({ all: 50, needs_attention: 42, in_progress: 3, completed: 5 })
|
||||
|
||||
expect(tasksService.needsAttentionTasks).toHaveLength(0)
|
||||
expect(tasksService.needsAttentionTasksCount).toEqual(42)
|
||||
})
|
||||
|
||||
it('calls acknowledge_tasks api endpoint on dismiss and reloads', () => {
|
||||
@@ -126,11 +122,10 @@ describe('TasksService', () => {
|
||||
httpTestingController
|
||||
.expectOne(
|
||||
(req: HttpRequest<unknown>) =>
|
||||
req.url === `${environment.apiBaseUrl}tasks/` &&
|
||||
req.params.get('acknowledged') === 'false' &&
|
||||
req.params.get('page_size') === '1000'
|
||||
req.url === `${environment.apiBaseUrl}tasks/status_counts/` &&
|
||||
req.params.get('acknowledged') === 'false'
|
||||
)
|
||||
.flush({ count: 0, results: [] })
|
||||
.flush({ all: 0, needs_attention: 0, in_progress: 0, completed: 0 })
|
||||
})
|
||||
|
||||
it('calls acknowledge_tasks api endpoint on dismiss all and reloads', () => {
|
||||
@@ -147,65 +142,10 @@ describe('TasksService', () => {
|
||||
httpTestingController
|
||||
.expectOne(
|
||||
(req: HttpRequest<unknown>) =>
|
||||
req.url === `${environment.apiBaseUrl}tasks/` &&
|
||||
req.params.get('acknowledged') === 'false' &&
|
||||
req.params.get('page_size') === '1000'
|
||||
req.url === `${environment.apiBaseUrl}tasks/status_counts/` &&
|
||||
req.params.get('acknowledged') === 'false'
|
||||
)
|
||||
.flush({ count: 0, results: [] })
|
||||
})
|
||||
|
||||
it('includes revoked tasks in needs attention', () => {
|
||||
const mockTasks = [
|
||||
{
|
||||
task_type: PaperlessTaskType.SanityCheck,
|
||||
trigger_source: PaperlessTaskTriggerSource.System,
|
||||
status: PaperlessTaskStatus.Failure,
|
||||
acknowledged: false,
|
||||
task_id: '1235',
|
||||
input_data: {},
|
||||
date_created: new Date(),
|
||||
related_document_ids: [],
|
||||
},
|
||||
{
|
||||
task_type: PaperlessTaskType.MailFetch,
|
||||
trigger_source: PaperlessTaskTriggerSource.Scheduled,
|
||||
status: PaperlessTaskStatus.Revoked,
|
||||
acknowledged: false,
|
||||
task_id: '1236',
|
||||
input_data: {},
|
||||
date_created: new Date(),
|
||||
related_document_ids: [],
|
||||
},
|
||||
{
|
||||
task_type: PaperlessTaskType.EmptyTrash,
|
||||
trigger_source: PaperlessTaskTriggerSource.Manual,
|
||||
status: PaperlessTaskStatus.Success,
|
||||
acknowledged: false,
|
||||
task_id: '1238',
|
||||
input_data: {},
|
||||
date_created: new Date(),
|
||||
related_document_ids: [],
|
||||
},
|
||||
]
|
||||
|
||||
tasksService.reload()
|
||||
|
||||
const req = httpTestingController.expectOne(
|
||||
(req: HttpRequest<unknown>) =>
|
||||
req.url === `${environment.apiBaseUrl}tasks/` &&
|
||||
req.params.get('acknowledged') === 'false' &&
|
||||
req.params.get('page_size') === '1000'
|
||||
)
|
||||
|
||||
req.flush({ count: mockTasks.length, results: mockTasks })
|
||||
|
||||
expect(tasksService.needsAttentionTasks).toHaveLength(2)
|
||||
expect(tasksService.needsAttentionTasks.map((task) => task.status)).toEqual(
|
||||
expect.arrayContaining([
|
||||
PaperlessTaskStatus.Failure,
|
||||
PaperlessTaskStatus.Revoked,
|
||||
])
|
||||
)
|
||||
.flush({ all: 0, needs_attention: 0, in_progress: 0, completed: 0 })
|
||||
})
|
||||
|
||||
it('supports running tasks', () => {
|
||||
|
||||
@@ -5,14 +5,12 @@ import {
|
||||
catchError,
|
||||
finalize,
|
||||
first,
|
||||
map,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
tap,
|
||||
} from 'rxjs/operators'
|
||||
import {
|
||||
PaperlessTask,
|
||||
PaperlessTaskStatus,
|
||||
PaperlessTaskStatusCounts,
|
||||
PaperlessTaskType,
|
||||
} from 'src/app/data/paperless-task'
|
||||
@@ -27,50 +25,42 @@ export class TasksService {
|
||||
|
||||
private baseUrl: string = environment.apiBaseUrl
|
||||
private endpoint: string = 'tasks'
|
||||
private readonly defaultReloadPageSize = 1000
|
||||
|
||||
public loading: boolean = false
|
||||
|
||||
private readonly tasks = signal<PaperlessTask[]>([])
|
||||
private readonly tasksStatusCounts = signal<PaperlessTaskStatusCounts>({
|
||||
all: 0,
|
||||
needs_attention: 0,
|
||||
in_progress: 0,
|
||||
completed: 0,
|
||||
})
|
||||
private readonly reloadNotifier = new Subject<void>()
|
||||
|
||||
private unsubscribeNotifer: Subject<any> = new Subject()
|
||||
|
||||
constructor() {
|
||||
this.reloadNotifier
|
||||
.pipe(
|
||||
switchMap(() => {
|
||||
this.loading = true
|
||||
return this.http
|
||||
.get<Results<PaperlessTask>>(`${this.baseUrl}${this.endpoint}/`, {
|
||||
params: {
|
||||
acknowledged: 'false',
|
||||
page_size: this.defaultReloadPageSize,
|
||||
},
|
||||
})
|
||||
.pipe(
|
||||
map((response) => response.results),
|
||||
takeUntil(this.unsubscribeNotifer),
|
||||
catchError(() => EMPTY),
|
||||
finalize(() => {
|
||||
this.loading = false
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
.subscribe((tasks) => {
|
||||
this.tasks.set(tasks)
|
||||
.pipe(switchMap(() => this.fetchUnacknowledgedTasksCounts()))
|
||||
.subscribe((taskStatusCounts) => {
|
||||
this.tasksStatusCounts.set(taskStatusCounts)
|
||||
})
|
||||
}
|
||||
|
||||
public get needsAttentionTasks(): PaperlessTask[] {
|
||||
return this.tasks().filter((t) =>
|
||||
[PaperlessTaskStatus.Failure, PaperlessTaskStatus.Revoked].includes(
|
||||
t.status
|
||||
)
|
||||
private fetchUnacknowledgedTasksCounts(): Observable<PaperlessTaskStatusCounts> {
|
||||
this.loading = true
|
||||
return this.statusCounts({ acknowledged: false }).pipe(
|
||||
takeUntil(this.unsubscribeNotifer),
|
||||
catchError(() => EMPTY),
|
||||
finalize(() => {
|
||||
this.loading = false
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
public get needsAttentionTasksCount(): number {
|
||||
return this.tasksStatusCounts().needs_attention
|
||||
}
|
||||
|
||||
public reload() {
|
||||
this.reloadNotifier.next()
|
||||
}
|
||||
@@ -105,7 +95,7 @@ export class TasksService {
|
||||
|
||||
public dismissTasks(task_ids: Set<number>): Observable<any> {
|
||||
return this.http
|
||||
.post(`${this.baseUrl}tasks/acknowledge/`, {
|
||||
.post(`${this.baseUrl}${this.endpoint}/acknowledge/`, {
|
||||
tasks: [...task_ids],
|
||||
})
|
||||
.pipe(
|
||||
@@ -119,7 +109,7 @@ export class TasksService {
|
||||
|
||||
public dismissAllTasks(): Observable<any> {
|
||||
return this.http
|
||||
.post(`${this.baseUrl}tasks/acknowledge/`, {
|
||||
.post(`${this.baseUrl}${this.endpoint}/acknowledge/`, {
|
||||
all: true,
|
||||
})
|
||||
.pipe(
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -42,7 +42,6 @@ import {
|
||||
bell,
|
||||
bodyText,
|
||||
bookmark,
|
||||
boundingBoxCircles,
|
||||
boxArrowInRight,
|
||||
boxArrowUp,
|
||||
boxArrowUpRight,
|
||||
@@ -106,7 +105,6 @@ import {
|
||||
fileEarmarkPlus,
|
||||
fileEarmarkRichtext,
|
||||
fileEarmarkSpreadsheet,
|
||||
fileEarmarkRuled,
|
||||
fileText,
|
||||
files,
|
||||
filter,
|
||||
@@ -157,7 +155,6 @@ import {
|
||||
send,
|
||||
shop,
|
||||
slashCircle,
|
||||
sliders2,
|
||||
sliders2Vertical,
|
||||
sortAlphaDown,
|
||||
sortAlphaUpAlt,
|
||||
@@ -302,7 +299,6 @@ const icons = {
|
||||
braces,
|
||||
bodyText,
|
||||
bookmark,
|
||||
boundingBoxCircles,
|
||||
boxArrowInRight,
|
||||
boxArrowUp,
|
||||
boxArrowUpRight,
|
||||
@@ -365,7 +361,6 @@ const icons = {
|
||||
fileEarmarkPlus,
|
||||
fileEarmarkRichtext,
|
||||
fileEarmarkSpreadsheet,
|
||||
fileEarmarkRuled,
|
||||
files,
|
||||
fileText,
|
||||
filter,
|
||||
@@ -416,7 +411,6 @@ const icons = {
|
||||
send,
|
||||
slashCircle,
|
||||
shop,
|
||||
sliders2,
|
||||
sliders2Vertical,
|
||||
sortAlphaDown,
|
||||
sortAlphaUpAlt,
|
||||
|
||||
@@ -691,12 +691,6 @@ table.table {
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
.modal-xxl {
|
||||
--bs-modal-width: min(1440px, calc(100vw - 2 * var(--bs-modal-margin)));
|
||||
}
|
||||
}
|
||||
|
||||
.toast {
|
||||
--bs-toast-max-width: var(--pngx-toast-max-width);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ class DocumentsConfig(AppConfig):
|
||||
from documents.signals.handlers import add_to_index
|
||||
from documents.signals.handlers import run_workflows_added
|
||||
from documents.signals.handlers import run_workflows_updated
|
||||
from documents.signals.handlers import run_zone_ocr_extraction
|
||||
from documents.signals.handlers import send_websocket_document_updated
|
||||
from documents.signals.handlers import set_correspondent
|
||||
from documents.signals.handlers import set_document_type
|
||||
@@ -30,8 +29,6 @@ class DocumentsConfig(AppConfig):
|
||||
document_consumption_finished.connect(run_workflows_added)
|
||||
document_consumption_finished.connect(add_to_index)
|
||||
document_consumption_finished.connect(add_or_update_document_in_llm_index)
|
||||
document_consumption_finished.connect(run_zone_ocr_extraction)
|
||||
|
||||
document_updated.connect(run_workflows_updated)
|
||||
document_updated.connect(send_websocket_document_updated)
|
||||
document_updated.connect(add_or_update_document_in_llm_index)
|
||||
|
||||
@@ -33,7 +33,6 @@ from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import OcrTemplate
|
||||
from documents.models import PaperlessTask
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
@@ -42,7 +41,6 @@ from documents.permissions import set_permissions_for_objects
|
||||
from documents.plugins.helpers import DocumentsStatusManager
|
||||
from documents.tasks import bulk_update_documents
|
||||
from documents.tasks import consume_file
|
||||
from documents.tasks import extract_zones
|
||||
from documents.tasks import remove_document_from_index
|
||||
from documents.tasks import update_document_content_maybe_archive_file
|
||||
from documents.versioning import get_latest_version_for_root
|
||||
@@ -1199,22 +1197,3 @@ def remove_doclink(
|
||||
target_doc_field_instance.value.remove(document.id)
|
||||
target_doc_field_instance.save()
|
||||
Document.objects.filter(id=target_doc_id).update(modified=timezone.now())
|
||||
|
||||
|
||||
def run_zone_ocr(
|
||||
doc_ids: list[int],
|
||||
*,
|
||||
template: OcrTemplate | None = None,
|
||||
) -> Literal["OK"]:
|
||||
"""
|
||||
Queue zone OCR for the given documents, with the given template or with the
|
||||
templates that match each document's content when its task runs.
|
||||
"""
|
||||
template_id = template.pk if template is not None else None
|
||||
for document_id in doc_ids:
|
||||
extract_zones.apply_async(
|
||||
kwargs={"document_id": document_id, "template_id": template_id},
|
||||
headers={"trigger_source": PaperlessTask.TriggerSource.MANUAL},
|
||||
)
|
||||
|
||||
return "OK"
|
||||
@@ -1,311 +0,0 @@
|
||||
# Generated by Django 5.2.17 on 2026-10-20 20:29
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("documents", "0027_documentbarcode"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="OcrTemplate",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=128, verbose_name="name")),
|
||||
(
|
||||
"match",
|
||||
models.CharField(blank=True, max_length=256, verbose_name="match"),
|
||||
),
|
||||
(
|
||||
"matching_algorithm",
|
||||
models.PositiveSmallIntegerField(
|
||||
choices=[
|
||||
(0, "None"),
|
||||
(1, "Any word"),
|
||||
(2, "All words"),
|
||||
(3, "Exact match"),
|
||||
(4, "Regular expression"),
|
||||
(5, "Fuzzy word"),
|
||||
(6, "Automatic"),
|
||||
],
|
||||
default=1,
|
||||
verbose_name="matching algorithm",
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_insensitive",
|
||||
models.BooleanField(default=True, verbose_name="is insensitive"),
|
||||
),
|
||||
(
|
||||
"source_width",
|
||||
models.PositiveIntegerField(
|
||||
help_text="Width of the image the zones were drawn on (px)",
|
||||
validators=[django.core.validators.MinValueValidator(1)],
|
||||
verbose_name="source width",
|
||||
),
|
||||
),
|
||||
(
|
||||
"source_height",
|
||||
models.PositiveIntegerField(
|
||||
help_text="Height of the image the zones were drawn on (px)",
|
||||
validators=[django.core.validators.MinValueValidator(1)],
|
||||
verbose_name="source height",
|
||||
),
|
||||
),
|
||||
("enabled", models.BooleanField(default=True, verbose_name="enabled")),
|
||||
(
|
||||
"combine_formats",
|
||||
models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
help_text="Per-target format strings for combining several zones into one field, keyed by target (custom field id, or 'title'/'asn'/'created'). Tokens like {Zone Name} are replaced with that zone's value.",
|
||||
verbose_name="combine formats",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
models.DateTimeField(
|
||||
db_index=True,
|
||||
default=django.utils.timezone.now,
|
||||
editable=False,
|
||||
verbose_name="created",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated",
|
||||
models.DateTimeField(auto_now=True, verbose_name="updated"),
|
||||
),
|
||||
(
|
||||
"owner",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="owner",
|
||||
),
|
||||
),
|
||||
(
|
||||
"sample_document",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Document used for previewing zones in the editor",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="+",
|
||||
to="documents.document",
|
||||
verbose_name="sample document",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "OCR template",
|
||||
"verbose_name_plural": "OCR templates",
|
||||
"ordering": ("name",),
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="OcrTemplateZone",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"name",
|
||||
models.CharField(
|
||||
help_text="Descriptive name for this zone (e.g. 'Invoice Number')",
|
||||
max_length=128,
|
||||
verbose_name="zone name",
|
||||
),
|
||||
),
|
||||
(
|
||||
"target",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("custom_field", "Custom field"),
|
||||
("title", "Title"),
|
||||
("asn", "Archive serial number"),
|
||||
("created", "Date created"),
|
||||
],
|
||||
default="custom_field",
|
||||
help_text="Where the extracted value is written: a custom field, or a built-in document field (title, ASN, created date)",
|
||||
max_length=20,
|
||||
verbose_name="target",
|
||||
),
|
||||
),
|
||||
(
|
||||
"page",
|
||||
models.IntegerField(
|
||||
blank=True,
|
||||
help_text="Page (1 = first, -1 = last; blank uses the template default)",
|
||||
null=True,
|
||||
verbose_name="page",
|
||||
),
|
||||
),
|
||||
(
|
||||
"x",
|
||||
models.PositiveIntegerField(
|
||||
help_text="Left edge (px)",
|
||||
verbose_name="x",
|
||||
),
|
||||
),
|
||||
(
|
||||
"y",
|
||||
models.PositiveIntegerField(
|
||||
help_text="Top edge (px)",
|
||||
verbose_name="y",
|
||||
),
|
||||
),
|
||||
(
|
||||
"width",
|
||||
models.PositiveIntegerField(
|
||||
help_text="Zone width (px)",
|
||||
validators=[django.core.validators.MinValueValidator(1)],
|
||||
verbose_name="width",
|
||||
),
|
||||
),
|
||||
(
|
||||
"height",
|
||||
models.PositiveIntegerField(
|
||||
help_text="Zone height (px)",
|
||||
validators=[django.core.validators.MinValueValidator(1)],
|
||||
verbose_name="height",
|
||||
),
|
||||
),
|
||||
(
|
||||
"zone_source_width",
|
||||
models.PositiveIntegerField(
|
||||
blank=True,
|
||||
help_text="Width of the page image this zone was drawn on (px). Falls back to template source_width if unset.",
|
||||
null=True,
|
||||
verbose_name="zone source width",
|
||||
),
|
||||
),
|
||||
(
|
||||
"zone_source_height",
|
||||
models.PositiveIntegerField(
|
||||
blank=True,
|
||||
help_text="Height of the page image this zone was drawn on (px). Falls back to template source_height if unset.",
|
||||
null=True,
|
||||
verbose_name="zone source height",
|
||||
),
|
||||
),
|
||||
(
|
||||
"ocr_language",
|
||||
models.CharField(
|
||||
default="eng",
|
||||
help_text="Tesseract language code(s), e.g. 'deu+eng'",
|
||||
max_length=20,
|
||||
verbose_name="OCR language",
|
||||
),
|
||||
),
|
||||
(
|
||||
"transform",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("none", "None"),
|
||||
("strip", "Strip whitespace"),
|
||||
("uppercase", "Uppercase"),
|
||||
("lowercase", "Lowercase"),
|
||||
("numeric", "Numeric only"),
|
||||
(
|
||||
"strip_punctuation",
|
||||
"Remove leading/trailing punctuation",
|
||||
),
|
||||
("date", "Parse date"),
|
||||
("qr_code", "Read QR/barcode"),
|
||||
],
|
||||
default="strip",
|
||||
max_length=20,
|
||||
verbose_name="transform",
|
||||
),
|
||||
),
|
||||
(
|
||||
"date_format",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="Python strptime format for the 'Parse date' transform (e.g. %d.%m.%Y). Blank = auto-detect.",
|
||||
max_length=64,
|
||||
verbose_name="date format",
|
||||
),
|
||||
),
|
||||
(
|
||||
"validation_regex",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="Optional regex pattern — extracted text is only accepted if it matches",
|
||||
max_length=256,
|
||||
verbose_name="validation regex",
|
||||
),
|
||||
),
|
||||
("order", models.PositiveIntegerField(default=0, verbose_name="order")),
|
||||
(
|
||||
"custom_field",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Target custom field (only used when target is 'custom_field')",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="ocr_zones",
|
||||
to="documents.customfield",
|
||||
verbose_name="custom field",
|
||||
),
|
||||
),
|
||||
(
|
||||
"template",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="zones",
|
||||
to="documents.ocrtemplate",
|
||||
verbose_name="template",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "OCR template zone",
|
||||
"verbose_name_plural": "OCR template zones",
|
||||
"ordering": ("template", "order"),
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="ocrtemplate",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("name", "owner"),
|
||||
name="documents_ocrtemplate_unique_name_owner",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="ocrtemplate",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("owner__isnull", True)),
|
||||
fields=("name",),
|
||||
name="documents_ocrtemplate_name_uniq",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -2149,215 +2149,3 @@ class WorkflowRun(SoftDeleteModel):
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"WorkflowRun of {self.workflow} at {self.run_at} on {self.document}"
|
||||
|
||||
|
||||
class OcrTemplate(MatchingModel):
|
||||
"""Matching rules and extraction zones for documents with a common layout."""
|
||||
|
||||
source_width = models.PositiveIntegerField(
|
||||
_("source width"),
|
||||
validators=[MinValueValidator(1)],
|
||||
help_text=_("Width of the image the zones were drawn on (px)"),
|
||||
)
|
||||
|
||||
source_height = models.PositiveIntegerField(
|
||||
_("source height"),
|
||||
validators=[MinValueValidator(1)],
|
||||
help_text=_("Height of the image the zones were drawn on (px)"),
|
||||
)
|
||||
|
||||
sample_document = models.ForeignKey(
|
||||
"documents.Document",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="+",
|
||||
verbose_name=_("sample document"),
|
||||
help_text=_("Document used for previewing zones in the editor"),
|
||||
)
|
||||
|
||||
enabled = models.BooleanField(_("enabled"), default=True)
|
||||
|
||||
combine_formats = models.JSONField(
|
||||
_("combine formats"),
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text=_(
|
||||
"Per-target format strings for combining several zones into one "
|
||||
"field, keyed by target (custom field id, or 'title'/'asn'/'created'). "
|
||||
"Tokens like {Zone Name} are replaced with that zone's value.",
|
||||
),
|
||||
)
|
||||
|
||||
created = models.DateTimeField(
|
||||
_("created"),
|
||||
default=timezone.now,
|
||||
db_index=True,
|
||||
editable=False,
|
||||
)
|
||||
|
||||
updated = models.DateTimeField(
|
||||
_("updated"),
|
||||
auto_now=True,
|
||||
)
|
||||
|
||||
class Meta(MatchingModel.Meta):
|
||||
verbose_name = _("OCR template")
|
||||
verbose_name_plural = _("OCR templates")
|
||||
|
||||
|
||||
class OcrTemplateZone(models.Model):
|
||||
"""A page region and the field its extracted value is written to."""
|
||||
|
||||
template = models.ForeignKey(
|
||||
OcrTemplate,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="zones",
|
||||
verbose_name=_("template"),
|
||||
)
|
||||
|
||||
name = models.CharField(
|
||||
_("zone name"),
|
||||
max_length=128,
|
||||
help_text=_("Descriptive name for this zone (e.g. 'Invoice Number')"),
|
||||
)
|
||||
|
||||
class TargetType(models.TextChoices):
|
||||
CUSTOM_FIELD = ("custom_field", _("Custom field"))
|
||||
TITLE = ("title", _("Title"))
|
||||
ASN = ("asn", _("Archive serial number"))
|
||||
CREATED = ("created", _("Date created"))
|
||||
|
||||
target = models.CharField(
|
||||
_("target"),
|
||||
max_length=20,
|
||||
choices=TargetType.choices,
|
||||
default=TargetType.CUSTOM_FIELD,
|
||||
help_text=_(
|
||||
"Where the extracted value is written: a custom field, or a "
|
||||
"built-in document field (title, ASN, created date)",
|
||||
),
|
||||
)
|
||||
|
||||
custom_field = models.ForeignKey(
|
||||
"documents.CustomField",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="ocr_zones",
|
||||
verbose_name=_("custom field"),
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_("Target custom field (only used when target is 'custom_field')"),
|
||||
)
|
||||
|
||||
page = models.IntegerField(
|
||||
_("page"),
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_("Page (1 = first, -1 = last; blank uses the template default)"),
|
||||
)
|
||||
|
||||
x = models.PositiveIntegerField(_("x"), help_text=_("Left edge (px)"))
|
||||
y = models.PositiveIntegerField(_("y"), help_text=_("Top edge (px)"))
|
||||
width = models.PositiveIntegerField(
|
||||
_("width"),
|
||||
validators=[MinValueValidator(1)],
|
||||
help_text=_("Zone width (px)"),
|
||||
)
|
||||
height = models.PositiveIntegerField(
|
||||
_("height"),
|
||||
validators=[MinValueValidator(1)],
|
||||
help_text=_("Zone height (px)"),
|
||||
)
|
||||
|
||||
# Mixed-size PDFs need each zone's original page dimensions for scaling.
|
||||
zone_source_width = models.PositiveIntegerField(
|
||||
_("zone source width"),
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_(
|
||||
"Width of the page image this zone was drawn on (px). "
|
||||
"Falls back to template source_width if unset.",
|
||||
),
|
||||
)
|
||||
zone_source_height = models.PositiveIntegerField(
|
||||
_("zone source height"),
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_(
|
||||
"Height of the page image this zone was drawn on (px). "
|
||||
"Falls back to template source_height if unset.",
|
||||
),
|
||||
)
|
||||
|
||||
ocr_language = models.CharField(
|
||||
_("OCR language"),
|
||||
max_length=20,
|
||||
default="eng",
|
||||
help_text=_("Tesseract language code(s), e.g. 'deu+eng'"),
|
||||
)
|
||||
|
||||
class TransformType(models.TextChoices):
|
||||
NONE = ("none", _("None"))
|
||||
STRIP = ("strip", _("Strip whitespace"))
|
||||
UPPERCASE = ("uppercase", _("Uppercase"))
|
||||
LOWERCASE = ("lowercase", _("Lowercase"))
|
||||
NUMERIC = ("numeric", _("Numeric only"))
|
||||
STRIP_PUNCTUATION = (
|
||||
"strip_punctuation",
|
||||
_("Remove leading/trailing punctuation"),
|
||||
)
|
||||
DATE = ("date", _("Parse date"))
|
||||
QR_CODE = ("qr_code", _("Read QR/barcode"))
|
||||
|
||||
transform = models.CharField(
|
||||
_("transform"),
|
||||
max_length=20,
|
||||
choices=TransformType.choices,
|
||||
default=TransformType.STRIP,
|
||||
)
|
||||
|
||||
date_format = models.CharField(
|
||||
_("date format"),
|
||||
max_length=64,
|
||||
blank=True,
|
||||
default="",
|
||||
help_text=_(
|
||||
"Python strptime format for the 'Parse date' transform "
|
||||
"(e.g. %d.%m.%Y). Blank = auto-detect.",
|
||||
),
|
||||
)
|
||||
|
||||
validation_regex = models.CharField(
|
||||
_("validation regex"),
|
||||
max_length=256,
|
||||
blank=True,
|
||||
default="",
|
||||
help_text=_(
|
||||
"Optional regex pattern — extracted text is only accepted if it matches",
|
||||
),
|
||||
)
|
||||
|
||||
order = models.PositiveIntegerField(_("order"), default=0)
|
||||
|
||||
class Meta:
|
||||
ordering = ("template", "order")
|
||||
verbose_name = _("OCR template zone")
|
||||
verbose_name_plural = _("OCR template zones")
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.template.name} -> {self.name}"
|
||||
|
||||
|
||||
# Document links and select fields require object references, not OCR text.
|
||||
OCR_SUPPORTED_FIELD_TYPES = frozenset(
|
||||
{
|
||||
CustomField.FieldDataType.STRING,
|
||||
CustomField.FieldDataType.URL,
|
||||
CustomField.FieldDataType.DATE,
|
||||
CustomField.FieldDataType.INT,
|
||||
CustomField.FieldDataType.FLOAT,
|
||||
CustomField.FieldDataType.MONETARY,
|
||||
CustomField.FieldDataType.LONG_TEXT,
|
||||
CustomField.FieldDataType.BOOL,
|
||||
},
|
||||
)
|
||||
@@ -77,33 +77,6 @@ def safe_regex_match(pattern: str, text: str, *, flags: int = 0):
|
||||
return None
|
||||
|
||||
|
||||
def safe_regex_fullmatch(pattern: str, text: str, *, flags: int = 0):
|
||||
"""
|
||||
Run a regex full match with a timeout. Returns a match object or None.
|
||||
Validation errors and timeouts are logged and treated as no match.
|
||||
"""
|
||||
|
||||
try:
|
||||
validate_regex_pattern(pattern)
|
||||
compiled = regex.compile(pattern, flags=flags)
|
||||
except (regex.error, ValueError) as exc:
|
||||
logger.exception(
|
||||
"Error while processing regular expression %s: %s",
|
||||
textwrap.shorten(pattern, width=80, placeholder="…"),
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
return compiled.fullmatch(text, timeout=REGEX_TIMEOUT_SECONDS)
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
"Regular expression matching timed out for pattern %s",
|
||||
textwrap.shorten(pattern, width=80, placeholder="…"),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def safe_regex_sub(pattern: str, repl: str, text: str, *, flags: int = 0) -> str | None:
|
||||
"""
|
||||
Run a regex substitution with a timeout. Returns the substituted string,
|
||||
|
||||
@@ -23,7 +23,6 @@ from django.core.validators import MaxValueValidator
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.core.validators import RegexValidator
|
||||
from django.core.validators import integer_validator
|
||||
from django.db import transaction
|
||||
from django.db.models import Count
|
||||
from django.db.models import Q
|
||||
from django.db.models.functions import Lower
|
||||
@@ -55,7 +54,6 @@ if settings.AUDIT_LOG_ENABLED:
|
||||
from documents import bulk_edit
|
||||
from documents.data_models import DocumentSource
|
||||
from documents.filters import CustomFieldQueryParser
|
||||
from documents.models import OCR_SUPPORTED_FIELD_TYPES
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
@@ -64,8 +62,6 @@ from documents.models import DocumentBarcode
|
||||
from documents.models import DocumentType
|
||||
from documents.models import MatchingModel
|
||||
from documents.models import Note
|
||||
from documents.models import OcrTemplate
|
||||
from documents.models import OcrTemplateZone
|
||||
from documents.models import PaperlessTask
|
||||
from documents.models import SavedView
|
||||
from documents.models import SavedViewFilterRule
|
||||
@@ -1825,18 +1821,6 @@ class ReprocessDocumentsSerializer(DocumentSelectionSerializer):
|
||||
remote_ocr = serializers.BooleanField(required=False, default=False)
|
||||
|
||||
|
||||
class RunZoneOcrSerializer(serializers.Serializer):
|
||||
template = serializers.PrimaryKeyRelatedField(
|
||||
queryset=OcrTemplate.objects.prefetch_related("zones", "zones__custom_field"),
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
|
||||
|
||||
class RunZoneOcrDocumentsSerializer(DocumentSelectionSerializer, RunZoneOcrSerializer):
|
||||
pass
|
||||
|
||||
|
||||
class BulkEditSerializer(
|
||||
SerializerWithPerms,
|
||||
DocumentSelectionSerializer,
|
||||
@@ -3714,181 +3698,3 @@ class StoragePathTestSerializer(SerializerWithPerms):
|
||||
document_field.queryset = Document.objects.filter(
|
||||
id__in=permitted_document_ids(user),
|
||||
)
|
||||
|
||||
|
||||
class OcrTemplateZoneSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = OcrTemplateZone
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"target",
|
||||
"custom_field",
|
||||
"page",
|
||||
"x",
|
||||
"y",
|
||||
"width",
|
||||
"height",
|
||||
"ocr_language",
|
||||
"transform",
|
||||
"date_format",
|
||||
"order",
|
||||
"zone_source_width",
|
||||
"zone_source_height",
|
||||
"validation_regex",
|
||||
]
|
||||
|
||||
def validate_custom_field(self, value):
|
||||
if value is None:
|
||||
return value
|
||||
if value.data_type not in OCR_SUPPORTED_FIELD_TYPES:
|
||||
raise serializers.ValidationError(
|
||||
f"Custom field type '{value.data_type}' is not supported for OCR extraction. "
|
||||
f"Use string, integer, float, date, monetary, boolean, URL, or long text.",
|
||||
)
|
||||
return value
|
||||
|
||||
def validate_page(self, value):
|
||||
if value is not None and value != -1 and value < 1:
|
||||
raise serializers.ValidationError(
|
||||
"Page must be positive or -1 for the last page.",
|
||||
)
|
||||
return value
|
||||
|
||||
def validate(self, data):
|
||||
# A template PATCH still replaces each zone in full.
|
||||
if self.root.partial:
|
||||
for name, field in self.fields.items():
|
||||
if field.required and name not in data:
|
||||
raise serializers.ValidationError(
|
||||
{name: field.error_messages["required"]},
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
class OcrTemplateZoneTestSerializer(OcrTemplateZoneSerializer):
|
||||
name = serializers.CharField(default="test")
|
||||
x = serializers.IntegerField(min_value=0, default=0)
|
||||
y = serializers.IntegerField(min_value=0, default=0)
|
||||
width = serializers.IntegerField(min_value=2)
|
||||
height = serializers.IntegerField(min_value=2)
|
||||
|
||||
class Meta(OcrTemplateZoneSerializer.Meta):
|
||||
fields = [
|
||||
"name",
|
||||
"x",
|
||||
"y",
|
||||
"width",
|
||||
"height",
|
||||
"page",
|
||||
"ocr_language",
|
||||
"transform",
|
||||
"date_format",
|
||||
"validation_regex",
|
||||
"zone_source_width",
|
||||
"zone_source_height",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"zone_source_width": {"min_value": 1},
|
||||
"zone_source_height": {"min_value": 1},
|
||||
}
|
||||
|
||||
|
||||
class OcrTemplateTestSerializer(serializers.Serializer):
|
||||
document = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Document.objects.select_related("root_document__owner"),
|
||||
)
|
||||
zone = OcrTemplateZoneTestSerializer()
|
||||
|
||||
|
||||
class OcrTemplateSerializer(MatchingModelSerializer, OwnedObjectSerializer):
|
||||
zones = OcrTemplateZoneSerializer(many=True, required=False)
|
||||
combine_formats = serializers.DictField(
|
||||
child=serializers.CharField(allow_blank=True, trim_whitespace=False),
|
||||
required=False,
|
||||
)
|
||||
|
||||
# Templates are never assigned to documents
|
||||
slug = None
|
||||
document_count = None
|
||||
|
||||
class Meta:
|
||||
model = OcrTemplate
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"match",
|
||||
"matching_algorithm",
|
||||
"is_insensitive",
|
||||
"source_width",
|
||||
"source_height",
|
||||
"sample_document",
|
||||
"enabled",
|
||||
"combine_formats",
|
||||
"created",
|
||||
"updated",
|
||||
"zones",
|
||||
"owner",
|
||||
"permissions",
|
||||
"user_can_change",
|
||||
"set_permissions",
|
||||
]
|
||||
read_only_fields = ["created", "updated"]
|
||||
|
||||
def validate_matching_algorithm(self, value):
|
||||
if value == MatchingModel.MATCH_AUTO:
|
||||
raise serializers.ValidationError(
|
||||
"Automatic matching is not supported for OCR templates.",
|
||||
)
|
||||
return value
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
zones = data.get("zones", [])
|
||||
if (
|
||||
self.instance
|
||||
and "zones" not in data
|
||||
and ("source_width" in data or "source_height" in data)
|
||||
):
|
||||
zones = self.instance.zones.values()
|
||||
source_width = data.get(
|
||||
"source_width",
|
||||
getattr(self.instance, "source_width", None),
|
||||
)
|
||||
source_height = data.get(
|
||||
"source_height",
|
||||
getattr(self.instance, "source_height", None),
|
||||
)
|
||||
for zone in zones:
|
||||
width = zone.get("zone_source_width") or source_width
|
||||
height = zone.get("zone_source_height") or source_height
|
||||
if (
|
||||
zone.get("x", 0) + zone.get("width", 0) > width
|
||||
or zone.get("y", 0) + zone.get("height", 0) > height
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"zones": f"Zone '{zone.get('name', '?')}' extends beyond its source dimensions.",
|
||||
},
|
||||
)
|
||||
return data
|
||||
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
zones_data = validated_data.pop("zones", [])
|
||||
template = super().create(validated_data)
|
||||
for zone_data in zones_data:
|
||||
OcrTemplateZone.objects.create(template=template, **zone_data)
|
||||
return template
|
||||
|
||||
@transaction.atomic
|
||||
def update(self, instance, validated_data):
|
||||
zones_data = validated_data.pop("zones", None)
|
||||
instance = super().update(instance, validated_data)
|
||||
|
||||
if zones_data is not None:
|
||||
instance.zones.all().delete()
|
||||
for zone_data in zones_data:
|
||||
OcrTemplateZone.objects.create(template=instance, **zone_data)
|
||||
|
||||
return instance
|
||||
@@ -1404,19 +1404,6 @@ def close_connection_pool_on_worker_init(**kwargs) -> None:
|
||||
conn.close_pool()
|
||||
|
||||
|
||||
def run_zone_ocr_extraction(sender, document, original_file=None, **kwargs):
|
||||
"""Apply matching templates without failing consumption if extraction fails."""
|
||||
try:
|
||||
from documents.zone_ocr import run_zone_extraction
|
||||
|
||||
run_zone_extraction(document, Path(original_file) if original_file else None)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Zone OCR extraction failed for document %s",
|
||||
document.pk,
|
||||
)
|
||||
|
||||
|
||||
@worker_process_shutdown.connect
|
||||
def close_connection_pool_on_worker_shutdown(**kwargs) -> None: # pragma: no cover
|
||||
"""
|
||||
|
||||
@@ -47,7 +47,6 @@ from documents.models import CustomFieldInstance
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentBarcode
|
||||
from documents.models import DocumentType
|
||||
from documents.models import OcrTemplate
|
||||
from documents.models import PaperlessTask
|
||||
from documents.models import ShareLink
|
||||
from documents.models import ShareLinkBundle
|
||||
@@ -70,7 +69,6 @@ from documents.utils import compute_checksum
|
||||
from documents.utils import identity
|
||||
from documents.versioning import annotate_effective_content
|
||||
from documents.workflows.utils import get_workflows_for_trigger
|
||||
from documents.zone_ocr import run_zone_extraction
|
||||
from paperless.config import AIConfig
|
||||
from paperless.config import BarcodeConfig
|
||||
from paperless.config import RemoteOCRConfig
|
||||
@@ -370,31 +368,6 @@ def _read_barcodes_for_reprocess(document: Document) -> list[StoredBarcode] | No
|
||||
return None
|
||||
|
||||
|
||||
@shared_task
|
||||
def extract_zones(document_id: int, template_id: int | None = None) -> None:
|
||||
"""
|
||||
Run an OCR template against a stored document, or every template that
|
||||
matches it when no template is given.
|
||||
"""
|
||||
document = Document.objects.get(id=document_id)
|
||||
if template_id is None:
|
||||
run_zone_extraction(document, None)
|
||||
return
|
||||
|
||||
try:
|
||||
template = OcrTemplate.objects.prefetch_related(
|
||||
"zones",
|
||||
"zones__custom_field",
|
||||
).get(id=template_id)
|
||||
except OcrTemplate.DoesNotExist:
|
||||
logger.warning(
|
||||
"OCR template %s no longer exists, not running zone OCR",
|
||||
template_id,
|
||||
)
|
||||
return
|
||||
run_zone_extraction(document, None, templates=[template])
|
||||
|
||||
|
||||
@shared_task
|
||||
def update_document_content_maybe_archive_file(
|
||||
document_id,
|
||||
|
||||
@@ -12,7 +12,6 @@ from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import OcrTemplate
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
@@ -584,87 +583,6 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(args[0], [self.doc1.id])
|
||||
self.assertEqual(kwargs, {"remote_ocr": True})
|
||||
|
||||
@mock.patch("documents.views.bulk_edit.run_zone_ocr")
|
||||
def test_run_zone_ocr_documents_endpoint(self, m) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- API data to run zone OCR on a document
|
||||
WHEN:
|
||||
- API is called
|
||||
THEN:
|
||||
- run_zone_ocr is called with the document
|
||||
"""
|
||||
self.setup_mock(m, "run_zone_ocr")
|
||||
response = self.client.post(
|
||||
"/api/documents/run_zone_ocr/",
|
||||
json.dumps({"documents": [self.doc1.id]}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
m.assert_called_once()
|
||||
args, kwargs = m.call_args
|
||||
self.assertEqual(args[0], [self.doc1.id])
|
||||
self.assertEqual(kwargs, {})
|
||||
|
||||
@mock.patch("documents.views.bulk_edit.run_zone_ocr")
|
||||
def test_run_zone_ocr_documents_endpoint_with_template(self, m) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- API data to run a chosen OCR template on a document
|
||||
WHEN:
|
||||
- API is called
|
||||
THEN:
|
||||
- run_zone_ocr is called with the template
|
||||
"""
|
||||
self.setup_mock(m, "run_zone_ocr")
|
||||
template = OcrTemplate.objects.create(
|
||||
name="Bills",
|
||||
source_width=100,
|
||||
source_height=100,
|
||||
)
|
||||
response = self.client.post(
|
||||
"/api/documents/run_zone_ocr/",
|
||||
json.dumps({"documents": [self.doc1.id], "template": template.pk}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
m.assert_called_once()
|
||||
args, kwargs = m.call_args
|
||||
self.assertEqual(args[0], [self.doc1.id])
|
||||
self.assertEqual(kwargs, {"template": template})
|
||||
|
||||
@mock.patch("documents.views.bulk_edit.run_zone_ocr")
|
||||
def test_run_zone_ocr_documents_endpoint_rejects_private_template(
|
||||
self,
|
||||
m,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A user who can change the documents but not view the template
|
||||
WHEN:
|
||||
- API is called with that template
|
||||
THEN:
|
||||
- The whole request is refused
|
||||
"""
|
||||
user = UserFactory(username="editor")
|
||||
grant_global(user, "view_document", "change_document")
|
||||
template = OcrTemplate.objects.create(
|
||||
name="Private",
|
||||
owner=UserFactory(username="other"),
|
||||
source_width=100,
|
||||
source_height=100,
|
||||
)
|
||||
self.client.force_authenticate(user=user)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/documents/run_zone_ocr/",
|
||||
json.dumps({"documents": [self.doc1.id], "template": template.pk}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
m.assert_not_called()
|
||||
|
||||
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
|
||||
def test_api_set_storage_path(self, m) -> None:
|
||||
"""
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -21,13 +21,11 @@ from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import OcrTemplate
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.pdf_ops import PageSpec
|
||||
from documents.permissions import PermissionsSpec
|
||||
from documents.permissions import set_permissions_for_objects
|
||||
from documents.tasks import extract_zones
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.factories import DocumentFactory
|
||||
from paperless_testing.permissions import grant_object
|
||||
@@ -1897,111 +1895,6 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
||||
self.assertIn("Error removing password from document", cm.output[0])
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestBulkEditRunZoneOcr:
|
||||
@pytest.fixture
|
||||
def mock_task(self, mocker: MockerFixture) -> mock.MagicMock:
|
||||
return mocker.patch("documents.bulk_edit.extract_zones")
|
||||
|
||||
def test_queues_every_selected_document(
|
||||
self,
|
||||
mock_task: mock.MagicMock,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Selected documents
|
||||
WHEN:
|
||||
- run_zone_ocr is called with them
|
||||
THEN:
|
||||
- Each document is queued once, templates are matched when its
|
||||
task runs
|
||||
"""
|
||||
docs = DocumentFactory.create_batch(2)
|
||||
|
||||
result = bulk_edit.run_zone_ocr([doc.id for doc in docs])
|
||||
|
||||
assert result == "OK"
|
||||
assert [
|
||||
call.kwargs["kwargs"] for call in mock_task.apply_async.call_args_list
|
||||
] == [{"document_id": doc.id, "template_id": None} for doc in docs]
|
||||
|
||||
def test_queues_the_chosen_template(self, mock_task: mock.MagicMock) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A chosen OCR template
|
||||
WHEN:
|
||||
- run_zone_ocr is called with it
|
||||
THEN:
|
||||
- The template is queued with the document
|
||||
"""
|
||||
doc = DocumentFactory()
|
||||
template = OcrTemplate.objects.create(
|
||||
name="Bills",
|
||||
source_width=100,
|
||||
source_height=100,
|
||||
)
|
||||
|
||||
bulk_edit.run_zone_ocr([doc.id], template=template)
|
||||
|
||||
assert mock_task.apply_async.call_args.kwargs["kwargs"] == {
|
||||
"document_id": doc.id,
|
||||
"template_id": template.pk,
|
||||
}
|
||||
|
||||
def test_task_runs_extraction(self, mocker: MockerFixture) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A queued zone OCR task
|
||||
WHEN:
|
||||
- The task runs
|
||||
THEN:
|
||||
- Zone extraction runs on the stored document
|
||||
"""
|
||||
extract = mocker.patch("documents.tasks.run_zone_extraction")
|
||||
doc = DocumentFactory()
|
||||
|
||||
extract_zones(doc.id)
|
||||
|
||||
extract.assert_called_once_with(doc, None)
|
||||
|
||||
def test_task_runs_the_chosen_template(self, mocker: MockerFixture) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A queued zone OCR task with a chosen template
|
||||
WHEN:
|
||||
- The task runs
|
||||
THEN:
|
||||
- Zone extraction runs with only that template
|
||||
"""
|
||||
extract = mocker.patch("documents.tasks.run_zone_extraction")
|
||||
doc = DocumentFactory()
|
||||
template = OcrTemplate.objects.create(
|
||||
name="Bills",
|
||||
source_width=100,
|
||||
source_height=100,
|
||||
)
|
||||
|
||||
extract_zones(doc.id, template.pk)
|
||||
|
||||
extract.assert_called_once_with(doc, None, templates=[template])
|
||||
|
||||
def test_task_skips_a_deleted_template(self, mocker: MockerFixture) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A template deleted between queueing and running
|
||||
WHEN:
|
||||
- The task runs
|
||||
THEN:
|
||||
- Nothing is extracted
|
||||
"""
|
||||
extract = mocker.patch("documents.tasks.run_zone_extraction")
|
||||
doc = DocumentFactory()
|
||||
|
||||
extract_zones(doc.id, 999)
|
||||
|
||||
extract.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestBulkEditReprocess:
|
||||
@pytest.fixture
|
||||
|
||||
Loaded 100 of 153 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user