-
diff --git a/src-ui/src/app/components/admin/settings/settings.component.spec.ts b/src-ui/src/app/components/admin/settings/settings.component.spec.ts
index 650d6d8ea..62a5aa363 100644
--- a/src-ui/src/app/components/admin/settings/settings.component.spec.ts
+++ b/src-ui/src/app/components/admin/settings/settings.component.spec.ts
@@ -201,9 +201,9 @@ describe('SettingsComponent', () => {
const navigateSpy = jest.spyOn(router, 'navigate')
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink))
tabButtons[1].nativeElement.dispatchEvent(new MouseEvent('click'))
- expect(navigateSpy).toHaveBeenCalledWith(['settings', 'permissions'])
+ expect(navigateSpy).toHaveBeenCalledWith(['settings', 'documents'])
tabButtons[2].nativeElement.dispatchEvent(new MouseEvent('click'))
- expect(navigateSpy).toHaveBeenCalledWith(['settings', 'notifications'])
+ expect(navigateSpy).toHaveBeenCalledWith(['settings', 'permissions'])
const initSpy = jest.spyOn(component, 'initialize')
component.isDirty = true // mock dirty
@@ -213,8 +213,8 @@ describe('SettingsComponent', () => {
expect(initSpy).not.toHaveBeenCalled()
navigateSpy.mockResolvedValueOnce(true) // nav accepted even though dirty
- tabButtons[1].nativeElement.dispatchEvent(new MouseEvent('click'))
- expect(navigateSpy).toHaveBeenCalledWith(['settings', 'notifications'])
+ tabButtons[2].nativeElement.dispatchEvent(new MouseEvent('click'))
+ expect(navigateSpy).toHaveBeenCalledWith(['settings', 'permissions'])
expect(initSpy).toHaveBeenCalled()
})
@@ -226,7 +226,7 @@ describe('SettingsComponent', () => {
activatedRoute.snapshot.fragment = '#notifications'
const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor')
component.ngOnInit()
- expect(component.activeNavID).toEqual(3) // Notifications
+ expect(component.activeNavID).toEqual(4) // Notifications
component.ngAfterViewInit()
expect(scrollSpy).toHaveBeenCalledWith('#notifications')
})
@@ -251,7 +251,7 @@ describe('SettingsComponent', () => {
expect(toastErrorSpy).toHaveBeenCalled()
expect(storeSpy).toHaveBeenCalled()
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
- expect(setSpy).toHaveBeenCalledTimes(30)
+ expect(setSpy).toHaveBeenCalledTimes(31)
// succeed
storeSpy.mockReturnValueOnce(of(true))
@@ -366,4 +366,22 @@ describe('SettingsComponent', () => {
settingsService.settingsSaved.emit(true)
expect(maybeRefreshSpy).toHaveBeenCalled()
})
+
+ it('should support toggling document detail fields', () => {
+ completeSetup()
+ const field = 'storage_path'
+ expect(
+ component.settingsForm.get('documentDetailsHiddenFields').value.length
+ ).toEqual(0)
+ component.toggleDocumentDetailField(field, false)
+ expect(
+ component.settingsForm.get('documentDetailsHiddenFields').value.length
+ ).toEqual(1)
+ expect(component.isDocumentDetailFieldShown(field)).toBeFalsy()
+ component.toggleDocumentDetailField(field, true)
+ expect(
+ component.settingsForm.get('documentDetailsHiddenFields').value.length
+ ).toEqual(0)
+ expect(component.isDocumentDetailFieldShown(field)).toBeTruthy()
+ })
})
diff --git a/src-ui/src/app/components/admin/settings/settings.component.ts b/src-ui/src/app/components/admin/settings/settings.component.ts
index 614d2fcd0..990944ff6 100644
--- a/src-ui/src/app/components/admin/settings/settings.component.ts
+++ b/src-ui/src/app/components/admin/settings/settings.component.ts
@@ -70,9 +70,9 @@ import { ComponentWithPermissions } from '../../with-permissions/with-permission
enum SettingsNavIDs {
General = 1,
- Permissions = 2,
- Notifications = 3,
- SavedViews = 4,
+ Documents = 2,
+ Permissions = 3,
+ Notifications = 4,
}
const systemLanguage = { code: '', name: $localize`Use system language` }
@@ -81,6 +81,25 @@ const systemDateFormat = {
name: $localize`Use date format of display language`,
}
+export enum DocumentDetailFieldID {
+ ArchiveSerialNumber = 'archive_serial_number',
+ Correspondent = 'correspondent',
+ DocumentType = 'document_type',
+ StoragePath = 'storage_path',
+ Tags = 'tags',
+}
+
+const documentDetailFieldOptions = [
+ {
+ id: DocumentDetailFieldID.ArchiveSerialNumber,
+ label: $localize`Archive serial number`,
+ },
+ { id: DocumentDetailFieldID.Correspondent, label: $localize`Correspondent` },
+ { id: DocumentDetailFieldID.DocumentType, label: $localize`Document type` },
+ { id: DocumentDetailFieldID.StoragePath, label: $localize`Storage path` },
+ { id: DocumentDetailFieldID.Tags, label: $localize`Tags` },
+]
+
@Component({
selector: 'pngx-settings',
templateUrl: './settings.component.html',
@@ -146,6 +165,7 @@ export class SettingsComponent
pdfViewerDefaultZoom: new FormControl(null),
documentEditingRemoveInboxTags: new FormControl(null),
documentEditingOverlayThumbnail: new FormControl(null),
+ documentDetailsHiddenFields: new FormControl([]),
searchDbOnly: new FormControl(null),
searchLink: new FormControl(null),
@@ -176,6 +196,8 @@ export class SettingsComponent
public readonly ZoomSetting = ZoomSetting
+ public readonly documentDetailFieldOptions = documentDetailFieldOptions
+
get systemStatusHasErrors(): boolean {
return (
this.systemStatus.database.status === SystemStatusItemStatus.ERROR ||
@@ -336,6 +358,9 @@ export class SettingsComponent
documentEditingOverlayThumbnail: this.settings.get(
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL
),
+ documentDetailsHiddenFields: this.settings.get(
+ SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS
+ ),
searchDbOnly: this.settings.get(SETTINGS_KEYS.SEARCH_DB_ONLY),
searchLink: this.settings.get(SETTINGS_KEYS.SEARCH_FULL_TYPE),
}
@@ -526,6 +551,10 @@ export class SettingsComponent
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL,
this.settingsForm.value.documentEditingOverlayThumbnail
)
+ this.settings.set(
+ SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS,
+ this.settingsForm.value.documentDetailsHiddenFields
+ )
this.settings.set(
SETTINGS_KEYS.SEARCH_DB_ONLY,
this.settingsForm.value.searchDbOnly
@@ -587,6 +616,26 @@ export class SettingsComponent
this.settingsForm.get('themeColor').patchValue('')
}
+ isDocumentDetailFieldShown(fieldId: string): boolean {
+ const hiddenFields =
+ this.settingsForm.value.documentDetailsHiddenFields || []
+ return !hiddenFields.includes(fieldId)
+ }
+
+ toggleDocumentDetailField(fieldId: string, checked: boolean) {
+ const hiddenFields = new Set(
+ this.settingsForm.value.documentDetailsHiddenFields || []
+ )
+ if (checked) {
+ hiddenFields.delete(fieldId)
+ } else {
+ hiddenFields.add(fieldId)
+ }
+ this.settingsForm
+ .get('documentDetailsHiddenFields')
+ .setValue(Array.from(hiddenFields))
+ }
+
showSystemStatus() {
const modal: NgbModalRef = this.modalService.open(
SystemStatusDialogComponent,
diff --git a/src-ui/src/app/components/document-detail/document-detail.component.html b/src-ui/src/app/components/document-detail/document-detail.component.html
index 5ca002479..306152cc4 100644
--- a/src-ui/src/app/components/document-detail/document-detail.component.html
+++ b/src-ui/src/app/components/document-detail/document-detail.component.html
@@ -146,16 +146,26 @@
-
+ @if (!isFieldHidden(DocumentDetailFieldID.ArchiveSerialNumber)) {
+
+ }
-
-
-
-
+ @if (!isFieldHidden(DocumentDetailFieldID.Correspondent)) {
+
+ }
+ @if (!isFieldHidden(DocumentDetailFieldID.DocumentType)) {
+
+ }
+ @if (!isFieldHidden(DocumentDetailFieldID.StoragePath)) {
+
+ }
+ @if (!isFieldHidden(DocumentDetailFieldID.Tags)) {
+
+ }
@for (fieldInstance of document?.custom_fields; track fieldInstance.field; let i = $index) {
@switch (getCustomFieldFromInstance(fieldInstance)?.data_type) {
diff --git a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts
index d1d10c985..809478816 100644
--- a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts
+++ b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts
@@ -48,6 +48,7 @@ import {
} from 'src/app/data/filter-rule-type'
import { StoragePath } from 'src/app/data/storage-path'
import { Tag } from 'src/app/data/tag'
+import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
@@ -1015,7 +1016,7 @@ describe('DocumentDetailComponent', () => {
it('should display built-in pdf viewer if not disabled', () => {
initNormally()
component.document.archived_file_name = 'file.pdf'
- jest.spyOn(settingsService, 'get').mockReturnValue(false)
+ settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
expect(component.useNativePdfViewer).toBeFalsy()
fixture.detectChanges()
expect(fixture.debugElement.query(By.css('pdf-viewer'))).not.toBeNull()
@@ -1024,7 +1025,7 @@ describe('DocumentDetailComponent', () => {
it('should display native pdf viewer if enabled', () => {
initNormally()
component.document.archived_file_name = 'file.pdf'
- jest.spyOn(settingsService, 'get').mockReturnValue(true)
+ settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, true)
expect(component.useNativePdfViewer).toBeTruthy()
fixture.detectChanges()
expect(fixture.debugElement.query(By.css('object'))).not.toBeNull()
diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts
index 917597ef6..8c22f53c2 100644
--- a/src-ui/src/app/components/document-detail/document-detail.component.ts
+++ b/src-ui/src/app/components/document-detail/document-detail.component.ts
@@ -84,6 +84,7 @@ import { ToastService } from 'src/app/services/toast.service'
import { getFilenameFromContentDisposition } from 'src/app/utils/http'
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
import * as UTIF from 'utif'
+import { DocumentDetailFieldID } from '../admin/settings/settings.component'
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
@@ -281,6 +282,8 @@ export class DocumentDetailComponent
public readonly DataType = DataType
+ public readonly DocumentDetailFieldID = DocumentDetailFieldID
+
@ViewChild('nav') nav: NgbNav
@ViewChild('pdfPreview') set pdfPreview(element) {
// this gets called when component added or removed from DOM
@@ -327,6 +330,12 @@ export class DocumentDetailComponent
return this.settings.get(SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL)
}
+ isFieldHidden(fieldId: DocumentDetailFieldID): boolean {
+ return this.settings
+ .get(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS)
+ .includes(fieldId)
+ }
+
private getRenderType(mimeType: string): ContentRenderType {
if (!mimeType) return ContentRenderType.Unknown
if (mimeType === 'application/pdf') {
diff --git a/src-ui/src/app/data/ui-settings.ts b/src-ui/src/app/data/ui-settings.ts
index e797fe9b3..827a1b82d 100644
--- a/src-ui/src/app/data/ui-settings.ts
+++ b/src-ui/src/app/data/ui-settings.ts
@@ -70,6 +70,8 @@ export const SETTINGS_KEYS = {
'general-settings:document-editing:remove-inbox-tags',
DOCUMENT_EDITING_OVERLAY_THUMBNAIL:
'general-settings:document-editing:overlay-thumbnail',
+ DOCUMENT_DETAILS_HIDDEN_FIELDS:
+ 'general-settings:document-details:hidden-fields',
SEARCH_DB_ONLY: 'general-settings:search:db-only',
SEARCH_FULL_TYPE: 'general-settings:search:more-link',
EMPTY_TRASH_DELAY: 'trash_delay',
@@ -255,6 +257,11 @@ export const SETTINGS: UiSetting[] = [
type: 'boolean',
default: true,
},
+ {
+ key: SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS,
+ type: 'array',
+ default: [],
+ },
{
key: SETTINGS_KEYS.SEARCH_DB_ONLY,
type: 'boolean',
From 94b0f4e1146113970517b1b6df47ed5cba606963 Mon Sep 17 00:00:00 2001
From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com>
Date: Tue, 27 Jan 2026 07:25:45 +0000
Subject: [PATCH 2/9] Auto translate strings
---
src-ui/messages.xlf | 628 +++++++++++++++++++++++---------------------
1 file changed, 335 insertions(+), 293 deletions(-)
diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf
index ea44b87bf..f9cf0e906 100644
--- a/src-ui/messages.xlf
+++ b/src-ui/messages.xlf
@@ -314,6 +314,14 @@
src/app/app.component.ts
152
+
+ src/app/components/admin/settings/settings.component.html
+ 193
+
+
+ src/app/components/admin/settings/settings.component.html
+ 197
+
src/app/components/app-frame/app-frame.component.html
94
@@ -534,7 +542,7 @@
src/app/components/document-detail/document-detail.component.html
- 427
+ 437
@@ -545,7 +553,7 @@
src/app/components/admin/settings/settings.component.html
- 362
+ 386
src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html
@@ -593,7 +601,7 @@
src/app/components/document-detail/document-detail.component.html
- 420
+ 430
src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.html
@@ -761,7 +769,7 @@
src/app/components/document-detail/document-detail.component.html
- 440
+ 450
src/app/components/document-list/document-list.component.html
@@ -914,88 +922,128 @@
99,100
-
- Items per page
-
- src/app/components/admin/settings/settings.component.html
- 107
-
-
Sidebar
src/app/components/admin/settings/settings.component.html
- 123
+ 107
Use 'slim' sidebar (icons only)
src/app/components/admin/settings/settings.component.html
- 127
+ 111
Dark mode
src/app/components/admin/settings/settings.component.html
- 134
+ 118
Use system settings
src/app/components/admin/settings/settings.component.html
- 137
+ 121
Enable dark mode
src/app/components/admin/settings/settings.component.html
- 138
+ 122
Invert thumbnails in dark mode
src/app/components/admin/settings/settings.component.html
- 139
+ 123
Theme Color
src/app/components/admin/settings/settings.component.html
- 145
+ 129
Reset
src/app/components/admin/settings/settings.component.html
- 152
+ 136
+
+
+
+ Global search
+
+ src/app/components/admin/settings/settings.component.html
+ 142
+
+
+ src/app/components/app-frame/global-search/global-search.component.ts
+ 122
+
+
+
+ Do not include advanced search results
+
+ src/app/components/admin/settings/settings.component.html
+ 145
+
+
+
+ Full search links to
+
+ src/app/components/admin/settings/settings.component.html
+ 151
+
+
+
+ Title and content search
+
+ src/app/components/admin/settings/settings.component.html
+ 155
+
+
+
+ Advanced search
+
+ src/app/components/admin/settings/settings.component.html
+ 156
+
+
+ src/app/components/app-frame/global-search/global-search.component.html
+ 24
+
+
+ src/app/components/document-list/filter-editor/filter-editor.component.ts
+ 208
Update checking
src/app/components/admin/settings/settings.component.html
- 157
+ 161
Enable update checking
src/app/components/admin/settings/settings.component.html
- 160
+ 164
What's this?
src/app/components/admin/settings/settings.component.html
- 161
+ 165
src/app/components/common/page-header/page-header.component.html
@@ -1014,21 +1062,21 @@
Update checking works by pinging the public GitHub API for the latest release to determine whether a new version is available. Actual updating of the app must still be performed manually.
src/app/components/admin/settings/settings.component.html
- 165,167
+ 169,171
No tracking data is collected by the app in any way.
src/app/components/admin/settings/settings.component.html
- 169
+ 173
Saved Views
src/app/components/admin/settings/settings.component.html
- 175
+ 179
src/app/components/app-frame/app-frame.component.html
@@ -1047,152 +1095,126 @@
Show warning when closing saved views with unsaved changes
src/app/components/admin/settings/settings.component.html
- 178
+ 182
Show document counts in sidebar saved views
src/app/components/admin/settings/settings.component.html
- 179
+ 183
+
+
+
+ Items per page
+
+ src/app/components/admin/settings/settings.component.html
+ 200
Document editing
src/app/components/admin/settings/settings.component.html
- 185
+ 212
Use PDF viewer provided by the browser
src/app/components/admin/settings/settings.component.html
- 189
+ 215
This is usually faster for displaying large PDF documents, but it might not work on some browsers.
src/app/components/admin/settings/settings.component.html
- 189
+ 215
Default zoom
src/app/components/admin/settings/settings.component.html
- 195
+ 221
Fit width
src/app/components/admin/settings/settings.component.html
- 199
+ 225
Fit page
src/app/components/admin/settings/settings.component.html
- 200
+ 226
Only applies to the Paperless-ngx PDF viewer.
src/app/components/admin/settings/settings.component.html
- 202
+ 228
Automatically remove inbox tag(s) on save
src/app/components/admin/settings/settings.component.html
- 208
+ 234
Show document thumbnail during loading
src/app/components/admin/settings/settings.component.html
- 214
+ 240
-
- Global search
+
+ Built-in fields to show:
src/app/components/admin/settings/settings.component.html
- 218
-
-
- src/app/components/app-frame/global-search/global-search.component.ts
- 122
+ 246
-
- Do not include advanced search results
+
+ Uncheck fields to hide them on the document details page.
src/app/components/admin/settings/settings.component.html
- 221
-
-
-
- Full search links to
-
- src/app/components/admin/settings/settings.component.html
- 227
-
-
-
- Title and content search
-
- src/app/components/admin/settings/settings.component.html
- 231
-
-
-
- Advanced search
-
- src/app/components/admin/settings/settings.component.html
- 232
-
-
- src/app/components/app-frame/global-search/global-search.component.html
- 24
-
-
- src/app/components/document-list/filter-editor/filter-editor.component.ts
- 208
+ 258
Bulk editing
src/app/components/admin/settings/settings.component.html
- 237
+ 263
Show confirmation dialogs
src/app/components/admin/settings/settings.component.html
- 240
+ 266
Apply on close
src/app/components/admin/settings/settings.component.html
- 241
+ 267
Notes
src/app/components/admin/settings/settings.component.html
- 245
+ 271
src/app/components/document-list/document-list.component.html
@@ -1211,14 +1233,14 @@
Enable notes
src/app/components/admin/settings/settings.component.html
- 248
+ 274
Permissions
src/app/components/admin/settings/settings.component.html
- 259
+ 283
src/app/components/common/edit-dialog/group-edit-dialog/group-edit-dialog.component.html
@@ -1234,7 +1256,7 @@
src/app/components/document-detail/document-detail.component.html
- 365
+ 375
src/app/components/document-list/bulk-editor/bulk-editor.component.html
@@ -1281,28 +1303,28 @@
Default Permissions
src/app/components/admin/settings/settings.component.html
- 262
+ 286
Settings apply to this user account for objects (Tags, Mail Rules, etc. but not documents) created via the web UI.
src/app/components/admin/settings/settings.component.html
- 266,268
+ 290,292
Default Owner
src/app/components/admin/settings/settings.component.html
- 273
+ 297
Objects without an owner can be viewed and edited by all users
src/app/components/admin/settings/settings.component.html
- 277
+ 301
src/app/components/common/input/permissions/permissions-form/permissions-form.component.html
@@ -1313,18 +1335,18 @@
Default View Permissions
src/app/components/admin/settings/settings.component.html
- 282
+ 306
Users:
src/app/components/admin/settings/settings.component.html
- 287
+ 311
src/app/components/admin/settings/settings.component.html
- 314
+ 338
src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html
@@ -1355,11 +1377,11 @@
Groups:
src/app/components/admin/settings/settings.component.html
- 297
+ 321
src/app/components/admin/settings/settings.component.html
- 324
+ 348
src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html
@@ -1390,14 +1412,14 @@
Default Edit Permissions
src/app/components/admin/settings/settings.component.html
- 309
+ 333
Edit permissions also grant viewing permissions
src/app/components/admin/settings/settings.component.html
- 333
+ 357
src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html
@@ -1416,7 +1438,7 @@
Notifications
src/app/components/admin/settings/settings.component.html
- 341
+ 365
src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.html
@@ -1427,49 +1449,49 @@
Document processing
src/app/components/admin/settings/settings.component.html
- 344
+ 368
Show notifications when new documents are detected
src/app/components/admin/settings/settings.component.html
- 348
+ 372
Show notifications when document processing completes successfully
src/app/components/admin/settings/settings.component.html
- 349
+ 373
Show notifications when document processing fails
src/app/components/admin/settings/settings.component.html
- 350
+ 374
Suppress notifications on dashboard
src/app/components/admin/settings/settings.component.html
- 351
+ 375
This will suppress all messages about document processing status on the dashboard.
src/app/components/admin/settings/settings.component.html
- 351
+ 375
Cancel
src/app/components/admin/settings/settings.component.html
- 361
+ 385
src/app/components/common/confirm-dialog/confirm-dialog.component.ts
@@ -1550,11 +1572,150 @@
81
+
+ Archive serial number
+
+ src/app/components/admin/settings/settings.component.ts
+ 95
+
+
+ src/app/components/document-detail/document-detail.component.html
+ 150
+
+
+
+ Correspondent
+
+ src/app/components/admin/settings/settings.component.ts
+ 97
+
+
+ src/app/components/document-detail/document-detail.component.html
+ 155
+
+
+ src/app/components/document-list/bulk-editor/bulk-editor.component.html
+ 19
+
+
+ src/app/components/document-list/document-list.component.html
+ 211
+
+
+ src/app/components/document-list/filter-editor/filter-editor.component.html
+ 50
+
+
+ src/app/data/document.ts
+ 46
+
+
+ src/app/data/document.ts
+ 89
+
+
+
+ Document type
+
+ src/app/components/admin/settings/settings.component.ts
+ 98
+
+
+ src/app/components/document-detail/document-detail.component.html
+ 159
+
+
+ src/app/components/document-list/bulk-editor/bulk-editor.component.html
+ 33
+
+
+ src/app/components/document-list/document-list.component.html
+ 251
+
+
+ src/app/components/document-list/filter-editor/filter-editor.component.html
+ 61
+
+
+ src/app/data/document.ts
+ 50
+
+
+ src/app/data/document.ts
+ 91
+
+
+
+ Storage path
+
+ src/app/components/admin/settings/settings.component.ts
+ 99
+
+
+ src/app/components/document-detail/document-detail.component.html
+ 163
+
+
+ src/app/components/document-list/bulk-editor/bulk-editor.component.html
+ 47
+
+
+ src/app/components/document-list/document-list.component.html
+ 260
+
+
+ src/app/components/document-list/filter-editor/filter-editor.component.html
+ 72
+
+
+ src/app/data/document.ts
+ 54
+
+
+
+ Tags
+
+ src/app/components/admin/settings/settings.component.ts
+ 100
+
+
+ src/app/components/app-frame/app-frame.component.html
+ 188
+
+
+ src/app/components/app-frame/app-frame.component.html
+ 191
+
+
+ src/app/components/common/input/tags/tags.component.ts
+ 80
+
+
+ src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html
+ 94
+
+
+ src/app/components/document-list/bulk-editor/bulk-editor.component.html
+ 5
+
+
+ src/app/components/document-list/document-list.component.html
+ 224
+
+
+ src/app/components/document-list/filter-editor/filter-editor.component.html
+ 39
+
+
+ src/app/data/document.ts
+ 42
+
+
Error retrieving users
src/app/components/admin/settings/settings.component.ts
- 226
+ 248
src/app/components/admin/users-groups/users-groups.component.ts
@@ -1565,7 +1726,7 @@
Error retrieving groups
src/app/components/admin/settings/settings.component.ts
- 245
+ 267
src/app/components/admin/users-groups/users-groups.component.ts
@@ -1576,28 +1737,28 @@
Settings were saved successfully.
src/app/components/admin/settings/settings.component.ts
- 548
+ 577
Settings were saved successfully. Reload is required to apply some changes.
src/app/components/admin/settings/settings.component.ts
- 552
+ 581
Reload now
src/app/components/admin/settings/settings.component.ts
- 553
+ 582
An error occurred while saving settings.
src/app/components/admin/settings/settings.component.ts
- 563
+ 592
src/app/components/app-frame/app-frame.component.ts
@@ -2598,11 +2759,11 @@
src/app/components/document-detail/document-detail.component.ts
- 1112
+ 1121
src/app/components/document-detail/document-detail.component.ts
- 1477
+ 1486
src/app/components/document-list/bulk-editor/bulk-editor.component.ts
@@ -2787,41 +2948,6 @@
107
-
- Tags
-
- src/app/components/app-frame/app-frame.component.html
- 188
-
-
- src/app/components/app-frame/app-frame.component.html
- 191
-
-
- src/app/components/common/input/tags/tags.component.ts
- 80
-
-
- src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html
- 94
-
-
- src/app/components/document-list/bulk-editor/bulk-editor.component.html
- 5
-
-
- src/app/components/document-list/document-list.component.html
- 224
-
-
- src/app/components/document-list/filter-editor/filter-editor.component.html
- 39
-
-
- src/app/data/document.ts
- 42
-
-
Document Types
@@ -3228,7 +3354,7 @@
src/app/components/document-detail/document-detail.component.ts
- 1065
+ 1074
src/app/components/document-list/bulk-editor/bulk-editor.component.ts
@@ -3333,7 +3459,7 @@
src/app/components/document-detail/document-detail.component.ts
- 1528
+ 1537
@@ -3344,7 +3470,7 @@
src/app/components/document-detail/document-detail.component.ts
- 1529
+ 1538
@@ -3355,7 +3481,7 @@
src/app/components/document-detail/document-detail.component.ts
- 1530
+ 1539
@@ -4465,7 +4591,7 @@
src/app/components/document-detail/document-detail.component.html
- 331
+ 341
@@ -6991,7 +7117,7 @@
src/app/components/document-detail/document-detail.component.ts
- 1476
+ 1485
@@ -7045,102 +7171,18 @@
90
-
- Archive serial number
-
- src/app/components/document-detail/document-detail.component.html
- 149
-
-
Date created
-
- src/app/components/document-detail/document-detail.component.html
- 150
-
-
-
- Correspondent
src/app/components/document-detail/document-detail.component.html
152
-
- src/app/components/document-list/bulk-editor/bulk-editor.component.html
- 19
-
-
- src/app/components/document-list/document-list.component.html
- 211
-
-
- src/app/components/document-list/filter-editor/filter-editor.component.html
- 50
-
-
- src/app/data/document.ts
- 46
-
-
- src/app/data/document.ts
- 89
-
-
-
- Document type
-
- src/app/components/document-detail/document-detail.component.html
- 154
-
-
- src/app/components/document-list/bulk-editor/bulk-editor.component.html
- 33
-
-
- src/app/components/document-list/document-list.component.html
- 251
-
-
- src/app/components/document-list/filter-editor/filter-editor.component.html
- 61
-
-
- src/app/data/document.ts
- 50
-
-
- src/app/data/document.ts
- 91
-
-
-
- Storage path
-
- src/app/components/document-detail/document-detail.component.html
- 156
-
-
- src/app/components/document-list/bulk-editor/bulk-editor.component.html
- 47
-
-
- src/app/components/document-list/document-list.component.html
- 260
-
-
- src/app/components/document-list/filter-editor/filter-editor.component.html
- 72
-
-
- src/app/data/document.ts
- 54
-
Default
src/app/components/document-detail/document-detail.component.html
- 157
+ 164
src/app/components/manage/saved-views/saved-views.component.html
@@ -7151,14 +7193,14 @@
Content
src/app/components/document-detail/document-detail.component.html
- 261
+ 271
Metadata
src/app/components/document-detail/document-detail.component.html
- 270
+ 280
src/app/components/document-detail/metadata-collapse/metadata-collapse.component.ts
@@ -7169,196 +7211,196 @@
Date modified
src/app/components/document-detail/document-detail.component.html
- 277
+ 287
Date added
src/app/components/document-detail/document-detail.component.html
- 281
+ 291
Media filename
src/app/components/document-detail/document-detail.component.html
- 285
+ 295
Original filename
src/app/components/document-detail/document-detail.component.html
- 289
+ 299
Original MD5 checksum
src/app/components/document-detail/document-detail.component.html
- 293
+ 303
Original file size
src/app/components/document-detail/document-detail.component.html
- 297
+ 307
Original mime type
src/app/components/document-detail/document-detail.component.html
- 301
+ 311
Archive MD5 checksum
src/app/components/document-detail/document-detail.component.html
- 306
+ 316
Archive file size
src/app/components/document-detail/document-detail.component.html
- 312
+ 322
Original document metadata
src/app/components/document-detail/document-detail.component.html
- 321
+ 331
Archived document metadata
src/app/components/document-detail/document-detail.component.html
- 324
+ 334
Notes
src/app/components/document-detail/document-detail.component.html
- 343,346
+ 353,356
History
src/app/components/document-detail/document-detail.component.html
- 354
+ 364
Duplicates
src/app/components/document-detail/document-detail.component.html
- 376,380
+ 386,390
Duplicate documents detected:
src/app/components/document-detail/document-detail.component.html
- 382
+ 392
In trash
src/app/components/document-detail/document-detail.component.html
- 393
+ 403
Save & next
src/app/components/document-detail/document-detail.component.html
- 422
+ 432
Save & close
src/app/components/document-detail/document-detail.component.html
- 425
+ 435
Document loading...
src/app/components/document-detail/document-detail.component.html
- 435
+ 445
Enter Password
src/app/components/document-detail/document-detail.component.html
- 489
+ 499
An error occurred loading content:
src/app/components/document-detail/document-detail.component.ts
- 432,434
+ 441,443
Document changes detected
src/app/components/document-detail/document-detail.component.ts
- 471
+ 480
The version of this document in your browser session appears older than the existing version.
src/app/components/document-detail/document-detail.component.ts
- 472
+ 481
Saving the document here may overwrite other changes that were made. To restore the existing version, discard your changes or close the document.
src/app/components/document-detail/document-detail.component.ts
- 473
+ 482
Ok
src/app/components/document-detail/document-detail.component.ts
- 475
+ 484
Next document
src/app/components/document-detail/document-detail.component.ts
- 601
+ 610
Previous document
src/app/components/document-detail/document-detail.component.ts
- 611
+ 620
Close document
src/app/components/document-detail/document-detail.component.ts
- 619
+ 628
src/app/services/open-documents.service.ts
@@ -7369,67 +7411,67 @@
Save document
src/app/components/document-detail/document-detail.component.ts
- 626
+ 635
Save and close / next
src/app/components/document-detail/document-detail.component.ts
- 635
+ 644
Error retrieving metadata
src/app/components/document-detail/document-detail.component.ts
- 690
+ 699
Error retrieving suggestions.
src/app/components/document-detail/document-detail.component.ts
- 745
+ 754
Document "" saved successfully.
src/app/components/document-detail/document-detail.component.ts
- 954
+ 963
src/app/components/document-detail/document-detail.component.ts
- 978
+ 987
Error saving document ""
src/app/components/document-detail/document-detail.component.ts
- 984
+ 993
Error saving document
src/app/components/document-detail/document-detail.component.ts
- 1034
+ 1043
Do you really want to move the document "" to the trash?
src/app/components/document-detail/document-detail.component.ts
- 1066
+ 1075
Documents can be restored prior to permanent deletion.
src/app/components/document-detail/document-detail.component.ts
- 1067
+ 1076
src/app/components/document-list/bulk-editor/bulk-editor.component.ts
@@ -7440,7 +7482,7 @@
Move to trash
src/app/components/document-detail/document-detail.component.ts
- 1069
+ 1078
src/app/components/document-list/bulk-editor/bulk-editor.component.ts
@@ -7451,14 +7493,14 @@
Error deleting document
src/app/components/document-detail/document-detail.component.ts
- 1088
+ 1097
Reprocess confirm
src/app/components/document-detail/document-detail.component.ts
- 1108
+ 1117
src/app/components/document-list/bulk-editor/bulk-editor.component.ts
@@ -7469,102 +7511,102 @@
This operation will permanently recreate the archive file for this document.
src/app/components/document-detail/document-detail.component.ts
- 1109
+ 1118
The archive file will be re-generated with the current settings.
src/app/components/document-detail/document-detail.component.ts
- 1110
+ 1119
Reprocess operation for "" will begin in the background. Close and re-open or reload this document after the operation has completed to see new content.
src/app/components/document-detail/document-detail.component.ts
- 1120
+ 1129
Error executing operation
src/app/components/document-detail/document-detail.component.ts
- 1131
+ 1140
Error downloading document
src/app/components/document-detail/document-detail.component.ts
- 1180
+ 1189
Page Fit
src/app/components/document-detail/document-detail.component.ts
- 1257
+ 1266
PDF edit operation for "" will begin in the background.
src/app/components/document-detail/document-detail.component.ts
- 1495
+ 1504
Error executing PDF edit operation
src/app/components/document-detail/document-detail.component.ts
- 1507
+ 1516
Please enter the current password before attempting to remove it.
src/app/components/document-detail/document-detail.component.ts
- 1518
+ 1527
Password removal operation for "" will begin in the background.
src/app/components/document-detail/document-detail.component.ts
- 1550
+ 1559
Error executing password removal operation
src/app/components/document-detail/document-detail.component.ts
- 1564
+ 1573
Print failed.
src/app/components/document-detail/document-detail.component.ts
- 1601
+ 1610
Error loading document for printing.
src/app/components/document-detail/document-detail.component.ts
- 1613
+ 1622
An error occurred loading tiff:
src/app/components/document-detail/document-detail.component.ts
- 1678
+ 1687
src/app/components/document-detail/document-detail.component.ts
- 1682
+ 1691
From 50d676c59278354584a9dd7fbdac852d9adaf478 Mon Sep 17 00:00:00 2001
From: Trenton H <797416+stumpylog@users.noreply.github.com>
Date: Tue, 27 Jan 2026 09:01:13 -0800
Subject: [PATCH 3/9] Chore: Upgrade to Pytest 9 (#11898)
---
pyproject.toml | 33 +++++++----
src/documents/tests/test_double_sided.py | 5 +-
uv.lock | 74 ++++++++++++------------
3 files changed, 61 insertions(+), 51 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index 500461199..ac6c39b2b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -114,15 +114,16 @@ testing = [
"daphne",
"factory-boy~=3.3.1",
"imagehash",
- "pytest~=8.4.1",
+ "pytest~=9.0.0",
"pytest-cov~=7.0.0",
"pytest-django~=4.11.1",
- "pytest-env",
+ "pytest-env~=1.2.0",
"pytest-httpx",
- "pytest-mock",
- "pytest-rerunfailures",
+ "pytest-mock~=3.15.1",
+ #"pytest-randomly~=4.0.1",
+ "pytest-rerunfailures~=16.1",
"pytest-sugar",
- "pytest-xdist",
+ "pytest-xdist~=3.8.0",
]
lint = [
@@ -260,11 +261,15 @@ write-changes = true
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish"
skip = "src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/documents/tests/samples/*,*.po,*.json"
-[tool.pytest.ini_options]
-minversion = "8.0"
-pythonpath = [
- "src",
-]
+[tool.pytest]
+minversion = "9.0"
+pythonpath = [ "src" ]
+
+strict_config = true
+strict_markers = true
+strict_parametrization_ids = true
+strict_xfail = true
+
testpaths = [
"src/documents/tests/",
"src/paperless/tests/",
@@ -275,6 +280,7 @@ testpaths = [
"src/paperless_remote/tests/",
"src/paperless_ai/tests",
]
+
addopts = [
"--pythonwarnings=all",
"--cov",
@@ -282,11 +288,14 @@ addopts = [
"--cov-report=xml",
"--numprocesses=auto",
"--maxprocesses=16",
- "--quiet",
+ "--dist=loadscope",
"--durations=50",
+ "--durations-min=0.5",
"--junitxml=junit.xml",
- "-o junit_family=legacy",
+ "-o",
+ "junit_family=legacy",
]
+
norecursedirs = [ "src/locale/", ".venv/", "src-ui/" ]
DJANGO_SETTINGS_MODULE = "paperless.settings"
diff --git a/src/documents/tests/test_double_sided.py b/src/documents/tests/test_double_sided.py
index 5d068b735..32ca5ceab 100644
--- a/src/documents/tests/test_double_sided.py
+++ b/src/documents/tests/test_double_sided.py
@@ -224,17 +224,18 @@ class TestDoubleSided(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
THEN:
- The collated file gets put into foo/bar
"""
+ # TODO: parameterize this instead
for path in [
Path("foo") / "bar" / "double-sided",
Path("double-sided") / "foo" / "bar",
]:
- with self.subTest(path=path):
+ with self.subTest(path=str(path)):
# Ensure we get fresh directories for each run
self.tearDown()
self.setUp()
self.create_staging_file()
- self.consume_file("double-sided-odd.pdf", path / "foo.pdf")
+ self.consume_file("double-sided-odd.pdf", Path(path) / "foo.pdf")
self.assertIsFile(
self.dirs.consumption_dir / "foo" / "bar" / "foo-collated.pdf",
)
diff --git a/uv.lock b/uv.lock
index da7c721f5..960b5aaa3 100644
--- a/uv.lock
+++ b/uv.lock
@@ -3152,15 +3152,15 @@ dev = [
{ name = "mkdocs-material", specifier = "~=9.7.0" },
{ name = "pre-commit", specifier = "~=4.5.1" },
{ name = "pre-commit-uv", specifier = "~=4.2.0" },
- { name = "pytest", specifier = "~=8.4.1" },
+ { name = "pytest", specifier = "~=9.0.0" },
{ name = "pytest-cov", specifier = "~=7.0.0" },
{ name = "pytest-django", specifier = "~=4.11.1" },
- { name = "pytest-env" },
+ { name = "pytest-env", specifier = "~=1.2.0" },
{ name = "pytest-httpx" },
- { name = "pytest-mock" },
- { name = "pytest-rerunfailures" },
+ { name = "pytest-mock", specifier = "~=3.15.1" },
+ { name = "pytest-rerunfailures", specifier = "~=16.1" },
{ name = "pytest-sugar" },
- { name = "pytest-xdist" },
+ { name = "pytest-xdist", specifier = "~=3.8.0" },
{ name = "ruff", specifier = "~=0.14.0" },
]
docs = [
@@ -3176,15 +3176,15 @@ testing = [
{ name = "daphne" },
{ name = "factory-boy", specifier = "~=3.3.1" },
{ name = "imagehash" },
- { name = "pytest", specifier = "~=8.4.1" },
+ { name = "pytest", specifier = "~=9.0.0" },
{ name = "pytest-cov", specifier = "~=7.0.0" },
{ name = "pytest-django", specifier = "~=4.11.1" },
- { name = "pytest-env" },
+ { name = "pytest-env", specifier = "~=1.2.0" },
{ name = "pytest-httpx" },
- { name = "pytest-mock" },
- { name = "pytest-rerunfailures" },
+ { name = "pytest-mock", specifier = "~=3.15.1" },
+ { name = "pytest-rerunfailures", specifier = "~=16.1" },
{ name = "pytest-sugar" },
- { name = "pytest-xdist" },
+ { name = "pytest-xdist", specifier = "~=3.8.0" },
]
typing = [
{ name = "celery-types" },
@@ -3841,7 +3841,7 @@ wheels = [
[[package]]
name = "pytest"
-version = "8.4.2"
+version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "exceptiongroup", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux')" },
@@ -3851,9 +3851,9 @@ dependencies = [
{ name = "pygments", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "tomli", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux')" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
@@ -3897,15 +3897,15 @@ wheels = [
[[package]]
name = "pytest-httpx"
-version = "0.35.0"
+version = "0.36.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/1f/89/5b12b7b29e3d0af3a4b9c071ee92fa25a9017453731a38f08ba01c280f4c/pytest_httpx-0.35.0.tar.gz", hash = "sha256:d619ad5d2e67734abfbb224c3d9025d64795d4b8711116b1a13f72a251ae511f", size = 54146, upload-time = "2024-11-28T19:16:54.237Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/5574834da9499066fa1a5ea9c336f94dba2eae02298d36dab192fcf95c86/pytest_httpx-0.36.0.tar.gz", hash = "sha256:9edb66a5fd4388ce3c343189bc67e7e1cb50b07c2e3fc83b97d511975e8a831b", size = 56793, upload-time = "2025-12-02T16:34:57.414Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b0/ed/026d467c1853dd83102411a78126b4842618e86c895f93528b0528c7a620/pytest_httpx-0.35.0-py3-none-any.whl", hash = "sha256:ee11a00ffcea94a5cbff47af2114d34c5b231c326902458deed73f9c459fd744", size = 19442, upload-time = "2024-11-28T19:16:52.787Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/d2/1eb1ea9c84f0d2033eb0b49675afdc71aa4ea801b74615f00f3c33b725e3/pytest_httpx-0.36.0-py3-none-any.whl", hash = "sha256:bd4c120bb80e142df856e825ec9f17981effb84d159f9fa29ed97e2357c3a9c8", size = 20229, upload-time = "2025-12-02T16:34:56.45Z" },
]
[[package]]
@@ -5108,13 +5108,13 @@ dependencies = [
{ name = "typing-extensions", marker = "sys_platform == 'darwin'" },
]
wheels = [
- { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp310-none-macosx_11_0_arm64.whl" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp311-none-macosx_11_0_arm64.whl" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp312-none-macosx_11_0_arm64.whl" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp313-cp313t-macosx_11_0_arm64.whl" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp313-none-macosx_11_0_arm64.whl" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp314-cp314-macosx_11_0_arm64.whl" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp314-cp314t-macosx_11_0_arm64.whl" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp310-none-macosx_11_0_arm64.whl", hash = "sha256:bf1e68cfb935ae2046374ff02a7aa73dda70351b46342846f557055b3a540bf0" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:a52952a8c90a422c14627ea99b9826b7557203b46b4d0772d3ca5c7699692425" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:287242dd1f830846098b5eca847f817aa5c6015ea57ab4c1287809efea7b77eb" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8924d10d36eac8fe0652a060a03fc2ae52980841850b9a1a2ddb0f27a4f181cd" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:bcee64ae7aa65876ceeae6dcaebe75109485b213528c74939602208a20706e3f" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:defadbeb055cfcf5def58f70937145aecbd7a4bc295238ded1d0e85ae2cf0e1d" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:886f84b181f766f53265ba0a1d503011e60f53fff9d569563ef94f24160e1072" },
]
[[package]]
@@ -5138,20 +5138,20 @@ dependencies = [
{ name = "typing-extensions", marker = "sys_platform == 'linux'" },
]
wheels = [
- { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp310-cp310-manylinux_2_28_aarch64.whl" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp310-cp310-manylinux_2_28_x86_64.whl" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp311-cp311-manylinux_2_28_aarch64.whl" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:10866c8a48c4aa5ae3f48538dc8a055b99c57d9c6af2bf5dd715374d9d6ddca3" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:7210713b66943fdbfcc237b2e782871b649123ac5d29f548ce8c85be4223ab38" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:0e611cfb16724e62252b67d31073bc5c490cb83e92ecdc1192762535e0e44487" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:3de2adb9b4443dc9210ef1f1b16da3647ace53553166d6360bbbd7edd6f16e4d" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3bf9b442a51a2948e41216a76d7ab00f0694cfcaaa51b6f9bcab57b7f89843e6" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:7417d8c565f219d3455654cb431c6d892a3eb40246055e14d645422de13b9ea1" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:3e532e553b37ee859205a9b2d1c7977fd6922f53bbb1b9bfdd5bdc00d1a60ed4" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:39b3dff6d8fba240ae0d1bede4ca11c2531ae3b47329206512d99e17907ff74b" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:01b1884f724977a20c7da2f640f1c7b37f4a2c117a7f4a6c1c0424d14cb86322" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:031a597147fa81b1e6d79ccf1ad3ccc7fafa27941d6cf26ff5caaa384fb20e92" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:65010ab4aacce6c9a1ddfc935f986c003ca8638ded04348fd326c3e74346237c" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:88adf5157db5da1d54b1c9fe4a6c1d20ceef00e75d854e206a87dbf69e3037dc" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:3ac2b8df2c55430e836dcda31940d47f1f5f94b8731057b6f20300ebea394dd9" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:5b688445f928f13563b7418b17c57e97bf955ab559cf73cd8f2b961f8572dbb3" },
]
[[package]]
From 1f074390e4e3ef73eb182795d91c084b090d7863 Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Tue, 27 Jan 2026 10:54:51 -0800
Subject: [PATCH 4/9] Feature: sharelink bundles (#11682)
---
docs/configuration.md | 10 +
docs/usage.md | 6 +-
.../share-link-bundle-dialog.component.html | 129 +++++
.../share-link-bundle-dialog.component.scss | 0
...share-link-bundle-dialog.component.spec.ts | 161 ++++++
.../share-link-bundle-dialog.component.ts | 118 ++++
...e-link-bundle-manage-dialog.component.html | 156 +++++
...e-link-bundle-manage-dialog.component.scss | 4 +
...ink-bundle-manage-dialog.component.spec.ts | 251 ++++++++
...are-link-bundle-manage-dialog.component.ts | 177 ++++++
.../share-links-dialog.component.html | 2 +-
.../share-links-dialog.component.ts | 13 +-
.../bulk-editor/bulk-editor.component.html | 32 +-
.../bulk-editor/bulk-editor.component.spec.ts | 141 +++++
.../bulk-editor/bulk-editor.component.ts | 56 ++
src-ui/src/app/data/share-link-bundle.ts | 53 ++
src-ui/src/app/data/share-link.ts | 12 +
.../rest/share-link-bundle.service.spec.ts | 60 ++
.../rest/share-link-bundle.service.ts | 41 ++
src/documents/admin.py | 18 +
src/documents/filters.py | 24 +
.../migrations/0008_sharelinkbundle.py | 177 ++++++
src/documents/models.py | 108 ++++
src/documents/serialisers.py | 101 ++++
src/documents/tasks.py | 120 ++++
.../tests/test_migration_share_link_bundle.py | 51 ++
.../tests/test_share_link_bundles.py | 536 ++++++++++++++++++
src/documents/views.py | 186 +++++-
src/paperless/settings.py | 12 +
src/paperless/tests/test_settings.py | 23 +
src/paperless/urls.py | 2 +
31 files changed, 2758 insertions(+), 22 deletions(-)
create mode 100644 src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html
create mode 100644 src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.scss
create mode 100644 src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.spec.ts
create mode 100644 src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.ts
create mode 100644 src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html
create mode 100644 src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.scss
create mode 100644 src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.spec.ts
create mode 100644 src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.ts
create mode 100644 src-ui/src/app/data/share-link-bundle.ts
create mode 100644 src-ui/src/app/services/rest/share-link-bundle.service.spec.ts
create mode 100644 src-ui/src/app/services/rest/share-link-bundle.service.ts
create mode 100644 src/documents/migrations/0008_sharelinkbundle.py
create mode 100644 src/documents/tests/test_migration_share_link_bundle.py
create mode 100644 src/documents/tests/test_share_link_bundles.py
diff --git a/docs/configuration.md b/docs/configuration.md
index 41d43d424..ef252ad4a 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -1617,6 +1617,16 @@ processing. This only has an effect if
Defaults to `0 1 * * *`, once per day.
+## Share links
+
+#### [`PAPERLESS_SHARE_LINK_BUNDLE_CLEANUP_CRON=`](#PAPERLESS_SHARE_LINK_BUNDLE_CLEANUP_CRON) {#PAPERLESS_SHARE_LINK_BUNDLE_CLEANUP_CRON}
+
+: Controls how often Paperless-ngx removes expired share link bundles (and their generated ZIP archives).
+
+: If set to the string "disable", expired bundles are not cleaned up automatically.
+
+ Defaults to `0 2 * * *`, once per day at 02:00.
+
## Binaries
There are a few external software packages that Paperless expects to
diff --git a/docs/usage.md b/docs/usage.md
index 7da83a3e1..f652164da 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -308,12 +308,14 @@ or using [email](#workflow-action-email) or [webhook](#workflow-action-webhook)
### Share Links
-"Share links" are shareable public links to files and can be created and managed under the 'Send' button on the document detail screen.
+"Share links" are public links to files (or an archive of files) and can be created and managed under the 'Send' button on the document detail screen or from the bulk editor.
-- Share links do not require a user to login and thus link directly to a file.
+- Share links do not require a user to login and thus link directly to a file or bundled download.
- Links are unique and are of the form `{paperless-url}/share/{randomly-generated-slug}`.
- Links can optionally have an expiration time set.
- After a link expires or is deleted users will be redirected to the regular paperless-ngx login.
+- From the document detail screen you can create a share link for that single document.
+- From the bulk editor you can create a **share link bundle** for any selection. Paperless-ngx prepares a ZIP archive in the background and exposes a single share link. You can revisit the "Manage share link bundles" dialog to monitor progress, retry failed bundles, or delete links.
!!! tip
diff --git a/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html b/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html
new file mode 100644
index 000000000..b7fed28e1
--- /dev/null
+++ b/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html
@@ -0,0 +1,129 @@
+
+
+ @if (!createdBundle) {
+
+ } @else {
+
+
+
Share link bundle requested
+
+ You can copy the share link below or open the manager to monitor progress. The link will start working once the bundle is ready.
+
+
+
+ - Status
+ -
+ {{ statusLabel(createdBundle.status) }}
+
+ - Slug
+ {{ createdBundle.slug }}
+ - Link
+ -
+
+
+
+
+
+ - Documents
+ - {{ createdBundle.document_count }}
+ - Expires
+ -
+ @if (createdBundle.expiration) {
+ {{ createdBundle.expiration | date: 'short' }}
+ }
+ @if (!createdBundle.expiration) {
+ Never
+ }
+
+ - File version
+ - {{ fileVersionLabel(createdBundle.file_version) }}
+ @if (createdBundle.size_bytes !== undefined && createdBundle.size_bytes !== null) {
+ - Size
+ - {{ createdBundle.size_bytes | fileSize }}
+ }
+
+
+ }
+
+
diff --git a/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.scss b/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.scss
new file mode 100644
index 000000000..e69de29bb
diff --git a/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.spec.ts b/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.spec.ts
new file mode 100644
index 000000000..da4d93c6a
--- /dev/null
+++ b/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.spec.ts
@@ -0,0 +1,161 @@
+import { Clipboard } from '@angular/cdk/clipboard'
+import {
+ ComponentFixture,
+ TestBed,
+ fakeAsync,
+ tick,
+} from '@angular/core/testing'
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
+import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
+import { FileVersion } from 'src/app/data/share-link'
+import {
+ ShareLinkBundleStatus,
+ ShareLinkBundleSummary,
+} from 'src/app/data/share-link-bundle'
+import { ToastService } from 'src/app/services/toast.service'
+import { environment } from 'src/environments/environment'
+import { ShareLinkBundleDialogComponent } from './share-link-bundle-dialog.component'
+
+class MockToastService {
+ showInfo = jest.fn()
+ showError = jest.fn()
+}
+
+describe('ShareLinkBundleDialogComponent', () => {
+ let component: ShareLinkBundleDialogComponent
+ let fixture: ComponentFixture
+ let clipboard: Clipboard
+ let toastService: MockToastService
+ let activeModal: NgbActiveModal
+ let originalApiBaseUrl: string
+
+ beforeEach(() => {
+ originalApiBaseUrl = environment.apiBaseUrl
+ toastService = new MockToastService()
+
+ TestBed.configureTestingModule({
+ imports: [
+ ShareLinkBundleDialogComponent,
+ NgxBootstrapIconsModule.pick(allIcons),
+ ],
+ providers: [
+ NgbActiveModal,
+ { provide: ToastService, useValue: toastService },
+ ],
+ })
+
+ fixture = TestBed.createComponent(ShareLinkBundleDialogComponent)
+ component = fixture.componentInstance
+ clipboard = TestBed.inject(Clipboard)
+ activeModal = TestBed.inject(NgbActiveModal)
+ fixture.detectChanges()
+ })
+
+ afterEach(() => {
+ jest.clearAllTimers()
+ environment.apiBaseUrl = originalApiBaseUrl
+ })
+
+ it('builds payload and emits confirm on submit', () => {
+ const confirmSpy = jest.spyOn(component.confirmClicked, 'emit')
+ component.documents = [
+ { id: 1, title: 'Doc 1' } as any,
+ { id: 2, title: 'Doc 2' } as any,
+ ]
+ component.form.setValue({
+ shareArchiveVersion: false,
+ expirationDays: 3,
+ })
+
+ component.submit()
+
+ expect(component.payload).toEqual({
+ document_ids: [1, 2],
+ file_version: FileVersion.Original,
+ expiration_days: 3,
+ })
+ expect(component.buttonsEnabled).toBe(false)
+ expect(confirmSpy).toHaveBeenCalled()
+
+ component.form.setValue({
+ shareArchiveVersion: true,
+ expirationDays: 7,
+ })
+ component.submit()
+
+ expect(component.payload).toEqual({
+ document_ids: [1, 2],
+ file_version: FileVersion.Archive,
+ expiration_days: 7,
+ })
+ })
+
+ it('ignores submit when bundle already created', () => {
+ component.createdBundle = { id: 1 } as ShareLinkBundleSummary
+ const confirmSpy = jest.spyOn(component, 'confirm')
+ component.submit()
+ expect(confirmSpy).not.toHaveBeenCalled()
+ })
+
+ it('limits preview to ten documents', () => {
+ const docs = Array.from({ length: 12 }).map((_, index) => ({
+ id: index + 1,
+ }))
+ component.documents = docs as any
+
+ expect(component.selectionCount).toBe(12)
+ expect(component.documentPreview).toHaveLength(10)
+ expect(component.documentPreview[0].id).toBe(1)
+ })
+
+ it('copies share link and resets state after timeout', fakeAsync(() => {
+ const copySpy = jest.spyOn(clipboard, 'copy').mockReturnValue(true)
+ const bundle = {
+ slug: 'bundle-slug',
+ status: ShareLinkBundleStatus.Ready,
+ } as ShareLinkBundleSummary
+
+ component.copy(bundle)
+
+ expect(copySpy).toHaveBeenCalledWith(component.getShareUrl(bundle))
+ expect(component.copied).toBe(true)
+ expect(toastService.showInfo).toHaveBeenCalled()
+
+ tick(3000)
+ expect(component.copied).toBe(false)
+ }))
+
+ it('generates share URLs based on API base URL', () => {
+ environment.apiBaseUrl = 'https://example.com/api/'
+ expect(
+ component.getShareUrl({ slug: 'abc' } as ShareLinkBundleSummary)
+ ).toBe('https://example.com/share/abc')
+ })
+
+ it('opens manage dialog when callback provided', () => {
+ const manageSpy = jest.fn()
+ component.onOpenManage = manageSpy
+ component.openManage()
+ expect(manageSpy).toHaveBeenCalled()
+ })
+
+ it('falls back to cancel when manage callback missing', () => {
+ const cancelSpy = jest.spyOn(component, 'cancel')
+ component.onOpenManage = undefined
+ component.openManage()
+ expect(cancelSpy).toHaveBeenCalled()
+ })
+
+ it('maps status and file version labels', () => {
+ expect(component.statusLabel(ShareLinkBundleStatus.Processing)).toContain(
+ 'Processing'
+ )
+ expect(component.fileVersionLabel(FileVersion.Archive)).toContain('Archive')
+ })
+
+ it('closes dialog when cancel invoked', () => {
+ const closeSpy = jest.spyOn(activeModal, 'close')
+ component.cancel()
+ expect(closeSpy).toHaveBeenCalled()
+ })
+})
diff --git a/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.ts b/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.ts
new file mode 100644
index 000000000..37aa70950
--- /dev/null
+++ b/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.ts
@@ -0,0 +1,118 @@
+import { Clipboard } from '@angular/cdk/clipboard'
+import { CommonModule } from '@angular/common'
+import { Component, Input, inject } from '@angular/core'
+import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'
+import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
+import { Document } from 'src/app/data/document'
+import {
+ FileVersion,
+ SHARE_LINK_EXPIRATION_OPTIONS,
+} from 'src/app/data/share-link'
+import {
+ SHARE_LINK_BUNDLE_FILE_VERSION_LABELS,
+ SHARE_LINK_BUNDLE_STATUS_LABELS,
+ ShareLinkBundleCreatePayload,
+ ShareLinkBundleStatus,
+ ShareLinkBundleSummary,
+} from 'src/app/data/share-link-bundle'
+import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
+import { FileSizePipe } from 'src/app/pipes/file-size.pipe'
+import { ToastService } from 'src/app/services/toast.service'
+import { environment } from 'src/environments/environment'
+import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component'
+
+@Component({
+ selector: 'pngx-share-link-bundle-dialog',
+ templateUrl: './share-link-bundle-dialog.component.html',
+ imports: [
+ CommonModule,
+ ReactiveFormsModule,
+ NgxBootstrapIconsModule,
+ FileSizePipe,
+ DocumentTitlePipe,
+ ],
+ providers: [],
+})
+export class ShareLinkBundleDialogComponent extends ConfirmDialogComponent {
+ private readonly formBuilder = inject(FormBuilder)
+ private readonly clipboard = inject(Clipboard)
+ private readonly toastService = inject(ToastService)
+
+ private _documents: Document[] = []
+
+ selectionCount = 0
+ documentPreview: Document[] = []
+ form: FormGroup = this.formBuilder.group({
+ shareArchiveVersion: true,
+ expirationDays: [7],
+ })
+ payload: ShareLinkBundleCreatePayload | null = null
+
+ readonly expirationOptions = SHARE_LINK_EXPIRATION_OPTIONS
+
+ createdBundle: ShareLinkBundleSummary | null = null
+ copied = false
+ onOpenManage?: () => void
+ readonly statuses = ShareLinkBundleStatus
+
+ constructor() {
+ super()
+ this.loading = false
+ this.title = $localize`Create share link bundle`
+ this.btnCaption = $localize`Create link`
+ }
+
+ @Input()
+ set documents(docs: Document[]) {
+ this._documents = docs.concat()
+ this.selectionCount = this._documents.length
+ this.documentPreview = this._documents.slice(0, 10)
+ }
+
+ submit() {
+ if (this.createdBundle) return
+ this.payload = {
+ document_ids: this._documents.map((doc) => doc.id),
+ file_version: this.form.value.shareArchiveVersion
+ ? FileVersion.Archive
+ : FileVersion.Original,
+ expiration_days: this.form.value.expirationDays,
+ }
+ this.buttonsEnabled = false
+ super.confirm()
+ }
+
+ getShareUrl(bundle: ShareLinkBundleSummary): string {
+ const apiURL = new URL(environment.apiBaseUrl)
+ return `${apiURL.origin}${apiURL.pathname.replace(/\/api\/$/, '/share/')}${
+ bundle.slug
+ }`
+ }
+
+ copy(bundle: ShareLinkBundleSummary): void {
+ const success = this.clipboard.copy(this.getShareUrl(bundle))
+ if (success) {
+ this.copied = true
+ this.toastService.showInfo($localize`Share link copied to clipboard.`)
+ setTimeout(() => {
+ this.copied = false
+ }, 3000)
+ }
+ }
+
+ openManage(): void {
+ if (this.onOpenManage) {
+ this.onOpenManage()
+ } else {
+ this.cancel()
+ }
+ }
+
+ statusLabel(status: ShareLinkBundleSummary['status']): string {
+ return SHARE_LINK_BUNDLE_STATUS_LABELS[status] ?? status
+ }
+
+ fileVersionLabel(version: FileVersion): string {
+ return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version
+ }
+}
diff --git a/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html b/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html
new file mode 100644
index 000000000..2f2155412
--- /dev/null
+++ b/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html
@@ -0,0 +1,156 @@
+
+
+
+ @if (loading) {
+
+
+
Loading share link bundles…
+
+ }
+ @if (!loading && error) {
+
+ {{ error }}
+
+ }
+ @if (!loading && !error) {
+
+
+ Status updates every few seconds while bundles are being prepared.
+
+
+ @if (bundles.length === 0) {
+
No share link bundles currently exist.
+ }
+ @if (bundles.length > 0) {
+
+
+
+
+ | Created |
+ Status |
+ Size |
+ Expires |
+ Documents |
+ File version |
+ Actions |
+
+
+
+ @for (bundle of bundles; track bundle.id) {
+
+ |
+ {{ bundle.created | date: 'short' }}
+ @if (bundle.built_at) {
+
+ Built: {{ bundle.built_at | date: 'short' }}
+
+ }
+ |
+
+
+ @if (bundle.status === statuses.Failed && bundle.last_error) {
+
+
+ @if (bundle.last_error.timestamp) {
+
+ {{ bundle.last_error.timestamp | date: 'short' }}
+
+ }
+ {{ bundle.last_error.exception_type || ($localize`Unknown error`) }}
+ @if (bundle.last_error.message) {
+ {{ bundle.last_error.message }}
+ }
+
+ }
+ @if (bundle.status === statuses.Processing || bundle.status === statuses.Pending) {
+
+ }
+ @if (bundle.status !== statuses.Failed) {
+ {{ statusLabel(bundle.status) }}
+ }
+
+ |
+
+ @if (bundle.size_bytes !== undefined && bundle.size_bytes !== null) {
+ {{ bundle.size_bytes | fileSize }}
+ }
+ @if (bundle.size_bytes === undefined || bundle.size_bytes === null) {
+ —
+ }
+ |
+
+ @if (bundle.expiration) {
+ {{ bundle.expiration | date: 'short' }}
+ }
+ @if (!bundle.expiration) {
+ Never
+ }
+ |
+ {{ bundle.document_count }} |
+ {{ fileVersionLabel(bundle.file_version) }} |
+
+
+
+ @if (bundle.status === statuses.Failed) {
+
+ }
+
+ Delete share link bundle
+
+
+ |
+
+ }
+
+
+
+ }
+ }
+
+
+
diff --git a/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.scss b/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.scss
new file mode 100644
index 000000000..c8ffc4d5d
--- /dev/null
+++ b/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.scss
@@ -0,0 +1,4 @@
+:host ::ng-deep .popover {
+ min-width: 300px;
+ max-width: 400px;
+ }
diff --git a/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.spec.ts b/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.spec.ts
new file mode 100644
index 000000000..113cd65a3
--- /dev/null
+++ b/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.spec.ts
@@ -0,0 +1,251 @@
+import { Clipboard } from '@angular/cdk/clipboard'
+import {
+ ComponentFixture,
+ TestBed,
+ fakeAsync,
+ tick,
+} from '@angular/core/testing'
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
+import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
+import { of, throwError } from 'rxjs'
+import { FileVersion } from 'src/app/data/share-link'
+import {
+ ShareLinkBundleStatus,
+ ShareLinkBundleSummary,
+} from 'src/app/data/share-link-bundle'
+import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
+import { ToastService } from 'src/app/services/toast.service'
+import { environment } from 'src/environments/environment'
+import { ShareLinkBundleManageDialogComponent } from './share-link-bundle-manage-dialog.component'
+
+class MockShareLinkBundleService {
+ listAllBundles = jest.fn()
+ delete = jest.fn()
+ rebuildBundle = jest.fn()
+}
+
+class MockToastService {
+ showInfo = jest.fn()
+ showError = jest.fn()
+}
+
+describe('ShareLinkBundleManageDialogComponent', () => {
+ let component: ShareLinkBundleManageDialogComponent
+ let fixture: ComponentFixture
+ let service: MockShareLinkBundleService
+ let toastService: MockToastService
+ let clipboard: Clipboard
+ let activeModal: NgbActiveModal
+ let originalApiBaseUrl: string
+
+ beforeEach(() => {
+ service = new MockShareLinkBundleService()
+ toastService = new MockToastService()
+ originalApiBaseUrl = environment.apiBaseUrl
+
+ service.listAllBundles.mockReturnValue(of([]))
+ service.delete.mockReturnValue(of(true))
+ service.rebuildBundle.mockReturnValue(of(sampleBundle()))
+
+ TestBed.configureTestingModule({
+ imports: [
+ ShareLinkBundleManageDialogComponent,
+ NgxBootstrapIconsModule.pick(allIcons),
+ ],
+ providers: [
+ NgbActiveModal,
+ { provide: ShareLinkBundleService, useValue: service },
+ { provide: ToastService, useValue: toastService },
+ ],
+ })
+
+ fixture = TestBed.createComponent(ShareLinkBundleManageDialogComponent)
+ component = fixture.componentInstance
+ clipboard = TestBed.inject(Clipboard)
+ activeModal = TestBed.inject(NgbActiveModal)
+ })
+
+ afterEach(() => {
+ component.ngOnDestroy()
+ fixture.destroy()
+ environment.apiBaseUrl = originalApiBaseUrl
+ jest.clearAllMocks()
+ })
+
+ const sampleBundle = (overrides: Partial = {}) =>
+ ({
+ id: 1,
+ slug: 'bundle-slug',
+ created: new Date().toISOString(),
+ document_count: 1,
+ documents: [1],
+ status: ShareLinkBundleStatus.Pending,
+ file_version: FileVersion.Archive,
+ last_error: undefined,
+ ...overrides,
+ }) as ShareLinkBundleSummary
+
+ it('loads bundles on init and polls periodically', fakeAsync(() => {
+ const bundles = [sampleBundle({ status: ShareLinkBundleStatus.Ready })]
+ service.listAllBundles.mockReset()
+ service.listAllBundles
+ .mockReturnValueOnce(of(bundles))
+ .mockReturnValue(of(bundles))
+
+ fixture.detectChanges()
+ tick()
+
+ expect(service.listAllBundles).toHaveBeenCalledTimes(1)
+ expect(component.bundles).toEqual(bundles)
+ expect(component.loading).toBe(false)
+ expect(component.error).toBeNull()
+
+ tick(5000)
+ expect(service.listAllBundles).toHaveBeenCalledTimes(2)
+ }))
+
+ it('handles errors when loading bundles', fakeAsync(() => {
+ service.listAllBundles.mockReset()
+ service.listAllBundles
+ .mockReturnValueOnce(throwError(() => new Error('load fail')))
+ .mockReturnValue(of([]))
+
+ fixture.detectChanges()
+ tick()
+
+ expect(component.error).toContain('Failed to load share link bundles.')
+ expect(toastService.showError).toHaveBeenCalled()
+ expect(component.loading).toBe(false)
+
+ tick(5000)
+ expect(service.listAllBundles).toHaveBeenCalledTimes(2)
+ }))
+
+ it('copies bundle links when ready', fakeAsync(() => {
+ jest.spyOn(clipboard, 'copy').mockReturnValue(true)
+ fixture.detectChanges()
+ tick()
+
+ const readyBundle = sampleBundle({
+ slug: 'ready-slug',
+ status: ShareLinkBundleStatus.Ready,
+ })
+ component.copy(readyBundle)
+
+ expect(clipboard.copy).toHaveBeenCalledWith(
+ component.getShareUrl(readyBundle)
+ )
+ expect(component.copiedSlug).toBe('ready-slug')
+ expect(toastService.showInfo).toHaveBeenCalled()
+
+ tick(3000)
+ expect(component.copiedSlug).toBeNull()
+ }))
+
+ it('ignores copy requests for non-ready bundles', fakeAsync(() => {
+ const copySpy = jest.spyOn(clipboard, 'copy')
+ fixture.detectChanges()
+ tick()
+ component.copy(sampleBundle({ status: ShareLinkBundleStatus.Pending }))
+ expect(copySpy).not.toHaveBeenCalled()
+ }))
+
+ it('deletes bundles and refreshes list', fakeAsync(() => {
+ service.listAllBundles.mockReturnValue(of([]))
+ service.delete.mockReturnValue(of(true))
+
+ fixture.detectChanges()
+ tick()
+
+ component.delete(sampleBundle())
+ tick()
+
+ expect(service.delete).toHaveBeenCalled()
+ expect(toastService.showInfo).toHaveBeenCalledWith(
+ expect.stringContaining('deleted.')
+ )
+ expect(service.listAllBundles).toHaveBeenCalledTimes(2)
+ expect(component.loading).toBe(false)
+ }))
+
+ it('handles delete errors gracefully', fakeAsync(() => {
+ service.listAllBundles.mockReturnValue(of([]))
+ service.delete.mockReturnValue(throwError(() => new Error('delete fail')))
+
+ fixture.detectChanges()
+ tick()
+
+ component.delete(sampleBundle())
+ tick()
+
+ expect(toastService.showError).toHaveBeenCalled()
+ expect(component.loading).toBe(false)
+ }))
+
+ it('retries bundle build and replaces existing entry', fakeAsync(() => {
+ service.listAllBundles.mockReturnValue(of([]))
+ const updated = sampleBundle({ status: ShareLinkBundleStatus.Ready })
+ service.rebuildBundle.mockReturnValue(of(updated))
+
+ fixture.detectChanges()
+ tick()
+
+ component.bundles = [sampleBundle()]
+ component.retry(component.bundles[0])
+ tick()
+
+ expect(service.rebuildBundle).toHaveBeenCalledWith(updated.id)
+ expect(component.bundles[0].status).toBe(ShareLinkBundleStatus.Ready)
+ expect(toastService.showInfo).toHaveBeenCalled()
+ }))
+
+ it('adds new bundle when retry returns unknown entry', fakeAsync(() => {
+ service.listAllBundles.mockReturnValue(of([]))
+ service.rebuildBundle.mockReturnValue(
+ of(sampleBundle({ id: 99, slug: 'new-slug' }))
+ )
+
+ fixture.detectChanges()
+ tick()
+
+ component.bundles = [sampleBundle()]
+ component.retry({ id: 99 } as ShareLinkBundleSummary)
+ tick()
+
+ expect(component.bundles.find((bundle) => bundle.id === 99)).toBeTruthy()
+ }))
+
+ it('handles retry errors', fakeAsync(() => {
+ service.listAllBundles.mockReturnValue(of([]))
+ service.rebuildBundle.mockReturnValue(throwError(() => new Error('fail')))
+
+ fixture.detectChanges()
+ tick()
+
+ component.retry(sampleBundle())
+ tick()
+
+ expect(toastService.showError).toHaveBeenCalled()
+ }))
+
+ it('maps helpers and closes dialog', fakeAsync(() => {
+ service.listAllBundles.mockReturnValue(of([]))
+ fixture.detectChanges()
+ tick()
+
+ expect(component.statusLabel(ShareLinkBundleStatus.Processing)).toContain(
+ 'Processing'
+ )
+ expect(component.fileVersionLabel(FileVersion.Original)).toContain(
+ 'Original'
+ )
+
+ environment.apiBaseUrl = 'https://example.com/api/'
+ const url = component.getShareUrl(sampleBundle({ slug: 'sluggy' }))
+ expect(url).toBe('https://example.com/share/sluggy')
+
+ const closeSpy = jest.spyOn(activeModal, 'close')
+ component.close()
+ expect(closeSpy).toHaveBeenCalled()
+ }))
+})
diff --git a/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.ts b/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.ts
new file mode 100644
index 000000000..6eef144f9
--- /dev/null
+++ b/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.ts
@@ -0,0 +1,177 @@
+import { Clipboard } from '@angular/cdk/clipboard'
+import { CommonModule } from '@angular/common'
+import { Component, OnDestroy, OnInit, inject } from '@angular/core'
+import { NgbActiveModal, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
+import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
+import { Subject, catchError, of, switchMap, takeUntil, timer } from 'rxjs'
+import { FileVersion } from 'src/app/data/share-link'
+import {
+ SHARE_LINK_BUNDLE_FILE_VERSION_LABELS,
+ SHARE_LINK_BUNDLE_STATUS_LABELS,
+ ShareLinkBundleStatus,
+ ShareLinkBundleSummary,
+} from 'src/app/data/share-link-bundle'
+import { FileSizePipe } from 'src/app/pipes/file-size.pipe'
+import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
+import { ToastService } from 'src/app/services/toast.service'
+import { environment } from 'src/environments/environment'
+import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
+import { ConfirmButtonComponent } from '../confirm-button/confirm-button.component'
+
+@Component({
+ selector: 'pngx-share-link-bundle-manage-dialog',
+ templateUrl: './share-link-bundle-manage-dialog.component.html',
+ styleUrls: ['./share-link-bundle-manage-dialog.component.scss'],
+ imports: [
+ ConfirmButtonComponent,
+ CommonModule,
+ NgbPopoverModule,
+ NgxBootstrapIconsModule,
+ FileSizePipe,
+ ],
+})
+export class ShareLinkBundleManageDialogComponent
+ extends LoadingComponentWithPermissions
+ implements OnInit, OnDestroy
+{
+ private readonly activeModal = inject(NgbActiveModal)
+ private readonly shareLinkBundleService = inject(ShareLinkBundleService)
+ private readonly toastService = inject(ToastService)
+ private readonly clipboard = inject(Clipboard)
+
+ title = $localize`Share link bundles`
+
+ bundles: ShareLinkBundleSummary[] = []
+ error: string | null = null
+ copiedSlug: string | null = null
+
+ readonly statuses = ShareLinkBundleStatus
+ readonly fileVersions = FileVersion
+
+ private readonly refresh$ = new Subject()
+
+ ngOnInit(): void {
+ this.refresh$
+ .pipe(
+ switchMap((silent) => {
+ if (!silent) {
+ this.loading = true
+ }
+ this.error = null
+ return this.shareLinkBundleService.listAllBundles().pipe(
+ catchError((error) => {
+ if (!silent) {
+ this.loading = false
+ }
+ this.error = $localize`Failed to load share link bundles.`
+ this.toastService.showError(
+ $localize`Error retrieving share link bundles.`,
+ error
+ )
+ return of(null)
+ })
+ )
+ }),
+ takeUntil(this.unsubscribeNotifier)
+ )
+ .subscribe((results) => {
+ if (results) {
+ this.bundles = results
+ this.copiedSlug = null
+ }
+ this.loading = false
+ })
+
+ this.triggerRefresh(false)
+ timer(5000, 5000)
+ .pipe(takeUntil(this.unsubscribeNotifier))
+ .subscribe(() => this.triggerRefresh(true))
+ }
+
+ ngOnDestroy(): void {
+ super.ngOnDestroy()
+ }
+
+ getShareUrl(bundle: ShareLinkBundleSummary): string {
+ const apiURL = new URL(environment.apiBaseUrl)
+ return `${apiURL.origin}${apiURL.pathname.replace(/\/api\/$/, '/share/')}${
+ bundle.slug
+ }`
+ }
+
+ copy(bundle: ShareLinkBundleSummary): void {
+ if (bundle.status !== ShareLinkBundleStatus.Ready) {
+ return
+ }
+ const success = this.clipboard.copy(this.getShareUrl(bundle))
+ if (success) {
+ this.copiedSlug = bundle.slug
+ setTimeout(() => {
+ this.copiedSlug = null
+ }, 3000)
+ this.toastService.showInfo($localize`Share link copied to clipboard.`)
+ }
+ }
+
+ delete(bundle: ShareLinkBundleSummary): void {
+ this.error = null
+ this.loading = true
+ this.shareLinkBundleService.delete(bundle).subscribe({
+ next: () => {
+ this.toastService.showInfo($localize`Share link bundle deleted.`)
+ this.triggerRefresh(false)
+ },
+ error: (e) => {
+ this.loading = false
+ this.toastService.showError(
+ $localize`Error deleting share link bundle.`,
+ e
+ )
+ },
+ })
+ }
+
+ retry(bundle: ShareLinkBundleSummary): void {
+ this.error = null
+ this.shareLinkBundleService.rebuildBundle(bundle.id).subscribe({
+ next: (updated) => {
+ this.toastService.showInfo(
+ $localize`Share link bundle rebuild requested.`
+ )
+ this.replaceBundle(updated)
+ },
+ error: (e) => {
+ this.toastService.showError($localize`Error requesting rebuild.`, e)
+ },
+ })
+ }
+
+ statusLabel(status: ShareLinkBundleStatus): string {
+ return SHARE_LINK_BUNDLE_STATUS_LABELS[status] ?? status
+ }
+
+ fileVersionLabel(version: FileVersion): string {
+ return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version
+ }
+
+ close(): void {
+ this.activeModal.close()
+ }
+
+ private replaceBundle(updated: ShareLinkBundleSummary): void {
+ const index = this.bundles.findIndex((bundle) => bundle.id === updated.id)
+ if (index >= 0) {
+ this.bundles = [
+ ...this.bundles.slice(0, index),
+ updated,
+ ...this.bundles.slice(index + 1),
+ ]
+ } else {
+ this.bundles = [updated, ...this.bundles]
+ }
+ }
+
+ private triggerRefresh(silent: boolean): void {
+ this.refresh$.next(silent)
+ }
+}
diff --git a/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.html b/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.html
index fe3f9b9c3..e41a897a8 100644
--- a/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.html
+++ b/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.html
@@ -51,7 +51,7 @@
diff --git a/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.ts b/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.ts
index ffe11808c..9df3d438b 100644
--- a/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.ts
+++ b/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.ts
@@ -4,7 +4,11 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { first } from 'rxjs'
-import { FileVersion, ShareLink } from 'src/app/data/share-link'
+import {
+ FileVersion,
+ SHARE_LINK_EXPIRATION_OPTIONS,
+ ShareLink,
+} from 'src/app/data/share-link'
import { ShareLinkService } from 'src/app/services/rest/share-link.service'
import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment'
@@ -21,12 +25,7 @@ export class ShareLinksDialogComponent implements OnInit {
private toastService = inject(ToastService)
private clipboard = inject(Clipboard)
- EXPIRATION_OPTIONS = [
- { label: $localize`1 day`, value: 1 },
- { label: $localize`7 days`, value: 7 },
- { label: $localize`30 days`, value: 30 },
- { label: $localize`Never`, value: null },
- ]
+ readonly expirationOptions = SHARE_LINK_EXPIRATION_OPTIONS
@Input()
title = $localize`Share Links`
diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html
index 2323929d1..6f3a84eee 100644
--- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html
+++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html
@@ -96,14 +96,36 @@
- @if (emailEnabled) {
-
- }
+