mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-10 11:48:00 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9e3ba4f69 | ||
|
|
54a6f0fd2b | ||
|
|
2d64684043 | ||
|
|
60709b8319 | ||
|
|
1c96819625 | ||
|
|
3e56dace73 |
@@ -1200,6 +1200,15 @@ 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.
|
||||||
|
|||||||
+250
-203
File diff suppressed because it is too large
Load Diff
@@ -112,6 +112,22 @@
|
|||||||
|
|
||||||
<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 { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { HideableSidebarItemID, 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,6 +209,45 @@ 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')
|
||||||
@@ -249,6 +288,7 @@ 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')
|
||||||
@@ -267,7 +307,10 @@ 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(33)
|
expect(setSpy).toHaveBeenCalledTimes(34)
|
||||||
|
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
|
||||||
|
HideableSidebarItemID.Workflows,
|
||||||
|
])
|
||||||
|
|
||||||
// succeed
|
// succeed
|
||||||
storeSpy.mockReturnValueOnce(of(true))
|
storeSpy.mockReturnValueOnce(of(true))
|
||||||
|
|||||||
@@ -39,7 +39,12 @@ import {
|
|||||||
SystemStatus,
|
SystemStatus,
|
||||||
SystemStatusItemStatus,
|
SystemStatusItemStatus,
|
||||||
} from 'src/app/data/system-status'
|
} from 'src/app/data/system-status'
|
||||||
import { GlobalSearchType, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import {
|
||||||
|
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'
|
||||||
@@ -102,6 +107,14 @@ 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',
|
||||||
@@ -149,6 +162,7 @@ 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),
|
||||||
@@ -186,6 +200,7 @@ 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()
|
||||||
@@ -203,6 +218,10 @@ 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()
|
||||||
@@ -230,6 +249,10 @@ 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()
|
||||||
@@ -279,14 +302,21 @@ 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) {
|
||||||
this.activeNavID.set(SettingsNavIDs[navIDKey])
|
navID = SettingsNavIDs[navIDKey]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.activeNavID.set(navID)
|
||||||
|
this.settings.sidebarHiddenItemsEditing.set(
|
||||||
|
navID === SettingsNavIDs.General
|
||||||
|
? [...this.settingsForm.controls.sidebarHiddenItems.value]
|
||||||
|
: null
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,6 +340,7 @@ 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(
|
||||||
@@ -436,6 +467,12 @@ 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)
|
||||||
@@ -444,8 +481,18 @@ 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() {
|
||||||
@@ -473,6 +520,10 @@ 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
|
||||||
@@ -632,6 +683,11 @@ 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,12 +86,15 @@
|
|||||||
}
|
}
|
||||||
<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">
|
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard) && !settingsService.organizingSidebarItems()">
|
||||||
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" 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"
|
||||||
@@ -237,29 +240,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews) && !settingsService.organizingSidebarItems()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
||||||
<a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" 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"
|
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows) && !settingsService.organizingSidebarItems()"
|
||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
|
||||||
tourAnchor="tour.workflows">
|
tourAnchor="tour.workflows">
|
||||||
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" 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" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail) && !settingsService.organizingSidebarItems()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
||||||
tourAnchor="tour.mail">
|
tourAnchor="tour.mail">
|
||||||
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="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"
|
||||||
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"
|
||||||
@@ -322,13 +334,16 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
<li class="nav-item mt-2" tourAnchor="tour.outro">
|
<li class="nav-item mt-2 position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Documentation) && !settingsService.organizingSidebarItems()" tourAnchor="tour.outro">
|
||||||
<a class="text-muted small d-flex align-items-center flex-wrap text-decoration-none nav-anchor"
|
<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()"
|
||||||
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 { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { 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 { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||||
import {
|
import {
|
||||||
@@ -287,6 +287,82 @@ 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,6 +7,7 @@ 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,
|
||||||
@@ -21,7 +22,11 @@ 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 { CollapsibleSection, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import {
|
||||||
|
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'
|
||||||
@@ -48,6 +53,7 @@ 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'
|
||||||
@@ -76,6 +82,8 @@ const SCROLL_THRESHOLD = 16
|
|||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
DragDropModule,
|
DragDropModule,
|
||||||
TourNgBootstrap,
|
TourNgBootstrap,
|
||||||
|
FormsModule,
|
||||||
|
SwitchComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppFrameComponent
|
export class AppFrameComponent
|
||||||
@@ -98,6 +106,7 @@ 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
|
||||||
)
|
)
|
||||||
@@ -195,6 +204,10 @@ 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">
|
<div [class.mb-3]="!compact">
|
||||||
<div class="row">
|
<div [class.row]="!compact">
|
||||||
@if (!horizontal) {
|
@if (!horizontal && !compact) {
|
||||||
<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">
|
<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">
|
||||||
@if (horizontal) {
|
@if (horizontal && !compact) {
|
||||||
<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,4 +48,14 @@ 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,6 +25,9 @@ export class SwitchComponent extends AbstractInputComponent<boolean> {
|
|||||||
@Input()
|
@Input()
|
||||||
showUnsetNote: boolean = false
|
showUnsetNote: boolean = false
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
compact: boolean = false
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,16 @@ 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 = {
|
||||||
@@ -56,6 +66,7 @@ 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',
|
||||||
@@ -127,6 +138,11 @@ 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,7 +14,11 @@ 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 { SETTINGS_KEYS, UiSettings } from '../data/ui-settings'
|
import {
|
||||||
|
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'
|
||||||
@@ -230,6 +234,35 @@ 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,6 +24,7 @@ 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,
|
||||||
@@ -313,6 +314,18 @@ 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
|
||||||
@@ -749,6 +762,29 @@ 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[]
|
||||||
|
|||||||
+40
-35
@@ -299,53 +299,55 @@ 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 tuples, supports old API
|
# Ensure add_custom_fields is a list of (int, value) tuples, supports old API
|
||||||
add_custom_fields = (
|
add_custom_fields = (
|
||||||
add_custom_fields.items()
|
[(int(field), value) for field, value in add_custom_fields.items()]
|
||||||
if isinstance(add_custom_fields, dict)
|
if isinstance(add_custom_fields, dict)
|
||||||
else [(field, None) for field in add_custom_fields]
|
else [(int(field), None) for field in add_custom_fields]
|
||||||
)
|
)
|
||||||
|
|
||||||
custom_fields = CustomField.objects.filter(
|
# Resolved once, instead of re-querying the same field for every document
|
||||||
id__in=[int(field) for field, _ in add_custom_fields],
|
custom_fields_by_id: dict[int, CustomField] = CustomField.objects.in_bulk(
|
||||||
).distinct()
|
[field_id for field_id, _ in add_custom_fields],
|
||||||
|
)
|
||||||
|
# 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]
|
||||||
|
value_field = CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[
|
||||||
|
custom_field.data_type
|
||||||
|
]
|
||||||
|
is_doclink = custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK
|
||||||
for doc_id in affected_docs:
|
for doc_id in affected_docs:
|
||||||
defaults = {}
|
if is_doclink and value and doc_id in value:
|
||||||
custom_field = custom_fields.get(id=field_id)
|
# Prevent self-linking
|
||||||
if custom_field:
|
continue
|
||||||
value_field = CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[
|
|
||||||
custom_field.data_type
|
|
||||||
]
|
|
||||||
defaults[value_field] = value
|
|
||||||
if (
|
|
||||||
custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK
|
|
||||||
and value
|
|
||||||
and doc_id in value
|
|
||||||
):
|
|
||||||
# Prevent self-linking
|
|
||||||
continue
|
|
||||||
CustomFieldInstance.objects.update_or_create(
|
CustomFieldInstance.objects.update_or_create(
|
||||||
document_id=doc_id,
|
document=docs_by_id[doc_id],
|
||||||
field_id=field_id,
|
field=custom_field,
|
||||||
defaults=defaults,
|
defaults={value_field: value},
|
||||||
)
|
)
|
||||||
if custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
|
if is_doclink:
|
||||||
doc = Document.objects.get(id=doc_id)
|
reflect_doclinks(docs_by_id[doc_id], custom_field, value)
|
||||||
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=Document.objects.get(
|
document=doclink_being_removed_instance.document,
|
||||||
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,
|
||||||
)
|
)
|
||||||
@@ -1178,10 +1180,13 @@ 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
|
||||||
"""
|
"""
|
||||||
target_doc_field_instance = CustomFieldInstance.objects.filter(
|
# select_related: a signal receiver (auditlog) touches .document/.field on
|
||||||
document_id=target_doc_id,
|
# the save() below, without this that is a per-call reload query
|
||||||
field=field,
|
target_doc_field_instance = (
|
||||||
).first()
|
CustomFieldInstance.objects.filter(document_id=target_doc_id, field=field)
|
||||||
|
.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
|
||||||
|
|||||||
+66
-28
@@ -34,6 +34,27 @@ 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
|
||||||
)
|
)
|
||||||
@@ -102,7 +123,8 @@ 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
|
||||||
FORMAT_VERSION = 10
|
# v11 - Use sample_weight for balanced training; predict_proba with threshold
|
||||||
|
FORMAT_VERSION = 11
|
||||||
|
|
||||||
HMAC_SIZE = 32 # SHA-256 digest length
|
HMAC_SIZE = 32 # SHA-256 digest length
|
||||||
|
|
||||||
@@ -324,6 +346,13 @@ 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...")
|
||||||
@@ -369,7 +398,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)
|
self.tags_classifier = MLPClassifier(tol=0.01, random_state=0)
|
||||||
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
|
||||||
@@ -380,8 +409,12 @@ 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)
|
self.correspondent_classifier = MLPClassifier(tol=0.01, random_state=0)
|
||||||
self.correspondent_classifier.fit(data_vectorized, labels_correspondent)
|
self.correspondent_classifier.fit(
|
||||||
|
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(
|
||||||
@@ -393,8 +426,12 @@ 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)
|
self.document_type_classifier = MLPClassifier(tol=0.01, random_state=0)
|
||||||
self.document_type_classifier.fit(data_vectorized, labels_document_type)
|
self.document_type_classifier.fit(
|
||||||
|
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(
|
||||||
@@ -406,10 +443,11 @@ 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)
|
self.storage_path_classifier = MLPClassifier(tol=0.01, random_state=0)
|
||||||
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
|
||||||
@@ -546,24 +584,24 @@ 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)
|
||||||
correspondent_id = self.correspondent_classifier.predict(X)
|
predicted_id = _predict_with_threshold(
|
||||||
if correspondent_id != -1:
|
self.correspondent_classifier,
|
||||||
return correspondent_id
|
X,
|
||||||
else:
|
settings.CLASSIFIER_MATCH_THRESHOLD,
|
||||||
return None
|
)
|
||||||
else:
|
return predicted_id
|
||||||
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)
|
||||||
document_type_id = self.document_type_classifier.predict(X)
|
predicted_id = _predict_with_threshold(
|
||||||
if document_type_id != -1:
|
self.document_type_classifier,
|
||||||
return document_type_id
|
X,
|
||||||
else:
|
settings.CLASSIFIER_MATCH_THRESHOLD,
|
||||||
return None
|
)
|
||||||
else:
|
return predicted_id
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def predict_tags(self, content: str) -> list[int]:
|
def predict_tags(self, content: str) -> list[int]:
|
||||||
from sklearn.utils.multiclass import type_of_target
|
from sklearn.utils.multiclass import type_of_target
|
||||||
@@ -589,10 +627,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)
|
||||||
storage_path_id = self.storage_path_classifier.predict(X)
|
predicted_id = _predict_with_threshold(
|
||||||
if storage_path_id != -1:
|
self.storage_path_classifier,
|
||||||
return storage_path_id
|
X,
|
||||||
else:
|
settings.CLASSIFIER_MATCH_THRESHOLD,
|
||||||
return None
|
)
|
||||||
else:
|
return predicted_id
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -1256,20 +1256,26 @@ class DocumentSerializer(
|
|||||||
if "tags" in validated_data
|
if "tags" in validated_data
|
||||||
else []
|
else []
|
||||||
)
|
)
|
||||||
inbox_tags_not_being_added = Tag.objects.filter(is_inbox_tag=True).exclude(
|
tags_being_added = Tag.objects.filter(id__in=tag_ids_being_added)
|
||||||
id__in=tag_ids_being_added,
|
required_by_add_tags = set(tags_being_added)
|
||||||
)
|
for tag in tags_being_added:
|
||||||
|
required_by_add_tags.update(tag.get_ancestors())
|
||||||
|
|
||||||
|
# Remove its descendants too, except any that is being added in this same update
|
||||||
|
tags_to_remove = set()
|
||||||
|
for tag in Tag.objects.filter(is_inbox_tag=True):
|
||||||
|
if tag in required_by_add_tags:
|
||||||
|
continue
|
||||||
|
tags_to_remove.add(tag)
|
||||||
|
tags_to_remove.update(tag.get_descendants())
|
||||||
|
|
||||||
if "tags" in validated_data:
|
if "tags" in validated_data:
|
||||||
validated_data["tags"] = [
|
validated_data["tags"] = [
|
||||||
tag
|
tag for tag in validated_data["tags"] if tag not in tags_to_remove
|
||||||
for tag in validated_data["tags"]
|
|
||||||
if tag not in inbox_tags_not_being_added
|
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
validated_data["tags"] = [
|
validated_data["tags"] = [
|
||||||
tag
|
tag for tag in instance.tags.all() if tag not in tags_to_remove
|
||||||
for tag in instance.tags.all()
|
|
||||||
if tag not in inbox_tags_not_being_added
|
|
||||||
]
|
]
|
||||||
|
|
||||||
if settings.AUDIT_LOG_ENABLED:
|
if settings.AUDIT_LOG_ENABLED:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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
|
||||||
@@ -11,6 +12,7 @@ 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
|
||||||
@@ -625,6 +627,103 @@ 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)
|
||||||
|
|
||||||
@@ -810,6 +909,52 @@ 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:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from unittest import mock
|
|||||||
|
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from documents import bulk_edit
|
from documents import bulk_edit
|
||||||
@@ -108,6 +109,44 @@ class TestTagHierarchy(DirectoriesMixin, APITestCase):
|
|||||||
self.document.refresh_from_db()
|
self.document.refresh_from_db()
|
||||||
assert self.document.tags.count() == 0
|
assert self.document.tags.count() == 0
|
||||||
|
|
||||||
|
def test_remove_inbox_tags_removes_nested_children(self) -> None:
|
||||||
|
inbox = Tag.objects.create(name="Inbox", is_inbox_tag=True)
|
||||||
|
nested = Tag.objects.create(name="Nested", tn_parent=inbox)
|
||||||
|
self.document.add_nested_tags([nested])
|
||||||
|
|
||||||
|
resp = self.client.patch(
|
||||||
|
f"/api/documents/{self.document.pk}/",
|
||||||
|
{"title": "new title", "remove_inbox_tags": True},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert resp.status_code == status.HTTP_200_OK
|
||||||
|
self.document.refresh_from_db()
|
||||||
|
assert self.document.tags.count() == 0
|
||||||
|
|
||||||
|
# A subsequent save must not re-add the inbox tag as an ancestor
|
||||||
|
resp = self.client.patch(
|
||||||
|
f"/api/documents/{self.document.pk}/",
|
||||||
|
{"title": "another title", "tags": [], "remove_inbox_tags": True},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert resp.status_code == status.HTTP_200_OK
|
||||||
|
self.document.refresh_from_db()
|
||||||
|
assert self.document.tags.count() == 0
|
||||||
|
|
||||||
|
def test_remove_inbox_tags_keeps_inbox_when_nested_child_added(self) -> None:
|
||||||
|
inbox = Tag.objects.create(name="Inbox", is_inbox_tag=True)
|
||||||
|
nested = Tag.objects.create(name="Nested", tn_parent=inbox)
|
||||||
|
self.document.add_nested_tags([inbox])
|
||||||
|
|
||||||
|
self.client.patch(
|
||||||
|
f"/api/documents/{self.document.pk}/",
|
||||||
|
{"tags": [nested.pk], "remove_inbox_tags": True},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.document.refresh_from_db()
|
||||||
|
tags = set(self.document.tags.values_list("pk", flat=True))
|
||||||
|
assert tags == {inbox.pk, nested.pk}
|
||||||
|
|
||||||
def test_bulk_edit_respects_hierarchy(self) -> None:
|
def test_bulk_edit_respects_hierarchy(self) -> None:
|
||||||
bulk_edit.add_tag([self.document.pk], self.child.pk)
|
bulk_edit.add_tag([self.document.pk], self.child.pk)
|
||||||
self.document.refresh_from_db()
|
self.document.refresh_from_db()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-09-09 16:03+0000\n"
|
"POT-Creation-Date: 2026-09-09 22:41+0000\n"
|
||||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: English\n"
|
"Language-Team: English\n"
|
||||||
@@ -2258,151 +2258,151 @@ msgstr ""
|
|||||||
msgid "paperless application settings"
|
msgid "paperless application settings"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:562
|
#: paperless/settings/__init__.py:569
|
||||||
msgid "English (US)"
|
msgid "English (US)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:563
|
#: paperless/settings/__init__.py:570
|
||||||
msgid "Arabic"
|
msgid "Arabic"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:564
|
#: paperless/settings/__init__.py:571
|
||||||
msgid "Afrikaans"
|
msgid "Afrikaans"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:565
|
#: paperless/settings/__init__.py:572
|
||||||
msgid "Belarusian"
|
msgid "Belarusian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:566
|
#: paperless/settings/__init__.py:573
|
||||||
msgid "Bulgarian"
|
msgid "Bulgarian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:567
|
#: paperless/settings/__init__.py:574
|
||||||
msgid "Catalan"
|
msgid "Catalan"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:568
|
#: paperless/settings/__init__.py:575
|
||||||
msgid "Czech"
|
msgid "Czech"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:569
|
#: paperless/settings/__init__.py:576
|
||||||
msgid "Danish"
|
msgid "Danish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:570
|
#: paperless/settings/__init__.py:577
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:571
|
#: paperless/settings/__init__.py:578
|
||||||
msgid "Greek"
|
msgid "Greek"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:572
|
#: paperless/settings/__init__.py:579
|
||||||
msgid "English (GB)"
|
msgid "English (GB)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:573
|
#: paperless/settings/__init__.py:580
|
||||||
msgid "Spanish"
|
msgid "Spanish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:574
|
#: paperless/settings/__init__.py:581
|
||||||
msgid "Persian"
|
msgid "Persian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:575
|
#: paperless/settings/__init__.py:582
|
||||||
msgid "Finnish"
|
msgid "Finnish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:576
|
#: paperless/settings/__init__.py:583
|
||||||
msgid "French"
|
msgid "French"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:577
|
#: paperless/settings/__init__.py:584
|
||||||
msgid "Hungarian"
|
msgid "Hungarian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:578
|
#: paperless/settings/__init__.py:585
|
||||||
msgid "Indonesian"
|
msgid "Indonesian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:579
|
#: paperless/settings/__init__.py:586
|
||||||
msgid "Italian"
|
msgid "Italian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:580
|
#: paperless/settings/__init__.py:587
|
||||||
msgid "Japanese"
|
msgid "Japanese"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:581
|
#: paperless/settings/__init__.py:588
|
||||||
msgid "Korean"
|
msgid "Korean"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:582
|
#: paperless/settings/__init__.py:589
|
||||||
msgid "Luxembourgish"
|
msgid "Luxembourgish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:583
|
#: paperless/settings/__init__.py:590
|
||||||
msgid "Norwegian"
|
msgid "Norwegian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:584
|
#: paperless/settings/__init__.py:591
|
||||||
msgid "Dutch"
|
msgid "Dutch"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:585
|
#: paperless/settings/__init__.py:592
|
||||||
msgid "Polish"
|
msgid "Polish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:586
|
#: paperless/settings/__init__.py:593
|
||||||
msgid "Portuguese (Brazil)"
|
msgid "Portuguese (Brazil)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:587
|
#: paperless/settings/__init__.py:594
|
||||||
msgid "Portuguese"
|
msgid "Portuguese"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:588
|
#: paperless/settings/__init__.py:595
|
||||||
msgid "Romanian"
|
msgid "Romanian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:589
|
#: paperless/settings/__init__.py:596
|
||||||
msgid "Russian"
|
msgid "Russian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:590
|
#: paperless/settings/__init__.py:597
|
||||||
msgid "Slovak"
|
msgid "Slovak"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:591
|
#: paperless/settings/__init__.py:598
|
||||||
msgid "Slovenian"
|
msgid "Slovenian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:592
|
#: paperless/settings/__init__.py:599
|
||||||
msgid "Serbian"
|
msgid "Serbian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:593
|
#: paperless/settings/__init__.py:600
|
||||||
msgid "Swedish"
|
msgid "Swedish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:594
|
#: paperless/settings/__init__.py:601
|
||||||
msgid "Turkish"
|
msgid "Turkish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:595
|
#: paperless/settings/__init__.py:602
|
||||||
msgid "Ukrainian"
|
msgid "Ukrainian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:596
|
#: paperless/settings/__init__.py:603
|
||||||
msgid "Vietnamese"
|
msgid "Vietnamese"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:597
|
#: paperless/settings/__init__.py:604
|
||||||
msgid "Chinese Simplified"
|
msgid "Chinese Simplified"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:598
|
#: paperless/settings/__init__.py:605
|
||||||
msgid "Chinese Traditional"
|
msgid "Chinese Traditional"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,13 @@ MODEL_FILE = get_path_from_env(
|
|||||||
"PAPERLESS_MODEL_FILE",
|
"PAPERLESS_MODEL_FILE",
|
||||||
DATA_DIR / "classification_model.pickle",
|
DATA_DIR / "classification_model.pickle",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Minimum confidence (0.0-1.0) for the ML classifier to assign a correspondent,
|
||||||
|
# document type, or storage path. 0.0 disables the threshold.
|
||||||
|
CLASSIFIER_MATCH_THRESHOLD: Final[float] = get_float_from_env(
|
||||||
|
"PAPERLESS_CLASSIFIER_MATCH_THRESHOLD",
|
||||||
|
0.6,
|
||||||
|
)
|
||||||
LLM_INDEX_DIR = DATA_DIR / "llm_index"
|
LLM_INDEX_DIR = DATA_DIR / "llm_index"
|
||||||
LLM_INDEX_LOCK = LLM_INDEX_DIR / "index.lock"
|
LLM_INDEX_LOCK = LLM_INDEX_DIR / "index.lock"
|
||||||
# Cross-process read/write lock guarding the LLM index compaction/migration
|
# Cross-process read/write lock guarding the LLM index compaction/migration
|
||||||
|
|||||||
Reference in New Issue
Block a user