Compare commits

..
49 changed files with 6922 additions and 1561 deletions
+5 -5
View File
@@ -83,7 +83,7 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with: with:
package_json_file: src-ui/package.json version: 10
- name: Use Node.js 24 - name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
@@ -115,7 +115,7 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with: with:
package_json_file: src-ui/package.json version: 10
- name: Use Node.js 24 - name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
@@ -154,7 +154,7 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with: with:
package_json_file: src-ui/package.json version: 10
- name: Use Node.js 24 - name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
@@ -209,7 +209,7 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with: with:
package_json_file: src-ui/package.json version: 10
- name: Use Node.js 24 - name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
@@ -243,7 +243,7 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with: with:
package_json_file: src-ui/package.json version: 10
- name: Use Node.js 24 - name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with: with:
package_json_file: src-ui/package.json version: 10
- name: Use Node.js 24 - name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with: with:
package_json_file: src-ui/package.json version: 10
- name: Use Node.js 24 - name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
+12
View File
@@ -2048,6 +2048,18 @@ password. All of these options come from their similarly-named [Django settings]
Defaults to None. Defaults to None.
#### [`PAPERLESS_REMOTE_OCR_MODE=<str>`](#PAPERLESS_REMOTE_OCR_MODE) {#PAPERLESS_REMOTE_OCR_MODE}
: Which documents are sent to the remote OCR engine.
- `always`: every document of a supported file type is sent to the remote
engine, bypassing the local OCR engine.
- `workflow_only`: documents are processed locally unless a workflow
explicitly enables remote OCR for them, letting you use the remote engine
selectively.
Defaults to "always".
## AI {#ai} ## AI {#ai}
#### [`PAPERLESS_AI_ENABLED=<bool>`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED} #### [`PAPERLESS_AI_ENABLED=<bool>`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED}
+14
View File
@@ -456,6 +456,20 @@ def score(
return 10 return 10
``` ```
**Remote services**
If your parser sends document content to a remote service, declare it:
```python
class MyCustomParser:
uses_remote_service = True
```
Paperless-ngx excludes such parsers when the document being consumed has not
been marked for remote processing, so users can keep remote OCR off by default
and enable it selectively with a workflow. Parsers that do not declare the
attribute are treated as fully local and are always considered.
**Archive and rendition flags** **Archive and rendition flags**
```python ```python
+6 -1
View File
@@ -1086,11 +1086,16 @@ 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). [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 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 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 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). 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.
## Architecture ## Architecture
Paperless-ngx consists of the following components: Paperless-ngx consists of the following components:
+63 -63
View File
@@ -5,14 +5,14 @@
<trans-unit id="ngb.alert.close" datatype="html"> <trans-unit id="ngb.alert.close" datatype="html">
<source>Close</source> <source>Close</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/alert/alert.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.0_@angular+core@22.1.0_@angular+_379bf773160c49398165933da2ae33fd/node_modules/src/alert/alert.ts</context>
<context context-type="linenumber">39</context> <context context-type="linenumber">39</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.carousel.slide-number" datatype="html"> <trans-unit id="ngb.carousel.slide-number" datatype="html">
<source> Slide <x id="INTERPOLATION"/> of <x id="INTERPOLATION_1"/> </source> <source> Slide <x id="INTERPOLATION"/> of <x id="INTERPOLATION_1"/> </source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/carousel/carousel.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.0_@angular+core@22.1.0_@angular+_379bf773160c49398165933da2ae33fd/node_modules/src/carousel/carousel.ts</context>
<context context-type="linenumber">129</context> <context context-type="linenumber">129</context>
</context-group> </context-group>
<note priority="1" from="description">Currently selected slide number read by screen reader</note> <note priority="1" from="description">Currently selected slide number read by screen reader</note>
@@ -20,219 +20,219 @@
<trans-unit id="ngb.carousel.previous" datatype="html"> <trans-unit id="ngb.carousel.previous" datatype="html">
<source>Previous</source> <source>Previous</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/carousel/carousel.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.0_@angular+core@22.1.0_@angular+_379bf773160c49398165933da2ae33fd/node_modules/src/carousel/carousel.ts</context>
<context context-type="linenumber">147,148</context> <context context-type="linenumber">147,148</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.carousel.next" datatype="html"> <trans-unit id="ngb.carousel.next" datatype="html">
<source>Next</source> <source>Next</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/carousel/carousel.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.0_@angular+core@22.1.0_@angular+_379bf773160c49398165933da2ae33fd/node_modules/src/carousel/carousel.ts</context>
<context context-type="linenumber">187</context> <context context-type="linenumber">187</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.datepicker.select-month" datatype="html"> <trans-unit id="ngb.datepicker.select-month" datatype="html">
<source>Select month</source> <source>Select month</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/datepicker/datepicker-navigation-select.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.0_@angular+core@22.1.0_@angular+_379bf773160c49398165933da2ae33fd/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
<context context-type="linenumber">89</context> <context context-type="linenumber">89</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/datepicker/datepicker-navigation-select.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.0_@angular+core@22.1.0_@angular+_379bf773160c49398165933da2ae33fd/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
<context context-type="linenumber">89</context> <context context-type="linenumber">89</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.datepicker.select-year" datatype="html"> <trans-unit id="ngb.datepicker.select-year" datatype="html">
<source>Select year</source> <source>Select year</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/datepicker/datepicker-navigation-select.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.0_@angular+core@22.1.0_@angular+_379bf773160c49398165933da2ae33fd/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
<context context-type="linenumber">89</context> <context context-type="linenumber">89</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/datepicker/datepicker-navigation-select.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.0_@angular+core@22.1.0_@angular+_379bf773160c49398165933da2ae33fd/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
<context context-type="linenumber">89</context> <context context-type="linenumber">89</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.datepicker.previous-month" datatype="html"> <trans-unit id="ngb.datepicker.previous-month" datatype="html">
<source>Previous month</source> <source>Previous month</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/datepicker/datepicker-navigation.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.0_@angular+core@22.1.0_@angular+_379bf773160c49398165933da2ae33fd/node_modules/src/datepicker/datepicker-navigation.ts</context>
<context context-type="linenumber">74,76</context> <context context-type="linenumber">74,76</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/datepicker/datepicker-navigation.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.0_@angular+core@22.1.0_@angular+_379bf773160c49398165933da2ae33fd/node_modules/src/datepicker/datepicker-navigation.ts</context>
<context context-type="linenumber">103</context> <context context-type="linenumber">103</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.datepicker.next-month" datatype="html"> <trans-unit id="ngb.datepicker.next-month" datatype="html">
<source>Next month</source> <source>Next month</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/datepicker/datepicker-navigation.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.0_@angular+core@22.1.0_@angular+_379bf773160c49398165933da2ae33fd/node_modules/src/datepicker/datepicker-navigation.ts</context>
<context context-type="linenumber">103</context> <context context-type="linenumber">103</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/datepicker/datepicker-navigation.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.0_@angular+core@22.1.0_@angular+_379bf773160c49398165933da2ae33fd/node_modules/src/datepicker/datepicker-navigation.ts</context>
<context context-type="linenumber">103</context> <context context-type="linenumber">103</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.pagination.first" datatype="html"> <trans-unit id="ngb.pagination.first" datatype="html">
<source>««</source> <source>««</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/pagination/pagination.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.0_@angular+core@22.1.0_@angular+_379bf773160c49398165933da2ae33fd/node_modules/src/pagination/pagination.ts</context>
<context context-type="linenumber">297</context> <context context-type="linenumber">297</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.pagination.previous" datatype="html"> <trans-unit id="ngb.pagination.previous" datatype="html">
<source>«</source> <source>«</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/pagination/pagination.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.0_@angular+core@22.1.0_@angular+_379bf773160c49398165933da2ae33fd/node_modules/src/pagination/pagination.ts</context>
<context context-type="linenumber">322</context> <context context-type="linenumber">322</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.pagination.next" datatype="html"> <trans-unit id="ngb.pagination.next" datatype="html">
<source>»</source> <source>»</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/pagination/pagination.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.0_@angular+core@22.1.0_@angular+_379bf773160c49398165933da2ae33fd/node_modules/src/pagination/pagination.ts</context>
<context context-type="linenumber">358</context> <context context-type="linenumber">358</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.pagination.last" datatype="html"> <trans-unit id="ngb.pagination.last" datatype="html">
<source>»»</source> <source>»»</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/pagination/pagination.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.0_@angular+core@22.1.0_@angular+_379bf773160c49398165933da2ae33fd/node_modules/src/pagination/pagination.ts</context>
<context context-type="linenumber">396</context> <context context-type="linenumber">396</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.pagination.first-aria" datatype="html"> <trans-unit id="ngb.pagination.first-aria" datatype="html">
<source>First</source> <source>First</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/pagination/pagination.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.0_@angular+core@22.1.0_@angular+_379bf773160c49398165933da2ae33fd/node_modules/src/pagination/pagination.ts</context>
<context context-type="linenumber">414,415</context> <context context-type="linenumber">414,415</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.pagination.previous-aria" datatype="html"> <trans-unit id="ngb.pagination.previous-aria" datatype="html">
<source>Previous</source> <source>Previous</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/pagination/pagination.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.0_@angular+core@22.1.0_@angular+_379bf773160c49398165933da2ae33fd/node_modules/src/pagination/pagination.ts</context>
<context context-type="linenumber">444,445</context> <context context-type="linenumber">444,445</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.pagination.next-aria" datatype="html"> <trans-unit id="ngb.pagination.next-aria" datatype="html">
<source>Next</source> <source>Next</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/pagination/pagination.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.0_@angular+core@22.1.0_@angular+_379bf773160c49398165933da2ae33fd/node_modules/src/pagination/pagination.ts</context>
<context context-type="linenumber">479,480</context> <context context-type="linenumber">479,480</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.pagination.last-aria" datatype="html"> <trans-unit id="ngb.pagination.last-aria" datatype="html">
<source>Last</source> <source>Last</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/pagination/pagination.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.0_@angular+core@22.1.0_@angular+_379bf773160c49398165933da2ae33fd/node_modules/src/pagination/pagination.ts</context>
<context context-type="linenumber">506</context> <context context-type="linenumber">506</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.progressbar.value" datatype="html"> <trans-unit id="ngb.progressbar.value" datatype="html">
<source><x id="INTERPOLATION"/></source> <source><x id="INTERPOLATION"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/progressbar/progressbar.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.0_@angular+core@22.1.0_@angular+_379bf773160c49398165933da2ae33fd/node_modules/src/progressbar/progressbar.ts</context>
<context context-type="linenumber">40</context> <context context-type="linenumber">40</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.HH" datatype="html"> <trans-unit id="ngb.timepicker.HH" datatype="html">
<source>HH</source> <source>HH</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.0_@angular+core@22.1.0_@angular+_379bf773160c49398165933da2ae33fd/node_modules/src/timepicker/timepicker.ts</context>
<context context-type="linenumber">256,259</context> <context context-type="linenumber">256,259</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.hours" datatype="html"> <trans-unit id="ngb.timepicker.hours" datatype="html">
<source>Hours</source> <source>Hours</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.0_@angular+core@22.1.0_@angular+_379bf773160c49398165933da2ae33fd/node_modules/src/timepicker/timepicker.ts</context>
<context context-type="linenumber">293,295</context> <context context-type="linenumber">293,295</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.MM" datatype="html"> <trans-unit id="ngb.timepicker.MM" datatype="html">
<source>MM</source> <source>MM</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.0_@angular+core@22.1.0_@angular+_379bf773160c49398165933da2ae33fd/node_modules/src/timepicker/timepicker.ts</context>
<context context-type="linenumber">322</context> <context context-type="linenumber">322</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.minutes" datatype="html"> <trans-unit id="ngb.timepicker.minutes" datatype="html">
<source>Minutes</source> <source>Minutes</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.0_@angular+core@22.1.0_@angular+_379bf773160c49398165933da2ae33fd/node_modules/src/timepicker/timepicker.ts</context>
<context context-type="linenumber">360</context> <context context-type="linenumber">360</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.increment-hours" datatype="html"> <trans-unit id="ngb.timepicker.increment-hours" datatype="html">
<source>Increment hours</source> <source>Increment hours</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.0_@angular+core@22.1.0_@angular+_379bf773160c49398165933da2ae33fd/node_modules/src/timepicker/timepicker.ts</context>
<context context-type="linenumber">397,399</context> <context context-type="linenumber">397,399</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.decrement-hours" datatype="html"> <trans-unit id="ngb.timepicker.decrement-hours" datatype="html">
<source>Decrement hours</source> <source>Decrement hours</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.0_@angular+core@22.1.0_@angular+_379bf773160c49398165933da2ae33fd/node_modules/src/timepicker/timepicker.ts</context>
<context context-type="linenumber">435,438</context> <context context-type="linenumber">435,438</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.increment-minutes" datatype="html"> <trans-unit id="ngb.timepicker.increment-minutes" datatype="html">
<source>Increment minutes</source> <source>Increment minutes</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.0_@angular+core@22.1.0_@angular+_379bf773160c49398165933da2ae33fd/node_modules/src/timepicker/timepicker.ts</context>
<context context-type="linenumber">442</context> <context context-type="linenumber">442</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.decrement-minutes" datatype="html"> <trans-unit id="ngb.timepicker.decrement-minutes" datatype="html">
<source>Decrement minutes</source> <source>Decrement minutes</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.0_@angular+core@22.1.0_@angular+_379bf773160c49398165933da2ae33fd/node_modules/src/timepicker/timepicker.ts</context>
<context context-type="linenumber">442</context> <context context-type="linenumber">442</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.SS" datatype="html"> <trans-unit id="ngb.timepicker.SS" datatype="html">
<source>SS</source> <source>SS</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.0_@angular+core@22.1.0_@angular+_379bf773160c49398165933da2ae33fd/node_modules/src/timepicker/timepicker.ts</context>
<context context-type="linenumber">442</context> <context context-type="linenumber">442</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.seconds" datatype="html"> <trans-unit id="ngb.timepicker.seconds" datatype="html">
<source>Seconds</source> <source>Seconds</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.0_@angular+core@22.1.0_@angular+_379bf773160c49398165933da2ae33fd/node_modules/src/timepicker/timepicker.ts</context>
<context context-type="linenumber">442</context> <context context-type="linenumber">442</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.increment-seconds" datatype="html"> <trans-unit id="ngb.timepicker.increment-seconds" datatype="html">
<source>Increment seconds</source> <source>Increment seconds</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.0_@angular+core@22.1.0_@angular+_379bf773160c49398165933da2ae33fd/node_modules/src/timepicker/timepicker.ts</context>
<context context-type="linenumber">442</context> <context context-type="linenumber">442</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.decrement-seconds" datatype="html"> <trans-unit id="ngb.timepicker.decrement-seconds" datatype="html">
<source>Decrement seconds</source> <source>Decrement seconds</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.0_@angular+core@22.1.0_@angular+_379bf773160c49398165933da2ae33fd/node_modules/src/timepicker/timepicker.ts</context>
<context context-type="linenumber">442</context> <context context-type="linenumber">442</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.timepicker.PM" datatype="html"> <trans-unit id="ngb.timepicker.PM" datatype="html">
<source><x id="INTERPOLATION"/></source> <source><x id="INTERPOLATION"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.0_@angular+core@22.1.0_@angular+_379bf773160c49398165933da2ae33fd/node_modules/src/timepicker/timepicker.ts</context>
<context context-type="linenumber">442</context> <context context-type="linenumber">442</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="ngb.toast.close-aria" datatype="html"> <trans-unit id="ngb.toast.close-aria" datatype="html">
<source>Close</source> <source>Close</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/toast/toast.ts</context> <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.0_@angular+core@22.1.0_@angular+_379bf773160c49398165933da2ae33fd/node_modules/src/toast/toast.ts</context>
<context context-type="linenumber">110,112</context> <context context-type="linenumber">110,112</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
@@ -343,7 +343,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context> <context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
<context context-type="linenumber">59</context> <context context-type="linenumber">58</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/management-list/management-list.component.html</context> <context context-type="sourcefile">src/app/components/manage/document-attributes/management-list/management-list.component.html</context>
@@ -2655,11 +2655,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context> <context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
<context context-type="linenumber">32,33</context> <context context-type="linenumber">31,32</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context> <context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
<context context-type="linenumber">50</context> <context context-type="linenumber">49</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.html</context> <context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.html</context>
@@ -2962,11 +2962,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context> <context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
<context context-type="linenumber">31,32</context> <context context-type="linenumber">30,31</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context> <context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
<context context-type="linenumber">47</context> <context context-type="linenumber">46</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/management-list/management-list.component.html</context> <context context-type="sourcefile">src/app/components/manage/document-attributes/management-list/management-list.component.html</context>
@@ -3098,15 +3098,15 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">915</context> <context context-type="linenumber">910</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">951</context> <context context-type="linenumber">946</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">974</context> <context context-type="linenumber">969</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.ts</context> <context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.ts</context>
@@ -3723,7 +3723,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">863</context> <context context-type="linenumber">858</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="994016933065248559" datatype="html"> <trans-unit id="994016933065248559" datatype="html">
@@ -5743,7 +5743,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">867</context> <context context-type="linenumber">862</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4522609911791833187" datatype="html"> <trans-unit id="4522609911791833187" datatype="html">
@@ -5965,7 +5965,7 @@
<source>Not assigned</source> <source>Not assigned</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts</context> <context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts</context>
<context context-type="linenumber">104</context> <context context-type="linenumber">100</context>
</context-group> </context-group>
<note priority="1" from="description">Filter drop down element to filter for documents with no correspondent/type/tag assigned</note> <note priority="1" from="description">Filter drop down element to filter for documents with no correspondent/type/tag assigned</note>
</trans-unit> </trans-unit>
@@ -5973,7 +5973,7 @@
<source>Open <x id="PH" equiv-text="this.title"/> filter</source> <source>Open <x id="PH" equiv-text="this.title"/> filter</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts</context> <context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts</context>
<context context-type="linenumber">835</context> <context context-type="linenumber">831</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7005745151564974365" datatype="html"> <trans-unit id="7005745151564974365" datatype="html">
@@ -8197,7 +8197,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">865</context> <context context-type="linenumber">860</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7295637485862454066" datatype="html"> <trans-unit id="7295637485862454066" datatype="html">
@@ -8215,7 +8215,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">911</context> <context context-type="linenumber">906</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2951161989614003846" datatype="html"> <trans-unit id="2951161989614003846" datatype="html">
@@ -8762,84 +8762,84 @@
<source>Move <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s) to the trash?</source> <source>Move <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s) to the trash?</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">864</context> <context context-type="linenumber">859</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8585195717323764335" datatype="html"> <trans-unit id="8585195717323764335" datatype="html">
<source>This operation will permanently recreate the archive files for <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</source> <source>This operation will permanently recreate the archive files for <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">912</context> <context context-type="linenumber">907</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7366623494074776040" datatype="html"> <trans-unit id="7366623494074776040" datatype="html">
<source>The archive files will be re-generated with the current settings.</source> <source>The archive files will be re-generated with the current settings.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">913</context> <context context-type="linenumber">908</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6555329262222566158" datatype="html"> <trans-unit id="6555329262222566158" datatype="html">
<source>Rotate confirm</source> <source>Rotate confirm</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">948</context> <context context-type="linenumber">943</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5203024009814367559" datatype="html"> <trans-unit id="5203024009814367559" datatype="html">
<source>This operation will add rotated versions of the <x id="PH" equiv-text="this.getSelectionSize()"/> document(s).</source> <source>This operation will add rotated versions of the <x id="PH" equiv-text="this.getSelectionSize()"/> document(s).</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">949</context> <context context-type="linenumber">944</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7910756456450124185" datatype="html"> <trans-unit id="7910756456450124185" datatype="html">
<source>Merge confirm</source> <source>Merge confirm</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">972</context> <context context-type="linenumber">967</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7643543647233874431" datatype="html"> <trans-unit id="7643543647233874431" datatype="html">
<source>This operation will merge <x id="PH" equiv-text="this.getSelectionSize()"/> selected documents into a new document.</source> <source>This operation will merge <x id="PH" equiv-text="this.getSelectionSize()"/> selected documents into a new document.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">973</context> <context context-type="linenumber">968</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7869008840945899895" datatype="html"> <trans-unit id="7869008840945899895" datatype="html">
<source>Merged document will be queued for consumption.</source> <source>Merged document will be queued for consumption.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">996</context> <context context-type="linenumber">991</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="476913782630693351" datatype="html"> <trans-unit id="476913782630693351" datatype="html">
<source>Custom fields updated.</source> <source>Custom fields updated.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">1021</context> <context context-type="linenumber">1016</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3873496751167944011" datatype="html"> <trans-unit id="3873496751167944011" datatype="html">
<source>Error updating custom fields.</source> <source>Error updating custom fields.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">1030</context> <context context-type="linenumber">1025</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6144801143088984138" datatype="html"> <trans-unit id="6144801143088984138" datatype="html">
<source>Share link bundle creation requested.</source> <source>Share link bundle creation requested.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">1078</context> <context context-type="linenumber">1073</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="46019676931295023" datatype="html"> <trans-unit id="46019676931295023" datatype="html">
<source>Share link bundle creation is not available yet.</source> <source>Share link bundle creation is not available yet.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">1085</context> <context context-type="linenumber">1080</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6307402210351946694" datatype="html"> <trans-unit id="6307402210351946694" datatype="html">
@@ -9677,7 +9677,7 @@
<source>Filter Documents (<x id="INTERPOLATION" equiv-text="{{ field.document_count }}"/>)</source> <source>Filter Documents (<x id="INTERPOLATION" equiv-text="{{ field.document_count }}"/>)</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context> <context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
<context context-type="linenumber">39,40</context> <context context-type="linenumber">38,39</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/management-list/management-list.component.html</context> <context context-type="sourcefile">src/app/components/manage/document-attributes/management-list/management-list.component.html</context>
@@ -9700,7 +9700,7 @@
<source>No fields defined.</source> <source>No fields defined.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context> <context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
<context context-type="linenumber">70,72</context> <context context-type="linenumber">68,70</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3032792139967609806" datatype="html"> <trans-unit id="3032792139967609806" datatype="html">
+21 -23
View File
@@ -11,21 +11,20 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/cdk": "^22.1.1", "@angular/cdk": "^22.0.6",
"@angular/common": "~22.1.1", "@angular/common": "~22.1.0",
"@angular/compiler": "~22.1.1", "@angular/compiler": "~22.1.0",
"@angular/core": "~22.1.1", "@angular/core": "~22.1.0",
"@angular/forms": "~22.1.1", "@angular/forms": "~22.1.0",
"@angular/localize": "~22.1.1", "@angular/localize": "~22.1.0",
"@angular/platform-browser": "~22.1.1", "@angular/platform-browser": "~22.1.0",
"@angular/router": "~22.1.1", "@angular/router": "~22.1.0",
"@ng-bootstrap/ng-bootstrap": "^21.0.0", "@ng-bootstrap/ng-bootstrap": "^21.0.0",
"@ng-select/ng-select": "~23.6.0", "@ng-select/ng-select": "^23.5.0",
"@ngneat/dirty-check-forms": "^3.0.3", "@ngneat/dirty-check-forms": "^3.0.3",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"lodash-es": "^4.18.1",
"mime-names": "^1.0.0", "mime-names": "^1.0.0",
"ngx-bootstrap-icons": "^1.9.3", "ngx-bootstrap-icons": "^1.9.3",
"ngx-color": "^10.1.0", "ngx-color": "^10.1.0",
@@ -41,31 +40,30 @@
}, },
"devDependencies": { "devDependencies": {
"@angular-builders/jest": "^22.0.1", "@angular-builders/jest": "^22.0.1",
"@angular-devkit/core": "^22.1.3", "@angular-devkit/core": "^22.1.2",
"@angular-devkit/schematics": "^22.1.3", "@angular-devkit/schematics": "^22.1.2",
"@angular-eslint/builder": "22.1.0", "@angular-eslint/builder": "22.1.0",
"@angular-eslint/eslint-plugin": "22.1.0", "@angular-eslint/eslint-plugin": "22.1.0",
"@angular-eslint/eslint-plugin-template": "22.1.0", "@angular-eslint/eslint-plugin-template": "22.1.0",
"@angular-eslint/schematics": "22.1.0", "@angular-eslint/schematics": "22.1.0",
"@angular-eslint/template-parser": "22.1.0", "@angular-eslint/template-parser": "22.1.0",
"@angular/build": "22.1.3", "@angular/build": "22.1.2",
"@angular/cli": "22.1.3", "@angular/cli": "22.1.2",
"@angular/compiler-cli": "~22.1.1", "@angular/compiler-cli": "~22.1.0",
"@playwright/test": "^1.62.1", "@playwright/test": "^1.62.0",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^26.2.0", "@types/node": "^26.1.1",
"@typescript-eslint/eslint-plugin": "^8.67.0", "@typescript-eslint/eslint-plugin": "^8.65.0",
"@typescript-eslint/parser": "^8.67.0", "@typescript-eslint/parser": "^8.65.0",
"@typescript-eslint/utils": "^8.67.0", "@typescript-eslint/utils": "^8.65.0",
"eslint": "^10.8.1", "eslint": "^10.8.0",
"jest": "30.4.2", "jest": "30.4.2",
"jest-environment-jsdom": "^30.4.1", "jest-environment-jsdom": "^30.4.1",
"jest-junit": "^17.0.0", "jest-junit": "^17.0.0",
"jest-preset-angular": "^17.0.0", "jest-preset-angular": "^17.0.0",
"jest-websocket-mock": "^2.5.0", "jest-websocket-mock": "^2.5.0",
"prettier": "^3.9.6",
"prettier-plugin-organize-imports": "^4.3.0", "prettier-plugin-organize-imports": "^4.3.0",
"ts-node": "~10.9.2", "ts-node": "~10.9.1",
"typescript": "^6.0.3" "typescript": "^6.0.3"
}, },
"packageManager": "pnpm@11.15.1" "packageManager": "pnpm@11.15.1"
+5881 -1023
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -6,7 +6,6 @@ trustPolicyExclude:
- "chokidar@4.0.3" - "chokidar@4.0.3"
- "semver@6.3.1 || 5.7.2" - "semver@6.3.1 || 5.7.2"
blockExoticSubdeps: true blockExoticSubdeps: true
autoInstallPeers: false
allowBuilds: allowBuilds:
"@parcel/watcher": true "@parcel/watcher": true
canvas: true canvas: true
@@ -14,43 +14,48 @@
<a ngbNavLink>{{category}}</a> <a ngbNavLink>{{category}}</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<div class="p-3"> <div class="p-3">
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2"> @for (section of getCategorySections(category); track section) {
@for (option of getCategoryOptions(category); track option.key) { @if (section) {
<div class="col"> <h5 class="mt-4 mb-3">{{section}}</h5>
<div class="card bg-light"> }
<div class="card-body"> <div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
<div class="card-title d-flex align-items-center"> @for (option of getCategoryOptions(category, section); track option.key) {
<h6 class="mb-0"> <div class="col">
{{option.title}} <div class="card bg-light">
</h6> <div class="card-body">
<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"> <div class="card-title d-flex align-items-center">
<i-bs name="info-circle"></i-bs> <h6 class="mb-0">
</a> {{option.title}}
@if (isSet(option.key)) { </h6>
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)"> <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 class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container> <i-bs name="info-circle"></i-bs>
</button> </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>
<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>
</div> }
</div> </div>
</ng-template> </ng-template>
</li> </li>
@@ -8,7 +8,11 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select' import { NgSelectModule } from '@ng-select/ng-select'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of, throwError } from 'rxjs' 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 { ConfigService } from 'src/app/services/config.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
@@ -158,4 +162,24 @@ describe('ConfigComponent', () => {
component.resetOption('barcodes_enabled') component.resetOption('barcodes_enabled')
expect(component.configForm.get('barcodes_enabled').value).toBeNull() 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',
'remote_ocr_mode',
])
})
}) })
@@ -74,8 +74,20 @@ export class ConfigComponent
return Object.values(ConfigCategory) return Object.values(ConfigCategory)
} }
getCategoryOptions(category: string): ConfigOption[] { getCategorySections(category: string): string[] {
return PaperlessConfigOptions.filter((o) => o.category === category) 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 initialConfig: PaperlessConfig
@@ -2,8 +2,3 @@
.d-block.d-sm-none .dropdown-toggle::after { .d-block.d-sm-none .dropdown-toggle::after {
display: none; display: none;
} }
tbody tr:last-child td,
table:not(:has(tbody tr)) thead th {
border-bottom: none;
}
@@ -19,11 +19,6 @@
height: 0.8em; height: 0.8em;
} }
.sidebar-heading,
.text-uppercase {
letter-spacing: 0.06em;
}
.view-name { .view-name {
max-width: calc(100% - 50px) max-width: calc(100% - 50px)
} }
@@ -86,7 +81,7 @@ main {
.sidebar li.nav-item span, .sidebar li.nav-item span,
.sidebar .sidebar-heading span { .sidebar .sidebar-heading span {
transition: opacity .1s ease; transition: all .1s ease;
} }
@media(min-width: 768px) { @media(min-width: 768px) {
@@ -154,7 +149,7 @@ main {
z-index: 996; z-index: 996;
--bs-btn-padding-x: 0.35rem; --bs-btn-padding-x: 0.35rem;
--bs-btn-padding-y: 0.125rem; --bs-btn-padding-y: 0.125rem;
transition: left .2s ease; transition: all .2s ease;
} }
.sidebar.slim .sidebar-slim-toggler { .sidebar.slim .sidebar-slim-toggler {
@@ -186,8 +181,6 @@ main {
.sidebar .nav-link { .sidebar .nav-link {
font-weight: 500; font-weight: 500;
white-space: nowrap; white-space: nowrap;
border-left: 2px solid transparent;
transition: color .15s ease-in-out;
&:hover, &.active, &:focus { &:hover, &.active, &:focus {
color: var(--bs-primary); color: var(--bs-primary);
@@ -200,7 +193,6 @@ main {
&.active { &.active {
font-weight: bold; font-weight: bold;
border-left-color: var(--bs-primary);
} }
i-bs { i-bs {
@@ -209,17 +201,6 @@ main {
} }
} }
// sub-page gets marker only
.nav-item:has(.attributes-submenu.show .nav-link.active) > .attributes-row > .nav-link.active {
border-left-color: transparent;
}
// bring sub-menu markers back out to L edge
.attributes-submenu .nav-link {
margin-left: -0.5rem;
padding-left: calc(var(--bs-nav-link-padding-x) + 0.5rem);
}
.attributes-row .attributes-expand-btn { .attributes-row .attributes-expand-btn {
opacity: 0.2; opacity: 0.2;
transition: opacity 0.15s ease-in-out; transition: opacity 0.15s ease-in-out;
@@ -1,4 +1,4 @@
<div class="btn-group w-100" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown" (keydown)="listKeyDown($event)" [popperOptions]="popperOptions" [autoClose]="!creating()"> <div class="btn-group w-100" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown" (keydown)="listKeyDown($event)" [popperOptions]="popperOptions">
<button class="btn btn-sm" id="dropdown_{{name}}" ngbDropdownToggle [ngClass]="!editing && selectionModel.selectionSize() > 0 ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled"> <button class="btn btn-sm" id="dropdown_{{name}}" ngbDropdownToggle [ngClass]="!editing && selectionModel.selectionSize() > 0 ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
<i-bs name="{{icon}}"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div> <i-bs name="{{icon}}"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
@if (!editing && selectionModel.totalCount > 0) { @if (!editing && selectionModel.totalCount > 0) {
@@ -3,7 +3,6 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing' import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { NEVER, Subject } from 'rxjs'
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type' import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
import { import {
DEFAULT_MATCHING_ALGORITHM, DEFAULT_MATCHING_ALGORITHM,
@@ -49,7 +48,6 @@ const negativeNullItem = {
let selectionModel: FilterableDropdownSelectionModel let selectionModel: FilterableDropdownSelectionModel
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
const createModalRef = () => ({ closed: NEVER, dismissed: NEVER }) as any
describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => { describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => {
let component: FilterableDropdownComponent let component: FilterableDropdownComponent
@@ -870,7 +868,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0) expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0)
}) })
it('should keep the dropdown open while the create modal is active', async () => { it('should set support create, keep open model and call createRef method', async () => {
component.selectionModel.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
component.selectionModel = selectionModel component.selectionModel = selectionModel
@@ -884,44 +882,20 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
fixture.detectChanges() fixture.detectChanges()
component.filterText = 'Test Filter Text' component.filterText = 'Test Filter Text'
const modalClosed = new Subject<void>() component.createRef = jest.fn()
component.createRef = jest.fn(
() =>
({
closed: modalClosed,
dismissed: NEVER,
}) as any
)
component.createClicked() component.createClicked()
expect(component.creating()).toBeTruthy() expect(component.creating).toBeTruthy()
expect(component.createRef).toHaveBeenCalledWith('Test Filter Text') expect(component.createRef).toHaveBeenCalledWith('Test Filter Text')
fixture.detectChanges()
expect(component.dropdown.autoClose).toBeFalsy()
document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
document.body.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
await wait(10)
expect(component.dropdown.isOpen()).toBeTruthy()
// Also cover a close that was already scheduled before autoClose changed.
const openSpy = jest.spyOn(component.dropdown, 'open') const openSpy = jest.spyOn(component.dropdown, 'open')
component.dropdownOpenChange(false) component.dropdownOpenChange(false)
expect(openSpy).toHaveBeenCalled() // should keep open expect(openSpy).toHaveBeenCalled() // should keep open
component.dropdownOpenChange(false)
expect(openSpy).toHaveBeenCalledTimes(2) // modal interactions keep it open
modalClosed.next()
fixture.detectChanges()
expect(component.creating()).toBeFalsy()
expect(component.dropdown.autoClose).toBeTruthy()
expect(component.dropdown.isOpen()).toBeTruthy()
}) })
it('should call create on enter inside filter field if 0 items remain while editing', async () => { it('should call create on enter inside filter field if 0 items remain while editing', async () => {
component.selectionModel.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
component.editing = true component.editing = true
component.createRef = jest.fn(createModalRef) component.createRef = jest.fn()
const createSpy = jest.spyOn(component, 'createClicked') const createSpy = jest.spyOn(component, 'createClicked')
expect(component.selectionModel.getSelectedItems()).toEqual([]) expect(component.selectionModel.getSelectedItems()).toEqual([])
fixture.nativeElement fixture.nativeElement
@@ -941,7 +915,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
component.selectionModel.items = [] component.selectionModel.items = []
component.icon = 'tag-fill' component.icon = 'tag-fill'
component.editing = true component.editing = true
component.createRef = jest.fn(createModalRef) component.createRef = jest.fn()
fixture.detectChanges() fixture.detectChanges()
expect(fixture.nativeElement.textContent).not.toContain('Create') expect(fixture.nativeElement.textContent).not.toContain('Create')
@@ -15,13 +15,9 @@ import {
signal, signal,
} from '@angular/core' } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
NgbDropdown,
NgbDropdownModule,
NgbModalRef,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subject, filter, first, merge, takeUntil } from 'rxjs' import { Subject, filter, takeUntil } from 'rxjs'
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type' import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
import { MatchingModel } from 'src/app/data/matching-model' import { MatchingModel } from 'src/app/data/matching-model'
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions' import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
@@ -763,7 +759,7 @@ export class FilterableDropdownComponent
disabled = false disabled = false
@Input() @Input()
createRef: (name: string) => NgbModalRef createRef: (name) => void
@Input() @Input()
set documentCounts(counts: SelectionDataItem[]) { set documentCounts(counts: SelectionDataItem[]) {
@@ -781,7 +777,7 @@ export class FilterableDropdownComponent
@Input() @Input()
showExtraButtonIfEmpty: boolean = false showExtraButtonIfEmpty: boolean = false
readonly creating = signal(false) creating: boolean = false
@Output() @Output()
apply = new EventEmitter<ChangedItems>() apply = new EventEmitter<ChangedItems>()
@@ -858,18 +854,12 @@ export class FilterableDropdownComponent
} }
createClicked() { createClicked() {
this.creating.set(true) this.creating = true
const modal = this.createRef(this.filterText) this.createRef(this.filterText)
merge(modal.closed, modal.dismissed)
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe(() => this.creating.set(false))
} }
dropdownOpenChange(open: boolean): void { dropdownOpenChange(open: boolean): void {
if (open) { if (open) {
// Dont let a create modal close this
if (this.creating()) return
setTimeout(() => { setTimeout(() => {
this.listFilterTextInput?.nativeElement.focus() this.listFilterTextInput?.nativeElement.focus()
this.buttonsViewport?.checkViewportSize() this.buttonsViewport?.checkViewportSize()
@@ -882,8 +872,9 @@ export class FilterableDropdownComponent
this.editing && !this.selectionModel.manyToOne this.editing && !this.selectionModel.manyToOne
this.opened.next(this) this.opened.next(this)
} else { } else {
if (this.creating()) { if (this.creating) {
this.dropdown?.open() this.dropdown?.open()
this.creating = false
} else { } else {
this.filterText = '' this.filterText = ''
if (this.applyOnClose && this.selectionModel.isDirty()) { if (this.applyOnClose && this.selectionModel.isDirty()) {
@@ -762,7 +762,6 @@ export class BulkEditorComponent
this.tagSelectionModel.items = flattenTags(tags.results) this.tagSelectionModel.items = flattenTags(tags.results)
this.tagSelectionModel.toggle(newTag.id) this.tagSelectionModel.toggle(newTag.id)
}) })
return modal
} }
createCorrespondent(name: string) { createCorrespondent(name: string) {
@@ -786,7 +785,6 @@ export class BulkEditorComponent
this.correspondentSelectionModel.items = correspondents.results this.correspondentSelectionModel.items = correspondents.results
this.correspondentSelectionModel.toggle(newCorrespondent.id) this.correspondentSelectionModel.toggle(newCorrespondent.id)
}) })
return modal
} }
createDocumentType(name: string) { createDocumentType(name: string) {
@@ -808,7 +806,6 @@ export class BulkEditorComponent
this.documentTypeSelectionModel.items = documentTypes.results this.documentTypeSelectionModel.items = documentTypes.results
this.documentTypeSelectionModel.toggle(newDocumentType.id) this.documentTypeSelectionModel.toggle(newDocumentType.id)
}) })
return modal
} }
createStoragePath(name: string) { createStoragePath(name: string) {
@@ -830,7 +827,6 @@ export class BulkEditorComponent
this.storagePathsSelectionModel.items = storagePaths.results this.storagePathsSelectionModel.items = storagePaths.results
this.storagePathsSelectionModel.toggle(newStoragePath.id) this.storagePathsSelectionModel.toggle(newStoragePath.id)
}) })
return modal
} }
createCustomField(name: string) { createCustomField(name: string) {
@@ -852,7 +848,6 @@ export class BulkEditorComponent
this.customFieldsSelectionModel.items = customFields.results this.customFieldsSelectionModel.items = customFields.results
this.customFieldsSelectionModel.toggle(newCustomField.id) this.customFieldsSelectionModel.toggle(newCustomField.id)
}) })
return modal
} }
applyDelete() { applyDelete() {
@@ -21,47 +21,45 @@
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editField(field)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.CustomField)">{{field.name}}</button></div> <div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editField(field)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.CustomField)">{{field.name}}</button></div>
<div class="col d-flex align-items-center">{{getDataType(field)}}</div> <div class="col d-flex align-items-center">{{getDataType(field)}}</div>
<div class="col"> <div class="col">
<div class="btn-toolbar gap-2"> <div class="btn-group d-block d-sm-none">
<div class="btn-group d-block d-sm-none"> <div ngbDropdown container="body" class="d-inline-block">
<div ngbDropdown container="body" class="d-inline-block"> <button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle> <i-bs name="three-dots-vertical"></i-bs>
<i-bs name="three-dots-vertical"></i-bs> </button>
</button> <div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile"> <button (click)="editField(field)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" ngbDropdownItem i18n>Edit</button>
<button (click)="editField(field)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" ngbDropdownItem i18n>Edit</button> <button class="text-danger" (click)="deleteField(field)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.CustomField }" ngbDropdownItem i18n>Delete</button>
<button class="text-danger" (click)="deleteField(field)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.CustomField }" ngbDropdownItem i18n>Delete</button> @if (field.document_count > 0) {
@if (field.document_count > 0) { <a
<a *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }" ngbDropdownItem
ngbDropdownItem [routerLink]="getDocumentFilterUrl(field)"
[routerLink]="getDocumentFilterUrl(field)" i18n
i18n >Filter Documents ({{ field.document_count }})</a
>Filter Documents ({{ field.document_count }})</a >
> }
}
</div>
</div> </div>
</div> </div>
<div class="btn-group d-none d-sm-inline-block">
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-secondary" type="button" (click)="editField(field)">
<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.CustomField }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteField(field)">
<i-bs width="1em" height="1em" name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container>
</button>
</div>
@if (field.document_count > 0) {
<div class="btn-group d-none d-sm-inline-block">
<a
class="btn btn-sm btn-outline-secondary"
[routerLink]="getDocumentFilterUrl(field)"
>
<i-bs width="1em" height="1em" name="filter" class="me-1"></i-bs><ng-container i18n>Documents</ng-container
><span class="badge bg-light text-secondary ms-2">{{ field.document_count }}</span>
</a>
</div>
}
</div> </div>
<div class="btn-group d-none d-sm-inline-block">
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-secondary" type="button" (click)="editField(field)">
<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.CustomField }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteField(field)">
<i-bs width="1em" height="1em" name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container>
</button>
</div>
@if (field.document_count > 0) {
<div class="btn-group d-none d-sm-inline-block ms-2">
<a
class="btn btn-sm btn-outline-secondary"
[routerLink]="getDocumentFilterUrl(field)"
>
<i-bs width="1em" height="1em" name="filter" class="me-1"></i-bs><ng-container i18n>Documents</ng-container
><span class="badge bg-light text-secondary ms-2">{{ field.document_count }}</span>
</a>
</div>
}
</div> </div>
</div> </div>
</li> </li>
@@ -69,7 +69,7 @@
} }
</ul> </ul>
<div class="my-3"> <div class="my-3 shadow-sm">
<ng-container <ng-container
[ngComponentOutlet]="activeSection?.component" [ngComponentOutlet]="activeSection?.component"
#activeOutlet="ngComponentOutlet" #activeOutlet="ngComponentOutlet"
+55
View File
@@ -54,6 +54,10 @@ export const ConfigCategory = {
AI: $localize`AI Settings`, AI: $localize`AI Settings`,
} }
export const ConfigSection = {
RemoteOCR: $localize`Remote OCR`,
}
export const LLMEmbeddingBackendConfig = { export const LLMEmbeddingBackendConfig = {
OPENAI_LIKE: 'openai-like', OPENAI_LIKE: 'openai-like',
HUGGINGFACE: 'huggingface', HUGGINGFACE: 'huggingface',
@@ -65,6 +69,15 @@ export const LLMBackendConfig = {
OLLAMA: 'ollama', OLLAMA: 'ollama',
} }
export const RemoteOCREngineConfig = {
AZURE_AI: 'azureai',
}
export const RemoteOCRModeConfig = {
ALWAYS: 'always',
WORKFLOW_ONLY: 'workflow_only',
}
export interface ConfigOption { export interface ConfigOption {
key: string key: string
title: string title: string
@@ -72,6 +85,7 @@ export interface ConfigOption {
choices?: Array<{ id: string; name: string }> choices?: Array<{ id: string; name: string }>
config_key?: string config_key?: string
category: string category: string
section?: string
note?: string note?: string
} }
@@ -181,6 +195,43 @@ export const PaperlessConfigOptions: ConfigOption[] = [
config_key: 'PAPERLESS_OCR_USER_ARGS', config_key: 'PAPERLESS_OCR_USER_ARGS',
category: ConfigCategory.OCR, 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: 'remote_ocr_mode',
title: $localize`Remote OCR Mode`,
type: ConfigOptionType.Select,
choices: mapToItems(RemoteOCRModeConfig),
config_key: 'PAPERLESS_REMOTE_OCR_MODE',
category: ConfigCategory.OCR,
section: ConfigSection.RemoteOCR,
note: $localize`Which documents are sent to the remote engine. Use 'workflow_only' to keep remote OCR off unless a workflow enables it for a document.`,
},
{ {
key: 'app_logo', key: 'app_logo',
title: $localize`Application Logo`, title: $localize`Application Logo`,
@@ -398,6 +449,10 @@ export interface PaperlessConfig extends ObjectWithId {
barcode_enable_tag: boolean barcode_enable_tag: boolean
barcode_tag_mapping: object barcode_tag_mapping: object
barcode_tag_split: boolean barcode_tag_split: boolean
remote_ocr_engine: string
remote_ocr_api_key: string
remote_ocr_endpoint: string
remote_ocr_mode: string
ai_enabled: boolean ai_enabled: boolean
llm_embedding_backend: string llm_embedding_backend: string
llm_embedding_model: string llm_embedding_model: string
+29 -68
View File
@@ -127,27 +127,6 @@ table .btn-link {
background-color: var(--bs-body-bg); background-color: var(--bs-body-bg);
} }
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3, h4, h5, h6,
.h1, .h2, .h3, .h4, .h5, .h6 {
font-weight: 600;
}
pngx-page-header h3 {
font-size: 1.5rem;
}
table,
.badge,
.card-info,
.pagination {
font-variant-numeric: tabular-nums;
}
.bg-primary { .bg-primary {
background-color: var(--bs-primary) !important; background-color: var(--bs-primary) !important;
color: var(--pngx-primary-text-contrast); color: var(--pngx-primary-text-contrast);
@@ -234,8 +213,7 @@ table,
} }
.form-switch .form-check-input:focus { .form-switch .form-check-input:focus {
// neutral knob in place of bootstrap's blue, which clashes with the theme colour background-image: escape-svg(url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'><circle r='3' fill='#bbb'/></svg>"));
--bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23bbb'/%3e%3c/svg%3e");
} }
.nav-item a:focus-visible { .nav-item a:focus-visible {
@@ -291,44 +269,33 @@ a.btn-link:focus-visible,
box-shadow: 0 0 0 3px rgba(255, 255, 255, .25); box-shadow: 0 0 0 3px rgba(255, 255, 255, .25);
} }
// Sortable table headers .asc {
th[pngxSortable] { background-color: #f8f9fa!important;
cursor: pointer;
user-select: none;
white-space: nowrap;
&::after {
content: '';
display: inline-block;
vertical-align: -0.15em;
width: 0.8rem;
height: 0.8rem;
margin-left: 0.25rem;
// chevron, matched to the bootstrap-icons set used elsewhere in the app
mask: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e") no-repeat center / 0.8rem;
background-color: currentColor;
opacity: 0;
transition: opacity 0.15s ease-in-out;
}
// hint that an unsorted column can be sorted
&:hover::after {
opacity: 0.35;
}
} }
.asc, .asc:after {
.des { content: '';
color: var(--bs-primary);
--bs-table-color-state: var(--bs-primary); // bootstrap sets cell color at higher specificity
&::after {
opacity: 1 !important;
}
}
.asc::after {
transform: rotate(180deg); transform: rotate(180deg);
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAmxJREFUeAHtmksrRVEUx72fH8CIGQNJkpGUUmakDEiZSJRIZsRQmCkTJRmZmJgQE0kpX0D5DJKJgff7v+ru2u3O3vvc67TOvsdatdrnnP1Y///v7HvvubdbUiIhBISAEBACQkAICAEhIAQ4CXSh2DnyDfmCPEG2Iv9F9MPlM/LHyAecdyMzHYNwR3fdNK/OH9HXl1UCozD24TCvILxizEDWIEzA0FcM8woCgRrJCoS5PIwrANQSMAJX1LEI9bqpQo4JYNFFKRSvIgsxHDVnqZgIkPnNBM0rIGtYk9YOOsqgbgepRCfdbmFtqhFkVEDVPjJp0+Z6e6hRHhqBKgg6ZDCvYBygVmUoEGoh5JTRvIJwhJo1aUOoh4CLPMyvxxi7EWOMgnCGsXXI1GIXlZUYX7ucU+kbR8NW8lh3O7cue0Pk32MKndfUxQFAwxdirk3fHappAnc0oqDPzDfGTBrCfHP04dM4oTV8cxr0SVzH9FF07xD3ib6xCDE+M+aUcVygtWzzbtGX2rPBrEUYfecfQkaFzYi6HjVnGBdtL7epqAlc1+jRdAap74RrnPc4BCijttY2tRcdN0g17w7HqZrXhdJTYAuS3hd8z+vKgK3V1zWPae0mZDMykadBn1hTQBLnZNwVrJpSe/NwEeDsEwCctEOsJTsgxLvCqUl2ACftEGvJDgjxrnBqkh3ASTvEWrIDQrwrnJpkB3DSDrGW7IAQ7wqnJtkBnLRztejXXVu4+mxz/nQ9jR1w5VB86ejLTFcnnDwhzV+F6T+CHZlx6THSjn76eyyBIOPHyDakhBAQAkJACAgBISAEhIAQYCLwC8JxpAmsEGt6AAAAAElFTkSuQmCC") no-repeat;
height: 1rem;
width: 1rem;
display: block;
background-size: 1rem;
float: right;
}
.des {
background-color: #f8f9fa!important;
}
.des:after {
content: '';
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAmxJREFUeAHtmksrRVEUx72fH8CIGQNJkpGUUmakDEiZSJRIZsRQmCkTJRmZmJgQE0kpX0D5DJKJgff7v+ru2u3O3vvc67TOvsdatdrnnP1Y///v7HvvubdbUiIhBISAEBACQkAICAEhIAQ4CXSh2DnyDfmCPEG2Iv9F9MPlM/LHyAecdyMzHYNwR3fdNK/OH9HXl1UCozD24TCvILxizEDWIEzA0FcM8woCgRrJCoS5PIwrANQSMAJX1LEI9bqpQo4JYNFFKRSvIgsxHDVnqZgIkPnNBM0rIGtYk9YOOsqgbgepRCfdbmFtqhFkVEDVPjJp0+Z6e6hRHhqBKgg6ZDCvYBygVmUoEGoh5JTRvIJwhJo1aUOoh4CLPMyvxxi7EWOMgnCGsXXI1GIXlZUYX7ucU+kbR8NW8lh3O7cue0Pk32MKndfUxQFAwxdirk3fHappAnc0oqDPzDfGTBrCfHP04dM4oTV8cxr0SVzH9FF07xD3ib6xCDE+M+aUcVygtWzzbtGX2rPBrEUYfecfQkaFzYi6HjVnGBdtL7epqAlc1+jRdAap74RrnPc4BCijttY2tRcdN0g17w7HqZrXhdJTYAuS3hd8z+vKgK3V1zWPae0mZDMykadBn1hTQBLnZNwVrJpSe/NwEeDsEwCctEOsJTsgxLvCqUl2ACftEGvJDgjxrnBqkh3ASTvEWrIDQrwrnJpkB3DSDrGW7IAQ7wqnJtkBnLRztejXXVu4+mxz/nQ9jR1w5VB86ejLTFcnnDwhzV+F6T+CHZlx6THSjn76eyyBIOPHyDakhBAQAkJACAgBISAEhIAQYCLwC8JxpAmsEGt6AAAAAElFTkSuQmCC") no-repeat;
height: 1rem;
width: 1rem;
display: block;
background-size: 1rem;
float: right;
} }
.paperless-input-select { .paperless-input-select {
@@ -597,6 +564,10 @@ ul.pagination {
table.table { table.table {
--bs-table-color: var(--bs-body-color); --bs-table-color: var(--bs-body-color);
--bs-table-bg: var(--bs-light-rgb); --bs-table-bg: var(--bs-light-rgb);
.des,.asc {
background-color: var(--bs-body-bg) !important;
}
} }
.close { .close {
@@ -805,16 +776,6 @@ canvas.hiddenCanvasElement {
.document-card { .document-card {
overflow: hidden; overflow: hidden;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
&:hover:not(.card-selected) {
border-color: var(--pngx-card-hover-border);
box-shadow: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.1);
}
.document-card-check {
border-color: var(--pngx-card-hover-border) !important;
}
.card-footer i-bs svg { .card-footer i-bs svg {
vertical-align: middle; vertical-align: middle;
+12 -9
View File
@@ -23,7 +23,6 @@
--pngx-bg-darker: var(--bs-gray-100); --pngx-bg-darker: var(--bs-gray-100);
--pngx-bg-alt2: var(--bs-gray-200); // #e9ecef --pngx-bg-alt2: var(--bs-gray-200); // #e9ecef
--pngx-bg-disabled: #f7f7f7; --pngx-bg-disabled: #f7f7f7;
--pngx-card-hover-border: var(--bs-tertiary-color);
--pngx-focus-alpha: 0.3; --pngx-focus-alpha: 0.3;
--pngx-toast-max-width: 340px; --pngx-toast-max-width: 340px;
--bs-info: var(--pngx-bg-alt2); --bs-info: var(--pngx-bg-alt2);
@@ -37,22 +36,20 @@
$text-color-light-bg: #212529; $text-color-light-bg: #212529;
$text-color-dark-bg: #abb2bf; $text-color-dark-bg: #abb2bf;
$text-color-dark-bg-accent: color.adjust($text-color-dark-bg, $lightness: 10%); $text-color-dark-bg-accent: color.adjust($text-color-dark-bg, $lightness: 10%);
// url-encoded $text-color-light-bg // Taken from bootstrap
$text-color-light-bg-esc: "%23212529"; $form-check-input-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'><path fill='none' stroke='#{$text-color-light-bg}' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10l3 3l6-6'/></svg>");
// Taken from bootstrap, pre-encoded $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'><circle r='2' fill='#{$text-color-light-bg}'/></svg>");
$form-check-input-checked-bg-image-dark: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='#{$text-color-light-bg-esc}' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10l3 3l6-6'/%3e%3c/svg%3e");
$form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='#{$text-color-light-bg-esc}'/%3e%3c/svg%3e");
.primary-light { .primary-light {
--pngx-primary-text-contrast: #{$text-color-light-bg} !important; --pngx-primary-text-contrast: #{$text-color-light-bg} !important;
.form-check:not(.form-switch) { .form-check:not(.form-switch) {
.form-check-input:checked[type=checkbox] { .form-check-input:checked[type=checkbox] {
--bs-form-check-bg-image: #{$form-check-input-checked-bg-image-dark}; background-image: escape-svg($form-check-input-checked-bg-image-dark);
} }
.form-check-input:checked[type=radio] { .form-check-input:checked[type=radio] {
--bs-form-check-bg-image: #{$form-check-radio-checked-bg-image-dark}; background-image: escape-svg($form-check-radio-checked-bg-image-dark);
} }
} }
@@ -93,7 +90,6 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,%3csvg xmlns='h
--pngx-bg-alt2: #232323; --pngx-bg-alt2: #232323;
--pngx-bg-darker: #101216; --pngx-bg-darker: #101216;
--pngx-bg-disabled: var(--pngx-bg-alt); --pngx-bg-disabled: var(--pngx-bg-alt);
--pngx-card-hover-border: var(--bs-border-color);
--pngx-focus-alpha: 0.6; --pngx-focus-alpha: 0.6;
--pngx-primary-faded: var(--pngx-primary-darken-15); --pngx-primary-faded: var(--pngx-primary-darken-15);
--pngx-primary-text-contrast: var(--bs-body-color); --pngx-primary-text-contrast: var(--bs-body-color);
@@ -253,6 +249,13 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,%3csvg xmlns='h
} }
table { table {
.des,
.asc {
&::after {
filter: invert(0.8); /* arrow is a black inline png bkgd image (!) so use filter */
}
}
&.table-hover > tbody > tr:hover > * { &.table-hover > tbody > tr:hover > * {
background-color: var(--bs-light); background-color: var(--bs-light);
color: var(--pngx-body-color-accent); color: var(--pngx-body-color-accent);
+8
View File
@@ -53,6 +53,7 @@ from documents.utils import copy_basic_file_stats
from documents.utils import copy_file_with_basic_stats from documents.utils import copy_file_with_basic_stats
from documents.utils import run_subprocess from documents.utils import run_subprocess
from paperless.config import OcrConfig from paperless.config import OcrConfig
from paperless.config import RemoteOCRConfig
from paperless.models import ArchiveFileGenerationChoices from paperless.models import ArchiveFileGenerationChoices
from paperless.parsers import ParserContext from paperless.parsers import ParserContext
from paperless.parsers import ParserProtocol from paperless.parsers import ParserProtocol
@@ -451,12 +452,19 @@ class ConsumerPlugin(
except Exception as e: except Exception as e:
self.log.error(f"Error attempting to clean PDF: {e}") self.log.error(f"Error attempting to clean PDF: {e}")
# Workflows have already run at this point, so the metadata knows
# whether this document was singled out for remote OCR
allow_remote = (
self.metadata.remote_ocr or RemoteOCRConfig().remote_ocr_by_default
)
# Based on the mime type, get the parser for that type # Based on the mime type, get the parser for that type
parser_class: type[ParserProtocol] | None = ( parser_class: type[ParserProtocol] | None = (
get_parser_registry().get_parser_for_file( get_parser_registry().get_parser_for_file(
mime_type, mime_type,
self.filename, self.filename,
self.working_copy, self.working_copy,
allow_remote=allow_remote,
) )
) )
if not parser_class: if not parser_class:
+3
View File
@@ -34,6 +34,7 @@ class DocumentMetadataOverrides:
skip_asn_if_exists: bool = False skip_asn_if_exists: bool = False
version_label: str | None = None version_label: str | None = None
actor_id: int | None = None actor_id: int | None = None
remote_ocr: bool = False
def update(self, other: "DocumentMetadataOverrides") -> "DocumentMetadataOverrides": def update(self, other: "DocumentMetadataOverrides") -> "DocumentMetadataOverrides":
""" """
@@ -57,6 +58,8 @@ class DocumentMetadataOverrides:
self.actor_id = other.actor_id self.actor_id = other.actor_id
if other.skip_asn_if_exists: if other.skip_asn_if_exists:
self.skip_asn_if_exists = True self.skip_asn_if_exists = True
if other.remote_ocr:
self.remote_ocr = True
if other.version_label is not None: if other.version_label is not None:
self.version_label = other.version_label self.version_label = other.version_label
+10 -12
View File
@@ -62,17 +62,18 @@ def generate_unique_filename(doc, *, archive_filename=False) -> Path:
old_filename = Path(doc.filename) if doc.filename else None old_filename = Path(doc.filename) if doc.filename else None
root = settings.ORIGINALS_DIR root = settings.ORIGINALS_DIR
base_filename = generate_filename(doc, archive_filename=archive_filename)
# If generating archive filenames, try to make a name that is similar to # If generating archive filenames, try to make a name that is similar to
# the original filename first. # the original filename first.
if archive_filename and doc.filename: if archive_filename and doc.filename:
# Generate the full path using the same logic as generate_filename
base_generated = generate_filename(doc, archive_filename=archive_filename)
# Try to create a simple PDF version based on the original filename # Try to create a simple PDF version based on the original filename
# but preserve any directory structure from the template # but preserve any directory structure from the template
if str(base_filename.parent) != ".": if str(base_generated.parent) != ".":
# Has directory structure, preserve it # Has directory structure, preserve it
simple_pdf_name = base_filename.parent / (Path(doc.filename).stem + ".pdf") simple_pdf_name = base_generated.parent / (Path(doc.filename).stem + ".pdf")
else: else:
# No directory structure # No directory structure
simple_pdf_name = Path(Path(doc.filename).stem + ".pdf") simple_pdf_name = Path(Path(doc.filename).stem + ".pdf")
@@ -80,17 +81,14 @@ def generate_unique_filename(doc, *, archive_filename=False) -> Path:
if simple_pdf_name == old_filename or not (root / simple_pdf_name).exists(): if simple_pdf_name == old_filename or not (root / simple_pdf_name).exists():
return simple_pdf_name return simple_pdf_name
file_extension = ".pdf" if archive_filename else doc.file_type
filename_stem = base_filename.name.removesuffix(file_extension)
counter = 0 counter = 0
while True: while True:
new_filename = base_filename new_filename = generate_filename(
if counter: doc,
new_filename = base_filename.with_name( counter=counter,
f"{filename_stem}_{counter:02}{file_extension}", archive_filename=archive_filename,
) )
if new_filename == old_filename: if new_filename == old_filename:
# still the same as before. # still the same as before.
return new_filename return new_filename
+10 -1
View File
@@ -66,6 +66,7 @@ from documents.utils import compute_checksum
from documents.utils import identity from documents.utils import identity
from documents.workflows.utils import get_workflows_for_trigger from documents.workflows.utils import get_workflows_for_trigger
from paperless.config import AIConfig from paperless.config import AIConfig
from paperless.config import RemoteOCRConfig
from paperless.logging import consume_task_id from paperless.logging import consume_task_id
from paperless.parsers import ParserContext from paperless.parsers import ParserContext
from paperless.parsers.registry import get_parser_registry from paperless.parsers.registry import get_parser_registry
@@ -337,10 +338,17 @@ def bulk_update_documents(document_ids) -> None:
@shared_task @shared_task
def update_document_content_maybe_archive_file(document_id) -> None: def update_document_content_maybe_archive_file(
document_id,
*,
remote_ocr: bool = False,
) -> None:
""" """
Re-creates OCR content and thumbnail for a document, and archive file if Re-creates OCR content and thumbnail for a document, and archive file if
it exists. it exists.
Remote OCR is used only when the engine is configured to handle everything
or if explicitly asked for via ``remote_ocr``.
""" """
document = Document.objects.get(id=document_id) document = Document.objects.get(id=document_id)
@@ -350,6 +358,7 @@ def update_document_content_maybe_archive_file(document_id) -> None:
mime_type, mime_type,
document.original_filename or "", document.original_filename or "",
document.source_path, document.source_path,
allow_remote=remote_ocr or RemoteOCRConfig().remote_ocr_by_default,
) )
if not parser_class: if not parser_class:
@@ -72,6 +72,10 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
"barcode_enable_tag": None, "barcode_enable_tag": None,
"barcode_tag_mapping": None, "barcode_tag_mapping": None,
"barcode_tag_split": None, "barcode_tag_split": None,
"remote_ocr_engine": None,
"remote_ocr_api_key": None,
"remote_ocr_endpoint": None,
"remote_ocr_mode": None,
"ai_enabled": False, "ai_enabled": False,
"llm_embedding_backend": None, "llm_embedding_backend": None,
"llm_embedding_model": None, "llm_embedding_model": None,
@@ -870,6 +874,49 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
config.refresh_from_db() config.refresh_from_db()
self.assertEqual(config.llm_api_key, None) 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: def test_enable_ai_index_triggers_update(self) -> None:
""" """
GIVEN: GIVEN:
+80
View File
@@ -1559,6 +1559,72 @@ class PostConsumeTestCase(DirectoriesMixin, GetConsumerMixin, TestCase):
consumer.run_post_consume_script(doc) consumer.run_post_consume_script(doc)
class TestConsumerRemoteOCR(
DirectoriesMixin,
FileSystemAssertsMixin,
GetConsumerMixin,
TestCase,
):
"""
The consumer resolves the remote OCR mode and the per-document request from
workflows into the allow_remote flag it hands to the parser registry.
"""
def setUp(self) -> None:
super().setUp()
patcher = mock.patch("documents.consumer.get_parser_registry")
self.mock_registry = patcher.start()
self.mock_registry.return_value.get_parser_for_file.return_value = DummyParser
self.addCleanup(patcher.stop)
def _consume(self, *, overrides: DocumentMetadataOverrides | None = None) -> bool:
src = (
Path(__file__).parent
/ "samples"
/ "documents"
/ "originals"
/ "0000001.pdf"
)
dst = self.dirs.scratch_dir / "sample.pdf"
shutil.copy(src, dst)
with self.get_consumer(dst, overrides=overrides) as consumer:
consumer.run()
_, kwargs = self.mock_registry.return_value.get_parser_for_file.call_args
return kwargs["allow_remote"]
@override_settings(REMOTE_OCR_MODE="always")
def test_always_mode_allows_remote(self) -> None:
"""
GIVEN: Remote OCR mode is 'always'.
WHEN: A document is consumed without any workflow asking for it.
THEN: The registry is allowed to pick the remote parser.
"""
self.assertTrue(self._consume())
@override_settings(REMOTE_OCR_MODE="workflow_only")
def test_workflow_only_mode_denies_remote_by_default(self) -> None:
"""
GIVEN: Remote OCR mode is 'workflow_only'.
WHEN: A document is consumed and nothing asked for remote OCR.
THEN: The remote parser is excluded.
"""
self.assertFalse(self._consume())
@override_settings(REMOTE_OCR_MODE="workflow_only")
def test_workflow_only_mode_allows_remote_when_requested(self) -> None:
"""
GIVEN: Remote OCR mode is 'workflow_only'.
WHEN: A workflow set remote_ocr on the metadata overrides.
THEN: The registry is allowed to pick the remote parser.
"""
self.assertTrue(
self._consume(overrides=DocumentMetadataOverrides(remote_ocr=True)),
)
class TestMetadataOverrides(TestCase): class TestMetadataOverrides(TestCase):
def test_update_skip_asn_if_exists(self) -> None: def test_update_skip_asn_if_exists(self) -> None:
base = DocumentMetadataOverrides() base = DocumentMetadataOverrides()
@@ -1566,6 +1632,20 @@ class TestMetadataOverrides(TestCase):
base.update(incoming) base.update(incoming)
self.assertTrue(base.skip_asn_if_exists) self.assertTrue(base.skip_asn_if_exists)
def test_update_remote_ocr(self) -> None:
base = DocumentMetadataOverrides()
base.update(DocumentMetadataOverrides(remote_ocr=True))
self.assertTrue(base.remote_ocr)
def test_update_remote_ocr_is_not_unset(self) -> None:
"""
A later workflow that says nothing must not undo an earlier one that
asked for remote OCR.
"""
base = DocumentMetadataOverrides(remote_ocr=True)
base.update(DocumentMetadataOverrides())
self.assertTrue(base.remote_ocr)
def test_update_actor_and_version_label(self) -> None: def test_update_actor_and_version_label(self) -> None:
base = DocumentMetadataOverrides( base = DocumentMetadataOverrides(
actor_id=1, actor_id=1,
-32
View File
@@ -10,10 +10,8 @@ from auditlog.context import disable_auditlog
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import DatabaseError from django.db import DatabaseError
from django.db import connection
from django.test import TestCase from django.test import TestCase
from django.test import override_settings from django.test import override_settings
from django.test.utils import CaptureQueriesContext
from django.utils import timezone from django.utils import timezone
from documents.file_handling import create_source_path_directory from documents.file_handling import create_source_path_directory
@@ -34,36 +32,6 @@ from documents.tests.utils import FileSystemAssertsMixin
class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase): class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
@override_settings(FILENAME_FORMAT="{title}")
def test_generate_unique_filename_renders_template_once(self) -> None:
document = Document.objects.create(
title="collision",
mime_type="application/pdf",
)
Document.objects.filter(pk=document.pk).update(filename="collision_03.pdf")
document.refresh_from_db()
for filename in (
"collision.pdf",
"collision_01.pdf",
"collision_02.pdf",
"collision_03.pdf",
):
(settings.ORIGINALS_DIR / filename).touch()
with CaptureQueriesContext(connection) as queries:
generated = generate_unique_filename(document)
relation_queries = [
query["sql"]
for query in queries.captured_queries
if "documents_tag" in query["sql"]
or "documents_customfieldinstance" in query["sql"]
]
self.assertEqual(generated, Path("collision_03.pdf"))
self.assertEqual(len(relation_queries), 2)
@override_settings(FILENAME_FORMAT="") @override_settings(FILENAME_FORMAT="")
def test_generate_source_filename(self) -> None: def test_generate_source_filename(self) -> None:
document = Document() document = Document()
+39
View File
@@ -287,6 +287,45 @@ class TestUpdateContent(DirectoriesMixin, TestCase):
self.assertNotEqual(Document.objects.get(pk=doc.pk).content, "test") self.assertNotEqual(Document.objects.get(pk=doc.pk).content, "test")
class TestUpdateContentRemoteOCR(DirectoriesMixin, TestCase):
"""
Consumption workflows do not run on reprocess, so the remote parser is
used only in 'always' mode or when the caller explicitly asks for it.
"""
def setUp(self) -> None:
super().setUp()
patcher = mock.patch("documents.tasks.get_parser_registry")
self.mock_registry = patcher.start()
self.mock_registry.return_value.get_parser_for_file.return_value = None
self.addCleanup(patcher.stop)
self.doc = Document.objects.create(
title="test",
content="my document",
checksum="wow",
mime_type="application/pdf",
)
def _allow_remote(self, **kwargs) -> bool:
tasks.update_document_content_maybe_archive_file(self.doc.pk, **kwargs)
_, call_kwargs = self.mock_registry.return_value.get_parser_for_file.call_args
return call_kwargs["allow_remote"]
@override_settings(REMOTE_OCR_MODE="always")
def test_always_mode_allows_remote(self) -> None:
self.assertTrue(self._allow_remote())
@override_settings(REMOTE_OCR_MODE="workflow_only")
def test_workflow_only_mode_denies_remote_by_default(self) -> None:
self.assertFalse(self._allow_remote())
@override_settings(REMOTE_OCR_MODE="workflow_only")
def test_workflow_only_mode_allows_remote_when_requested(self) -> None:
self.assertTrue(self._allow_remote(remote_ocr=True))
class TestAIIndex(DirectoriesMixin, TestCase): class TestAIIndex(DirectoriesMixin, TestCase):
@override_settings( @override_settings(
AI_ENABLED=True, AI_ENABLED=True,
+8 -5
View File
@@ -338,13 +338,16 @@ def check_deprecated_v2_ocr_env_vars(
@register() @register()
def check_remote_parser_configured(app_configs: Any, **kwargs: Any) -> list[Error]: def check_remote_ocr_mode(app_configs: Any, **kwargs: Any) -> list[Error]:
if settings.REMOTE_OCR_ENGINE == "azureai" and not ( # Import here because checks.py runs before the app registry is ready
settings.REMOTE_OCR_ENDPOINT and settings.REMOTE_OCR_API_KEY from paperless.models import RemoteOCRMode
):
valid_modes = {mode.value for mode in RemoteOCRMode}
if settings.REMOTE_OCR_MODE not in valid_modes:
return [ return [
Error( Error(
"Azure AI remote parser requires endpoint and API key to be configured.", f"PAPERLESS_REMOTE_OCR_MODE is set to {settings.REMOTE_OCR_MODE!r}, "
f"expected one of {sorted(valid_modes)}.",
), ),
] ]
+40
View File
@@ -9,6 +9,7 @@ from paperless.models import CleanChoices
from paperless.models import ColorConvertChoices from paperless.models import ColorConvertChoices
from paperless.models import ModeChoices from paperless.models import ModeChoices
from paperless.models import OutputTypeChoices from paperless.models import OutputTypeChoices
from paperless.models import RemoteOCRMode
@dataclasses.dataclass @dataclasses.dataclass
@@ -185,6 +186,45 @@ class GeneralConfig(BaseConfig):
self.app_logo = app_config.app_logo.url if app_config.app_logo else None 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)
remote_ocr_mode: RemoteOCRMode = 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
)
self.remote_ocr_mode = app_config.remote_ocr_mode or RemoteOCRMode(
settings.REMOTE_OCR_MODE,
)
@property
def remote_ocr_by_default(self) -> bool:
"""
Whether every supported document goes to the remote engine.
When False the remote engine is used only for documents that
explicitly asked for it, i.e. a workflow matched during consumption or
the user ticked the box when reprocessing.
"""
return self.remote_ocr_mode == RemoteOCRMode.ALWAYS
@dataclasses.dataclass @dataclasses.dataclass
class AIConfig(BaseConfig): 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",
),
),
]
@@ -0,0 +1,27 @@
# Generated by Django 5.2.16 on 2026-08-10 15:43
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("paperless", "0014_applicationconfiguration_remote_ocr_api_key_and_more"),
]
operations = [
migrations.AddField(
model_name="applicationconfiguration",
name="remote_ocr_mode",
field=models.CharField(
blank=True,
choices=[
("always", "All supported documents"),
("workflow_only", "Only when a workflow enables it"),
],
max_length=32,
null=True,
verbose_name="Sets which documents are sent to the remote OCR engine",
),
),
]
+55
View File
@@ -74,6 +74,23 @@ class ColorConvertChoices(models.TextChoices):
CMYK = ("CMYK", _("CMYK")) CMYK = ("CMYK", _("CMYK"))
class RemoteOCREngine(models.TextChoices):
"""
Matches to PAPERLESS_REMOTE_OCR_ENGINE
"""
AZURE_AI = ("azureai", _("Azure AI Document Intelligence"))
class RemoteOCRMode(models.TextChoices):
"""
Matches to PAPERLESS_REMOTE_OCR_MODE
"""
ALWAYS = ("always", _("All supported documents"))
WORKFLOW_ONLY = ("workflow_only", _("Only when a workflow enables it"))
class LLMEmbeddingBackend(models.TextChoices): class LLMEmbeddingBackend(models.TextChoices):
OPENAI_LIKE = ("openai-like", _("OpenAI-compatible")) OPENAI_LIKE = ("openai-like", _("OpenAI-compatible"))
HUGGINGFACE = ("huggingface", _("Huggingface")) HUGGINGFACE = ("huggingface", _("Huggingface"))
@@ -286,6 +303,44 @@ class ApplicationConfiguration(AbstractSingletonModel):
null=True, 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,
)
# PAPERLESS_REMOTE_OCR_MODE
remote_ocr_mode = models.CharField(
verbose_name=_("Sets which documents are sent to the remote OCR engine"),
blank=True,
null=True,
max_length=32,
choices=RemoteOCRMode.choices,
)
""" """
AI related settings AI related settings
""" """
+9
View File
@@ -134,6 +134,11 @@ class ParserProtocol(Protocol):
Author or organisation name. Author or organisation name.
url : str url : str
URL for documentation, source code, or issue tracker. URL for documentation, source code, or issue tracker.
Parsers that send document content to a remote service should additionally
set ``uses_remote_service = True`` so the registry can exclude them when
remote processing has not been requested for a document. The attribute is
optional so a parser that omits it is treated as fully local.
""" """
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -145,6 +150,10 @@ class ParserProtocol(Protocol):
author: str author: str
url: str url: str
# NOTE: uses_remote_service is not declared here, the registry reads it
# with getattr(cls, ..., False) for backwards-compatibility with existing
# parsers
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Class methods # Class methods
# ------------------------------------------------------------------ # ------------------------------------------------------------------
+14
View File
@@ -334,6 +334,8 @@ class ParserRegistry:
mime_type: str, mime_type: str,
filename: str, filename: str,
path: Path | None = None, path: Path | None = None,
*,
allow_remote: bool = True,
) -> type[ParserProtocol] | None: ) -> type[ParserProtocol] | None:
"""Return the best parser class for the given file, or None. """Return the best parser class for the given file, or None.
@@ -359,6 +361,11 @@ class ParserRegistry:
path: path:
Optional filesystem path to the file. Forwarded to each Optional filesystem path to the file. Forwarded to each
parser's score method. parser's score method.
allow_remote:
When False, parsers that declare ``uses_remote_service = True``
are excluded from consideration, so a document is never sent to
a remote service. Parsers that do not declare the attribute
are treated as local and are always considered.
Returns Returns
------- -------
@@ -374,6 +381,13 @@ class ParserRegistry:
if mime_type not in parser_class.supported_mime_types(): if mime_type not in parser_class.supported_mime_types():
continue continue
if not allow_remote and getattr(
parser_class,
"uses_remote_service",
False,
):
continue
score = parser_class.score(mime_type, filename, path) score = parser_class.score(mime_type, filename, path)
if score is None: if score is None:
continue continue
+19 -10
View File
@@ -61,6 +61,18 @@ class RemoteEngineConfig:
self.api_key = api_key self.api_key = api_key
self.endpoint = endpoint 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: def engine_is_valid(self) -> bool:
"""Return True when the engine is known and fully configured.""" """Return True when the engine is known and fully configured."""
return ( return (
@@ -90,6 +102,9 @@ class RemoteDocumentParser:
Maintainer name. Maintainer name.
url : str url : str
Issue tracker / source URL. Issue tracker / source URL.
uses_remote_service : bool
Content is sent to a remote service, True so that the registry
can skip this parser if remote processing was not requested.
""" """
name: str = "Paperless-ngx Remote OCR Parser" name: str = "Paperless-ngx Remote OCR Parser"
@@ -97,6 +112,8 @@ class RemoteDocumentParser:
author: str = "Paperless-ngx Contributors" author: str = "Paperless-ngx Contributors"
url: str = "https://github.com/paperless-ngx/paperless-ngx" url: str = "https://github.com/paperless-ngx/paperless-ngx"
uses_remote_service: bool = True
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Class methods # Class methods
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -145,11 +162,7 @@ class RemoteDocumentParser:
20 when the remote engine is configured and the MIME type is 20 when the remote engine is configured and the MIME type is
supported, otherwise None. supported, otherwise None.
""" """
config = RemoteEngineConfig( config = RemoteEngineConfig.from_app_config()
engine=settings.REMOTE_OCR_ENGINE,
api_key=settings.REMOTE_OCR_API_KEY,
endpoint=settings.REMOTE_OCR_ENDPOINT,
)
if not config.engine_is_valid(): if not config.engine_is_valid():
return None return None
if mime_type not in _SUPPORTED_MIME_TYPES: if mime_type not in _SUPPORTED_MIME_TYPES:
@@ -244,11 +257,7 @@ class RemoteDocumentParser:
Whether an archive copy is wanted. For PDFs, False skips the Whether an archive copy is wanted. For PDFs, False skips the
remote engine and uses locally-extracted text instead. remote engine and uses locally-extracted text instead.
""" """
config = RemoteEngineConfig( config = RemoteEngineConfig.from_app_config()
engine=settings.REMOTE_OCR_ENGINE,
api_key=settings.REMOTE_OCR_API_KEY,
endpoint=settings.REMOTE_OCR_ENDPOINT,
)
if not config.engine_is_valid(): if not config.engine_is_valid():
logger.warning( logger.warning(
+14 -5
View File
@@ -219,6 +219,13 @@ class ApplicationConfigurationSerializer(
allow_null=True, allow_null=True,
max_length=1024, 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): def run_validation(self, data):
# Empty strings treated as None to avoid unexpected behavior # Empty strings treated as None to avoid unexpected behavior
@@ -230,11 +237,13 @@ class ApplicationConfigurationSerializer(
data["language"] = None data["language"] = None
if "llm_output_language" in data and data["llm_output_language"] == "": if "llm_output_language" in data and data["llm_output_language"] == "":
data["llm_output_language"] = None data["llm_output_language"] = None
if "llm_api_key" in data and data["llm_api_key"] is not None: for field in self.OBFUSCATED_FIELDS:
if data["llm_api_key"] == "": if field in data and data[field] is not None:
data["llm_api_key"] = None if data[field] == "":
elif len(data["llm_api_key"].replace("*", "")) == 0: data[field] = None
del data["llm_api_key"] # Not a real value, don't overwrite the stored one
elif len(data[field].replace("*", "")) == 0:
del data[field]
return super().run_validation(data) return super().run_validation(data)
def update(self, instance, validated_data): def update(self, instance, validated_data):
+1
View File
@@ -1197,6 +1197,7 @@ WEBHOOKS_ALLOW_INTERNAL_REQUESTS = get_bool_from_env(
REMOTE_OCR_ENGINE = os.getenv("PAPERLESS_REMOTE_OCR_ENGINE") REMOTE_OCR_ENGINE = os.getenv("PAPERLESS_REMOTE_OCR_ENGINE")
REMOTE_OCR_API_KEY = os.getenv("PAPERLESS_REMOTE_OCR_API_KEY") REMOTE_OCR_API_KEY = os.getenv("PAPERLESS_REMOTE_OCR_API_KEY")
REMOTE_OCR_ENDPOINT = os.getenv("PAPERLESS_REMOTE_OCR_ENDPOINT") REMOTE_OCR_ENDPOINT = os.getenv("PAPERLESS_REMOTE_OCR_ENDPOINT")
REMOTE_OCR_MODE = os.getenv("PAPERLESS_REMOTE_OCR_MODE", "always")
################################################################################ ################################################################################
# AI Settings # # AI Settings #
@@ -21,6 +21,7 @@ from unittest.mock import Mock
import pytest import pytest
from documents.parsers import ParseError from documents.parsers import ParseError
from paperless.models import ApplicationConfiguration
from paperless.parsers import ParserContext from paperless.parsers import ParserContext
from paperless.parsers import ParserProtocol from paperless.parsers import ParserProtocol
from paperless.parsers.remote import RemoteDocumentParser from paperless.parsers.remote import RemoteDocumentParser
@@ -33,6 +34,10 @@ if TYPE_CHECKING:
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
# Remote ocr config from ApplicationConfiguration needs DB access
pytestmark = pytest.mark.django_db
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Module-local fixtures # Module-local fixtures
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -227,6 +232,18 @@ class TestRemoteParserScore:
score = RemoteDocumentParser.score("application/pdf", "doc.pdf") score = RemoteDocumentParser.score("application/pdf", "doc.pdf")
assert score is not None and score > 10 assert score is not None and score > 10
@pytest.mark.usefixtures("no_engine_settings")
def test_score_uses_app_config_when_env_unset(self) -> None:
"""The app config alone is enough to activate the parser."""
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 # Properties
@@ -1277,6 +1277,8 @@ class TestParserFileTypes:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Remote ocr config from ApplicationConfiguration needs DB access
@pytest.mark.django_db
class TestRasterisedDocumentParserRegistry: class TestRasterisedDocumentParserRegistry:
def test_registered_in_defaults(self) -> None: def test_registered_in_defaults(self) -> None:
from paperless.parsers.registry import ParserRegistry from paperless.parsers.registry import ParserRegistry
+10 -18
View File
@@ -15,7 +15,7 @@ from paperless.checks import audit_log_check
from paperless.checks import binaries_check from paperless.checks import binaries_check
from paperless.checks import check_default_language_available from paperless.checks import check_default_language_available
from paperless.checks import check_deprecated_db_settings from paperless.checks import check_deprecated_db_settings
from paperless.checks import check_remote_parser_configured from paperless.checks import check_remote_ocr_mode
from paperless.checks import check_v3_minimum_upgrade_version from paperless.checks import check_v3_minimum_upgrade_version
from paperless.checks import debug_mode_check from paperless.checks import debug_mode_check
from paperless.checks import paths_check from paperless.checks import paths_check
@@ -631,29 +631,21 @@ class TestV3MinimumUpgradeVersionCheck:
assert check_v3_minimum_upgrade_version(None) == [] assert check_v3_minimum_upgrade_version(None) == []
class TestRemoteParserChecks: class TestRemoteOCRModeCheck:
def test_no_engine(self, settings: SettingsWrapper) -> None: def test_valid_mode(self, settings: SettingsWrapper) -> None:
settings.REMOTE_OCR_ENGINE = None settings.REMOTE_OCR_MODE = "workflow_only"
msgs = check_remote_parser_configured(None)
msgs = check_remote_ocr_mode(None)
assert len(msgs) == 0 assert len(msgs) == 0
def test_azure_no_endpoint(self, settings: SettingsWrapper) -> None: def test_invalid_mode(self, settings: SettingsWrapper) -> None:
settings.REMOTE_OCR_MODE = "sometimes"
settings.REMOTE_OCR_ENGINE = "azureai" msgs = check_remote_ocr_mode(None)
settings.REMOTE_OCR_API_KEY = "somekey"
settings.REMOTE_OCR_ENDPOINT = None
msgs = check_remote_parser_configured(None)
assert len(msgs) == 1 assert len(msgs) == 1
assert "PAPERLESS_REMOTE_OCR_MODE is set to 'sometimes'" in msgs[0].msg
msg = msgs[0]
assert (
"Azure AI remote parser requires endpoint and API key to be configured."
in msg.msg
)
class TestTesseractChecks: class TestTesseractChecks:
+118
View File
@@ -468,6 +468,124 @@ class TestParserRegistryGetParserForFile:
assert result is AcceptingBuiltin assert result is AcceptingBuiltin
class TestParserRegistryRemoteParsers:
"""Verify the allow_remote filter in ParserRegistry.get_parser_for_file()."""
@staticmethod
def _remote_parser_cls() -> type:
class RemoteParser:
name = "remote"
version = "1.0"
author = "A"
url = "https://example.com/remote"
uses_remote_service = True
@classmethod
def supported_mime_types(cls):
return {"text/plain": ".txt"}
@classmethod
def score(cls, mime_type, filename, path=None):
return 20
return RemoteParser
def test_remote_parser_wins_when_remote_allowed(
self,
dummy_parser_cls: type,
) -> None:
"""
GIVEN: A remote parser scoring 20 and a local parser scoring 10.
WHEN: get_parser_for_file() is called with allow_remote=True.
THEN: The remote parser is returned.
"""
remote_parser_cls = self._remote_parser_cls()
registry = ParserRegistry()
registry.register_builtin(dummy_parser_cls)
registry.register_builtin(remote_parser_cls)
result = registry.get_parser_for_file(
"text/plain",
"readme.txt",
allow_remote=True,
)
assert result is remote_parser_cls
def test_remote_parser_skipped_when_remote_not_allowed(
self,
dummy_parser_cls: type,
) -> None:
"""
GIVEN: A remote parser scoring 20 and a local parser scoring 10.
WHEN: get_parser_for_file() is called with allow_remote=False.
THEN: The local parser is returned despite its lower score.
"""
registry = ParserRegistry()
registry.register_builtin(dummy_parser_cls)
registry.register_builtin(self._remote_parser_cls())
result = registry.get_parser_for_file(
"text/plain",
"readme.txt",
allow_remote=False,
)
assert result is dummy_parser_cls
def test_no_parser_when_only_remote_available_and_not_allowed(self) -> None:
"""
GIVEN: A registry whose only candidate declares uses_remote_service.
WHEN: get_parser_for_file() is called with allow_remote=False.
THEN: None is returned the remote parser is never used as a
fallback when remote processing was not requested.
"""
registry = ParserRegistry()
registry.register_builtin(self._remote_parser_cls())
result = registry.get_parser_for_file(
"text/plain",
"readme.txt",
allow_remote=False,
)
assert result is None
def test_parser_without_attribute_treated_as_local(
self,
dummy_parser_cls: type,
) -> None:
"""
GIVEN: A third-party parser predating uses_remote_service, so it does
not declare the attribute at all.
WHEN: get_parser_for_file() is called with allow_remote=False.
THEN: It is still considered, i.e. treated as fully local, rather
than raising AttributeError.
"""
assert not hasattr(dummy_parser_cls, "uses_remote_service")
registry = ParserRegistry()
registry.register_builtin(dummy_parser_cls)
result = registry.get_parser_for_file(
"text/plain",
"readme.txt",
allow_remote=False,
)
assert result is dummy_parser_cls
def test_remote_allowed_by_default(self) -> None:
"""
GIVEN: A registry containing only a remote parser.
WHEN: get_parser_for_file() is called without allow_remote.
THEN: The remote parser is returned callers that do not opt in to
the filter keep the previous behaviour.
"""
remote_parser_cls = self._remote_parser_cls()
registry = ParserRegistry()
registry.register_builtin(remote_parser_cls)
result = registry.get_parser_for_file("text/plain", "readme.txt")
assert result is remote_parser_cls
class TestDiscover: class TestDiscover:
"""Verify entrypoint discovery in ParserRegistry.discover().""" """Verify entrypoint discovery in ParserRegistry.discover()."""
@@ -0,0 +1,113 @@
"""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
from paperless.models import RemoteOCRMode
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,
remote_ocr_mode=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",
REMOTE_OCR_MODE=RemoteOCRMode.WORKFLOW_ONLY,
)
assert cfg.remote_ocr_engine == "azureai"
assert cfg.remote_ocr_api_key == "env-key"
assert cfg.remote_ocr_endpoint == "https://env.cognitiveservices.azure.com"
assert cfg.remote_ocr_mode == RemoteOCRMode.WORKFLOW_ONLY
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",
remote_ocr_mode=RemoteOCRMode.WORKFLOW_ONLY,
)
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",
REMOTE_OCR_MODE=RemoteOCRMode.ALWAYS,
)
assert cfg.remote_ocr_engine == "azureai"
assert cfg.remote_ocr_api_key == "db-key"
assert cfg.remote_ocr_endpoint == "https://db.cognitiveservices.azure.com"
assert cfg.remote_ocr_mode == RemoteOCRMode.WORKFLOW_ONLY
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
class TestRemoteOCRByDefault:
def test_always_mode(self, make_remote_ocr_config, null_app_config) -> None:
cfg = make_remote_ocr_config(
null_app_config,
REMOTE_OCR_MODE=RemoteOCRMode.ALWAYS,
)
assert cfg.remote_ocr_by_default is True
def test_workflow_only_mode(self, make_remote_ocr_config, null_app_config) -> None:
cfg = make_remote_ocr_config(
null_app_config,
REMOTE_OCR_MODE=RemoteOCRMode.WORKFLOW_ONLY,
)
assert cfg.remote_ocr_by_default is False
+4 -44
View File
@@ -1,34 +1,7 @@
from typing import Any
from typing import Final
from typing import TypedDict from typing import TypedDict
from pydantic import BaseModel from pydantic import BaseModel
from pydantic import Field from pydantic import Field
from pydantic import ValidationInfo
from pydantic import field_validator
from pydantic.fields import FieldInfo
# taxonomy.py MAX_TAG_CANDIDATES = 10, prompt is "up to 3 relevant dates"
MAX_EXISTING_IDS: Final = 10
MAX_NEW_NAMES: Final = 8
MAX_DATES: Final = 3
# Matches documents.models.Document.title's CharField(max_length=128).
MAX_TITLE_LENGTH: Final = 128
def _truncate_to_field_limit(value: Any, field: FieldInfo) -> Any:
"""
Clip down to its it's declared maximum. Run as a `mode="before"` validator.
"""
limit = next(
(m.max_length for m in field.metadata if hasattr(m, "max_length")),
None,
)
return (
value
if (limit is None or not isinstance(value, (list, str)))
else value[:limit]
)
class TaxonomyChoice(BaseModel): class TaxonomyChoice(BaseModel):
@@ -41,32 +14,19 @@ class TaxonomyChoice(BaseModel):
TaxonomyChoiceDict below. TaxonomyChoiceDict below.
""" """
existing_ids: list[int] = Field( existing_ids: list[int] = Field(default_factory=list)
default_factory=list, new_names: list[str] = Field(default_factory=list)
max_length=MAX_EXISTING_IDS,
)
new_names: list[str] = Field(default_factory=list, max_length=MAX_NEW_NAMES)
@field_validator("existing_ids", "new_names", mode="before")
@classmethod
def _truncate(cls, value: Any, info: ValidationInfo) -> Any:
return _truncate_to_field_limit(value, cls.model_fields[info.field_name])
class DocumentClassifierSchema(BaseModel): class DocumentClassifierSchema(BaseModel):
"""Schema for document classification suggestions.""" """Schema for document classification suggestions."""
title: str = Field(max_length=MAX_TITLE_LENGTH) title: str
tags: TaxonomyChoice = Field(default_factory=TaxonomyChoice) tags: TaxonomyChoice = Field(default_factory=TaxonomyChoice)
correspondents: TaxonomyChoice = Field(default_factory=TaxonomyChoice) correspondents: TaxonomyChoice = Field(default_factory=TaxonomyChoice)
document_types: TaxonomyChoice = Field(default_factory=TaxonomyChoice) document_types: TaxonomyChoice = Field(default_factory=TaxonomyChoice)
storage_paths: TaxonomyChoice = Field(default_factory=TaxonomyChoice) storage_paths: TaxonomyChoice = Field(default_factory=TaxonomyChoice)
dates: list[str] = Field(default_factory=list, max_length=MAX_DATES) dates: list[str] = Field(default_factory=list)
@field_validator("title", "dates", mode="before")
@classmethod
def _truncate(cls, value: Any, info: ValidationInfo) -> Any:
return _truncate_to_field_limit(value, cls.model_fields[info.field_name])
class TaxonomyChoiceDict(TypedDict): class TaxonomyChoiceDict(TypedDict):
-84
View File
@@ -1,7 +1,3 @@
from paperless_ai.base_model import MAX_DATES
from paperless_ai.base_model import MAX_EXISTING_IDS
from paperless_ai.base_model import MAX_NEW_NAMES
from paperless_ai.base_model import MAX_TITLE_LENGTH
from paperless_ai.base_model import ClassificationSuggestions from paperless_ai.base_model import ClassificationSuggestions
from paperless_ai.base_model import DocumentClassifierSchema from paperless_ai.base_model import DocumentClassifierSchema
from paperless_ai.base_model import TaxonomyChoice from paperless_ai.base_model import TaxonomyChoice
@@ -67,86 +63,6 @@ def test_document_classifier_schema_json_schema_is_self_contained():
assert set(taxonomy_choice_properties.keys()) == {"existing_ids", "new_names"} assert set(taxonomy_choice_properties.keys()) == {"existing_ids", "new_names"}
def test_every_sequence_in_the_emitted_schema_is_bounded():
"""
GIVEN:
- The DocumentClassifierSchema pydantic model
WHEN:
- Its JSON schema is generated via model_json_schema()
THEN:
- Every array property in the schema, including those on the
referenced TaxonomyChoice definition, carries a maxItems
"""
schema = DocumentClassifierSchema.model_json_schema()
unbounded = [
f"{owner}.{name}"
for owner, definition in [
("DocumentClassifierSchema", schema),
*schema.get("$defs", {}).items(),
]
for name, prop in definition.get("properties", {}).items()
if prop.get("type") == "array" and "maxItems" not in prop
]
assert unbounded == []
def test_dates_bound_matches_what_the_prompt_asks_for():
"""
GIVEN:
- The DocumentClassifierSchema pydantic model
WHEN:
- The emitted maxItems for dates is inspected
THEN:
- It equals the 3 that build_prompt_without_rag asks the model for
"""
dates_schema = DocumentClassifierSchema.model_json_schema()["properties"]["dates"]
assert dates_schema["maxItems"] == MAX_DATES == 3
def test_over_long_response_is_truncated_rather_than_rejected():
"""
GIVEN:
- An LLM response overshooting every declared bound
WHEN:
- DocumentClassifierSchema is constructed from it
THEN:
- Each field is clipped to its maximum, with no ValidationError
"""
parsed = DocumentClassifierSchema(
title="T" * (MAX_TITLE_LENGTH + 50),
tags=TaxonomyChoice(
existing_ids=list(range(MAX_EXISTING_IDS + 20)),
new_names=["n"] * (MAX_NEW_NAMES + 20),
),
dates=[f"2016-{month:02d}-01" for month in range(1, 13)],
)
assert len(parsed.title) == MAX_TITLE_LENGTH
assert len(parsed.dates) == MAX_DATES
assert len(parsed.tags.existing_ids) == MAX_EXISTING_IDS
assert len(parsed.tags.new_names) == MAX_NEW_NAMES
def test_truncation_keeps_the_earliest_entries():
"""
GIVEN:
- An over-long dates list from an LLM response
WHEN:
- DocumentClassifierSchema is constructed from it
THEN:
- The kept entries are the first ones the model emitted
"""
parsed = DocumentClassifierSchema(
title="T",
dates=["2016-10-01", "2016-09-01", "2016-08-01", "2016-07-01", "2016-06-01"],
)
assert parsed.dates == ["2016-10-01", "2016-09-01", "2016-08-01"]
def test_model_dump_matches_typed_dict_keys(): def test_model_dump_matches_typed_dict_keys():
""" """
GIVEN: GIVEN: