diff --git a/docs/usage.md b/docs/usage.md index 0dc13b07a..20d4c1189 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -654,6 +654,19 @@ happened while it was still encrypted, that original version will likewise be mi **Current limitation**: Passwords are stored as a simple list without descriptions. To handle multiple PDF types with different passwords, create separate workflows for each use case. +##### Remote OCR {#workflow-action-remote-ocr} + +"Remote OCR" actions send the document to the configured remote OCR engine instead of processing it +locally. To use remote OCR selectively, set the [remote OCR mode](configuration.md#PAPERLESS_REMOTE_OCR_MODE) +to `workflow_only` then add this action to a workflow that matches only the documents you +want sent to the remote engine. See [Remote OCR](#remote-ocr) for the engine setup. The action only works with +a **Consumption Started** trigger. + +The action takes no options, its presence is what enables remote OCR for a matching document. + +If the remote engine is not configured, or does not support the document's file type, the document is +processed locally instead and a warning is written to the log. + #### Workflow placeholders Titles and webhook payloads can be generated by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/). @@ -1098,7 +1111,8 @@ or page limitations (e.g. with a free tier). By default, every document of a supported file type is sent to the remote engine. To use it more selectively, set the [remote OCR mode](configuration.md#PAPERLESS_REMOTE_OCR_MODE) to `workflow_only`. Documents are then processed locally -unless a workflow explicitly enables remote OCR for them, so you can limit the remote engine to particular documents. +unless a [remote OCR workflow action](#workflow-action-remote-ocr) enables it for them, so you can limit the remote +engine to particular documents. Setting the mode to `workflow_only` also allows the **Reprocess** actions to selectively use remote OCR for individual documents. diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html index 0e3329ea3..3827c41e6 100644 --- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html +++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html @@ -455,6 +455,13 @@ } + @case (WorkflowActionType.RemoteOcr) { +
+
+

The document will be sent to the configured remote OCR service. May incur costs.

