Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
551445773a | ||
|
|
c54cb4a84b | ||
|
|
af61044b63 | ||
|
|
1ef5a6d3cf | ||
|
|
4da311f91f | ||
|
|
cbb84649c7 | ||
|
|
8da734a495 | ||
|
|
351671011d | ||
|
|
15c95c251d | ||
|
|
310628699d | ||
|
|
aff0f9cf41 | ||
|
|
bf716ebfd1 | ||
|
|
7d67a10a35 | ||
|
|
8d1bc5dd24 | ||
|
|
43a8d7d412 | ||
|
|
c40922440b | ||
|
|
b989b74140 | ||
|
|
5194f47291 | ||
|
|
714885d7a5 | ||
|
|
73e777a48c | ||
|
|
e9141366bb | ||
|
|
7813375123 | ||
|
|
0132c7bd6e | ||
|
|
4d5897ec80 | ||
|
|
f197d09b3e | ||
|
|
937feb1bef | ||
|
|
f5ff18326d | ||
|
|
d52cc1b609 | ||
|
|
3f5f4f3ed4 | ||
|
|
9a47b20d2a | ||
|
|
05905287b3 | ||
|
|
d65de00ca1 | ||
|
|
f287a4cb8c | ||
|
|
a415d1bf74 | ||
|
|
4b1434f876 | ||
|
|
43fd109bc6 | ||
|
|
ec70e4f423 | ||
|
|
340118ad51 | ||
|
|
3899e0f0d6 | ||
|
|
d648526858 |
@@ -72,11 +72,9 @@ jobs:
|
||||
'You are welcome to open a new issue that describes the problem you observed in your own words.'
|
||||
: 'This issue was automatically closed because it was not opened using our bug report form. ' +
|
||||
'Issues have to be created through the form so that the details we need to investigate are included.\n\n' +
|
||||
`If the problem is still there, please [open a new issue](${newIssue}) using the form — that is all it takes ` +
|
||||
'to get it looked at, and no other action is needed here.\n\n' +
|
||||
`If the problem is still there, please [open a new issue](${newIssue}) using the form. No other action is needed here.\n\n` +
|
||||
'If any part of your report was written by an AI tool or agent, you must say so: undisclosed AI-generated ' +
|
||||
`contributions are a violation of our [Code of Conduct](${codeOfConduct}), and such reports must describe the ` +
|
||||
`behavior you observed only, without code analysis or suggested fixes. See our [contributing guidelines](${contributing}).`;
|
||||
`contributions are a violation of our [Code of Conduct](${codeOfConduct}).`;
|
||||
|
||||
await github.rest.issues.createComment({ ...common, body });
|
||||
await github.rest.issues.addLabels({ ...common, labels: ['ai'] });
|
||||
|
||||
@@ -25,6 +25,10 @@ jobs:
|
||||
pr-bot:
|
||||
name: Automated PR Bot
|
||||
runs-on: ubuntu-latest
|
||||
# Runs after Anti-slop so the welcome comment can see whether the PR was closed
|
||||
# instead of racing it. Still runs if that job fails, so labeling is not lost.
|
||||
needs: Anti-slop
|
||||
if: ${{ !cancelled() }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
@@ -99,8 +103,25 @@ jobs:
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
const user = pr.user.login;
|
||||
const user = context.payload.pull_request.user.login;
|
||||
|
||||
// Re-read the PR: Anti-slop may have closed and labeled it after the webhook
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.payload.pull_request.number,
|
||||
});
|
||||
|
||||
if (pr.state === 'closed') {
|
||||
core.info('Skipping comment: PR is already closed');
|
||||
return;
|
||||
}
|
||||
|
||||
const labels = pr.labels.map((label) => (typeof label === 'string' ? label : label.name));
|
||||
if (labels.includes('ai')) {
|
||||
core.info('Skipping comment: PR is labeled ai');
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: members } = await github.rest.orgs.listMembers({
|
||||
org: 'paperless-ngx',
|
||||
|
||||
@@ -138,7 +138,9 @@ for suggested generation and embedding models.
|
||||
With AI enabled, Paperless-ngx can suggest a title, tags, correspondent, document type,
|
||||
storage path and dates by sending the document to the LLM. This is **opt-in per request**
|
||||
and surfaces through the "Suggest" control on the document detail page, alongside the
|
||||
classic classifier-based suggestions — it does not disable them. Suggestion output
|
||||
classic classifier-based suggestions — it does not disable them. Suggestions are requested
|
||||
automatically when you open a document that carries an inbox tag unless "Automatically request
|
||||
suggestions for inbox documents" under Settings > Documents is disabled. Suggestion output
|
||||
language can be steered with
|
||||
[`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE`](configuration.md#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE)
|
||||
(otherwise it follows the user's UI language).
|
||||
|
||||
@@ -317,6 +317,8 @@ a "document already exists" message.
|
||||
|
||||
Paperless-ngx can suggest tags, correspondents, document types and storage paths for documents based on the content of the document. This is done using a (non-LLM) machine learning model that is trained on the documents in your database. The suggestions are shown in the document detail page and can be accepted or rejected by the user.
|
||||
|
||||
Suggestions are requested automatically when you open a document that still has an inbox tag. To only request them by pressing the "Suggest" button instead, turn off "Automatically request suggestions for inbox documents" under Settings > Documents.
|
||||
|
||||
## AI Features
|
||||
|
||||
Paperless-ngx includes several features that use AI to enhance the document management experience. These features are optional and can be enabled or disabled in the settings. If you are using the AI features, you may want to also enable the "LLM index" feature, which supports Retrieval-Augmented Generation (RAG) designed to improve the quality of AI responses. The LLM index feature is not enabled by default and requires additional configuration.
|
||||
@@ -684,7 +686,8 @@ It requires [AI features](configuration.md#ai) to be enabled. You can specify:
|
||||
never replace the document's existing tags.
|
||||
|
||||
The action works with every trigger **except Consumption Started**, because suggestions are made from
|
||||
the document's text, which does not exist until after the document has been processed.
|
||||
the document's text, which does not exist until after the document has been processed. Documents whose
|
||||
processed text is empty or contains only whitespace are skipped.
|
||||
|
||||
Because the query to the AI service is slow, the action is queued and runs in the background rather
|
||||
than as part of the workflow run itself. The document is updated once the suggestions come back.
|
||||
|
||||
@@ -32,21 +32,21 @@ dependencies = [
|
||||
"django-cors-headers~=4.9.0",
|
||||
"django-extensions~=4.1",
|
||||
"django-filter~=25.1",
|
||||
"django-guardian~=3.3.3",
|
||||
"django-guardian>=3.3.3,<3.5",
|
||||
"django-multiselectfield~=1.0.1",
|
||||
"django-rich~=2.2.0",
|
||||
"django-soft-delete~=1.0.18",
|
||||
"django-treenode>=0.24",
|
||||
"djangorestframework~=3.16",
|
||||
"drf-spectacular~=0.30",
|
||||
"drf-spectacular-sidecar~=2026.7.1",
|
||||
"drf-spectacular-sidecar>=2026.7.1,<2026.9",
|
||||
"drf-writable-nested~=0.7.1",
|
||||
"filelock~=3.32.0",
|
||||
"flower>=2.0.1,<2.2",
|
||||
"gotenberg-client[httpx]~=1.0",
|
||||
"httpx-oauth~=0.17",
|
||||
"ijson>=3.5.1",
|
||||
"imap-tools~=1.14.0",
|
||||
"imap-tools>=1.14,<1.16",
|
||||
"jinja2~=3.1.6",
|
||||
"langdetect~=1.0.9",
|
||||
"llama-index-core>=0.14.23",
|
||||
@@ -56,7 +56,7 @@ dependencies = [
|
||||
"llama-index-llms-ollama>=0.9.1",
|
||||
"llama-index-llms-openai-like>=0.7.1",
|
||||
"nltk~=3.10.0",
|
||||
"ocrmypdf>=17.7,<17.11",
|
||||
"ocrmypdf>=17.7,<17.12",
|
||||
"openai>=2.48",
|
||||
"pathvalidate~=3.3.1",
|
||||
"pdf2image~=1.17.0",
|
||||
@@ -103,17 +103,17 @@ docs = [
|
||||
"zensical>=0.0.51",
|
||||
]
|
||||
lint = [
|
||||
"prek~=0.4.11",
|
||||
"prek>=0.4.11,<0.6",
|
||||
"ruff~=0.16.1",
|
||||
]
|
||||
testing = [
|
||||
"daphne",
|
||||
"factory-boy~=3.3.1",
|
||||
"faker~=40.36.0",
|
||||
"faker>=40.36,<40.38",
|
||||
"imagehash",
|
||||
"pytest~=9.1.1",
|
||||
"pytest-cov~=7.1.0",
|
||||
"pytest-django~=4.12.0",
|
||||
"pytest-django>=4.12,<4.15",
|
||||
"pytest-env~=1.7.0",
|
||||
"pytest-httpx",
|
||||
"pytest-mock~=3.15.1",
|
||||
|
||||
@@ -71,8 +71,10 @@
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"localize": true,
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/apple-touch-icon.png",
|
||||
"src/icon-192.png",
|
||||
"src/icon-512.png",
|
||||
"src/icon-512-maskable.png",
|
||||
"src/assets",
|
||||
"src/manifest.webmanifest",
|
||||
{
|
||||
|
||||
@@ -13,8 +13,9 @@ import { DocumentDetailComponent } from './components/document-detail/document-d
|
||||
import { DocumentListComponent } from './components/document-list/document-list.component'
|
||||
import { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.component'
|
||||
import { MailComponent } from './components/manage/mail/mail.component'
|
||||
import { OcrTemplateEditorComponent } from './components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component'
|
||||
import { OcrTemplatesComponent } from './components/manage/ocr-templates/ocr-templates.component'
|
||||
import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component'
|
||||
import { ShareLinksComponent } from './components/manage/share-links/share-links.component'
|
||||
import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
|
||||
import { NotFoundComponent } from './components/not-found/not-found.component'
|
||||
import { DirtyDocGuard } from './guards/dirty-doc.guard'
|
||||
@@ -275,6 +276,42 @@ export const routes: Routes = [
|
||||
componentName: 'WorkflowsComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'ocr-templates',
|
||||
component: OcrTemplatesComponent,
|
||||
canActivate: [PermissionsGuard],
|
||||
data: {
|
||||
requiredPermission: {
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.OcrTemplate,
|
||||
},
|
||||
componentName: 'OcrTemplatesComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'ocr-templates/new',
|
||||
component: OcrTemplateEditorComponent,
|
||||
canActivate: [PermissionsGuard],
|
||||
data: {
|
||||
requiredPermission: {
|
||||
action: PermissionAction.Add,
|
||||
type: PermissionType.OcrTemplate,
|
||||
},
|
||||
componentName: 'OcrTemplateEditorComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'ocr-templates/:id',
|
||||
component: OcrTemplateEditorComponent,
|
||||
canActivate: [PermissionsGuard],
|
||||
data: {
|
||||
requiredPermission: {
|
||||
action: PermissionAction.Change,
|
||||
type: PermissionType.OcrTemplate,
|
||||
},
|
||||
componentName: 'OcrTemplateEditorComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'mail',
|
||||
component: MailComponent,
|
||||
@@ -311,24 +348,6 @@ export const routes: Routes = [
|
||||
componentName: 'SavedViewsComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'share-links',
|
||||
component: ShareLinksComponent,
|
||||
canActivate: [PermissionsGuard],
|
||||
data: {
|
||||
requiredPermissionAny: [
|
||||
{
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.ShareLink,
|
||||
},
|
||||
{
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.ShareLinkBundle,
|
||||
},
|
||||
],
|
||||
componentName: 'ShareLinksComponent',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -23,17 +23,30 @@
|
||||
<div class="col">
|
||||
<div class="card bg-light">
|
||||
<div class="card-body">
|
||||
<div class="card-title d-flex align-items-center">
|
||||
<div class="card-title d-flex align-items-center flex-wrap">
|
||||
<h6 class="mb-0">
|
||||
{{option.title}}
|
||||
</h6>
|
||||
<a class="btn btn-sm btn-link" title="Read the documentation about this setting" i18n-title [href]="getDocsUrl(option.config_key)" target="_blank" referrerpolicy="no-referrer">
|
||||
<i-bs name="info-circle"></i-bs>
|
||||
</a>
|
||||
@if (isExternallyConfigured(option.config_key)) {
|
||||
@if (isSet(option.key)) {
|
||||
<span class="badge rounded-pill bg-body-secondary text-dark fw-normal" title="This value overrides {{option.config_key}}, which is set outside Paperless." i18n-title>Overrides external</span>
|
||||
} @else {
|
||||
<span class="badge rounded-pill bg-body-secondary text-dark fw-normal" title="{{option.config_key}} is set outside Paperless. Enter a value here to override it." i18n-title>Set externally</span>
|
||||
}
|
||||
}
|
||||
@if (isSet(option.key)) {
|
||||
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)">
|
||||
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
|
||||
</button>
|
||||
@if (isExternallyConfigured(option.config_key)) {
|
||||
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Use the externally configured value" i18n-title (click)="resetOption(option.key)">
|
||||
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset to external</ng-container>
|
||||
</button>
|
||||
} @else {
|
||||
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)">
|
||||
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="mb-n3">
|
||||
|
||||
@@ -163,6 +163,19 @@ describe('ConfigComponent', () => {
|
||||
expect(component.configForm.get('barcodes_enabled').value).toBeNull()
|
||||
})
|
||||
|
||||
it('should identify externally configured options', () => {
|
||||
component.externallyConfiguredVariables = new Set([
|
||||
'PAPERLESS_OCR_LANGUAGE',
|
||||
])
|
||||
|
||||
expect(
|
||||
component.isExternallyConfigured('PAPERLESS_OCR_LANGUAGE')
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
component.isExternallyConfigured('PAPERLESS_OCR_OUTPUT_TYPE')
|
||||
).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should group options into sections within a category, or not', () => {
|
||||
const sections = component.getCategorySections(ConfigCategory.OCR)
|
||||
expect(sections).toEqual([null, ConfigSection.RemoteOCR])
|
||||
|
||||
@@ -69,6 +69,7 @@ export class ConfigComponent
|
||||
public configForm = new FormGroup({})
|
||||
|
||||
public errors = {}
|
||||
public externallyConfiguredVariables = new Set<string>()
|
||||
|
||||
get optionCategories(): string[] {
|
||||
return Object.values(ConfigCategory)
|
||||
@@ -152,6 +153,9 @@ export class ConfigComponent
|
||||
}
|
||||
|
||||
private initialize(config: PaperlessConfig) {
|
||||
this.externallyConfiguredVariables = new Set(
|
||||
config.externally_configured_variables ?? []
|
||||
)
|
||||
if (!this.store) {
|
||||
this.store = new BehaviorSubject(config)
|
||||
|
||||
@@ -162,7 +166,9 @@ export class ConfigComponent
|
||||
this.configForm.patchValue(state, { emitEvent: false })
|
||||
})
|
||||
|
||||
this.isDirty$ = dirtyCheck(this.configForm, this.store.asObservable())
|
||||
this.isDirty$ = dirtyCheck(this.configForm, this.store.asObservable(), {
|
||||
excludeKeys: ['externally_configured_variables'],
|
||||
})
|
||||
}
|
||||
this.configForm.patchValue(config)
|
||||
|
||||
@@ -227,6 +233,10 @@ export class ConfigComponent
|
||||
return this.configForm.get(key).value != null
|
||||
}
|
||||
|
||||
public isExternallyConfigured(configKey: string): boolean {
|
||||
return this.externallyConfiguredVariables.has(configKey)
|
||||
}
|
||||
|
||||
public resetOption(key: string) {
|
||||
this.configForm.get(key).setValue(null)
|
||||
}
|
||||
|
||||
@@ -237,6 +237,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<pngx-input-check i18n-title title="Automatically request suggestions for inbox documents" i18n-hint hint="If un-checked, suggestions must be requested via the Suggest button." formControlName="documentEditingAutoSuggest"></pngx-input-check>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<pngx-input-check i18n-title title="Show document thumbnail during loading" formControlName="documentEditingOverlayThumbnail"></pngx-input-check>
|
||||
|
||||
@@ -267,7 +267,7 @@ describe('SettingsComponent', () => {
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(storeSpy).toHaveBeenCalled()
|
||||
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
||||
expect(setSpy).toHaveBeenCalledTimes(32)
|
||||
expect(setSpy).toHaveBeenCalledTimes(33)
|
||||
|
||||
// succeed
|
||||
storeSpy.mockReturnValueOnce(of(true))
|
||||
|
||||
@@ -168,6 +168,7 @@ export class SettingsComponent
|
||||
pdfEditorDefaultEditMode: new FormControl(null),
|
||||
documentEditingRemoveInboxTags: new FormControl(null),
|
||||
documentEditingOverlayThumbnail: new FormControl(null),
|
||||
documentEditingAutoSuggest: new FormControl(null),
|
||||
documentDetailsHiddenFields: new FormControl([]),
|
||||
searchDbOnly: new FormControl(null),
|
||||
searchLink: new FormControl(null),
|
||||
@@ -368,6 +369,9 @@ export class SettingsComponent
|
||||
documentEditingOverlayThumbnail: this.settings.get(
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL
|
||||
),
|
||||
documentEditingAutoSuggest: this.settings.get(
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST
|
||||
),
|
||||
documentDetailsHiddenFields: this.settings.get(
|
||||
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS
|
||||
),
|
||||
@@ -565,6 +569,10 @@ export class SettingsComponent
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL,
|
||||
this.settingsForm.value.documentEditingOverlayThumbnail
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST,
|
||||
this.settingsForm.value.documentEditingAutoSuggest
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS,
|
||||
this.settingsForm.value.documentDetailsHiddenFields
|
||||
|
||||
@@ -244,15 +244,6 @@
|
||||
<i-bs class="me-2" name="window-stack"></i-bs><span class="nav-link-label"><ng-container i18n>Saved Views</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
@if (canManageShareLinks) {
|
||||
<li class="nav-item app-link">
|
||||
<a class="nav-link" routerLink="share-links" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Share links" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="link"></i-bs><span class="nav-link-label"><ng-container i18n>Share links</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
<li class="nav-item app-link"
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
|
||||
tourAnchor="tour.workflows">
|
||||
@@ -262,6 +253,14 @@
|
||||
<i-bs class="me-2" name="boxes"></i-bs><span class="nav-link-label"><ng-container i18n>Workflows</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link"
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.OcrTemplate }">
|
||||
<a class="nav-link" routerLink="ocr-templates" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="OCR Templates" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="file-earmark-break"></i-bs><span><ng-container i18n>OCR Templates</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
||||
tourAnchor="tour.mail">
|
||||
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
|
||||
|
||||
@@ -221,19 +221,6 @@ export class AppFrameComponent
|
||||
return this.appTitleSetting() || environment.appTitle
|
||||
}
|
||||
|
||||
get canManageShareLinks(): boolean {
|
||||
return (
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.ShareLink
|
||||
) ||
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.ShareLinkBundle
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
get customAppTitle(): string {
|
||||
return this.appTitleSetting()
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { of } from 'rxjs'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import {
|
||||
MailAction,
|
||||
MailMetadataCorrespondentOption,
|
||||
@@ -15,6 +15,7 @@ import { CorrespondentService } from 'src/app/services/rest/correspondent.servic
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||
import { MailAccountService } from 'src/app/services/rest/mail-account.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { CheckComponent } from '../../input/check/check.component'
|
||||
import { NumberComponent } from '../../input/number/number.component'
|
||||
import { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component'
|
||||
@@ -81,6 +82,41 @@ describe('MailRuleEditDialogComponent', () => {
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should use empty related object lists when retrieval fails', () => {
|
||||
const failed = () => throwError(() => new Error('Forbidden'))
|
||||
const toastSpy = jest.spyOn(TestBed.inject(ToastService), 'showError')
|
||||
jest
|
||||
.spyOn(TestBed.inject(MailAccountService), 'listAll')
|
||||
.mockReturnValue(failed())
|
||||
jest
|
||||
.spyOn(TestBed.inject(CorrespondentService), 'listAll')
|
||||
.mockReturnValue(failed())
|
||||
jest
|
||||
.spyOn(TestBed.inject(DocumentTypeService), 'listAll')
|
||||
.mockReturnValue(failed())
|
||||
|
||||
const failedFixture = TestBed.createComponent(MailRuleEditDialogComponent)
|
||||
const failedComponent = failedFixture.componentInstance
|
||||
|
||||
expect(failedComponent.accounts()).toEqual([])
|
||||
expect(failedComponent.correspondents()).toEqual([])
|
||||
expect(failedComponent.documentTypes()).toEqual([])
|
||||
expect(() => failedFixture.detectChanges()).not.toThrow()
|
||||
expect(toastSpy).toHaveBeenCalledTimes(3)
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
'Error retrieving mail accounts',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
'Error retrieving correspondents',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
'Error retrieving document types',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('should support create and edit modes', () => {
|
||||
component.dialogMode.set(EditDialogMode.CREATE)
|
||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { map } from 'rxjs'
|
||||
import { catchError, map, of } from 'rxjs'
|
||||
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
||||
import { Correspondent } from 'src/app/data/correspondent'
|
||||
import { DocumentType } from 'src/app/data/document-type'
|
||||
@@ -26,6 +26,7 @@ import { MailAccountService } from 'src/app/services/rest/mail-account.service'
|
||||
import { MailRuleService } from 'src/app/services/rest/mail-rule.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 { CheckComponent } from '../../input/check/check.component'
|
||||
import { NumberComponent } from '../../input/number/number.component'
|
||||
import { SelectComponent } from '../../input/select/select.component'
|
||||
@@ -158,17 +159,45 @@ export class MailRuleEditDialogComponent extends EditDialogComponent<MailRule> {
|
||||
private readonly accountService = inject(MailAccountService)
|
||||
private readonly correspondentService = inject(CorrespondentService)
|
||||
private readonly documentTypeService = inject(DocumentTypeService)
|
||||
private readonly toastService = inject(ToastService)
|
||||
|
||||
readonly accounts = toSignal(
|
||||
this.accountService.listAll().pipe(map((result) => result.results)),
|
||||
this.accountService.listAll().pipe(
|
||||
map((result) => result.results),
|
||||
catchError((error) => {
|
||||
this.toastService.showError(
|
||||
$localize`Error retrieving mail accounts`,
|
||||
error
|
||||
)
|
||||
return of([])
|
||||
})
|
||||
),
|
||||
{ initialValue: undefined as MailAccount[] }
|
||||
)
|
||||
readonly correspondents = toSignal(
|
||||
this.correspondentService.listAll().pipe(map((result) => result.results)),
|
||||
this.correspondentService.listAll().pipe(
|
||||
map((result) => result.results),
|
||||
catchError((error) => {
|
||||
this.toastService.showError(
|
||||
$localize`Error retrieving correspondents`,
|
||||
error
|
||||
)
|
||||
return of([])
|
||||
})
|
||||
),
|
||||
{ initialValue: undefined as Correspondent[] }
|
||||
)
|
||||
readonly documentTypes = toSignal(
|
||||
this.documentTypeService.listAll().pipe(map((result) => result.results)),
|
||||
this.documentTypeService.listAll().pipe(
|
||||
map((result) => result.results),
|
||||
catchError((error) => {
|
||||
this.toastService.showError(
|
||||
$localize`Error retrieving document types`,
|
||||
error
|
||||
)
|
||||
return of([])
|
||||
})
|
||||
),
|
||||
{ initialValue: undefined as DocumentType[] }
|
||||
)
|
||||
|
||||
|
||||
@@ -81,6 +81,23 @@ describe('UserEditDialogComponent', () => {
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should use an empty group list when retrieval fails', () => {
|
||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||
jest
|
||||
.spyOn(TestBed.inject(GroupService), 'listAll')
|
||||
.mockReturnValue(throwError(() => new Error('Forbidden')))
|
||||
|
||||
const failedFixture = TestBed.createComponent(UserEditDialogComponent)
|
||||
const failedComponent = failedFixture.componentInstance
|
||||
|
||||
expect(failedComponent.groups()).toEqual([])
|
||||
expect(() => failedFixture.detectChanges()).not.toThrow()
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
'Error retrieving groups',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('should support create and edit modes', () => {
|
||||
component.dialogMode.set(EditDialogMode.CREATE)
|
||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { first, map } from 'rxjs'
|
||||
import { catchError, first, map, of } from 'rxjs'
|
||||
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
||||
import { Group } from 'src/app/data/group'
|
||||
import { User } from 'src/app/data/user'
|
||||
@@ -42,7 +42,13 @@ export class UserEditDialogComponent
|
||||
private readonly groupsService = inject(GroupService)
|
||||
|
||||
readonly groups = toSignal(
|
||||
this.groupsService.listAll().pipe(map((result) => result.results)),
|
||||
this.groupsService.listAll().pipe(
|
||||
map((result) => result.results),
|
||||
catchError((error) => {
|
||||
this.toastService.showError($localize`Error retrieving groups`, error)
|
||||
return of([])
|
||||
})
|
||||
),
|
||||
{ initialValue: undefined as Group[] }
|
||||
)
|
||||
readonly passwordIsSet = signal(false)
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from '@angular/forms'
|
||||
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { of } from 'rxjs'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { CustomFieldQueriesModel } from 'src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component'
|
||||
import { CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
import { CustomFieldQueryLogicalOperator } from 'src/app/data/custom-field-query'
|
||||
@@ -39,6 +39,7 @@ import { DocumentTypeService } from 'src/app/services/rest/document-type.service
|
||||
import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
|
||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { CustomFieldQueryExpression } from 'src/app/utils/custom-field-query-element'
|
||||
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
|
||||
import { NumberComponent } from '../../input/number/number.component'
|
||||
@@ -206,6 +207,44 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
settingsService.set(SETTINGS_KEYS.AI_ENABLED, ai)
|
||||
}
|
||||
|
||||
it('should use empty related object lists when access is forbidden', () => {
|
||||
const forbidden = () => throwError(() => new Error('Forbidden'))
|
||||
const toastSpy = jest.spyOn(TestBed.inject(ToastService), 'showError')
|
||||
jest
|
||||
.spyOn(TestBed.inject(CorrespondentService), 'listAll')
|
||||
.mockReturnValue(forbidden())
|
||||
jest
|
||||
.spyOn(TestBed.inject(DocumentTypeService), 'listAll')
|
||||
.mockReturnValue(forbidden())
|
||||
jest
|
||||
.spyOn(TestBed.inject(StoragePathService), 'listAll')
|
||||
.mockReturnValue(forbidden())
|
||||
jest
|
||||
.spyOn(TestBed.inject(MailRuleService), 'listAll')
|
||||
.mockReturnValue(forbidden())
|
||||
jest
|
||||
.spyOn(TestBed.inject(CustomFieldsService), 'listAll')
|
||||
.mockReturnValue(forbidden())
|
||||
|
||||
const forbiddenFixture = TestBed.createComponent(
|
||||
WorkflowEditDialogComponent
|
||||
)
|
||||
const forbiddenComponent = forbiddenFixture.componentInstance
|
||||
|
||||
expect(forbiddenComponent.correspondents()).toEqual([])
|
||||
expect(forbiddenComponent.documentTypes()).toEqual([])
|
||||
expect(forbiddenComponent.storagePaths()).toEqual([])
|
||||
expect(forbiddenComponent.mailRules()).toEqual([])
|
||||
expect(forbiddenComponent.customFields()).toEqual([])
|
||||
expect(forbiddenComponent.dateCustomFields()).toEqual([])
|
||||
expect(() => forbiddenFixture.detectChanges()).not.toThrow()
|
||||
expect(toastSpy).toHaveBeenCalledTimes(1)
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
'Some workflow options could not be loaded.',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('should support create and edit modes, support adding triggers and actions on new workflow', () => {
|
||||
component.dialogMode.set(EditDialogMode.CREATE)
|
||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from '@angular/forms'
|
||||
import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { Subscription, map, takeUntil } from 'rxjs'
|
||||
import { Subscription, catchError, map, of, takeUntil } from 'rxjs'
|
||||
import { Correspondent } from 'src/app/data/correspondent'
|
||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
import { DocumentType } from 'src/app/data/document-type'
|
||||
@@ -48,6 +48,7 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { WorkflowService } from 'src/app/services/rest/workflow.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { CustomFieldQueryExpression } from 'src/app/utils/custom-field-query-element'
|
||||
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
|
||||
import {
|
||||
@@ -512,26 +513,43 @@ export class WorkflowEditDialogComponent
|
||||
private readonly storagePathService = inject(StoragePathService)
|
||||
private readonly mailRuleService = inject(MailRuleService)
|
||||
private readonly customFieldsService = inject(CustomFieldsService)
|
||||
private readonly toastService = inject(ToastService)
|
||||
private relatedObjectLoadErrorShown = false
|
||||
|
||||
readonly templates = signal<Workflow[]>(undefined)
|
||||
readonly correspondents = toSignal(
|
||||
this.correspondentService.listAll().pipe(map((result) => result.results)),
|
||||
this.correspondentService.listAll().pipe(
|
||||
map((result) => result.results),
|
||||
catchError((error) => this.handleRelatedObjectLoadError(error))
|
||||
),
|
||||
{ initialValue: undefined as Correspondent[] }
|
||||
)
|
||||
readonly documentTypes = toSignal(
|
||||
this.documentTypeService.listAll().pipe(map((result) => result.results)),
|
||||
this.documentTypeService.listAll().pipe(
|
||||
map((result) => result.results),
|
||||
catchError((error) => this.handleRelatedObjectLoadError(error))
|
||||
),
|
||||
{ initialValue: undefined as DocumentType[] }
|
||||
)
|
||||
readonly storagePaths = toSignal(
|
||||
this.storagePathService.listAll().pipe(map((result) => result.results)),
|
||||
this.storagePathService.listAll().pipe(
|
||||
map((result) => result.results),
|
||||
catchError((error) => this.handleRelatedObjectLoadError(error))
|
||||
),
|
||||
{ initialValue: undefined as StoragePath[] }
|
||||
)
|
||||
readonly mailRules = toSignal(
|
||||
this.mailRuleService.listAll().pipe(map((result) => result.results)),
|
||||
this.mailRuleService.listAll().pipe(
|
||||
map((result) => result.results),
|
||||
catchError((error) => this.handleRelatedObjectLoadError(error))
|
||||
),
|
||||
{ initialValue: undefined as MailRule[] }
|
||||
)
|
||||
readonly customFields = toSignal(
|
||||
this.customFieldsService.listAll().pipe(map((result) => result.results)),
|
||||
this.customFieldsService.listAll().pipe(
|
||||
map((result) => result.results),
|
||||
catchError((error) => this.handleRelatedObjectLoadError(error))
|
||||
),
|
||||
{ initialValue: undefined as CustomField[] }
|
||||
)
|
||||
readonly dateCustomFields = computed(() =>
|
||||
@@ -545,6 +563,17 @@ export class WorkflowEditDialogComponent
|
||||
SETTINGS_KEYS.AI_ENABLED
|
||||
)
|
||||
|
||||
private handleRelatedObjectLoadError(error) {
|
||||
if (!this.relatedObjectLoadErrorShown) {
|
||||
this.relatedObjectLoadErrorShown = true
|
||||
this.toastService.showError(
|
||||
$localize`Some workflow options could not be loaded.`,
|
||||
error
|
||||
)
|
||||
}
|
||||
return of([])
|
||||
}
|
||||
|
||||
expandedItem: number = null
|
||||
|
||||
private readonly triggerFilterOptionsMap = new WeakMap<
|
||||
|
||||
@@ -7,8 +7,9 @@ import {
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { of } from 'rxjs'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { GroupService } from 'src/app/services/rest/group.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { PermissionsGroupComponent } from './permissions-group.component'
|
||||
|
||||
describe('PermissionsGroupComponent', () => {
|
||||
@@ -60,4 +61,19 @@ describe('PermissionsGroupComponent', () => {
|
||||
expect(component.value).toEqual({ id: 2, name: 'Group 2' })
|
||||
expect(groupServiceSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use an empty group list when retrieval fails', () => {
|
||||
const toastSpy = jest.spyOn(TestBed.inject(ToastService), 'showError')
|
||||
groupServiceSpy.mockReturnValue(throwError(() => new Error('Forbidden')))
|
||||
|
||||
const failedFixture = TestBed.createComponent(PermissionsGroupComponent)
|
||||
const failedComponent = failedFixture.componentInstance
|
||||
|
||||
expect(failedComponent.groups()).toEqual([])
|
||||
expect(() => failedFixture.detectChanges()).not.toThrow()
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
'Error retrieving groups',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,9 +6,10 @@ import {
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { NgSelectComponent } from '@ng-select/ng-select'
|
||||
import { map } from 'rxjs/operators'
|
||||
import { catchError, map, of } from 'rxjs'
|
||||
import { Group } from 'src/app/data/group'
|
||||
import { GroupService } from 'src/app/services/rest/group.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { AbstractInputComponent } from '../../abstract-input'
|
||||
|
||||
@Component({
|
||||
@@ -26,8 +27,15 @@ import { AbstractInputComponent } from '../../abstract-input'
|
||||
})
|
||||
export class PermissionsGroupComponent extends AbstractInputComponent<Group> {
|
||||
private readonly groupService = inject(GroupService)
|
||||
private readonly toastService = inject(ToastService)
|
||||
readonly groups = toSignal(
|
||||
this.groupService.listAll().pipe(map((result) => result.results)),
|
||||
this.groupService.listAll().pipe(
|
||||
map((result) => result.results),
|
||||
catchError((error) => {
|
||||
this.toastService.showError($localize`Error retrieving groups`, error)
|
||||
return of([])
|
||||
})
|
||||
),
|
||||
{ initialValue: undefined as Group[] }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,8 +7,9 @@ import {
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { of } from 'rxjs'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { PermissionsUserComponent } from './permissions-user.component'
|
||||
|
||||
describe('PermissionsUserComponent', () => {
|
||||
@@ -60,4 +61,19 @@ describe('PermissionsUserComponent', () => {
|
||||
expect(component.value).toEqual({ id: 2, name: 'User 2' })
|
||||
expect(userServiceSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use an empty user list when retrieval fails', () => {
|
||||
const toastSpy = jest.spyOn(TestBed.inject(ToastService), 'showError')
|
||||
userServiceSpy.mockReturnValue(throwError(() => new Error('Forbidden')))
|
||||
|
||||
const failedFixture = TestBed.createComponent(PermissionsUserComponent)
|
||||
const failedComponent = failedFixture.componentInstance
|
||||
|
||||
expect(failedComponent.users()).toEqual([])
|
||||
expect(() => failedFixture.detectChanges()).not.toThrow()
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
'Error retrieving users',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,9 +6,10 @@ import {
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { NgSelectComponent } from '@ng-select/ng-select'
|
||||
import { map } from 'rxjs/operators'
|
||||
import { catchError, map, of } from 'rxjs'
|
||||
import { User } from 'src/app/data/user'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { AbstractInputComponent } from '../../abstract-input'
|
||||
|
||||
@Component({
|
||||
@@ -26,8 +27,15 @@ import { AbstractInputComponent } from '../../abstract-input'
|
||||
})
|
||||
export class PermissionsUserComponent extends AbstractInputComponent<User[]> {
|
||||
private readonly userService = inject(UserService)
|
||||
private readonly toastService = inject(ToastService)
|
||||
readonly users = toSignal(
|
||||
this.userService.listAll().pipe(map((result) => result.results)),
|
||||
this.userService.listAll().pipe(
|
||||
map((result) => result.results),
|
||||
catchError((error) => {
|
||||
this.toastService.showError($localize`Error retrieving users`, error)
|
||||
return of([])
|
||||
})
|
||||
),
|
||||
{ initialValue: undefined as User[] }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { of } from 'rxjs'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { PermissionsFormComponent } from '../input/permissions/permissions-form/permissions-form.component'
|
||||
import { PermissionsGroupComponent } from '../input/permissions/permissions-group/permissions-group.component'
|
||||
import { PermissionsUserComponent } from '../input/permissions/permissions-user/permissions-user.component'
|
||||
@@ -77,6 +78,23 @@ describe('PermissionsDialogComponent', () => {
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should use an empty user list when retrieval fails', () => {
|
||||
const toastSpy = jest.spyOn(TestBed.inject(ToastService), 'showError')
|
||||
jest
|
||||
.spyOn(TestBed.inject(UserService), 'listAll')
|
||||
.mockReturnValue(throwError(() => new Error('Forbidden')))
|
||||
|
||||
const failedFixture = TestBed.createComponent(PermissionsDialogComponent)
|
||||
const failedComponent = failedFixture.componentInstance
|
||||
|
||||
expect(failedComponent.users()).toEqual([])
|
||||
expect(() => failedFixture.detectChanges()).not.toThrow()
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
'Error retrieving users',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('should return permissions', () => {
|
||||
expect(component.permissions).toEqual({
|
||||
owner: null,
|
||||
|
||||
@@ -14,10 +14,11 @@ import {
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { map } from 'rxjs'
|
||||
import { catchError, map, of } from 'rxjs'
|
||||
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
||||
import { User } from 'src/app/data/user'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { PermissionsFormComponent } from '../input/permissions/permissions-form/permissions-form.component'
|
||||
import { SwitchComponent } from '../input/switch/switch.component'
|
||||
|
||||
@@ -35,9 +36,16 @@ import { SwitchComponent } from '../input/switch/switch.component'
|
||||
export class PermissionsDialogComponent {
|
||||
activeModal = inject(NgbActiveModal)
|
||||
private userService = inject(UserService)
|
||||
private toastService = inject(ToastService)
|
||||
|
||||
readonly users = toSignal(
|
||||
this.userService.listAll().pipe(map((r) => r.results)),
|
||||
this.userService.listAll().pipe(
|
||||
map((r) => r.results),
|
||||
catchError((error) => {
|
||||
this.toastService.showError($localize`Error retrieving users`, error)
|
||||
return of([])
|
||||
})
|
||||
),
|
||||
{ initialValue: undefined as User[] }
|
||||
)
|
||||
readonly title = signal($localize`Set permissions`)
|
||||
|
||||
@@ -1,22 +1,38 @@
|
||||
<div class="border border-top-0 rounded-bottom p-3">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">{{ title }}</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
@if (loading()) {
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
<span i18n>Loading share link bundles…</span>
|
||||
</div>
|
||||
}
|
||||
@if (!loading() && error()) {
|
||||
<div class="alert alert-danger mb-0" role="alert">
|
||||
{{ error() }}
|
||||
</div>
|
||||
}
|
||||
@if (!loading() && !error()) {
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<p class="mb-0 text-muted small">
|
||||
<ng-container i18n>Status updates every few seconds while bundles are being prepared.</ng-container>
|
||||
</p>
|
||||
</div>
|
||||
@if (bundles().length === 0) {
|
||||
<p class="mb-0 text-muted fst-italic" i18n>No share link bundles currently exist.</p>
|
||||
}
|
||||
@if (bundles().length > 0) {
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-0 bg-body">
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="fw-normal" pngxSortable="created" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Created</th>
|
||||
<th scope="col" class="fw-normal" pngxSortable="status" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Status</th>
|
||||
<th scope="col" i18n>Created</th>
|
||||
<th scope="col" i18n>Status</th>
|
||||
<th scope="col" i18n>Size</th>
|
||||
<th scope="col" class="fw-normal" pngxSortable="expiration" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Expires</th>
|
||||
<th scope="col" i18n>Expires</th>
|
||||
<th scope="col" i18n>Documents</th>
|
||||
<th scope="col" i18n>File version</th>
|
||||
<th scope="col" class="text-end" i18n>Actions</th>
|
||||
@@ -80,9 +96,6 @@
|
||||
<td>
|
||||
@if (bundle.expiration) {
|
||||
{{ bundle.expiration | date: 'short' }}
|
||||
@if (isExpired(bundle.expiration)) {
|
||||
<span class="badge text-bg-danger ms-2" i18n>Expired</span>
|
||||
}
|
||||
}
|
||||
@if (!bundle.expiration) {
|
||||
<span i18n>Never</span>
|
||||
@@ -91,49 +104,42 @@
|
||||
<td>{{ bundle.document_count }}</td>
|
||||
<td>{{ fileVersionLabel(bundle.file_version) }}</td>
|
||||
<td class="text-end">
|
||||
<div class="d-inline-block position-relative">
|
||||
<span
|
||||
class="badge bg-primary small fade position-absolute top-50 end-100 translate-middle-y me-2 pe-none z-3 text-nowrap"
|
||||
[class.show]="copiedSlug() === bundle.slug"
|
||||
i18n
|
||||
>Copied!</span>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-primary"
|
||||
[disabled]="bundle.status !== statuses.Ready"
|
||||
(click)="copy(bundle)"
|
||||
title="Copy share link"
|
||||
i18n-title
|
||||
>
|
||||
@if (copiedSlug() === bundle.slug) {
|
||||
<i-bs name="clipboard-check"></i-bs>
|
||||
}
|
||||
@if (copiedSlug() !== bundle.slug) {
|
||||
<i-bs name="clipboard"></i-bs>
|
||||
}
|
||||
<span class="visually-hidden" i18n>Copy share link</span>
|
||||
</button>
|
||||
@if (bundle.status === statuses.Failed) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-primary"
|
||||
[disabled]="bundle.status !== statuses.Ready"
|
||||
(click)="copy(bundle)"
|
||||
title="Copy share link"
|
||||
i18n-title
|
||||
>
|
||||
@if (copiedSlug() === bundle.slug) {
|
||||
<i-bs name="clipboard-check"></i-bs>
|
||||
}
|
||||
@if (copiedSlug() !== bundle.slug) {
|
||||
<i-bs name="clipboard"></i-bs>
|
||||
}
|
||||
<span class="visually-hidden" i18n>Copy share link</span>
|
||||
</button>
|
||||
@if (bundle.status === statuses.Failed) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-warning"
|
||||
[disabled]="loading()"
|
||||
(click)="retry(bundle)"
|
||||
>
|
||||
<i-bs name="arrow-clockwise"></i-bs>
|
||||
<span class="visually-hidden" i18n>Retry</span>
|
||||
</button>
|
||||
}
|
||||
<pngx-confirm-button
|
||||
buttonClasses="btn btn-sm btn-outline-danger"
|
||||
class="btn btn-outline-warning"
|
||||
[disabled]="loading()"
|
||||
(confirm)="delete(bundle)"
|
||||
iconName="trash"
|
||||
(click)="retry(bundle)"
|
||||
>
|
||||
<span class="visually-hidden" i18n>Delete share link bundle</span>
|
||||
</pngx-confirm-button>
|
||||
</div>
|
||||
<i-bs name="arrow-clockwise"></i-bs>
|
||||
<span class="visually-hidden" i18n>Retry</span>
|
||||
</button>
|
||||
}
|
||||
<pngx-confirm-button
|
||||
buttonClasses="btn btn-sm btn-outline-danger"
|
||||
[disabled]="loading()"
|
||||
(confirm)="delete(bundle)"
|
||||
iconName="trash"
|
||||
>
|
||||
<span class="visually-hidden" i18n>Delete share link bundle</span>
|
||||
</pngx-confirm-button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -141,32 +147,10 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap justify-content-end align-items-center gap-3 mt-3 ms-auto">
|
||||
<div class="d-flex flex-wrap justify-content-end align-items-center gap-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<label class="small text-muted me-2" for="shareLinkBundlePageSize" i18n>Show:</label>
|
||||
<select id="shareLinkBundlePageSize" class="form-select form-select-sm w-auto" [(ngModel)]="pageSize">
|
||||
<option [ngValue]="25">25</option>
|
||||
<option [ngValue]="50">50</option>
|
||||
<option [ngValue]="100">100</option>
|
||||
</select>
|
||||
<span class="small text-muted ms-2 d-none d-md-inline" i18n>per page</span>
|
||||
</div>
|
||||
@if (total() > pageSize) {
|
||||
<ngb-pagination
|
||||
class="mb-0"
|
||||
[pageSize]="pageSize"
|
||||
[collectionSize]="total()"
|
||||
[page]="page()"
|
||||
[maxSize]="5"
|
||||
(pageChange)="setPage($event)"
|
||||
size="sm"
|
||||
aria-label="Share link bundles pagination"
|
||||
i18n-aria-label
|
||||
></ngb-pagination>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" (click)="close()" i18n>Close</button>
|
||||
</div>
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Clipboard } from '@angular/cdk/clipboard'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { FileVersion } from 'src/app/data/share-link'
|
||||
@@ -7,15 +8,13 @@ import {
|
||||
ShareLinkBundleStatus,
|
||||
ShareLinkBundleSummary,
|
||||
} from 'src/app/data/share-link-bundle'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { ShareLinkBundleListComponent } from './share-link-bundle-list.component'
|
||||
import { ShareLinkBundleManageDialogComponent } from './share-link-bundle-manage-dialog.component'
|
||||
|
||||
class MockShareLinkBundleService {
|
||||
list = jest.fn()
|
||||
listAllBundles = jest.fn()
|
||||
delete = jest.fn()
|
||||
rebuildBundle = jest.fn()
|
||||
}
|
||||
@@ -25,12 +24,13 @@ class MockToastService {
|
||||
showError = jest.fn()
|
||||
}
|
||||
|
||||
describe('ShareLinkBundleListComponent', () => {
|
||||
let component: ShareLinkBundleListComponent
|
||||
let fixture: ComponentFixture<ShareLinkBundleListComponent>
|
||||
describe('ShareLinkBundleManageDialogComponent', () => {
|
||||
let component: ShareLinkBundleManageDialogComponent
|
||||
let fixture: ComponentFixture<ShareLinkBundleManageDialogComponent>
|
||||
let service: MockShareLinkBundleService
|
||||
let toastService: MockToastService
|
||||
let clipboard: Clipboard
|
||||
let activeModal: NgbActiveModal
|
||||
let originalApiBaseUrl: string
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -38,24 +38,26 @@ describe('ShareLinkBundleListComponent', () => {
|
||||
toastService = new MockToastService()
|
||||
originalApiBaseUrl = environment.apiBaseUrl
|
||||
|
||||
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||
service.listAllBundles.mockReturnValue(of([]))
|
||||
service.delete.mockReturnValue(of(true))
|
||||
service.rebuildBundle.mockReturnValue(of(sampleBundle()))
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
ShareLinkBundleListComponent,
|
||||
ShareLinkBundleManageDialogComponent,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
],
|
||||
providers: [
|
||||
NgbActiveModal,
|
||||
{ provide: ShareLinkBundleService, useValue: service },
|
||||
{ provide: ToastService, useValue: toastService },
|
||||
],
|
||||
})
|
||||
|
||||
fixture = TestBed.createComponent(ShareLinkBundleListComponent)
|
||||
fixture = TestBed.createComponent(ShareLinkBundleManageDialogComponent)
|
||||
component = fixture.componentInstance
|
||||
clipboard = TestBed.inject(Clipboard)
|
||||
activeModal = TestBed.inject(NgbActiveModal)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -82,28 +84,28 @@ describe('ShareLinkBundleListComponent', () => {
|
||||
it('loads bundles on init and polls periodically', () => {
|
||||
jest.useFakeTimers()
|
||||
const bundles = [sampleBundle({ status: ShareLinkBundleStatus.Ready })]
|
||||
service.list.mockReset()
|
||||
service.list
|
||||
.mockReturnValueOnce(of({ count: bundles.length, results: bundles }))
|
||||
.mockReturnValue(of({ count: bundles.length, results: bundles }))
|
||||
service.listAllBundles.mockReset()
|
||||
service.listAllBundles
|
||||
.mockReturnValueOnce(of(bundles))
|
||||
.mockReturnValue(of(bundles))
|
||||
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(service.list).toHaveBeenCalledWith(1, 25, 'created', true)
|
||||
expect(service.listAllBundles).toHaveBeenCalledTimes(1)
|
||||
expect(component.bundles()).toEqual(bundles)
|
||||
expect(component.loading()).toBe(false)
|
||||
expect(component.error()).toBeNull()
|
||||
|
||||
jest.advanceTimersByTime(5000)
|
||||
expect(service.list).toHaveBeenCalledTimes(2)
|
||||
expect(service.listAllBundles).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('handles errors when loading bundles', () => {
|
||||
jest.useFakeTimers()
|
||||
service.list.mockReset()
|
||||
service.list
|
||||
service.listAllBundles.mockReset()
|
||||
service.listAllBundles
|
||||
.mockReturnValueOnce(throwError(() => new Error('load fail')))
|
||||
.mockReturnValue(of({ count: 0, results: [] }))
|
||||
.mockReturnValue(of([]))
|
||||
|
||||
fixture.detectChanges()
|
||||
|
||||
@@ -112,57 +114,7 @@ describe('ShareLinkBundleListComponent', () => {
|
||||
expect(component.loading()).toBe(false)
|
||||
|
||||
jest.advanceTimersByTime(5000)
|
||||
expect(service.list).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('loads another page', () => {
|
||||
fixture.detectChanges()
|
||||
|
||||
component.setPage(2)
|
||||
|
||||
expect(service.list).toHaveBeenLastCalledWith(2, 25, 'created', true)
|
||||
})
|
||||
|
||||
it('sorts bundles and returns to the first page', () => {
|
||||
fixture.detectChanges()
|
||||
component.page.set(2)
|
||||
|
||||
component.onSort({ column: 'status', reverse: false })
|
||||
|
||||
expect(component.page()).toBe(1)
|
||||
expect(service.list).toHaveBeenLastCalledWith(1, 25, 'status', false)
|
||||
})
|
||||
|
||||
it('marks expired share link bundles', () => {
|
||||
service.list.mockReturnValue(
|
||||
of({
|
||||
count: 1,
|
||||
results: [sampleBundle({ expiration: '2000-01-01T00:00:00.000Z' })],
|
||||
})
|
||||
)
|
||||
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Expired')
|
||||
})
|
||||
|
||||
it('stores a changed page size and reloads from the first page', () => {
|
||||
fixture.detectChanges()
|
||||
const settingsService = TestBed.inject(SettingsService)
|
||||
jest
|
||||
.spyOn(settingsService, 'get')
|
||||
.mockReturnValueOnce({ share_link_bundles: 25 })
|
||||
const setSpy = jest.spyOn(settingsService, 'set')
|
||||
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of({}))
|
||||
component.page.set(2)
|
||||
|
||||
component.pageSize = 100
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.OBJECT_LIST_SIZES, {
|
||||
share_link_bundles: 100,
|
||||
})
|
||||
expect(component.page()).toBe(1)
|
||||
expect(service.list).toHaveBeenLastCalledWith(1, 100, 'created', true)
|
||||
expect(service.listAllBundles).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('copies bundle links when ready', () => {
|
||||
@@ -174,24 +126,16 @@ describe('ShareLinkBundleListComponent', () => {
|
||||
slug: 'ready-slug',
|
||||
status: ShareLinkBundleStatus.Ready,
|
||||
})
|
||||
component.bundles.set([readyBundle])
|
||||
fixture.detectChanges()
|
||||
component.copy(readyBundle)
|
||||
|
||||
expect(clipboard.copy).toHaveBeenCalledWith(
|
||||
component.getShareUrl(readyBundle)
|
||||
)
|
||||
expect(component.copiedSlug()).toBe('ready-slug')
|
||||
expect(toastService.showInfo).not.toHaveBeenCalled()
|
||||
fixture.detectChanges()
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.badge.show').textContent
|
||||
).toContain('Copied!')
|
||||
expect(toastService.showInfo).toHaveBeenCalled()
|
||||
|
||||
jest.advanceTimersByTime(3000)
|
||||
expect(component.copiedSlug()).toBeNull()
|
||||
fixture.detectChanges()
|
||||
expect(fixture.nativeElement.querySelector('.badge.show')).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores copy requests for non-ready bundles', () => {
|
||||
@@ -202,7 +146,7 @@ describe('ShareLinkBundleListComponent', () => {
|
||||
})
|
||||
|
||||
it('deletes bundles and refreshes list', () => {
|
||||
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||
service.listAllBundles.mockReturnValue(of([]))
|
||||
service.delete.mockReturnValue(of(true))
|
||||
|
||||
fixture.detectChanges()
|
||||
@@ -213,12 +157,12 @@ describe('ShareLinkBundleListComponent', () => {
|
||||
expect(toastService.showInfo).toHaveBeenCalledWith(
|
||||
expect.stringContaining('deleted.')
|
||||
)
|
||||
expect(service.list).toHaveBeenCalledTimes(2)
|
||||
expect(service.listAllBundles).toHaveBeenCalledTimes(2)
|
||||
expect(component.loading()).toBe(false)
|
||||
})
|
||||
|
||||
it('handles delete errors gracefully', () => {
|
||||
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||
service.listAllBundles.mockReturnValue(of([]))
|
||||
service.delete.mockReturnValue(throwError(() => new Error('delete fail')))
|
||||
|
||||
fixture.detectChanges()
|
||||
@@ -230,7 +174,7 @@ describe('ShareLinkBundleListComponent', () => {
|
||||
})
|
||||
|
||||
it('retries bundle build and replaces existing entry', () => {
|
||||
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||
service.listAllBundles.mockReturnValue(of([]))
|
||||
const updated = sampleBundle({ status: ShareLinkBundleStatus.Ready })
|
||||
service.rebuildBundle.mockReturnValue(of(updated))
|
||||
|
||||
@@ -245,7 +189,7 @@ describe('ShareLinkBundleListComponent', () => {
|
||||
})
|
||||
|
||||
it('adds new bundle when retry returns unknown entry', () => {
|
||||
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||
service.listAllBundles.mockReturnValue(of([]))
|
||||
service.rebuildBundle.mockReturnValue(
|
||||
of(sampleBundle({ id: 99, slug: 'new-slug' }))
|
||||
)
|
||||
@@ -259,7 +203,7 @@ describe('ShareLinkBundleListComponent', () => {
|
||||
})
|
||||
|
||||
it('handles retry errors', () => {
|
||||
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||
service.listAllBundles.mockReturnValue(of([]))
|
||||
service.rebuildBundle.mockReturnValue(throwError(() => new Error('fail')))
|
||||
|
||||
fixture.detectChanges()
|
||||
@@ -269,8 +213,8 @@ describe('ShareLinkBundleListComponent', () => {
|
||||
expect(toastService.showError).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps status and file version helpers', () => {
|
||||
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||
it('maps helpers and closes dialog', () => {
|
||||
service.listAllBundles.mockReturnValue(of([]))
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(component.statusLabel(ShareLinkBundleStatus.Processing)).toContain(
|
||||
@@ -283,5 +227,9 @@ describe('ShareLinkBundleListComponent', () => {
|
||||
environment.apiBaseUrl = 'https://example.com/api/'
|
||||
const url = component.getShareUrl(sampleBundle({ slug: 'sluggy' }))
|
||||
expect(url).toBe('https://example.com/share/sluggy')
|
||||
|
||||
const closeSpy = jest.spyOn(activeModal, 'close')
|
||||
component.close()
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Clipboard } from '@angular/cdk/clipboard'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import {
|
||||
NgbPaginationModule,
|
||||
NgbPopoverModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgbActiveModal, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { Subject, catchError, of, switchMap, takeUntil, timer } from 'rxjs'
|
||||
import { FileVersion } from 'src/app/data/share-link'
|
||||
@@ -15,77 +11,42 @@ import {
|
||||
ShareLinkBundleStatus,
|
||||
ShareLinkBundleSummary,
|
||||
} from 'src/app/data/share-link-bundle'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import {
|
||||
SortEvent,
|
||||
SortableDirective,
|
||||
} from 'src/app/directives/sortable.directive'
|
||||
import { FileSizePipe } from 'src/app/pipes/file-size.pipe'
|
||||
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { ConfirmButtonComponent } from 'src/app/components/common/confirm-button/confirm-button.component'
|
||||
import { LoadingComponentWithPermissions } from 'src/app/components/loading-component/loading.component'
|
||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||
import { ConfirmButtonComponent } from '../confirm-button/confirm-button.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-share-link-bundle-list',
|
||||
templateUrl: './share-link-bundle-list.component.html',
|
||||
styleUrls: ['./share-link-bundle-list.component.scss'],
|
||||
selector: 'pngx-share-link-bundle-manage-dialog',
|
||||
templateUrl: './share-link-bundle-manage-dialog.component.html',
|
||||
styleUrls: ['./share-link-bundle-manage-dialog.component.scss'],
|
||||
imports: [
|
||||
ConfirmButtonComponent,
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgbPaginationModule,
|
||||
NgbPopoverModule,
|
||||
NgxBootstrapIconsModule,
|
||||
SortableDirective,
|
||||
FileSizePipe,
|
||||
],
|
||||
})
|
||||
export class ShareLinkBundleListComponent
|
||||
export class ShareLinkBundleManageDialogComponent
|
||||
extends LoadingComponentWithPermissions
|
||||
implements OnInit, OnDestroy
|
||||
{
|
||||
private readonly activeModal = inject(NgbActiveModal)
|
||||
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
|
||||
private readonly settingsService = inject(SettingsService)
|
||||
private readonly toastService = inject(ToastService)
|
||||
private readonly clipboard = inject(Clipboard)
|
||||
|
||||
title = $localize`Share link bundles`
|
||||
readonly bundles = signal<ShareLinkBundleSummary[]>([])
|
||||
readonly error = signal<string | null>(null)
|
||||
readonly copiedSlug = signal<string | null>(null)
|
||||
readonly total = signal(0)
|
||||
readonly page = signal(1)
|
||||
readonly sortField = signal('created')
|
||||
readonly sortReverse = signal(true)
|
||||
|
||||
readonly statuses = ShareLinkBundleStatus
|
||||
readonly fileVersions = FileVersion
|
||||
|
||||
get pageSize(): number {
|
||||
return (
|
||||
this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES)
|
||||
?.share_link_bundles || 25
|
||||
)
|
||||
}
|
||||
|
||||
set pageSize(pageSize: number) {
|
||||
this.settingsService.set(SETTINGS_KEYS.OBJECT_LIST_SIZES, {
|
||||
...this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES),
|
||||
share_link_bundles: pageSize,
|
||||
})
|
||||
this.settingsService.storeSettings().subscribe({
|
||||
next: () => {
|
||||
this.page.set(1)
|
||||
this.triggerRefresh(false)
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError($localize`Error saving settings`, error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private readonly refresh$ = new Subject<boolean>()
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -96,33 +57,25 @@ export class ShareLinkBundleListComponent
|
||||
this.loading.set(true)
|
||||
}
|
||||
this.error.set(null)
|
||||
return this.shareLinkBundleService
|
||||
.list(
|
||||
this.page(),
|
||||
this.pageSize,
|
||||
this.sortField(),
|
||||
this.sortReverse()
|
||||
)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
if (!silent) {
|
||||
this.loading.set(false)
|
||||
}
|
||||
this.error.set($localize`Failed to load share link bundles.`)
|
||||
this.toastService.showError(
|
||||
$localize`Error retrieving share link bundles.`,
|
||||
error
|
||||
)
|
||||
return of(null)
|
||||
})
|
||||
)
|
||||
return this.shareLinkBundleService.listAllBundles().pipe(
|
||||
catchError((error) => {
|
||||
if (!silent) {
|
||||
this.loading.set(false)
|
||||
}
|
||||
this.error.set($localize`Failed to load share link bundles.`)
|
||||
this.toastService.showError(
|
||||
$localize`Error retrieving share link bundles.`,
|
||||
error
|
||||
)
|
||||
return of(null)
|
||||
})
|
||||
)
|
||||
}),
|
||||
takeUntil(this.unsubscribeNotifier)
|
||||
)
|
||||
.subscribe((results) => {
|
||||
if (results) {
|
||||
this.bundles.set(results.results)
|
||||
this.total.set(results.count)
|
||||
this.bundles.set(results)
|
||||
this.copiedSlug.set(null)
|
||||
}
|
||||
this.loading.set(false)
|
||||
@@ -145,18 +98,6 @@ export class ShareLinkBundleListComponent
|
||||
}`
|
||||
}
|
||||
|
||||
setPage(page: number): void {
|
||||
this.page.set(page)
|
||||
this.triggerRefresh(false)
|
||||
}
|
||||
|
||||
onSort(event: SortEvent): void {
|
||||
this.sortField.set(event.column || 'created')
|
||||
this.sortReverse.set(event.column ? event.reverse : true)
|
||||
this.page.set(1)
|
||||
this.triggerRefresh(false)
|
||||
}
|
||||
|
||||
copy(bundle: ShareLinkBundleSummary): void {
|
||||
if (bundle.status !== ShareLinkBundleStatus.Ready) {
|
||||
return
|
||||
@@ -167,6 +108,7 @@ export class ShareLinkBundleListComponent
|
||||
setTimeout(() => {
|
||||
this.copiedSlug.set(null)
|
||||
}, 3000)
|
||||
this.toastService.showInfo($localize`Share link copied to clipboard.`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,9 +117,6 @@ export class ShareLinkBundleListComponent
|
||||
this.loading.set(true)
|
||||
this.shareLinkBundleService.delete(bundle).subscribe({
|
||||
next: () => {
|
||||
if (this.bundles().length === 1 && this.page() > 1) {
|
||||
this.page.update((page) => page - 1)
|
||||
}
|
||||
this.toastService.showInfo($localize`Share link bundle deleted.`)
|
||||
this.triggerRefresh(false)
|
||||
},
|
||||
@@ -214,8 +153,8 @@ export class ShareLinkBundleListComponent
|
||||
return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version
|
||||
}
|
||||
|
||||
isExpired(expiration?: string): boolean {
|
||||
return !!expiration && Date.parse(expiration) <= Date.now()
|
||||
close(): void {
|
||||
this.activeModal.close()
|
||||
}
|
||||
|
||||
private replaceBundle(updated: ShareLinkBundleSummary): void {
|
||||
@@ -82,6 +82,23 @@
|
||||
<i-bs name="pencil" class="me-1"></i-bs><ng-container i18n>PDF Editor</ng-container>
|
||||
</button>
|
||||
|
||||
<button
|
||||
ngbDropdownItem
|
||||
(click)="runZoneOcr()"
|
||||
[disabled]="!userCanEdit || !document?.document_type"
|
||||
*pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.OcrTemplate }"
|
||||
>
|
||||
<i-bs width="1em" height="1em" name="file-earmark-ruled" class="me-1"></i-bs><span i18n>Run Zone OCR</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
ngbDropdownItem
|
||||
(click)="createOcrTemplate()"
|
||||
*pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.OcrTemplate }"
|
||||
>
|
||||
<i-bs width="1em" height="1em" name="file-earmark-medical" class="me-1"></i-bs><span i18n>Create OCR Template</span>
|
||||
</button>
|
||||
|
||||
@if (userIsOwner && (requiresPassword || password)) {
|
||||
<button ngbDropdownItem (click)="removePassword()" [disabled]="!password">
|
||||
<i-bs name="unlock" class="me-1"></i-bs><ng-container i18n>Remove Password</ng-container>
|
||||
|
||||
@@ -1473,6 +1473,35 @@ describe('DocumentDetailComponent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should not automatically get suggestions if auto-suggest is disabled', () => {
|
||||
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST, false)
|
||||
const suggestionsSpy = jest.spyOn(documentService, 'getSuggestions')
|
||||
suggestionsSpy.mockReturnValue(of({ tags: [42] }))
|
||||
initNormally()
|
||||
expect(suggestionsSpy).not.toHaveBeenCalled()
|
||||
|
||||
// still available on demand
|
||||
component.getSuggestions()
|
||||
expect(suggestionsSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not automatically get AI suggestions if auto-suggest is disabled', () => {
|
||||
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST, false)
|
||||
const getSetting = settingsService.get.bind(settingsService)
|
||||
jest
|
||||
.spyOn(settingsService, 'get')
|
||||
.mockImplementation((key) =>
|
||||
key === SETTINGS_KEYS.AI_ENABLED ? true : getSetting(key)
|
||||
)
|
||||
const aiSuggestionsSpy = jest.spyOn(documentService, 'getAiSuggestions')
|
||||
aiSuggestionsSpy.mockReturnValue(of({ tags: [42] }))
|
||||
initNormally()
|
||||
expect(aiSuggestionsSpy).not.toHaveBeenCalled()
|
||||
|
||||
component.getSuggestions()
|
||||
expect(aiSuggestionsSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reset the suggestions loading state if the document changes mid-request', () => {
|
||||
const getSetting = settingsService.get.bind(settingsService)
|
||||
jest
|
||||
|
||||
@@ -237,6 +237,9 @@ export class DocumentDetailComponent
|
||||
this.settings.getSignal<boolean>(
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL
|
||||
)
|
||||
private readonly autoSuggestSetting = this.settings.getSignal<boolean>(
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST
|
||||
)
|
||||
private readonly hiddenFieldsSetting = this.settings.getSignal<
|
||||
DocumentDetailFieldID[]
|
||||
>(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS)
|
||||
@@ -357,6 +360,10 @@ export class DocumentDetailComponent
|
||||
return this.aiEnabledSetting()
|
||||
}
|
||||
|
||||
get autoSuggest(): boolean {
|
||||
return this.autoSuggestSetting()
|
||||
}
|
||||
|
||||
get archiveContentRenderType(): ContentRenderType {
|
||||
const hasArchiveVersion =
|
||||
this.metadata()?.has_archive_version ??
|
||||
@@ -904,6 +911,7 @@ export class DocumentDetailComponent
|
||||
this.updateFormForCustomFields()
|
||||
this.loadMetadataForSelectedVersion()
|
||||
if (
|
||||
this.autoSuggest &&
|
||||
this.permissionsService.currentUserHasObjectPermissions(
|
||||
PermissionAction.Change,
|
||||
doc
|
||||
@@ -1441,6 +1449,48 @@ export class DocumentDetailComponent
|
||||
})
|
||||
}
|
||||
|
||||
runZoneOcr() {
|
||||
this.documentsService.runZoneOcr(this.document.id).subscribe({
|
||||
next: (res) => {
|
||||
const results = res.results ?? []
|
||||
if (results.length) {
|
||||
const failed = results.filter(
|
||||
(r) =>
|
||||
r.value === null ||
|
||||
r.value === undefined ||
|
||||
`${r.value}`.trim() === ''
|
||||
)
|
||||
const filled = results.length - failed.length
|
||||
let msg = $localize`Filled ${filled} of ${results.length} fields`
|
||||
if (failed.length) {
|
||||
const names = failed.map((r) => r.zone).join(', ')
|
||||
msg = `${msg}. ${$localize`Failed to match zones: ${names}`}`
|
||||
}
|
||||
this.toastService.showInfo(msg)
|
||||
} else {
|
||||
this.toastService.showInfo(
|
||||
$localize`Zone OCR ran but no results extracted.`
|
||||
)
|
||||
}
|
||||
this.documentsService
|
||||
.get(this.documentId)
|
||||
.subscribe((doc) => this.updateComponent(doc))
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError($localize`Zone OCR failed`, error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
createOcrTemplate() {
|
||||
this.router.navigate(['/ocr-templates', 'new'], {
|
||||
queryParams: {
|
||||
document_type: this.document.document_type,
|
||||
sample_document: this.document.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private getSelectedNonLatestVersionId(): number | null {
|
||||
const versions = this.document()?.versions ?? []
|
||||
if (!versions.length || !this.selectedVersionId()) {
|
||||
|
||||
@@ -98,6 +98,9 @@
|
||||
<button ngbDropdownItem (click)="mergeSelectedAsVersions()" [disabled]="!userOwnsAll || !userCanEditAll || !userCanDelete || list.allSelected || list.selectedCount < 2">
|
||||
<i-bs name="journal-bookmark-fill" class="me-1"></i-bs><ng-container i18n>Merge as versions</ng-container>
|
||||
</button>
|
||||
<button ngbDropdownItem (click)="runZoneOcrSelected()" [disabled]="!userCanEditAll || list.allSelected">
|
||||
<i-bs name="file-earmark-ruled" class="me-1"></i-bs><ng-container i18n>Run Zone OCR</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
import { EventEmitter, signal } from '@angular/core'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { Router } from '@angular/router'
|
||||
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { of, throwError } from 'rxjs'
|
||||
@@ -47,6 +46,7 @@ import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage
|
||||
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
|
||||
import { FilterableDropdownComponent } from '../../common/filterable-dropdown/filterable-dropdown.component'
|
||||
import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component'
|
||||
import { ShareLinkBundleManageDialogComponent } from '../../common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component'
|
||||
import { BulkEditorComponent } from './bulk-editor.component'
|
||||
|
||||
const selectionData: SelectionData = {
|
||||
@@ -82,7 +82,6 @@ describe('BulkEditorComponent', () => {
|
||||
let customFieldsService: CustomFieldsService
|
||||
let httpTestingController: HttpTestingController
|
||||
let shareLinkBundleService: ShareLinkBundleService
|
||||
let router: Router
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
@@ -168,14 +167,11 @@ describe('BulkEditorComponent', () => {
|
||||
provide: ShareLinkBundleService,
|
||||
useValue: {
|
||||
createBundle: jest.fn(),
|
||||
listAllBundles: jest.fn(),
|
||||
rebuildBundle: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: Router,
|
||||
useValue: { navigate: jest.fn().mockResolvedValue(true) },
|
||||
},
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
@@ -193,7 +189,6 @@ describe('BulkEditorComponent', () => {
|
||||
customFieldsService = TestBed.inject(CustomFieldsService)
|
||||
httpTestingController = TestBed.inject(HttpTestingController)
|
||||
shareLinkBundleService = TestBed.inject(ShareLinkBundleService)
|
||||
router = TestBed.inject(Router)
|
||||
|
||||
fixture = TestBed.createComponent(BulkEditorComponent)
|
||||
component = fixture.componentInstance
|
||||
@@ -1829,9 +1824,9 @@ describe('BulkEditorComponent', () => {
|
||||
},
|
||||
}
|
||||
|
||||
const openSpy = jest
|
||||
.spyOn(modalService, 'open')
|
||||
.mockReturnValueOnce(modalRef as NgbModalRef)
|
||||
const openSpy = jest.spyOn(modalService, 'open')
|
||||
openSpy.mockReturnValueOnce(modalRef as NgbModalRef)
|
||||
openSpy.mockReturnValueOnce({} as NgbModalRef)
|
||||
;(shareLinkBundleService.createBundle as jest.Mock).mockReturnValueOnce(
|
||||
of({ id: 42 })
|
||||
)
|
||||
@@ -1865,9 +1860,11 @@ describe('BulkEditorComponent', () => {
|
||||
|
||||
dialogInstance.onOpenManage()
|
||||
expect(modalRef.close).toHaveBeenCalled()
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/share-links'], {
|
||||
queryParams: { type: 'bundles' },
|
||||
})
|
||||
expect(openSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
ShareLinkBundleManageDialogComponent,
|
||||
expect.objectContaining({ backdrop: 'static', size: 'lg' })
|
||||
)
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
@@ -1920,10 +1917,13 @@ describe('BulkEditorComponent', () => {
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should navigate to share link bundle management', () => {
|
||||
it('should open share link bundle management dialog', () => {
|
||||
const openSpy = jest.spyOn(modalService, 'open')
|
||||
component.manageShareLinkBundles()
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/share-links'], {
|
||||
queryParams: { type: 'bundles' },
|
||||
})
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
ShareLinkBundleManageDialogComponent,
|
||||
expect.objectContaining({ backdrop: 'static', size: 'lg' })
|
||||
)
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { Router } from '@angular/router'
|
||||
import {
|
||||
NgbDropdownModule,
|
||||
NgbModal,
|
||||
@@ -20,7 +19,15 @@ import {
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { saveAs } from 'file-saver'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { first, map, Observable, Subject, switchMap, takeUntil } from 'rxjs'
|
||||
import {
|
||||
first,
|
||||
forkJoin,
|
||||
map,
|
||||
Observable,
|
||||
Subject,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
} from 'rxjs'
|
||||
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
|
||||
import { CustomField } from 'src/app/data/custom-field'
|
||||
import { MatchingModel } from 'src/app/data/matching-model'
|
||||
@@ -70,6 +77,7 @@ import {
|
||||
import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'
|
||||
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
|
||||
import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component'
|
||||
import { ShareLinkBundleManageDialogComponent } from '../../common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component'
|
||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||
import { CustomFieldsBulkEditDialogComponent } from './custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component'
|
||||
|
||||
@@ -104,7 +112,6 @@ export class BulkEditorComponent
|
||||
public readonly permissionService = inject(PermissionsService)
|
||||
private savedViewService = inject(SavedViewService)
|
||||
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
|
||||
private readonly router = inject(Router)
|
||||
|
||||
tagSelectionModel = new FilterableDropdownSelectionModel(true)
|
||||
correspondentSelectionModel = new FilterableDropdownSelectionModel()
|
||||
@@ -940,6 +947,27 @@ export class BulkEditorComponent
|
||||
})
|
||||
}
|
||||
|
||||
runZoneOcrSelected() {
|
||||
const ids = Array.from(this.list.selected)
|
||||
if (!ids.length) return
|
||||
const modal = this.modalService.open(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.title = $localize`Run Zone OCR`
|
||||
modal.componentInstance.messageBold = $localize`Run zone OCR on ${this.getSelectionSize()} selected document(s)?`
|
||||
modal.componentInstance.message = $localize`Each document's type template (if it has one) is applied, overwriting the mapped fields.`
|
||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||
modal.componentInstance.confirmClicked
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
this.executeDocumentAction(
|
||||
modal,
|
||||
forkJoin(ids.map((id) => this.documentService.runZoneOcr(id)))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
setPermissions() {
|
||||
let modal = this.modalService.open(PermissionsDialogComponent, {
|
||||
backdrop: 'static',
|
||||
@@ -1136,8 +1164,9 @@ export class BulkEditorComponent
|
||||
}
|
||||
|
||||
manageShareLinkBundles() {
|
||||
void this.router.navigate(['/share-links'], {
|
||||
queryParams: { type: 'bundles' },
|
||||
this.modalService.open(ShareLinkBundleManageDialogComponent, {
|
||||
backdrop: 'static',
|
||||
size: 'lg',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (textFilterTarget === 'asn') {
|
||||
@if (textFilterTarget === 'asn' || textFilterTarget === 'duplicates') {
|
||||
<select class="form-select flex-grow-0 w-auto" [(ngModel)]="textFilterModifier" (change)="textFilterModifierChange()">
|
||||
@for (m of textFilterModifiers; track m) {
|
||||
<option ngbDropdownItem [value]="m.id">{{m.label}}</option>
|
||||
@@ -23,7 +23,7 @@
|
||||
</button>
|
||||
}
|
||||
<input #textFilterInput class="form-control form-control-sm" type="text"
|
||||
[disabled]="textFilterModifierIsNull"
|
||||
[disabled]="textFilterInputDisabled"
|
||||
[(ngModel)]="textFilter"
|
||||
(keydown)="textFilterKeydown($event)"
|
||||
[ngbTypeahead]="searchAutoComplete"
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
FILTER_HAS_CUSTOM_FIELDS_ALL,
|
||||
FILTER_HAS_CUSTOM_FIELDS_ANY,
|
||||
FILTER_HAS_DOCUMENT_TYPE_ANY,
|
||||
FILTER_HAS_DUPLICATES,
|
||||
FILTER_HAS_STORAGE_PATH_ANY,
|
||||
FILTER_HAS_TAGS_ALL,
|
||||
FILTER_HAS_TAGS_ANY,
|
||||
@@ -427,6 +428,38 @@ describe('FilterEditorComponent', () => {
|
||||
expect(component.textFilterTarget).toEqual('mime-type') // TEXT_FILTER_TARGET_MIME_TYPE
|
||||
})
|
||||
|
||||
it('should ingest filter rules for documents with duplicates', () => {
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_HAS_DUPLICATES,
|
||||
value: 'true',
|
||||
},
|
||||
]
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(component.textFilterTarget).toEqual('duplicates')
|
||||
expect(component.textFilterModifier).toEqual('has-duplicates')
|
||||
expect(component.textFilterInputDisabled).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should ingest filter rules for documents without duplicates', () => {
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_HAS_DUPLICATES,
|
||||
value: 'false',
|
||||
},
|
||||
]
|
||||
|
||||
expect(component.textFilterTarget).toEqual('duplicates')
|
||||
expect(component.textFilterModifier).toEqual('does-not-have-duplicates')
|
||||
expect(component.filterRules).toEqual([
|
||||
{
|
||||
rule_type: FILTER_HAS_DUPLICATES,
|
||||
value: 'false',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should ingest text filter rules for fulltext query', () => {
|
||||
expect(component.textFilter).toEqual(null)
|
||||
component.filterRules = [
|
||||
@@ -1390,6 +1423,33 @@ describe('FilterEditorComponent', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('should convert duplicate target input to the correct filter rule', () => {
|
||||
const textFieldTargetDropdown = fixture.debugElement.queryAll(
|
||||
By.directive(NgbDropdownItem)
|
||||
)[5]
|
||||
textFieldTargetDropdown.triggerEventHandler('click')
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(component.textFilterTarget).toEqual('duplicates')
|
||||
expect(component.filterRules).toEqual([
|
||||
{
|
||||
rule_type: FILTER_HAS_DUPLICATES,
|
||||
value: 'true',
|
||||
},
|
||||
])
|
||||
|
||||
const textFieldModifierSelect = fixture.debugElement.query(By.css('select'))
|
||||
textFieldModifierSelect.nativeElement.value = 'does-not-have-duplicates'
|
||||
textFieldModifierSelect.nativeElement.dispatchEvent(new Event('change'))
|
||||
fixture.detectChanges()
|
||||
expect(component.filterRules).toEqual([
|
||||
{
|
||||
rule_type: FILTER_HAS_DUPLICATES,
|
||||
value: 'false',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should convert user input to correct filter rules on full text query', () => {
|
||||
component.textFilterInput.nativeElement.value = 'foo'
|
||||
component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
|
||||
@@ -2178,6 +2238,22 @@ describe('FilterEditorComponent', () => {
|
||||
]
|
||||
expect(component.generateFilterName()).toEqual('Without any tag')
|
||||
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_HAS_DUPLICATES,
|
||||
value: 'true',
|
||||
},
|
||||
]
|
||||
expect(component.generateFilterName()).toEqual('With duplicates')
|
||||
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_HAS_DUPLICATES,
|
||||
value: 'false',
|
||||
},
|
||||
]
|
||||
expect(component.generateFilterName()).toEqual('Without duplicates')
|
||||
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
FILTER_HAS_CUSTOM_FIELDS_ALL,
|
||||
FILTER_HAS_CUSTOM_FIELDS_ANY,
|
||||
FILTER_HAS_DOCUMENT_TYPE_ANY,
|
||||
FILTER_HAS_DUPLICATES,
|
||||
FILTER_HAS_STORAGE_PATH_ANY,
|
||||
FILTER_HAS_TAGS_ALL,
|
||||
FILTER_HAS_TAGS_ANY,
|
||||
@@ -129,12 +130,15 @@ const TEXT_FILTER_TARGET_FULLTEXT_QUERY = 'fulltext-query'
|
||||
const TEXT_FILTER_TARGET_FULLTEXT_MORELIKE = 'fulltext-morelike'
|
||||
const TEXT_FILTER_TARGET_CUSTOM_FIELDS = 'custom-fields'
|
||||
const TEXT_FILTER_TARGET_MIME_TYPE = 'mime-type'
|
||||
const TEXT_FILTER_TARGET_DUPLICATES = 'duplicates'
|
||||
|
||||
const TEXT_FILTER_MODIFIER_EQUALS = 'equals'
|
||||
const TEXT_FILTER_MODIFIER_NULL = 'is null'
|
||||
const TEXT_FILTER_MODIFIER_NOTNULL = 'not null'
|
||||
const TEXT_FILTER_MODIFIER_GT = 'greater'
|
||||
const TEXT_FILTER_MODIFIER_LT = 'less'
|
||||
const TEXT_FILTER_MODIFIER_HAS_DUPLICATES = 'has-duplicates'
|
||||
const TEXT_FILTER_MODIFIER_DOES_NOT_HAVE_DUPLICATES = 'does-not-have-duplicates'
|
||||
|
||||
const RELATIVE_DATE_QUERY_REGEXP_CREATED = /created:[\["]([^\]]+)[\]"]/g
|
||||
const RELATIVE_DATE_QUERY_REGEXP_ADDED = /added:[\["]([^\]]+)[\]"]/g
|
||||
@@ -205,6 +209,7 @@ const DEFAULT_TEXT_FILTER_TARGET_OPTIONS = [
|
||||
id: TEXT_FILTER_TARGET_FULLTEXT_QUERY,
|
||||
name: $localize`Advanced search`,
|
||||
},
|
||||
{ id: TEXT_FILTER_TARGET_DUPLICATES, name: $localize`Duplicates` },
|
||||
]
|
||||
|
||||
const DEPRECATED_CUSTOM_FIELDS_TEXT_FILTER_TARGET_OPTION = {
|
||||
@@ -241,6 +246,17 @@ const DEFAULT_TEXT_FILTER_MODIFIER_OPTIONS = [
|
||||
},
|
||||
]
|
||||
|
||||
const DUPLICATES_FILTER_MODIFIER_OPTIONS = [
|
||||
{
|
||||
id: TEXT_FILTER_MODIFIER_HAS_DUPLICATES,
|
||||
label: $localize`exist`,
|
||||
},
|
||||
{
|
||||
id: TEXT_FILTER_MODIFIER_DOES_NOT_HAVE_DUPLICATES,
|
||||
label: $localize`do not exist`,
|
||||
},
|
||||
]
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-filter-editor',
|
||||
templateUrl: './filter-editor.component.html',
|
||||
@@ -320,6 +336,12 @@ export class FilterEditorComponent
|
||||
if (rule.value == 'false') {
|
||||
return $localize`Without any tag`
|
||||
}
|
||||
break
|
||||
|
||||
case FILTER_HAS_DUPLICATES:
|
||||
return rule.value == 'false'
|
||||
? $localize`Without duplicates`
|
||||
: $localize`With duplicates`
|
||||
|
||||
case FILTER_CUSTOM_FIELDS_QUERY:
|
||||
return $localize`Custom fields query`
|
||||
@@ -390,7 +412,9 @@ export class FilterEditorComponent
|
||||
public textFilterModifier: string
|
||||
|
||||
get textFilterModifiers() {
|
||||
return DEFAULT_TEXT_FILTER_MODIFIER_OPTIONS
|
||||
return this.textFilterTarget === TEXT_FILTER_TARGET_DUPLICATES
|
||||
? DUPLICATES_FILTER_MODIFIER_OPTIONS
|
||||
: DEFAULT_TEXT_FILTER_MODIFIER_OPTIONS
|
||||
}
|
||||
|
||||
get textFilterModifierIsNull(): boolean {
|
||||
@@ -399,6 +423,13 @@ export class FilterEditorComponent
|
||||
)
|
||||
}
|
||||
|
||||
get textFilterInputDisabled(): boolean {
|
||||
return (
|
||||
this.textFilterModifierIsNull ||
|
||||
this.textFilterTarget === TEXT_FILTER_TARGET_DUPLICATES
|
||||
)
|
||||
}
|
||||
|
||||
tagSelectionModel = new FilterableDropdownSelectionModel(true)
|
||||
correspondentSelectionModel = new FilterableDropdownSelectionModel()
|
||||
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
|
||||
@@ -444,6 +475,7 @@ export class FilterEditorComponent
|
||||
this.customFieldQueriesModel.clear(false)
|
||||
this._textFilter = null
|
||||
this._moreLikeId = null
|
||||
this.textFilterTarget = TEXT_FILTER_TARGET_TITLE_CONTENT
|
||||
this.dateAddedTo = null
|
||||
this.dateAddedFrom = null
|
||||
this.dateCreatedTo = null
|
||||
@@ -477,6 +509,13 @@ export class FilterEditorComponent
|
||||
this.textFilterTarget = TEXT_FILTER_TARGET_MIME_TYPE
|
||||
this._textFilter = rule.value
|
||||
break
|
||||
case FILTER_HAS_DUPLICATES:
|
||||
this.textFilterTarget = TEXT_FILTER_TARGET_DUPLICATES
|
||||
this.textFilterModifier =
|
||||
rule.value == 'false' || rule.value == '0'
|
||||
? TEXT_FILTER_MODIFIER_DOES_NOT_HAVE_DUPLICATES
|
||||
: TEXT_FILTER_MODIFIER_HAS_DUPLICATES
|
||||
break
|
||||
case FILTER_FULLTEXT_QUERY:
|
||||
let allQueryArgs = rule.value.split(',')
|
||||
let textQueryArgs = []
|
||||
@@ -800,6 +839,14 @@ export class FilterEditorComponent
|
||||
value: this._textFilter.trim(),
|
||||
})
|
||||
}
|
||||
if (this.textFilterTarget == TEXT_FILTER_TARGET_DUPLICATES) {
|
||||
filterRules.push({
|
||||
rule_type: FILTER_HAS_DUPLICATES,
|
||||
value: (
|
||||
this.textFilterModifier == TEXT_FILTER_MODIFIER_HAS_DUPLICATES
|
||||
).toString(),
|
||||
})
|
||||
}
|
||||
if (this._textFilter && this.textFilterTarget == TEXT_FILTER_TARGET_TITLE) {
|
||||
filterRules.push({
|
||||
rule_type: FILTER_SIMPLE_TITLE,
|
||||
@@ -1163,7 +1210,7 @@ export class FilterEditorComponent
|
||||
}
|
||||
|
||||
get textFilter() {
|
||||
return this.textFilterModifierIsNull ? '' : this._textFilter
|
||||
return this.textFilterInputDisabled ? '' : this._textFilter
|
||||
}
|
||||
|
||||
set textFilter(value) {
|
||||
@@ -1363,12 +1410,24 @@ export class FilterEditorComponent
|
||||
this._textFilter = ''
|
||||
}
|
||||
this.textFilterTarget = target
|
||||
if (target == TEXT_FILTER_TARGET_DUPLICATES) {
|
||||
this._textFilter = ''
|
||||
this.textFilterModifier = TEXT_FILTER_MODIFIER_HAS_DUPLICATES
|
||||
} else if (
|
||||
[
|
||||
TEXT_FILTER_MODIFIER_HAS_DUPLICATES,
|
||||
TEXT_FILTER_MODIFIER_DOES_NOT_HAVE_DUPLICATES,
|
||||
].includes(this.textFilterModifier)
|
||||
) {
|
||||
this.textFilterModifier = TEXT_FILTER_MODIFIER_EQUALS
|
||||
}
|
||||
this.textFilterInput.nativeElement.focus()
|
||||
this.updateRules()
|
||||
}
|
||||
|
||||
textFilterModifierChange() {
|
||||
if (
|
||||
this.textFilterTarget == TEXT_FILTER_TARGET_DUPLICATES ||
|
||||
this.textFilterModifierIsNull ||
|
||||
([
|
||||
TEXT_FILTER_MODIFIER_EQUALS,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
@if (zones.length === 0) {
|
||||
<p class="text-muted" i18n>
|
||||
No zones defined. Load a document preview and draw rectangles to add zones.
|
||||
</p>
|
||||
}
|
||||
|
||||
<div class="list-group">
|
||||
@for (zone of zones; track $index; let i = $index) {
|
||||
<div
|
||||
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center"
|
||||
[style.box-shadow]="selectedZoneIndex === i ? 'inset 3px 0 0 0 var(--bs-primary)' : null"
|
||||
>
|
||||
<div class="flex-grow-1" role="button" style="cursor: pointer;" (click)="zoneSelected.emit(i)">
|
||||
<div>
|
||||
<strong [class.text-primary]="selectedZoneIndex === i">
|
||||
{{ zone.name }}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="small text-muted">
|
||||
{{ getZoneTargetName(zone) }} - {{ zone.width }}x{{ zone.height }}px
|
||||
<ng-container i18n>p.</ng-container>{{ zonePage(zone) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="zoneSelected.emit(i)" title="Edit" i18n-title>
|
||||
<i-bs name="pencil"></i-bs>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" type="button" (click)="zoneRemoved.emit(i)" title="Delete" i18n-title>
|
||||
<i-bs name="trash"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { CustomField } from 'src/app/data/custom-field'
|
||||
import { OcrTemplateZone } from 'src/app/data/ocr-template'
|
||||
import { OcrTemplateEditorZoneListComponent } from './ocr-template-editor-zone-list.component'
|
||||
|
||||
function zone(overrides: Partial<OcrTemplateZone> = {}): OcrTemplateZone {
|
||||
return {
|
||||
name: 'Zone 1',
|
||||
target: 'custom_field',
|
||||
custom_field: 7,
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 30,
|
||||
height: 40,
|
||||
page: 1,
|
||||
ocr_language: 'eng',
|
||||
transform: 'strip',
|
||||
validation_regex: '',
|
||||
order: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('OcrTemplateEditorZoneListComponent', () => {
|
||||
let fixture: ComponentFixture<OcrTemplateEditorZoneListComponent>
|
||||
let component: OcrTemplateEditorZoneListComponent
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
OcrTemplateEditorZoneListComponent,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(OcrTemplateEditorZoneListComponent)
|
||||
component = fixture.componentInstance
|
||||
})
|
||||
|
||||
it('shows empty state when no zones are defined', () => {
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('No zones defined')
|
||||
})
|
||||
|
||||
it('renders zone target, size, and page', () => {
|
||||
component.zones = [zone()]
|
||||
component.customFields = [{ id: 7, name: 'Invoice Number' } as CustomField]
|
||||
fixture.detectChanges()
|
||||
|
||||
const text = fixture.nativeElement.textContent
|
||||
expect(text).toContain('Zone 1')
|
||||
expect(text).toContain('Invoice Number')
|
||||
expect(text).toContain('30x40px')
|
||||
expect(text).toContain('p.1')
|
||||
})
|
||||
|
||||
it('emits select and remove events', () => {
|
||||
component.zones = [zone()]
|
||||
const selectSpy = jest.spyOn(component.zoneSelected, 'emit')
|
||||
const removeSpy = jest.spyOn(component.zoneRemoved, 'emit')
|
||||
fixture.detectChanges()
|
||||
|
||||
const buttons = fixture.nativeElement.querySelectorAll('button')
|
||||
buttons[0].click()
|
||||
buttons[1].click()
|
||||
|
||||
expect(selectSpy).toHaveBeenCalledWith(0)
|
||||
expect(removeSpy).toHaveBeenCalledWith(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { CustomField } from 'src/app/data/custom-field'
|
||||
import { OCR_BUILTIN_TARGETS, OcrTemplateZone } from 'src/app/data/ocr-template'
|
||||
import { getZonePage } from '../zone-geometry'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-ocr-template-zone-list',
|
||||
imports: [NgxBootstrapIconsModule],
|
||||
templateUrl: './ocr-template-editor-zone-list.component.html',
|
||||
})
|
||||
export class OcrTemplateEditorZoneListComponent {
|
||||
@Input() zones: OcrTemplateZone[] = []
|
||||
@Input() selectedZoneIndex: number | null = null
|
||||
@Input() previewPage = 0
|
||||
@Input() previewPageCount: number | null = null
|
||||
@Input() customFields: CustomField[] = []
|
||||
|
||||
@Output() zoneSelected = new EventEmitter<number>()
|
||||
@Output() zoneRemoved = new EventEmitter<number>()
|
||||
|
||||
zonePage(zone: OcrTemplateZone): number {
|
||||
return getZonePage(zone, this.previewPage, this.previewPageCount)
|
||||
}
|
||||
|
||||
getZoneTargetName(zone: OcrTemplateZone): string {
|
||||
const target = zone.target || 'custom_field'
|
||||
if (target === 'custom_field') {
|
||||
return zone.custom_field
|
||||
? this.getCustomFieldName(zone.custom_field)
|
||||
: $localize`(no field)`
|
||||
}
|
||||
return OCR_BUILTIN_TARGETS.find((t) => t.id === target)?.name ?? target
|
||||
}
|
||||
|
||||
private getCustomFieldName(id: number): string {
|
||||
return (
|
||||
this.customFields.find((field) => field.id === id)?.name ?? `Field #${id}`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
<pngx-page-header [title]="pageTitle" [id]="template.id">
|
||||
<div class="input-group input-group-sm me-5 align-items-center">
|
||||
<div class="input-group-text">
|
||||
<i-bs name="file-text"></i-bs>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
[(ngModel)]="previewDocModel"
|
||||
[ngbTypeahead]="searchDocuments"
|
||||
[inputFormatter]="documentFormatter"
|
||||
[resultFormatter]="documentFormatter"
|
||||
(selectItem)="onPreviewDocSelected($event)"
|
||||
[editable]="false"
|
||||
placeholder="Search documents by title..."
|
||||
i18n-placeholder
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center flex-wrap gap-2">
|
||||
<div class="input-group input-group-sm ms-2 d-none d-md-flex">
|
||||
<div class="input-group-text" i18n>Page</div>
|
||||
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewPageCount" [(ngModel)]="previewPageDisplay" />
|
||||
<div class="input-group-text" i18n>of {{previewPageCount}}</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Previous" (click)="prevPage()" [disabled]="!pageImageUrl || previewPage <= 0">
|
||||
<i-bs width="1.2em" height="1.2em" name="arrow-left"></i-bs>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Next" (click)="nextPage()" [disabled]="!pageImageUrl || previewPage >= (previewPageCount ?? 1) - 1">
|
||||
<i-bs width="1.2em" height="1.2em" name="arrow-right"></i-bs>
|
||||
</button>
|
||||
|
||||
<div class="input-group input-group-sm">
|
||||
<button class="btn btn-outline-secondary" (click)="zoomOut()" i18n>-</button>
|
||||
<span class="input-group-text">{{ zoom * 100 | number: '1.0-0' }}%</span>
|
||||
<button class="btn btn-outline-secondary" (click)="zoomIn()" i18n>+</button>
|
||||
</div>
|
||||
</div>
|
||||
</pngx-page-header>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="btn-toolbar mb-1 border-bottom">
|
||||
<div class="btn-group pb-3">
|
||||
<a routerLink="/ocr-templates" class="btn btn-sm btn-outline-secondary">
|
||||
<i-bs width="1.2em" height="1.2em" name="x"></i-bs>
|
||||
<span class="ms-1" i18n>Close</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="btn-group ms-auto pb-3">
|
||||
<button class="btn btn-sm btn-primary" (click)="save()" [disabled]="saving">
|
||||
@if (saving) {
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||
}
|
||||
<span i18n>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTab" class="nav-underline flex-nowrap flex-md-wrap overflow-auto">
|
||||
<li ngbNavItem="settings">
|
||||
<a ngbNavLink i18n>Settings</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="row mb-3">
|
||||
<div class="col-9">
|
||||
<pngx-input-text [(ngModel)]="template.name" title="Template name" i18n-title></pngx-input-text>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<pngx-input-switch [(ngModel)]="template.enabled" title="Enabled" i18n-title></pngx-input-switch>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pngx-input-select [(ngModel)]="template.document_type" [items]="documentTypes" bindLabel="name" bindValue="id" title="Document type" i18n-title></pngx-input-select>
|
||||
|
||||
<small class="text-muted" i18n>
|
||||
Draw rectangles on the preview to define extraction zones. Use the
|
||||
page controls above the preview to add zones on different pages.
|
||||
</small>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li ngbNavItem="zones">
|
||||
<a ngbNavLink><ng-container i18n>Zones</ng-container> <span class="badge bg-primary ms-2">{{ template.zones.length }}</span></a>
|
||||
<ng-template ngbNavContent>
|
||||
<pngx-ocr-template-zone-list
|
||||
[zones]="template.zones"
|
||||
[selectedZoneIndex]="selectedZoneIndex"
|
||||
[previewPage]="previewPage"
|
||||
[previewPageCount]="previewPageCount"
|
||||
[customFields]="customFields"
|
||||
(zoneSelected)="selectZone($event)"
|
||||
(zoneRemoved)="removeZone($event)"
|
||||
></pngx-ocr-template-zone-list>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li ngbNavItem="zone">
|
||||
<a ngbNavLink i18n>Zone</a>
|
||||
<ng-template ngbNavContent>
|
||||
@if (selectedZone; as zone) {
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<strong>{{ zone.name }}</strong>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-primary" (click)="save()" [disabled]="saving">
|
||||
@if (saving) {
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||
}
|
||||
<span i18n>Save</span>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" (click)="deleteSelectedZone()">
|
||||
<i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Delete zone</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" i18n>Zone Name</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
[(ngModel)]="zone.name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" i18n>Page</label>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control"
|
||||
[(ngModel)]="zone.page"
|
||||
min="-1"
|
||||
/>
|
||||
<small class="text-muted" i18n>Page this zone is on. Use -1 for the last page. Set automatically when you draw it.</small>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" i18n>Field</label>
|
||||
<div class="input-group">
|
||||
<select class="form-select" [ngModel]="zoneFieldValue(zone)" (ngModelChange)="setZoneField(zone, $event)">
|
||||
<optgroup label="Built-in fields" i18n-label>
|
||||
@for (t of builtinTargets; track t.id) {
|
||||
<option [ngValue]="t.id">{{ t.name }}</option>
|
||||
}
|
||||
</optgroup>
|
||||
<optgroup label="Custom fields" i18n-label>
|
||||
@for (cf of customFields; track cf.id) {
|
||||
<option [ngValue]="cf.id">{{ cf.name }} ({{ cf.data_type }})</option>
|
||||
}
|
||||
</optgroup>
|
||||
</select>
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
type="button"
|
||||
(click)="openQuickCreate(selectedZoneIndex)"
|
||||
title="Create new custom field"
|
||||
i18n-title
|
||||
>
|
||||
<i-bs name="plus"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
<small class="text-muted" i18n>Write the extracted value to a custom field, or to a built-in field (Title, ASN, Date created).</small>
|
||||
</div>
|
||||
|
||||
@if (isFieldShared(zone)) {
|
||||
<div class="card mb-3 border-info">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title d-flex align-items-center gap-2">
|
||||
<i-bs name="braces"></i-bs>
|
||||
<span i18n>Combine zones into this field</span>
|
||||
</h6>
|
||||
<p class="small text-muted mb-2" i18n>
|
||||
More than one zone writes to this field. Build the combined
|
||||
value below: click a zone to insert its token, and type any
|
||||
separators or literal text between tokens.
|
||||
</p>
|
||||
<div class="d-flex flex-wrap gap-1 mb-2">
|
||||
@for (z of zonesForField(zone); track $index) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-info"
|
||||
(click)="insertCombineToken(zone, z)"
|
||||
title="Insert token"
|
||||
i18n-title
|
||||
>
|
||||
+ {{ z.name || 'Zone' }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control font-monospace"
|
||||
[ngModel]="getCombineFormat(zone)"
|
||||
(ngModelChange)="setCombineFormat(zone, $event)"
|
||||
placeholder="{Zone 1} - {Zone 2}"
|
||||
/>
|
||||
<small class="text-muted" i18n>
|
||||
Tokens are matched by zone name. An empty zone leaves its
|
||||
token blank and the stray separator is trimmed. Leave empty
|
||||
to just join the zones in order with a space.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showQuickCreate) {
|
||||
<div class="card mb-3 border-primary">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title" i18n>Create Custom Field</h6>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small" i18n>Field Name</label>
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
[(ngModel)]="quickCreateName" placeholder="e.g. Invoice Number" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small" i18n>Field Type</label>
|
||||
<select class="form-select form-select-sm" [(ngModel)]="quickCreateType">
|
||||
@for (t of quickCreateTypes; track t.id) {
|
||||
<option [ngValue]="t.id">{{ t.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-primary btn-sm" (click)="submitQuickCreate()"
|
||||
[disabled]="!quickCreateName.trim()" i18n>
|
||||
Create & Assign
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" (click)="cancelQuickCreate()" i18n>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" i18n>OCR Language</label>
|
||||
<ng-select
|
||||
[items]="ocrLanguageOptions"
|
||||
bindLabel="name"
|
||||
bindValue="id"
|
||||
[multiple]="true"
|
||||
[closeOnSelect]="false"
|
||||
[ngModel]="ocrLanguageArray(zone)"
|
||||
(ngModelChange)="setOcrLanguages(zone, $event)"
|
||||
placeholder="Select languages"
|
||||
i18n-placeholder
|
||||
></ng-select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" i18n>Transform</label>
|
||||
<select class="form-select" [(ngModel)]="zone.transform">
|
||||
@for (opt of transformOptions; track opt.id) {
|
||||
<option [ngValue]="opt.id">{{ opt.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@if (zone.transform === dateTransform) {
|
||||
<div class="mb-3">
|
||||
<label class="form-label" i18n>Date format</label>
|
||||
<select class="form-select" [ngModel]="dateFormatChoice(zone)" (ngModelChange)="setDateFormatChoice(zone, $event)">
|
||||
@for (opt of dateFormatOptions; track opt.id) {
|
||||
<option [ngValue]="opt.id">{{ opt.name }}</option>
|
||||
}
|
||||
<option [ngValue]="customDateFormatChoice" i18n>Custom...</option>
|
||||
</select>
|
||||
@if (usesCustomDateFormat(zone)) {
|
||||
<div class="input-group mt-2">
|
||||
<input type="text" class="form-control font-monospace" [(ngModel)]="zone.date_format" placeholder="%d.%m.%Y" />
|
||||
<button class="btn btn-outline-secondary" type="button" [ngbPopover]="dateFmtHelp" [autoClose]="true" title="Date format help" i18n-title>
|
||||
<i-bs name="question-circle"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
<ng-template #dateFmtHelp>
|
||||
<p class="mb-1" i18n>Python date codes:</p>
|
||||
<ul class="mb-1 ps-3">
|
||||
<li><code>%d</code> <ng-container i18n>day (01-31)</ng-container></li>
|
||||
<li><code>%m</code> <ng-container i18n>month (01-12)</ng-container></li>
|
||||
<li><code>%Y</code> <ng-container i18n>year, 4-digit</ng-container></li>
|
||||
<li><code>%y</code> <ng-container i18n>year, 2-digit</ng-container></li>
|
||||
<li><code>%b</code> <ng-container i18n>month name (Jan)</ng-container></li>
|
||||
</ul>
|
||||
<span i18n>Example:</span> <code>%d.%m.%Y</code> -> 03.03.2026
|
||||
</ng-template>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" i18n>Validation Regex</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control font-monospace"
|
||||
[(ngModel)]="zone.validation_regex"
|
||||
placeholder="e.g. \d{2}\.\d{2}\.\d{4}"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="text-muted small">
|
||||
{{ zone.x }}, {{ zone.y }} - {{ zone.width }}x{{ zone.height }}px
|
||||
</div>
|
||||
|
||||
<hr class="my-3" />
|
||||
<h6 i18n>Test</h6>
|
||||
@if (!previewDocId) {
|
||||
<p class="text-muted small mb-0" i18n>
|
||||
Load a document in the Settings tab to test this zone.
|
||||
</p>
|
||||
} @else {
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="testZone()" [disabled]="zoneTesting">
|
||||
@if (zoneTesting) {
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||
}
|
||||
<span i18n>Test this zone</span>
|
||||
</button>
|
||||
@if (zoneTestResult) {
|
||||
@if (zoneTestResult.error) {
|
||||
<div class="alert alert-warning py-2 mt-2 mb-0 small">{{ zoneTestResult.error }}</div>
|
||||
} @else {
|
||||
<dl class="row small mt-2 mb-0">
|
||||
<dt class="col-sm-4" i18n>OCR text</dt>
|
||||
<dd class="col-sm-8"><code>{{ zoneTestResult.raw_text || '(nothing detected)' }}</code></dd>
|
||||
<dt class="col-sm-4" i18n>Value</dt>
|
||||
<dd class="col-sm-8"><code>{{ zoneTestResult.value || '(empty)' }}</code></dd>
|
||||
@if (zoneTestResult.regex) {
|
||||
<dt class="col-sm-4" i18n>Validation</dt>
|
||||
<dd class="col-sm-8">
|
||||
@if (zoneTestResult.regex_match) {
|
||||
<span class="badge bg-success" i18n>Regex matches</span>
|
||||
} @else {
|
||||
<span class="badge bg-danger" i18n>Regex does not match</span>
|
||||
}
|
||||
</dd>
|
||||
}
|
||||
</dl>
|
||||
}
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
<p class="text-muted" i18n>
|
||||
Select a zone from the Zones tab, or draw a rectangle on the document to create one.
|
||||
</p>
|
||||
}
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div [ngbNavOutlet]="nav" class="mt-3"></div>
|
||||
</div>
|
||||
|
||||
<!-- Right column: Document preview with zone overlay -->
|
||||
<div class="col-md-8">
|
||||
@if (pageImageUrl) {
|
||||
<div class="zone-preview-scroll border">
|
||||
<div class="zone-preview-stage" [style.width.%]="zoom * 100">
|
||||
<img
|
||||
#pageImage
|
||||
[src]="pageImageUrl"
|
||||
(load)="onImageLoad()"
|
||||
class="zone-preview-image"
|
||||
[style.visibility]="imageLoaded ? 'visible' : 'hidden'"
|
||||
crossorigin="use-credentials"
|
||||
/>
|
||||
@if (imageLoaded) {
|
||||
<svg
|
||||
#zoneOverlay
|
||||
class="zone-overlay"
|
||||
[attr.viewBox]="overlayViewBox()"
|
||||
preserveAspectRatio="none"
|
||||
[style.cursor]="overlayCursor"
|
||||
(mousedown)="onOverlayMouseDown($event)"
|
||||
(mousemove)="onOverlayMouseMove($event)"
|
||||
(mouseup)="onOverlayMouseUp($event)"
|
||||
>
|
||||
@for (zone of template.zones; track $index; let i = $index) {
|
||||
@if (zoneDisplayRect(i); as rect) {
|
||||
<g>
|
||||
<rect
|
||||
class="zone-rect"
|
||||
[class.zone-rect-selected]="selectedZoneIndex === i"
|
||||
[attr.x]="rect.x"
|
||||
[attr.y]="rect.y"
|
||||
[attr.width]="rect.w"
|
||||
[attr.height]="rect.h"
|
||||
[attr.stroke]="zoneColor(i)"
|
||||
[attr.fill]="zoneFill(i)"
|
||||
></rect>
|
||||
<text
|
||||
class="zone-label"
|
||||
[attr.x]="rect.x + overlayUnitSize(6)"
|
||||
[attr.y]="zoneLabelY(rect)"
|
||||
[attr.font-size]="overlayFontSize()"
|
||||
[attr.fill]="zoneColor(i)"
|
||||
>{{ zoneLabel(zone, i) }}</text>
|
||||
|
||||
@if (selectedZoneIndex === i) {
|
||||
@for (handle of resizeHandles(rect); track handle.handle) {
|
||||
<rect
|
||||
class="zone-resize-handle"
|
||||
[attr.x]="handle.x - overlayHandleSize() / 2"
|
||||
[attr.y]="handle.y - overlayHandleSize() / 2"
|
||||
[attr.width]="overlayHandleSize()"
|
||||
[attr.height]="overlayHandleSize()"
|
||||
[attr.fill]="zoneColor(i)"
|
||||
></rect>
|
||||
}
|
||||
}
|
||||
</g>
|
||||
}
|
||||
}
|
||||
|
||||
@if (drawingRect(); as rect) {
|
||||
<rect
|
||||
class="zone-drawing-rect"
|
||||
[attr.x]="rect.x"
|
||||
[attr.y]="rect.y"
|
||||
[attr.width]="rect.w"
|
||||
[attr.height]="rect.h"
|
||||
></rect>
|
||||
}
|
||||
</svg>
|
||||
}
|
||||
@if (!imageLoaded) {
|
||||
<div class="d-flex justify-content-center p-5">
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="visually-hidden" i18n>Loading page...</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="border rounded p-5 text-center text-muted">
|
||||
<i-bs name="file-earmark-image" width="48" height="48"></i-bs>
|
||||
<p class="mt-3" i18n>
|
||||
Enter a document ID and click "Load" to preview a page and draw extraction zones.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,63 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.zone-preview-scroll {
|
||||
max-height: 78vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.zone-preview-stage {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.zone-preview-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.zone-overlay {
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
touch-action: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.zone-rect,
|
||||
.zone-drawing-rect {
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.zone-rect {
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.zone-rect-selected {
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.zone-label {
|
||||
font-family: var(--bs-font-sans-serif);
|
||||
font-weight: 600;
|
||||
paint-order: stroke;
|
||||
pointer-events: none;
|
||||
stroke: #fff;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 4px;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.zone-resize-handle {
|
||||
stroke: #fff;
|
||||
stroke-width: 1;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.zone-drawing-rect {
|
||||
fill: rgba(105, 219, 124, 0.25);
|
||||
stroke: #69db7c;
|
||||
stroke-dasharray: 5 5;
|
||||
stroke-width: 2;
|
||||
}
|
||||
@@ -0,0 +1,962 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
inject,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
ViewChild,
|
||||
} from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
||||
import {
|
||||
NgbNavModule,
|
||||
NgbPopoverModule,
|
||||
NgbTypeaheadModule,
|
||||
NgbTypeaheadSelectItemEvent,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import {
|
||||
catchError,
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
Observable,
|
||||
of,
|
||||
Subject,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
} from 'rxjs'
|
||||
import { SelectComponent } from 'src/app/components/common/input/select/select.component'
|
||||
import { SwitchComponent } from 'src/app/components/common/input/switch/switch.component'
|
||||
import { TextComponent } from 'src/app/components/common/input/text/text.component'
|
||||
import { PageHeaderComponent } from 'src/app/components/common/page-header/page-header.component'
|
||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
import { Document } from 'src/app/data/document'
|
||||
import { DocumentType } from 'src/app/data/document-type'
|
||||
import {
|
||||
DATE_FORMAT_OPTIONS,
|
||||
DEFAULT_OCR_ZONE_LANGUAGE,
|
||||
DEFAULT_OCR_ZONE_TARGET,
|
||||
DEFAULT_OCR_ZONE_TRANSFORM,
|
||||
isOcrBuiltinTarget,
|
||||
OCR_BUILTIN_TARGETS,
|
||||
OCR_LANGUAGE_OPTIONS,
|
||||
OCR_ZONE_TARGET,
|
||||
OCR_ZONE_TRANSFORM,
|
||||
OcrBuiltinTarget,
|
||||
OcrTemplate,
|
||||
OcrTemplateZone,
|
||||
OcrZoneTestResult,
|
||||
TRANSFORM_OPTIONS,
|
||||
ZoneTestRequest,
|
||||
} from 'src/app/data/ocr-template'
|
||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { OcrTemplateEditorZoneListComponent } from './ocr-template-editor-zone-list/ocr-template-editor-zone-list.component'
|
||||
import {
|
||||
DisplayRect,
|
||||
DrawingRect,
|
||||
findHandleAt,
|
||||
findZoneAt,
|
||||
getZoneDisplayRect,
|
||||
getZonePage,
|
||||
HANDLE_SIZE,
|
||||
isZoneOnPage,
|
||||
MoveStart,
|
||||
moveZone,
|
||||
Point,
|
||||
ResizeHandle,
|
||||
resizeZone,
|
||||
} from './zone-geometry'
|
||||
|
||||
type ActiveTab = 'settings' | 'zones' | 'zone'
|
||||
type ZoneFieldSelection = OcrBuiltinTarget | number | null
|
||||
type OverlayInteraction =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'drawing'; rect: DrawingRect }
|
||||
| { kind: 'moving'; zoneIndex: number; start: MoveStart }
|
||||
| { kind: 'resizing'; zoneIndex: number; handle: ResizeHandle }
|
||||
interface ResizeHandleMarker extends Point {
|
||||
handle: ResizeHandle
|
||||
}
|
||||
|
||||
const CUSTOM_DATE_FORMAT_CHOICE = 'custom'
|
||||
const MIN_DRAWN_ZONE_SIZE = 10
|
||||
const NO_OVERLAY_INTERACTION: OverlayInteraction = { kind: 'idle' }
|
||||
const ZONE_COLORS = [
|
||||
'#4f8ff7',
|
||||
'#ff6b6b',
|
||||
'#51cf66',
|
||||
'#ffd43b',
|
||||
'#cc5de8',
|
||||
'#ff922b',
|
||||
'#20c997',
|
||||
'#e599f7',
|
||||
]
|
||||
const RESIZE_CURSOR: Record<ResizeHandle, string> = {
|
||||
nw: 'nw-resize',
|
||||
ne: 'ne-resize',
|
||||
sw: 'sw-resize',
|
||||
se: 'se-resize',
|
||||
n: 'n-resize',
|
||||
s: 's-resize',
|
||||
w: 'w-resize',
|
||||
e: 'e-resize',
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-ocr-template-editor',
|
||||
standalone: true,
|
||||
imports: [
|
||||
PageHeaderComponent,
|
||||
TextComponent,
|
||||
SelectComponent,
|
||||
SwitchComponent,
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule,
|
||||
NgbNavModule,
|
||||
NgbPopoverModule,
|
||||
NgbTypeaheadModule,
|
||||
NgSelectModule,
|
||||
NgxBootstrapIconsModule,
|
||||
OcrTemplateEditorZoneListComponent,
|
||||
],
|
||||
templateUrl: './ocr-template-editor.component.html',
|
||||
styleUrls: ['./ocr-template-editor.component.scss'],
|
||||
})
|
||||
export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
private readonly route = inject(ActivatedRoute)
|
||||
private readonly router = inject(Router)
|
||||
private readonly templateService = inject(OcrTemplateService)
|
||||
private readonly customFieldsService = inject(CustomFieldsService)
|
||||
private readonly documentTypeService = inject(DocumentTypeService)
|
||||
private readonly correspondentService = inject(CorrespondentService)
|
||||
private readonly documentService = inject(DocumentService)
|
||||
private readonly toastService = inject(ToastService)
|
||||
private readonly destroy$ = new Subject<void>()
|
||||
private readonly customDateFormatZones = new WeakSet<OcrTemplateZone>()
|
||||
|
||||
@ViewChild('zoneOverlay') overlayRef: ElementRef<SVGSVGElement>
|
||||
@ViewChild('pageImage') imageRef: ElementRef<HTMLImageElement>
|
||||
|
||||
template: OcrTemplate = {
|
||||
id: null,
|
||||
name: '',
|
||||
document_type: null,
|
||||
sample_document: null,
|
||||
source_width: 0,
|
||||
source_height: 0,
|
||||
enabled: true,
|
||||
combine_formats: {},
|
||||
zones: [],
|
||||
}
|
||||
|
||||
customFields: CustomField[] = []
|
||||
documentTypes: DocumentType[] = []
|
||||
transformOptions = TRANSFORM_OPTIONS
|
||||
builtinTargets = OCR_BUILTIN_TARGETS
|
||||
dateFormatOptions = DATE_FORMAT_OPTIONS
|
||||
ocrLanguageOptions = OCR_LANGUAGE_OPTIONS
|
||||
dateTransform = OCR_ZONE_TRANSFORM.Date
|
||||
customDateFormatChoice = CUSTOM_DATE_FORMAT_CHOICE
|
||||
isNew = true
|
||||
saving = false
|
||||
|
||||
previewDocId: number | null = null
|
||||
previewPage = 0
|
||||
previewPageCount: number | null = null
|
||||
private pageCountForDoc: number | null = null
|
||||
pageImageUrl: string | null = null
|
||||
imageLoaded = false
|
||||
zoom = 1
|
||||
previewDocModel: Document | string = ''
|
||||
private correspondentNames = new Map<number, string>()
|
||||
|
||||
public get previewPageDisplay(): number {
|
||||
return this.previewPage + 1
|
||||
}
|
||||
|
||||
public set previewPageDisplay(value: number) {
|
||||
this.goToPage(value - 1)
|
||||
}
|
||||
|
||||
activeTab: ActiveTab = 'settings'
|
||||
|
||||
selectedZoneIndex: number | null = null
|
||||
private overlayInteraction: OverlayInteraction = NO_OVERLAY_INTERACTION
|
||||
overlayCursor = 'crosshair'
|
||||
|
||||
zoneTestResult: OcrZoneTestResult | null = null
|
||||
zoneTesting = false
|
||||
|
||||
showQuickCreate = false
|
||||
quickCreateName = ''
|
||||
quickCreateType = CustomFieldDataType.String
|
||||
quickCreateForZoneIndex: number | null = null
|
||||
quickCreateTypes = [
|
||||
{ id: CustomFieldDataType.String, name: $localize`String` },
|
||||
{ id: CustomFieldDataType.Integer, name: $localize`Integer` },
|
||||
{ id: CustomFieldDataType.Float, name: $localize`Float` },
|
||||
{ id: CustomFieldDataType.Date, name: $localize`Date` },
|
||||
{ id: CustomFieldDataType.Monetary, name: $localize`Monetary` },
|
||||
{ id: CustomFieldDataType.Boolean, name: $localize`Boolean` },
|
||||
{ id: CustomFieldDataType.Url, name: $localize`URL` },
|
||||
{ id: CustomFieldDataType.LongText, name: $localize`Long Text` },
|
||||
]
|
||||
|
||||
get selectedZone(): OcrTemplateZone | null {
|
||||
return this.selectedZoneIndex !== null
|
||||
? (this.template.zones[this.selectedZoneIndex] ?? null)
|
||||
: null
|
||||
}
|
||||
|
||||
get pageTitle(): string {
|
||||
return this.isNew
|
||||
? $localize`New OCR Template`
|
||||
: $localize`Edit OCR Template`
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.customFieldsService
|
||||
.listAll()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((r) => (this.customFields = r.results))
|
||||
|
||||
this.documentTypeService
|
||||
.listAll()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((r) => (this.documentTypes = r.results))
|
||||
|
||||
this.correspondentService
|
||||
.listAll()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((r) => {
|
||||
this.correspondentNames = new Map(r.results.map((c) => [c.id, c.name]))
|
||||
})
|
||||
|
||||
const id = this.route.snapshot.paramMap.get('id')
|
||||
if (id && id !== 'new') {
|
||||
this.isNew = false
|
||||
this.templateService
|
||||
.get(parseInt(id))
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((t) => {
|
||||
this.template = t
|
||||
this.template.combine_formats ??= {}
|
||||
if (t.sample_document) {
|
||||
this.previewDocId = t.sample_document
|
||||
this.loadPreview()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const qp = this.route.snapshot.queryParams
|
||||
if (qp['document_type']) {
|
||||
this.template.document_type = parseInt(qp['document_type'])
|
||||
}
|
||||
if (qp['sample_document']) {
|
||||
const docId = parseInt(qp['sample_document'])
|
||||
this.template.sample_document = docId
|
||||
this.previewDocId = docId
|
||||
this.loadPreview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
searchDocuments = (text$: Observable<string>): Observable<Document[]> =>
|
||||
text$.pipe(
|
||||
debounceTime(250),
|
||||
distinctUntilChanged(),
|
||||
switchMap((term) => {
|
||||
if (!term || term.trim().length < 2) return of([])
|
||||
const params: { title__icontains: string; document_type__id?: number } =
|
||||
{ title__icontains: term.trim() }
|
||||
if (this.template.document_type) {
|
||||
params['document_type__id'] = this.template.document_type
|
||||
}
|
||||
return this.documentService.list(1, 10, 'created', true, params).pipe(
|
||||
map((r) => r.results),
|
||||
catchError(() => of([]))
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
documentFormatter = (doc: Document | string): string => {
|
||||
if (typeof doc === 'string') return doc
|
||||
const corr = doc.correspondent
|
||||
? this.correspondentNames.get(doc.correspondent)
|
||||
: null
|
||||
return corr
|
||||
? `#${doc.id} ${doc.title} (${corr})`
|
||||
: `#${doc.id} ${doc.title}`
|
||||
}
|
||||
|
||||
onPreviewDocSelected(event: NgbTypeaheadSelectItemEvent<Document>) {
|
||||
event.preventDefault()
|
||||
const doc: Document = event.item
|
||||
this.previewDocModel = doc
|
||||
this.previewDocId = doc.id
|
||||
if (!this.template.document_type && doc.document_type) {
|
||||
this.template.document_type = doc.document_type
|
||||
}
|
||||
this.previewPage = 0
|
||||
this.loadPreview()
|
||||
}
|
||||
|
||||
clearPreviewDoc() {
|
||||
this.previewDocModel = ''
|
||||
this.previewDocId = null
|
||||
this.previewPageCount = null
|
||||
this.pageCountForDoc = null
|
||||
this.previewPage = 0
|
||||
this.pageImageUrl = null
|
||||
this.imageLoaded = false
|
||||
}
|
||||
|
||||
loadPreview() {
|
||||
if (!this.previewDocId) return
|
||||
if (this.pageCountForDoc !== this.previewDocId) {
|
||||
this.pageCountForDoc = this.previewDocId
|
||||
this.previewPageCount = null
|
||||
this.documentService
|
||||
.get(this.previewDocId)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (doc) => {
|
||||
this.previewPageCount = doc?.page_count ?? null
|
||||
if (doc && !this.previewDocModel) this.previewDocModel = doc
|
||||
},
|
||||
error: () => (this.previewPageCount = null),
|
||||
})
|
||||
}
|
||||
this.pageImageUrl = this.templateService.getPageImageUrl(
|
||||
this.previewDocId,
|
||||
this.previewPage
|
||||
)
|
||||
this.imageLoaded = false
|
||||
}
|
||||
|
||||
goToPage(page: number) {
|
||||
if (!Number.isFinite(page)) return
|
||||
const max = this.previewPageCount ? this.previewPageCount - 1 : page
|
||||
const clamped = Math.max(0, Math.min(page, max))
|
||||
if (clamped === this.previewPage) return
|
||||
this.previewPage = clamped
|
||||
this.loadPreview()
|
||||
}
|
||||
|
||||
prevPage() {
|
||||
this.goToPage(this.previewPage - 1)
|
||||
}
|
||||
|
||||
nextPage() {
|
||||
this.goToPage(this.previewPage + 1)
|
||||
}
|
||||
|
||||
zoomIn() {
|
||||
this.zoom = Math.min(4, Math.round((this.zoom + 0.25) * 100) / 100)
|
||||
}
|
||||
|
||||
zoomOut() {
|
||||
this.zoom = Math.max(0.5, Math.round((this.zoom - 0.25) * 100) / 100)
|
||||
}
|
||||
|
||||
resetZoom() {
|
||||
this.zoom = 1
|
||||
}
|
||||
|
||||
zonePage(zone: OcrTemplateZone): number {
|
||||
return getZonePage(zone, this.previewPage, this.previewPageCount)
|
||||
}
|
||||
|
||||
private isOnCurrentPage(zone: OcrTemplateZone): boolean {
|
||||
return isZoneOnPage(zone, this.previewPage, this.previewPageCount)
|
||||
}
|
||||
|
||||
onImageLoad() {
|
||||
this.imageLoaded = true
|
||||
const img = this.imageRef.nativeElement
|
||||
this.template.source_width = img.naturalWidth
|
||||
this.template.source_height = img.naturalHeight
|
||||
}
|
||||
|
||||
onOverlayMouseDown(event: MouseEvent) {
|
||||
const point = this.svgPointFromEvent(event)
|
||||
if (!point) return
|
||||
event.preventDefault()
|
||||
|
||||
if (this.selectedZoneIndex !== null) {
|
||||
const handle = this.findHandleAt(point, this.selectedZoneIndex)
|
||||
if (handle) {
|
||||
this.overlayInteraction = {
|
||||
kind: 'resizing',
|
||||
zoneIndex: this.selectedZoneIndex,
|
||||
handle,
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const clickedIdx = this.findZoneAt(point)
|
||||
if (clickedIdx !== null && !event.shiftKey) {
|
||||
this.selectZone(clickedIdx)
|
||||
const zone = this.template.zones[clickedIdx]
|
||||
this.overlayInteraction = {
|
||||
kind: 'moving',
|
||||
zoneIndex: clickedIdx,
|
||||
start: {
|
||||
mouseX: point.x,
|
||||
mouseY: point.y,
|
||||
zoneX: zone.x,
|
||||
zoneY: zone.y,
|
||||
},
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Shift+click or click on empty area starts a new zone.
|
||||
this.overlayInteraction = {
|
||||
kind: 'drawing',
|
||||
rect: {
|
||||
startX: point.x,
|
||||
startY: point.y,
|
||||
endX: point.x,
|
||||
endY: point.y,
|
||||
},
|
||||
}
|
||||
this.selectedZoneIndex = null
|
||||
}
|
||||
|
||||
onOverlayMouseMove(event: MouseEvent) {
|
||||
const point = this.svgPointFromEvent(event)
|
||||
if (!point) return
|
||||
|
||||
if (this.overlayInteraction.kind === 'resizing') {
|
||||
this.applyResize(
|
||||
this.overlayInteraction.zoneIndex,
|
||||
this.overlayInteraction.handle,
|
||||
point
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.overlayInteraction.kind === 'moving') {
|
||||
moveZone(
|
||||
this.template.zones[this.overlayInteraction.zoneIndex],
|
||||
point,
|
||||
this.overlayInteraction.start,
|
||||
this.imageNaturalSize(),
|
||||
this.imageNaturalSize()
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.overlayInteraction.kind === 'drawing') {
|
||||
this.overlayInteraction.rect.endX = point.x
|
||||
this.overlayInteraction.rect.endY = point.y
|
||||
return
|
||||
}
|
||||
|
||||
this.updateOverlayCursor(point)
|
||||
}
|
||||
|
||||
private updateOverlayCursor(point: Point) {
|
||||
if (this.selectedZoneIndex !== null) {
|
||||
const handle = this.findHandleAt(point, this.selectedZoneIndex)
|
||||
if (handle) {
|
||||
this.overlayCursor = RESIZE_CURSOR[handle] || 'crosshair'
|
||||
return
|
||||
}
|
||||
}
|
||||
this.overlayCursor = this.findZoneAt(point) !== null ? 'move' : 'crosshair'
|
||||
}
|
||||
|
||||
onOverlayMouseUp(_event: MouseEvent) {
|
||||
if (
|
||||
this.overlayInteraction.kind === 'moving' ||
|
||||
this.overlayInteraction.kind === 'resizing'
|
||||
) {
|
||||
this.stopOverlayInteraction()
|
||||
return
|
||||
}
|
||||
|
||||
if (this.overlayInteraction.kind !== 'drawing') return
|
||||
const drawingRect = this.overlayInteraction.rect
|
||||
this.stopOverlayInteraction()
|
||||
|
||||
const rect = this.sourceRectFromDrawing(drawingRect)
|
||||
|
||||
// Ignore tiny accidental clicks.
|
||||
if (rect.w < MIN_DRAWN_ZONE_SIZE || rect.h < MIN_DRAWN_ZONE_SIZE) {
|
||||
return
|
||||
}
|
||||
|
||||
this.template.zones.push(this.createZoneFromRect(rect))
|
||||
this.selectZone(this.template.zones.length - 1)
|
||||
}
|
||||
|
||||
private createZoneFromRect(rect: DisplayRect): OcrTemplateZone {
|
||||
const imageSize = this.imageNaturalSize()
|
||||
return {
|
||||
name: `Zone ${this.template.zones.length + 1}`,
|
||||
target: DEFAULT_OCR_ZONE_TARGET,
|
||||
custom_field: this.defaultCustomFieldId(),
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.w,
|
||||
height: rect.h,
|
||||
page: this.previewPageDisplay,
|
||||
ocr_language: DEFAULT_OCR_ZONE_LANGUAGE,
|
||||
transform: DEFAULT_OCR_ZONE_TRANSFORM,
|
||||
date_format: '',
|
||||
validation_regex: '',
|
||||
order: this.template.zones.length,
|
||||
zone_source_width: imageSize.width,
|
||||
zone_source_height: imageSize.height,
|
||||
}
|
||||
}
|
||||
|
||||
private defaultCustomFieldId(): number | null {
|
||||
return this.customFields[0]?.id ?? null
|
||||
}
|
||||
|
||||
@HostListener('document:mouseup')
|
||||
onDocumentMouseUp() {
|
||||
if (this.overlayInteraction.kind === 'idle') return
|
||||
this.stopOverlayInteraction()
|
||||
}
|
||||
|
||||
private stopOverlayInteraction() {
|
||||
this.overlayInteraction = NO_OVERLAY_INTERACTION
|
||||
this.overlayCursor = 'crosshair'
|
||||
}
|
||||
|
||||
drawingRect(): DisplayRect | null {
|
||||
return this.overlayInteraction.kind === 'drawing'
|
||||
? this.displayRectFromDrawing(this.overlayInteraction.rect)
|
||||
: null
|
||||
}
|
||||
|
||||
zoneDisplayRect(zoneIdx: number): DisplayRect | null {
|
||||
const img = this.imageRef?.nativeElement
|
||||
if (!img || !img.naturalWidth) return null
|
||||
const zone = this.template.zones[zoneIdx]
|
||||
if (!zone) return null
|
||||
if (!this.isOnCurrentPage(zone)) return null
|
||||
return getZoneDisplayRect(
|
||||
zone,
|
||||
this.imageNaturalSize(),
|
||||
this.imageNaturalSize()
|
||||
)
|
||||
}
|
||||
|
||||
private findHandleAt(point: Point, zoneIdx: number): ResizeHandle | null {
|
||||
const r = this.zoneDisplayRect(zoneIdx)
|
||||
if (!r) return null
|
||||
return findHandleAt(point, r, this.overlayHandleSize())
|
||||
}
|
||||
|
||||
private applyResize(zoneIndex: number, handle: ResizeHandle, point: Point) {
|
||||
const zone = this.template.zones[zoneIndex]
|
||||
if (!zone) return
|
||||
resizeZone(
|
||||
zone,
|
||||
handle,
|
||||
point,
|
||||
this.imageNaturalSize(),
|
||||
this.imageNaturalSize()
|
||||
)
|
||||
}
|
||||
|
||||
private findZoneAt(point: Point): number | null {
|
||||
const img = this.imageRef.nativeElement
|
||||
if (!img.naturalWidth) return null
|
||||
|
||||
return findZoneAt(
|
||||
point,
|
||||
this.template.zones,
|
||||
this.previewPage,
|
||||
this.previewPageCount,
|
||||
this.imageNaturalSize(),
|
||||
this.imageNaturalSize()
|
||||
)
|
||||
}
|
||||
|
||||
overlayViewBox(): string {
|
||||
const imageSize = this.imageNaturalSize()
|
||||
return `0 0 ${imageSize.width} ${imageSize.height}`
|
||||
}
|
||||
|
||||
zoneColor(index: number): string {
|
||||
return ZONE_COLORS[index % ZONE_COLORS.length]
|
||||
}
|
||||
|
||||
zoneFill(index: number): string {
|
||||
return `${this.zoneColor(index)}33`
|
||||
}
|
||||
|
||||
zoneLabel(zone: OcrTemplateZone, index: number): string {
|
||||
return zone.name || `Zone ${index + 1}`
|
||||
}
|
||||
|
||||
zoneLabelY(rect: DisplayRect): number {
|
||||
return Math.max(this.overlayUnitSize(14), rect.y - this.overlayUnitSize(4))
|
||||
}
|
||||
|
||||
resizeHandles(rect: DisplayRect): ResizeHandleMarker[] {
|
||||
return [
|
||||
{ handle: 'nw', x: rect.x, y: rect.y },
|
||||
{ handle: 'n', x: rect.x + rect.w / 2, y: rect.y },
|
||||
{ handle: 'ne', x: rect.x + rect.w, y: rect.y },
|
||||
{ handle: 'w', x: rect.x, y: rect.y + rect.h / 2 },
|
||||
{ handle: 'e', x: rect.x + rect.w, y: rect.y + rect.h / 2 },
|
||||
{ handle: 'sw', x: rect.x, y: rect.y + rect.h },
|
||||
{ handle: 's', x: rect.x + rect.w / 2, y: rect.y + rect.h },
|
||||
{ handle: 'se', x: rect.x + rect.w, y: rect.y + rect.h },
|
||||
]
|
||||
}
|
||||
|
||||
overlayHandleSize(): number {
|
||||
return this.overlayUnitSize(HANDLE_SIZE)
|
||||
}
|
||||
|
||||
overlayFontSize(): number {
|
||||
return this.overlayUnitSize(12)
|
||||
}
|
||||
|
||||
overlayUnitSize(screenPixels: number): number {
|
||||
const img = this.imageRef?.nativeElement
|
||||
if (!img?.naturalWidth || !img.clientWidth) return screenPixels
|
||||
return (screenPixels * img.naturalWidth) / img.clientWidth
|
||||
}
|
||||
|
||||
private svgPointFromEvent(event: MouseEvent): Point | null {
|
||||
const svg = this.overlayRef?.nativeElement
|
||||
const matrix = svg?.getScreenCTM()
|
||||
if (!svg || !matrix) return null
|
||||
|
||||
const point = svg.createSVGPoint()
|
||||
point.x = event.clientX
|
||||
point.y = event.clientY
|
||||
|
||||
const svgPoint = point.matrixTransform(matrix.inverse())
|
||||
return { x: svgPoint.x, y: svgPoint.y }
|
||||
}
|
||||
|
||||
private displayRectFromDrawing(rect: DrawingRect): DisplayRect {
|
||||
return {
|
||||
x: Math.min(rect.startX, rect.endX),
|
||||
y: Math.min(rect.startY, rect.endY),
|
||||
w: Math.abs(rect.endX - rect.startX),
|
||||
h: Math.abs(rect.endY - rect.startY),
|
||||
}
|
||||
}
|
||||
|
||||
private sourceRectFromDrawing(rect: DrawingRect): DisplayRect {
|
||||
const displayRect = this.displayRectFromDrawing(rect)
|
||||
return {
|
||||
x: Math.round(displayRect.x),
|
||||
y: Math.round(displayRect.y),
|
||||
w: Math.round(displayRect.w),
|
||||
h: Math.round(displayRect.h),
|
||||
}
|
||||
}
|
||||
|
||||
private imageNaturalSize() {
|
||||
const img = this.imageRef.nativeElement
|
||||
return { width: img.naturalWidth, height: img.naturalHeight }
|
||||
}
|
||||
|
||||
removeZone(index: number) {
|
||||
this.template.zones.splice(index, 1)
|
||||
if (this.selectedZoneIndex === index) {
|
||||
this.selectedZoneIndex = null
|
||||
} else if (this.selectedZoneIndex > index) {
|
||||
this.selectedZoneIndex--
|
||||
}
|
||||
}
|
||||
|
||||
selectZone(index: number) {
|
||||
this.selectedZoneIndex = index
|
||||
this.activeTab = 'zone'
|
||||
this.zoneTestResult = null
|
||||
const zone = this.template.zones[index]
|
||||
if (zone) {
|
||||
this.seedCombineDefault(zone)
|
||||
this.goToPage(this.zonePage(zone) - 1)
|
||||
}
|
||||
}
|
||||
|
||||
testZone() {
|
||||
const zone = this.selectedZone
|
||||
if (!zone || !this.previewDocId) return
|
||||
this.zoneTesting = true
|
||||
this.zoneTestResult = null
|
||||
this.templateService
|
||||
.testZone(this.previewDocId, this.zoneTestRequest(zone))
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (res) => {
|
||||
this.zoneTestResult = res
|
||||
this.zoneTesting = false
|
||||
},
|
||||
error: (err) => {
|
||||
this.zoneTestResult = {
|
||||
error: err.error?.error || $localize`Test failed`,
|
||||
}
|
||||
this.zoneTesting = false
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private zoneTestRequest(zone: OcrTemplateZone): ZoneTestRequest {
|
||||
return {
|
||||
name: zone.name,
|
||||
x: zone.x,
|
||||
y: zone.y,
|
||||
width: zone.width,
|
||||
height: zone.height,
|
||||
page: zone.page ?? 1,
|
||||
ocr_language: zone.ocr_language,
|
||||
transform: zone.transform,
|
||||
date_format: zone.date_format,
|
||||
validation_regex: zone.validation_regex,
|
||||
zone_source_width: zone.zone_source_width,
|
||||
zone_source_height: zone.zone_source_height,
|
||||
}
|
||||
}
|
||||
|
||||
deleteSelectedZone() {
|
||||
if (this.selectedZoneIndex === null) return
|
||||
this.removeZone(this.selectedZoneIndex)
|
||||
this.activeTab = 'zones'
|
||||
}
|
||||
|
||||
save() {
|
||||
this.saving = true
|
||||
this.pruneCombineFormats()
|
||||
this.template.sample_document = this.previewDocId
|
||||
const obs = this.isNew
|
||||
? this.templateService.create(this.template)
|
||||
: this.templateService.update(this.template)
|
||||
|
||||
obs.pipe(takeUntil(this.destroy$)).subscribe({
|
||||
next: (saved) => {
|
||||
const idx = this.selectedZoneIndex
|
||||
this.template = saved
|
||||
this.isNew = false
|
||||
this.selectedZoneIndex = idx
|
||||
this.saving = false
|
||||
this.toastService.showInfo($localize`OCR template saved.`)
|
||||
},
|
||||
error: (e) => {
|
||||
this.saving = false
|
||||
this.toastService.showError($localize`Error saving OCR template.`, e)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private ocrLangCache = new WeakMap<
|
||||
OcrTemplateZone,
|
||||
{ src: string; arr: string[] }
|
||||
>()
|
||||
|
||||
ocrLanguageArray(zone: OcrTemplateZone): string[] {
|
||||
const src = zone.ocr_language || ''
|
||||
const cached = this.ocrLangCache.get(zone)
|
||||
if (cached && cached.src === src) return cached.arr
|
||||
const arr = src ? src.split('+').filter(Boolean) : []
|
||||
this.ocrLangCache.set(zone, { src, arr })
|
||||
return arr
|
||||
}
|
||||
|
||||
setOcrLanguages(zone: OcrTemplateZone, langs: string[]) {
|
||||
zone.ocr_language = (langs || []).join('+')
|
||||
this.ocrLangCache.set(zone, {
|
||||
src: zone.ocr_language,
|
||||
arr: langs ? [...langs] : [],
|
||||
})
|
||||
}
|
||||
|
||||
getCustomFieldName(id: number): string {
|
||||
const cf = this.customFields.find((f) => f.id === id)
|
||||
return cf ? cf.name : `Field #${id}`
|
||||
}
|
||||
|
||||
/** Value bound to the field select: a built-in id string or a custom-field id. */
|
||||
zoneFieldValue(zone: OcrTemplateZone): ZoneFieldSelection {
|
||||
const target = zone.target || DEFAULT_OCR_ZONE_TARGET
|
||||
return target === OCR_ZONE_TARGET.CustomField ? zone.custom_field : target
|
||||
}
|
||||
|
||||
setZoneField(zone: OcrTemplateZone, value: ZoneFieldSelection) {
|
||||
if (isOcrBuiltinTarget(value)) {
|
||||
zone.target = value
|
||||
zone.custom_field = null
|
||||
} else {
|
||||
zone.target = OCR_ZONE_TARGET.CustomField
|
||||
zone.custom_field = typeof value === 'number' ? value : null
|
||||
}
|
||||
this.seedCombineDefault(zone)
|
||||
}
|
||||
|
||||
fieldKeyFor(zone: OcrTemplateZone): string | null {
|
||||
const v = this.zoneFieldValue(zone)
|
||||
return v === null || v === undefined ? null : String(v)
|
||||
}
|
||||
|
||||
zonesForField(zone: OcrTemplateZone): OcrTemplateZone[] {
|
||||
const key = this.fieldKeyFor(zone)
|
||||
if (!key) return []
|
||||
return this.template.zones.filter((z) => this.fieldKeyFor(z) === key)
|
||||
}
|
||||
|
||||
isFieldShared(zone: OcrTemplateZone): boolean {
|
||||
return this.zonesForField(zone).length > 1
|
||||
}
|
||||
|
||||
getCombineFormat(zone: OcrTemplateZone): string {
|
||||
const key = this.fieldKeyFor(zone)
|
||||
return (key && this.template.combine_formats?.[key]) || ''
|
||||
}
|
||||
|
||||
setCombineFormat(zone: OcrTemplateZone, value: string) {
|
||||
const key = this.fieldKeyFor(zone)
|
||||
if (!key) return
|
||||
this.template.combine_formats ??= {}
|
||||
this.template.combine_formats[key] = value
|
||||
}
|
||||
|
||||
insertCombineToken(zone: OcrTemplateZone, tokenZone: OcrTemplateZone) {
|
||||
const token = `{${tokenZone.name}}`
|
||||
const current = this.getCombineFormat(zone)
|
||||
const sep = current && !current.endsWith(' ') ? ' ' : ''
|
||||
this.setCombineFormat(zone, `${current}${sep}${token}`)
|
||||
}
|
||||
|
||||
private seedCombineDefault(zone: OcrTemplateZone) {
|
||||
const key = this.fieldKeyFor(zone)
|
||||
if (!key) return
|
||||
const shared = this.zonesForField(zone)
|
||||
if (shared.length <= 1) return
|
||||
this.template.combine_formats ??= {}
|
||||
if (!this.template.combine_formats[key]) {
|
||||
this.template.combine_formats[key] = shared
|
||||
.map((z) => `{${z.name}}`)
|
||||
.join(' ')
|
||||
}
|
||||
}
|
||||
|
||||
private pruneCombineFormats() {
|
||||
const formats = this.template.combine_formats
|
||||
if (!formats) return
|
||||
const counts = new Map<string, number>()
|
||||
for (const z of this.template.zones) {
|
||||
const key = this.fieldKeyFor(z)
|
||||
if (key) counts.set(key, (counts.get(key) ?? 0) + 1)
|
||||
}
|
||||
for (const key of Object.keys(formats)) {
|
||||
if ((counts.get(key) ?? 0) <= 1) delete formats[key]
|
||||
}
|
||||
}
|
||||
|
||||
/** Value bound to the date-format select: a preset, '' (auto), or 'custom'. */
|
||||
dateFormatChoice(zone: OcrTemplateZone): string {
|
||||
return this.usesCustomDateFormat(zone)
|
||||
? CUSTOM_DATE_FORMAT_CHOICE
|
||||
: zone.date_format || ''
|
||||
}
|
||||
|
||||
setDateFormatChoice(zone: OcrTemplateZone, value: string) {
|
||||
if (value === CUSTOM_DATE_FORMAT_CHOICE) {
|
||||
this.customDateFormatZones.add(zone)
|
||||
zone.date_format ||= ''
|
||||
} else {
|
||||
this.customDateFormatZones.delete(zone)
|
||||
zone.date_format = value
|
||||
}
|
||||
}
|
||||
|
||||
usesCustomDateFormat(zone: OcrTemplateZone): boolean {
|
||||
return (
|
||||
this.customDateFormatZones.has(zone) ||
|
||||
(!!zone.date_format &&
|
||||
!this.dateFormatOptions.some(
|
||||
(option) => option.id === zone.date_format
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
getZoneTargetName(zone: OcrTemplateZone): string {
|
||||
const target = zone.target || DEFAULT_OCR_ZONE_TARGET
|
||||
if (target === OCR_ZONE_TARGET.CustomField) {
|
||||
return zone.custom_field
|
||||
? this.getCustomFieldName(zone.custom_field)
|
||||
: $localize`(no field)`
|
||||
}
|
||||
return this.builtinTargets.find((t) => t.id === target)?.name ?? target
|
||||
}
|
||||
|
||||
getDocumentTypeName(id: number): string {
|
||||
const dt = this.documentTypes.find((d) => d.id === id)
|
||||
return dt ? dt.name : `Type #${id}`
|
||||
}
|
||||
|
||||
openQuickCreate(zoneIndex: number | null) {
|
||||
if (zoneIndex === null) return
|
||||
this.quickCreateForZoneIndex = zoneIndex
|
||||
this.quickCreateName = this.template.zones[zoneIndex]?.name || ''
|
||||
this.quickCreateType = CustomFieldDataType.String
|
||||
this.showQuickCreate = true
|
||||
}
|
||||
|
||||
cancelQuickCreate() {
|
||||
this.showQuickCreate = false
|
||||
this.quickCreateForZoneIndex = null
|
||||
}
|
||||
|
||||
submitQuickCreate() {
|
||||
if (!this.quickCreateName.trim()) return
|
||||
|
||||
this.templateService
|
||||
.quickCreateField(this.quickCreateName.trim(), this.quickCreateType)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
this.customFieldsService.clearCache()
|
||||
this.customFieldsService
|
||||
.listAll()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((r) => {
|
||||
this.customFields = r.results
|
||||
if (this.quickCreateForZoneIndex !== null) {
|
||||
this.template.zones[this.quickCreateForZoneIndex].custom_field =
|
||||
result.id
|
||||
this.template.zones[this.quickCreateForZoneIndex].target =
|
||||
OCR_ZONE_TARGET.CustomField
|
||||
}
|
||||
this.showQuickCreate = false
|
||||
this.quickCreateForZoneIndex = null
|
||||
})
|
||||
},
|
||||
error: (err) => {
|
||||
this.toastService.showError(
|
||||
$localize`Failed to create custom field.`,
|
||||
err
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next()
|
||||
this.destroy$.complete()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { OcrTemplateZone } from 'src/app/data/ocr-template'
|
||||
import {
|
||||
findHandleAt,
|
||||
findZoneAt,
|
||||
getZoneDisplayRect,
|
||||
getZonePage,
|
||||
isZoneOnPage,
|
||||
moveZone,
|
||||
resizeZone,
|
||||
sourceRectFromDrawing,
|
||||
} from './zone-geometry'
|
||||
|
||||
function zone(overrides: Partial<OcrTemplateZone> = {}): OcrTemplateZone {
|
||||
return {
|
||||
name: 'Zone',
|
||||
target: 'custom_field',
|
||||
custom_field: 1,
|
||||
x: 100,
|
||||
y: 200,
|
||||
width: 300,
|
||||
height: 400,
|
||||
page: 1,
|
||||
ocr_language: 'eng',
|
||||
transform: 'strip',
|
||||
validation_regex: '',
|
||||
order: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('OCR template editor geometry', () => {
|
||||
it('normalizes zone pages', () => {
|
||||
expect(getZonePage(zone({ page: 2 }), 0, 5)).toBe(2)
|
||||
expect(getZonePage(zone({ page: -1 }), 0, 5)).toBe(5)
|
||||
expect(getZonePage(zone({ page: -1 }), 2, null)).toBe(3)
|
||||
expect(getZonePage(zone({ page: 0 }), 0, 5)).toBe(1)
|
||||
expect(getZonePage(zone({ page: undefined }), 0, 5)).toBe(1)
|
||||
})
|
||||
|
||||
it('checks whether a zone is on the current preview page', () => {
|
||||
expect(isZoneOnPage(zone({ page: 2 }), 1, 5)).toBe(true)
|
||||
expect(isZoneOnPage(zone({ page: 2 }), 0, 5)).toBe(false)
|
||||
expect(isZoneOnPage(zone({ page: -1 }), 4, 5)).toBe(true)
|
||||
})
|
||||
|
||||
it('scales source coordinates to canvas display coordinates', () => {
|
||||
expect(
|
||||
getZoneDisplayRect(
|
||||
zone({ x: 100, y: 200, width: 300, height: 400 }),
|
||||
{ width: 500, height: 1000 },
|
||||
{ width: 1000, height: 2000 }
|
||||
)
|
||||
).toEqual({ x: 50, y: 100, w: 150, h: 200 })
|
||||
})
|
||||
|
||||
it('uses per-zone source dimensions when present', () => {
|
||||
expect(
|
||||
getZoneDisplayRect(
|
||||
zone({
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
zone_source_width: 1000,
|
||||
zone_source_height: 1000,
|
||||
}),
|
||||
{ width: 500, height: 500 },
|
||||
{ width: 2000, height: 2000 }
|
||||
)
|
||||
).toEqual({ x: 50, y: 50, w: 50, h: 50 })
|
||||
})
|
||||
|
||||
it('finds zones from topmost to bottommost on the current page', () => {
|
||||
const zones = [
|
||||
zone({ name: 'first', x: 0, y: 0, width: 100, height: 100, page: 1 }),
|
||||
zone({ name: 'second', x: 0, y: 0, width: 50, height: 50, page: 1 }),
|
||||
zone({ name: 'third', x: 0, y: 0, width: 50, height: 50, page: 2 }),
|
||||
]
|
||||
|
||||
expect(
|
||||
findZoneAt(
|
||||
{ x: 25, y: 25 },
|
||||
zones,
|
||||
0,
|
||||
2,
|
||||
{ width: 100, height: 100 },
|
||||
{ width: 100, height: 100 }
|
||||
)
|
||||
).toBe(1)
|
||||
})
|
||||
|
||||
it('finds resize handles around a display rect', () => {
|
||||
const rect = { x: 10, y: 20, w: 100, h: 200 }
|
||||
|
||||
expect(findHandleAt({ x: 10, y: 20 }, rect)).toBe('nw')
|
||||
expect(findHandleAt({ x: 110, y: 220 }, rect)).toBe('se')
|
||||
expect(findHandleAt({ x: 60, y: 20 }, rect)).toBe('n')
|
||||
expect(findHandleAt({ x: 90, y: 160 }, rect)).toBeNull()
|
||||
})
|
||||
|
||||
it('moves zones without leaving source image bounds', () => {
|
||||
const z = zone({ x: 50, y: 50, width: 100, height: 100 })
|
||||
|
||||
moveZone(
|
||||
z,
|
||||
{ x: 500, y: 500 },
|
||||
{ mouseX: 50, mouseY: 50, zoneX: 50, zoneY: 50 },
|
||||
{ width: 500, height: 500 },
|
||||
{ width: 500, height: 500 }
|
||||
)
|
||||
|
||||
expect(z.x).toBe(400)
|
||||
expect(z.y).toBe(400)
|
||||
})
|
||||
|
||||
it('resizes zones without leaving source image bounds', () => {
|
||||
const z = zone({ x: 50, y: 50, width: 100, height: 100 })
|
||||
|
||||
resizeZone(
|
||||
z,
|
||||
'se',
|
||||
{ x: 500, y: 500 },
|
||||
{ width: 500, height: 500 },
|
||||
{ width: 200, height: 200 }
|
||||
)
|
||||
|
||||
expect(z.width).toBe(150)
|
||||
expect(z.height).toBe(150)
|
||||
})
|
||||
|
||||
it('converts drawn canvas rectangles to source rectangles', () => {
|
||||
expect(
|
||||
sourceRectFromDrawing(
|
||||
{ startX: 100, startY: 200, endX: 50, endY: 100 },
|
||||
{ width: 500, height: 1000 },
|
||||
{ width: 1000, height: 2000 }
|
||||
)
|
||||
).toEqual({ x: 100, y: 200, w: 100, h: 200 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,201 @@
|
||||
import { OcrTemplateZone } from 'src/app/data/ocr-template'
|
||||
|
||||
export interface DrawingRect {
|
||||
startX: number
|
||||
startY: number
|
||||
endX: number
|
||||
endY: number
|
||||
}
|
||||
|
||||
export interface Dimensions {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface Point {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface DisplayRect {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
export interface MoveStart {
|
||||
mouseX: number
|
||||
mouseY: number
|
||||
zoneX: number
|
||||
zoneY: number
|
||||
}
|
||||
|
||||
export type ResizeHandle = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
|
||||
|
||||
export const HANDLE_SIZE = 8
|
||||
export const MIN_ZONE_SIZE = 10
|
||||
|
||||
export function getZonePage(
|
||||
zone: OcrTemplateZone,
|
||||
previewPage: number,
|
||||
previewPageCount: number | null
|
||||
): number {
|
||||
const page = zone.page ?? 1
|
||||
if (page === -1) return previewPageCount ?? previewPage + 1
|
||||
return page >= 1 ? page : 1
|
||||
}
|
||||
|
||||
export function isZoneOnPage(
|
||||
zone: OcrTemplateZone,
|
||||
previewPage: number,
|
||||
previewPageCount: number | null
|
||||
): boolean {
|
||||
return getZonePage(zone, previewPage, previewPageCount) === previewPage + 1
|
||||
}
|
||||
|
||||
export function getZoneSourceSize(
|
||||
zone: OcrTemplateZone,
|
||||
imageSize: Dimensions
|
||||
): Dimensions {
|
||||
return {
|
||||
width: zone.zone_source_width || imageSize.width,
|
||||
height: zone.zone_source_height || imageSize.height,
|
||||
}
|
||||
}
|
||||
|
||||
export function getZoneDisplayRect(
|
||||
zone: OcrTemplateZone,
|
||||
canvasSize: Dimensions,
|
||||
imageSize: Dimensions
|
||||
): DisplayRect {
|
||||
const sourceSize = getZoneSourceSize(zone, imageSize)
|
||||
const scaleX = canvasSize.width / sourceSize.width
|
||||
const scaleY = canvasSize.height / sourceSize.height
|
||||
|
||||
return {
|
||||
x: zone.x * scaleX,
|
||||
y: zone.y * scaleY,
|
||||
w: zone.width * scaleX,
|
||||
h: zone.height * scaleY,
|
||||
}
|
||||
}
|
||||
|
||||
export function findHandleAt(
|
||||
point: Point,
|
||||
rect: DisplayRect,
|
||||
handleSize = HANDLE_SIZE
|
||||
): ResizeHandle | null {
|
||||
const handles: [ResizeHandle, number, number][] = [
|
||||
['nw', rect.x, rect.y],
|
||||
['n', rect.x + rect.w / 2, rect.y],
|
||||
['ne', rect.x + rect.w, rect.y],
|
||||
['w', rect.x, rect.y + rect.h / 2],
|
||||
['e', rect.x + rect.w, rect.y + rect.h / 2],
|
||||
['sw', rect.x, rect.y + rect.h],
|
||||
['s', rect.x + rect.w / 2, rect.y + rect.h],
|
||||
['se', rect.x + rect.w, rect.y + rect.h],
|
||||
]
|
||||
|
||||
return (
|
||||
handles.find(
|
||||
([, x, y]) =>
|
||||
Math.abs(point.x - x) <= handleSize &&
|
||||
Math.abs(point.y - y) <= handleSize
|
||||
)?.[0] ?? null
|
||||
)
|
||||
}
|
||||
|
||||
export function findZoneAt(
|
||||
point: Point,
|
||||
zones: OcrTemplateZone[],
|
||||
previewPage: number,
|
||||
previewPageCount: number | null,
|
||||
canvasSize: Dimensions,
|
||||
imageSize: Dimensions
|
||||
): number | null {
|
||||
for (let i = zones.length - 1; i >= 0; i--) {
|
||||
const zone = zones[i]
|
||||
if (!isZoneOnPage(zone, previewPage, previewPageCount)) continue
|
||||
const rect = getZoneDisplayRect(zone, canvasSize, imageSize)
|
||||
|
||||
if (
|
||||
point.x >= rect.x &&
|
||||
point.x <= rect.x + rect.w &&
|
||||
point.y >= rect.y &&
|
||||
point.y <= rect.y + rect.h
|
||||
) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function moveZone(
|
||||
zone: OcrTemplateZone,
|
||||
point: Point,
|
||||
moveStart: MoveStart,
|
||||
canvasSize: Dimensions,
|
||||
imageSize: Dimensions
|
||||
) {
|
||||
const sourceSize = getZoneSourceSize(zone, imageSize)
|
||||
const scaleX = sourceSize.width / canvasSize.width
|
||||
const scaleY = sourceSize.height / canvasSize.height
|
||||
const dx = Math.round((point.x - moveStart.mouseX) * scaleX)
|
||||
const dy = Math.round((point.y - moveStart.mouseY) * scaleY)
|
||||
|
||||
zone.x = clamp(moveStart.zoneX + dx, 0, sourceSize.width - zone.width)
|
||||
zone.y = clamp(moveStart.zoneY + dy, 0, sourceSize.height - zone.height)
|
||||
}
|
||||
|
||||
export function resizeZone(
|
||||
zone: OcrTemplateZone,
|
||||
handle: ResizeHandle,
|
||||
point: Point,
|
||||
canvasSize: Dimensions,
|
||||
imageSize: Dimensions
|
||||
) {
|
||||
const sourceSize = getZoneSourceSize(zone, imageSize)
|
||||
const scaleX = sourceSize.width / canvasSize.width
|
||||
const scaleY = sourceSize.height / canvasSize.height
|
||||
const imageX = clamp(Math.round(point.x * scaleX), 0, sourceSize.width)
|
||||
const imageY = clamp(Math.round(point.y * scaleY), 0, sourceSize.height)
|
||||
|
||||
if (handle.includes('w')) {
|
||||
const right = Math.min(zone.x + zone.width, sourceSize.width)
|
||||
zone.x = clamp(imageX, 0, right - MIN_ZONE_SIZE)
|
||||
zone.width = right - zone.x
|
||||
}
|
||||
if (handle.includes('e')) {
|
||||
zone.width = Math.max(MIN_ZONE_SIZE, imageX - zone.x)
|
||||
}
|
||||
if (handle.includes('n')) {
|
||||
const bottom = Math.min(zone.y + zone.height, sourceSize.height)
|
||||
zone.y = clamp(imageY, 0, bottom - MIN_ZONE_SIZE)
|
||||
zone.height = bottom - zone.y
|
||||
}
|
||||
if (handle.includes('s')) {
|
||||
zone.height = Math.max(MIN_ZONE_SIZE, imageY - zone.y)
|
||||
}
|
||||
}
|
||||
|
||||
export function sourceRectFromDrawing(
|
||||
rect: DrawingRect,
|
||||
canvasSize: Dimensions,
|
||||
imageSize: Dimensions
|
||||
): DisplayRect {
|
||||
const scaleX = imageSize.width / canvasSize.width
|
||||
const scaleY = imageSize.height / canvasSize.height
|
||||
|
||||
return {
|
||||
x: Math.round(Math.min(rect.startX, rect.endX) * scaleX),
|
||||
y: Math.round(Math.min(rect.startY, rect.endY) * scaleY),
|
||||
w: Math.round(Math.abs(rect.endX - rect.startX) * scaleX),
|
||||
h: Math.round(Math.abs(rect.endY - rect.startY) * scaleY),
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(value, max))
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<pngx-page-header
|
||||
title="OCR Templates"
|
||||
i18n-title
|
||||
info="Define extraction zones on document types to automatically populate custom fields via OCR."
|
||||
i18n-info
|
||||
>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="createTemplate()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.OcrTemplate }">
|
||||
<i-bs name="plus-circle" class="me-1"></i-bs><ng-container i18n>Create Template</ng-container>
|
||||
</button>
|
||||
</pngx-page-header>
|
||||
|
||||
<ul class="list-group">
|
||||
|
||||
<li class="list-group-item">
|
||||
<div class="row">
|
||||
<div class="col" i18n>Name</div>
|
||||
<div class="col d-none d-sm-flex" i18n>Document Type</div>
|
||||
<div class="col d-none d-sm-flex" i18n>Zones</div>
|
||||
<div class="col" i18n>Status</div>
|
||||
<div class="col" i18n>Actions</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@if (loading && templates.length === 0) {
|
||||
<li class="list-group-item">
|
||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
<ng-container i18n>Loading...</ng-container>
|
||||
</li>
|
||||
}
|
||||
|
||||
@for (t of templates; track t.id) {
|
||||
<li class="list-group-item">
|
||||
<div class="row fade" [class.show]="show">
|
||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editTemplate(t)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.OcrTemplate)">{{t.name}}</button></div>
|
||||
<div class="col d-flex align-items-center d-none d-sm-flex">{{getDocumentTypeName(t)}}</div>
|
||||
<div class="col d-flex align-items-center d-none d-sm-flex"><code>{{t.zones?.length || 0}}</code></div>
|
||||
<div class="col d-flex align-items-center">
|
||||
<div class="form-check form-switch mb-0">
|
||||
<input type="checkbox" class="form-check-input cursor-pointer" [id]="t.id+'_enable'" [(ngModel)]="t.enabled" (change)="toggleTemplate(t)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.OcrTemplate }">
|
||||
<label class="form-check-label cursor-pointer" [for]="t.id+'_enable'">
|
||||
<code> @if(t.enabled) { <ng-container i18n>Enabled</ng-container> } @else { <span i18n class="text-muted">Disabled</span> }</code>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
|
||||
<div class="btn-group d-block d-sm-none">
|
||||
<div ngbDropdown container="body" class="d-inline-block">
|
||||
<button type="button" class="btn btn-link" id="actionsMenuMobile{{t.id}}" (click)="$event.stopPropagation()" ngbDropdownToggle>
|
||||
<i-bs name="three-dots-vertical"></i-bs>
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile{{t.id}}">
|
||||
<button (click)="editTemplate(t)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.OcrTemplate }" ngbDropdownItem i18n>Edit</button>
|
||||
<button (click)="deleteTemplate(t)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.OcrTemplate }" ngbDropdownItem i18n>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-toolbar d-none d-sm-flex gap-2" role="toolbar">
|
||||
<div class="btn-group">
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.OcrTemplate }" class="btn btn-sm btn-outline-secondary" type="button" (click)="editTemplate(t)">
|
||||
<i-bs width="1em" height="1em" name="pencil" class="me-1"></i-bs><ng-container i18n>Edit</ng-container>
|
||||
</button>
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.OcrTemplate }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteTemplate(t)">
|
||||
<i-bs width="1em" height="1em" name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
@if (!loading && templates.length === 0) {
|
||||
<li class="list-group-item" [class.show]="show" i18n>No OCR templates defined.</li>
|
||||
}
|
||||
</ul>
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Component, OnInit, inject } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { Router } from '@angular/router'
|
||||
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { delay, takeUntil, tap } from 'rxjs'
|
||||
import { OcrTemplate } from 'src/app/data/ocr-template'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-ocr-templates',
|
||||
templateUrl: './ocr-templates.component.html',
|
||||
imports: [
|
||||
PageHeaderComponent,
|
||||
IfPermissionsDirective,
|
||||
FormsModule,
|
||||
NgbDropdownModule,
|
||||
NgxBootstrapIconsModule,
|
||||
],
|
||||
})
|
||||
export class OcrTemplatesComponent
|
||||
extends LoadingComponentWithPermissions
|
||||
implements OnInit
|
||||
{
|
||||
private readonly service = inject(OcrTemplateService)
|
||||
private readonly documentTypeService = inject(DocumentTypeService)
|
||||
private readonly router = inject(Router)
|
||||
private readonly modalService = inject(NgbModal)
|
||||
private readonly toastService = inject(ToastService)
|
||||
permissionsService = inject(PermissionsService)
|
||||
|
||||
public templates: OcrTemplate[] = []
|
||||
private documentTypeNames: Map<number, string> = new Map()
|
||||
|
||||
ngOnInit() {
|
||||
this.documentTypeService
|
||||
.listAll()
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((r) => {
|
||||
this.documentTypeNames = new Map(
|
||||
r.results.map((dt) => [dt.id, dt.name])
|
||||
)
|
||||
})
|
||||
this.reload()
|
||||
}
|
||||
|
||||
reload() {
|
||||
this.loading = true
|
||||
this.service
|
||||
.listAll()
|
||||
.pipe(
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
tap((r) => (this.templates = r.results)),
|
||||
delay(100)
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.show = true
|
||||
this.loading = false
|
||||
})
|
||||
}
|
||||
|
||||
getDocumentTypeName(t: OcrTemplate): string {
|
||||
return (
|
||||
this.documentTypeNames.get(t.document_type) ?? `${t.document_type ?? ''}`
|
||||
)
|
||||
}
|
||||
|
||||
createTemplate() {
|
||||
this.router.navigate(['/ocr-templates', 'new'])
|
||||
}
|
||||
|
||||
editTemplate(t: OcrTemplate) {
|
||||
this.router.navigate(['/ocr-templates', t.id])
|
||||
}
|
||||
|
||||
toggleTemplate(t: OcrTemplate) {
|
||||
// ngModel has already flipped t.enabled; restore it if persistence fails.
|
||||
const enabled = t.enabled
|
||||
this.service.patch(t).subscribe({
|
||||
error: (error) => {
|
||||
t.enabled = !enabled
|
||||
this.toastService.showError(
|
||||
$localize`Error updating OCR template.`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
deleteTemplate(t: OcrTemplate) {
|
||||
const modal = this.modalService.open(ConfirmDialogComponent)
|
||||
modal.componentInstance.title = $localize`Delete OCR Template`
|
||||
modal.componentInstance.messageBoldPart = t.name
|
||||
modal.componentInstance.message = $localize`Do you really want to delete this OCR template?`
|
||||
modal.componentInstance.btnClass = 'btn-danger'
|
||||
modal.componentInstance.btnCaption = $localize`Delete`
|
||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||
modal.close()
|
||||
this.service.delete(t).subscribe(() => this.reload())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
<div class="border border-top-0 rounded-bottom p-3">
|
||||
@if (!loading() && error()) {
|
||||
<div class="alert alert-danger mb-0" role="alert">{{ error() }}</div>
|
||||
}
|
||||
@if (!loading() && !error() && links().length === 0) {
|
||||
<p class="mb-0 text-muted fst-italic" i18n>
|
||||
No document share links currently exist.
|
||||
</p>
|
||||
}
|
||||
@if (!loading() && !error() && links().length > 0) {
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-0 bg-body">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="fw-normal" pngxSortable="document__title" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Document</th>
|
||||
<th scope="col" class="fw-normal" pngxSortable="created" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Created</th>
|
||||
<th scope="col" class="fw-normal" pngxSortable="expiration" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Expires</th>
|
||||
<th scope="col" i18n>File version</th>
|
||||
<th scope="col" class="text-end" i18n>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (link of links(); track link.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<a routerLink="/documents/{{ link.document }}">{{ link.document_title | documentTitle }}</a>
|
||||
<span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyDocumentID(link.document)">
|
||||
@if (copiedDocumentID() === link.document) {
|
||||
<i-bs width="1em" height="1em" name="clipboard-check" class="me-1"></i-bs><ng-container i18n>Copied!</ng-container>
|
||||
} @else {
|
||||
ID: {{link.document}}
|
||||
}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ link.created | date: 'short' }}</td>
|
||||
<td>
|
||||
@if (link.expiration) {
|
||||
{{ link.expiration | date: 'short' }}
|
||||
@if (isExpired(link.expiration)) {
|
||||
<span class="badge text-bg-danger ms-2" i18n>Expired</span>
|
||||
}
|
||||
} @else {
|
||||
<span i18n>Never</span>
|
||||
}
|
||||
</td>
|
||||
<td>{{ fileVersionLabel(link.file_version) }}</td>
|
||||
<td class="text-end">
|
||||
<div class="d-inline-block position-relative">
|
||||
<span
|
||||
class="badge bg-primary small fade position-absolute top-50 end-100 translate-middle-y me-2 pe-none z-3 text-nowrap"
|
||||
[class.show]="copiedID() === link.id"
|
||||
i18n
|
||||
>Copied!</span>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-primary"
|
||||
(click)="copy(link)"
|
||||
title="Copy share link"
|
||||
i18n-title
|
||||
>
|
||||
@if (copiedID() === link.id) {
|
||||
<i-bs name="clipboard-check"></i-bs>
|
||||
} @else {
|
||||
<i-bs name="clipboard"></i-bs>
|
||||
}
|
||||
<span class="visually-hidden" i18n>Copy share link</span>
|
||||
</button>
|
||||
<pngx-confirm-button
|
||||
*pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.ShareLink }"
|
||||
buttonClasses="btn btn-sm btn-outline-danger"
|
||||
(confirm)="delete(link)"
|
||||
iconName="trash"
|
||||
>
|
||||
<span class="visually-hidden" i18n>Delete share link</span>
|
||||
</pngx-confirm-button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap justify-content-end align-items-center gap-3 mt-3 ms-auto">
|
||||
<div class="d-flex align-items-center">
|
||||
<label class="small text-muted me-2" for="shareLinkPageSize" i18n>Show:</label>
|
||||
<select id="shareLinkPageSize" class="form-select form-select-sm w-auto" [(ngModel)]="pageSize">
|
||||
<option [ngValue]="25">25</option>
|
||||
<option [ngValue]="50">50</option>
|
||||
<option [ngValue]="100">100</option>
|
||||
</select>
|
||||
<span class="small text-muted ms-2 d-none d-md-inline" i18n>per page</span>
|
||||
</div>
|
||||
@if (total() > pageSize) {
|
||||
<ngb-pagination
|
||||
class="mb-0"
|
||||
[pageSize]="pageSize"
|
||||
[collectionSize]="total()"
|
||||
[page]="page()"
|
||||
[maxSize]="5"
|
||||
(pageChange)="setPage($event)"
|
||||
size="sm"
|
||||
aria-label="Share links pagination"
|
||||
i18n-aria-label
|
||||
></ngb-pagination>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -1,155 +0,0 @@
|
||||
import { Clipboard } from '@angular/cdk/clipboard'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { FileVersion, ShareLink } from 'src/app/data/share-link'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { ShareLinkService } from 'src/app/services/rest/share-link.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ShareLinkListComponent } from './share-link-list.component'
|
||||
|
||||
describe('ShareLinkListComponent', () => {
|
||||
let component: ShareLinkListComponent
|
||||
let fixture: ComponentFixture<ShareLinkListComponent>
|
||||
let service: jest.Mocked<Pick<ShareLinkService, 'list' | 'delete'>>
|
||||
let clipboard: Clipboard
|
||||
let toastService: jest.Mocked<Pick<ToastService, 'showInfo' | 'showError'>>
|
||||
|
||||
const link = {
|
||||
id: 1,
|
||||
document: 42,
|
||||
document_title: 'Test document',
|
||||
slug: 'share-slug',
|
||||
created: new Date().toISOString(),
|
||||
expiration: null,
|
||||
file_version: FileVersion.Archive,
|
||||
} as ShareLink
|
||||
|
||||
beforeEach(() => {
|
||||
service = {
|
||||
list: jest.fn().mockReturnValue(of({ count: 1, results: [link] })),
|
||||
delete: jest.fn().mockReturnValue(of(true)),
|
||||
}
|
||||
toastService = {
|
||||
showInfo: jest.fn(),
|
||||
showError: jest.fn(),
|
||||
}
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
ShareLinkListComponent,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
RouterTestingModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: ShareLinkService, useValue: service },
|
||||
{ provide: ToastService, useValue: toastService },
|
||||
],
|
||||
})
|
||||
|
||||
fixture = TestBed.createComponent(ShareLinkListComponent)
|
||||
component = fixture.componentInstance
|
||||
clipboard = TestBed.inject(Clipboard)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllTimers()
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
it('loads and renders document share links', () => {
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(service.list).toHaveBeenCalledWith(1, 25, 'created', true)
|
||||
expect(component.links()).toEqual([link])
|
||||
expect(fixture.nativeElement.textContent).toContain('Test document')
|
||||
expect(fixture.nativeElement.textContent).toContain('ID: 42')
|
||||
})
|
||||
|
||||
it('loads another page', () => {
|
||||
fixture.detectChanges()
|
||||
component.setPage(2)
|
||||
|
||||
expect(service.list).toHaveBeenLastCalledWith(2, 25, 'created', true)
|
||||
})
|
||||
|
||||
it('sorts links and returns to the first page', () => {
|
||||
fixture.detectChanges()
|
||||
component.page.set(2)
|
||||
|
||||
component.onSort({ column: 'expiration', reverse: false })
|
||||
|
||||
expect(component.page()).toBe(1)
|
||||
expect(service.list).toHaveBeenLastCalledWith(1, 25, 'expiration', false)
|
||||
})
|
||||
|
||||
it('marks expired share links', () => {
|
||||
service.list.mockReturnValue(
|
||||
of({
|
||||
count: 1,
|
||||
results: [
|
||||
{
|
||||
...link,
|
||||
expiration: '2000-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Expired')
|
||||
})
|
||||
|
||||
it('stores a changed page size and reloads from the first page', () => {
|
||||
const settingsService = TestBed.inject(SettingsService)
|
||||
jest.spyOn(settingsService, 'get').mockReturnValueOnce({ share_links: 25 })
|
||||
const setSpy = jest.spyOn(settingsService, 'set')
|
||||
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of({}))
|
||||
const reloadSpy = jest.spyOn(component, 'reload')
|
||||
component.page.set(2)
|
||||
|
||||
component.pageSize = 50
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.OBJECT_LIST_SIZES, {
|
||||
share_links: 50,
|
||||
})
|
||||
expect(component.page()).toBe(1)
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows local copy feedback without a toast', () => {
|
||||
jest.useFakeTimers()
|
||||
jest.spyOn(clipboard, 'copy').mockReturnValue(true)
|
||||
fixture.detectChanges()
|
||||
|
||||
component.copy(link)
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(component.copiedID()).toBe(link.id)
|
||||
expect(fixture.nativeElement.querySelector('.badge.show')).not.toBeNull()
|
||||
expect(toastService.showInfo).not.toHaveBeenCalled()
|
||||
|
||||
jest.advanceTimersByTime(3000)
|
||||
expect(component.copiedID()).toBeNull()
|
||||
})
|
||||
|
||||
it('deletes a link and reloads the list', () => {
|
||||
fixture.detectChanges()
|
||||
component.delete(link)
|
||||
|
||||
expect(service.delete).toHaveBeenCalledWith(link)
|
||||
expect(service.list).toHaveBeenCalledTimes(2)
|
||||
expect(toastService.showInfo).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows an error when loading fails', () => {
|
||||
service.list.mockReturnValue(throwError(() => new Error('load failed')))
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(component.error()).toContain('Failed to load share links.')
|
||||
expect(toastService.showError).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,172 +0,0 @@
|
||||
import { Clipboard } from '@angular/cdk/clipboard'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { Component, OnInit, inject, signal } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { takeUntil } from 'rxjs'
|
||||
import { ConfirmButtonComponent } from 'src/app/components/common/confirm-button/confirm-button.component'
|
||||
import { LoadingComponentWithPermissions } from 'src/app/components/loading-component/loading.component'
|
||||
import { FileVersion, ShareLink } from 'src/app/data/share-link'
|
||||
import { SHARE_LINK_BUNDLE_FILE_VERSION_LABELS } from 'src/app/data/share-link-bundle'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import {
|
||||
SortEvent,
|
||||
SortableDirective,
|
||||
} from 'src/app/directives/sortable.directive'
|
||||
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
||||
import {
|
||||
PermissionAction,
|
||||
PermissionType,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { ShareLinkService } from 'src/app/services/rest/share-link.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-share-link-list',
|
||||
templateUrl: './share-link-list.component.html',
|
||||
imports: [
|
||||
CommonModule,
|
||||
ConfirmButtonComponent,
|
||||
DocumentTitlePipe,
|
||||
FormsModule,
|
||||
IfPermissionsDirective,
|
||||
NgbPaginationModule,
|
||||
NgxBootstrapIconsModule,
|
||||
RouterModule,
|
||||
SortableDirective,
|
||||
],
|
||||
})
|
||||
export class ShareLinkListComponent
|
||||
extends LoadingComponentWithPermissions
|
||||
implements OnInit
|
||||
{
|
||||
private readonly clipboard = inject(Clipboard)
|
||||
private readonly shareLinkService = inject(ShareLinkService)
|
||||
private readonly settingsService = inject(SettingsService)
|
||||
private readonly toastService = inject(ToastService)
|
||||
|
||||
readonly links = signal<ShareLink[]>([])
|
||||
readonly total = signal(0)
|
||||
readonly page = signal(1)
|
||||
readonly sortField = signal('created')
|
||||
readonly sortReverse = signal(true)
|
||||
readonly copiedID = signal<number | null>(null)
|
||||
readonly copiedDocumentID = signal<number | null>(null)
|
||||
readonly error = signal<string | null>(null)
|
||||
readonly PermissionAction = PermissionAction
|
||||
readonly PermissionType = PermissionType
|
||||
|
||||
get pageSize(): number {
|
||||
return (
|
||||
this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES)?.share_links ||
|
||||
25
|
||||
)
|
||||
}
|
||||
|
||||
set pageSize(pageSize: number) {
|
||||
this.settingsService.set(SETTINGS_KEYS.OBJECT_LIST_SIZES, {
|
||||
...this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES),
|
||||
share_links: pageSize,
|
||||
})
|
||||
this.settingsService.storeSettings().subscribe({
|
||||
next: () => {
|
||||
this.page.set(1)
|
||||
this.reload()
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError($localize`Error saving settings`, error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.reload()
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
this.loading.set(true)
|
||||
this.error.set(null)
|
||||
this.shareLinkService
|
||||
.list(this.page(), this.pageSize, this.sortField(), this.sortReverse())
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: (results) => {
|
||||
this.links.set(results.results)
|
||||
this.total.set(results.count)
|
||||
this.loading.set(false)
|
||||
},
|
||||
error: (error) => {
|
||||
this.loading.set(false)
|
||||
this.error.set($localize`Failed to load share links.`)
|
||||
this.toastService.showError(
|
||||
$localize`Error retrieving share links.`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
setPage(page: number): void {
|
||||
this.page.set(page)
|
||||
this.reload()
|
||||
}
|
||||
|
||||
onSort(event: SortEvent): void {
|
||||
this.sortField.set(event.column || 'created')
|
||||
this.sortReverse.set(event.column ? event.reverse : true)
|
||||
this.page.set(1)
|
||||
this.reload()
|
||||
}
|
||||
|
||||
getShareUrl(link: ShareLink): string {
|
||||
const apiURL = new URL(environment.apiBaseUrl)
|
||||
return `${apiURL.origin}${apiURL.pathname.replace(/\/api\/$/, '/share/')}${
|
||||
link.slug
|
||||
}`
|
||||
}
|
||||
|
||||
fileVersionLabel(version: FileVersion): string {
|
||||
return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version
|
||||
}
|
||||
|
||||
isExpired(expiration?: string): boolean {
|
||||
return !!expiration && Date.parse(expiration) <= Date.now()
|
||||
}
|
||||
|
||||
copy(link: ShareLink): void {
|
||||
if (this.clipboard.copy(this.getShareUrl(link))) {
|
||||
this.copiedID.set(link.id)
|
||||
setTimeout(() => this.copiedID.set(null), 3000)
|
||||
}
|
||||
}
|
||||
|
||||
delete(link: ShareLink): void {
|
||||
this.shareLinkService.delete(link).subscribe({
|
||||
next: () => {
|
||||
if (this.links().length === 1 && this.page() > 1) {
|
||||
this.page.update((page) => page - 1)
|
||||
}
|
||||
this.toastService.showInfo($localize`Share link deleted.`)
|
||||
this.reload()
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError(
|
||||
$localize`Error deleting share link.`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
copyDocumentID(documentID: number): void {
|
||||
if (this.clipboard.copy(documentID.toString())) {
|
||||
this.copiedDocumentID.set(documentID)
|
||||
setTimeout(() => this.copiedDocumentID.set(null), 3000)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
<pngx-page-header
|
||||
title="Share links"
|
||||
i18n-title
|
||||
info="Manage public links to individual documents and document bundles."
|
||||
i18n-info
|
||||
[loading]="loading()"
|
||||
></pngx-page-header>
|
||||
|
||||
<ul
|
||||
ngbNav
|
||||
#nav="ngbNav"
|
||||
class="nav-tabs"
|
||||
[activeId]="activeNavID()"
|
||||
(activeIdChange)="selectTab($event)"
|
||||
>
|
||||
@if (canViewDocumentLinks) {
|
||||
<li [ngbNavItem]="ShareLinksNavIDs.DocumentLinks">
|
||||
<button ngbNavLink i18n>Document links</button>
|
||||
<ng-template ngbNavContent>
|
||||
<pngx-share-link-list></pngx-share-link-list>
|
||||
</ng-template>
|
||||
</li>
|
||||
}
|
||||
@if (canViewBundles) {
|
||||
<li [ngbNavItem]="ShareLinksNavIDs.Bundles">
|
||||
<button ngbNavLink i18n>Bundles</button>
|
||||
<ng-template ngbNavContent>
|
||||
<pngx-share-link-bundle-list></pngx-share-link-bundle-list>
|
||||
</ng-template>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
<div class="bg-body" [ngbNavOutlet]="nav"></div>
|
||||
@@ -1,102 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'
|
||||
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { of } from 'rxjs'
|
||||
import {
|
||||
PermissionAction,
|
||||
PermissionsService,
|
||||
PermissionType,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
||||
import { ShareLinkService } from 'src/app/services/rest/share-link.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { ShareLinksComponent, ShareLinksNavIDs } from './share-links.component'
|
||||
|
||||
describe('ShareLinksComponent', () => {
|
||||
let fixture: ComponentFixture<ShareLinksComponent>
|
||||
let permissionsService: PermissionsService
|
||||
let router: Router
|
||||
|
||||
const configure = async (type: string = null) => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
ShareLinksComponent,
|
||||
NgbNavModule,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
PageHeaderComponent,
|
||||
],
|
||||
providers: [
|
||||
PermissionsService,
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: { queryParamMap: convertToParamMap({ type }) },
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: Router,
|
||||
useValue: { navigate: jest.fn().mockResolvedValue(true) },
|
||||
},
|
||||
{
|
||||
provide: ShareLinkBundleService,
|
||||
useValue: {
|
||||
list: jest.fn().mockReturnValue(of({ count: 0, results: [] })),
|
||||
rebuildBundle: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ShareLinkService,
|
||||
useValue: {
|
||||
list: jest.fn().mockReturnValue(of({ count: 0, results: [] })),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ToastService,
|
||||
useValue: { showInfo: jest.fn(), showError: jest.fn() },
|
||||
},
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
permissionsService = TestBed.inject(PermissionsService)
|
||||
router = TestBed.inject(Router)
|
||||
}
|
||||
|
||||
afterEach(() => TestBed.resetTestingModule())
|
||||
|
||||
it('uses the requested bundles tab when permitted', async () => {
|
||||
await configure(ShareLinksNavIDs.Bundles)
|
||||
jest
|
||||
.spyOn(permissionsService, 'currentUserCan')
|
||||
.mockImplementation(
|
||||
(action, type) =>
|
||||
action === PermissionAction.View &&
|
||||
type === PermissionType.ShareLinkBundle
|
||||
)
|
||||
|
||||
fixture = TestBed.createComponent(ShareLinksComponent)
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(fixture.componentInstance.activeNavID()).toBe(
|
||||
ShareLinksNavIDs.Bundles
|
||||
)
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Document links')
|
||||
})
|
||||
|
||||
it('updates the URL when a tab is selected', async () => {
|
||||
await configure()
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
|
||||
fixture = TestBed.createComponent(ShareLinksComponent)
|
||||
fixture.componentInstance.selectTab(ShareLinksNavIDs.Bundles)
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith([], {
|
||||
relativeTo: TestBed.inject(ActivatedRoute),
|
||||
queryParams: { type: ShareLinksNavIDs.Bundles },
|
||||
queryParamsHandling: 'merge',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,78 +0,0 @@
|
||||
import { Component, computed, inject, signal, viewChild } from '@angular/core'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import {
|
||||
PermissionAction,
|
||||
PermissionsService,
|
||||
PermissionType,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { ShareLinkBundleListComponent } from './share-link-bundle-list/share-link-bundle-list.component'
|
||||
import { ShareLinkListComponent } from './share-link-list/share-link-list.component'
|
||||
|
||||
export enum ShareLinksNavIDs {
|
||||
DocumentLinks = 'documents',
|
||||
Bundles = 'bundles',
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-share-links',
|
||||
templateUrl: './share-links.component.html',
|
||||
imports: [
|
||||
NgbNavModule,
|
||||
PageHeaderComponent,
|
||||
ShareLinkBundleListComponent,
|
||||
ShareLinkListComponent,
|
||||
],
|
||||
})
|
||||
export class ShareLinksComponent {
|
||||
private readonly route = inject(ActivatedRoute)
|
||||
private readonly router = inject(Router)
|
||||
private readonly permissionsService = inject(PermissionsService)
|
||||
|
||||
readonly ShareLinksNavIDs = ShareLinksNavIDs
|
||||
readonly activeNavID = signal(this.getInitialNavID())
|
||||
private readonly documentLinks = viewChild(ShareLinkListComponent)
|
||||
private readonly bundles = viewChild(ShareLinkBundleListComponent)
|
||||
readonly loading = computed(() => {
|
||||
const activeList =
|
||||
this.activeNavID() === ShareLinksNavIDs.DocumentLinks
|
||||
? this.documentLinks()
|
||||
: this.bundles()
|
||||
return activeList?.loading() ?? true
|
||||
})
|
||||
|
||||
get canViewDocumentLinks(): boolean {
|
||||
return this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.ShareLink
|
||||
)
|
||||
}
|
||||
|
||||
get canViewBundles(): boolean {
|
||||
return this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.ShareLinkBundle
|
||||
)
|
||||
}
|
||||
|
||||
selectTab(tab: ShareLinksNavIDs): void {
|
||||
this.activeNavID.set(tab)
|
||||
void this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
queryParams: { type: tab },
|
||||
queryParamsHandling: 'merge',
|
||||
})
|
||||
}
|
||||
|
||||
private getInitialNavID(): ShareLinksNavIDs {
|
||||
const requestedTab = this.route.snapshot.queryParamMap.get('type')
|
||||
if (requestedTab === ShareLinksNavIDs.Bundles && this.canViewBundles) {
|
||||
return ShareLinksNavIDs.Bundles
|
||||
}
|
||||
if (this.canViewDocumentLinks) {
|
||||
return ShareLinksNavIDs.DocumentLinks
|
||||
}
|
||||
return ShareLinksNavIDs.Bundles
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ export const FILTER_MODIFIED_AFTER = 16
|
||||
export const FILTER_TITLE_CONTENT = 19 // Deprecated in favor of Tantivy-backed `text` filtervar. Keep for now for existing saved views
|
||||
export const FILTER_SIMPLE_TITLE = 48
|
||||
export const FILTER_SIMPLE_TEXT = 49
|
||||
export const FILTER_HAS_DUPLICATES = 50
|
||||
export const FILTER_FULLTEXT_QUERY = 20
|
||||
export const FILTER_FULLTEXT_MORELIKE = 21
|
||||
|
||||
@@ -382,6 +383,13 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
|
||||
datatype: 'string',
|
||||
multi: false,
|
||||
},
|
||||
{
|
||||
id: FILTER_HAS_DUPLICATES,
|
||||
filtervar: 'has_duplicates',
|
||||
datatype: 'boolean',
|
||||
multi: false,
|
||||
default: true,
|
||||
},
|
||||
]
|
||||
|
||||
export interface FilterRuleType {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { ObjectWithId } from './object-with-id'
|
||||
|
||||
export type OcrZoneTarget = 'custom_field' | 'title' | 'asn' | 'created'
|
||||
export type OcrBuiltinTarget = Exclude<OcrZoneTarget, 'custom_field'>
|
||||
export type OcrZoneTransform =
|
||||
| 'none'
|
||||
| 'strip'
|
||||
| 'uppercase'
|
||||
| 'lowercase'
|
||||
| 'numeric'
|
||||
| 'strip_punctuation'
|
||||
| 'date'
|
||||
| 'qr_code'
|
||||
|
||||
export const OCR_ZONE_TARGET = {
|
||||
CustomField: 'custom_field',
|
||||
Title: 'title',
|
||||
Asn: 'asn',
|
||||
Created: 'created',
|
||||
} as const satisfies Record<string, OcrZoneTarget>
|
||||
|
||||
export const OCR_ZONE_TRANSFORM = {
|
||||
None: 'none',
|
||||
Strip: 'strip',
|
||||
Uppercase: 'uppercase',
|
||||
Lowercase: 'lowercase',
|
||||
Numeric: 'numeric',
|
||||
StripPunctuation: 'strip_punctuation',
|
||||
Date: 'date',
|
||||
QrCode: 'qr_code',
|
||||
} as const satisfies Record<string, OcrZoneTransform>
|
||||
|
||||
export const DEFAULT_OCR_ZONE_TARGET = OCR_ZONE_TARGET.CustomField
|
||||
export const DEFAULT_OCR_ZONE_TRANSFORM = OCR_ZONE_TRANSFORM.Strip
|
||||
export const DEFAULT_OCR_ZONE_LANGUAGE = 'deu+eng'
|
||||
|
||||
export function isOcrBuiltinTarget(value: unknown): value is OcrBuiltinTarget {
|
||||
return (
|
||||
value === OCR_ZONE_TARGET.Title ||
|
||||
value === OCR_ZONE_TARGET.Asn ||
|
||||
value === OCR_ZONE_TARGET.Created
|
||||
)
|
||||
}
|
||||
|
||||
export const OCR_BUILTIN_TARGETS = [
|
||||
{ id: OCR_ZONE_TARGET.Title, name: $localize`Title` },
|
||||
{ id: OCR_ZONE_TARGET.Asn, name: $localize`Archive serial number` },
|
||||
{ id: OCR_ZONE_TARGET.Created, name: $localize`Date created` },
|
||||
]
|
||||
|
||||
export interface OcrTemplateZone {
|
||||
id?: number
|
||||
name: string
|
||||
target?: OcrZoneTarget
|
||||
custom_field: number | null
|
||||
page?: number
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
ocr_language: string
|
||||
transform: OcrZoneTransform
|
||||
date_format?: string
|
||||
validation_regex: string
|
||||
order: number
|
||||
zone_source_width?: number
|
||||
zone_source_height?: number
|
||||
}
|
||||
|
||||
export const TRANSFORM_OPTIONS = [
|
||||
{ id: OCR_ZONE_TRANSFORM.None, name: $localize`None` },
|
||||
{ id: OCR_ZONE_TRANSFORM.Strip, name: $localize`Strip whitespace` },
|
||||
{ id: OCR_ZONE_TRANSFORM.Uppercase, name: $localize`Uppercase` },
|
||||
{ id: OCR_ZONE_TRANSFORM.Lowercase, name: $localize`Lowercase` },
|
||||
{ id: OCR_ZONE_TRANSFORM.Numeric, name: $localize`Numeric only` },
|
||||
{
|
||||
id: OCR_ZONE_TRANSFORM.StripPunctuation,
|
||||
name: $localize`Remove leading/trailing punctuation`,
|
||||
},
|
||||
{ id: OCR_ZONE_TRANSFORM.Date, name: $localize`Parse date` },
|
||||
{ id: OCR_ZONE_TRANSFORM.QrCode, name: $localize`Read QR/barcode` },
|
||||
]
|
||||
|
||||
export const OCR_LANGUAGE_OPTIONS = [
|
||||
{ id: 'eng', name: $localize`English` },
|
||||
{ id: 'deu', name: $localize`German` },
|
||||
{ id: 'fra', name: $localize`French` },
|
||||
{ id: 'ita', name: $localize`Italian` },
|
||||
{ id: 'spa', name: $localize`Spanish` },
|
||||
{ id: 'por', name: $localize`Portuguese` },
|
||||
{ id: 'nld', name: $localize`Dutch` },
|
||||
]
|
||||
|
||||
export const DATE_FORMAT_OPTIONS = [
|
||||
{ id: '', name: $localize`Auto-detect` },
|
||||
{ id: '%d.%m.%Y', name: 'DD.MM.YYYY' },
|
||||
{ id: '%Y/%m/%d', name: 'YYYY/MM/DD' },
|
||||
{ id: '%d/%m/%Y', name: 'DD/MM/YYYY' },
|
||||
]
|
||||
|
||||
export interface OcrTemplate extends ObjectWithId {
|
||||
name: string
|
||||
document_type: number
|
||||
sample_document: number | null
|
||||
source_width: number
|
||||
source_height: number
|
||||
enabled: boolean
|
||||
combine_formats?: Record<string, string>
|
||||
created?: string
|
||||
updated?: string
|
||||
zones: OcrTemplateZone[]
|
||||
}
|
||||
|
||||
export interface ZoneTestRequest {
|
||||
name: string
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
page: number
|
||||
ocr_language: string
|
||||
transform: OcrZoneTransform
|
||||
date_format?: string
|
||||
validation_regex: string
|
||||
zone_source_width?: number
|
||||
zone_source_height?: number
|
||||
}
|
||||
|
||||
export interface OcrZoneTestResult {
|
||||
raw_text?: string | null
|
||||
value?: string | null
|
||||
regex?: string
|
||||
regex_match?: boolean | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface OcrZoneRunResult {
|
||||
template: string
|
||||
zone: string
|
||||
custom_field: string
|
||||
value: string | number | null
|
||||
}
|
||||
@@ -422,6 +422,7 @@ export const PaperlessConfigOptions: ConfigOption[] = [
|
||||
]
|
||||
|
||||
export interface PaperlessConfig extends ObjectWithId {
|
||||
externally_configured_variables: string[]
|
||||
output_type: OutputTypeConfig
|
||||
pages: number
|
||||
language: string
|
||||
|
||||
@@ -26,7 +26,5 @@ export interface ShareLink extends ObjectWithPermissions {
|
||||
|
||||
document: number // Document
|
||||
|
||||
document_title?: string
|
||||
|
||||
file_version: string
|
||||
}
|
||||
|
||||
@@ -84,6 +84,8 @@ export const SETTINGS_KEYS = {
|
||||
'general-settings:document-editing:remove-inbox-tags',
|
||||
DOCUMENT_EDITING_OVERLAY_THUMBNAIL:
|
||||
'general-settings:document-editing:overlay-thumbnail',
|
||||
DOCUMENT_EDITING_AUTO_SUGGEST:
|
||||
'general-settings:document-editing:auto-suggest',
|
||||
DOCUMENT_DETAILS_HIDDEN_FIELDS:
|
||||
'general-settings:document-details:hidden-fields',
|
||||
SEARCH_DB_ONLY: 'general-settings:search:db-only',
|
||||
@@ -228,8 +230,6 @@ export const SETTINGS: UiSetting[] = [
|
||||
document_types: 25,
|
||||
tags: 25,
|
||||
storage_paths: 25,
|
||||
share_links: 25,
|
||||
share_link_bundles: 25,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -302,6 +302,11 @@ export const SETTINGS: UiSetting[] = [
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST,
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS,
|
||||
type: 'array',
|
||||
|
||||
@@ -29,6 +29,7 @@ export enum PermissionType {
|
||||
ShareLinkBundle = '%s_sharelinkbundle',
|
||||
CustomField = '%s_customfield',
|
||||
Workflow = '%s_workflow',
|
||||
OcrTemplate = '%s_ocrtemplate',
|
||||
ProcessedMail = '%s_processedmail',
|
||||
GlobalStatistics = '%s_global_statistics',
|
||||
SystemMonitoring = '%s_system_monitoring',
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { DocumentMetadata } from 'src/app/data/document-metadata'
|
||||
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
|
||||
import { FilterRule } from 'src/app/data/filter-rule'
|
||||
import { OcrZoneRunResult } from 'src/app/data/ocr-template'
|
||||
import { Results, SelectionData } from 'src/app/data/results'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { queryParamsFromFilterRules } from '../../utils/query-params'
|
||||
@@ -359,6 +360,13 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
||||
})
|
||||
}
|
||||
|
||||
runZoneOcr(id: number): Observable<{ results: OcrZoneRunResult[] }> {
|
||||
return this.http.post<{ results: OcrZoneRunResult[] }>(
|
||||
this.getResourceUrl(id, 'run-zone-ocr'),
|
||||
{}
|
||||
)
|
||||
}
|
||||
|
||||
rotateDocuments(
|
||||
selection: DocumentSelectionQuery,
|
||||
degrees: number,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { Observable } from 'rxjs'
|
||||
import {
|
||||
OcrTemplate,
|
||||
OcrZoneTestResult,
|
||||
ZoneTestRequest,
|
||||
} from '../../data/ocr-template'
|
||||
import { AbstractPaperlessService } from './abstract-paperless-service'
|
||||
|
||||
export interface QuickCreateFieldResult {
|
||||
id: number
|
||||
name: string
|
||||
data_type: string
|
||||
created: boolean
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class OcrTemplateService extends AbstractPaperlessService<OcrTemplate> {
|
||||
constructor() {
|
||||
super()
|
||||
this.resourceName = 'ocr_templates'
|
||||
}
|
||||
|
||||
getPageImageUrl(docId: number, page: number): string {
|
||||
return `${this.baseUrl}${this.resourceName}/document-page-image/${docId}/${page}/`
|
||||
}
|
||||
|
||||
testZone(
|
||||
docId: number,
|
||||
zone: ZoneTestRequest
|
||||
): Observable<OcrZoneTestResult> {
|
||||
return this.http.post<OcrZoneTestResult>(
|
||||
`${this.baseUrl}${this.resourceName}/test-zone/`,
|
||||
{ document: docId, zone }
|
||||
)
|
||||
}
|
||||
|
||||
quickCreateField(
|
||||
name: string,
|
||||
dataType: string
|
||||
): Observable<QuickCreateFieldResult> {
|
||||
return this.http.post<QuickCreateFieldResult>(
|
||||
`${this.baseUrl}${this.resourceName}/quick-create-field/`,
|
||||
{ name, data_type: dataType }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -48,4 +48,13 @@ describe('ShareLinkBundleService', () => {
|
||||
expect(req.request.body).toEqual({})
|
||||
req.flush({})
|
||||
})
|
||||
|
||||
it('lists bundles with expected parameters', () => {
|
||||
subscription = service.listAllBundles().subscribe()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}${endpoint}/?page=1&page_size=1000&ordering=-created`
|
||||
)
|
||||
expect(req.request.method).toBe('GET')
|
||||
req.flush({ results: [] })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { Observable } from 'rxjs'
|
||||
import { map } from 'rxjs/operators'
|
||||
import {
|
||||
ShareLinkBundleCreatePayload,
|
||||
ShareLinkBundleSummary,
|
||||
@@ -31,4 +32,10 @@ export class ShareLinkBundleService extends AbstractNameFilterService<ShareLinkB
|
||||
{}
|
||||
)
|
||||
}
|
||||
|
||||
listAllBundles(): Observable<ShareLinkBundleSummary[]> {
|
||||
return this.list(1, 1000, 'created', true).pipe(
|
||||
map((response) => response.results)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
FILTER_HAS_ANY_TAG,
|
||||
FILTER_HAS_CUSTOM_FIELDS_ALL,
|
||||
FILTER_HAS_CUSTOM_FIELDS_ANY,
|
||||
FILTER_HAS_DUPLICATES,
|
||||
FILTER_HAS_TAGS_ALL,
|
||||
FILTER_SIMPLE_TEXT,
|
||||
FILTER_SIMPLE_TITLE,
|
||||
@@ -132,6 +133,16 @@ describe('QueryParams Utils', () => {
|
||||
is_tagged: 0,
|
||||
})
|
||||
|
||||
params = queryParamsFromFilterRules([
|
||||
{
|
||||
rule_type: FILTER_HAS_DUPLICATES,
|
||||
value: 'false',
|
||||
},
|
||||
])
|
||||
expect(params).toEqual({
|
||||
has_duplicates: 0,
|
||||
})
|
||||
|
||||
params = queryParamsFromFilterRules([
|
||||
{
|
||||
rule_type: FILTER_TITLE_CONTENT,
|
||||
@@ -247,6 +258,18 @@ describe('QueryParams Utils', () => {
|
||||
},
|
||||
])
|
||||
|
||||
rules = filterRulesFromQueryParams(
|
||||
convertToParamMap({
|
||||
has_duplicates: 'true',
|
||||
})
|
||||
)
|
||||
expect(rules).toEqual([
|
||||
{
|
||||
rule_type: FILTER_HAS_DUPLICATES,
|
||||
value: 'true',
|
||||
},
|
||||
])
|
||||
|
||||
rules = filterRulesFromQueryParams(
|
||||
convertToParamMap({
|
||||
correspondent__isnull: '1',
|
||||
|
||||
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 7.6 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 198.4 238.9" style="enable-background:new 0 0 198.4 238.9" xml:space="preserve">
|
||||
<path d="M194.7 0C164.211 70.943 17.64 79.733 64.55 194.06c.59 1.468-10.848 17-18.47 29.897-1.758-6.453-3.816-13.486-3.516-14.075 38.109-45.141-27.26-70.643-30.776-107.583-16.423 29.318-22.286 80.623 27.25 110.23.29 0 2.637 11.138 3.816 16.712-1.169 2.348-2.348 4.695-2.927 6.454-1.168 2.926 7.622 2.637 7.622 3.226.879-.29 21.697-36.94 22.276-37.23C187.667 174.711 208.485 68.596 194.699 0zm-60.096 74.749c-55.11 49.246-64.49 85.897-62.732 103.777-18.47-43.682 35.772-91.76 62.732-103.777zM28.2 145.102c10.548 9.67 28.14 39.278 13.196 56.58 3.506-7.912 4.684-25.793-13.196-56.58z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 727 B |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2897.4 896.6" style="enable-background:new 0 0 2897.4 896.6" xml:space="preserve">
|
||||
<path d="M1022.3 428.7c-17.8-19.9-42.7-29.8-74.7-29.8-22.3 0-42.4 5.7-60.5 17.3-18.1 11.6-32.3 27.5-42.5 47.8s-15.3 42.9-15.3 67.8 5.1 47.5 15.3 67.8c10.3 20.3 24.4 36.2 42.5 47.8 18.1 11.5 38.3 17.3 60.5 17.3 32 0 56.9-9.9 74.7-29.8V655.5h84.5V408.3h-84.5v20.4zM1010.5 575c-10.2 11.7-23.6 17.6-40.2 17.6s-29.9-5.9-40-17.6-15.1-26.1-15.1-43.3c0-17.1 5-31.6 15.1-43.3s23.4-17.6 40-17.6 30 5.9 40.2 17.6 15.3 26.1 15.3 43.3-5.1 31.6-15.3 43.3zM1381 416.1c-18.1-11.5-38.3-17.3-60.5-17.4-32 0-56.9 9.9-74.7 29.8v-20.4h-84.5v390.7h84.5v-164c17.8 19.9 42.7 29.8 74.7 29.8 22.3 0 42.4-5.7 60.5-17.3s32.3-27.5 42.5-47.8c10.2-20.3 15.3-42.9 15.3-67.8s-5.1-47.5-15.3-67.8c-10.3-20.3-24.4-36.2-42.5-47.8zM1337.9 575c-10.1 11.7-23.4 17.6-40 17.6s-29.9-5.9-40-17.6-15.1-26.1-15.1-43.3c0-17.1 5-31.6 15.1-43.3s23.4-17.6 40-17.6 29.9 5.9 40 17.6 15.1 26.1 15.1 43.3-5.1 31.6-15.1 43.3zM1672.2 416.8c-20.5-12-43-18-67.6-18-24.9 0-47.6 5.9-68 17.6-20.4 11.7-36.5 27.7-48.2 48s-17.6 42.7-17.6 67.3c.3 25.2 6.2 47.8 17.8 68 11.5 20.2 28 36 49.3 47.6 21.3 11.5 45.9 17.3 73.8 17.3 48.6 0 86.8-14.7 114.7-44l-52.5-48.9c-8.6 8.3-17.6 14.6-26.7 19-9.3 4.3-21.1 6.4-35.3 6.4-11.6 0-22.5-3.6-32.7-10.9-10.3-7.3-17.1-16.5-20.7-27.8h180l.4-11.6c0-29.6-6-55.7-18-78.2s-28.3-39.8-48.7-51.8zm-113.9 86.4c2.1-12.1 7.5-21.8 16.2-29.1s18.7-10.9 30-10.9 21.2 3.6 29.8 10.9c8.6 7.2 13.9 16.9 16 29.1h-92zM1895.3 411.7c-11 5.6-20.3 13.7-28 24.4h-.1v-28h-84.5v247.3h84.5V536.3c0-22.6 4.7-38.1 14.2-46.5 9.5-8.5 22.7-12.7 39.6-12.7 6.2 0 13.5 1 21.8 3.1l10.7-72c-5.9-3.3-14.5-4.9-25.8-4.9-10.6 0-21.4 2.8-32.4 8.4zM1985 277.4h84.5v377.8H1985zM2313.2 416.8c-20.5-12-43-18-67.6-18-24.9 0-47.6 5.9-68 17.6s-36.5 27.7-48.2 48c-11.7 20.3-17.6 42.7-17.6 67.3.3 25.2 6.2 47.8 17.8 68 11.5 20.2 28 36 49.3 47.6 21.3 11.5 45.9 17.3 73.8 17.3 48.6 0 86.8-14.7 114.7-44l-52.5-48.9c-8.6 8.3-17.6 14.6-26.7 19-9.3 4.3-21.1 6.4-35.3 6.4-11.6 0-22.5-3.6-32.7-10.9-10.3-7.3-17.1-16.5-20.7-27.8h180l.4-11.6c0-29.6-6-55.7-18-78.2s-28.3-39.8-48.7-51.8zm-113.9 86.4c2.1-12.1 7.5-21.8 16.2-29.1s18.7-10.9 30-10.9 21.2 3.6 29.8 10.9c8.6 7.2 13.9 16.9 16 29.1h-92zM2583.6 507.7c-13.8-4.4-30.6-8.1-50.5-11.1-15.1-2.7-26.1-5.2-32.9-7.6-6.8-2.4-10.2-6.1-10.2-11.1s2.3-8.7 6.7-10.9c4.4-2.2 11.5-3.3 21.3-3.3 11.6 0 24.3 2.4 38.1 7.2 13.9 4.8 26.2 11 36.9 18.4l32.4-58.2c-11.3-7.4-26.2-14.7-44.9-21.8-18.7-7.1-39.6-10.7-62.7-10.7-33.7 0-60.2 7.6-79.3 22.7-19.1 15.1-28.7 36.1-28.7 63.1 0 19 4.8 33.9 14.4 44.7 9.6 10.8 21 18.5 34 22.9 13.1 4.5 28.9 8.3 47.6 11.6 14.6 2.7 25.1 5.3 31.6 7.8s9.8 6.5 9.8 11.8c0 10.4-9.7 15.6-29.3 15.6-13.7 0-28.5-2.3-44.7-6.9-16.1-4.6-29.2-11.3-39.3-20.2l-33.3 60c9.2 7.4 24.6 14.7 46.2 22 21.7 7.3 45.2 10.9 70.7 10.9 34.7 0 62.9-7.4 84.5-22.4 21.7-15 32.5-37.3 32.5-66.9 0-19.3-5-34.2-15.1-44.9s-22-18.3-35.8-22.7zM2883.4 575.3c0-19.3-5-34.2-15.1-44.9s-22-18.3-35.8-22.7c-13.8-4.4-30.6-8.1-50.5-11.1-15.1-2.7-26.1-5.2-32.9-7.6-6.8-2.4-10.2-6.1-10.2-11.1s2.3-8.7 6.7-10.9c4.4-2.2 11.5-3.3 21.3-3.3 11.6 0 24.3 2.4 38.1 7.2 13.9 4.8 26.2 11 36.9 18.4l32.4-58.2c-11.3-7.4-26.2-14.7-44.9-21.8-18.7-7.1-39.6-10.7-62.7-10.7-33.7 0-60.2 7.6-79.3 22.7-19.1 15.1-28.7 36.1-28.7 63.1 0 19 4.8 33.9 14.4 44.7 9.6 10.8 21 18.5 34 22.9 13.1 4.5 28.9 8.3 47.6 11.6 14.6 2.7 25.1 5.3 31.6 7.8s9.8 6.5 9.8 11.8c0 10.4-9.7 15.6-29.3 15.6-13.7 0-28.5-2.3-44.7-6.9-16.1-4.6-29.2-11.3-39.3-20.2l-33.3 60c9.2 7.4 24.6 14.7 46.2 22 21.7 7.3 45.2 10.9 70.7 10.9 34.7 0 62.9-7.4 84.5-22.4 21.7-15 32.5-37.3 32.5-66.9zM2460.7 738.7h59.6v17.2h-59.6zM2596.5 706.4c-5.7 0-11 1-15.8 3s-9 5-12.5 8.9v-9.4h-19.4v93.6h19.4v-52c0-8.6 2.1-15.3 6.3-20 4.2-4.7 9.5-7.1 15.9-7.1 7.8 0 13.4 2.3 16.8 6.7 3.4 4.5 5.1 11.3 5.1 20.5v52h19.4v-56.8c0-12.8-3.2-22.6-9.5-29.3-6.4-6.7-14.9-10.1-25.7-10.1zM2733.8 717.7c-3.6-3.4-7.9-6.1-13.1-8.2s-10.6-3.1-16.2-3.1c-8.7 0-16.5 2.1-23.5 6.3s-12.5 10-16.5 17.3c-4 7.3-6 15.4-6 24.4 0 8.9 2 17.1 6 24.3 4 7.3 9.5 13 16.5 17.2s14.9 6.3 23.5 6.3c5.6 0 11-1 16.2-3.1 5.1-2.1 9.5-4.8 13.1-8.2v24.4c0 8.5-2.5 14.8-7.6 18.7-5 3.9-11 5.9-18 5.9-6.7 0-12.4-1.6-17.3-4.7-4.8-3.1-7.6-7.7-8.3-13.8h-19.4c.6 7.7 2.9 14.2 7.1 19.5s9.6 9.3 16.2 12c6.6 2.7 13.8 4 21.7 4 12.8 0 23.5-3.4 32-10.1 8.6-6.7 12.8-17.1 12.8-31.1V708.9h-19.2v8.8zm-1.6 52.4c-2.5 4.7-6 8.3-10.4 11.2-4.4 2.7-9.4 4-14.9 4-5.7 0-10.8-1.4-15.2-4.3s-7.8-6.7-10.2-11.4c-2.3-4.8-3.5-9.8-3.5-15.2 0-5.5 1.1-10.6 3.5-15.3s5.8-8.5 10.2-11.3 9.5-4.2 15.2-4.2c5.5 0 10.5 1.4 14.9 4s7.9 6.3 10.4 11 3.8 10 3.8 15.8-1.3 11-3.8 15.7zM2867.9 708.9h-21.4l-25.6 33-25.4-33h-22.4l36 46.1-37.6 47.5h21.4l27.2-34.6 27.1 34.7h22.4l-37.6-48.2zM757.6 293.7c-20-10.8-42.6-16.2-67.8-16.2H600c-8.5 39.2-21.1 76.4-37.6 111.3-9.9 20.8-21.1 40.6-33.6 59.4v207.2h88.9V521.5h72c25.2 0 47.8-5.4 67.8-16.2s35.7-25.6 47.1-44.2c11.4-18.7 17.1-39.1 17.1-61.3.1-22.7-5.6-43.3-17-61.9-11.4-18.7-27.1-33.4-47.1-44.2zm-41 140.6c-9.3 8.9-21.6 13.3-36.7 13.3l-62.2.4v-92.5l62.2-.4c15.1 0 27.3 4.4 36.7 13.3 9.4 8.9 14 19.9 14 32.9 0 13.2-4.6 24.1-14 33z"/>
|
||||
<path d="M140 713.7c-3.4-16.4-10.3-49.1-11.2-49.1C-16.9 577.5.4 426.6 48.6 340.4 59 449 251.2 524 139.1 656.8c-.9 1.7 5.2 22.4 10.3 41.4 22.4-37.9 56-83.6 54.3-87.9C65.9 273.9 496.9 248.1 586.6 39.4c40.5 201.8-20.7 513.9-367.2 593.2-1.7.9-62.9 108.6-65.5 109.5 0-1.7-25.9-.9-22.4-9.5 1.6-5.2 5.1-12 8.5-18.9zm-4.3-81.1c44-50.9-7.8-137.9-38.8-166.4 52.6 90.5 49.1 143.1 38.8 166.4z" style="fill:#17541f"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.4 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="264.567" height="318.552" viewBox="0 0 70 84.284">
|
||||
<path style="fill:#17541f;stroke-width:1.10017" d="M752.438 82.365C638.02 348.605 87.938 381.61 263.964 810.674c2.2 5.5-40.706 63.81-69.31 112.217-6.602-24.204-14.304-50.607-13.204-52.807C324.473 700.658 79.136 604.944 65.934 466.322 4.324 576.34-17.678 768.868 168.25 879.984c1.1 0 9.902 41.808 14.303 62.711-4.4 8.802-8.802 17.602-11.002 24.203-4.4 11.002 28.603 9.902 28.603 12.102 3.3-1.1 81.413-138.62 83.614-139.72 442.267-101.216 520.377-499.476 468.67-756.915ZM526.904 362.906c-206.831 184.828-242.036 322.35-235.435 389.46-69.31-163.926 134.22-344.353 235.435-389.46ZM127.543 626.947c39.606 36.306 105.616 147.422 49.508 212.332 13.202-29.704 17.602-96.814-49.508-212.332z" transform="matrix(.094 0 0 .094 -2.042 -7.742)" fill="#17541F"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 855 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="264.567" height="318.552" viewBox="0 0 70 84.284">
|
||||
<path style="fill:#fff;stroke-width:1.10017" d="M752.438 82.365C638.02 348.605 87.938 381.61 263.964 810.674c2.2 5.5-40.706 63.81-69.31 112.217-6.602-24.204-14.304-50.607-13.204-52.807C324.473 700.658 79.136 604.944 65.934 466.322 4.324 576.34-17.678 768.868 168.25 879.984c1.1 0 9.902 41.808 14.303 62.711-4.4 8.802-8.802 17.602-11.002 24.203-4.4 11.002 28.603 9.902 28.603 12.102 3.3-1.1 81.413-138.62 83.614-139.72 442.267-101.216 520.377-499.476 468.67-756.915ZM526.904 362.906c-206.831 184.828-242.036 322.35-235.435 389.46-69.31-163.926 134.22-344.353 235.435-389.46ZM127.543 626.947c39.606 36.306 105.616 147.422 49.508 212.332 13.202-29.704 17.602-96.814-49.508-212.332z" transform="matrix(.094 0 0 .094 -2.042 -7.742)" fill="#fff"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 849 B |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2897.4 896.6" style="enable-background:new 0 0 2897.4 896.6" xml:space="preserve">
|
||||
<path d="M1022.3 428.7c-17.8-19.9-42.7-29.8-74.7-29.8-22.3 0-42.4 5.7-60.5 17.3-18.1 11.6-32.3 27.5-42.5 47.8s-15.3 42.9-15.3 67.8 5.1 47.5 15.3 67.8c10.3 20.3 24.4 36.2 42.5 47.8 18.1 11.5 38.3 17.3 60.5 17.3 32 0 56.9-9.9 74.7-29.8V655.5h84.5V408.3h-84.5v20.4zM1010.5 575c-10.2 11.7-23.6 17.6-40.2 17.6s-29.9-5.9-40-17.6-15.1-26.1-15.1-43.3c0-17.1 5-31.6 15.1-43.3s23.4-17.6 40-17.6 30 5.9 40.2 17.6 15.3 26.1 15.3 43.3-5.1 31.6-15.3 43.3zM1381 416.1c-18.1-11.5-38.3-17.3-60.5-17.4-32 0-56.9 9.9-74.7 29.8v-20.4h-84.5v390.7h84.5v-164c17.8 19.9 42.7 29.8 74.7 29.8 22.3 0 42.4-5.7 60.5-17.3s32.3-27.5 42.5-47.8c10.2-20.3 15.3-42.9 15.3-67.8s-5.1-47.5-15.3-67.8c-10.3-20.3-24.4-36.2-42.5-47.8zM1337.9 575c-10.1 11.7-23.4 17.6-40 17.6s-29.9-5.9-40-17.6-15.1-26.1-15.1-43.3c0-17.1 5-31.6 15.1-43.3s23.4-17.6 40-17.6 29.9 5.9 40 17.6 15.1 26.1 15.1 43.3-5.1 31.6-15.1 43.3zM1672.2 416.8c-20.5-12-43-18-67.6-18-24.9 0-47.6 5.9-68 17.6-20.4 11.7-36.5 27.7-48.2 48s-17.6 42.7-17.6 67.3c.3 25.2 6.2 47.8 17.8 68 11.5 20.2 28 36 49.3 47.6 21.3 11.5 45.9 17.3 73.8 17.3 48.6 0 86.8-14.7 114.7-44l-52.5-48.9c-8.6 8.3-17.6 14.6-26.7 19-9.3 4.3-21.1 6.4-35.3 6.4-11.6 0-22.5-3.6-32.7-10.9-10.3-7.3-17.1-16.5-20.7-27.8h180l.4-11.6c0-29.6-6-55.7-18-78.2s-28.3-39.8-48.7-51.8zm-113.9 86.4c2.1-12.1 7.5-21.8 16.2-29.1s18.7-10.9 30-10.9 21.2 3.6 29.8 10.9c8.6 7.2 13.9 16.9 16 29.1h-92zM1895.3 411.7c-11 5.6-20.3 13.7-28 24.4h-.1v-28h-84.5v247.3h84.5V536.3c0-22.6 4.7-38.1 14.2-46.5 9.5-8.5 22.7-12.7 39.6-12.7 6.2 0 13.5 1 21.8 3.1l10.7-72c-5.9-3.3-14.5-4.9-25.8-4.9-10.6 0-21.4 2.8-32.4 8.4zM1985 277.4h84.5v377.8H1985zM2313.2 416.8c-20.5-12-43-18-67.6-18-24.9 0-47.6 5.9-68 17.6s-36.5 27.7-48.2 48c-11.7 20.3-17.6 42.7-17.6 67.3.3 25.2 6.2 47.8 17.8 68 11.5 20.2 28 36 49.3 47.6 21.3 11.5 45.9 17.3 73.8 17.3 48.6 0 86.8-14.7 114.7-44l-52.5-48.9c-8.6 8.3-17.6 14.6-26.7 19-9.3 4.3-21.1 6.4-35.3 6.4-11.6 0-22.5-3.6-32.7-10.9-10.3-7.3-17.1-16.5-20.7-27.8h180l.4-11.6c0-29.6-6-55.7-18-78.2s-28.3-39.8-48.7-51.8zm-113.9 86.4c2.1-12.1 7.5-21.8 16.2-29.1s18.7-10.9 30-10.9 21.2 3.6 29.8 10.9c8.6 7.2 13.9 16.9 16 29.1h-92zM2583.6 507.7c-13.8-4.4-30.6-8.1-50.5-11.1-15.1-2.7-26.1-5.2-32.9-7.6-6.8-2.4-10.2-6.1-10.2-11.1s2.3-8.7 6.7-10.9c4.4-2.2 11.5-3.3 21.3-3.3 11.6 0 24.3 2.4 38.1 7.2 13.9 4.8 26.2 11 36.9 18.4l32.4-58.2c-11.3-7.4-26.2-14.7-44.9-21.8-18.7-7.1-39.6-10.7-62.7-10.7-33.7 0-60.2 7.6-79.3 22.7-19.1 15.1-28.7 36.1-28.7 63.1 0 19 4.8 33.9 14.4 44.7 9.6 10.8 21 18.5 34 22.9 13.1 4.5 28.9 8.3 47.6 11.6 14.6 2.7 25.1 5.3 31.6 7.8s9.8 6.5 9.8 11.8c0 10.4-9.7 15.6-29.3 15.6-13.7 0-28.5-2.3-44.7-6.9-16.1-4.6-29.2-11.3-39.3-20.2l-33.3 60c9.2 7.4 24.6 14.7 46.2 22 21.7 7.3 45.2 10.9 70.7 10.9 34.7 0 62.9-7.4 84.5-22.4 21.7-15 32.5-37.3 32.5-66.9 0-19.3-5-34.2-15.1-44.9s-22-18.3-35.8-22.7zM2883.4 575.3c0-19.3-5-34.2-15.1-44.9s-22-18.3-35.8-22.7c-13.8-4.4-30.6-8.1-50.5-11.1-15.1-2.7-26.1-5.2-32.9-7.6-6.8-2.4-10.2-6.1-10.2-11.1s2.3-8.7 6.7-10.9c4.4-2.2 11.5-3.3 21.3-3.3 11.6 0 24.3 2.4 38.1 7.2 13.9 4.8 26.2 11 36.9 18.4l32.4-58.2c-11.3-7.4-26.2-14.7-44.9-21.8-18.7-7.1-39.6-10.7-62.7-10.7-33.7 0-60.2 7.6-79.3 22.7-19.1 15.1-28.7 36.1-28.7 63.1 0 19 4.8 33.9 14.4 44.7 9.6 10.8 21 18.5 34 22.9 13.1 4.5 28.9 8.3 47.6 11.6 14.6 2.7 25.1 5.3 31.6 7.8s9.8 6.5 9.8 11.8c0 10.4-9.7 15.6-29.3 15.6-13.7 0-28.5-2.3-44.7-6.9-16.1-4.6-29.2-11.3-39.3-20.2l-33.3 60c9.2 7.4 24.6 14.7 46.2 22 21.7 7.3 45.2 10.9 70.7 10.9 34.7 0 62.9-7.4 84.5-22.4 21.7-15 32.5-37.3 32.5-66.9zM2460.7 738.7h59.6v17.2h-59.6zM2596.5 706.4c-5.7 0-11 1-15.8 3s-9 5-12.5 8.9v-9.4h-19.4v93.6h19.4v-52c0-8.6 2.1-15.3 6.3-20 4.2-4.7 9.5-7.1 15.9-7.1 7.8 0 13.4 2.3 16.8 6.7 3.4 4.5 5.1 11.3 5.1 20.5v52h19.4v-56.8c0-12.8-3.2-22.6-9.5-29.3-6.4-6.7-14.9-10.1-25.7-10.1zM2733.8 717.7c-3.6-3.4-7.9-6.1-13.1-8.2s-10.6-3.1-16.2-3.1c-8.7 0-16.5 2.1-23.5 6.3s-12.5 10-16.5 17.3c-4 7.3-6 15.4-6 24.4 0 8.9 2 17.1 6 24.3 4 7.3 9.5 13 16.5 17.2s14.9 6.3 23.5 6.3c5.6 0 11-1 16.2-3.1 5.1-2.1 9.5-4.8 13.1-8.2v24.4c0 8.5-2.5 14.8-7.6 18.7-5 3.9-11 5.9-18 5.9-6.7 0-12.4-1.6-17.3-4.7-4.8-3.1-7.6-7.7-8.3-13.8h-19.4c.6 7.7 2.9 14.2 7.1 19.5s9.6 9.3 16.2 12c6.6 2.7 13.8 4 21.7 4 12.8 0 23.5-3.4 32-10.1 8.6-6.7 12.8-17.1 12.8-31.1V708.9h-19.2v8.8zm-1.6 52.4c-2.5 4.7-6 8.3-10.4 11.2-4.4 2.7-9.4 4-14.9 4-5.7 0-10.8-1.4-15.2-4.3s-7.8-6.7-10.2-11.4c-2.3-4.8-3.5-9.8-3.5-15.2 0-5.5 1.1-10.6 3.5-15.3s5.8-8.5 10.2-11.3 9.5-4.2 15.2-4.2c5.5 0 10.5 1.4 14.9 4s7.9 6.3 10.4 11 3.8 10 3.8 15.8-1.3 11-3.8 15.7zM2867.9 708.9h-21.4l-25.6 33-25.4-33h-22.4l36 46.1-37.6 47.5h21.4l27.2-34.6 27.1 34.7h22.4l-37.6-48.2zM757.6 293.7c-20-10.8-42.6-16.2-67.8-16.2H600c-8.5 39.2-21.1 76.4-37.6 111.3-9.9 20.8-21.1 40.6-33.6 59.4v207.2h88.9V521.5h72c25.2 0 47.8-5.4 67.8-16.2s35.7-25.6 47.1-44.2c11.4-18.7 17.1-39.1 17.1-61.3.1-22.7-5.6-43.3-17-61.9-11.4-18.7-27.1-33.4-47.1-44.2zm-41 140.6c-9.3 8.9-21.6 13.3-36.7 13.3l-62.2.4v-92.5l62.2-.4c15.1 0 27.3 4.4 36.7 13.3 9.4 8.9 14 19.9 14 32.9 0 13.2-4.6 24.1-14 33z"/>
|
||||
<path d="M140 713.7c-3.4-16.4-10.3-49.1-11.2-49.1C-16.9 577.5.4 426.6 48.6 340.4 59 449 251.2 524 139.1 656.8c-.9 1.7 5.2 22.4 10.3 41.4 22.4-37.9 56-83.6 54.3-87.9C65.9 273.9 496.9 248.1 586.6 39.4c40.5 201.8-20.7 513.9-367.2 593.2-1.7.9-62.9 108.6-65.5 109.5 0-1.7-25.9-.9-22.4-9.5 1.6-5.2 5.1-12 8.5-18.9zm-4.3-81.1c44-50.9-7.8-137.9-38.8-166.4 52.6 90.5 49.1 143.1 38.8 166.4z" style="fill:#17541f"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
@@ -8,7 +8,6 @@
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<meta name="theme-color" content="#17541f" />
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -89,6 +89,7 @@ import {
|
||||
exclamationTriangleFill,
|
||||
eye,
|
||||
fileEarmark,
|
||||
fileEarmarkBreak,
|
||||
fileEarmarkCheck,
|
||||
fileEarmarkDiff,
|
||||
fileEarmarkFill,
|
||||
@@ -99,6 +100,7 @@ import {
|
||||
fileEarmarkPlus,
|
||||
fileEarmarkRichtext,
|
||||
fileEarmarkSpreadsheet,
|
||||
fileEarmarkRuled,
|
||||
fileText,
|
||||
files,
|
||||
filter,
|
||||
@@ -338,6 +340,7 @@ const icons = {
|
||||
exclamationTriangleFill,
|
||||
eye,
|
||||
fileEarmark,
|
||||
fileEarmarkBreak,
|
||||
fileEarmarkCheck,
|
||||
fileEarmarkDiff,
|
||||
fileEarmarkFill,
|
||||
@@ -348,6 +351,7 @@ const icons = {
|
||||
fileEarmarkPlus,
|
||||
fileEarmarkRichtext,
|
||||
fileEarmarkSpreadsheet,
|
||||
fileEarmarkRuled,
|
||||
files,
|
||||
fileText,
|
||||
filter,
|
||||
|
||||
@@ -4,12 +4,20 @@
|
||||
"display": "standalone",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "256x256"
|
||||
"src": "icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "assets/logo-notext.svg",
|
||||
"sizes": "any"
|
||||
"src": "icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icon-512-maskable.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"name": "Paperless-ngx",
|
||||
|
||||
@@ -626,10 +626,6 @@ ul.pagination {
|
||||
table.table {
|
||||
--bs-table-color: var(--bs-body-color);
|
||||
--bs-table-bg: var(--bs-light-rgb);
|
||||
|
||||
&.bg-body {
|
||||
--bs-table-bg: var(--bs-body-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.close {
|
||||
|
||||
@@ -13,8 +13,11 @@ class DocumentsConfig(AppConfig):
|
||||
from documents.signals.handlers import add_inbox_tags
|
||||
from documents.signals.handlers import add_or_update_document_in_llm_index
|
||||
from documents.signals.handlers import add_to_index
|
||||
from documents.signals.handlers import capture_old_document_type
|
||||
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 run_zone_ocr_on_type_change
|
||||
from documents.signals.handlers import send_websocket_document_updated
|
||||
from documents.signals.handlers import set_correspondent
|
||||
from documents.signals.handlers import set_document_type
|
||||
@@ -29,6 +32,16 @@ class DocumentsConfig(AppConfig):
|
||||
document_consumption_finished.connect(add_to_index)
|
||||
document_consumption_finished.connect(run_workflows_added)
|
||||
document_consumption_finished.connect(add_or_update_document_in_llm_index)
|
||||
document_consumption_finished.connect(run_zone_ocr_extraction)
|
||||
|
||||
from django.db.models.signals import post_save
|
||||
from django.db.models.signals import pre_save
|
||||
|
||||
from documents.models import Document
|
||||
|
||||
pre_save.connect(capture_old_document_type, sender=Document)
|
||||
post_save.connect(run_zone_ocr_on_type_change, sender=Document)
|
||||
|
||||
document_updated.connect(run_workflows_updated)
|
||||
document_updated.connect(send_websocket_document_updated)
|
||||
document_updated.connect(add_or_update_document_in_llm_index)
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Literal
|
||||
@@ -379,7 +380,7 @@ def delete(doc_ids: list[int]) -> Literal["OK"]:
|
||||
)
|
||||
delete_ids = list({*doc_ids, *version_ids})
|
||||
|
||||
Document.objects.filter(id__in=delete_ids).delete()
|
||||
Document.objects.filter(id__in=delete_ids).delete(transaction_id=uuid.uuid4())
|
||||
|
||||
from documents.search import get_backend
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import FieldError
|
||||
from django.db.models import Case
|
||||
from django.db.models import CharField
|
||||
from django.db.models import Count
|
||||
@@ -25,6 +24,7 @@ from django.db.models import Sum
|
||||
from django.db.models import Value
|
||||
from django.db.models import When
|
||||
from django.db.models.functions import Cast
|
||||
from django.db.models.functions import NullIf
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_filters import DateFilter
|
||||
from django_filters.rest_framework import BooleanFilter
|
||||
@@ -50,7 +50,9 @@ from documents.models import ShareLink
|
||||
from documents.models import ShareLinkBundle
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.permissions import permitted_document_ids
|
||||
from documents.permissions import permitted_object_ids
|
||||
from documents.versioning import annotate_effective_content
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
@@ -180,14 +182,9 @@ class TitleContentFilter(Filter):
|
||||
logger.warning(
|
||||
"Deprecated document filter parameter 'title_content' used; use `text` instead.",
|
||||
)
|
||||
try:
|
||||
return qs.filter(
|
||||
Q(title__icontains=value) | Q(effective_content__icontains=value),
|
||||
)
|
||||
except FieldError:
|
||||
return qs.filter(
|
||||
Q(title__icontains=value) | Q(content__icontains=value),
|
||||
)
|
||||
return annotate_effective_content(qs).filter(
|
||||
Q(title__icontains=value) | Q(effective_content__icontains=value),
|
||||
)
|
||||
else:
|
||||
return qs
|
||||
|
||||
@@ -198,14 +195,9 @@ class EffectiveContentFilter(Filter):
|
||||
value = value.strip() if isinstance(value, str) else value
|
||||
if not value:
|
||||
return qs
|
||||
try:
|
||||
return qs.filter(
|
||||
**{f"effective_content__{self.lookup_expr}": value},
|
||||
)
|
||||
except FieldError:
|
||||
return qs.filter(
|
||||
**{f"content__{self.lookup_expr}": value},
|
||||
)
|
||||
return annotate_effective_content(qs).filter(
|
||||
**{f"effective_content__{self.lookup_expr}": value},
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_field(serializers.BooleanField)
|
||||
@@ -793,6 +785,12 @@ class CustomFieldQueryFilter(Filter):
|
||||
|
||||
|
||||
class DocumentFilterSet(FilterSet):
|
||||
has_duplicates = BooleanFilter(method="filter_has_duplicates")
|
||||
|
||||
def __init__(self, *args: Any, user: Any = None, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self._user = user
|
||||
|
||||
is_tagged = BooleanFilter(
|
||||
label="Is tagged",
|
||||
field_name="tags",
|
||||
@@ -852,6 +850,38 @@ class DocumentFilterSet(FilterSet):
|
||||
|
||||
mime_type = MimeTypeFilter()
|
||||
|
||||
def filter_has_duplicates(self, queryset, name, value):
|
||||
if value is None:
|
||||
return queryset
|
||||
|
||||
user = (
|
||||
self._user
|
||||
if self._user is not None
|
||||
else getattr(self.request, "user", None)
|
||||
)
|
||||
queryset = queryset.alias(
|
||||
nonempty_archive_checksum=NullIf("archive_checksum", Value("")),
|
||||
)
|
||||
|
||||
visible_root_documents = Document.global_objects.filter(
|
||||
root_document__isnull=True,
|
||||
pk__in=permitted_document_ids(
|
||||
user,
|
||||
include_deleted=True,
|
||||
),
|
||||
).exclude(pk=OuterRef("pk"))
|
||||
# see serialisers._get_viewable_duplicates().
|
||||
matching_duplicates = visible_root_documents.filter(
|
||||
Q(checksum=OuterRef("checksum"))
|
||||
| Q(checksum=OuterRef("nonempty_archive_checksum"))
|
||||
| Q(archive_checksum=OuterRef("checksum"))
|
||||
| Q(archive_checksum=OuterRef("nonempty_archive_checksum")),
|
||||
)
|
||||
|
||||
return queryset.alias(
|
||||
has_visible_duplicates=Exists(matching_duplicates),
|
||||
).filter(has_visible_duplicates=value)
|
||||
|
||||
# Backwards compatibility
|
||||
created__date__gt = DateFilter(field_name="created", lookup_expr="gt")
|
||||
created__date__gte = DateFilter(field_name="created", lookup_expr="gte")
|
||||
|
||||
@@ -156,6 +156,15 @@ class FileStabilityTracker:
|
||||
logger.debug(f"File disappeared during stability check: {path}")
|
||||
continue
|
||||
|
||||
# Stable, but empty: some scanners create a zero byte placeholder
|
||||
# and only write the page some time later. Consuming it now can
|
||||
# only fail so drop it and let the writer's next event
|
||||
# (or the periodic rescan) bring it back once it has content
|
||||
if not tracked.last_size:
|
||||
to_remove.append(path)
|
||||
logger.debug("Ignoring stable but empty file: %s", path)
|
||||
continue
|
||||
|
||||
# File is stable, we can return it
|
||||
to_yield.append(path)
|
||||
logger.info(f"File is stable: {path}")
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
# Generated by Django 5.2.14 on 2026-06-16 17:36
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("documents", "0021_widen_workflow_integer_fields"),
|
||||
]
|
||||
|
||||
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")),
|
||||
(
|
||||
"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"),
|
||||
),
|
||||
(
|
||||
"document_type",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="ocr_templates",
|
||||
to="documents.documenttype",
|
||||
verbose_name="document type",
|
||||
),
|
||||
),
|
||||
(
|
||||
"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",),
|
||||
},
|
||||
),
|
||||
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="deu+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"),
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,86 @@
|
||||
# Generated by Django 5.2.16 on 2026-09-05 16:29
|
||||
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("documents", "0025_workflowaction_apply_ai_suggestions"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="document",
|
||||
name="archive_checksum",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
db_index=True,
|
||||
editable=False,
|
||||
help_text="The checksum of the archived document.",
|
||||
max_length=64,
|
||||
null=True,
|
||||
verbose_name="archive checksum",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="savedviewfilterrule",
|
||||
name="rule_type",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
choices=[
|
||||
(0, "title contains"),
|
||||
(1, "content contains"),
|
||||
(2, "ASN is"),
|
||||
(3, "correspondent is"),
|
||||
(4, "document type is"),
|
||||
(5, "is in inbox"),
|
||||
(6, "has tag"),
|
||||
(7, "has any tag"),
|
||||
(8, "created before"),
|
||||
(9, "created after"),
|
||||
(10, "created year is"),
|
||||
(11, "created month is"),
|
||||
(12, "created day is"),
|
||||
(13, "added before"),
|
||||
(14, "added after"),
|
||||
(15, "modified before"),
|
||||
(16, "modified after"),
|
||||
(17, "does not have tag"),
|
||||
(18, "does not have ASN"),
|
||||
(19, "title or content contains"),
|
||||
(20, "fulltext query"),
|
||||
(21, "more like this"),
|
||||
(22, "has tags in"),
|
||||
(23, "ASN greater than"),
|
||||
(24, "ASN less than"),
|
||||
(25, "storage path is"),
|
||||
(26, "has correspondent in"),
|
||||
(27, "does not have correspondent in"),
|
||||
(28, "has document type in"),
|
||||
(29, "does not have document type in"),
|
||||
(30, "has storage path in"),
|
||||
(31, "does not have storage path in"),
|
||||
(32, "owner is"),
|
||||
(33, "has owner in"),
|
||||
(34, "does not have owner"),
|
||||
(35, "does not have owner in"),
|
||||
(36, "has custom field value"),
|
||||
(37, "is shared by me"),
|
||||
(38, "has custom fields"),
|
||||
(39, "has custom field in"),
|
||||
(40, "does not have custom field in"),
|
||||
(41, "does not have custom field"),
|
||||
(42, "custom fields query"),
|
||||
(43, "created to"),
|
||||
(44, "created from"),
|
||||
(45, "added to"),
|
||||
(46, "added from"),
|
||||
(47, "mime type is"),
|
||||
(48, "simple title search"),
|
||||
(49, "simple text search"),
|
||||
(50, "has duplicates"),
|
||||
],
|
||||
verbose_name="rule type",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,4 +1,5 @@
|
||||
import datetime
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
@@ -227,6 +228,7 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
||||
editable=False,
|
||||
blank=True,
|
||||
null=True,
|
||||
db_index=True,
|
||||
help_text=_("The checksum of the archived document."),
|
||||
)
|
||||
|
||||
@@ -513,13 +515,20 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
||||
def delete(
|
||||
self,
|
||||
*args,
|
||||
transaction_id=None,
|
||||
**kwargs,
|
||||
):
|
||||
# If deleting a root document, move all its versions to trash as well.
|
||||
# Versions must share the root's transaction ID so they are restored
|
||||
# together by django-softdelete.
|
||||
if transaction_id is None:
|
||||
transaction_id = uuid.uuid4()
|
||||
if self.root_document_id is None:
|
||||
Document.objects.filter(root_document=self).delete()
|
||||
Document.objects.filter(root_document=self).delete(
|
||||
transaction_id=transaction_id,
|
||||
)
|
||||
return super().delete(
|
||||
*args,
|
||||
transaction_id=transaction_id,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -706,6 +715,7 @@ class SavedViewFilterRule(models.Model):
|
||||
(47, _("mime type is")),
|
||||
(48, _("simple title search")),
|
||||
(49, _("simple text search")),
|
||||
(50, _("has duplicates")),
|
||||
]
|
||||
|
||||
saved_view = models.ForeignKey(
|
||||
@@ -2023,3 +2033,248 @@ class WorkflowRun(SoftDeleteModel):
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"WorkflowRun of {self.workflow} at {self.run_at} on {self.document}"
|
||||
|
||||
|
||||
class OcrTemplate(models.Model):
|
||||
"""
|
||||
Defines a set of OCR extraction zones for a specific document type.
|
||||
|
||||
When a document of that type is consumed, each zone in the template is
|
||||
cropped from the document image and OCR'd separately. The extracted text
|
||||
is written to the configured custom field or built-in document field.
|
||||
"""
|
||||
|
||||
name = models.CharField(
|
||||
_("name"),
|
||||
max_length=128,
|
||||
)
|
||||
|
||||
document_type = models.ForeignKey(
|
||||
"documents.DocumentType",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="ocr_templates",
|
||||
verbose_name=_("document type"),
|
||||
db_index=True,
|
||||
)
|
||||
|
||||
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:
|
||||
ordering = ("name",)
|
||||
verbose_name = _("OCR template")
|
||||
verbose_name_plural = _("OCR templates")
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name} ({self.document_type})"
|
||||
|
||||
|
||||
class OcrTemplateZone(models.Model):
|
||||
"""
|
||||
A rectangular region within a document page to OCR and extract into a custom
|
||||
field or built-in document field. Coordinates are relative to the source
|
||||
image dimensions stored on the template.
|
||||
"""
|
||||
|
||||
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)"),
|
||||
)
|
||||
|
||||
# Per-zone source dimensions for coordinate scaling.
|
||||
# Stored from the page image the zone was drawn on.
|
||||
# If null, falls back to the template's source_width/source_height.
|
||||
# This handles PDFs with mixed page sizes (e.g. landscape + portrait,
|
||||
# or different paper formats across pages).
|
||||
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="deu+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}"
|
||||
|
||||
|
||||
# Custom field data types that zone OCR can extract into. DOCUMENTLINK and
|
||||
# SELECT are excluded (they reference other objects, not free text). Single
|
||||
# source of truth for the serializer, the quick-create endpoint and the engine.
|
||||
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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -284,6 +284,46 @@ class WriteBatch:
|
||||
tantivy.Query.term_query(self._backend._schema, "id", doc_id),
|
||||
)
|
||||
|
||||
def add_or_update_ids(self, ids: Sequence[int]) -> None:
|
||||
"""
|
||||
Add or update multiple documents in the batch by primary key.
|
||||
|
||||
Unlike calling ``add_or_update()`` once per document, this resolves
|
||||
viewer permissions and effective (versioned) content in bulk against
|
||||
the ids as a whole, instead of once per document -- see
|
||||
``_DocumentViewerStream`` and ``annotate_effective_content``. Use
|
||||
this whenever more than one document is being written in the same
|
||||
batch.
|
||||
|
||||
An id with no matching document (e.g. deleted between the caller
|
||||
collecting ids and the batch running) is silently skipped, matching
|
||||
``add_or_update()``'s existing single-document deferred-task behavior
|
||||
rather than erroring or leaving a stale index entry.
|
||||
|
||||
Args:
|
||||
ids: Primary keys of Document instances to index
|
||||
"""
|
||||
from documents.models import Document
|
||||
from documents.versioning import annotate_effective_content
|
||||
|
||||
ids = list(ids)
|
||||
if not ids:
|
||||
return
|
||||
|
||||
queryset = annotate_effective_content(
|
||||
Document.objects.filter(pk__in=ids)
|
||||
.select_related("correspondent", "document_type", "storage_path", "owner")
|
||||
.prefetch_related("tags", "notes__user", "custom_fields__field"),
|
||||
)
|
||||
for document, grant in _DocumentViewerStream(queryset, chunk_size=1000):
|
||||
self.remove(document.pk)
|
||||
doc = self._backend._build_tantivy_doc(
|
||||
document,
|
||||
viewer_ids=grant.viewer_ids,
|
||||
viewer_group_ids=grant.viewer_group_ids,
|
||||
)
|
||||
self._writer.add_document(doc)
|
||||
|
||||
|
||||
class TantivyBackend:
|
||||
"""
|
||||
|
||||
@@ -56,6 +56,7 @@ 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
|
||||
@@ -63,6 +64,8 @@ from documents.models import Document
|
||||
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
|
||||
@@ -674,6 +677,9 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
|
||||
ordering = ordering or (Lower("name"),)
|
||||
children = children.order_by(*ordering)
|
||||
|
||||
if not children:
|
||||
return []
|
||||
|
||||
serializer = TagSerializer(
|
||||
children,
|
||||
many=True,
|
||||
@@ -2812,11 +2818,6 @@ class AcknowledgeTasksViewSerializer(serializers.Serializer[dict[str, Any]]):
|
||||
|
||||
|
||||
class ShareLinkSerializer(OwnedObjectSerializer):
|
||||
document_title = serializers.CharField(
|
||||
source="document.title",
|
||||
read_only=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ShareLink
|
||||
fields = (
|
||||
@@ -2825,7 +2826,6 @@ class ShareLinkSerializer(OwnedObjectSerializer):
|
||||
"expiration",
|
||||
"slug",
|
||||
"document",
|
||||
"document_title",
|
||||
"file_version",
|
||||
)
|
||||
|
||||
@@ -3665,3 +3665,129 @@ 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_width(self, value):
|
||||
if value < 1:
|
||||
raise serializers.ValidationError("Width must be at least 1.")
|
||||
return value
|
||||
|
||||
def validate_height(self, value):
|
||||
if value < 1:
|
||||
raise serializers.ValidationError("Height must be at least 1.")
|
||||
return value
|
||||
|
||||
def validate_custom_field(self, value):
|
||||
if value is None:
|
||||
# Built-in target (title/asn/created) — no custom field required.
|
||||
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
|
||||
|
||||
|
||||
class OcrTemplateSerializer(serializers.ModelSerializer):
|
||||
zones = OcrTemplateZoneSerializer(many=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = OcrTemplate
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"document_type",
|
||||
"source_width",
|
||||
"source_height",
|
||||
"sample_document",
|
||||
"enabled",
|
||||
"combine_formats",
|
||||
"created",
|
||||
"updated",
|
||||
"zones",
|
||||
]
|
||||
read_only_fields = ["created", "updated"]
|
||||
|
||||
def validate_source_width(self, value):
|
||||
if value < 1:
|
||||
raise serializers.ValidationError("Source width must be at least 1.")
|
||||
return value
|
||||
|
||||
def validate_source_height(self, value):
|
||||
if value < 1:
|
||||
raise serializers.ValidationError("Source height must be at least 1.")
|
||||
return value
|
||||
|
||||
def validate_zones(self, zones_data):
|
||||
"""Validate zone coordinates are within the source dimensions."""
|
||||
# source_width/height may not be in initial_data during partial updates
|
||||
source_width = self.initial_data.get("source_width") or (
|
||||
self.instance.source_width if self.instance else None
|
||||
)
|
||||
source_height = self.initial_data.get("source_height") or (
|
||||
self.instance.source_height if self.instance else None
|
||||
)
|
||||
|
||||
if source_width and source_height:
|
||||
for zone in zones_data:
|
||||
x = zone.get("x", 0)
|
||||
y = zone.get("y", 0)
|
||||
w = zone.get("width", 0)
|
||||
h = zone.get("height", 0)
|
||||
if x + w > int(source_width):
|
||||
raise serializers.ValidationError(
|
||||
f"Zone '{zone.get('name', '?')}' extends beyond source width "
|
||||
f"({x + w} > {source_width}).",
|
||||
)
|
||||
if y + h > int(source_height):
|
||||
raise serializers.ValidationError(
|
||||
f"Zone '{zone.get('name', '?')}' extends beyond source height "
|
||||
f"({y + h} > {source_height}).",
|
||||
)
|
||||
|
||||
return zones_data
|
||||
|
||||
def create(self, validated_data):
|
||||
zones_data = validated_data.pop("zones", [])
|
||||
template = OcrTemplate.objects.create(**validated_data)
|
||||
for zone_data in zones_data:
|
||||
OcrTemplateZone.objects.create(template=template, **zone_data)
|
||||
return template
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
zones_data = validated_data.pop("zones", None)
|
||||
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
instance.save()
|
||||
|
||||
if zones_data is not None:
|
||||
# Replace all zones with the new set
|
||||
instance.zones.all().delete()
|
||||
for zone_data in zones_data:
|
||||
OcrTemplateZone.objects.create(template=instance, **zone_data)
|
||||
|
||||
return instance
|
||||
|
||||
@@ -1398,6 +1398,76 @@ def close_connection_pool_on_worker_init(**kwargs) -> None:
|
||||
conn.close_pool()
|
||||
|
||||
|
||||
def run_zone_ocr_extraction(sender, document, original_file=None, **kwargs):
|
||||
"""
|
||||
Run zone-based OCR extraction if the document's type has an active template.
|
||||
"""
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def capture_old_document_type(sender, instance, **kwargs):
|
||||
"""pre_save: remember the document's previous type so the post_save handler
|
||||
can tell whether the type actually changed (vs. every other save)."""
|
||||
if instance.pk:
|
||||
instance._old_document_type_id = (
|
||||
Document.objects.filter(pk=instance.pk)
|
||||
.values_list("document_type_id", flat=True)
|
||||
.first()
|
||||
)
|
||||
else:
|
||||
instance._old_document_type_id = None
|
||||
|
||||
|
||||
def run_zone_ocr_on_type_change(sender, instance, *, created=False, **kwargs):
|
||||
"""
|
||||
Run zone OCR only when a document's TYPE actually changes (and the new type
|
||||
has an enabled template). NOT on every save — zone OCR overwrites fields, so
|
||||
re-running it on each edit would clobber the user's changes. Newly created
|
||||
documents are handled by the consumption signal, and the user can always
|
||||
trigger extraction manually via the run-zone-ocr action.
|
||||
"""
|
||||
if created or not instance.pk or not instance.document_type_id:
|
||||
return
|
||||
|
||||
# Only proceed if the type changed compared to what was in the DB before.
|
||||
old_type = getattr(instance, "_old_document_type_id", None)
|
||||
if old_type == instance.document_type_id:
|
||||
return
|
||||
|
||||
from documents.models import OcrTemplate
|
||||
|
||||
if not OcrTemplate.objects.filter(
|
||||
document_type_id=instance.document_type_id,
|
||||
enabled=True,
|
||||
).exists():
|
||||
return
|
||||
|
||||
try:
|
||||
from documents.zone_ocr import run_zone_extraction
|
||||
|
||||
doc_path = instance.archive_path or instance.source_path
|
||||
if doc_path and Path(doc_path).is_file():
|
||||
logger.info(
|
||||
"Zone OCR: running extraction for document %d (type %d)",
|
||||
instance.pk,
|
||||
instance.document_type_id,
|
||||
)
|
||||
run_zone_extraction(instance, None)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Zone OCR extraction failed for document %s",
|
||||
instance.pk,
|
||||
)
|
||||
|
||||
|
||||
@worker_process_shutdown.connect
|
||||
def close_connection_pool_on_worker_shutdown(**kwargs) -> None: # pragma: no cover
|
||||
"""
|
||||
|
||||
@@ -312,7 +312,10 @@ def bulk_update_documents(document_ids) -> None:
|
||||
from documents.search import get_backend
|
||||
|
||||
document_ids = list(document_ids)
|
||||
# Annotated so indexing below doesn't query the versions of each document
|
||||
# Annotated so the signal handlers below (e.g. matching) don't query the
|
||||
# versions of each document. Indexing re-queries and re-annotates its own
|
||||
# copy via add_or_update_ids() below, after these signals (and any
|
||||
# workflow they trigger) have had a chance to mutate the documents.
|
||||
documents = annotate_effective_content(
|
||||
Document.objects.filter(id__in=document_ids),
|
||||
)
|
||||
@@ -328,8 +331,7 @@ def bulk_update_documents(document_ids) -> None:
|
||||
post_save.send(Document, instance=doc, created=False)
|
||||
|
||||
with get_backend().batch_update() as batch:
|
||||
for doc in documents:
|
||||
batch.add_or_update(doc)
|
||||
batch.add_or_update_ids(document_ids)
|
||||
|
||||
ai_config = AIConfig()
|
||||
if ai_config.llm_index_enabled:
|
||||
|
||||
@@ -10,7 +10,7 @@ import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from guardian.shortcuts import clear_ct_cache
|
||||
from pytest_django.fixtures import SettingsWrapper
|
||||
from pytest_django.fixtures import Settings
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from documents.tests.factories import DocumentFactory
|
||||
@@ -100,7 +100,7 @@ def sample_doc(
|
||||
@pytest.fixture()
|
||||
def _search_index(
|
||||
tmp_path: Path,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
) -> Generator[None, None, None]:
|
||||
"""Create a temp index directory and point INDEX_DIR at it.
|
||||
|
||||
@@ -118,7 +118,7 @@ def _search_index(
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def settings_timezone(settings: SettingsWrapper) -> zoneinfo.ZoneInfo:
|
||||
def settings_timezone(settings: Settings) -> zoneinfo.ZoneInfo:
|
||||
return zoneinfo.ZoneInfo(settings.TIME_ZONE)
|
||||
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ def clear_lru_cache() -> Generator[None, None, None]:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_date_parser_settings(settings: pytest_django.fixtures.SettingsWrapper) -> Any:
|
||||
def mock_date_parser_settings(settings: pytest_django.fixtures.Settings) -> Any:
|
||||
"""
|
||||
Override Django settings for the duration of date parser tests.
|
||||
"""
|
||||
|
||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import pytest_mock
|
||||
from pytest_django.fixtures import SettingsWrapper
|
||||
from pytest_django.fixtures import Settings
|
||||
|
||||
from documents.export.sinks import DirectoryExportSink
|
||||
from documents.export.sinks import ExportSink
|
||||
@@ -242,7 +242,7 @@ class TestZipExportSink:
|
||||
self,
|
||||
tmp_path: Path,
|
||||
source_file: Path,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
scratch_dir = tmp_path / "scratch"
|
||||
settings.SCRATCH_DIR = scratch_dir
|
||||
@@ -261,7 +261,7 @@ class TestZipExportSink:
|
||||
def test_abort_after_manifest_written_cleans_up_pending_tmp(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
scratch_dir = tmp_path / "scratch"
|
||||
settings.SCRATCH_DIR = scratch_dir
|
||||
|
||||
@@ -15,11 +15,11 @@ if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
|
||||
from pytest_django.fixtures import SettingsWrapper
|
||||
from pytest_django.fixtures import Settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index_dir(tmp_path: Path, settings: SettingsWrapper) -> Path:
|
||||
def index_dir(tmp_path: Path, settings: Settings) -> Path:
|
||||
path = tmp_path / "index"
|
||||
path.mkdir()
|
||||
settings.INDEX_DIR = path
|
||||
|
||||
@@ -4,6 +4,8 @@ from pathlib import Path
|
||||
import pytest
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import connection
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from guardian.shortcuts import assign_perm
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
@@ -102,6 +104,191 @@ class TestWriteBatch:
|
||||
assert len(backend.search_ids("indexable", user=None)) == 1
|
||||
|
||||
|
||||
class TestAddOrUpdateIds:
|
||||
"""Test WriteBatch.add_or_update_ids(), the bulk id-based upsert path.
|
||||
|
||||
Unlike add_or_update() called once per document, this resolves viewer
|
||||
permissions and effective (versioned) content in bulk against the ids as
|
||||
a whole, so it must produce identical indexed output to the per-document
|
||||
path while issuing a constant number of queries regardless of batch size.
|
||||
"""
|
||||
|
||||
def test_missing_id_is_skipped_not_errored(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
) -> None:
|
||||
doc = Document.objects.create(
|
||||
title="doc",
|
||||
content="present",
|
||||
checksum="EXIST1",
|
||||
pk=1,
|
||||
)
|
||||
missing_pk = 999
|
||||
|
||||
with backend.batch_update() as batch:
|
||||
batch.add_or_update_ids([doc.pk, missing_pk])
|
||||
|
||||
assert backend.search_ids("present", user=None) == [doc.pk]
|
||||
|
||||
def test_query_count_does_not_scale_with_batch_size(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
) -> None:
|
||||
"""Each query count must stay far below N, not merely match between
|
||||
two runs -- an exact-equality assertion between two measurements is
|
||||
at the mercy of incidental process-level caches (e.g. Django's
|
||||
ContentType.objects.get_for_model) warming on whichever run happens
|
||||
first, which makes counts differ by a query for reasons unrelated to
|
||||
batch size. A generous fixed bound sidesteps that: the old
|
||||
per-document path issued roughly 8 queries per document, so 50
|
||||
documents under a bound this low proves the fix regardless of cache
|
||||
state.
|
||||
"""
|
||||
max_queries_for_any_batch_size = 15
|
||||
|
||||
small_docs = [
|
||||
Document.objects.create(
|
||||
title="doc",
|
||||
content=f"unique{i}",
|
||||
checksum=f"SMALL{i}",
|
||||
pk=i,
|
||||
)
|
||||
for i in range(1, 3)
|
||||
]
|
||||
with CaptureQueriesContext(connection) as ctx_small:
|
||||
with backend.batch_update() as batch:
|
||||
batch.add_or_update_ids([d.pk for d in small_docs])
|
||||
assert len(ctx_small.captured_queries) <= max_queries_for_any_batch_size
|
||||
|
||||
large_docs = [
|
||||
Document.objects.create(
|
||||
title="doc",
|
||||
content=f"unique{i}",
|
||||
checksum=f"LARGE{i}",
|
||||
pk=i,
|
||||
)
|
||||
for i in range(100, 150)
|
||||
]
|
||||
with CaptureQueriesContext(connection) as ctx_large:
|
||||
with backend.batch_update() as batch:
|
||||
batch.add_or_update_ids([d.pk for d in large_docs])
|
||||
assert len(ctx_large.captured_queries) <= max_queries_for_any_batch_size
|
||||
|
||||
for doc in large_docs:
|
||||
assert backend.search_ids(f"unique{doc.pk}", user=None) == [doc.pk]
|
||||
|
||||
def test_resolves_direct_user_grant_in_bulk(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
) -> None:
|
||||
owner = UserFactory()
|
||||
user = UserFactory()
|
||||
doc = Document.objects.create(
|
||||
title="doc",
|
||||
checksum="PERM1",
|
||||
pk=1,
|
||||
owner=owner,
|
||||
)
|
||||
assign_perm("view_document", user, doc)
|
||||
|
||||
with backend.batch_update() as batch:
|
||||
batch.add_or_update_ids([doc.pk])
|
||||
|
||||
assert backend.search_ids("doc", user=user) == [doc.pk]
|
||||
other = UserFactory()
|
||||
assert backend.search_ids("doc", user=other) == []
|
||||
|
||||
def test_resolves_group_grant_in_bulk(self, backend: TantivyBackend) -> None:
|
||||
owner = UserFactory()
|
||||
group = Group.objects.create(name="reviewers")
|
||||
user = UserFactory()
|
||||
user.groups.add(group)
|
||||
doc = Document.objects.create(
|
||||
title="doc",
|
||||
checksum="GPERM1",
|
||||
pk=1,
|
||||
owner=owner,
|
||||
)
|
||||
assign_perm("view_document", group, doc)
|
||||
|
||||
with backend.batch_update() as batch:
|
||||
batch.add_or_update_ids([doc.pk])
|
||||
|
||||
assert backend.search_ids("doc", user=user) == [doc.pk]
|
||||
other = UserFactory()
|
||||
assert backend.search_ids("doc", user=other) == []
|
||||
|
||||
def test_indexes_notes_and_custom_fields(self, backend: TantivyBackend) -> None:
|
||||
note_author = UserFactory(username="noter")
|
||||
field = CustomField.objects.create(
|
||||
name="Invoice Number",
|
||||
data_type=CustomField.FieldDataType.STRING,
|
||||
)
|
||||
doc = Document.objects.create(title="doc", checksum="RICH1", pk=1)
|
||||
Note.objects.create(document=doc, note="Reviewed", user=note_author)
|
||||
CustomFieldInstance.objects.create(
|
||||
document=doc,
|
||||
field=field,
|
||||
value_text="INV-42",
|
||||
)
|
||||
|
||||
with backend.batch_update() as batch:
|
||||
batch.add_or_update_ids([doc.pk])
|
||||
|
||||
assert backend.search_ids("notes.user:noter", user=None) == [doc.pk]
|
||||
assert backend.search_ids("custom_fields.value:INV-42", user=None) == [
|
||||
doc.pk,
|
||||
]
|
||||
|
||||
def test_uses_effective_content_for_versioned_documents(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
) -> None:
|
||||
root = Document.objects.create(
|
||||
title="Statement",
|
||||
content="stale text",
|
||||
checksum="ROOT1",
|
||||
pk=1,
|
||||
)
|
||||
Document.objects.create(
|
||||
title="Statement",
|
||||
content="latest version text",
|
||||
checksum="VER1",
|
||||
pk=2,
|
||||
root_document=root,
|
||||
version_index=1,
|
||||
)
|
||||
|
||||
with backend.batch_update() as batch:
|
||||
batch.add_or_update_ids([root.pk])
|
||||
|
||||
assert backend.search_ids("latest", user=None) == [root.pk]
|
||||
assert backend.search_ids("stale", user=None) == []
|
||||
|
||||
def test_reindexes_documents_already_in_the_index(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
) -> None:
|
||||
"""add_or_update_ids must upsert, matching add_or_update's behaviour."""
|
||||
doc = Document.objects.create(
|
||||
title="doc",
|
||||
content="original",
|
||||
checksum="UP1",
|
||||
pk=1,
|
||||
)
|
||||
backend.add_or_update(doc)
|
||||
assert backend.search_ids("original", user=None) == [doc.pk]
|
||||
|
||||
doc.content = "updated"
|
||||
doc.save()
|
||||
|
||||
with backend.batch_update() as batch:
|
||||
batch.add_or_update_ids([doc.pk])
|
||||
|
||||
assert backend.search_ids("original", user=None) == []
|
||||
assert backend.search_ids("updated", user=None) == [doc.pk]
|
||||
|
||||
|
||||
class TestSearch:
|
||||
"""Test search query parsing and matching via search_ids."""
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ from documents.search._schema import needs_rebuild
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from pytest_django.fixtures import SettingsWrapper
|
||||
from pytest_django.fixtures import Settings
|
||||
|
||||
|
||||
pytestmark = pytest.mark.search
|
||||
|
||||
@@ -25,7 +26,7 @@ class TestNeedsRebuild:
|
||||
def test_returns_false_when_version_and_language_match(
|
||||
self,
|
||||
index_dir: Path,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
settings.SEARCH_LANGUAGE = "en"
|
||||
(index_dir / ".index_settings.json").write_text(
|
||||
@@ -36,7 +37,7 @@ class TestNeedsRebuild:
|
||||
def test_returns_true_on_schema_version_mismatch(
|
||||
self,
|
||||
index_dir: Path,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
settings.SEARCH_LANGUAGE = None
|
||||
(index_dir / ".index_settings.json").write_text(
|
||||
@@ -47,7 +48,7 @@ class TestNeedsRebuild:
|
||||
def test_returns_true_when_version_is_not_an_integer(
|
||||
self,
|
||||
index_dir: Path,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
settings.SEARCH_LANGUAGE = None
|
||||
(index_dir / ".index_settings.json").write_text(
|
||||
@@ -58,7 +59,7 @@ class TestNeedsRebuild:
|
||||
def test_returns_true_when_language_key_missing(
|
||||
self,
|
||||
index_dir: Path,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
settings.SEARCH_LANGUAGE = "en"
|
||||
(index_dir / ".index_settings.json").write_text(
|
||||
@@ -69,7 +70,7 @@ class TestNeedsRebuild:
|
||||
def test_returns_true_when_language_differs(
|
||||
self,
|
||||
index_dir: Path,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
settings.SEARCH_LANGUAGE = "de"
|
||||
(index_dir / ".index_settings.json").write_text(
|
||||
|
||||
@@ -35,7 +35,8 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
||||
THEN:
|
||||
- Existing config
|
||||
"""
|
||||
response = self.client.get(self.ENDPOINT, format="json")
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
response = self.client.get(self.ENDPOINT, format="json")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
@@ -45,6 +46,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
||||
response.data[0],
|
||||
{
|
||||
"id": 1,
|
||||
"externally_configured_variables": [],
|
||||
"output_type": None,
|
||||
"pages": None,
|
||||
"language": None,
|
||||
@@ -76,7 +78,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
||||
"remote_ocr_api_key": None,
|
||||
"remote_ocr_endpoint": None,
|
||||
"remote_ocr_mode": None,
|
||||
"ai_enabled": False,
|
||||
"ai_enabled": None,
|
||||
"llm_embedding_backend": None,
|
||||
"llm_embedding_model": None,
|
||||
"llm_embedding_endpoint": None,
|
||||
@@ -91,6 +93,31 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
||||
},
|
||||
)
|
||||
|
||||
def test_api_get_config_reports_external_configuration_without_values(self) -> None:
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"PAPERLESS_OCR_LANGUAGE": "eng",
|
||||
"PAPERLESS_REMOTE_OCR_API_KEY": "secret-value",
|
||||
"PAPERLESS_FUTURE_SETTING": "future-value",
|
||||
"UNRELATED_SETTING": "unrelated-value",
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
response = self.client.get(self.ENDPOINT, format="json")
|
||||
|
||||
self.assertCountEqual(
|
||||
response.data[0]["externally_configured_variables"],
|
||||
[
|
||||
"PAPERLESS_FUTURE_SETTING",
|
||||
"PAPERLESS_OCR_LANGUAGE",
|
||||
"PAPERLESS_REMOTE_OCR_API_KEY",
|
||||
],
|
||||
)
|
||||
self.assertNotContains(response, "secret-value")
|
||||
self.assertNotContains(response, "future-value")
|
||||
self.assertNotContains(response, "UNRELATED_SETTING")
|
||||
|
||||
def test_api_get_ui_settings_with_config(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -949,6 +976,26 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
||||
)
|
||||
mock_update.assert_called_once()
|
||||
|
||||
@override_settings(AI_ENABLED=True, LLM_EMBEDDING_BACKEND=None)
|
||||
def test_external_ai_setting_triggers_index_update(self) -> None:
|
||||
config = ApplicationConfiguration.objects.first()
|
||||
assert config is not None
|
||||
config.ai_enabled = None
|
||||
config.llm_embedding_backend = None
|
||||
config.save()
|
||||
|
||||
with (
|
||||
patch("documents.tasks.llmindex_index.apply_async") as mock_update,
|
||||
patch("paperless.views.llm_index_exists", return_value=False),
|
||||
):
|
||||
self.client.patch(
|
||||
f"{self.ENDPOINT}1/",
|
||||
json.dumps({"llm_embedding_backend": "openai-like"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
mock_update.assert_called_once()
|
||||
|
||||
def test_update_llm_embedding_chunk_size_triggers_rebuild(self) -> None:
|
||||
config = ApplicationConfiguration.objects.first()
|
||||
assert config is not None
|
||||
|
||||