mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-10 11:48:00 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f8cf4cd6e |
@@ -1200,15 +1200,6 @@ still perform some basic text pre-processing before matching.
|
|||||||
|
|
||||||
Defaults to true, enabling the feature.
|
Defaults to true, enabling the feature.
|
||||||
|
|
||||||
#### [`PAPERLESS_CLASSIFIER_MATCH_THRESHOLD=<float>`](#PAPERLESS_CLASSIFIER_MATCH_THRESHOLD) {#PAPERLESS_CLASSIFIER_MATCH_THRESHOLD}
|
|
||||||
|
|
||||||
: Sets the minimum confidence score (0.0-1.0) required for the automatic
|
|
||||||
classifier to assign a correspondent, document type, or storage path to a
|
|
||||||
document. Predictions below this threshold are discarded and the field is
|
|
||||||
left unassigned, preventing low-confidence guesses from being applied.
|
|
||||||
|
|
||||||
Defaults to 0.6.
|
|
||||||
|
|
||||||
#### [`PAPERLESS_DATE_PARSER_LANGUAGES=<lang>`](#PAPERLESS_DATE_PARSER_LANGUAGES) {#PAPERLESS_DATE_PARSER_LANGUAGES}
|
#### [`PAPERLESS_DATE_PARSER_LANGUAGES=<lang>`](#PAPERLESS_DATE_PARSER_LANGUAGES) {#PAPERLESS_DATE_PARSER_LANGUAGES}
|
||||||
|
|
||||||
: Specifies which language Paperless should use when parsing dates from documents.
|
: Specifies which language Paperless should use when parsing dates from documents.
|
||||||
|
|||||||
+203
-250
File diff suppressed because it is too large
Load Diff
@@ -112,22 +112,6 @@
|
|||||||
|
|
||||||
<pngx-input-check i18n-title title="Use 'slim' sidebar (icons only)" formControlName="slimSidebarEnabled"></pngx-input-check>
|
<pngx-input-check i18n-title title="Use 'slim' sidebar (icons only)" formControlName="slimSidebarEnabled"></pngx-input-check>
|
||||||
|
|
||||||
<p class="mb-2 mt-3" i18n>Sidebar items to show:</p>
|
|
||||||
@for (option of sidebarItemOptions; track option.id) {
|
|
||||||
<div class="form-check">
|
|
||||||
<input
|
|
||||||
class="form-check-input"
|
|
||||||
type="checkbox"
|
|
||||||
[id]="'sidebar-item-setting-' + option.id"
|
|
||||||
[checked]="isSidebarItemShown(option.id)"
|
|
||||||
(change)="toggleSidebarItem(option.id, $event.target.checked)"
|
|
||||||
/>
|
|
||||||
<label class="form-check-label" [for]="'sidebar-item-setting-' + option.id">
|
|
||||||
{{ option.label }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
SystemStatus,
|
SystemStatus,
|
||||||
SystemStatusItemStatus,
|
SystemStatusItemStatus,
|
||||||
} from 'src/app/data/system-status'
|
} from 'src/app/data/system-status'
|
||||||
import { HideableSidebarItemID, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||||
@@ -209,45 +209,6 @@ describe('SettingsComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
}
|
}
|
||||||
|
|
||||||
it('supports configuring sidebar items and canceling changes', () => {
|
|
||||||
completeSetup()
|
|
||||||
|
|
||||||
component.toggleSidebarItem(HideableSidebarItemID.Workflows, false)
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
expect(component.settingsForm.value.sidebarHiddenItems).toContain(
|
|
||||||
HideableSidebarItemID.Workflows
|
|
||||||
)
|
|
||||||
|
|
||||||
settingsService.updateSidebarItemVisibility(
|
|
||||||
HideableSidebarItemID.Mail,
|
|
||||||
false
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(component.settingsForm.value.sidebarHiddenItems).toContain(
|
|
||||||
HideableSidebarItemID.Mail
|
|
||||||
)
|
|
||||||
|
|
||||||
component.reset()
|
|
||||||
|
|
||||||
expect(component.settingsForm.value.sidebarHiddenItems).not.toContain(
|
|
||||||
HideableSidebarItemID.Workflows
|
|
||||||
)
|
|
||||||
expect(component.settingsForm.value.sidebarHiddenItems).not.toContain(
|
|
||||||
HideableSidebarItemID.Mail
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('enables sidebar item controls on general settings until destroyed', () => {
|
|
||||||
completeSetup()
|
|
||||||
|
|
||||||
expect(settingsService.organizingSidebarItems()).toBe(true)
|
|
||||||
|
|
||||||
component.ngOnDestroy()
|
|
||||||
|
|
||||||
expect(settingsService.organizingSidebarItems()).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should support tabbed settings & change URL, prevent navigation if dirty confirmation rejected', async () => {
|
it('should support tabbed settings & change URL, prevent navigation if dirty confirmation rejected', async () => {
|
||||||
completeSetup()
|
completeSetup()
|
||||||
const navigateSpy = jest.spyOn(router, 'navigate')
|
const navigateSpy = jest.spyOn(router, 'navigate')
|
||||||
@@ -288,7 +249,6 @@ describe('SettingsComponent', () => {
|
|||||||
|
|
||||||
it('should support save local settings updating appearance settings and calling API, show error', () => {
|
it('should support save local settings updating appearance settings and calling API, show error', () => {
|
||||||
completeSetup()
|
completeSetup()
|
||||||
component.toggleSidebarItem(HideableSidebarItemID.Workflows, false)
|
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastSpy = jest.spyOn(toastService, 'show')
|
const toastSpy = jest.spyOn(toastService, 'show')
|
||||||
const storeSpy = jest.spyOn(settingsService, 'storeSettings')
|
const storeSpy = jest.spyOn(settingsService, 'storeSettings')
|
||||||
@@ -307,10 +267,7 @@ describe('SettingsComponent', () => {
|
|||||||
expect(toastErrorSpy).toHaveBeenCalled()
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
expect(storeSpy).toHaveBeenCalled()
|
expect(storeSpy).toHaveBeenCalled()
|
||||||
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
||||||
expect(setSpy).toHaveBeenCalledTimes(34)
|
expect(setSpy).toHaveBeenCalledTimes(33)
|
||||||
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
|
|
||||||
HideableSidebarItemID.Workflows,
|
|
||||||
])
|
|
||||||
|
|
||||||
// succeed
|
// succeed
|
||||||
storeSpy.mockReturnValueOnce(of(true))
|
storeSpy.mockReturnValueOnce(of(true))
|
||||||
|
|||||||
@@ -39,12 +39,7 @@ import {
|
|||||||
SystemStatus,
|
SystemStatus,
|
||||||
SystemStatusItemStatus,
|
SystemStatusItemStatus,
|
||||||
} from 'src/app/data/system-status'
|
} from 'src/app/data/system-status'
|
||||||
import {
|
import { GlobalSearchType, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
GlobalSearchType,
|
|
||||||
HIDEABLE_SIDEBAR_ITEM_IDS,
|
|
||||||
HideableSidebarItemID,
|
|
||||||
SETTINGS_KEYS,
|
|
||||||
} from 'src/app/data/ui-settings'
|
|
||||||
import { User } from 'src/app/data/user'
|
import { User } from 'src/app/data/user'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
@@ -107,14 +102,6 @@ const documentDetailFieldOptions = [
|
|||||||
{ id: DocumentDetailFieldID.Tags, label: $localize`Tags` },
|
{ id: DocumentDetailFieldID.Tags, label: $localize`Tags` },
|
||||||
]
|
]
|
||||||
|
|
||||||
const sidebarItemLabels: Record<HideableSidebarItemID, string> = {
|
|
||||||
[HideableSidebarItemID.Dashboard]: $localize`Dashboard`,
|
|
||||||
[HideableSidebarItemID.SavedViews]: $localize`Saved Views`,
|
|
||||||
[HideableSidebarItemID.Workflows]: $localize`Workflows`,
|
|
||||||
[HideableSidebarItemID.Mail]: $localize`Mail`,
|
|
||||||
[HideableSidebarItemID.Documentation]: $localize`Documentation`,
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-settings',
|
selector: 'pngx-settings',
|
||||||
templateUrl: './settings.component.html',
|
templateUrl: './settings.component.html',
|
||||||
@@ -162,7 +149,6 @@ export class SettingsComponent
|
|||||||
bulkEditApplyOnClose: new FormControl(null),
|
bulkEditApplyOnClose: new FormControl(null),
|
||||||
documentListItemPerPage: new FormControl(null),
|
documentListItemPerPage: new FormControl(null),
|
||||||
slimSidebarEnabled: new FormControl(null),
|
slimSidebarEnabled: new FormControl(null),
|
||||||
sidebarHiddenItems: new FormControl<HideableSidebarItemID[]>([]),
|
|
||||||
darkModeUseSystem: new FormControl(null),
|
darkModeUseSystem: new FormControl(null),
|
||||||
darkModeEnabled: new FormControl(null),
|
darkModeEnabled: new FormControl(null),
|
||||||
darkModeInvertThumbs: new FormControl(null),
|
darkModeInvertThumbs: new FormControl(null),
|
||||||
@@ -200,7 +186,6 @@ export class SettingsComponent
|
|||||||
|
|
||||||
store: BehaviorSubject<any>
|
store: BehaviorSubject<any>
|
||||||
storeSub: Subscription
|
storeSub: Subscription
|
||||||
sidebarItemsSub: Subscription
|
|
||||||
isDirty$: Observable<boolean>
|
isDirty$: Observable<boolean>
|
||||||
isDirty: boolean = false
|
isDirty: boolean = false
|
||||||
unsubscribeNotifier: Subject<any> = new Subject()
|
unsubscribeNotifier: Subject<any> = new Subject()
|
||||||
@@ -218,10 +203,6 @@ export class SettingsComponent
|
|||||||
public readonly PdfEditorEditMode = PdfEditorEditMode
|
public readonly PdfEditorEditMode = PdfEditorEditMode
|
||||||
|
|
||||||
public readonly documentDetailFieldOptions = documentDetailFieldOptions
|
public readonly documentDetailFieldOptions = documentDetailFieldOptions
|
||||||
public readonly sidebarItemOptions = HIDEABLE_SIDEBAR_ITEM_IDS.map((id) => ({
|
|
||||||
id,
|
|
||||||
label: sidebarItemLabels[id],
|
|
||||||
}))
|
|
||||||
|
|
||||||
get systemStatusHasErrors(): boolean {
|
get systemStatusHasErrors(): boolean {
|
||||||
const status = this.systemStatus()
|
const status = this.systemStatus()
|
||||||
@@ -249,10 +230,6 @@ export class SettingsComponent
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.sidebarItemsSub =
|
|
||||||
this.settings.sidebarHiddenItemsEditingChanged.subscribe((hiddenItems) =>
|
|
||||||
this.settingsForm.controls.sidebarHiddenItems.setValue(hiddenItems)
|
|
||||||
)
|
|
||||||
this.settings.settingsSaved.subscribe(() => {
|
this.settings.settingsSaved.subscribe(() => {
|
||||||
if (!this.savePending) this.initialize()
|
if (!this.savePending) this.initialize()
|
||||||
this.savedViewsService.maybeRefreshDocumentCounts()
|
this.savedViewsService.maybeRefreshDocumentCounts()
|
||||||
@@ -302,21 +279,14 @@ export class SettingsComponent
|
|||||||
|
|
||||||
this.activatedRoute.paramMap.subscribe((paramMap) => {
|
this.activatedRoute.paramMap.subscribe((paramMap) => {
|
||||||
const section = paramMap.get('section')
|
const section = paramMap.get('section')
|
||||||
let navID = SettingsNavIDs.General
|
|
||||||
if (section) {
|
if (section) {
|
||||||
const navIDKey: string = Object.keys(SettingsNavIDs).find(
|
const navIDKey: string = Object.keys(SettingsNavIDs).find(
|
||||||
(navID) => navID.toLowerCase() == section
|
(navID) => navID.toLowerCase() == section
|
||||||
)
|
)
|
||||||
if (navIDKey) {
|
if (navIDKey) {
|
||||||
navID = SettingsNavIDs[navIDKey]
|
this.activeNavID.set(SettingsNavIDs[navIDKey])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.activeNavID.set(navID)
|
|
||||||
this.settings.sidebarHiddenItemsEditing.set(
|
|
||||||
navID === SettingsNavIDs.General
|
|
||||||
? [...this.settingsForm.controls.sidebarHiddenItems.value]
|
|
||||||
: null
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,7 +310,6 @@ export class SettingsComponent
|
|||||||
SETTINGS_KEYS.DOCUMENT_LIST_SIZE
|
SETTINGS_KEYS.DOCUMENT_LIST_SIZE
|
||||||
),
|
),
|
||||||
slimSidebarEnabled: this.settings.get(SETTINGS_KEYS.SLIM_SIDEBAR),
|
slimSidebarEnabled: this.settings.get(SETTINGS_KEYS.SLIM_SIDEBAR),
|
||||||
sidebarHiddenItems: this.settings.get(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS),
|
|
||||||
darkModeUseSystem: this.settings.get(SETTINGS_KEYS.DARK_MODE_USE_SYSTEM),
|
darkModeUseSystem: this.settings.get(SETTINGS_KEYS.DARK_MODE_USE_SYSTEM),
|
||||||
darkModeEnabled: this.settings.get(SETTINGS_KEYS.DARK_MODE_ENABLED),
|
darkModeEnabled: this.settings.get(SETTINGS_KEYS.DARK_MODE_ENABLED),
|
||||||
darkModeInvertThumbs: this.settings.get(
|
darkModeInvertThumbs: this.settings.get(
|
||||||
@@ -467,12 +436,6 @@ export class SettingsComponent
|
|||||||
this.settingsForm.patchValue(currentFormValue)
|
this.settingsForm.patchValue(currentFormValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.settings.organizingSidebarItems()) {
|
|
||||||
this.settings.sidebarHiddenItemsEditing.set([
|
|
||||||
...this.settingsForm.controls.sidebarHiddenItems.value,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.canViewSystemStatus) {
|
if (this.canViewSystemStatus) {
|
||||||
this.systemStatusService.get().subscribe((status) => {
|
this.systemStatusService.get().subscribe((status) => {
|
||||||
this.systemStatus.set(status)
|
this.systemStatus.set(status)
|
||||||
@@ -481,18 +444,8 @@ export class SettingsComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
this.settings.sidebarHiddenItemsEditing.set(null)
|
|
||||||
if (this.isDirty) this.settings.updateAppearanceSettings() // in case user changed appearance but didn't save
|
if (this.isDirty) this.settings.updateAppearanceSettings() // in case user changed appearance but didn't save
|
||||||
this.storeSub && this.storeSub.unsubscribe()
|
this.storeSub && this.storeSub.unsubscribe()
|
||||||
this.sidebarItemsSub.unsubscribe()
|
|
||||||
}
|
|
||||||
|
|
||||||
isSidebarItemShown(item: HideableSidebarItemID): boolean {
|
|
||||||
return !(this.settingsForm.value.sidebarHiddenItems || []).includes(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleSidebarItem(item: HideableSidebarItemID, checked: boolean): void {
|
|
||||||
this.settings.updateSidebarItemVisibility(item, checked)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public saveSettings() {
|
public saveSettings() {
|
||||||
@@ -520,10 +473,6 @@ export class SettingsComponent
|
|||||||
SETTINGS_KEYS.SLIM_SIDEBAR,
|
SETTINGS_KEYS.SLIM_SIDEBAR,
|
||||||
this.settingsForm.value.slimSidebarEnabled
|
this.settingsForm.value.slimSidebarEnabled
|
||||||
)
|
)
|
||||||
this.settings.set(
|
|
||||||
SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS,
|
|
||||||
this.settingsForm.value.sidebarHiddenItems
|
|
||||||
)
|
|
||||||
this.settings.set(
|
this.settings.set(
|
||||||
SETTINGS_KEYS.DARK_MODE_USE_SYSTEM,
|
SETTINGS_KEYS.DARK_MODE_USE_SYSTEM,
|
||||||
this.settingsForm.value.darkModeUseSystem
|
this.settingsForm.value.darkModeUseSystem
|
||||||
@@ -683,11 +632,6 @@ export class SettingsComponent
|
|||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
this.settingsForm.patchValue(this.store.getValue())
|
this.settingsForm.patchValue(this.store.getValue())
|
||||||
if (this.settings.organizingSidebarItems()) {
|
|
||||||
this.settings.sidebarHiddenItemsEditing.set([
|
|
||||||
...this.settingsForm.controls.sidebarHiddenItems.value,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clearThemeColor() {
|
clearThemeColor() {
|
||||||
|
|||||||
@@ -86,15 +86,12 @@
|
|||||||
}
|
}
|
||||||
<div class="sidebar-sticky pt-3 pb-1 d-flex flex-column justify-space-around">
|
<div class="sidebar-sticky pt-3 pb-1 d-flex flex-column justify-space-around">
|
||||||
<ul class="nav flex-column">
|
<ul class="nav flex-column">
|
||||||
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard) && !settingsService.organizingSidebarItems()">
|
<li class="nav-item app-link">
|
||||||
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="house"></i-bs><span class="nav-link-label"><ng-container i18n>Dashboard</ng-container></span>
|
<i-bs class="me-2" name="house"></i-bs><span class="nav-link-label"><ng-container i18n>Dashboard</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@if (settingsService.organizingSidebarItems()) {
|
|
||||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Dashboard" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Dashboard, $event)"></pngx-input-switch>
|
|
||||||
}
|
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||||
<a class="nav-link" routerLink="documents" routerLinkActive="active"
|
<a class="nav-link" routerLink="documents" routerLinkActive="active"
|
||||||
@@ -240,38 +237,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews) && !settingsService.organizingSidebarItems()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
||||||
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="window-stack"></i-bs><span class="nav-link-label"><ng-container i18n>Saved Views</ng-container></span>
|
<i-bs class="me-2" name="window-stack"></i-bs><span class="nav-link-label"><ng-container i18n>Saved Views</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@if (settingsService.organizingSidebarItems()) {
|
|
||||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Saved Views" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.SavedViews, $event)"></pngx-input-switch>
|
|
||||||
}
|
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows) && !settingsService.organizingSidebarItems()"
|
<li class="nav-item app-link"
|
||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
|
||||||
tourAnchor="tour.workflows">
|
tourAnchor="tour.workflows">
|
||||||
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="boxes"></i-bs><span class="nav-link-label"><ng-container i18n>Workflows</ng-container></span>
|
<i-bs class="me-2" name="boxes"></i-bs><span class="nav-link-label"><ng-container i18n>Workflows</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@if (settingsService.organizingSidebarItems()) {
|
|
||||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Workflows" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Workflows, $event)"></pngx-input-switch>
|
|
||||||
}
|
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail) && !settingsService.organizingSidebarItems()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
||||||
tourAnchor="tour.mail">
|
tourAnchor="tour.mail">
|
||||||
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
|
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
|
||||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="envelope"></i-bs><span class="nav-link-label"><ng-container i18n>Mail</ng-container></span>
|
<i-bs class="me-2" name="envelope"></i-bs><span class="nav-link-label"><ng-container i18n>Mail</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@if (settingsService.organizingSidebarItems()) {
|
|
||||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Mail" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Mail, $event)"></pngx-input-switch>
|
|
||||||
}
|
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
|
||||||
<a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash"
|
<a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash"
|
||||||
@@ -334,16 +322,13 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
<li class="nav-item mt-2 position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Documentation) && !settingsService.organizingSidebarItems()" tourAnchor="tour.outro">
|
<li class="nav-item mt-2" tourAnchor="tour.outro">
|
||||||
<a class="text-muted small d-flex align-items-center flex-wrap text-decoration-none nav-anchor" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Documentation)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()"
|
<a class="text-muted small d-flex align-items-center flex-wrap text-decoration-none nav-anchor"
|
||||||
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
|
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
|
||||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="d-flex me-2" name="question-circle"></i-bs><span><ng-container i18n>Documentation</ng-container></span>
|
<i-bs class="d-flex me-2" name="question-circle"></i-bs><span><ng-container i18n>Documentation</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@if (settingsService.organizingSidebarItems()) {
|
|
||||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Documentation" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Documentation)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Documentation, $event)"></pngx-input-switch>
|
|
||||||
}
|
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
||||||
<div class="text-muted small d-flex align-items-center flex-wrap nav-label">
|
<div class="text-muted small d-flex align-items-center flex-wrap nav-label">
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
|
|||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import { routes } from 'src/app/app-routing.module'
|
import { routes } from 'src/app/app-routing.module'
|
||||||
import { SavedView } from 'src/app/data/saved-view'
|
import { SavedView } from 'src/app/data/saved-view'
|
||||||
import { HideableSidebarItemID, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||||
import {
|
import {
|
||||||
@@ -287,82 +287,6 @@ describe('AppFrameComponent', () => {
|
|||||||
jest.useRealTimers()
|
jest.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should hide configured sidebar items', () => {
|
|
||||||
settingsService.set(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
|
|
||||||
HideableSidebarItemID.Dashboard,
|
|
||||||
HideableSidebarItemID.Workflows,
|
|
||||||
])
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
expect(
|
|
||||||
fixture.nativeElement.querySelector('[routerLink="dashboard"]')
|
|
||||||
.parentElement.classList
|
|
||||||
).toContain('d-none')
|
|
||||||
expect(
|
|
||||||
fixture.nativeElement.querySelector('[routerLink="workflows"]')
|
|
||||||
.parentElement.classList
|
|
||||||
).toContain('d-none')
|
|
||||||
expect(
|
|
||||||
fixture.nativeElement.querySelector('[routerLink="mail"]').parentElement
|
|
||||||
.classList
|
|
||||||
).not.toContain('d-none')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should show hidden items and visibility switches while customizing', () => {
|
|
||||||
settingsService.set(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
|
|
||||||
HideableSidebarItemID.Dashboard,
|
|
||||||
])
|
|
||||||
settingsService.sidebarHiddenItemsEditing.set([
|
|
||||||
HideableSidebarItemID.Dashboard,
|
|
||||||
])
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
expect(
|
|
||||||
fixture.nativeElement.querySelectorAll('pngx-input-switch').length
|
|
||||||
).toBe(5)
|
|
||||||
expect(
|
|
||||||
fixture.nativeElement.querySelector('[routerLink="dashboard"]')
|
|
||||||
.parentElement.classList
|
|
||||||
).not.toContain('d-none')
|
|
||||||
expect(
|
|
||||||
fixture.nativeElement.querySelector('[routerLink="dashboard"]').classList
|
|
||||||
).toContain('opacity-50')
|
|
||||||
|
|
||||||
settingsService.set(SETTINGS_KEYS.SLIM_SIDEBAR, true)
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
expect(
|
|
||||||
Array.from(
|
|
||||||
fixture.nativeElement.querySelectorAll('pngx-input-switch')
|
|
||||||
).every((toggle: HTMLElement) => toggle.classList.contains('d-none'))
|
|
||||||
).toBe(true)
|
|
||||||
expect(
|
|
||||||
fixture.nativeElement.querySelector('[routerLink="dashboard"]').classList
|
|
||||||
).not.toContain('pe-5')
|
|
||||||
|
|
||||||
settingsService.set(SETTINGS_KEYS.SLIM_SIDEBAR, false)
|
|
||||||
component.slimSidebarAnimating.set(true)
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
expect(
|
|
||||||
Array.from(
|
|
||||||
fixture.nativeElement.querySelectorAll('pngx-input-switch')
|
|
||||||
).every((toggle: HTMLElement) => toggle.classList.contains('d-none'))
|
|
||||||
).toBe(true)
|
|
||||||
|
|
||||||
component.slimSidebarAnimating.set(false)
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
expect(
|
|
||||||
Array.from(
|
|
||||||
fixture.nativeElement.querySelectorAll('pngx-input-switch')
|
|
||||||
).every((toggle: HTMLElement) => !toggle.classList.contains('d-none'))
|
|
||||||
).toBe(true)
|
|
||||||
expect(
|
|
||||||
fixture.nativeElement.querySelector('[routerLink="dashboard"]').classList
|
|
||||||
).toContain('pe-5')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should show error on toggle slim sidebar if store settings fails', () => {
|
it('should show error on toggle slim sidebar if store settings fails', () => {
|
||||||
jest.spyOn(console, 'warn').mockImplementation(() => {})
|
jest.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
} from '@angular/cdk/drag-drop'
|
} from '@angular/cdk/drag-drop'
|
||||||
import { NgClass } from '@angular/common'
|
import { NgClass } from '@angular/common'
|
||||||
import { Component, HostListener, inject, OnInit, signal } from '@angular/core'
|
import { Component, HostListener, inject, OnInit, signal } from '@angular/core'
|
||||||
import { FormsModule } from '@angular/forms'
|
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
||||||
import {
|
import {
|
||||||
NgbCollapseModule,
|
NgbCollapseModule,
|
||||||
@@ -22,11 +21,7 @@ import { Observable } from 'rxjs'
|
|||||||
import { first } from 'rxjs/operators'
|
import { first } from 'rxjs/operators'
|
||||||
import { Document } from 'src/app/data/document'
|
import { Document } from 'src/app/data/document'
|
||||||
import { SavedView } from 'src/app/data/saved-view'
|
import { SavedView } from 'src/app/data/saved-view'
|
||||||
import {
|
import { CollapsibleSection, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
CollapsibleSection,
|
|
||||||
HideableSidebarItemID,
|
|
||||||
SETTINGS_KEYS,
|
|
||||||
} from 'src/app/data/ui-settings'
|
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { ComponentCanDeactivate } from 'src/app/guards/dirty-doc.guard'
|
import { ComponentCanDeactivate } from 'src/app/guards/dirty-doc.guard'
|
||||||
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
||||||
@@ -53,7 +48,6 @@ import { ChatComponent } from '../chat/chat/chat.component'
|
|||||||
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
|
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
|
||||||
import { LogoComponent } from '../common/logo/logo.component'
|
import { LogoComponent } from '../common/logo/logo.component'
|
||||||
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||||
import { SwitchComponent } from '../common/input/switch/switch.component'
|
|
||||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
||||||
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
||||||
import { GlobalSearchComponent } from './global-search/global-search.component'
|
import { GlobalSearchComponent } from './global-search/global-search.component'
|
||||||
@@ -82,8 +76,6 @@ const SCROLL_THRESHOLD = 16
|
|||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
DragDropModule,
|
DragDropModule,
|
||||||
TourNgBootstrap,
|
TourNgBootstrap,
|
||||||
FormsModule,
|
|
||||||
SwitchComponent,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppFrameComponent
|
export class AppFrameComponent
|
||||||
@@ -106,7 +98,6 @@ export class AppFrameComponent
|
|||||||
readonly isMenuCollapsed = signal(true)
|
readonly isMenuCollapsed = signal(true)
|
||||||
readonly slimSidebarAnimating = signal(false)
|
readonly slimSidebarAnimating = signal(false)
|
||||||
readonly mobileSearchHidden = signal(false)
|
readonly mobileSearchHidden = signal(false)
|
||||||
readonly HideableSidebarItemID = HideableSidebarItemID
|
|
||||||
private readonly versionSetting = this.settingsService.getSignal<string>(
|
private readonly versionSetting = this.settingsService.getSignal<string>(
|
||||||
SETTINGS_KEYS.VERSION
|
SETTINGS_KEYS.VERSION
|
||||||
)
|
)
|
||||||
@@ -204,10 +195,6 @@ export class AppFrameComponent
|
|||||||
}, 200) // slightly longer than css animation for slim sidebar
|
}, 200) // slightly longer than css animation for slim sidebar
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleSidebarItem(item: HideableSidebarItemID, visible: boolean): void {
|
|
||||||
this.settingsService.updateSidebarItemVisibility(item, visible)
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleAttributesSections(event?: Event): void {
|
toggleAttributesSections(event?: Event): void {
|
||||||
event?.preventDefault()
|
event?.preventDefault()
|
||||||
event?.stopPropagation()
|
event?.stopPropagation()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div [class.mb-3]="!compact">
|
<div class="mb-3">
|
||||||
<div [class.row]="!compact">
|
<div class="row">
|
||||||
@if (!horizontal && !compact) {
|
@if (!horizontal) {
|
||||||
<div class="d-flex align-items-center position-relative hidden-button-container col-md-3">
|
<div class="d-flex align-items-center position-relative hidden-button-container col-md-3">
|
||||||
<label class="form-label" [for]="inputId" [ngbTooltip]="showUnsetNote && isUnset ? tipContent: null" placement="end">
|
<label class="form-label" [for]="inputId" [ngbTooltip]="showUnsetNote && isUnset ? tipContent: null" placement="end">
|
||||||
{{title}}
|
{{title}}
|
||||||
@@ -17,8 +17,8 @@
|
|||||||
}
|
}
|
||||||
<div [ngClass]="{'align-items-center': horizontal, 'd-flex': horizontal}">
|
<div [ngClass]="{'align-items-center': horizontal, 'd-flex': horizontal}">
|
||||||
<div class="form-check form-switch">
|
<div class="form-check form-switch">
|
||||||
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" [ngModelOptions]="{standalone: true}" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled" [attr.aria-label]="compact ? title : null">
|
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" [ngModelOptions]="{standalone: true}" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
|
||||||
@if (horizontal && !compact) {
|
@if (horizontal) {
|
||||||
<label class="form-check-label" [class.text-muted]="showUnsetNote && isUnset" [for]="inputId" [ngbTooltip]="showUnsetNote && isUnset ? tipContent: null" placement="end">
|
<label class="form-check-label" [class.text-muted]="showUnsetNote && isUnset" [for]="inputId" [ngbTooltip]="showUnsetNote && isUnset ? tipContent: null" placement="end">
|
||||||
{{title}}
|
{{title}}
|
||||||
@if (showUnsetNote && isUnset) {
|
@if (showUnsetNote && isUnset) {
|
||||||
|
|||||||
@@ -48,14 +48,4 @@ describe('SwitchComponent', () => {
|
|||||||
component.value = undefined
|
component.value = undefined
|
||||||
expect(component.isUnset).toBeTruthy()
|
expect(component.isUnset).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support a compact layout', () => {
|
|
||||||
component.compact = true
|
|
||||||
component.title = 'Test switch'
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
expect(fixture.nativeElement.querySelector('.mb-3')).toBeNull()
|
|
||||||
expect(fixture.nativeElement.querySelector('.row')).toBeNull()
|
|
||||||
expect(input.getAttribute('aria-label')).toEqual('Test switch')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -25,9 +25,6 @@ export class SwitchComponent extends AbstractInputComponent<boolean> {
|
|||||||
@Input()
|
@Input()
|
||||||
showUnsetNote: boolean = false
|
showUnsetNote: boolean = false
|
||||||
|
|
||||||
@Input()
|
|
||||||
compact: boolean = false
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,16 +24,6 @@ export enum CollapsibleSection {
|
|||||||
ATTRIBUTES = 'attributes',
|
ATTRIBUTES = 'attributes',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum HideableSidebarItemID {
|
|
||||||
Dashboard = 'dashboard',
|
|
||||||
SavedViews = 'saved_views',
|
|
||||||
Workflows = 'workflows',
|
|
||||||
Mail = 'mail',
|
|
||||||
Documentation = 'documentation',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const HIDEABLE_SIDEBAR_ITEM_IDS = Object.values(HideableSidebarItemID)
|
|
||||||
|
|
||||||
export const PAPERLESS_GREEN_HEX = '#17541f'
|
export const PAPERLESS_GREEN_HEX = '#17541f'
|
||||||
|
|
||||||
export const SETTINGS_KEYS = {
|
export const SETTINGS_KEYS = {
|
||||||
@@ -66,7 +56,6 @@ export const SETTINGS_KEYS = {
|
|||||||
NOTES_ENABLED: 'general-settings:notes-enabled',
|
NOTES_ENABLED: 'general-settings:notes-enabled',
|
||||||
AUDITLOG_ENABLED: 'general-settings:auditlog-enabled',
|
AUDITLOG_ENABLED: 'general-settings:auditlog-enabled',
|
||||||
SLIM_SIDEBAR: 'general-settings:slim-sidebar',
|
SLIM_SIDEBAR: 'general-settings:slim-sidebar',
|
||||||
SIDEBAR_HIDDEN_ITEMS: 'general-settings:sidebar:hidden-items',
|
|
||||||
ATTRIBUTES_SECTIONS_COLLAPSED:
|
ATTRIBUTES_SECTIONS_COLLAPSED:
|
||||||
'general-settings:attributes-sections-collapsed',
|
'general-settings:attributes-sections-collapsed',
|
||||||
UPDATE_CHECKING_ENABLED: 'general-settings:update-checking:enabled',
|
UPDATE_CHECKING_ENABLED: 'general-settings:update-checking:enabled',
|
||||||
@@ -138,11 +127,6 @@ export const SETTINGS: UiSetting[] = [
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS,
|
|
||||||
type: 'array',
|
|
||||||
default: [],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
|
key: SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
|
||||||
type: 'array',
|
type: 'array',
|
||||||
|
|||||||
@@ -14,11 +14,7 @@ import { CustomFieldDataType } from '../data/custom-field'
|
|||||||
import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
|
import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
|
||||||
import { SavedView } from '../data/saved-view'
|
import { SavedView } from '../data/saved-view'
|
||||||
import { RemoteOCRModeConfig } from '../data/paperless-config'
|
import { RemoteOCRModeConfig } from '../data/paperless-config'
|
||||||
import {
|
import { SETTINGS_KEYS, UiSettings } from '../data/ui-settings'
|
||||||
HideableSidebarItemID,
|
|
||||||
SETTINGS_KEYS,
|
|
||||||
UiSettings,
|
|
||||||
} from '../data/ui-settings'
|
|
||||||
import { PermissionsService } from './permissions.service'
|
import { PermissionsService } from './permissions.service'
|
||||||
import { CustomFieldsService } from './rest/custom-fields.service'
|
import { CustomFieldsService } from './rest/custom-fields.service'
|
||||||
import { SettingsService } from './settings.service'
|
import { SettingsService } from './settings.service'
|
||||||
@@ -234,35 +230,6 @@ describe('SettingsService', () => {
|
|||||||
expect(notesEnabled()).toBeFalsy()
|
expect(notesEnabled()).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('updates sidebar item visibility', () => {
|
|
||||||
httpTestingController
|
|
||||||
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
|
|
||||||
.flush(ui_settings)
|
|
||||||
|
|
||||||
expect(
|
|
||||||
settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)
|
|
||||||
).toBe(false)
|
|
||||||
|
|
||||||
settingsService.updateSidebarItemVisibility(
|
|
||||||
HideableSidebarItemID.Workflows,
|
|
||||||
false
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(
|
|
||||||
settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)
|
|
||||||
).toBe(true)
|
|
||||||
expect(settingsService.get(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS)).toEqual([])
|
|
||||||
|
|
||||||
settingsService.updateSidebarItemVisibility(
|
|
||||||
HideableSidebarItemID.Workflows,
|
|
||||||
true
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(
|
|
||||||
settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)
|
|
||||||
).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('updates setting signals when settings are reinitialized', () => {
|
it('updates setting signals when settings are reinitialized', () => {
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}ui_settings/`
|
`${environment.apiBaseUrl}ui_settings/`
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
|
|||||||
import { RemoteOCRModeConfig } from '../data/paperless-config'
|
import { RemoteOCRModeConfig } from '../data/paperless-config'
|
||||||
import { SavedView } from '../data/saved-view'
|
import { SavedView } from '../data/saved-view'
|
||||||
import {
|
import {
|
||||||
HideableSidebarItemID,
|
|
||||||
PAPERLESS_GREEN_HEX,
|
PAPERLESS_GREEN_HEX,
|
||||||
SETTINGS,
|
SETTINGS,
|
||||||
SETTINGS_KEYS,
|
SETTINGS_KEYS,
|
||||||
@@ -314,18 +313,6 @@ export class SettingsService {
|
|||||||
readonly globalDropzoneEnabled = signal(true)
|
readonly globalDropzoneEnabled = signal(true)
|
||||||
readonly globalDropzoneActive = signal(false)
|
readonly globalDropzoneActive = signal(false)
|
||||||
readonly organizingSidebarSavedViews = signal(false)
|
readonly organizingSidebarSavedViews = signal(false)
|
||||||
readonly sidebarHiddenItemsEditing = signal<HideableSidebarItemID[] | null>(
|
|
||||||
null
|
|
||||||
)
|
|
||||||
readonly organizingSidebarItems = computed(
|
|
||||||
() => this.sidebarHiddenItemsEditing() !== null
|
|
||||||
)
|
|
||||||
readonly sidebarHiddenItemsEditingChanged = new EventEmitter<
|
|
||||||
HideableSidebarItemID[]
|
|
||||||
>()
|
|
||||||
readonly hiddenSidebarItems = this.getSignal<HideableSidebarItemID[]>(
|
|
||||||
SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS
|
|
||||||
)
|
|
||||||
|
|
||||||
readonly allDisplayFields = signal<Array<{ id: DisplayField; name: string }>>(
|
readonly allDisplayFields = signal<Array<{ id: DisplayField; name: string }>>(
|
||||||
DEFAULT_DISPLAY_FIELDS
|
DEFAULT_DISPLAY_FIELDS
|
||||||
@@ -762,29 +749,6 @@ export class SettingsService {
|
|||||||
return this.storeSettings()
|
return this.storeSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
sidebarItemIsHidden(item: HideableSidebarItemID): boolean {
|
|
||||||
return (
|
|
||||||
this.sidebarHiddenItemsEditing() ?? this.hiddenSidebarItems()
|
|
||||||
).includes(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSidebarItemVisibility(
|
|
||||||
item: HideableSidebarItemID,
|
|
||||||
visible: boolean
|
|
||||||
): void {
|
|
||||||
const hiddenItems = new Set(
|
|
||||||
this.sidebarHiddenItemsEditing() ?? this.hiddenSidebarItems()
|
|
||||||
)
|
|
||||||
if (visible) {
|
|
||||||
hiddenItems.delete(item)
|
|
||||||
} else {
|
|
||||||
hiddenItems.add(item)
|
|
||||||
}
|
|
||||||
const updatedHiddenItems = [...hiddenItems]
|
|
||||||
this.sidebarHiddenItemsEditing.set(updatedHiddenItems)
|
|
||||||
this.sidebarHiddenItemsEditingChanged.emit(updatedHiddenItems)
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSavedViewsVisibility(
|
updateSavedViewsVisibility(
|
||||||
dashboardVisibleViewIds: number[],
|
dashboardVisibleViewIds: number[],
|
||||||
sidebarVisibleViewIds: number[]
|
sidebarVisibleViewIds: number[]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+32
-38
@@ -2,7 +2,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import tempfile
|
import tempfile
|
||||||
import uuid
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
@@ -299,55 +298,53 @@ def modify_custom_fields(
|
|||||||
) -> Literal["OK"]:
|
) -> Literal["OK"]:
|
||||||
qs = Document.objects.filter(id__in=doc_ids).only("pk")
|
qs = Document.objects.filter(id__in=doc_ids).only("pk")
|
||||||
affected_docs = list(qs.values_list("pk", flat=True))
|
affected_docs = list(qs.values_list("pk", flat=True))
|
||||||
# Ensure add_custom_fields is a list of (int, value) tuples, supports old API
|
# Ensure add_custom_fields is a list of tuples, supports old API
|
||||||
add_custom_fields = (
|
add_custom_fields = (
|
||||||
[(int(field), value) for field, value in add_custom_fields.items()]
|
add_custom_fields.items()
|
||||||
if isinstance(add_custom_fields, dict)
|
if isinstance(add_custom_fields, dict)
|
||||||
else [(int(field), None) for field in add_custom_fields]
|
else [(field, None) for field in add_custom_fields]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Resolved once, instead of re-querying the same field for every document
|
custom_fields = CustomField.objects.filter(
|
||||||
custom_fields_by_id: dict[int, CustomField] = CustomField.objects.in_bulk(
|
id__in=[int(field) for field, _ in add_custom_fields],
|
||||||
[field_id for field_id, _ in add_custom_fields],
|
).distinct()
|
||||||
)
|
|
||||||
# Passed to update_or_create() below rather than a bare id, so the FK is
|
|
||||||
# cached on the created instance and auditlog's post_save receiver does
|
|
||||||
# not reload it per row. Only needed for additions. content is deferred:
|
|
||||||
# the one field here that is both large and unused.
|
|
||||||
docs_by_id: dict[int, Document] = (
|
|
||||||
Document.objects.defer("content").in_bulk(affected_docs)
|
|
||||||
if add_custom_fields
|
|
||||||
else {}
|
|
||||||
)
|
|
||||||
for field_id, value in add_custom_fields:
|
for field_id, value in add_custom_fields:
|
||||||
custom_field = custom_fields_by_id[field_id]
|
for doc_id in affected_docs:
|
||||||
|
defaults = {}
|
||||||
|
custom_field = custom_fields.get(id=field_id)
|
||||||
|
if custom_field:
|
||||||
value_field = CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[
|
value_field = CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[
|
||||||
custom_field.data_type
|
custom_field.data_type
|
||||||
]
|
]
|
||||||
is_doclink = custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK
|
defaults[value_field] = value
|
||||||
for doc_id in affected_docs:
|
if (
|
||||||
if is_doclink and value and doc_id in value:
|
custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK
|
||||||
|
and value
|
||||||
|
and doc_id in value
|
||||||
|
):
|
||||||
# Prevent self-linking
|
# Prevent self-linking
|
||||||
continue
|
continue
|
||||||
CustomFieldInstance.objects.update_or_create(
|
CustomFieldInstance.objects.update_or_create(
|
||||||
document=docs_by_id[doc_id],
|
document_id=doc_id,
|
||||||
field=custom_field,
|
field_id=field_id,
|
||||||
defaults={value_field: value},
|
defaults=defaults,
|
||||||
)
|
)
|
||||||
if is_doclink:
|
if custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
|
||||||
reflect_doclinks(docs_by_id[doc_id], custom_field, value)
|
doc = Document.objects.get(id=doc_id)
|
||||||
|
reflect_doclinks(doc, custom_field, value)
|
||||||
|
|
||||||
# For doc link fields that are being removed, remove symmetrical links.
|
# For doc link fields that are being removed, remove symmetrical links
|
||||||
# select_related avoids a per-instance reload of the document and field.
|
|
||||||
for doclink_being_removed_instance in CustomFieldInstance.objects.filter(
|
for doclink_being_removed_instance in CustomFieldInstance.objects.filter(
|
||||||
document_id__in=affected_docs,
|
document_id__in=affected_docs,
|
||||||
field__id__in=remove_custom_fields,
|
field__id__in=remove_custom_fields,
|
||||||
field__data_type=CustomField.FieldDataType.DOCUMENTLINK,
|
field__data_type=CustomField.FieldDataType.DOCUMENTLINK,
|
||||||
value_document_ids__isnull=False,
|
value_document_ids__isnull=False,
|
||||||
).select_related("field", "document"):
|
):
|
||||||
for target_doc_id in doclink_being_removed_instance.value:
|
for target_doc_id in doclink_being_removed_instance.value:
|
||||||
remove_doclink(
|
remove_doclink(
|
||||||
document=doclink_being_removed_instance.document,
|
document=Document.objects.get(
|
||||||
|
id=doclink_being_removed_instance.document.id,
|
||||||
|
),
|
||||||
field=doclink_being_removed_instance.field,
|
field=doclink_being_removed_instance.field,
|
||||||
target_doc_id=target_doc_id,
|
target_doc_id=target_doc_id,
|
||||||
)
|
)
|
||||||
@@ -382,7 +379,7 @@ def delete(doc_ids: list[int]) -> Literal["OK"]:
|
|||||||
)
|
)
|
||||||
delete_ids = list({*doc_ids, *version_ids})
|
delete_ids = list({*doc_ids, *version_ids})
|
||||||
|
|
||||||
Document.objects.filter(id__in=delete_ids).delete(transaction_id=uuid.uuid4())
|
Document.objects.filter(id__in=delete_ids).delete()
|
||||||
|
|
||||||
from documents.search import get_backend
|
from documents.search import get_backend
|
||||||
|
|
||||||
@@ -1180,13 +1177,10 @@ def remove_doclink(
|
|||||||
"""
|
"""
|
||||||
Removes a 'symmetrical' link to `document` from the target document's existing custom field instance
|
Removes a 'symmetrical' link to `document` from the target document's existing custom field instance
|
||||||
"""
|
"""
|
||||||
# select_related: a signal receiver (auditlog) touches .document/.field on
|
target_doc_field_instance = CustomFieldInstance.objects.filter(
|
||||||
# the save() below, without this that is a per-call reload query
|
document_id=target_doc_id,
|
||||||
target_doc_field_instance = (
|
field=field,
|
||||||
CustomFieldInstance.objects.filter(document_id=target_doc_id, field=field)
|
).first()
|
||||||
.select_related("document", "field")
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if (
|
if (
|
||||||
target_doc_field_instance is not None
|
target_doc_field_instance is not None
|
||||||
and document.id in target_doc_field_instance.value
|
and document.id in target_doc_field_instance.value
|
||||||
|
|||||||
+25
-63
@@ -34,27 +34,6 @@ from paperless.signed_pickle import signed_pickle_loads
|
|||||||
|
|
||||||
logger = logging.getLogger("paperless.classifier")
|
logger = logging.getLogger("paperless.classifier")
|
||||||
|
|
||||||
|
|
||||||
def _predict_with_threshold(classifier, X, threshold: float) -> int | None:
|
|
||||||
"""
|
|
||||||
Return the predicted class id, or None if:
|
|
||||||
- the prediction is -1 (no match), or
|
|
||||||
- the winning class probability is below the configured threshold.
|
|
||||||
|
|
||||||
Using predict_proba() instead of predict() lets us apply a minimum-confidence
|
|
||||||
cutoff so that uncertain predictions are discarded rather than assigned.
|
|
||||||
"""
|
|
||||||
probas = classifier.predict_proba(X)[0]
|
|
||||||
best_idx = int(probas.argmax())
|
|
||||||
best_class = int(classifier.classes_[best_idx])
|
|
||||||
|
|
||||||
if best_class == -1:
|
|
||||||
return None
|
|
||||||
if threshold > 0.0 and probas[best_idx] < threshold:
|
|
||||||
return None
|
|
||||||
return best_class
|
|
||||||
|
|
||||||
|
|
||||||
ADVANCED_TEXT_PROCESSING_ENABLED = (
|
ADVANCED_TEXT_PROCESSING_ENABLED = (
|
||||||
settings.NLTK_LANGUAGE is not None and settings.NLTK_ENABLED
|
settings.NLTK_LANGUAGE is not None and settings.NLTK_ENABLED
|
||||||
)
|
)
|
||||||
@@ -123,8 +102,7 @@ class DocumentClassifier:
|
|||||||
# v8 - Added storage path classifier
|
# v8 - Added storage path classifier
|
||||||
# v9 - Changed from hashing to time/ids for re-train check
|
# v9 - Changed from hashing to time/ids for re-train check
|
||||||
# v10 - HMAC-signed model file
|
# v10 - HMAC-signed model file
|
||||||
# v11 - Use sample_weight for balanced training; predict_proba with threshold
|
FORMAT_VERSION = 10
|
||||||
FORMAT_VERSION = 11
|
|
||||||
|
|
||||||
HMAC_SIZE = 32 # SHA-256 digest length
|
HMAC_SIZE = 32 # SHA-256 digest length
|
||||||
|
|
||||||
@@ -346,13 +324,6 @@ class DocumentClassifier:
|
|||||||
from sklearn.preprocessing import LabelBinarizer
|
from sklearn.preprocessing import LabelBinarizer
|
||||||
from sklearn.preprocessing import MultiLabelBinarizer
|
from sklearn.preprocessing import MultiLabelBinarizer
|
||||||
|
|
||||||
# MLPClassifier does not support class_weight directly
|
|
||||||
# (https://github.com/scikit-learn/scikit-learn/issues/9113), so we use
|
|
||||||
# compute_sample_weight to balance classes during training and prevent
|
|
||||||
# over-represented correspondents from dominating predictions.
|
|
||||||
# https://scikit-learn.org/stable/modules/generated/sklearn.utils.class_weight.compute_sample_weight.html
|
|
||||||
from sklearn.utils.class_weight import compute_sample_weight
|
|
||||||
|
|
||||||
# Step 2: vectorize data
|
# Step 2: vectorize data
|
||||||
logger.debug("Vectorizing data...")
|
logger.debug("Vectorizing data...")
|
||||||
notify("Vectorizing document content...")
|
notify("Vectorizing document content...")
|
||||||
@@ -398,7 +369,7 @@ class DocumentClassifier:
|
|||||||
self.tags_binarizer = MultiLabelBinarizer()
|
self.tags_binarizer = MultiLabelBinarizer()
|
||||||
labels_tags_vectorized = self.tags_binarizer.fit_transform(labels_tags)
|
labels_tags_vectorized = self.tags_binarizer.fit_transform(labels_tags)
|
||||||
|
|
||||||
self.tags_classifier = MLPClassifier(tol=0.01, random_state=0)
|
self.tags_classifier = MLPClassifier(tol=0.01)
|
||||||
self.tags_classifier.fit(data_vectorized, labels_tags_vectorized)
|
self.tags_classifier.fit(data_vectorized, labels_tags_vectorized)
|
||||||
else:
|
else:
|
||||||
self.tags_classifier = None
|
self.tags_classifier = None
|
||||||
@@ -409,12 +380,8 @@ class DocumentClassifier:
|
|||||||
notify(
|
notify(
|
||||||
f"Training correspondent classifier ({num_correspondents} correspondent(s))...",
|
f"Training correspondent classifier ({num_correspondents} correspondent(s))...",
|
||||||
)
|
)
|
||||||
self.correspondent_classifier = MLPClassifier(tol=0.01, random_state=0)
|
self.correspondent_classifier = MLPClassifier(tol=0.01)
|
||||||
self.correspondent_classifier.fit(
|
self.correspondent_classifier.fit(data_vectorized, labels_correspondent)
|
||||||
data_vectorized,
|
|
||||||
labels_correspondent,
|
|
||||||
sample_weight=compute_sample_weight("balanced", labels_correspondent),
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
self.correspondent_classifier = None
|
self.correspondent_classifier = None
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -426,12 +393,8 @@ class DocumentClassifier:
|
|||||||
notify(
|
notify(
|
||||||
f"Training document type classifier ({num_document_types} type(s))...",
|
f"Training document type classifier ({num_document_types} type(s))...",
|
||||||
)
|
)
|
||||||
self.document_type_classifier = MLPClassifier(tol=0.01, random_state=0)
|
self.document_type_classifier = MLPClassifier(tol=0.01)
|
||||||
self.document_type_classifier.fit(
|
self.document_type_classifier.fit(data_vectorized, labels_document_type)
|
||||||
data_vectorized,
|
|
||||||
labels_document_type,
|
|
||||||
sample_weight=compute_sample_weight("balanced", labels_document_type),
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
self.document_type_classifier = None
|
self.document_type_classifier = None
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -443,11 +406,10 @@ class DocumentClassifier:
|
|||||||
"Training storage paths classifier...",
|
"Training storage paths classifier...",
|
||||||
)
|
)
|
||||||
notify(f"Training storage path classifier ({num_storage_paths} path(s))...")
|
notify(f"Training storage path classifier ({num_storage_paths} path(s))...")
|
||||||
self.storage_path_classifier = MLPClassifier(tol=0.01, random_state=0)
|
self.storage_path_classifier = MLPClassifier(tol=0.01)
|
||||||
self.storage_path_classifier.fit(
|
self.storage_path_classifier.fit(
|
||||||
data_vectorized,
|
data_vectorized,
|
||||||
labels_storage_path,
|
labels_storage_path,
|
||||||
sample_weight=compute_sample_weight("balanced", labels_storage_path),
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.storage_path_classifier = None
|
self.storage_path_classifier = None
|
||||||
@@ -584,23 +546,23 @@ class DocumentClassifier:
|
|||||||
def predict_correspondent(self, content: str) -> int | None:
|
def predict_correspondent(self, content: str) -> int | None:
|
||||||
if self.correspondent_classifier:
|
if self.correspondent_classifier:
|
||||||
X = self._vectorize(content)
|
X = self._vectorize(content)
|
||||||
predicted_id = _predict_with_threshold(
|
correspondent_id = self.correspondent_classifier.predict(X)
|
||||||
self.correspondent_classifier,
|
if correspondent_id != -1:
|
||||||
X,
|
return correspondent_id
|
||||||
settings.CLASSIFIER_MATCH_THRESHOLD,
|
else:
|
||||||
)
|
return None
|
||||||
return predicted_id
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def predict_document_type(self, content: str) -> int | None:
|
def predict_document_type(self, content: str) -> int | None:
|
||||||
if self.document_type_classifier:
|
if self.document_type_classifier:
|
||||||
X = self._vectorize(content)
|
X = self._vectorize(content)
|
||||||
predicted_id = _predict_with_threshold(
|
document_type_id = self.document_type_classifier.predict(X)
|
||||||
self.document_type_classifier,
|
if document_type_id != -1:
|
||||||
X,
|
return document_type_id
|
||||||
settings.CLASSIFIER_MATCH_THRESHOLD,
|
else:
|
||||||
)
|
return None
|
||||||
return predicted_id
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def predict_tags(self, content: str) -> list[int]:
|
def predict_tags(self, content: str) -> list[int]:
|
||||||
@@ -627,10 +589,10 @@ class DocumentClassifier:
|
|||||||
def predict_storage_path(self, content: str) -> int | None:
|
def predict_storage_path(self, content: str) -> int | None:
|
||||||
if self.storage_path_classifier:
|
if self.storage_path_classifier:
|
||||||
X = self._vectorize(content)
|
X = self._vectorize(content)
|
||||||
predicted_id = _predict_with_threshold(
|
storage_path_id = self.storage_path_classifier.predict(X)
|
||||||
self.storage_path_classifier,
|
if storage_path_id != -1:
|
||||||
X,
|
return storage_path_id
|
||||||
settings.CLASSIFIER_MATCH_THRESHOLD,
|
else:
|
||||||
)
|
return None
|
||||||
return predicted_id
|
else:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ from documents.templating.workflows import parse_w_workflow_placeholders
|
|||||||
from documents.utils import compute_checksum
|
from documents.utils import compute_checksum
|
||||||
from documents.utils import copy_basic_file_stats
|
from documents.utils import copy_basic_file_stats
|
||||||
from documents.utils import copy_file_with_basic_stats
|
from documents.utils import copy_file_with_basic_stats
|
||||||
|
from documents.utils import normalize_unicode
|
||||||
from documents.utils import run_subprocess
|
from documents.utils import run_subprocess
|
||||||
from paperless.config import OcrConfig
|
from paperless.config import OcrConfig
|
||||||
from paperless.config import RemoteOCRConfig
|
from paperless.config import RemoteOCRConfig
|
||||||
@@ -201,7 +202,9 @@ class ConsumerPluginMixin:
|
|||||||
|
|
||||||
self.renew_logging_group()
|
self.renew_logging_group()
|
||||||
|
|
||||||
self.filename = self.metadata.filename or self.input_doc.original_file.name
|
self.filename = normalize_unicode(
|
||||||
|
self.metadata.filename or self.input_doc.original_file.name,
|
||||||
|
)
|
||||||
|
|
||||||
def _send_progress(
|
def _send_progress(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -156,15 +156,6 @@ class FileStabilityTracker:
|
|||||||
logger.debug(f"File disappeared during stability check: {path}")
|
logger.debug(f"File disappeared during stability check: {path}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Stable, but empty: some scanners create a zero byte placeholder
|
|
||||||
# and only write the page some time later. Consuming it now can
|
|
||||||
# only fail so drop it and let the writer's next event
|
|
||||||
# (or the periodic rescan) bring it back once it has content
|
|
||||||
if not tracked.last_size:
|
|
||||||
to_remove.append(path)
|
|
||||||
logger.debug("Ignoring stable but empty file: %s", path)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# File is stable, we can return it
|
# File is stable, we can return it
|
||||||
to_yield.append(path)
|
to_yield.append(path)
|
||||||
logger.info(f"File is stable: {path}")
|
logger.info(f"File is stable: {path}")
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from documents.models import Workflow
|
|||||||
from documents.models import WorkflowTrigger
|
from documents.models import WorkflowTrigger
|
||||||
from documents.permissions import permitted_object_ids
|
from documents.permissions import permitted_object_ids
|
||||||
from documents.regex import safe_regex_search
|
from documents.regex import safe_regex_search
|
||||||
|
from documents.utils import normalize_unicode
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
@@ -311,11 +312,12 @@ def consumable_document_matches_workflow(
|
|||||||
trigger_matched = False
|
trigger_matched = False
|
||||||
|
|
||||||
# Document filename vs trigger filename
|
# Document filename vs trigger filename
|
||||||
|
document_filename = normalize_unicode(document.original_file.name)
|
||||||
if (
|
if (
|
||||||
trigger.filter_filename is not None
|
trigger.filter_filename is not None
|
||||||
and len(trigger.filter_filename) > 0
|
and len(trigger.filter_filename) > 0
|
||||||
and not fnmatch(
|
and not fnmatch(
|
||||||
document.original_file.name.lower(),
|
document_filename.lower(),
|
||||||
trigger.filter_filename.lower(),
|
trigger.filter_filename.lower(),
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
@@ -328,10 +330,12 @@ def consumable_document_matches_workflow(
|
|||||||
# Document path vs trigger path
|
# Document path vs trigger path
|
||||||
|
|
||||||
# Use the original_path if set, else us the original_file
|
# Use the original_path if set, else us the original_file
|
||||||
match_against = (
|
match_against = normalize_unicode(
|
||||||
|
str(
|
||||||
document.original_path
|
document.original_path
|
||||||
if document.original_path is not None
|
if document.original_path is not None
|
||||||
else document.original_file
|
else document.original_file,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -536,7 +540,7 @@ def existing_document_matches_workflow(
|
|||||||
and len(trigger.filter_filename) > 0
|
and len(trigger.filter_filename) > 0
|
||||||
and document.original_filename is not None
|
and document.original_filename is not None
|
||||||
and not fnmatch(
|
and not fnmatch(
|
||||||
document.original_filename.lower(),
|
normalize_unicode(document.original_filename).lower(),
|
||||||
trigger.filter_filename.lower(),
|
trigger.filter_filename.lower(),
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
|
|||||||
+4
-11
@@ -1,5 +1,4 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import uuid
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
@@ -28,6 +27,7 @@ from django_softdelete.models import SoftDeleteModel
|
|||||||
|
|
||||||
from documents.data_models import DocumentSource
|
from documents.data_models import DocumentSource
|
||||||
from documents.parsers import get_default_file_extension
|
from documents.parsers import get_default_file_extension
|
||||||
|
from documents.utils import normalize_unicode
|
||||||
|
|
||||||
|
|
||||||
class ModelWithOwner(models.Model):
|
class ModelWithOwner(models.Model):
|
||||||
@@ -468,7 +468,7 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
|||||||
context_document = (
|
context_document = (
|
||||||
self.root_document if self.root_document_id is not None else self
|
self.root_document if self.root_document_id is not None else self
|
||||||
)
|
)
|
||||||
result = str(context_document)
|
result = normalize_unicode(str(context_document))
|
||||||
|
|
||||||
if counter:
|
if counter:
|
||||||
result += f"_{counter:02}"
|
result += f"_{counter:02}"
|
||||||
@@ -515,20 +515,13 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
|||||||
def delete(
|
def delete(
|
||||||
self,
|
self,
|
||||||
*args,
|
*args,
|
||||||
transaction_id=None,
|
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
# Versions must share the root's transaction ID so they are restored
|
# If deleting a root document, move all its versions to trash as well.
|
||||||
# together by django-softdelete.
|
|
||||||
if transaction_id is None:
|
|
||||||
transaction_id = uuid.uuid4()
|
|
||||||
if self.root_document_id is None:
|
if self.root_document_id is None:
|
||||||
Document.objects.filter(root_document=self).delete(
|
Document.objects.filter(root_document=self).delete()
|
||||||
transaction_id=transaction_id,
|
|
||||||
)
|
|
||||||
return super().delete(
|
return super().delete(
|
||||||
*args,
|
*args,
|
||||||
transaction_id=transaction_id,
|
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ from documents.regex import validate_regex_pattern
|
|||||||
from documents.templating.filepath import validate_filepath_template_and_render
|
from documents.templating.filepath import validate_filepath_template_and_render
|
||||||
from documents.templating.utils import convert_format_str_to_template_format
|
from documents.templating.utils import convert_format_str_to_template_format
|
||||||
from documents.templating.workflows import validate_workflow_template
|
from documents.templating.workflows import validate_workflow_template
|
||||||
|
from documents.utils import normalize_unicode
|
||||||
from documents.validators import uri_validator
|
from documents.validators import uri_validator
|
||||||
from documents.validators import url_validator
|
from documents.validators import url_validator
|
||||||
from documents.versioning import sort_versions_newest_first
|
from documents.versioning import sort_versions_newest_first
|
||||||
@@ -3120,6 +3121,13 @@ class WorkflowTriggerSerializer(serializers.ModelSerializer[WorkflowTrigger]):
|
|||||||
):
|
):
|
||||||
attrs["filter_path"] = None
|
attrs["filter_path"] = None
|
||||||
|
|
||||||
|
# Normalize once at write time, since these are matched against many
|
||||||
|
# documents but edited rarely
|
||||||
|
if attrs.get("filter_filename") is not None:
|
||||||
|
attrs["filter_filename"] = normalize_unicode(attrs["filter_filename"])
|
||||||
|
if attrs.get("filter_path") is not None:
|
||||||
|
attrs["filter_path"] = normalize_unicode(attrs["filter_path"])
|
||||||
|
|
||||||
if (
|
if (
|
||||||
"filter_custom_field_query" in attrs
|
"filter_custom_field_query" in attrs
|
||||||
and attrs["filter_custom_field_query"] is not None
|
and attrs["filter_custom_field_query"] is not None
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import unicodedata
|
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from pathlib import PurePath
|
from pathlib import PurePath
|
||||||
|
|
||||||
@@ -26,6 +25,7 @@ from documents.templating.environment import _template_environment
|
|||||||
from documents.templating.filters import format_datetime
|
from documents.templating.filters import format_datetime
|
||||||
from documents.templating.filters import get_cf_value
|
from documents.templating.filters import get_cf_value
|
||||||
from documents.templating.filters import localize_date
|
from documents.templating.filters import localize_date
|
||||||
|
from documents.utils import normalize_unicode
|
||||||
|
|
||||||
logger = logging.getLogger("paperless.templating")
|
logger = logging.getLogger("paperless.templating")
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ class FilePathTemplate(Template):
|
|||||||
3. Removing extra spaces before and after forward slashes
|
3. Removing extra spaces before and after forward slashes
|
||||||
4. Preserving spaces in other parts of the path
|
4. Preserving spaces in other parts of the path
|
||||||
"""
|
"""
|
||||||
value = unicodedata.normalize("NFC", value)
|
value = normalize_unicode(value)
|
||||||
value = value.replace("\n", "").replace("\r", "")
|
value = value.replace("\n", "").replace("\r", "")
|
||||||
value = re.sub(r"\s*/\s*", "/", value)
|
value = re.sub(r"\s*/\s*", "/", value)
|
||||||
|
|
||||||
@@ -184,17 +184,17 @@ def get_basic_metadata_context(
|
|||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
"title": pathvalidate.sanitize_filename(
|
"title": pathvalidate.sanitize_filename(
|
||||||
unicodedata.normalize("NFC", document.title),
|
normalize_unicode(document.title),
|
||||||
replacement_text="-",
|
replacement_text="-",
|
||||||
),
|
),
|
||||||
"correspondent": pathvalidate.sanitize_filename(
|
"correspondent": pathvalidate.sanitize_filename(
|
||||||
unicodedata.normalize("NFC", document.correspondent.name),
|
normalize_unicode(document.correspondent.name),
|
||||||
replacement_text="-",
|
replacement_text="-",
|
||||||
)
|
)
|
||||||
if document.correspondent
|
if document.correspondent
|
||||||
else no_value_default,
|
else no_value_default,
|
||||||
"document_type": pathvalidate.sanitize_filename(
|
"document_type": pathvalidate.sanitize_filename(
|
||||||
unicodedata.normalize("NFC", document.document_type.name),
|
normalize_unicode(document.document_type.name),
|
||||||
replacement_text="-",
|
replacement_text="-",
|
||||||
)
|
)
|
||||||
if document.document_type
|
if document.document_type
|
||||||
@@ -205,8 +205,7 @@ def get_basic_metadata_context(
|
|||||||
"owner_username": document.owner.username
|
"owner_username": document.owner.username
|
||||||
if document.owner
|
if document.owner
|
||||||
else no_value_default,
|
else no_value_default,
|
||||||
"original_name": unicodedata.normalize(
|
"original_name": normalize_unicode(
|
||||||
"NFC",
|
|
||||||
PurePath(document.original_filename).with_suffix("").name,
|
PurePath(document.original_filename).with_suffix("").name,
|
||||||
)
|
)
|
||||||
if document.original_filename
|
if document.original_filename
|
||||||
@@ -275,12 +274,12 @@ def get_tags_context(tags: Iterable[Tag]) -> dict[str, str | list[str]]:
|
|||||||
return {
|
return {
|
||||||
"tag_list": pathvalidate.sanitize_filename(
|
"tag_list": pathvalidate.sanitize_filename(
|
||||||
",".join(
|
",".join(
|
||||||
sorted(unicodedata.normalize("NFC", tag.name) for tag in tags),
|
sorted(normalize_unicode(tag.name) for tag in tags),
|
||||||
),
|
),
|
||||||
replacement_text="-",
|
replacement_text="-",
|
||||||
),
|
),
|
||||||
# Assumed to be ordered, but a template could loop through to find what they want
|
# Assumed to be ordered, but a template could loop through to find what they want
|
||||||
"tag_name_list": [unicodedata.normalize("NFC", x.name) for x in tags],
|
"tag_name_list": [normalize_unicode(x.name) for x in tags],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -307,7 +306,7 @@ def get_custom_fields_context(
|
|||||||
CustomField.FieldDataType.LONG_TEXT,
|
CustomField.FieldDataType.LONG_TEXT,
|
||||||
}:
|
}:
|
||||||
value = pathvalidate.sanitize_filename(
|
value = pathvalidate.sanitize_filename(
|
||||||
unicodedata.normalize("NFC", field_instance.value),
|
normalize_unicode(field_instance.value),
|
||||||
replacement_text="-",
|
replacement_text="-",
|
||||||
)
|
)
|
||||||
elif (
|
elif (
|
||||||
@@ -316,8 +315,7 @@ def get_custom_fields_context(
|
|||||||
):
|
):
|
||||||
options = field_instance.field.extra_data["select_options"]
|
options = field_instance.field.extra_data["select_options"]
|
||||||
value = pathvalidate.sanitize_filename(
|
value = pathvalidate.sanitize_filename(
|
||||||
unicodedata.normalize(
|
normalize_unicode(
|
||||||
"NFC",
|
|
||||||
next(
|
next(
|
||||||
option["label"]
|
option["label"]
|
||||||
for option in options
|
for option in options
|
||||||
@@ -330,7 +328,7 @@ def get_custom_fields_context(
|
|||||||
value = field_instance.value
|
value = field_instance.value
|
||||||
field_data["custom_fields"][
|
field_data["custom_fields"][
|
||||||
pathvalidate.sanitize_filename(
|
pathvalidate.sanitize_filename(
|
||||||
unicodedata.normalize("NFC", field_instance.field.name),
|
normalize_unicode(field_instance.field.name),
|
||||||
replacement_text="-",
|
replacement_text="-",
|
||||||
)
|
)
|
||||||
] = {
|
] = {
|
||||||
|
|||||||
@@ -207,65 +207,3 @@ class TestTrashAPI(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn("have not yet been deleted", resp.data["documents"][0])
|
self.assertIn("have not yet been deleted", resp.data["documents"][0])
|
||||||
|
|
||||||
def _make_versioned_document(self) -> tuple[Document, list[Document]]:
|
|
||||||
root = Document.objects.create(
|
|
||||||
title="root",
|
|
||||||
content="root-content",
|
|
||||||
checksum="root",
|
|
||||||
mime_type="application/pdf",
|
|
||||||
)
|
|
||||||
versions = [
|
|
||||||
Document.objects.create(
|
|
||||||
title=f"v{index}",
|
|
||||||
content=f"v{index}-content",
|
|
||||||
checksum=f"v{index}",
|
|
||||||
mime_type="application/pdf",
|
|
||||||
root_document=root,
|
|
||||||
version_index=index,
|
|
||||||
)
|
|
||||||
for index in range(1, 3)
|
|
||||||
]
|
|
||||||
return root, versions
|
|
||||||
|
|
||||||
def test_api_trash_restore_document_restores_its_versions(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Existing document with two versions
|
|
||||||
WHEN:
|
|
||||||
- API request to delete the document
|
|
||||||
- API request to restore it from the trash
|
|
||||||
THEN:
|
|
||||||
- Only the document itself is listed in the trash
|
|
||||||
- A version cannot be restored without its root
|
|
||||||
- The document is restored together with all of its versions
|
|
||||||
"""
|
|
||||||
root, versions = self._make_versioned_document()
|
|
||||||
|
|
||||||
self.client.force_login(user=self.user)
|
|
||||||
self.client.delete(f"/api/documents/{root.pk}/")
|
|
||||||
self.assertEqual(Document.deleted_objects.count(), 3)
|
|
||||||
|
|
||||||
resp = self.client.get("/api/trash/")
|
|
||||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertEqual(resp.data["count"], 1)
|
|
||||||
self.assertEqual(resp.data["results"][0]["id"], root.pk)
|
|
||||||
|
|
||||||
# A version cannot be restored while its root remains in the trash.
|
|
||||||
resp = self.client.post(
|
|
||||||
"/api/trash/",
|
|
||||||
{"action": "restore", "documents": [versions[0].pk]},
|
|
||||||
)
|
|
||||||
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
|
||||||
self.assertIn("Restore the root document", resp.data["documents"][0])
|
|
||||||
|
|
||||||
resp = self.client.post(
|
|
||||||
"/api/trash/",
|
|
||||||
{"action": "restore", "documents": [root.pk]},
|
|
||||||
)
|
|
||||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertEqual(Document.deleted_objects.count(), 0)
|
|
||||||
self.assertCountEqual(
|
|
||||||
Document.objects.filter(root_document=root).values_list("id", flat=True),
|
|
||||||
[version.pk for version in versions],
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import unicodedata
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import celery.result
|
||||||
|
import pytest
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
|
||||||
|
from documents.models import Document
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from documents.data_models import ConsumableDocument
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def consume_file_mock():
|
||||||
|
with mock.patch("documents.tasks.consume_file.apply_async") as m:
|
||||||
|
m.return_value = celery.result.AsyncResult(id="test-task-id")
|
||||||
|
yield m
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def directories(tmp_path, settings, _media_settings):
|
||||||
|
scratch = tmp_path / "scratch"
|
||||||
|
scratch.mkdir()
|
||||||
|
settings.SCRATCH_DIR = scratch
|
||||||
|
return scratch
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestUpdateVersionNFCNormalization:
|
||||||
|
def test_nfd_filename_normalized_to_nfc(
|
||||||
|
self,
|
||||||
|
admin_client,
|
||||||
|
consume_file_mock: mock.MagicMock,
|
||||||
|
directories,
|
||||||
|
):
|
||||||
|
"""Uploaded new-version file with NFD filename must have its temp name stored as NFC."""
|
||||||
|
document = Document.objects.create(
|
||||||
|
title="Test",
|
||||||
|
content="content",
|
||||||
|
checksum="checksum",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
nfd = unicodedata.normalize("NFD", "Rechnung März.pdf")
|
||||||
|
nfc = unicodedata.normalize("NFC", "Rechnung März.pdf")
|
||||||
|
|
||||||
|
assert nfd != nfc
|
||||||
|
|
||||||
|
uploaded = SimpleUploadedFile(
|
||||||
|
nfd,
|
||||||
|
b"%PDF-1.4 test",
|
||||||
|
content_type="application/pdf",
|
||||||
|
)
|
||||||
|
response = admin_client.post(
|
||||||
|
f"/api/documents/{document.pk}/update_version/",
|
||||||
|
{"document": uploaded},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
task_kwargs = consume_file_mock.call_args.kwargs["kwargs"]
|
||||||
|
input_doc: ConsumableDocument = task_kwargs["input_doc"]
|
||||||
|
|
||||||
|
assert input_doc.original_file.name == nfc, (
|
||||||
|
f"Expected NFC filename {nfc!r}, got {input_doc.original_file.name!r}"
|
||||||
|
)
|
||||||
|
assert unicodedata.is_normalized("NFC", input_doc.original_file.name)
|
||||||
@@ -392,11 +392,6 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
|||||||
self.assertFalse(Document.objects.filter(id=self.doc1.id).exists())
|
self.assertFalse(Document.objects.filter(id=self.doc1.id).exists())
|
||||||
self.assertFalse(Document.objects.filter(id=version.id).exists())
|
self.assertFalse(Document.objects.filter(id=version.id).exists())
|
||||||
|
|
||||||
Document.deleted_objects.get(id=self.doc1.id).restore(strict=False)
|
|
||||||
|
|
||||||
self.assertTrue(Document.objects.filter(id=self.doc1.id).exists())
|
|
||||||
self.assertTrue(Document.objects.filter(id=version.id).exists())
|
|
||||||
|
|
||||||
def test_delete_version_document_keeps_root(self) -> None:
|
def test_delete_version_document_keeps_root(self) -> None:
|
||||||
version = Document.objects.create(
|
version = Document.objects.create(
|
||||||
checksum="A-v1",
|
checksum="A-v1",
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import warnings
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
@@ -12,7 +11,6 @@ from django.test import override_settings
|
|||||||
from documents.classifier import ClassifierModelCorruptError
|
from documents.classifier import ClassifierModelCorruptError
|
||||||
from documents.classifier import DocumentClassifier
|
from documents.classifier import DocumentClassifier
|
||||||
from documents.classifier import IncompatibleClassifierVersionError
|
from documents.classifier import IncompatibleClassifierVersionError
|
||||||
from documents.classifier import _predict_with_threshold
|
|
||||||
from documents.classifier import load_classifier
|
from documents.classifier import load_classifier
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
@@ -627,103 +625,6 @@ class TestClassifier(DirectoriesMixin, TestCase):
|
|||||||
self.assertEqual(self.classifier.predict_storage_path(doc1.content), sp.pk)
|
self.assertEqual(self.classifier.predict_storage_path(doc1.content), sp.pk)
|
||||||
self.assertIsNone(self.classifier.predict_storage_path(doc2.content))
|
self.assertIsNone(self.classifier.predict_storage_path(doc2.content))
|
||||||
|
|
||||||
def test_predict_rejects_prediction_below_match_threshold(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Classifiers trained against test data with confident predictions
|
|
||||||
WHEN:
|
|
||||||
- CLASSIFIER_MATCH_THRESHOLD exceeds the model's confidence
|
|
||||||
THEN:
|
|
||||||
- Every predict_* method discards the match in favor of no match
|
|
||||||
"""
|
|
||||||
c1 = Correspondent.objects.create(
|
|
||||||
name="c1",
|
|
||||||
matching_algorithm=Correspondent.MATCH_AUTO,
|
|
||||||
)
|
|
||||||
dt1 = DocumentType.objects.create(
|
|
||||||
name="dt1",
|
|
||||||
matching_algorithm=DocumentType.MATCH_AUTO,
|
|
||||||
)
|
|
||||||
sp1 = StoragePath.objects.create(
|
|
||||||
name="sp1",
|
|
||||||
matching_algorithm=StoragePath.MATCH_AUTO,
|
|
||||||
)
|
|
||||||
|
|
||||||
doc1 = Document.objects.create(
|
|
||||||
title="doc1",
|
|
||||||
content="this is a document from c1",
|
|
||||||
correspondent=c1,
|
|
||||||
document_type=dt1,
|
|
||||||
storage_path=sp1,
|
|
||||||
checksum="A",
|
|
||||||
)
|
|
||||||
Document.objects.create(
|
|
||||||
title="doc2",
|
|
||||||
content="this is a document from no one",
|
|
||||||
checksum="B",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.classifier.train()
|
|
||||||
|
|
||||||
predictors = {
|
|
||||||
"correspondent": self.classifier.predict_correspondent,
|
|
||||||
"document_type": self.classifier.predict_document_type,
|
|
||||||
"storage_path": self.classifier.predict_storage_path,
|
|
||||||
}
|
|
||||||
# No real prediction can reach a confidence this high, so this
|
|
||||||
# isolates the threshold check from the model's actual output.
|
|
||||||
with override_settings(CLASSIFIER_MATCH_THRESHOLD=0.999999):
|
|
||||||
for name, predict in predictors.items():
|
|
||||||
with self.subTest(field=name):
|
|
||||||
self.assertIsNone(predict(doc1.content))
|
|
||||||
|
|
||||||
def test_train_uses_balanced_sample_weight(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A training set with correspondents, document types and storage paths
|
|
||||||
WHEN:
|
|
||||||
- The classifier is trained
|
|
||||||
THEN:
|
|
||||||
- Each MLP classifier is fit with balanced sample weights, so that
|
|
||||||
over-represented classes don't dominate predictions
|
|
||||||
"""
|
|
||||||
c1 = Correspondent.objects.create(
|
|
||||||
name="c1",
|
|
||||||
matching_algorithm=Correspondent.MATCH_AUTO,
|
|
||||||
)
|
|
||||||
dt1 = DocumentType.objects.create(
|
|
||||||
name="dt1",
|
|
||||||
matching_algorithm=DocumentType.MATCH_AUTO,
|
|
||||||
)
|
|
||||||
sp1 = StoragePath.objects.create(
|
|
||||||
name="sp1",
|
|
||||||
matching_algorithm=StoragePath.MATCH_AUTO,
|
|
||||||
)
|
|
||||||
|
|
||||||
Document.objects.create(
|
|
||||||
title="doc1",
|
|
||||||
content="this is a document from c1",
|
|
||||||
correspondent=c1,
|
|
||||||
document_type=dt1,
|
|
||||||
storage_path=sp1,
|
|
||||||
checksum="A",
|
|
||||||
)
|
|
||||||
Document.objects.create(
|
|
||||||
title="doc2",
|
|
||||||
content="this is a document from no one",
|
|
||||||
checksum="B",
|
|
||||||
)
|
|
||||||
|
|
||||||
with mock.patch(
|
|
||||||
"sklearn.utils.class_weight.compute_sample_weight",
|
|
||||||
return_value=None,
|
|
||||||
) as mocked_compute_sample_weight:
|
|
||||||
self.classifier.train()
|
|
||||||
|
|
||||||
self.assertEqual(mocked_compute_sample_weight.call_count, 3)
|
|
||||||
for call in mocked_compute_sample_weight.call_args_list:
|
|
||||||
self.assertEqual(call.args[0], "balanced")
|
|
||||||
|
|
||||||
def test_one_tag_predict(self) -> None:
|
def test_one_tag_predict(self) -> None:
|
||||||
t1 = Tag.objects.create(name="t1", matching_algorithm=Tag.MATCH_AUTO, pk=12)
|
t1 = Tag.objects.create(name="t1", matching_algorithm=Tag.MATCH_AUTO, pk=12)
|
||||||
|
|
||||||
@@ -909,52 +810,6 @@ class TestClassifier(DirectoriesMixin, TestCase):
|
|||||||
load_classifier(raise_exception=True)
|
load_classifier(raise_exception=True)
|
||||||
|
|
||||||
|
|
||||||
class _StubProbaClassifier:
|
|
||||||
"""
|
|
||||||
A fake scikit-learn classifier exposing just enough of the API for
|
|
||||||
`_predict_with_threshold`: `classes_` and `predict_proba`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, classes: list[int], probabilities: list[float]) -> None:
|
|
||||||
self.classes_ = np.array(classes)
|
|
||||||
self._probabilities = np.array([probabilities])
|
|
||||||
|
|
||||||
def predict_proba(self, X) -> np.ndarray:
|
|
||||||
return self._probabilities
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("classes", "probabilities", "threshold", "expected"),
|
|
||||||
[
|
|
||||||
# confident prediction above the threshold is returned
|
|
||||||
([-1, 3], [0.1, 0.9], 0.6, 3),
|
|
||||||
# prediction below the threshold is discarded
|
|
||||||
([-1, 3], [0.45, 0.55], 0.6, None),
|
|
||||||
# boundary: exactly at the threshold is accepted, not discarded
|
|
||||||
([-1, 3], [0.4, 0.6], 0.6, 3),
|
|
||||||
# the winning class is the "no match" pseudo-class, regardless of its
|
|
||||||
# own confidence
|
|
||||||
([-1, 3], [0.99, 0.01], 0.0, None),
|
|
||||||
# threshold of 0.0 disables the confidence check entirely
|
|
||||||
([-1, 3], [0.45, 0.55], 0.0, 3),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_predict_with_threshold(classes, probabilities, threshold, expected) -> None:
|
|
||||||
classifier = _StubProbaClassifier(classes, probabilities)
|
|
||||||
result = _predict_with_threshold(classifier, X=None, threshold=threshold)
|
|
||||||
assert result == expected
|
|
||||||
|
|
||||||
|
|
||||||
def test_classifier_match_threshold_default() -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- No PAPERLESS_CLASSIFIER_MATCH_THRESHOLD environment variable is set
|
|
||||||
THEN:
|
|
||||||
- The classifier match threshold defaults to 0.6
|
|
||||||
"""
|
|
||||||
assert settings.CLASSIFIER_MATCH_THRESHOLD == 0.6
|
|
||||||
|
|
||||||
|
|
||||||
def test_preprocess_content() -> None:
|
def test_preprocess_content() -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ class TestDocument(TestCase):
|
|||||||
checksum="checksum",
|
checksum="checksum",
|
||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
)
|
)
|
||||||
version = Document.objects.create(
|
Document.objects.create(
|
||||||
root_document=root,
|
root_document=root,
|
||||||
correspondent=root.correspondent,
|
correspondent=root.correspondent,
|
||||||
title="Version",
|
title="Version",
|
||||||
@@ -124,10 +124,6 @@ class TestDocument(TestCase):
|
|||||||
self.assertEqual(Document.objects.count(), 0)
|
self.assertEqual(Document.objects.count(), 0)
|
||||||
self.assertEqual(Document.deleted_objects.count(), 2)
|
self.assertEqual(Document.deleted_objects.count(), 2)
|
||||||
|
|
||||||
root.restore(strict=False)
|
|
||||||
|
|
||||||
self.assertTrue(Document.objects.filter(pk=version.pk).exists())
|
|
||||||
|
|
||||||
def test_file_name(self) -> None:
|
def test_file_name(self) -> None:
|
||||||
doc = Document(
|
doc = Document(
|
||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import unicodedata
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from documents.models import Correspondent
|
||||||
|
from documents.models import Document
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestGetPublicFilenameNfc:
|
||||||
|
def test_normalizes_nfd_title_to_nfc(self) -> None:
|
||||||
|
nfd_title = unicodedata.normalize("NFD", "Gehaltserhöhung")
|
||||||
|
assert not unicodedata.is_normalized("NFC", nfd_title)
|
||||||
|
|
||||||
|
doc = Document(
|
||||||
|
mime_type="application/pdf",
|
||||||
|
title=nfd_title,
|
||||||
|
created=date(2025, 10, 17),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = doc.get_public_filename()
|
||||||
|
|
||||||
|
assert unicodedata.is_normalized("NFC", result)
|
||||||
|
assert (
|
||||||
|
result
|
||||||
|
== "2025-10-17 "
|
||||||
|
+ unicodedata.normalize(
|
||||||
|
"NFC",
|
||||||
|
nfd_title,
|
||||||
|
)
|
||||||
|
+ ".pdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_normalizes_nfd_correspondent_name_to_nfc(self) -> None:
|
||||||
|
nfd_name = unicodedata.normalize("NFD", "Müller GmbH")
|
||||||
|
correspondent = Correspondent.objects.create(name=nfd_name)
|
||||||
|
|
||||||
|
doc = Document.objects.create(
|
||||||
|
mime_type="application/pdf",
|
||||||
|
title="Rechnung",
|
||||||
|
created=date(2025, 10, 17),
|
||||||
|
correspondent=correspondent,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = doc.get_public_filename()
|
||||||
|
|
||||||
|
assert unicodedata.is_normalized("NFC", result)
|
||||||
@@ -136,23 +136,6 @@ def wait_for_mock_call(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def sleep_past_stability(
|
|
||||||
owner: FileStabilityTracker | ConsumerThread,
|
|
||||||
*,
|
|
||||||
windows: float = 1.5,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Block until a tracked file's stability window has certainly elapsed.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
owner: The tracker, or the consumer thread running one, whose
|
|
||||||
configured stability delay sets the wait.
|
|
||||||
windows: How many stability windows to wait, giving slop for a slow
|
|
||||||
or loaded test runner.
|
|
||||||
"""
|
|
||||||
sleep(owner.stability_delay * windows)
|
|
||||||
|
|
||||||
|
|
||||||
class TestTrackedFile:
|
class TestTrackedFile:
|
||||||
"""Tests for the TrackedFile dataclass."""
|
"""Tests for the TrackedFile dataclass."""
|
||||||
|
|
||||||
@@ -278,56 +261,6 @@ class TestFileStabilityTracker:
|
|||||||
assert len(stable) == 0
|
assert len(stable) == 0
|
||||||
assert stability_tracker.pending_count == 1
|
assert stability_tracker.pending_count == 1
|
||||||
|
|
||||||
def test_get_stable_files_skips_empty_file(
|
|
||||||
self,
|
|
||||||
stability_tracker: FileStabilityTracker,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A zero byte file, tracked and past its stability delay
|
|
||||||
WHEN:
|
|
||||||
- Stable files are collected
|
|
||||||
THEN:
|
|
||||||
- The file is not yielded for consumption
|
|
||||||
- The file is dropped from tracking rather than held, so an
|
|
||||||
abandoned placeholder does not keep the watch loop awake
|
|
||||||
"""
|
|
||||||
empty = tmp_path / "scan.pdf"
|
|
||||||
empty.write_bytes(b"")
|
|
||||||
stability_tracker.track(empty, Change.added)
|
|
||||||
sleep_past_stability(stability_tracker)
|
|
||||||
|
|
||||||
stable = list(stability_tracker.get_stable_files())
|
|
||||||
|
|
||||||
assert stable == []
|
|
||||||
assert stability_tracker.pending_count == 0
|
|
||||||
|
|
||||||
def test_empty_file_is_yielded_once_content_arrives(
|
|
||||||
self,
|
|
||||||
stability_tracker: FileStabilityTracker,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A zero byte file which was dropped from tracking while empty
|
|
||||||
WHEN:
|
|
||||||
- The writer fills the file and a new event re-tracks it
|
|
||||||
THEN:
|
|
||||||
- The file is yielded for consumption once it is stable
|
|
||||||
"""
|
|
||||||
target = tmp_path / "scan.pdf"
|
|
||||||
target.write_bytes(b"")
|
|
||||||
stability_tracker.track(target, Change.added)
|
|
||||||
sleep_past_stability(stability_tracker)
|
|
||||||
assert list(stability_tracker.get_stable_files()) == []
|
|
||||||
|
|
||||||
target.write_bytes(b"%PDF-1.4 content")
|
|
||||||
stability_tracker.track(target, Change.modified)
|
|
||||||
sleep_past_stability(stability_tracker)
|
|
||||||
|
|
||||||
assert list(stability_tracker.get_stable_files()) == [target]
|
|
||||||
|
|
||||||
def test_get_stable_files_deleted_during_check(self, temp_file: Path) -> None:
|
def test_get_stable_files_deleted_during_check(self, temp_file: Path) -> None:
|
||||||
"""Test deleted file is not returned during stability check."""
|
"""Test deleted file is not returned during stability check."""
|
||||||
tracker = FileStabilityTracker(stability_delay=0.1)
|
tracker = FileStabilityTracker(stability_delay=0.1)
|
||||||
@@ -946,51 +879,6 @@ class TestCommandWatch:
|
|||||||
|
|
||||||
mock_consume_file_delay.apply_async.assert_called()
|
mock_consume_file_delay.apply_async.assert_called()
|
||||||
|
|
||||||
def test_scanner_placeholder_is_not_consumed_while_empty(
|
|
||||||
self,
|
|
||||||
consumption_dir: Path,
|
|
||||||
sample_pdf: Path,
|
|
||||||
mock_consume_file_delay: MagicMock,
|
|
||||||
start_consumer: Callable[..., ConsumerThread],
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A scanner which creates a zero byte placeholder and only writes
|
|
||||||
the page some time later (GH discussion #13969)
|
|
||||||
WHEN:
|
|
||||||
- The placeholder sits untouched well past the stability delay
|
|
||||||
- The scanner then writes the real content
|
|
||||||
THEN:
|
|
||||||
- The empty placeholder is never queued, as it could only fail
|
|
||||||
with "Unsupported mime type inode/x-empty"
|
|
||||||
- The file is queued exactly once, when the content lands
|
|
||||||
"""
|
|
||||||
thread = start_consumer(stability_delay=0.2)
|
|
||||||
|
|
||||||
target = consumption_dir / "scan.pdf"
|
|
||||||
target.write_bytes(b"") # the scanner's placeholder
|
|
||||||
|
|
||||||
# Well past the stability delay: the old behaviour queued it here.
|
|
||||||
sleep_past_stability(thread, windows=5)
|
|
||||||
if thread.exception:
|
|
||||||
raise thread.exception
|
|
||||||
assert mock_consume_file_delay.apply_async.call_count == 0
|
|
||||||
|
|
||||||
shutil.copy(sample_pdf, target) # the scanner finishes the page
|
|
||||||
|
|
||||||
assert wait_for_mock_call(
|
|
||||||
mock_consume_file_delay.apply_async,
|
|
||||||
timeout_s=5.0,
|
|
||||||
)
|
|
||||||
if thread.exception:
|
|
||||||
raise thread.exception
|
|
||||||
|
|
||||||
assert mock_consume_file_delay.apply_async.call_count == 1
|
|
||||||
queued_doc = mock_consume_file_delay.apply_async.call_args.kwargs["kwargs"][
|
|
||||||
"input_doc"
|
|
||||||
]
|
|
||||||
assert queued_doc.original_file.name == "scan.pdf"
|
|
||||||
|
|
||||||
def test_ignores_macos_files(
|
def test_ignores_macos_files(
|
||||||
self,
|
self,
|
||||||
consumption_dir: Path,
|
consumption_dir: Path,
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import unicodedata
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from documents.data_models import ConsumableDocument
|
||||||
|
from documents.data_models import DocumentSource
|
||||||
|
from documents.matching import consumable_document_matches_workflow
|
||||||
|
from documents.matching import existing_document_matches_workflow
|
||||||
|
from documents.models import Document
|
||||||
|
from documents.models import Workflow
|
||||||
|
from documents.models import WorkflowTrigger
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestMatchingNfcNormalization:
|
||||||
|
def test_consumable_document_filename_nfd_matches_nfc_pattern(
|
||||||
|
self,
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A file on disk whose name is NFD-normalized
|
||||||
|
- A workflow trigger filename filter typed as NFC
|
||||||
|
WHEN:
|
||||||
|
- The consumable document is checked against the trigger
|
||||||
|
THEN:
|
||||||
|
- It matches, because both sides are normalized before comparing
|
||||||
|
"""
|
||||||
|
nfd_name = unicodedata.normalize("NFD", "Gehaltserhöhung.pdf")
|
||||||
|
nfc_pattern = unicodedata.normalize("NFC", "*Gehaltserhöhung*")
|
||||||
|
assert nfd_name != unicodedata.normalize("NFC", nfd_name)
|
||||||
|
|
||||||
|
file_path = tmp_path / nfd_name
|
||||||
|
file_path.write_bytes(b"%PDF-1.4 test")
|
||||||
|
|
||||||
|
document = ConsumableDocument(
|
||||||
|
source=DocumentSource.ConsumeFolder,
|
||||||
|
original_file=file_path,
|
||||||
|
)
|
||||||
|
trigger = WorkflowTrigger(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||||
|
filter_filename=nfc_pattern,
|
||||||
|
sources=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
matched, reason = consumable_document_matches_workflow(document, trigger)
|
||||||
|
|
||||||
|
assert matched, reason
|
||||||
|
|
||||||
|
def test_existing_document_filename_nfd_matches_nfc_pattern(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A Document whose original_filename is NFD-normalized (e.g. from
|
||||||
|
before normalization was applied at consumption time)
|
||||||
|
- A workflow trigger filename filter typed as NFC
|
||||||
|
WHEN:
|
||||||
|
- The document is checked against the trigger
|
||||||
|
THEN:
|
||||||
|
- It matches, because both sides are normalized before comparing
|
||||||
|
"""
|
||||||
|
nfd_name = unicodedata.normalize("NFD", "Gehaltserhöhung.pdf")
|
||||||
|
nfc_pattern = unicodedata.normalize("NFC", "*Gehaltserhöhung*")
|
||||||
|
|
||||||
|
document = Document.objects.create(
|
||||||
|
title="Test",
|
||||||
|
content="content",
|
||||||
|
checksum="checksum",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
original_filename=nfd_name,
|
||||||
|
)
|
||||||
|
workflow = Workflow.objects.create(name="Test workflow", order=0)
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
|
filter_filename=nfc_pattern,
|
||||||
|
)
|
||||||
|
workflow.triggers.add(trigger)
|
||||||
|
|
||||||
|
matched, reason = existing_document_matches_workflow(document, trigger)
|
||||||
|
|
||||||
|
assert matched, reason
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from documents.utils import normalize_unicode
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeUnicode:
|
||||||
|
def test_none_passes_through(self) -> None:
|
||||||
|
assert normalize_unicode(None) is None
|
||||||
@@ -32,7 +32,6 @@ from documents.signals.handlers import update_llm_suggestions_cache
|
|||||||
from documents.tests.utils import DirectoriesMixin
|
from documents.tests.utils import DirectoriesMixin
|
||||||
from documents.tests.utils import read_streaming_response
|
from documents.tests.utils import read_streaming_response
|
||||||
from paperless.models import ApplicationConfiguration
|
from paperless.models import ApplicationConfiguration
|
||||||
from paperless_ai.exceptions import LLMProviderError
|
|
||||||
from paperless_ai.exceptions import LLMTimeoutError
|
from paperless_ai.exceptions import LLMTimeoutError
|
||||||
|
|
||||||
|
|
||||||
@@ -738,38 +737,6 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
|||||||
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
|
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@patch("documents.views.get_ai_document_classification")
|
|
||||||
@override_settings(
|
|
||||||
AI_ENABLED=True,
|
|
||||||
LLM_BACKEND="openai-like",
|
|
||||||
)
|
|
||||||
def test_ai_suggestions_with_llm_provider_error(
|
|
||||||
self,
|
|
||||||
mock_get_ai_classification,
|
|
||||||
) -> None:
|
|
||||||
mock_get_ai_classification.side_effect = LLMProviderError(
|
|
||||||
"confidential provider response",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.client.force_login(user=self.user)
|
|
||||||
response = self.client.get(
|
|
||||||
f"/api/documents/{self.document.pk}/ai_suggestions/",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_502_BAD_GATEWAY)
|
|
||||||
self.assertEqual(
|
|
||||||
response.json(),
|
|
||||||
{
|
|
||||||
"ai": [
|
|
||||||
"AI backend rejected the request. Check logs for details.",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.assertNotIn("confidential provider response", response.content.decode())
|
|
||||||
self.assertIsNone(
|
|
||||||
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
|
|
||||||
)
|
|
||||||
|
|
||||||
@patch("documents.views.get_ai_document_classification")
|
@patch("documents.views.get_ai_document_classification")
|
||||||
@override_settings(
|
@override_settings(
|
||||||
AI_ENABLED=True,
|
AI_ENABLED=True,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
|
import unicodedata
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
@@ -31,6 +32,25 @@ def identity(iterable: Iterable[_T]) -> Iterable[_T]:
|
|||||||
return iterable
|
return iterable
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_unicode(value: str | None) -> str | None:
|
||||||
|
"""
|
||||||
|
Normalize a string to Unicode NFC form, or return None unchanged.
|
||||||
|
|
||||||
|
This is the single normalization pass for any user- or filesystem-supplied
|
||||||
|
text that ends up in a filename, path, or is compared/matched against one
|
||||||
|
(titles, correspondent/tag/type names, uploaded filenames, workflow and
|
||||||
|
mail rule filename/path filters). Composed (NFC) and decomposed (NFD)
|
||||||
|
forms of the same visible text are different byte sequences, which breaks
|
||||||
|
exact comparisons and filesystem lookups even though the text looks
|
||||||
|
identical. Always normalize through this function rather than calling
|
||||||
|
unicodedata.normalize() directly, so every call site agrees on the same
|
||||||
|
form.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return unicodedata.normalize("NFC", value)
|
||||||
|
|
||||||
|
|
||||||
class QuerySetStream(Generic[_M]):
|
class QuerySetStream(Generic[_M]):
|
||||||
"""Stream a QuerySet via .iterator(chunk_size=...) instead of
|
"""Stream a QuerySet via .iterator(chunk_size=...) instead of
|
||||||
materializing it (plus any prefetch caches) all at once, while still
|
materializing it (plus any prefetch caches) all at once, while still
|
||||||
|
|||||||
+5
-31
@@ -231,6 +231,7 @@ from documents.tasks import sanity_check
|
|||||||
from documents.tasks import train_classifier
|
from documents.tasks import train_classifier
|
||||||
from documents.tasks import update_document_parent_tags
|
from documents.tasks import update_document_parent_tags
|
||||||
from documents.utils import get_boolean
|
from documents.utils import get_boolean
|
||||||
|
from documents.utils import normalize_unicode
|
||||||
from documents.versioning import VersionResolutionError
|
from documents.versioning import VersionResolutionError
|
||||||
from documents.versioning import annotate_effective_content
|
from documents.versioning import annotate_effective_content
|
||||||
from documents.versioning import get_latest_version_for_root
|
from documents.versioning import get_latest_version_for_root
|
||||||
@@ -252,7 +253,6 @@ from paperless.views import StandardPagination
|
|||||||
from paperless_ai.ai_classifier import get_ai_document_classification
|
from paperless_ai.ai_classifier import get_ai_document_classification
|
||||||
from paperless_ai.ai_classifier import get_llm_output_language
|
from paperless_ai.ai_classifier import get_llm_output_language
|
||||||
from paperless_ai.chat import stream_chat_with_documents
|
from paperless_ai.chat import stream_chat_with_documents
|
||||||
from paperless_ai.exceptions import LLMProviderError
|
|
||||||
from paperless_ai.exceptions import LLMTimeoutError
|
from paperless_ai.exceptions import LLMTimeoutError
|
||||||
from paperless_ai.matching import extract_unmatched_names
|
from paperless_ai.matching import extract_unmatched_names
|
||||||
from paperless_ai.matching import match_correspondents_by_name
|
from paperless_ai.matching import match_correspondents_by_name
|
||||||
@@ -1604,22 +1604,6 @@ class DocumentViewSet(
|
|||||||
{"ai": [_("AI backend request timed out.")]},
|
{"ai": [_("AI backend request timed out.")]},
|
||||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
)
|
)
|
||||||
except LLMProviderError:
|
|
||||||
logger.exception(
|
|
||||||
"AI backend rejected the request for document %s",
|
|
||||||
doc.pk,
|
|
||||||
)
|
|
||||||
return Response(
|
|
||||||
{
|
|
||||||
"ai": [
|
|
||||||
_(
|
|
||||||
"AI backend rejected the request. "
|
|
||||||
"Check logs for details.",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
status=status.HTTP_502_BAD_GATEWAY,
|
|
||||||
)
|
|
||||||
set_llm_suggestions_cache(
|
set_llm_suggestions_cache(
|
||||||
doc.pk,
|
doc.pk,
|
||||||
llm_suggestions,
|
llm_suggestions,
|
||||||
@@ -2085,6 +2069,7 @@ class DocumentViewSet(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
doc_name, doc_data = serializer.validated_data.get("document")
|
doc_name, doc_data = serializer.validated_data.get("document")
|
||||||
|
doc_name = normalize_unicode(doc_name)
|
||||||
version_label = serializer.validated_data.get("version_label")
|
version_label = serializer.validated_data.get("version_label")
|
||||||
|
|
||||||
t = int(mktime(datetime.now().timetuple()))
|
t = int(mktime(datetime.now().timetuple()))
|
||||||
@@ -3351,7 +3336,7 @@ class PostDocumentView(GenericAPIView[Any]):
|
|||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
doc_name, doc_data = serializer.validated_data.get("document")
|
doc_name, doc_data = serializer.validated_data.get("document")
|
||||||
doc_name = normalize("NFC", doc_name)
|
doc_name = normalize_unicode(doc_name)
|
||||||
correspondent_id = serializer.validated_data.get("correspondent")
|
correspondent_id = serializer.validated_data.get("correspondent")
|
||||||
document_type_id = serializer.validated_data.get("document_type")
|
document_type_id = serializer.validated_data.get("document_type")
|
||||||
storage_path_id = serializer.validated_data.get("storage_path")
|
storage_path_id = serializer.validated_data.get("storage_path")
|
||||||
@@ -5454,10 +5439,7 @@ class TrashView(ListModelMixin, PassUserMixin):
|
|||||||
|
|
||||||
model = Document
|
model = Document
|
||||||
|
|
||||||
# A version is listed separately only when its root is not in the trash.
|
queryset = Document.deleted_objects.all()
|
||||||
queryset = Document.deleted_objects.exclude(
|
|
||||||
root_document_id__in=Document.deleted_objects.values("id"),
|
|
||||||
)
|
|
||||||
|
|
||||||
def get(self, request: Request, format: str | None = None) -> Response:
|
def get(self, request: Request, format: str | None = None) -> Response:
|
||||||
self.serializer_class = DocumentSerializer
|
self.serializer_class = DocumentSerializer
|
||||||
@@ -5488,15 +5470,7 @@ class TrashView(ListModelMixin, PassUserMixin):
|
|||||||
return HttpResponseForbidden("Insufficient permissions")
|
return HttpResponseForbidden("Insufficient permissions")
|
||||||
action = serializer.validated_data.get("action")
|
action = serializer.validated_data.get("action")
|
||||||
if action == "restore":
|
if action == "restore":
|
||||||
restored = list(self.get_queryset().filter(id__in=doc_ids))
|
restored = list(Document.deleted_objects.filter(id__in=doc_ids))
|
||||||
if len(restored) != len(doc_ids):
|
|
||||||
raise ValidationError(
|
|
||||||
{
|
|
||||||
"documents": [
|
|
||||||
"Restore the root document instead of one of its versions.",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
for doc in restored:
|
for doc in restored:
|
||||||
doc.restore(strict=False)
|
doc.restore(strict=False)
|
||||||
if restored:
|
if restored:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user