Compare commits

..
25 changed files with 489 additions and 222 deletions
+1 -1
View File
@@ -1215,7 +1215,7 @@ should be a valid crontab(5) expression describing when to run.
: If set to the string "disable", no emails will be fetched automatically.
Defaults to every ten minutes, with an installation-specific minute offset.
Defaults to `*/10 * * * *` or every ten minutes.
#### [`PAPERLESS_TRAIN_TASK_CRON=<cron expression>`](#PAPERLESS_TRAIN_TASK_CRON) {#PAPERLESS_TRAIN_TASK_CRON}
+2 -1
View File
@@ -1090,7 +1090,8 @@ Paperless-ngx supports performing OCR on documents using remote services. At the
[Microsoft's Azure "Document Intelligence" service](https://azure.microsoft.com/en-us/products/ai-services/ai-document-intelligence).
This is of course a paid service (with a free tier) which requires an Azure account and subscription. Azure AI is not affiliated with
Paperless-ngx in any way. When enabled, Paperless-ngx will automatically send appropriate documents to Azure for OCR processing, bypassing
the local OCR engine. See the [configuration](configuration.md#PAPERLESS_REMOTE_OCR_ENGINE) options for more details.
the local OCR engine. See the [configuration](configuration.md#PAPERLESS_REMOTE_OCR_ENGINE) options for more details. These
settings can be supplied as environment variables or via **Application Configuration**.
Additionally, when using a commercial service with this feature, consider both potential costs as well as any associated file size
or page limitations (e.g. with a free tier).
+10 -10
View File
@@ -8488,67 +8488,67 @@
<source>Delete version</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
<context context-type="linenumber">146,147</context>
<context context-type="linenumber">145,146</context>
</context-group>
</trans-unit>
<trans-unit id="9111284310237278846" datatype="html">
<source>Error deleting version</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
<context context-type="linenumber">164</context>
<context context-type="linenumber">158</context>
</context-group>
</trans-unit>
<trans-unit id="1450293275912542759" datatype="html">
<source>Error updating version label</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
<context context-type="linenumber">197</context>
<context context-type="linenumber">191</context>
</context-group>
</trans-unit>
<trans-unit id="3567158019134759286" datatype="html">
<source>Uploading new version. Processing will happen in the background.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
<context context-type="linenumber">219</context>
<context context-type="linenumber">213</context>
</context-group>
</trans-unit>
<trans-unit id="1549684689040001385" datatype="html">
<source>Missing task ID.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
<context context-type="linenumber">232</context>
<context context-type="linenumber">226</context>
</context-group>
</trans-unit>
<trans-unit id="205445381296663832" datatype="html">
<source>Upload failed.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
<context context-type="linenumber">254</context>
<context context-type="linenumber">248</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
<context context-type="linenumber">278</context>
<context context-type="linenumber">272</context>
</context-group>
</trans-unit>
<trans-unit id="8350861980330256599" datatype="html">
<source>Error uploading new version</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
<context context-type="linenumber">281</context>
<context context-type="linenumber">275</context>
</context-group>
</trans-unit>
<trans-unit id="4050545381573552340" datatype="html">
<source>Existing document added as a version.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
<context context-type="linenumber">324</context>
<context context-type="linenumber">318</context>
</context-group>
</trans-unit>
<trans-unit id="8552490241798412439" datatype="html">
<source>Error adding existing document as a version</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
<context context-type="linenumber">329</context>
<context context-type="linenumber">323</context>
</context-group>
</trans-unit>
<trans-unit id="6299008920007331381" datatype="html">
@@ -14,43 +14,48 @@
<a ngbNavLink>{{category}}</a>
<ng-template ngbNavContent>
<div class="p-3">
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
@for (option of getCategoryOptions(category); track option.key) {
<div class="col">
<div class="card bg-light">
<div class="card-body">
<div class="card-title d-flex align-items-center">
<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 (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>
@for (section of getCategorySections(category); track section) {
@if (section) {
<h5 class="mt-4 mb-3">{{section}}</h5>
}
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
@for (option of getCategoryOptions(category, section); track option.key) {
<div class="col">
<div class="card bg-light">
<div class="card-body">
<div class="card-title d-flex align-items-center">
<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 (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>
}
</div>
<div class="mb-n3">
@switch (option.type) {
@case (ConfigOptionType.Select) { <pngx-input-select [formControlName]="option.key" [error]="errors[option.key]" [items]="option.choices" [allowNull]="true"></pngx-input-select> }
@case (ConfigOptionType.Number) { <pngx-input-number [formControlName]="option.key" [error]="errors[option.key]" [showAdd]="false"></pngx-input-number> }
@case (ConfigOptionType.Boolean) { <pngx-input-switch [formControlName]="option.key" [error]="errors[option.key]" [showUnsetNote]="true" [horizontal]="true" title="Enable" i18n-title></pngx-input-switch> }
@case (ConfigOptionType.String) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
@case (ConfigOptionType.JSON) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
@case (ConfigOptionType.File) { <pngx-input-file [formControlName]="option.key" (upload)="uploadFile($event, option.key)" [error]="errors[option.key]"></pngx-input-file> }
@case (ConfigOptionType.Password) { <pngx-input-password [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-password> }
}
</div>
@if (option.note) {
<div class="form-text fst-italic">{{option.note}}</div>
}
</div>
<div class="mb-n3">
@switch (option.type) {
@case (ConfigOptionType.Select) { <pngx-input-select [formControlName]="option.key" [error]="errors[option.key]" [items]="option.choices" [allowNull]="true"></pngx-input-select> }
@case (ConfigOptionType.Number) { <pngx-input-number [formControlName]="option.key" [error]="errors[option.key]" [showAdd]="false"></pngx-input-number> }
@case (ConfigOptionType.Boolean) { <pngx-input-switch [formControlName]="option.key" [error]="errors[option.key]" [showUnsetNote]="true" [horizontal]="true" title="Enable" i18n-title></pngx-input-switch> }
@case (ConfigOptionType.String) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
@case (ConfigOptionType.JSON) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
@case (ConfigOptionType.File) { <pngx-input-file [formControlName]="option.key" (upload)="uploadFile($event, option.key)" [error]="errors[option.key]"></pngx-input-file> }
@case (ConfigOptionType.Password) { <pngx-input-password [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-password> }
}
</div>
@if (option.note) {
<div class="form-text fst-italic">{{option.note}}</div>
}
</div>
</div>
</div>
}
</div>
}
</div>
}
</div>
</ng-template>
</li>
@@ -8,7 +8,11 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of, throwError } from 'rxjs'
import { OutputTypeConfig } from 'src/app/data/paperless-config'
import {
ConfigCategory,
ConfigSection,
OutputTypeConfig,
} from 'src/app/data/paperless-config'
import { ConfigService } from 'src/app/services/config.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
@@ -158,4 +162,23 @@ describe('ConfigComponent', () => {
component.resetOption('barcodes_enabled')
expect(component.configForm.get('barcodes_enabled').value).toBeNull()
})
it('should group options into sections within a category, or not', () => {
const sections = component.getCategorySections(ConfigCategory.OCR)
expect(sections).toEqual([null, ConfigSection.RemoteOCR])
expect(
component
.getCategoryOptions(ConfigCategory.OCR)
.map((option) => option.key)
).toContain('output_type')
expect(
component
.getCategoryOptions(ConfigCategory.OCR, ConfigSection.RemoteOCR)
.map((option) => option.key)
).toEqual([
'remote_ocr_engine',
'remote_ocr_api_key',
'remote_ocr_endpoint',
])
})
})
@@ -74,8 +74,20 @@ export class ConfigComponent
return Object.values(ConfigCategory)
}
getCategoryOptions(category: string): ConfigOption[] {
return PaperlessConfigOptions.filter((o) => o.category === category)
getCategorySections(category: string): string[] {
return [
...new Set(
PaperlessConfigOptions.filter((o) => o.category === category).map(
(o) => o.section ?? null // null means no section
)
),
]
}
getCategoryOptions(category: string, section: string = null): ConfigOption[] {
return PaperlessConfigOptions.filter(
(o) => o.category === category && (o.section ?? null) === section
)
}
initialConfig: PaperlessConfig
@@ -141,7 +141,6 @@
i18n-confirmMessage
[disabled]="!userIsOwner || !userCanEdit"
(confirm)="deleteVersion(version.id)"
*pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }"
>
<span class="visually-hidden" i18n>Delete version</span>
</pngx-confirm-button>
@@ -1,16 +1,10 @@
import { DatePipe } from '@angular/common'
import { SimpleChange, signal } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { Subject, of, throwError } from 'rxjs'
import { DocumentVersionInfo } from 'src/app/data/document'
import {
PermissionAction,
PermissionsService,
PermissionType,
} from 'src/app/services/permissions.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
@@ -37,9 +31,6 @@ describe('DocumentVersionDropdownComponent', () => {
let finished$: Subject<{ taskId: string }>
let failed$: Subject<{ taskId: string; message?: string }>
let modalService: jest.Mocked<Pick<NgbModal, 'open'>>
let permissionsService: jest.Mocked<
Pick<PermissionsService, 'currentUserCan'>
>
beforeEach(async () => {
finished$ = new Subject<{ taskId: string }>()
@@ -56,9 +47,6 @@ describe('DocumentVersionDropdownComponent', () => {
showError: jest.fn(),
showInfo: jest.fn(),
}
permissionsService = {
currentUserCan: jest.fn().mockReturnValue(true),
}
await TestBed.configureTestingModule({
imports: [
@@ -85,10 +73,6 @@ describe('DocumentVersionDropdownComponent', () => {
provide: NgbModal,
useValue: modalService,
},
{
provide: PermissionsService,
useValue: permissionsService,
},
{
provide: WebsocketStatusService,
useValue: {
@@ -159,31 +143,6 @@ describe('DocumentVersionDropdownComponent', () => {
)
})
it('should not show version delete buttons without document delete permission', () => {
fixture.destroy()
permissionsService.currentUserCan.mockReturnValue(false)
fixture = TestBed.createComponent(DocumentVersionDropdownComponent)
component = fixture.componentInstance
component.documentId = 3
component.selectedVersionId = 3
component.userIsOwner = true
component.userCanEdit = true
component.versions = [
{ id: 3, is_root: true, checksum: 'aaaa' },
{ id: 10, is_root: false, checksum: 'bbbb' },
]
fixture.detectChanges()
expect(permissionsService.currentUserCan).toHaveBeenCalledWith(
PermissionAction.Delete,
PermissionType.Document
)
expect(
fixture.debugElement.queryAll(By.css('pngx-confirm-button'))
).toHaveLength(0)
})
it('beginEditingVersion should set active row and draft label', () => {
component.userCanEdit = true
component.userIsOwner = true
@@ -25,7 +25,6 @@ import {
tap,
} from 'rxjs/operators'
import { DocumentVersionInfo } from 'src/app/data/document'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DocumentService } from 'src/app/services/rest/document.service'
import { ToastService } from 'src/app/services/toast.service'
@@ -34,7 +33,6 @@ import {
WebsocketStatusService,
} from 'src/app/services/websocket-status.service'
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
import { AddExistingDocumentVersionDialogComponent } from './add-existing-document-version-dialog/add-existing-document-version-dialog.component'
@Component({
@@ -46,15 +44,11 @@ import { AddExistingDocumentVersionDialogComponent } from './add-existing-docume
NgbDropdownModule,
NgxBootstrapIconsModule,
ConfirmButtonComponent,
IfPermissionsDirective,
SlicePipe,
CustomDatePipe,
],
})
export class DocumentVersionDropdownComponent
extends ComponentWithPermissions
implements OnChanges, OnDestroy
{
export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
UploadState = UploadState
@Input() documentId: number
+39
View File
@@ -54,6 +54,10 @@ export const ConfigCategory = {
AI: $localize`AI Settings`,
}
export const ConfigSection = {
RemoteOCR: $localize`Remote OCR`,
}
export const LLMEmbeddingBackendConfig = {
OPENAI_LIKE: 'openai-like',
HUGGINGFACE: 'huggingface',
@@ -65,6 +69,10 @@ export const LLMBackendConfig = {
OLLAMA: 'ollama',
}
export const RemoteOCREngineConfig = {
AZURE_AI: 'azureai',
}
export interface ConfigOption {
key: string
title: string
@@ -72,6 +80,7 @@ export interface ConfigOption {
choices?: Array<{ id: string; name: string }>
config_key?: string
category: string
section?: string
note?: string
}
@@ -181,6 +190,33 @@ export const PaperlessConfigOptions: ConfigOption[] = [
config_key: 'PAPERLESS_OCR_USER_ARGS',
category: ConfigCategory.OCR,
},
{
key: 'remote_ocr_engine',
title: $localize`Remote OCR Engine`,
type: ConfigOptionType.Select,
choices: mapToItems(RemoteOCREngineConfig),
config_key: 'PAPERLESS_REMOTE_OCR_ENGINE',
category: ConfigCategory.OCR,
section: ConfigSection.RemoteOCR,
note: $localize`Enabling remote OCR sends documents to a third-party service for processing. Consider the privacy implications as well as potential costs before enabling.`,
},
{
key: 'remote_ocr_api_key',
title: $localize`Remote OCR API Key`,
type: ConfigOptionType.Password,
config_key: 'PAPERLESS_REMOTE_OCR_API_KEY',
category: ConfigCategory.OCR,
section: ConfigSection.RemoteOCR,
},
{
key: 'remote_ocr_endpoint',
title: $localize`Remote OCR Endpoint`,
type: ConfigOptionType.String,
config_key: 'PAPERLESS_REMOTE_OCR_ENDPOINT',
category: ConfigCategory.OCR,
section: ConfigSection.RemoteOCR,
note: $localize`Required when using the Azure AI engine.`,
},
{
key: 'app_logo',
title: $localize`Application Logo`,
@@ -398,6 +434,9 @@ export interface PaperlessConfig extends ObjectWithId {
barcode_enable_tag: boolean
barcode_tag_mapping: object
barcode_tag_split: boolean
remote_ocr_engine: string
remote_ocr_api_key: string
remote_ocr_endpoint: string
ai_enabled: boolean
llm_embedding_backend: string
llm_embedding_model: string
@@ -72,6 +72,9 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
"barcode_enable_tag": None,
"barcode_tag_mapping": None,
"barcode_tag_split": None,
"remote_ocr_engine": None,
"remote_ocr_api_key": None,
"remote_ocr_endpoint": None,
"ai_enabled": False,
"llm_embedding_backend": None,
"llm_embedding_model": None,
@@ -870,6 +873,49 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
config.refresh_from_db()
self.assertEqual(config.llm_api_key, None)
def test_update_remote_ocr_api_key(self) -> None:
"""
GIVEN:
- Existing config with remote_ocr_api_key specified
WHEN:
- API to update remote_ocr_api_key is called with all *s
- API to update remote_ocr_api_key is called with empty string
THEN:
- remote_ocr_api_key is unchanged
- remote_ocr_api_key is set to None
"""
config = ApplicationConfiguration.objects.first()
assert config is not None
config.remote_ocr_api_key = "1234567890"
config.save()
# Test with all *
response = self.client.patch(
f"{self.ENDPOINT}1/",
json.dumps(
{
"remote_ocr_api_key": "*" * 32,
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
config.refresh_from_db()
self.assertEqual(config.remote_ocr_api_key, "1234567890")
# Test with empty string
response = self.client.patch(
f"{self.ENDPOINT}1/",
json.dumps(
{
"remote_ocr_api_key": "",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
config.refresh_from_db()
self.assertEqual(config.remote_ocr_api_key, None)
def test_enable_ai_index_triggers_update(self) -> None:
"""
GIVEN:
+38 -38
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-19 18:18+0000\n"
"POT-Creation-Date: 2026-08-19 14:23+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
@@ -2170,151 +2170,151 @@ msgstr ""
msgid "paperless application settings"
msgstr ""
#: paperless/settings/__init__.py:562
#: paperless/settings/__init__.py:556
msgid "English (US)"
msgstr ""
#: paperless/settings/__init__.py:563
#: paperless/settings/__init__.py:557
msgid "Arabic"
msgstr ""
#: paperless/settings/__init__.py:564
#: paperless/settings/__init__.py:558
msgid "Afrikaans"
msgstr ""
#: paperless/settings/__init__.py:565
#: paperless/settings/__init__.py:559
msgid "Belarusian"
msgstr ""
#: paperless/settings/__init__.py:566
#: paperless/settings/__init__.py:560
msgid "Bulgarian"
msgstr ""
#: paperless/settings/__init__.py:567
#: paperless/settings/__init__.py:561
msgid "Catalan"
msgstr ""
#: paperless/settings/__init__.py:568
#: paperless/settings/__init__.py:562
msgid "Czech"
msgstr ""
#: paperless/settings/__init__.py:569
#: paperless/settings/__init__.py:563
msgid "Danish"
msgstr ""
#: paperless/settings/__init__.py:570
#: paperless/settings/__init__.py:564
msgid "German"
msgstr ""
#: paperless/settings/__init__.py:571
#: paperless/settings/__init__.py:565
msgid "Greek"
msgstr ""
#: paperless/settings/__init__.py:572
#: paperless/settings/__init__.py:566
msgid "English (GB)"
msgstr ""
#: paperless/settings/__init__.py:573
#: paperless/settings/__init__.py:567
msgid "Spanish"
msgstr ""
#: paperless/settings/__init__.py:574
#: paperless/settings/__init__.py:568
msgid "Persian"
msgstr ""
#: paperless/settings/__init__.py:575
#: paperless/settings/__init__.py:569
msgid "Finnish"
msgstr ""
#: paperless/settings/__init__.py:576
#: paperless/settings/__init__.py:570
msgid "French"
msgstr ""
#: paperless/settings/__init__.py:577
#: paperless/settings/__init__.py:571
msgid "Hungarian"
msgstr ""
#: paperless/settings/__init__.py:578
#: paperless/settings/__init__.py:572
msgid "Indonesian"
msgstr ""
#: paperless/settings/__init__.py:579
#: paperless/settings/__init__.py:573
msgid "Italian"
msgstr ""
#: paperless/settings/__init__.py:580
#: paperless/settings/__init__.py:574
msgid "Japanese"
msgstr ""
#: paperless/settings/__init__.py:581
#: paperless/settings/__init__.py:575
msgid "Korean"
msgstr ""
#: paperless/settings/__init__.py:582
#: paperless/settings/__init__.py:576
msgid "Luxembourgish"
msgstr ""
#: paperless/settings/__init__.py:583
#: paperless/settings/__init__.py:577
msgid "Norwegian"
msgstr ""
#: paperless/settings/__init__.py:584
#: paperless/settings/__init__.py:578
msgid "Dutch"
msgstr ""
#: paperless/settings/__init__.py:585
#: paperless/settings/__init__.py:579
msgid "Polish"
msgstr ""
#: paperless/settings/__init__.py:586
#: paperless/settings/__init__.py:580
msgid "Portuguese (Brazil)"
msgstr ""
#: paperless/settings/__init__.py:587
#: paperless/settings/__init__.py:581
msgid "Portuguese"
msgstr ""
#: paperless/settings/__init__.py:588
#: paperless/settings/__init__.py:582
msgid "Romanian"
msgstr ""
#: paperless/settings/__init__.py:589
#: paperless/settings/__init__.py:583
msgid "Russian"
msgstr ""
#: paperless/settings/__init__.py:590
#: paperless/settings/__init__.py:584
msgid "Slovak"
msgstr ""
#: paperless/settings/__init__.py:591
#: paperless/settings/__init__.py:585
msgid "Slovenian"
msgstr ""
#: paperless/settings/__init__.py:592
#: paperless/settings/__init__.py:586
msgid "Serbian"
msgstr ""
#: paperless/settings/__init__.py:593
#: paperless/settings/__init__.py:587
msgid "Swedish"
msgstr ""
#: paperless/settings/__init__.py:594
#: paperless/settings/__init__.py:588
msgid "Turkish"
msgstr ""
#: paperless/settings/__init__.py:595
#: paperless/settings/__init__.py:589
msgid "Ukrainian"
msgstr ""
#: paperless/settings/__init__.py:596
#: paperless/settings/__init__.py:590
msgid "Vietnamese"
msgstr ""
#: paperless/settings/__init__.py:597
#: paperless/settings/__init__.py:591
msgid "Chinese Simplified"
msgstr ""
#: paperless/settings/__init__.py:598
#: paperless/settings/__init__.py:592
msgid "Chinese Traditional"
msgstr ""
-14
View File
@@ -337,20 +337,6 @@ def check_deprecated_v2_ocr_env_vars(
return warnings
@register()
def check_remote_parser_configured(app_configs: Any, **kwargs: Any) -> list[Error]:
if settings.REMOTE_OCR_ENGINE == "azureai" and not (
settings.REMOTE_OCR_ENDPOINT and settings.REMOTE_OCR_API_KEY
):
return [
Error(
"Azure AI remote parser requires endpoint and API key to be configured.",
),
]
return []
def get_tesseract_langs():
proc = subprocess.run(
[shutil.which("tesseract"), "--list-langs"],
+24
View File
@@ -185,6 +185,30 @@ class GeneralConfig(BaseConfig):
self.app_logo = app_config.app_logo.url if app_config.app_logo else None
@dataclasses.dataclass
class RemoteOCRConfig(BaseConfig):
"""
Settings for the remote (cloud) OCR parser
"""
remote_ocr_engine: str | None = dataclasses.field(init=False)
remote_ocr_api_key: str | None = dataclasses.field(init=False)
remote_ocr_endpoint: str | None = dataclasses.field(init=False)
def __post_init__(self) -> None:
app_config = self._get_config_instance()
self.remote_ocr_engine = (
app_config.remote_ocr_engine or settings.REMOTE_OCR_ENGINE
)
self.remote_ocr_api_key = (
app_config.remote_ocr_api_key or settings.REMOTE_OCR_API_KEY
)
self.remote_ocr_endpoint = (
app_config.remote_ocr_endpoint or settings.REMOTE_OCR_ENDPOINT
)
@dataclasses.dataclass
class AIConfig(BaseConfig):
"""
@@ -0,0 +1,44 @@
# Generated by Django 5.2.16 on 2026-08-10 14:37
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("paperless", "0013_applicationconfiguration_llm_request_timeout"),
]
operations = [
migrations.AddField(
model_name="applicationconfiguration",
name="remote_ocr_api_key",
field=models.CharField(
blank=True,
max_length=1024,
null=True,
verbose_name="Sets the remote OCR API key",
),
),
migrations.AddField(
model_name="applicationconfiguration",
name="remote_ocr_endpoint",
field=models.CharField(
blank=True,
max_length=256,
null=True,
verbose_name="Sets the remote OCR endpoint",
),
),
migrations.AddField(
model_name="applicationconfiguration",
name="remote_ocr_engine",
field=models.CharField(
blank=True,
choices=[("azureai", "Azure AI Document Intelligence")],
max_length=32,
null=True,
verbose_name="Sets the remote OCR engine",
),
),
]
+37
View File
@@ -74,6 +74,14 @@ class ColorConvertChoices(models.TextChoices):
CMYK = ("CMYK", _("CMYK"))
class RemoteOCREngine(models.TextChoices):
"""
Matches to PAPERLESS_REMOTE_OCR_ENGINE
"""
AZURE_AI = ("azureai", _("Azure AI Document Intelligence"))
class LLMEmbeddingBackend(models.TextChoices):
OPENAI_LIKE = ("openai-like", _("OpenAI-compatible"))
HUGGINGFACE = ("huggingface", _("Huggingface"))
@@ -286,6 +294,35 @@ class ApplicationConfiguration(AbstractSingletonModel):
null=True,
)
"""
Settings for the remote OCR parser
"""
# PAPERLESS_REMOTE_OCR_ENGINE
remote_ocr_engine = models.CharField(
verbose_name=_("Sets the remote OCR engine"),
blank=True,
null=True,
max_length=32,
choices=RemoteOCREngine.choices,
)
# PAPERLESS_REMOTE_OCR_API_KEY
remote_ocr_api_key = models.CharField(
verbose_name=_("Sets the remote OCR API key"),
blank=True,
null=True,
max_length=1024,
)
# PAPERLESS_REMOTE_OCR_ENDPOINT
remote_ocr_endpoint = models.CharField(
verbose_name=_("Sets the remote OCR endpoint"),
blank=True,
null=True,
max_length=256,
)
"""
AI related settings
"""
+14 -10
View File
@@ -61,6 +61,18 @@ class RemoteEngineConfig:
self.api_key = api_key
self.endpoint = endpoint
@classmethod
def from_app_config(cls) -> Self:
"""Build the config from the app config, falling back to the env."""
from paperless.config import RemoteOCRConfig
app_config = RemoteOCRConfig()
return cls(
engine=app_config.remote_ocr_engine,
api_key=app_config.remote_ocr_api_key,
endpoint=app_config.remote_ocr_endpoint,
)
def engine_is_valid(self) -> bool:
"""Return True when the engine is known and fully configured."""
return (
@@ -145,11 +157,7 @@ class RemoteDocumentParser:
20 when the remote engine is configured and the MIME type is
supported, otherwise None.
"""
config = RemoteEngineConfig(
engine=settings.REMOTE_OCR_ENGINE,
api_key=settings.REMOTE_OCR_API_KEY,
endpoint=settings.REMOTE_OCR_ENDPOINT,
)
config = RemoteEngineConfig.from_app_config()
if not config.engine_is_valid():
return None
if mime_type not in _SUPPORTED_MIME_TYPES:
@@ -244,11 +252,7 @@ class RemoteDocumentParser:
Whether an archive copy is wanted. For PDFs, False skips the
remote engine and uses locally-extracted text instead.
"""
config = RemoteEngineConfig(
engine=settings.REMOTE_OCR_ENGINE,
api_key=settings.REMOTE_OCR_API_KEY,
endpoint=settings.REMOTE_OCR_ENDPOINT,
)
config = RemoteEngineConfig.from_app_config()
if not config.engine_is_valid():
logger.warning(
+14 -5
View File
@@ -219,6 +219,13 @@ class ApplicationConfigurationSerializer(
allow_null=True,
max_length=1024,
)
remote_ocr_api_key = ObfuscatedPasswordField(
required=False,
allow_null=True,
max_length=1024,
)
OBFUSCATED_FIELDS = ("llm_api_key", "remote_ocr_api_key")
def run_validation(self, data):
# Empty strings treated as None to avoid unexpected behavior
@@ -230,11 +237,13 @@ class ApplicationConfigurationSerializer(
data["language"] = None
if "llm_output_language" in data and data["llm_output_language"] == "":
data["llm_output_language"] = None
if "llm_api_key" in data and data["llm_api_key"] is not None:
if data["llm_api_key"] == "":
data["llm_api_key"] = None
elif len(data["llm_api_key"].replace("*", "")) == 0:
del data["llm_api_key"]
for field in self.OBFUSCATED_FIELDS:
if field in data and data[field] is not None:
if data[field] == "":
data[field] = None
# Not a real value, don't overwrite the stored one
elif len(data[field].replace("*", "")) == 0:
del data[field]
return super().run_validation(data)
def update(self, instance, validated_data):
-10
View File
@@ -1,7 +1,6 @@
import datetime
import logging
import os
from hashlib import sha256
from pathlib import Path
from typing import Any
@@ -173,15 +172,6 @@ def parse_beat_schedule() -> dict:
# Don't add disabled tasks to the schedule
if value == "disable":
continue
if (
task["env_key"] == "PAPERLESS_EMAIL_TASK_CRON"
and task["env_key"] not in os.environ
):
# Spread default polling across the ten-minute interval.
secret = os.environ["PAPERLESS_SECRET_KEY"].encode()
offset = int.from_bytes(sha256(secret).digest()) % 10
minutes = ",".join(str(minute) for minute in range(offset, 60, 10))
value = f"{minutes} * * * *"
# I find https://crontab.guru/ super helpful
# crontab(5) format
# - five time-and-date fields
+24 -2
View File
@@ -114,7 +114,26 @@ def remote_parser() -> Generator[RemoteDocumentParser, None, None]:
@pytest.fixture()
def azure_settings(settings: SettingsWrapper) -> SettingsWrapper:
def empty_remote_ocr_app_config(mocker: MockerFixture) -> MagicMock:
# empty app config without accessing db
app_config = mocker.MagicMock(
remote_ocr_engine=None,
remote_ocr_api_key=None,
remote_ocr_endpoint=None,
remote_ocr_mode=None,
)
mocker.patch(
"paperless.config.BaseConfig._get_config_instance",
return_value=app_config,
)
return app_config
@pytest.fixture()
def azure_settings(
settings: SettingsWrapper,
empty_remote_ocr_app_config: MagicMock,
) -> SettingsWrapper:
"""Configure Django settings for a valid Azure AI OCR engine.
Sets ``REMOTE_OCR_ENGINE``, ``REMOTE_OCR_API_KEY``, and
@@ -133,7 +152,10 @@ def azure_settings(settings: SettingsWrapper) -> SettingsWrapper:
@pytest.fixture()
def no_engine_settings(settings: SettingsWrapper) -> SettingsWrapper:
def no_engine_settings(
settings: SettingsWrapper,
empty_remote_ocr_app_config: MagicMock,
) -> SettingsWrapper:
"""Configure Django settings with no remote engine configured.
Returns
@@ -21,6 +21,7 @@ from unittest.mock import Mock
import pytest
from documents.parsers import ParseError
from paperless.models import ApplicationConfiguration
from paperless.parsers import ParserContext
from paperless.parsers import ParserProtocol
from paperless.parsers.remote import RemoteDocumentParser
@@ -198,21 +199,21 @@ class TestRemoteParserScore:
def test_score_returns_none_when_api_key_missing(
self,
settings: SettingsWrapper,
no_engine_settings: SettingsWrapper,
) -> None:
settings.REMOTE_OCR_ENGINE = "azureai"
settings.REMOTE_OCR_API_KEY = None
settings.REMOTE_OCR_ENDPOINT = "https://test.cognitiveservices.azure.com"
no_engine_settings.REMOTE_OCR_ENGINE = "azureai"
no_engine_settings.REMOTE_OCR_ENDPOINT = (
"https://test.cognitiveservices.azure.com"
)
result = RemoteDocumentParser.score("application/pdf", "doc.pdf")
assert result is None
def test_score_returns_none_when_endpoint_missing(
self,
settings: SettingsWrapper,
no_engine_settings: SettingsWrapper,
) -> None:
settings.REMOTE_OCR_ENGINE = "azureai"
settings.REMOTE_OCR_API_KEY = "key"
settings.REMOTE_OCR_ENDPOINT = None
no_engine_settings.REMOTE_OCR_ENGINE = "azureai"
no_engine_settings.REMOTE_OCR_API_KEY = "key"
result = RemoteDocumentParser.score("application/pdf", "doc.pdf")
assert result is None
@@ -227,6 +228,24 @@ class TestRemoteParserScore:
score = RemoteDocumentParser.score("application/pdf", "doc.pdf")
assert score is not None and score > 10
@pytest.mark.django_db
def test_score_uses_app_config_when_env_unset(
self,
settings: SettingsWrapper,
) -> None:
"""The app config alone is enough to activate the parser."""
settings.REMOTE_OCR_ENGINE = None
settings.REMOTE_OCR_API_KEY = None
settings.REMOTE_OCR_ENDPOINT = None
config = ApplicationConfiguration.objects.first()
assert config is not None
config.remote_ocr_engine = "azureai"
config.remote_ocr_api_key = "app-config-key"
config.remote_ocr_endpoint = "https://config.cognitiveservices.azure.com"
config.save()
assert RemoteDocumentParser.score("application/pdf", "doc.pdf") == 20
# ---------------------------------------------------------------------------
# Properties
@@ -1277,6 +1277,8 @@ class TestParserFileTypes:
# ---------------------------------------------------------------------------
# Remote ocr config from ApplicationConfiguration needs DB access
@pytest.mark.django_db
class TestRasterisedDocumentParserRegistry:
def test_registered_in_defaults(self) -> None:
from paperless.parsers.registry import ParserRegistry
@@ -168,7 +168,6 @@ class TestParseHostingSettings:
def make_expected_schedule(
overrides: dict[str, dict[str, Any]] | None = None,
disabled: set[str] | None = None,
email_minute: str = "6,16,26,36,46,56",
) -> dict[str, Any]:
"""
Build the expected schedule with optional overrides and disabled tasks.
@@ -186,7 +185,7 @@ def make_expected_schedule(
schedule: dict[str, Any] = {
"Check all e-mail accounts": {
"task": "paperless_mail.tasks.process_mail_accounts",
"schedule": crontab(minute=email_minute),
"schedule": crontab(minute="*/10"),
"options": {
"expires": mail_expire,
"headers": {"trigger_source": "scheduled"},
@@ -267,11 +266,6 @@ class TestParseBeatSchedule:
("env", "expected"),
[
pytest.param({}, make_expected_schedule(), id="defaults"),
pytest.param(
{"PAPERLESS_EMAIL_TASK_CRON": "*/10 * * * *"},
make_expected_schedule(email_minute="*/10"),
id="email-explicit-default",
),
pytest.param(
{"PAPERLESS_EMAIL_TASK_CRON": "*/50 * * * mon"},
make_expected_schedule(
@@ -310,11 +304,7 @@ class TestParseBeatSchedule:
expected: dict[str, Any],
mocker: MockerFixture,
) -> None:
mocker.patch.dict(
os.environ,
{"PAPERLESS_SECRET_KEY": "test-secret", **env},
clear=False,
)
mocker.patch.dict(os.environ, env, clear=False)
schedule = parse_beat_schedule()
assert schedule == expected
-26
View File
@@ -15,7 +15,6 @@ from paperless.checks import audit_log_check
from paperless.checks import binaries_check
from paperless.checks import check_default_language_available
from paperless.checks import check_deprecated_db_settings
from paperless.checks import check_remote_parser_configured
from paperless.checks import check_v3_minimum_upgrade_version
from paperless.checks import debug_mode_check
from paperless.checks import paths_check
@@ -631,31 +630,6 @@ class TestV3MinimumUpgradeVersionCheck:
assert check_v3_minimum_upgrade_version(None) == []
class TestRemoteParserChecks:
def test_no_engine(self, settings: SettingsWrapper) -> None:
settings.REMOTE_OCR_ENGINE = None
msgs = check_remote_parser_configured(None)
assert len(msgs) == 0
def test_azure_no_endpoint(self, settings: SettingsWrapper) -> None:
settings.REMOTE_OCR_ENGINE = "azureai"
settings.REMOTE_OCR_API_KEY = "somekey"
settings.REMOTE_OCR_ENDPOINT = None
msgs = check_remote_parser_configured(None)
assert len(msgs) == 1
msg = msgs[0]
assert (
"Azure AI remote parser requires endpoint and API key to be configured."
in msg.msg
)
class TestTesseractChecks:
def test_default_language(self) -> None:
check_default_language_available(None)
@@ -0,0 +1,88 @@
"""Tests for RemoteOCRConfig precedence between app config and Django settings."""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from django.test import override_settings
from paperless.config import RemoteOCRConfig
if TYPE_CHECKING:
from unittest.mock import MagicMock
@pytest.fixture()
def null_app_config(mocker) -> MagicMock:
"""Mock ApplicationConfiguration with all fields None → falls back to Django settings."""
return mocker.MagicMock(
remote_ocr_engine=None,
remote_ocr_api_key=None,
remote_ocr_endpoint=None,
)
@pytest.fixture()
def make_remote_ocr_config(mocker):
def _make(app_config, **django_settings_overrides):
mocker.patch(
"paperless.config.BaseConfig._get_config_instance",
return_value=app_config,
)
with override_settings(**django_settings_overrides):
return RemoteOCRConfig()
return _make
class TestRemoteOCRConfig:
def test_falls_back_to_settings(
self,
make_remote_ocr_config,
null_app_config,
) -> None:
cfg = make_remote_ocr_config(
null_app_config,
REMOTE_OCR_ENGINE="azureai",
REMOTE_OCR_API_KEY="env-key",
REMOTE_OCR_ENDPOINT="https://env.cognitiveservices.azure.com",
)
assert cfg.remote_ocr_engine == "azureai"
assert cfg.remote_ocr_api_key == "env-key"
assert cfg.remote_ocr_endpoint == "https://env.cognitiveservices.azure.com"
def test_app_config_takes_precedence(
self,
make_remote_ocr_config,
mocker,
) -> None:
app_config = mocker.MagicMock(
remote_ocr_engine="azureai",
remote_ocr_api_key="db-key",
remote_ocr_endpoint="https://db.cognitiveservices.azure.com",
)
cfg = make_remote_ocr_config(
app_config,
REMOTE_OCR_ENGINE=None,
REMOTE_OCR_API_KEY="env-key",
REMOTE_OCR_ENDPOINT="https://env.cognitiveservices.azure.com",
)
assert cfg.remote_ocr_engine == "azureai"
assert cfg.remote_ocr_api_key == "db-key"
assert cfg.remote_ocr_endpoint == "https://db.cognitiveservices.azure.com"
def test_unset_everywhere(
self,
make_remote_ocr_config,
null_app_config,
) -> None:
cfg = make_remote_ocr_config(
null_app_config,
REMOTE_OCR_ENGINE=None,
REMOTE_OCR_API_KEY=None,
REMOTE_OCR_ENDPOINT=None,
)
assert cfg.remote_ocr_engine is None
assert cfg.remote_ocr_api_key is None
assert cfg.remote_ocr_endpoint is None