+
+
+ } } diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts index bc7be5fad..dfe713c6d 100644 --- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts +++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts @@ -29,6 +29,7 @@ import { DocumentSource, WorkflowTriggerType, } from 'src/app/data/workflow-trigger' +import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { IfOwnerDirective } from 'src/app/directives/if-owner.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { CorrespondentService } from 'src/app/services/rest/correspondent.service' @@ -224,7 +225,12 @@ describe('WorkflowEditDialogComponent', () => { ).toEqual('Document Added') expect(component.getTriggerTypeOptionName(null)).toEqual('') expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS) - expect(component.actionTypeOptions).toEqual(WORKFLOW_ACTION_OPTIONS) + // Remote OCR is absent until the workflow has a consumption trigger + expect(component.actionTypeOptions).toEqual( + WORKFLOW_ACTION_OPTIONS.filter( + (a) => a.id !== WorkflowActionType.RemoteOcr + ) + ) expect( component.getActionTypeOptionName(WorkflowActionType.Assignment) ).toEqual('Assignment') @@ -237,7 +243,104 @@ describe('WorkflowEditDialogComponent', () => { jest.spyOn(settingsService, 'get').mockReturnValue(false) component.ngOnInit() expect(component.actionTypeOptions).toEqual( - WORKFLOW_ACTION_OPTIONS.filter((a) => a.id !== WorkflowActionType.Email) + WORKFLOW_ACTION_OPTIONS.filter( + (a) => + a.id !== WorkflowActionType.Email && + a.id !== WorkflowActionType.RemoteOcr + ) + ) + }) + + it('should offer remote OCR only for consumption workflows', () => { + jest.spyOn(settingsService, 'get').mockReturnValue(true) + + // A consumption trigger makes the action reachable + component.object = { + name: 'Workflow 1', + order: 0, + enabled: true, + triggers: [{ type: WorkflowTriggerType.Consumption }], + actions: [], + } as Workflow + component.ngOnInit() + expect(component.actionTypeOptions.map((a) => a.id)).toContain( + WorkflowActionType.RemoteOcr + ) + + // Any other trigger type runs after the document has been parsed + component.object = { + name: 'Workflow 2', + order: 0, + enabled: true, + triggers: [{ type: WorkflowTriggerType.DocumentAdded }], + actions: [], + } as Workflow + component.ngOnInit() + expect(component.actionTypeOptions.map((a) => a.id)).not.toContain( + WorkflowActionType.RemoteOcr + ) + }) + + it('should offer remote OCR on a trigger added to a new workflow', () => { + jest.spyOn(settingsService, 'get').mockReturnValue(true) + component.ngOnInit() + + // Nothing for the action to apply to yet + expect(component.actionTypeOptions.map((a) => a.id)).not.toContain( + WorkflowActionType.RemoteOcr + ) + + // addTrigger creates the form field with emitEvent false, so the options + // have to be computed on read rather than cached from valueChanges + component.addTrigger() + expect(component.actionTypeOptions.map((a) => a.id)).toContain( + WorkflowActionType.RemoteOcr + ) + + // Switching that trigger to a type that runs after parsing removes it + component.triggerFields + .at(0) + .get('type') + .setValue(WorkflowTriggerType.DocumentAdded) + expect(component.actionTypeOptions.map((a) => a.id)).not.toContain( + WorkflowActionType.RemoteOcr + ) + }) + + it('should keep remote OCR listed when an action already uses it', () => { + jest.spyOn(settingsService, 'get').mockReturnValue(true) + + // Otherwise changing the trigger would silently blank the selection + component.object = { + name: 'Workflow 1', + order: 0, + enabled: true, + triggers: [{ type: WorkflowTriggerType.DocumentAdded }], + actions: [{ type: WorkflowActionType.RemoteOcr }], + } as Workflow + component.ngOnInit() + + expect(component.actionTypeOptions.map((a) => a.id)).toContain( + WorkflowActionType.RemoteOcr + ) + }) + + it('should not offer remote OCR when no engine is configured', () => { + jest + .spyOn(settingsService, 'get') + .mockImplementation((key) => key !== SETTINGS_KEYS.REMOTE_OCR_CONFIGURED) + + component.object = { + name: 'Workflow 1', + order: 0, + enabled: true, + triggers: [{ type: WorkflowTriggerType.Consumption }], + actions: [], + } as Workflow + component.ngOnInit() + + expect(component.actionTypeOptions.map((a) => a.id)).not.toContain( + WorkflowActionType.RemoteOcr ) }) diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts index 8e8dd6ef7..ed66e61dc 100644 --- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts +++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts @@ -148,6 +148,10 @@ export const WORKFLOW_ACTION_OPTIONS = [ id: WorkflowActionType.MoveToTrash, name: $localize`Move to trash`, }, + { + id: WorkflowActionType.RemoteOcr, + name: $localize`Remote OCR`, + }, ] export enum TriggerFilterType { @@ -504,8 +508,6 @@ export class WorkflowEditDialogComponent expandedItem: number = null - readonly allowedActionTypes = signal([]) - private readonly triggerFilterOptionsMap = new WeakMap< FormArray, TriggerFilterOption[] @@ -548,13 +550,40 @@ export class WorkflowEditDialogComponent this.checkRemovalActionFields.bind(this) ) this.checkRemovalActionFields(this.objectForm.value) - this.allowedActionTypes.set( - this.settingsService.get(SETTINGS_KEYS.EMAIL_ENABLED) - ? WORKFLOW_ACTION_OPTIONS - : WORKFLOW_ACTION_OPTIONS.filter( - (a) => a.id !== WorkflowActionType.Email - ) - ) + } + + private allowedActionTypes: typeof WORKFLOW_ACTION_OPTIONS = null + + private getAllowedActionTypes() { + let allowed = WORKFLOW_ACTION_OPTIONS + + if (!this.settingsService.get(SETTINGS_KEYS.EMAIL_ENABLED)) { + allowed = allowed.filter((a) => a.id !== WorkflowActionType.Email) + } + + // Remote OCR is decided before the document is parsed, so it is only + // offered for workflows that run at consumption. + const formWorkflow: Workflow = this.objectForm?.value + const remoteOcrUsable = + this.settingsService.get(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED) && + (formWorkflow?.triggers?.some( + (trigger) => trigger.type === WorkflowTriggerType.Consumption + ) || + formWorkflow?.actions?.some( + (action) => action.type === WorkflowActionType.RemoteOcr + )) + if (!remoteOcrUsable) { + allowed = allowed.filter((a) => a.id !== WorkflowActionType.RemoteOcr) + } + + if ( + this.allowedActionTypes?.length === allowed.length && + this.allowedActionTypes.every((a, i) => a.id === allowed[i].id) + ) { + return this.allowedActionTypes + } + this.allowedActionTypes = allowed + return allowed } private checkRemovalActionFields(formWorkflow: Workflow) { @@ -1279,7 +1308,8 @@ export class WorkflowEditDialogComponent get actionTypeOptions() { this.settingsService.trackChanges() - return this.allowedActionTypes() + // Computed on read rather than cached + return this.getAllowedActionTypes() } getActionTypeOptionName(type: WorkflowActionType): string { diff --git a/src-ui/src/app/data/workflow-action.ts b/src-ui/src/app/data/workflow-action.ts index 5ddaeba7e..09ef5418f 100644 --- a/src-ui/src/app/data/workflow-action.ts +++ b/src-ui/src/app/data/workflow-action.ts @@ -7,6 +7,7 @@ export enum WorkflowActionType { Webhook = 4, PasswordRemoval = 5, MoveToTrash = 6, + RemoteOcr = 7, } export interface WorkflowActionEmail extends ObjectWithId { diff --git a/src/documents/consumer.py b/src/documents/consumer.py index 683f8f11a..79d0cfa8b 100644 --- a/src/documents/consumer.py +++ b/src/documents/consumer.py @@ -473,6 +473,16 @@ class ConsumerPlugin( f"Unsupported mime type {mime_type}", ) + if self.metadata.remote_ocr and not getattr( + parser_class, + "uses_remote_service", + False, + ): + self.log.warning( + "Remote OCR was requested for this document but no remote " + "parser is available for it, processing locally instead.", + ) + # Notify all listeners that we're going to do some work. document_consumption_started.send( diff --git a/src/documents/migrations/0024_alter_workflowaction_type.py b/src/documents/migrations/0024_alter_workflowaction_type.py new file mode 100644 index 000000000..781c0516e --- /dev/null +++ b/src/documents/migrations/0024_alter_workflowaction_type.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.16 on 2026-08-10 17:27 + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + dependencies = [ + ("documents", "0023_savedview_icon"), + ] + + operations = [ + migrations.AlterField( + model_name="workflowaction", + name="type", + field=models.PositiveSmallIntegerField( + choices=[ + (1, "Assignment"), + (2, "Removal"), + (3, "Email"), + (4, "Webhook"), + (5, "Password removal"), + (6, "Move to trash"), + (7, "Remote OCR"), + ], + default=1, + verbose_name="Workflow Action Type", + ), + ), + ] diff --git a/src/documents/models.py b/src/documents/models.py index 01b87ba56..2e46bfc57 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -1670,6 +1670,10 @@ class WorkflowAction(models.Model): 6, _("Move to trash"), ) + REMOTE_OCR = ( + 7, + _("Remote OCR"), + ) type = models.PositiveSmallIntegerField( _("Workflow Action Type"), diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 2c653b40d..c9dc2845b 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -3312,6 +3312,41 @@ class WorkflowSerializer(serializers.ModelSerializer[Workflow]): "actions", ] + def validate(self, attrs): + attrs = super().validate(attrs) + + if "actions" in attrs: + has_remote_ocr_action = any( + action.get("type") == WorkflowAction.WorkflowActionType.REMOTE_OCR + for action in attrs["actions"] + ) + else: + has_remote_ocr_action = self.instance is not None and ( + self.instance.actions.filter( + type=WorkflowAction.WorkflowActionType.REMOTE_OCR, + ).exists() + ) + + if "triggers" in attrs: + has_consumption_trigger = any( + trigger.get("type") == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION + for trigger in attrs["triggers"] + ) + else: + has_consumption_trigger = self.instance is not None and ( + self.instance.triggers.filter( + type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION, + ).exists() + ) + + # Remote OCR can only work with consumption triggers + if has_remote_ocr_action and not has_consumption_trigger: + raise serializers.ValidationError( + "Remote OCR actions require a consumption started trigger", + ) + + return attrs + def update_triggers_and_actions( self, instance: Workflow, diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py index 66de47ac4..87fa47643 100644 --- a/src/documents/signals/handlers.py +++ b/src/documents/signals/handlers.py @@ -973,6 +973,17 @@ def run_workflows( ) elif action.type == WorkflowAction.WorkflowActionType.MOVE_TO_TRASH: has_move_to_trash_action = True + elif action.type == WorkflowAction.WorkflowActionType.REMOTE_OCR: + if use_overrides and overrides: + overrides.remote_ocr = True + else: + # If a workflow has a consumption trigger *and* another type, + # the document has already been parsed by the time the other one fires + logger.debug( + "Remote OCR action only applies to consumption " + "triggers, ignoring", + extra={"group": logging_group}, + ) if not use_overrides: # limit title to 128 characters diff --git a/src/documents/tests/test_api_workflows.py b/src/documents/tests/test_api_workflows.py index f3d51d686..788207751 100644 --- a/src/documents/tests/test_api_workflows.py +++ b/src/documents/tests/test_api_workflows.py @@ -506,6 +506,141 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase): self.assertEqual(Workflow.objects.count(), 1) + def test_api_create_remote_ocr_action_requires_consumption_trigger( + self, + ) -> None: + """ + GIVEN: + - API request to create a workflow with a remote OCR action + - No consumption started trigger, so the action could never run + WHEN: + - API is called + THEN: + - Correct HTTP 400 response + - No objects are created + """ + existing_count = Workflow.objects.count() + + response = self.client.post( + self.ENDPOINT, + json.dumps( + { + "name": "Remote OCR too late", + "order": 1, + "triggers": [ + { + "type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED, + }, + ], + "actions": [ + { + "type": WorkflowAction.WorkflowActionType.REMOTE_OCR, + }, + ], + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(Workflow.objects.count(), existing_count) + + def test_api_create_remote_ocr_action_with_consumption_trigger(self) -> None: + """ + GIVEN: + - API request to create a workflow with a remote OCR action + - A consumption started trigger alongside another trigger type + WHEN: + - API is called + THEN: + - The workflow is created, the action applies to consumption only + """ + response = self.client.post( + self.ENDPOINT, + json.dumps( + { + "name": "Remote OCR on consume", + "order": 1, + "triggers": [ + { + "type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION, + "filter_filename": "*.pdf", + }, + { + "type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED, + }, + ], + "actions": [ + { + "type": WorkflowAction.WorkflowActionType.REMOTE_OCR, + }, + ], + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_api_partial_update_adds_remote_ocr_action(self) -> None: + """ + GIVEN: + - An existing workflow with a consumption started trigger + WHEN: + - A partial update adds a remote OCR action without resubmitting triggers + THEN: + - The existing trigger is considered and the update succeeds + """ + response = self.client.patch( + f"{self.ENDPOINT}{self.workflow.id}/", + json.dumps( + { + "actions": [ + { + "type": WorkflowAction.WorkflowActionType.REMOTE_OCR, + }, + ], + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + self.workflow.actions.get().type, + WorkflowAction.WorkflowActionType.REMOTE_OCR, + ) + + def test_api_partial_update_cannot_remove_remote_ocr_trigger(self) -> None: + """ + GIVEN: + - An existing workflow with a remote OCR action + - An existing consumption started trigger + WHEN: + - A partial update replaces the trigger without resubmitting actions + THEN: + - The existing action is considered and the update is rejected + """ + self.action.type = WorkflowAction.WorkflowActionType.REMOTE_OCR + self.action.save() + + response = self.client.patch( + f"{self.ENDPOINT}{self.workflow.id}/", + json.dumps( + { + "triggers": [ + { + "type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, + }, + ], + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(self.workflow.triggers.get(), self.trigger) + def test_api_create_workflow_trigger_action_empty_fields(self) -> None: """ GIVEN: diff --git a/src/documents/tests/test_workflows.py b/src/documents/tests/test_workflows.py index 574cecf4f..5d4ba6598 100644 --- a/src/documents/tests/test_workflows.py +++ b/src/documents/tests/test_workflows.py @@ -5409,3 +5409,82 @@ class TestDateWorkflowLocalization( document = Document.objects.first() assert document is not None assert document.title == expected_title + + +class TestRemoteOCRWorkflowAction(DirectoriesMixin, SampleDirMixin, APITestCase): + def _make_workflow(self, trigger_type) -> None: + trigger = WorkflowTrigger.objects.create(type=trigger_type) + action = WorkflowAction.objects.create( + type=WorkflowAction.WorkflowActionType.REMOTE_OCR, + ) + w = Workflow.objects.create(name="Remote OCR", order=0) + w.triggers.add(trigger) + w.actions.add(action) + w.save() + + def test_consumption_trigger_requests_remote_ocr(self) -> None: + """ + GIVEN: + - A consumption workflow with a remote OCR action + WHEN: + - A matching document is consumed + THEN: + - The overrides ask for remote OCR, which is what the consumer + reads when choosing a parser + """ + self._make_workflow(WorkflowTrigger.WorkflowTriggerType.CONSUMPTION) + + test_file = shutil.copy( + self.SAMPLE_DIR / "simple.pdf", + self.dirs.scratch_dir / "simple.pdf", + ) + overrides = DocumentMetadataOverrides() + + run_workflows( + WorkflowTrigger.WorkflowTriggerType.CONSUMPTION, + ConsumableDocument( + source=DocumentSource.ConsumeFolder, + original_file=test_file, + ), + overrides=overrides, + ) + + self.assertTrue(overrides.remote_ocr) + + def test_other_trigger_types_are_ignored(self) -> None: + """ + GIVEN: + - A workflow with a remote OCR action that also has a + non-consumption trigger, which is a valid combination + WHEN: + - The non-consumption trigger fires + THEN: + - The action is skipped, since the document has already been + parsed by this point + """ + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION, + ) + updated_trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, + ) + action = WorkflowAction.objects.create( + type=WorkflowAction.WorkflowActionType.REMOTE_OCR, + ) + w = Workflow.objects.create(name="Remote OCR", order=0) + w.triggers.add(trigger, updated_trigger) + w.actions.add(action) + w.save() + + doc = Document.objects.create( + title="sample test", + original_filename="sample.pdf", + ) + + with self.assertLogs("paperless.handlers", level="DEBUG") as cm: + run_workflows( + WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, + doc, + ) + + self.assertIn("only applies to consumption triggers", "".join(cm.output))