From 21ac856e3f974b4573daa57f04707b00f7a551f0 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 10 Aug 2026 09:48:32 -0700 Subject: [PATCH] Ok, frontend reprocess remote option --- .../reprocess-confirm-dialog.component.html | 28 ++++++++ ...reprocess-confirm-dialog.component.spec.ts | 72 +++++++++++++++++++ .../reprocess-confirm-dialog.component.ts | 20 ++++++ .../document-detail.component.spec.ts | 14 +++- .../document-detail.component.ts | 8 ++- .../bulk-editor/bulk-editor.component.spec.ts | 1 + .../bulk-editor/bulk-editor.component.ts | 8 ++- src-ui/src/app/data/ui-settings.ts | 13 ++++ .../services/rest/document.service.spec.ts | 15 ++++ .../src/app/services/rest/document.service.ts | 6 +- .../src/app/services/settings.service.spec.ts | 23 ++++++ src-ui/src/app/services/settings.service.ts | 12 ++++ 12 files changed, 214 insertions(+), 6 deletions(-) create mode 100644 src-ui/src/app/components/common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component.html create mode 100644 src-ui/src/app/components/common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component.spec.ts create mode 100644 src-ui/src/app/components/common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component.ts diff --git a/src-ui/src/app/components/common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component.html b/src-ui/src/app/components/common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component.html new file mode 100644 index 000000000..eccd39c3b --- /dev/null +++ b/src-ui/src/app/components/common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component.html @@ -0,0 +1,28 @@ + + + diff --git a/src-ui/src/app/components/common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component.spec.ts b/src-ui/src/app/components/common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component.spec.ts new file mode 100644 index 000000000..f6e2d9eac --- /dev/null +++ b/src-ui/src/app/components/common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component.spec.ts @@ -0,0 +1,72 @@ +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' +import { provideHttpClientTesting } from '@angular/common/http/testing' +import { ComponentFixture, TestBed } from '@angular/core/testing' +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' +import { RemoteOCRModeConfig } from 'src/app/data/paperless-config' +import { SETTINGS_KEYS } from 'src/app/data/ui-settings' +import { SettingsService } from 'src/app/services/settings.service' +import { ReprocessConfirmDialogComponent } from './reprocess-confirm-dialog.component' + +describe('ReprocessConfirmDialogComponent', () => { + let component: ReprocessConfirmDialogComponent + let fixture: ComponentFixture + let settingsService: SettingsService + + const createComponent = (configured: boolean, mode: string) => { + settingsService.set(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED, configured) + settingsService.set(SETTINGS_KEYS.REMOTE_OCR_MODE, mode) + + fixture = TestBed.createComponent(ReprocessConfirmDialogComponent) + component = fixture.componentInstance + fixture.detectChanges() + } + + beforeEach(async () => { + TestBed.configureTestingModule({ + providers: [ + NgbActiveModal, + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ], + imports: [ReprocessConfirmDialogComponent], + }).compileComponents() + + settingsService = TestBed.inject(SettingsService) + }) + + it('should not request remote OCR by default', () => { + createComponent(true, RemoteOCRModeConfig.WORKFLOW_ONLY) + + expect(component.remoteOcr).toBeFalsy() + }) + + it('should not offer remote OCR when no engine is configured', () => { + createComponent(false, RemoteOCRModeConfig.WORKFLOW_ONLY) + + expect(component.showRemoteOcr).toBeFalsy() + expect( + fixture.nativeElement.querySelector('#reprocessRemoteOcr') + ).toBeNull() + }) + + it('should not offer remote OCR when it already handles every document', () => { + createComponent(true, RemoteOCRModeConfig.ALWAYS) + + expect(component.showRemoteOcr).toBeFalsy() + expect( + fixture.nativeElement.querySelector('#reprocessRemoteOcr') + ).toBeNull() + }) + + it('should offer remote OCR when configured and selective', () => { + createComponent(true, RemoteOCRModeConfig.WORKFLOW_ONLY) + + expect(component.showRemoteOcr).toBeTruthy() + const checkbox = fixture.nativeElement.querySelector('#reprocessRemoteOcr') + expect(checkbox).not.toBeNull() + + checkbox.click() + fixture.detectChanges() + expect(component.remoteOcr).toBeTruthy() + }) +}) diff --git a/src-ui/src/app/components/common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component.ts b/src-ui/src/app/components/common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component.ts new file mode 100644 index 000000000..48f370944 --- /dev/null +++ b/src-ui/src/app/components/common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component.ts @@ -0,0 +1,20 @@ +import { Component, inject } from '@angular/core' +import { FormsModule } from '@angular/forms' +import { SettingsService } from 'src/app/services/settings.service' +import { ConfirmDialogComponent } from '../confirm-dialog.component' + +@Component({ + selector: 'pngx-reprocess-confirm-dialog', + templateUrl: './reprocess-confirm-dialog.component.html', + imports: [FormsModule], +}) +export class ReprocessConfirmDialogComponent extends ConfirmDialogComponent { + private settings = inject(SettingsService) + + remoteOcr: boolean = false + + public get showRemoteOcr(): boolean { + // Hidden when it is not configured, or when it already handles every document anyway. + return this.settings.remoteOCRIsSelectable + } +} diff --git a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts index dfc35e8c7..d4609a082 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts @@ -963,12 +963,24 @@ describe('DocumentDetailComponent', () => { component.reprocess() const modalCloseSpy = jest.spyOn(openModal, 'close') openModal.componentInstance.confirmClicked.next() - expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] }) + expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] }, false) expect(modalSpy).toHaveBeenCalled() expect(toastSpy).toHaveBeenCalled() expect(modalCloseSpy).toHaveBeenCalled() }) + it('should pass remote OCR choice when reprocessing', () => { + initNormally() + const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments') + reprocessSpy.mockReturnValue(of(true)) + let openModal: NgbModalRef + modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) + component.reprocess() + openModal.componentInstance.remoteOcr = true + openModal.componentInstance.confirmClicked.next() + expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] }, true) + }) + it('should show error if redo ocr call fails', () => { initNormally() const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments') diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts index a782cf1de..9512aac41 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.ts @@ -97,6 +97,7 @@ import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter' import * as UTIF from 'utif' import { DocumentDetailFieldID } from '../admin/settings/settings.component' import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component' +import { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component' import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component' import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component' import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component' @@ -1402,7 +1403,7 @@ export class DocumentDetailComponent } reprocess() { - let modal = this.modalService.open(ConfirmDialogComponent, { + let modal = this.modalService.open(ReprocessConfirmDialogComponent, { backdrop: 'static', }) modal.componentInstance.title = $localize`Reprocess confirm` @@ -1413,7 +1414,10 @@ export class DocumentDetailComponent modal.componentInstance.confirmClicked.subscribe(() => { modal.componentInstance.buttonsEnabled = false this.documentsService - .reprocessDocuments({ documents: [this.document().id] }) + .reprocessDocuments( + { documents: [this.document().id] }, + modal.componentInstance.remoteOcr + ) .subscribe({ next: () => { this.toastService.showInfo( diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts index 40d21f39b..0c8e5bca6 100644 --- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts @@ -1122,6 +1122,7 @@ describe('BulkEditorComponent', () => { req.flush(true) expect(req.request.body).toEqual({ documents: [3, 4], + remote_ocr: false, }) httpTestingController.match( `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts index 67b2e04ae..67f7a78b2 100644 --- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -51,6 +51,7 @@ import { ToastService } from 'src/app/services/toast.service' import { flattenTags } from 'src/app/utils/flatten-tags' import { queryParamsFromFilterRules } from 'src/app/utils/query-params' import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component' +import { ReprocessConfirmDialogComponent } from '../../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component' import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component' import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component' import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component' @@ -909,7 +910,7 @@ export class BulkEditorComponent } reprocessSelected() { - let modal = this.modalService.open(ConfirmDialogComponent, { + let modal = this.modalService.open(ReprocessConfirmDialogComponent, { backdrop: 'static', }) modal.componentInstance.title = $localize`Reprocess confirm` @@ -923,7 +924,10 @@ export class BulkEditorComponent modal.componentInstance.buttonsEnabled = false this.executeDocumentAction( modal, - this.documentService.reprocessDocuments(this.getSelectionQuery()) + this.documentService.reprocessDocuments( + this.getSelectionQuery(), + modal.componentInstance.remoteOcr + ) ) }) } diff --git a/src-ui/src/app/data/ui-settings.ts b/src-ui/src/app/data/ui-settings.ts index cec804f99..4739051fa 100644 --- a/src-ui/src/app/data/ui-settings.ts +++ b/src-ui/src/app/data/ui-settings.ts @@ -1,5 +1,6 @@ import { PdfEditorEditMode } from '../components/common/pdf-editor/pdf-editor-edit-mode' import { PdfZoomScale } from '../components/common/pdf-viewer/pdf-viewer.types' +import { RemoteOCRModeConfig } from './paperless-config' import { User } from './user' export interface UiSettings { @@ -94,6 +95,8 @@ export const SETTINGS_KEYS = { OUTLOOK_OAUTH_URL: 'outlook_oauth_url', EMAIL_ENABLED: 'email_enabled', AI_ENABLED: 'ai_enabled', + REMOTE_OCR_CONFIGURED: 'remote_ocr:configured', + REMOTE_OCR_MODE: 'remote_ocr:mode', } export const SETTINGS: UiSetting[] = [ @@ -347,4 +350,14 @@ export const SETTINGS: UiSetting[] = [ type: 'string', default: PdfEditorEditMode.Create, }, + { + key: SETTINGS_KEYS.REMOTE_OCR_CONFIGURED, + type: 'boolean', + default: false, + }, + { + key: SETTINGS_KEYS.REMOTE_OCR_MODE, + type: 'string', + default: RemoteOCRModeConfig.ALWAYS, + }, ] diff --git a/src-ui/src/app/services/rest/document.service.spec.ts b/src-ui/src/app/services/rest/document.service.spec.ts index d6f5799b1..9c33d5f63 100644 --- a/src-ui/src/app/services/rest/document.service.spec.ts +++ b/src-ui/src/app/services/rest/document.service.spec.ts @@ -284,6 +284,21 @@ describe(`DocumentService`, () => { expect(req.request.method).toEqual('POST') expect(req.request.body).toEqual({ documents: ids, + remote_ocr: false, + }) + }) + + it('should request remote OCR when reprocessing with it enabled', () => { + const ids = [1, 2, 3] + subscription = service + .reprocessDocuments({ documents: ids }, true) + .subscribe() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}${endpoint}/reprocess/` + ) + expect(req.request.body).toEqual({ + documents: ids, + remote_ocr: true, }) }) diff --git a/src-ui/src/app/services/rest/document.service.ts b/src-ui/src/app/services/rest/document.service.ts index bc87cb1fb..7cdb53e64 100644 --- a/src-ui/src/app/services/rest/document.service.ts +++ b/src-ui/src/app/services/rest/document.service.ts @@ -349,9 +349,13 @@ export class DocumentService extends AbstractPaperlessService { }) } - reprocessDocuments(selection: DocumentSelectionQuery) { + reprocessDocuments( + selection: DocumentSelectionQuery, + remoteOcr: boolean = false + ) { return this.http.post(this.getResourceUrl(null, 'reprocess'), { ...selection, + remote_ocr: remoteOcr, }) } diff --git a/src-ui/src/app/services/settings.service.spec.ts b/src-ui/src/app/services/settings.service.spec.ts index c91296537..510f1857f 100644 --- a/src-ui/src/app/services/settings.service.spec.ts +++ b/src-ui/src/app/services/settings.service.spec.ts @@ -13,6 +13,7 @@ import { environment } from 'src/environments/environment' import { CustomFieldDataType } from '../data/custom-field' import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document' import { SavedView } from '../data/saved-view' +import { RemoteOCRModeConfig } from '../data/paperless-config' import { SETTINGS_KEYS, UiSettings } from '../data/ui-settings' import { PermissionsService } from './permissions.service' import { CustomFieldsService } from './rest/custom-fields.service' @@ -434,4 +435,26 @@ describe('SettingsService', () => { ).name ).toEqual(customFields[0].name) }) + it('should offer remote OCR only when configured and selective', () => { + settingsService.set(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED, false) + settingsService.set( + SETTINGS_KEYS.REMOTE_OCR_MODE, + RemoteOCRModeConfig.WORKFLOW_ONLY + ) + expect(settingsService.remoteOCRIsSelectable).toBeFalsy() + + // configured, but already handling every document + settingsService.set(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED, true) + settingsService.set( + SETTINGS_KEYS.REMOTE_OCR_MODE, + RemoteOCRModeConfig.ALWAYS + ) + expect(settingsService.remoteOCRIsSelectable).toBeFalsy() + + settingsService.set( + SETTINGS_KEYS.REMOTE_OCR_MODE, + RemoteOCRModeConfig.WORKFLOW_ONLY + ) + expect(settingsService.remoteOCRIsSelectable).toBeTruthy() + }) }) diff --git a/src-ui/src/app/services/settings.service.ts b/src-ui/src/app/services/settings.service.ts index 415b99a8f..850b4d4e8 100644 --- a/src-ui/src/app/services/settings.service.ts +++ b/src-ui/src/app/services/settings.service.ts @@ -19,6 +19,7 @@ import { } from 'src/app/utils/color' import { DEFAULT_APP_TITLE, environment } from 'src/environments/environment' import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document' +import { RemoteOCRModeConfig } from '../data/paperless-config' import { SavedView } from '../data/saved-view' import { PAPERLESS_GREEN_HEX, @@ -687,6 +688,17 @@ export class SettingsService { return this.settingIsSet(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED) } + /** + * Offering remote OCR as a choice only makes sense when an engine + * is configured but is not already handling every document. + */ + get remoteOCRIsSelectable(): boolean { + return ( + this.get(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED) && + this.get(SETTINGS_KEYS.REMOTE_OCR_MODE) !== RemoteOCRModeConfig.ALWAYS + ) + } + offerTour(): boolean { return this.dashboardIsEmpty() && !this.get(SETTINGS_KEYS.TOUR_COMPLETE) }