Vm?s6M{$q>IB7+-VP@uG22QA=i&?0YNo0VkL*L1WfdT
z6#j_yUitH6SSaFTdRQpq2M2EEaU_5jKvu*N;Ee+eDOg-ilbf2yVQujiv!D~
zxA%T{+8Y1UiFUV}W`+hy!h;JMHm1k9^j(uR#ozkmtVulI;PXL7q`Rrtk(jTMF`j|7
z+U#3rRIbaM+0%ZXQQ$3Gtfm%5NV6&1xn%ATwZchQ@OI6@(3Y;@J=^CPU+J#<`qT;6
z6+tq##kzCpAJf*bnqM4?20MbkrL^9L=pDMz)-lJU?^F1$FLOHrV-F@KWxKA3d|tXB
znf`)c+E-G{34eJ}R_@+5Y+6y*Z~lIHc#qqe%H4fWNc_zF#tWJw4+ri-ANB1Vw~*6w
zD>M_fUPRlY8;gez>@1O=Ps-W%?DCZ&yfOJC>h1btQfioMZwVzid{K6kZT8C-4~n0R
znU56|=AE6tkze2Fp0w@Gmce^X5P|UVLetNw;boq&+p%(^T+g!_B4_Q
z&Po6OW1!az_Bj$&b^0L^21vFAC;OGReGMWU9rBeQd>u-)c(s;y%9H$`$X4VKZn%G
z-}3v|zWPz{?uKvMH|N9;wvYIhE@NcYQNwq2Gh%z|n=(XsZa4Phi_t5hoJ-VoEhX~k
zyhnD_yR`|zR>R%VwJ&;w^2c=Pc*;BO%M#Zw`eIQ_{?@bVNj+=FS2H$g4IMgB)PDde
zKYy1$WL7GokeBE8oInz;mB-wucnTC9k<}H~9qB%5CT|X2AWZYl^exC39WrC?s;_;p
zc1Tt@f9#3alrb)8;PiQ%3qNzj)Wu}Mos}MJ!;9}2%1IUU4{UrVnozJ@m@C6C7I=V;g(n6hjw!}8>xuI3C!^3%c1XkxbAF@Yp(LFUA6B}1Y_YeXKZ>jMi9=@wx2kjbmh1;FQY5VpTA#8TKG$gbB}cyDA&as7xMtSXguGf$
z>bgeP!Q7=yR>ypF=K6iR?K+S70kya(aqNuqfh%C4M#vnueY-M`Gc+9@i?w$Srajb;
zylLj2FTZL9OM+ZaGdrs@>cSItlawx
zx9F>TIT@(W2&@)19tZj>d`VA+2^^_b{Qy-1E>#K`<4#lYzIQ!^d@W!02Y-cit^xUU?=X(69
z={exg!@>t~>w4AN~jKTx}btwl_wAMagAO);z(6A6`qRGuNiZcUFnsf+MrvsIp|ondYxo
zW|ev8&-Q3%v7l+t=2n;2(gAb*!}s5whM62RFE(8;D}S3L&ggmWJH>Z!#;tQ@X53!I
zN7)OD>KU`%1a2=#U3_|#pG{eOSkA1S*%RBzCVEA=GyRRzq*NaY&412=IUhp>i0C$HsGL$(kHDlYVv2yV--m|~!V>P%+T}3Ylz32Dre~91*KQC>kJ|nj;!wrM}nZXe6u{F@26q
zd~*(>QQyE3iKI8=ks%VcwbSJp(WuxOC+Bcdl*JRksItcC%nOCFb%4r3reY(o!ltrf
zsVIbE{Y{pD7lQ&(StJgdN}>_ThQ@4&0>j2MF4Y(&8B None:
+ assert post_process_text(source) == expected
+
+
+class TestPdfBornDigitalText:
+ """Regression coverage for GH #13387.
+
+ should_produce_archive() and RasterisedDocumentParser.parse() must agree
+ on whether a PDF has real text, so both go through this one function.
+ """
+
+ @pytest.mark.parametrize(
+ ("extracted", "tagged", "expected_text", "expected_born_digital"),
+ [
+ pytest.param("tiny", True, "tiny", True, id="tagged-with-real-text"),
+ pytest.param("tiny", False, "tiny", False, id="untagged-below-min-length"),
+ pytest.param(
+ "x" * 51,
+ False,
+ "x" * 51,
+ True,
+ id="untagged-above-min-length",
+ ),
+ pytest.param(None, True, None, False, id="tagged-but-no-text"),
+ ],
+ )
+ def test_born_digital_decision(
+ self,
+ mocker: MockerFixture,
+ tmp_path: Path,
+ extracted: str | None,
+ tagged: bool, # noqa: FBT001
+ expected_text: str | None,
+ expected_born_digital: bool, # noqa: FBT001
+ ) -> None:
+ """
+ GIVEN:
+ - A PDF whose pdftotext output and /MarkInfo tag status vary
+ WHEN:
+ - pdf_born_digital_text() is called
+ THEN:
+ - The normalized text and born-digital verdict match; the tag
+ alone never counts as "has text"
+ """
+ mocker.patch(
+ "paperless.parsers.utils.extract_pdf_text",
+ return_value=extracted,
+ )
+ mocker.patch("paperless.parsers.utils.is_tagged_pdf", return_value=tagged)
+ text, born_digital = pdf_born_digital_text(tmp_path / "doc.pdf")
+ assert text == expected_text
+ assert born_digital is expected_born_digital
+
+ def test_tagged_but_textless_pdf_is_not_born_digital(
+ self,
+ tagged_no_text_pdf_file: Path,
+ ) -> None:
+ """
+ GIVEN:
+ - A real PDF that is tagged (/MarkInfo /Marked true) but whose
+ only "text" is layout padding (a stray form-feed byte)
+ WHEN:
+ - pdf_born_digital_text() is called with no mocking
+ THEN:
+ - The normalized text is None and the PDF is not treated as
+ born-digital. The raw, unnormalized pdftotext output is
+ non-empty for this file, which is exactly what caused the
+ archive decision to disagree with the OCR decision in #13387.
+ """
+ text, born_digital = pdf_born_digital_text(tagged_no_text_pdf_file)
+ assert text is None
+ assert born_digital is False
From 0a56018d6ba4c7d4cae840c69ad5d38e18216c31 Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Thu, 30 Jul 2026 07:08:50 -0700
Subject: [PATCH 31/77] Fix: hide attributes collapse / show button without UI
settings (#13425)
---
src-ui/src/app/components/app-frame/app-frame.component.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src-ui/src/app/components/app-frame/app-frame.component.html b/src-ui/src/app/components/app-frame/app-frame.component.html
index 052ea386d..9f8f6fd8d 100644
--- a/src-ui/src/app/components/app-frame/app-frame.component.html
+++ b/src-ui/src/app/components/app-frame/app-frame.component.html
@@ -182,7 +182,7 @@
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
Attributes
- @if (!slimSidebarEnabled) {
+ @if (!slimSidebarEnabled && canSaveSettings) {
- } @else if (activeCustomFields) {
-
diff --git a/src-ui/src/app/components/manage/document-attributes/document-attributes.component.spec.ts b/src-ui/src/app/components/manage/document-attributes/document-attributes.component.spec.ts
index 97a8bfd1f..b40c35692 100644
--- a/src-ui/src/app/components/manage/document-attributes/document-attributes.component.spec.ts
+++ b/src-ui/src/app/components/manage/document-attributes/document-attributes.component.spec.ts
@@ -18,6 +18,7 @@ import {
DocumentAttributesComponent,
DocumentAttributesSectionKind,
} from './document-attributes.component'
+import { CustomFieldsComponent } from './custom-fields/custom-fields.component'
import { ManagementListComponent } from './management-list/management-list.component'
@Component({
@@ -207,6 +208,29 @@ describe('DocumentAttributesComponent', () => {
expect(component.activeSection.kind).toBe(
DocumentAttributesSectionKind.CustomFields
)
- expect(component.activeCustomFields).toBeDefined()
+ const customFields = Object.create(CustomFieldsComponent.prototype)
+ customFields.editField = jest.fn()
+ component.activeOutlet = {
+ componentInstance: customFields,
+ } as any
+ expect(component.activeCustomFields).toBe(customFields)
+
+ component.addCustomField()
+ expect(customFields.editField).toHaveBeenCalled()
+ })
+
+ it('should show the add field button before the custom fields instance is available', async () => {
+ jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+
+ fixture.detectChanges()
+ component.activeNavID.set(2)
+ await fixture.whenStable()
+
+ expect(component.activeCustomFields).toBeNull()
+ expect(
+ fixture.nativeElement.querySelector(
+ 'pngx-page-header .btn-outline-primary'
+ )?.textContent
+ ).toContain('Add Field')
})
})
diff --git a/src-ui/src/app/components/manage/document-attributes/document-attributes.component.ts b/src-ui/src/app/components/manage/document-attributes/document-attributes.component.ts
index 482149ff0..0ff747407 100644
--- a/src-ui/src/app/components/manage/document-attributes/document-attributes.component.ts
+++ b/src-ui/src/app/components/manage/document-attributes/document-attributes.component.ts
@@ -163,12 +163,17 @@ export class DocumentAttributesComponent implements OnInit, OnDestroy {
}
get activeCustomFields(): CustomFieldsComponent | null {
- if (this.activeSection?.kind !== DocumentAttributesSectionKind.CustomFields)
- return null
+ if (!this.customFieldsActive) return null
const instance = this.activeOutlet?.componentInstance
return instance instanceof CustomFieldsComponent ? instance : null
}
+ get customFieldsActive(): boolean {
+ return (
+ this.activeSection?.kind === DocumentAttributesSectionKind.CustomFields
+ )
+ }
+
get activeTabLabel(): string {
return this.activeSection?.label ?? ''
}
@@ -224,6 +229,10 @@ export class DocumentAttributesComponent implements OnInit, OnDestroy {
this.router.navigate(['attributes', nextSection])
}
+ addCustomField(): void {
+ this.activeCustomFields?.editField(null)
+ }
+
private getDefaultNavID(): DocumentAttributesNavIDs | null {
return this.visibleSections[0]?.id ?? null
}
From f50a021ef62460626029cd551121d166f65318b3 Mon Sep 17 00:00:00 2001
From: Trenton H <797416+stumpylog@users.noreply.github.com>
Date: Thu, 30 Jul 2026 07:35:19 -0700
Subject: [PATCH 33/77] Fix: fold overflowing path text in sanity checker
(#13426)
---
src/documents/management/commands/document_sanity_checker.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/documents/management/commands/document_sanity_checker.py b/src/documents/management/commands/document_sanity_checker.py
index c8921d064..00f6dccb1 100644
--- a/src/documents/management/commands/document_sanity_checker.py
+++ b/src/documents/management/commands/document_sanity_checker.py
@@ -61,7 +61,7 @@ class Command(PaperlessCommand):
)
table.add_column("Level", width=7, no_wrap=True)
table.add_column("Document", min_width=20)
- table.add_column("Issue", ratio=1)
+ table.add_column("Issue", ratio=1, overflow="fold")
for doc_pk, doc_messages in messages.iter_messages():
if doc_pk is not None:
From baae99eb4994b6b4274140bfff8f64445534020f Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Thu, 30 Jul 2026 07:37:52 -0700
Subject: [PATCH 34/77] Fix: normalize monetary decimal symbol by locale
(#13427)
---
...ustom-fields-query-dropdown.component.html | 5 ++++
...om-fields-query-dropdown.component.spec.ts | 24 +++++++++++++++++++
.../custom-fields-query-dropdown.component.ts | 22 ++++++++++++++++-
3 files changed, 50 insertions(+), 1 deletion(-)
diff --git a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html
index 225012159..c0c9b0c74 100644
--- a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html
+++ b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html
@@ -68,6 +68,11 @@
>
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.DocumentLink) {
+ } @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Monetary) {
+
} @else {
}
diff --git a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.spec.ts b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.spec.ts
index a9c161d83..d1e755032 100644
--- a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.spec.ts
+++ b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.spec.ts
@@ -1,5 +1,6 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
+import { LOCALE_ID } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
@@ -41,6 +42,12 @@ const customFields = [
],
},
},
+ {
+ id: 3,
+ name: 'Test Monetary Field',
+ data_type: CustomFieldDataType.Monetary,
+ extra_data: { default_currency: 'EUR' },
+ },
]
describe('CustomFieldsQueryDropdownComponent', () => {
@@ -61,6 +68,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
providers: [
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
+ { provide: LOCALE_ID, useValue: 'de' },
],
}).compileComponents()
@@ -150,6 +158,22 @@ describe('CustomFieldsQueryDropdownComponent', () => {
expect(options2).toEqual([])
})
+ it('should normalize localized monetary comparison values', () => {
+ const atom = new CustomFieldQueryAtom([3, 'exact', null])
+
+ component.setMonetaryValue(atom, '1.234,56')
+
+ expect(atom.value).toEqual('1234.56')
+ })
+
+ it('should preserve API-formatted monetary comparison values', () => {
+ const atom = new CustomFieldQueryAtom([3, 'exact', null])
+
+ component.setMonetaryValue(atom, '1234.56')
+
+ expect(atom.value).toEqual('1234.56')
+ })
+
it('should remove an element from the selection model', () => {
const expression = new CustomFieldQueryExpression()
const atom = new CustomFieldQueryAtom()
diff --git a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts
index 1d2ce1f5c..7f17c0e3a 100644
--- a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts
+++ b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts
@@ -1,9 +1,14 @@
-import { NgTemplateOutlet } from '@angular/common'
+import {
+ getLocaleNumberSymbol,
+ NgTemplateOutlet,
+ NumberSymbol,
+} from '@angular/common'
import {
Component,
EventEmitter,
inject,
Input,
+ LOCALE_ID,
Output,
QueryList,
signal,
@@ -212,6 +217,7 @@ export class CustomFieldQueriesModel {
})
export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPermissions {
protected customFieldsService = inject(CustomFieldsService)
+ private readonly locale = inject(LOCALE_ID)
public CustomFieldQueryComponentType = CustomFieldQueryElementType
public CustomFieldQueryOperator = CustomFieldQueryOperator
@@ -376,4 +382,18 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
}
return []
}
+
+ setMonetaryValue(atom: CustomFieldQueryAtom, value: string) {
+ // Normalize the decimal symbol e.g. . vs , by locale
+ const decimalSymbol = getLocaleNumberSymbol(
+ this.locale,
+ NumberSymbol.Decimal
+ )
+ if (decimalSymbol !== '.' && value.includes(decimalSymbol)) {
+ const groupSymbol = getLocaleNumberSymbol(this.locale, NumberSymbol.Group)
+ value = value.split(groupSymbol).join('').split(decimalSymbol).join('.')
+ }
+
+ atom.value = value
+ }
}
From c29c9178f0eb35d87fbfe3ad61d657520ba35059 Mon Sep 17 00:00:00 2001
From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com>
Date: Thu, 30 Jul 2026 14:39:47 +0000
Subject: [PATCH 35/77] Auto translate strings
---
src-ui/messages.xlf | 20 ++++++++++----------
1 file changed, 10 insertions(+), 10 deletions(-)
diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf
index e8f0ae0bf..e0fc47399 100644
--- a/src-ui/messages.xlf
+++ b/src-ui/messages.xlf
@@ -1849,7 +1849,7 @@
src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html
- 154
+ 159
src/app/components/common/filterable-dropdown/filterable-dropdown.component.html
@@ -3972,11 +3972,11 @@
src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html
- 96
+ 101
src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html
- 102
+ 107