Compare commits

..
Author SHA1 Message Date
Trenton H c2a9532b8f Fix: Handle Celery enqueue failures when enqueuing files for consumption (#13935) 2026-09-02 15:58:27 +00:00
Trenton H 713c857a08 Fix: Handle Celery mail task chord errors (#13936)
* Fix: mail rule loops forever when all attachments are duplicates

When every attachment in a mail is rejected as a duplicate, the chord's
header tasks all fail. Celery's default task_allow_error_cb_on_chord_header
skips the error callback in that case, so no ProcessedMail row is ever
created, and the same mail is refetched and reprocessed on every poll for
as long as it stays in the rule's maximum_age window.

* Minor simplifications and cleanup
2026-09-02 08:41:30 -07:00
GitHub Actions 912c6eb52e Auto translate strings 2026-09-01 22:07:30 +00:00
shamoon 73ef14f37a Fix/chore: refactor some signal-backed conversion technical debt (#13902) 2026-09-01 15:05:58 -07:00
Trenton H d78754bff1 Security: validate remote OCR endpoint against internal SSRF (#13897)
* Security: validate remote OCR endpoint against internal SSRF

Adds PAPERLESS_REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS (default true)
and validates remote_ocr_endpoint via validate_outbound_http_url
on the config serializer, matching the existing LLM endpoint handling.

* Validates te outbound url again right before use

* cover empty-value branch of validate_remote_ocr_endpoint because coverage

* re-validate remote OCR endpoint on every outbound request
2026-09-01 20:22:10 +00:00
29 changed files with 1019 additions and 492 deletions
+6
View File
@@ -2088,6 +2088,12 @@ password. All of these options come from their similarly-named [Django settings]
Defaults to "always". Defaults to "always".
#### [`PAPERLESS_REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS=<bool>`](#PAPERLESS_REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS) {#PAPERLESS_REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS}
: If set to false, Paperless blocks remote OCR endpoint URLs that resolve to non-public addresses (e.g., localhost, etc).
Defaults to True.
## AI {#ai} ## AI {#ai}
#### [`PAPERLESS_AI_ENABLED=<bool>`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED} #### [`PAPERLESS_AI_ENABLED=<bool>`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED}
+140 -140
View File
File diff suppressed because it is too large Load Diff
@@ -41,6 +41,8 @@ export class TrashComponent
private modalService = inject(NgbModal) private modalService = inject(NgbModal)
private settingsService = inject(SettingsService) private settingsService = inject(SettingsService)
private router = inject(Router) private router = inject(Router)
private readonly emptyTrashDelaySetting =
this.settingsService.getSignal<number>(SETTINGS_KEYS.EMPTY_TRASH_DELAY)
readonly documentsInTrash = signal<Document[]>([]) readonly documentsInTrash = signal<Document[]>([])
readonly selectedDocuments = signal<Set<number>>(new Set()) readonly selectedDocuments = signal<Set<number>>(new Set())
@@ -200,8 +202,7 @@ export class TrashComponent
} }
getDaysRemaining(document: Document): number { getDaysRemaining(document: Document): number {
this.settingsService.trackChanges() const delay = this.emptyTrashDelaySetting()
const delay = this.settingsService.get(SETTINGS_KEYS.EMPTY_TRASH_DELAY)
const diff = new Date().getTime() - new Date(document.deleted_at).getTime() const diff = new Date().getTime() - new Date(document.deleted_at).getTime()
const days = Math.ceil(diff / (1000 * 3600 * 24)) const days = Math.ceil(diff / (1000 * 3600 * 24))
return delay - days return delay - days
@@ -193,6 +193,23 @@ describe('AppFrameComponent', () => {
expect(savedViewSpy).toHaveBeenCalled() expect(savedViewSpy).toHaveBeenCalled()
}) })
it('should update reinitialized signal-backed settings without manual change detection', async () => {
settingsService.initializeSettings().subscribe()
httpTestingController
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
.flush({
settings: { app_title: 'Reactive title' },
user: {},
permissions: [],
})
await fixture.whenStable()
expect(
fixture.nativeElement.querySelector('.brand-title').textContent
).toBe('Reactive title')
})
it('should check for update if enabled', () => { it('should check for update if enabled', () => {
const updateCheckSpy = jest.spyOn(remoteVersionService, 'checkForUpdates') const updateCheckSpy = jest.spyOn(remoteVersionService, 'checkForUpdates')
updateCheckSpy.mockImplementation(() => { updateCheckSpy.mockImplementation(() => {
@@ -98,6 +98,29 @@ 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)
private readonly versionSetting = this.settingsService.getSignal<string>(
SETTINGS_KEYS.VERSION
)
private readonly appTitleSetting = this.settingsService.getSignal<string>(
SETTINGS_KEYS.APP_TITLE
)
private readonly appLogoSetting = this.settingsService.getSignal<string>(
SETTINGS_KEYS.APP_LOGO
)
private readonly slimSidebarSetting = this.settingsService.getSignal<boolean>(
SETTINGS_KEYS.SLIM_SIDEBAR
)
private readonly attributesSectionsCollapsedSetting =
this.settingsService.getSignal<CollapsibleSection[]>(
SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED
)
private readonly aiEnabledSetting = this.settingsService.getSignal<boolean>(
SETTINGS_KEYS.AI_ENABLED
)
private readonly sidebarViewsShowCountSetting =
this.settingsService.getSignal<boolean>(
SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT
)
private lastScrollY: number = 0 private lastScrollY: number = 0
constructor() { constructor() {
@@ -191,33 +214,23 @@ export class AppFrameComponent
} }
get versionString(): string { get versionString(): string {
this.settingsService.trackChanges() return `${environment.appTitle} v${this.versionSetting()}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
} }
get appTitle(): string { get appTitle(): string {
this.settingsService.trackChanges() return this.appTitleSetting() || environment.appTitle
return (
this.settingsService.get(SETTINGS_KEYS.APP_TITLE) || environment.appTitle
)
} }
get customAppTitle(): string { get customAppTitle(): string {
this.settingsService.trackChanges() return this.appTitleSetting()
return this.settingsService.get(SETTINGS_KEYS.APP_TITLE)
} }
get hasCustomBranding(): boolean { get hasCustomBranding(): boolean {
this.settingsService.trackChanges() return !!(this.appTitleSetting()?.length || this.appLogoSetting()?.length)
return !!(
this.settingsService.get(SETTINGS_KEYS.APP_TITLE)?.length ||
this.settingsService.get(SETTINGS_KEYS.APP_LOGO)?.length
)
} }
get customAppLogo(): string { get customAppLogo(): string {
this.settingsService.trackChanges() const logo = this.appLogoSetting()
const logo = this.settingsService.get(SETTINGS_KEYS.APP_LOGO)
return logo?.length return logo?.length
? environment.apiBaseUrl.replace(/\/api\/$/, logo) ? environment.apiBaseUrl.replace(/\/api\/$/, logo)
: null : null
@@ -262,8 +275,7 @@ export class AppFrameComponent
} }
get slimSidebarEnabled(): boolean { get slimSidebarEnabled(): boolean {
this.settingsService.trackChanges() return this.slimSidebarSetting()
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
} }
set slimSidebarEnabled(enabled: boolean) { set slimSidebarEnabled(enabled: boolean) {
@@ -286,10 +298,9 @@ export class AppFrameComponent
} }
get attributesSectionsCollapsed(): boolean { get attributesSectionsCollapsed(): boolean {
this.settingsService.trackChanges() return this.attributesSectionsCollapsedSetting()?.includes(
return this.settingsService CollapsibleSection.ATTRIBUTES
.get(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED) )
?.includes(CollapsibleSection.ATTRIBUTES)
} }
set attributesSectionsCollapsed(collapsed: boolean) { set attributesSectionsCollapsed(collapsed: boolean) {
@@ -312,8 +323,7 @@ export class AppFrameComponent
} }
get aiEnabled(): boolean { get aiEnabled(): boolean {
this.settingsService.trackChanges() return this.aiEnabledSetting()
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
} }
@HostListener('window:resize') @HostListener('window:resize')
@@ -480,9 +490,8 @@ export class AppFrameComponent
} }
get showSidebarCounts(): boolean { get showSidebarCounts(): boolean {
this.settingsService.trackChanges()
return ( return (
this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT) && this.sidebarViewsShowCountSetting() &&
!this.settingsService.organizingSidebarSavedViews() !this.settingsService.organizingSidebarSavedViews()
) )
} }
@@ -81,6 +81,10 @@ export class GlobalSearchComponent implements OnInit {
private hotkeyService = inject(HotKeyService) private hotkeyService = inject(HotKeyService)
private settingsService = inject(SettingsService) private settingsService = inject(SettingsService)
private locationStrategy = inject(LocationStrategy) private locationStrategy = inject(LocationStrategy)
private readonly searchFullTypeSetting =
this.settingsService.getSignal<GlobalSearchType>(
SETTINGS_KEYS.SEARCH_FULL_TYPE
)
public DataType = DataType public DataType = DataType
readonly query = signal<string>(null) readonly query = signal<string>(null)
@@ -97,11 +101,7 @@ export class GlobalSearchComponent implements OnInit {
@ViewChildren('secondaryButton') secondaryButtons: QueryList<ElementRef> @ViewChildren('secondaryButton') secondaryButtons: QueryList<ElementRef>
get useAdvancedForFullSearch(): boolean { get useAdvancedForFullSearch(): boolean {
this.settingsService.trackChanges() return this.searchFullTypeSetting() === GlobalSearchType.ADVANCED
return (
this.settingsService.get(SETTINGS_KEYS.SEARCH_FULL_TYPE) ===
GlobalSearchType.ADVANCED
)
} }
constructor() { constructor() {
@@ -196,6 +196,16 @@ describe('WorkflowEditDialogComponent', () => {
fixture.detectChanges() fixture.detectChanges()
}) })
function setActionSettings({
email = true,
remoteOcr = true,
ai = true,
} = {}) {
settingsService.set(SETTINGS_KEYS.EMAIL_ENABLED, email)
settingsService.set(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED, remoteOcr)
settingsService.set(SETTINGS_KEYS.AI_ENABLED, ai)
}
it('should support create and edit modes, support adding triggers and actions on new workflow', () => { it('should support create and edit modes, support adding triggers and actions on new workflow', () => {
component.dialogMode.set(EditDialogMode.CREATE) component.dialogMode.set(EditDialogMode.CREATE)
const createTitleSpy = jest.spyOn(component, 'getCreateTitle') const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
@@ -218,7 +228,7 @@ describe('WorkflowEditDialogComponent', () => {
}) })
it('should return source options, type options, type name, schedule date field options', () => { it('should return source options, type options, type name, schedule date field options', () => {
jest.spyOn(settingsService, 'get').mockReturnValue(true) setActionSettings()
component.ngOnInit() component.ngOnInit()
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS) expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS)
expect(component.triggerTypeOptions).toEqual(WORKFLOW_TYPE_OPTIONS) expect(component.triggerTypeOptions).toEqual(WORKFLOW_TYPE_OPTIONS)
@@ -242,7 +252,7 @@ describe('WorkflowEditDialogComponent', () => {
) )
// Email, remote OCR and AI all disabled // Email, remote OCR and AI all disabled
jest.spyOn(settingsService, 'get').mockReturnValue(false) setActionSettings({ email: false, remoteOcr: false, ai: false })
component.ngOnInit() component.ngOnInit()
expect(component.actionTypeOptions).toEqual( expect(component.actionTypeOptions).toEqual(
WORKFLOW_ACTION_OPTIONS.filter( WORKFLOW_ACTION_OPTIONS.filter(
@@ -255,7 +265,7 @@ describe('WorkflowEditDialogComponent', () => {
}) })
it('should offer remote OCR only for consumption workflows', () => { it('should offer remote OCR only for consumption workflows', () => {
jest.spyOn(settingsService, 'get').mockReturnValue(true) setActionSettings()
// A consumption trigger makes the action reachable // A consumption trigger makes the action reachable
component.object = { component.object = {
@@ -285,7 +295,7 @@ describe('WorkflowEditDialogComponent', () => {
}) })
it('should offer remote OCR on a trigger added to a new workflow', () => { it('should offer remote OCR on a trigger added to a new workflow', () => {
jest.spyOn(settingsService, 'get').mockReturnValue(true) setActionSettings()
component.ngOnInit() component.ngOnInit()
// Nothing for the action to apply to yet // Nothing for the action to apply to yet
@@ -311,7 +321,7 @@ describe('WorkflowEditDialogComponent', () => {
}) })
it('should keep remote OCR listed when an action already uses it', () => { it('should keep remote OCR listed when an action already uses it', () => {
jest.spyOn(settingsService, 'get').mockReturnValue(true) setActionSettings()
// Otherwise changing the trigger would silently blank the selection // Otherwise changing the trigger would silently blank the selection
component.object = { component.object = {
@@ -329,9 +339,7 @@ describe('WorkflowEditDialogComponent', () => {
}) })
it('should not offer remote OCR when no engine is configured', () => { it('should not offer remote OCR when no engine is configured', () => {
jest setActionSettings({ remoteOcr: false })
.spyOn(settingsService, 'get')
.mockImplementation((key) => key !== SETTINGS_KEYS.REMOTE_OCR_CONFIGURED)
component.object = { component.object = {
name: 'Workflow 1', name: 'Workflow 1',
@@ -348,7 +356,7 @@ describe('WorkflowEditDialogComponent', () => {
}) })
it('should offer apply AI suggestions unless every trigger is consumption', () => { it('should offer apply AI suggestions unless every trigger is consumption', () => {
jest.spyOn(settingsService, 'get').mockReturnValue(true) setActionSettings()
// Consumption runs before the document has been parsed, so there would be // Consumption runs before the document has been parsed, so there would be
// no content to make suggestions from // no content to make suggestions from
@@ -382,7 +390,7 @@ describe('WorkflowEditDialogComponent', () => {
}) })
it('should keep apply AI suggestions listed when an action already uses it', () => { it('should keep apply AI suggestions listed when an action already uses it', () => {
jest.spyOn(settingsService, 'get').mockReturnValue(true) setActionSettings()
// Otherwise changing the trigger would silently blank the selection // Otherwise changing the trigger would silently blank the selection
component.object = { component.object = {
@@ -400,9 +408,7 @@ describe('WorkflowEditDialogComponent', () => {
}) })
it('should not offer apply AI suggestions when AI is disabled', () => { it('should not offer apply AI suggestions when AI is disabled', () => {
jest setActionSettings({ ai: false })
.spyOn(settingsService, 'get')
.mockImplementation((key) => key !== SETTINGS_KEYS.AI_ENABLED)
component.object = { component.object = {
name: 'Workflow 1', name: 'Workflow 1',
@@ -537,6 +537,13 @@ export class WorkflowEditDialogComponent
readonly dateCustomFields = computed(() => readonly dateCustomFields = computed(() =>
this.customFields()?.filter((f) => f.data_type === CustomFieldDataType.Date) this.customFields()?.filter((f) => f.data_type === CustomFieldDataType.Date)
) )
private readonly emailEnabledSetting =
this.settingsService.getSignal<boolean>(SETTINGS_KEYS.EMAIL_ENABLED)
private readonly remoteOcrConfiguredSetting =
this.settingsService.getSignal<boolean>(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED)
private readonly aiEnabledSetting = this.settingsService.getSignal<boolean>(
SETTINGS_KEYS.AI_ENABLED
)
expandedItem: number = null expandedItem: number = null
@@ -589,7 +596,7 @@ export class WorkflowEditDialogComponent
private getAllowedActionTypes() { private getAllowedActionTypes() {
let allowed = WORKFLOW_ACTION_OPTIONS let allowed = WORKFLOW_ACTION_OPTIONS
if (!this.settingsService.get(SETTINGS_KEYS.EMAIL_ENABLED)) { if (!this.emailEnabledSetting()) {
allowed = allowed.filter((a) => a.id !== WorkflowActionType.Email) allowed = allowed.filter((a) => a.id !== WorkflowActionType.Email)
} }
@@ -597,7 +604,7 @@ export class WorkflowEditDialogComponent
// offered for workflows that run at consumption. // offered for workflows that run at consumption.
const formWorkflow: Workflow = this.objectForm?.value const formWorkflow: Workflow = this.objectForm?.value
const remoteOcrUsable = const remoteOcrUsable =
this.settingsService.get(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED) && this.remoteOcrConfiguredSetting() &&
(formWorkflow?.triggers?.some( (formWorkflow?.triggers?.some(
(trigger) => trigger.type === WorkflowTriggerType.Consumption (trigger) => trigger.type === WorkflowTriggerType.Consumption
) || ) ||
@@ -612,7 +619,7 @@ export class WorkflowEditDialogComponent
// once every trigger is consumption, so it stays offered on a workflow // once every trigger is consumption, so it stays offered on a workflow
// that has no triggers yet. // that has no triggers yet.
const aiSuggestionsUsable = const aiSuggestionsUsable =
this.settingsService.get(SETTINGS_KEYS.AI_ENABLED) && this.aiEnabledSetting() &&
(!formWorkflow?.triggers?.length || (!formWorkflow?.triggers?.length ||
formWorkflow.triggers.some( formWorkflow.triggers.some(
(trigger) => trigger.type !== WorkflowTriggerType.Consumption (trigger) => trigger.type !== WorkflowTriggerType.Consumption
@@ -1362,7 +1369,6 @@ export class WorkflowEditDialogComponent
} }
get actionTypeOptions() { get actionTypeOptions() {
this.settingsService.trackChanges()
// Computed on read rather than cached // Computed on read rather than cached
return this.getAllowedActionTypes() return this.getAllowedActionTypes()
} }
@@ -839,7 +839,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
selectionModel.items = [memoRoot] selectionModel.items = [memoRoot]
selectionModel.documentCounts = [{ id: memoRoot.id, document_count: 9 }] selectionModel.documentCounts = [{ id: memoRoot.id, document_count: 9 }]
const getRootDocCount = (selectionModel as any).createRootDocCounter() const getRootDocCount = (selectionModel as any).createRootDocCounter(
selectionModel.items
)
expect(getRootDocCount(memoRoot.id)).toEqual(9) expect(getRootDocCount(memoRoot.id)).toEqual(9)
selectionModel.documentCounts = [] selectionModel.documentCounts = []
@@ -855,7 +857,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
selectionModel.items = [rootWithoutSelection] selectionModel.items = [rootWithoutSelection]
selectionModel.documentCounts = [] selectionModel.documentCounts = []
const getRootDocCount = (selectionModel as any).createRootDocCounter() const getRootDocCount = (selectionModel as any).createRootDocCounter(
selectionModel.items
)
expect(getRootDocCount(rootWithoutSelection.id)).toEqual(4) expect(getRootDocCount(rootWithoutSelection.id)).toEqual(4)
}) })
@@ -865,7 +869,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
selectionModel.items = [rootWithoutCounts] selectionModel.items = [rootWithoutCounts]
selectionModel.documentCounts = [] selectionModel.documentCounts = []
const getRootDocCount = (selectionModel as any).createRootDocCounter() const getRootDocCount = (selectionModel as any).createRootDocCounter(
selectionModel.items
)
expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0) expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0)
}) })
@@ -966,7 +972,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
component.selectionModel['temporarySelectionStates'].set(id, state) component.selectionModel['temporarySelectionStates'].set(id, state)
const changedSpy = jest.spyOn(component.selectionModel.changed, 'next') const changedSpy = jest.spyOn(component.selectionModel.changed, 'next')
component.selectionModel.exclude(id) component.selectionModel.exclude(id)
expect(component.selectionModel.temporaryLogicalOperator).toBe( expect(component.selectionModel.temporaryLogicalOperator()).toBe(
LogicalOperator.And LogicalOperator.And
) )
expect(component.selectionModel['temporarySelectionStates'].get(id)).toBe( expect(component.selectionModel['temporarySelectionStates'].get(id)).toBe(
@@ -64,43 +64,56 @@ export class FilterableDropdownSelectionModel {
manyToOne = false manyToOne = false
singleSelect = false singleSelect = false
private _logicalOperator: LogicalOperator = LogicalOperator.And
temporaryLogicalOperator: LogicalOperator = this._logicalOperator
private _intersection: Intersection = Intersection.Include
temporaryIntersection: Intersection = this._intersection
private _documentCounts: SelectionDataItem[] = [] private readonly _logicalOperator = signal(LogicalOperator.And)
readonly temporaryLogicalOperator = signal(LogicalOperator.And)
private readonly _intersection = signal(Intersection.Include)
readonly temporaryIntersection = signal(Intersection.Include)
private readonly _documentCounts = signal<SelectionDataItem[]>([])
private readonly _items = signal<MatchingModel[]>([])
private readonly _selectionStates = signal(
new Map<number, ToggleableItemState>()
)
private readonly _temporarySelectionStates = signal(
new Map<number, ToggleableItemState>()
)
public documentCountSortingEnabled = false public documentCountSortingEnabled = false
private get selectionStates(): ReadonlyMap<number, ToggleableItemState> {
return this._selectionStates()
}
private get temporarySelectionStates(): ReadonlyMap<
number,
ToggleableItemState
> {
return this._temporarySelectionStates()
}
public set documentCounts(counts: SelectionDataItem[]) { public set documentCounts(counts: SelectionDataItem[]) {
this._documentCounts = counts this._documentCounts.set(counts)
if (this.documentCountSortingEnabled) { if (this.documentCountSortingEnabled) {
this.sortItems() this._items.set(this.sortItems(this.items))
} }
} }
private _items: MatchingModel[] = []
get items(): MatchingModel[] { get items(): MatchingModel[] {
return this._items return this._items()
} }
set items(items: MatchingModel[]) { set items(items: MatchingModel[]) {
if (items) { if (items) {
this._items = Array.from(items) this._items.set(this.withNullItem(this.sortItems(Array.from(items))))
this.sortItems()
this.setNullItem()
} }
} }
private setNullItem() { private withNullItem(items: MatchingModel[]): MatchingModel[] {
if (this.manyToOne && this.logicalOperator === LogicalOperator.Or) { if (this.manyToOne && this.logicalOperator === LogicalOperator.Or) {
if (this._items[0]?.id === null) { return items[0]?.id === null ? items.slice(1) : items
this._items.shift()
}
return
} }
const item = { const nullItem = {
name: $localize`:Filter drop down element to filter for documents with no correspondent/type/tag assigned:Not assigned`, name: $localize`:Filter drop down element to filter for documents with no correspondent/type/tag assigned:Not assigned`,
id: id:
this.manyToOne || this.intersection === Intersection.Include this.manyToOne || this.intersection === Intersection.Include
@@ -108,22 +121,17 @@ export class FilterableDropdownSelectionModel {
: NEGATIVE_NULL_FILTER_VALUE, : NEGATIVE_NULL_FILTER_VALUE,
} }
if ( return items[0]?.id === null || items[0]?.id === NEGATIVE_NULL_FILTER_VALUE
this._items[0]?.id === null || ? [nullItem, ...items.slice(1)]
this._items[0]?.id === NEGATIVE_NULL_FILTER_VALUE : [nullItem, ...items]
) {
this._items[0] = item
} else if (this._items) {
this._items.unshift(item)
}
} }
constructor(manyToOne: boolean = false) { constructor(manyToOne: boolean = false) {
this.manyToOne = manyToOne this.manyToOne = manyToOne
} }
private sortItems() { private sortItems(items: MatchingModel[]): MatchingModel[] {
this._items.sort((a, b) => { const sorted = [...items].sort((a, b) => {
if ( if (
(a.id == null && b.id != null) || (a.id == null && b.id != null) ||
(a.id == NEGATIVE_NULL_FILTER_VALUE && (a.id == NEGATIVE_NULL_FILTER_VALUE &&
@@ -154,13 +162,13 @@ export class FilterableDropdownSelectionModel {
) { ) {
return -1 return -1
} else if ( } else if (
this._documentCounts.length && this._documentCounts().length &&
this.getDocumentCount(b.id) === 0 && this.getDocumentCount(b.id) === 0 &&
this.getDocumentCount(a.id) > this.getDocumentCount(b.id) this.getDocumentCount(a.id) > this.getDocumentCount(b.id)
) { ) {
return -1 return -1
} else if ( } else if (
this._documentCounts.length && this._documentCounts().length &&
this.getDocumentCount(a.id) === 0 && this.getDocumentCount(a.id) === 0 &&
this.getDocumentCount(a.id) < this.getDocumentCount(b.id) this.getDocumentCount(a.id) < this.getDocumentCount(b.id)
) { ) {
@@ -170,15 +178,11 @@ export class FilterableDropdownSelectionModel {
} }
}) })
if (this._documentCounts.length) { return this._documentCounts().length
this.promoteBranchesWithDocumentCounts() ? this.promoteBranchesWithDocumentCounts(sorted)
} : sorted
} }
private selectionStates = new Map<number, ToggleableItemState>()
private temporarySelectionStates = new Map<number, ToggleableItemState>()
getSelectedItems() { getSelectedItems() {
return this.items.filter( return this.items.filter(
(i) => (i) =>
@@ -194,30 +198,33 @@ export class FilterableDropdownSelectionModel {
} }
set(id: number, state: ToggleableItemState, fireEvent = true) { set(id: number, state: ToggleableItemState, fireEvent = true) {
const states = new Map(this.temporarySelectionStates)
if (state == ToggleableItemState.NotSelected) { if (state == ToggleableItemState.NotSelected) {
this.temporarySelectionStates.delete(id) states.delete(id)
} else { } else {
this.temporarySelectionStates.set(id, state) states.set(id, state)
} }
this._temporarySelectionStates.set(states)
if (fireEvent) { if (fireEvent) {
this.changed.next(this) this.changed.next(this)
} }
} }
toggle(id: number, fireEvent = true) { toggle(id: number, fireEvent = true) {
let state = this.temporarySelectionStates.get(id) const states = new Map(this.temporarySelectionStates)
let state = states.get(id)
if ( if (
state == undefined || state == undefined ||
(state != ToggleableItemState.Selected && (state != ToggleableItemState.Selected &&
state != ToggleableItemState.Excluded) state != ToggleableItemState.Excluded)
) { ) {
if (this.manyToOne || this.singleSelect) { if (this.manyToOne || this.singleSelect) {
this.temporarySelectionStates.set(id, ToggleableItemState.Selected) states.set(id, ToggleableItemState.Selected)
if (this.singleSelect) { if (this.singleSelect) {
for (let key of this.temporarySelectionStates.keys()) { for (let key of states.keys()) {
if (key != id) { if (key != id) {
this.temporarySelectionStates.delete(key) states.delete(key)
} }
} }
} }
@@ -233,25 +240,26 @@ export class FilterableDropdownSelectionModel {
) { ) {
newState = ToggleableItemState.NotSelected newState = ToggleableItemState.NotSelected
} }
this.temporarySelectionStates.set(id, newState) states.set(id, newState)
} }
} else if ( } else if (
state == ToggleableItemState.Selected || state == ToggleableItemState.Selected ||
state == ToggleableItemState.Excluded state == ToggleableItemState.Excluded
) { ) {
this.temporarySelectionStates.delete(id) states.delete(id)
this.clearDescendantSelections(id) this.clearDescendantSelections(states, id)
} }
if (!id) { if (!id) {
for (let key of this.temporarySelectionStates.keys()) { for (let key of states.keys()) {
if (key) { if (key) {
this.temporarySelectionStates.delete(key) states.delete(key)
} }
} }
} else { } else {
this.temporarySelectionStates.delete(null) states.delete(null)
} }
this._temporarySelectionStates.set(states)
if (fireEvent) { if (fireEvent) {
this.changed.next(this) this.changed.next(this)
@@ -259,20 +267,21 @@ export class FilterableDropdownSelectionModel {
} }
exclude(id: number, fireEvent: boolean = true) { exclude(id: number, fireEvent: boolean = true) {
let state = this.temporarySelectionStates.get(id) const states = new Map(this.temporarySelectionStates)
let state = states.get(id)
if (id && (state == null || state != ToggleableItemState.Excluded)) { if (id && (state == null || state != ToggleableItemState.Excluded)) {
this.temporaryLogicalOperator = this._logicalOperator = this.manyToOne const operator = this.manyToOne ? LogicalOperator.And : LogicalOperator.Or
? LogicalOperator.And this.temporaryLogicalOperator.set(operator)
: LogicalOperator.Or this._logicalOperator.set(operator)
if (this.manyToOne || this.singleSelect) { if (this.manyToOne || this.singleSelect) {
this.temporarySelectionStates.set(id, ToggleableItemState.Excluded) states.set(id, ToggleableItemState.Excluded)
this.clearDescendantSelections(id) this.clearDescendantSelections(states, id)
if (this.singleSelect) { if (this.singleSelect) {
for (let key of this.temporarySelectionStates.keys()) { for (let key of states.keys()) {
if (key != id) { if (key != id) {
this.temporarySelectionStates.delete(key) states.delete(key)
} }
} }
} }
@@ -287,17 +296,18 @@ export class FilterableDropdownSelectionModel {
) { ) {
newState = ToggleableItemState.NotSelected newState = ToggleableItemState.NotSelected
} }
this.temporarySelectionStates.set(id, newState) states.set(id, newState)
if (newState == ToggleableItemState.Excluded) { if (newState == ToggleableItemState.Excluded) {
this.clearDescendantSelections(id) this.clearDescendantSelections(states, id)
} }
} }
} else if (!id || state == ToggleableItemState.Excluded) { } else if (!id || state == ToggleableItemState.Excluded) {
this.temporarySelectionStates.delete(id) states.delete(id)
if (id) { if (id) {
this.clearDescendantSelections(id) this.clearDescendantSelections(states, id)
} }
} }
this._temporarySelectionStates.set(states)
if (fireEvent) { if (fireEvent) {
this.changed.next(this) this.changed.next(this)
@@ -308,9 +318,12 @@ export class FilterableDropdownSelectionModel {
return this.selectionStates.get(id) || ToggleableItemState.NotSelected return this.selectionStates.get(id) || ToggleableItemState.NotSelected
} }
private clearDescendantSelections(id: number) { private clearDescendantSelections(
states: Map<number, ToggleableItemState>,
id: number
) {
for (const descendantID of this.getDescendantIDs(id)) { for (const descendantID of this.getDescendantIDs(id)) {
this.temporarySelectionStates.delete(descendantID) states.delete(descendantID)
} }
} }
@@ -320,7 +333,7 @@ export class FilterableDropdownSelectionModel {
while (queue.length) { while (queue.length) {
const parentID = queue.shift() const parentID = queue.shift()
for (const item of this._items) { for (const item of this.items) {
if ( if (
typeof item?.id === 'number' && typeof item?.id === 'number' &&
typeof (item as any)['parent'] === 'number' && typeof (item as any)['parent'] === 'number' &&
@@ -336,12 +349,12 @@ export class FilterableDropdownSelectionModel {
} }
get logicalOperator(): LogicalOperator { get logicalOperator(): LogicalOperator {
return this.temporaryLogicalOperator return this.temporaryLogicalOperator()
} }
set logicalOperator(operator: LogicalOperator) { set logicalOperator(operator: LogicalOperator) {
this.temporaryLogicalOperator = operator this.temporaryLogicalOperator.set(operator)
this.setNullItem() this._items.set(this.withNullItem(this.items))
} }
toggleOperator() { toggleOperator() {
@@ -349,12 +362,12 @@ export class FilterableDropdownSelectionModel {
} }
get intersection(): Intersection { get intersection(): Intersection {
return this.temporaryIntersection return this.temporaryIntersection()
} }
set intersection(intersection: Intersection) { set intersection(intersection: Intersection) {
this.temporaryIntersection = intersection this.temporaryIntersection.set(intersection)
this.setNullItem() this._items.set(this.withNullItem(this.items))
} }
toggleIntersection() { toggleIntersection() {
@@ -364,18 +377,20 @@ export class FilterableDropdownSelectionModel {
? ToggleableItemState.Selected ? ToggleableItemState.Selected
: ToggleableItemState.Excluded : ToggleableItemState.Excluded
this.temporarySelectionStates.forEach((state, key) => { const states = new Map(this.temporarySelectionStates)
states.forEach((state, key) => {
if (key === null && this.intersection === Intersection.Exclude) { if (key === null && this.intersection === Intersection.Exclude) {
this.temporarySelectionStates.set(NEGATIVE_NULL_FILTER_VALUE, newState) states.set(NEGATIVE_NULL_FILTER_VALUE, newState)
} else if ( } else if (
key === NEGATIVE_NULL_FILTER_VALUE && key === NEGATIVE_NULL_FILTER_VALUE &&
this.intersection === Intersection.Include this.intersection === Intersection.Include
) { ) {
this.temporarySelectionStates.set(null, newState) states.set(null, newState)
} else { } else {
this.temporarySelectionStates.set(key, newState) states.set(key, newState)
} }
}) })
this._temporarySelectionStates.set(states)
this.changed.next(this) this.changed.next(this)
} }
@@ -395,10 +410,12 @@ export class FilterableDropdownSelectionModel {
} }
clear(fireEvent = true) { clear(fireEvent = true) {
this.temporarySelectionStates.clear() this._temporarySelectionStates.set(new Map())
this.temporaryLogicalOperator = this._logicalOperator = LogicalOperator.And this.temporaryLogicalOperator.set(LogicalOperator.And)
this.temporaryIntersection = this._intersection = Intersection.Include this._logicalOperator.set(LogicalOperator.And)
this.setNullItem() this.temporaryIntersection.set(Intersection.Include)
this._intersection.set(Intersection.Include)
this._items.set(this.withNullItem(this.items))
if (fireEvent) { if (fireEvent) {
this.changed.next(this) this.changed.next(this)
} }
@@ -419,9 +436,9 @@ export class FilterableDropdownSelectionModel {
) )
) { ) {
return true return true
} else if (this.temporaryLogicalOperator !== this._logicalOperator) { } else if (this.temporaryLogicalOperator() !== this._logicalOperator()) {
return true return true
} else if (this.temporaryIntersection !== this._intersection) { } else if (this.temporaryIntersection() !== this._intersection()) {
return true return true
} else { } else {
return false return false
@@ -438,23 +455,29 @@ export class FilterableDropdownSelectionModel {
} }
getDocumentCount(id: number) { getDocumentCount(id: number) {
return this._documentCounts.find((c) => c.id === id)?.document_count return this._documentCounts().find((c) => c.id === id)?.document_count
} }
private promoteBranchesWithDocumentCounts() { private promoteBranchesWithDocumentCounts(
const parentById = this.buildParentById() items: MatchingModel[]
): MatchingModel[] {
const parentById = this.buildParentById(items)
const findRootId = this.createRootFinder(parentById) const findRootId = this.createRootFinder(parentById)
const getRootDocCount = this.createRootDocCounter() const getRootDocCount = this.createRootDocCounter(items)
const summaries = this.buildBranchSummaries(findRootId, getRootDocCount) const summaries = this.buildBranchSummaries(
items,
findRootId,
getRootDocCount
)
const orderedBranches = this.orderBranchesByPriority(summaries) const orderedBranches = this.orderBranchesByPriority(summaries)
this._items = orderedBranches.flatMap((summary) => summary.items) return orderedBranches.flatMap((summary) => summary.items)
} }
private buildParentById(): Map<number, number | null> { private buildParentById(items: MatchingModel[]): Map<number, number | null> {
const parentById = new Map<number, number | null>() const parentById = new Map<number, number | null>()
for (const item of this._items) { for (const item of items) {
if (typeof item?.id === 'number') { if (typeof item?.id === 'number') {
const parentValue = (item as any)['parent'] const parentValue = (item as any)['parent']
parentById.set( parentById.set(
@@ -492,7 +515,9 @@ export class FilterableDropdownSelectionModel {
return findRootId return findRootId
} }
private createRootDocCounter(): (rootId: number) => number { private createRootDocCounter(
items: MatchingModel[]
): (rootId: number) => number {
const docCountMemo = new Map<number, number>() const docCountMemo = new Map<number, number>()
return (rootId: number): number => { return (rootId: number): number => {
@@ -507,7 +532,7 @@ export class FilterableDropdownSelectionModel {
return explicit return explicit
} }
const rootItem = this._items.find((i) => i.id === rootId) const rootItem = items.find((i) => i.id === rootId)
const fallback = const fallback =
typeof (rootItem as any)?.['document_count'] === 'number' typeof (rootItem as any)?.['document_count'] === 'number'
? (rootItem as any)['document_count'] ? (rootItem as any)['document_count']
@@ -519,12 +544,13 @@ export class FilterableDropdownSelectionModel {
} }
private buildBranchSummaries( private buildBranchSummaries(
items: MatchingModel[],
findRootId: (id: number) => number, findRootId: (id: number) => number,
getRootDocCount: (rootId: number) => number getRootDocCount: (rootId: number) => number
): Map<string, BranchSummary> { ): Map<string, BranchSummary> {
const summaries = new Map<string, BranchSummary>() const summaries = new Map<string, BranchSummary>()
for (const [index, item] of this._items.entries()) { for (const [index, item] of items.entries()) {
const { key, special, rootId } = this.describeBranchItem( const { key, special, rootId } = this.describeBranchItem(
item, item,
index, index,
@@ -616,28 +642,23 @@ export class FilterableDropdownSelectionModel {
} }
init(map: Map<number, ToggleableItemState>) { init(map: Map<number, ToggleableItemState>) {
this.temporarySelectionStates = map this._temporarySelectionStates.set(new Map(map))
this.apply() this.apply()
} }
apply() { apply() {
this.selectionStates.clear() this._selectionStates.set(new Map(this.temporarySelectionStates))
this.temporarySelectionStates.forEach((value, key) => { this._logicalOperator.set(this.temporaryLogicalOperator())
this.selectionStates.set(key, value) this._intersection.set(this.temporaryIntersection())
}) this._items.set(this.sortItems(this.items))
this._logicalOperator = this.temporaryLogicalOperator
this._intersection = this.temporaryIntersection
this.sortItems()
} }
reset(complete: boolean = false) { reset(complete: boolean = false) {
this.temporarySelectionStates.clear()
if (complete) { if (complete) {
this.selectionStates.clear() this._selectionStates.set(new Map())
this._temporarySelectionStates.set(new Map())
} else { } else {
this.selectionStates.forEach((value, key) => { this._temporarySelectionStates.set(new Map(this.selectionStates))
this.temporarySelectionStates.set(key, value)
})
} }
} }
@@ -7,7 +7,7 @@
<div class="list-group list-group-flush"> <div class="list-group list-group-flush">
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NONE)" [disabled]="disabled"> <button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NONE)" [disabled]="disabled">
<div class="selected-icon me-1"> <div class="selected-icon me-1">
@if (selectionModel.ownerFilter === OwnerFilterType.NONE) { @if (selectionModel.ownerFilter() === OwnerFilterType.NONE) {
<i-bs width="1em" height="1em" name="check"></i-bs> <i-bs width="1em" height="1em" name="check"></i-bs>
} }
</div> </div>
@@ -17,7 +17,7 @@
</button> </button>
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SELF)" [disabled]="disabled"> <button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SELF)" [disabled]="disabled">
<div class="selected-icon me-1"> <div class="selected-icon me-1">
@if (selectionModel.ownerFilter === OwnerFilterType.SELF) { @if (selectionModel.ownerFilter() === OwnerFilterType.SELF) {
<i-bs width="1em" height="1em" name="check"></i-bs> <i-bs width="1em" height="1em" name="check"></i-bs>
} }
</div> </div>
@@ -27,7 +27,7 @@
</button> </button>
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NOT_SELF)" [disabled]="disabled"> <button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NOT_SELF)" [disabled]="disabled">
<div class="selected-icon me-1"> <div class="selected-icon me-1">
@if (selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) { @if (selectionModel.ownerFilter() === OwnerFilterType.NOT_SELF) {
<i-bs width="1em" height="1em" name="check"></i-bs> <i-bs width="1em" height="1em" name="check"></i-bs>
} }
</div> </div>
@@ -37,7 +37,7 @@
</button> </button>
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SHARED_BY_ME)" [disabled]="disabled"> <button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SHARED_BY_ME)" [disabled]="disabled">
<div class="selected-icon me-1"> <div class="selected-icon me-1">
@if (selectionModel.ownerFilter === OwnerFilterType.SHARED_BY_ME) { @if (selectionModel.ownerFilter() === OwnerFilterType.SHARED_BY_ME) {
<i-bs width="1em" height="1em" name="check"></i-bs> <i-bs width="1em" height="1em" name="check"></i-bs>
} }
</div> </div>
@@ -47,7 +47,7 @@
</button> </button>
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.UNOWNED)" [disabled]="disabled"> <button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.UNOWNED)" [disabled]="disabled">
<div class="selected-icon me-1"> <div class="selected-icon me-1">
@if (selectionModel.ownerFilter === OwnerFilterType.UNOWNED) { @if (selectionModel.ownerFilter() === OwnerFilterType.UNOWNED) {
<i-bs width="1em" height="1em" name="check"></i-bs> <i-bs width="1em" height="1em" name="check"></i-bs>
} }
</div> </div>
@@ -57,7 +57,7 @@
</button> </button>
<button *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }" class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" [disabled]="disabled"> <button *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }" class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" [disabled]="disabled">
<div class="selected-icon me-1"> <div class="selected-icon me-1">
@if (selectionModel.ownerFilter === OwnerFilterType.OTHERS) { @if (selectionModel.ownerFilter() === OwnerFilterType.OTHERS) {
<i-bs width="1em" height="1em" name="check"></i-bs> <i-bs width="1em" height="1em" name="check"></i-bs>
} }
</div> </div>
@@ -65,7 +65,8 @@
<ng-select <ng-select
name="user" name="user"
class="user-select small" class="user-select small"
[(ngModel)]="selectionModel.includeUsers" [ngModel]="selectionModel.includeUsers()"
(ngModelChange)="selectionModel.includeUsers.set($event)"
[disabled]="disabled" [disabled]="disabled"
[clearable]="false" [clearable]="false"
[items]="users()" [items]="users()"
@@ -78,10 +79,10 @@
</ng-select> </ng-select>
</div> </div>
</button> </button>
@if (selectionModel.ownerFilter === OwnerFilterType.NONE || selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) { @if (selectionModel.ownerFilter() === OwnerFilterType.NONE || selectionModel.ownerFilter() === OwnerFilterType.NOT_SELF) {
<div class="list-group-item list-group-item-action d-flex align-items-center p-2 ps-3 border-bottom-0 border-start-0 border-end-0"> <div class="list-group-item list-group-item-action d-flex align-items-center p-2 ps-3 border-bottom-0 border-start-0 border-end-0">
<div class="form-check form-switch w-100"> <div class="form-check form-switch w-100">
<input type="checkbox" class="form-check-input" id="hideUnowned" [(ngModel)]="this.selectionModel.hideUnowned" (change)="onChange()" [disabled]="disabled"> <input type="checkbox" class="form-check-input" id="hideUnowned" [ngModel]="selectionModel.hideUnowned()" (ngModelChange)="selectionModel.hideUnowned.set($event)" (change)="onChange()" [disabled]="disabled">
<label class="form-check-label w-100" for="hideUnowned"><small i18n>Hide unowned</small></label> <label class="form-check-label w-100" for="hideUnowned"><small i18n>Hide unowned</small></label>
</div> </div>
</div> </div>
@@ -90,56 +90,56 @@ describe('PermissionsFilterDropdownComponent', () => {
component.setFilter(OwnerFilterType.OTHERS) component.setFilter(OwnerFilterType.OTHERS)
expect(component.isActive).toBeTruthy() expect(component.isActive).toBeTruthy()
component.setFilter(OwnerFilterType.NONE) component.setFilter(OwnerFilterType.NONE)
component.selectionModel.hideUnowned = true component.selectionModel.hideUnowned.set(true)
expect(component.isActive).toBeTruthy() expect(component.isActive).toBeTruthy()
}) })
it('should describe concrete user filters honestly', () => { it('should describe concrete user filters honestly', () => {
component.selectionModel.ownerFilter = OwnerFilterType.SELF component.selectionModel.ownerFilter.set(OwnerFilterType.SELF)
component.selectionModel.userID = 1 component.selectionModel.userID.set(1)
expect(component.ownerFilterLabel).toEqual('Owned by user1') expect(component.ownerFilterLabel).toEqual('Owned by user1')
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF component.selectionModel.ownerFilter.set(OwnerFilterType.NOT_SELF)
component.selectionModel.excludeUsers = [1] component.selectionModel.excludeUsers.set([1])
expect(component.ownerExclusionFilterLabel).toEqual('Not owned by user1') expect(component.ownerExclusionFilterLabel).toEqual('Not owned by user1')
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME component.selectionModel.ownerFilter.set(OwnerFilterType.SHARED_BY_ME)
component.selectionModel.userID = 1 component.selectionModel.userID.set(1)
expect(component.sharedByFilterLabel).toEqual('Shared by user1') expect(component.sharedByFilterLabel).toEqual('Shared by user1')
}) })
it('should describe concrete filters when usernames are unavailable', () => { it('should describe concrete filters when usernames are unavailable', () => {
component.selectionModel.ownerFilter = OwnerFilterType.SELF component.selectionModel.ownerFilter.set(OwnerFilterType.SELF)
component.selectionModel.userID = 99 component.selectionModel.userID.set(99)
expect(component.ownerFilterLabel).toEqual('Owned by another user') expect(component.ownerFilterLabel).toEqual('Owned by another user')
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF component.selectionModel.ownerFilter.set(OwnerFilterType.NOT_SELF)
component.selectionModel.excludeUsers = [99] component.selectionModel.excludeUsers.set([99])
expect(component.ownerExclusionFilterLabel).toEqual( expect(component.ownerExclusionFilterLabel).toEqual(
'Not owned by another user' 'Not owned by another user'
) )
component.selectionModel.excludeUsers = [98, 99] component.selectionModel.excludeUsers.set([98, 99])
expect(component.ownerExclusionFilterLabel).toEqual( expect(component.ownerExclusionFilterLabel).toEqual(
'Not owned by selected users' 'Not owned by selected users'
) )
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME component.selectionModel.ownerFilter.set(OwnerFilterType.SHARED_BY_ME)
component.selectionModel.userID = 99 component.selectionModel.userID.set(99)
expect(component.sharedByFilterLabel).toEqual('Shared by another user') expect(component.sharedByFilterLabel).toEqual('Shared by another user')
}) })
it('should retain relative labels for filters bound to the current user', () => { it('should retain relative labels for filters bound to the current user', () => {
component.selectionModel.userID = currentUserID component.selectionModel.userID.set(currentUserID)
expect(component.ownerFilterLabel).toEqual('My documents') expect(component.ownerFilterLabel).toEqual('My documents')
expect(component.sharedByFilterLabel).toEqual('Shared by me') expect(component.sharedByFilterLabel).toEqual('Shared by me')
component.selectionModel.excludeUsers = [currentUserID] component.selectionModel.excludeUsers.set([currentUserID])
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me') expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
}) })
it('should retain relative labels for inactive filter choices', () => { it('should retain relative labels for inactive filter choices', () => {
component.selectionModel.ownerFilter = OwnerFilterType.NONE component.selectionModel.ownerFilter.set(OwnerFilterType.NONE)
expect(component.ownerFilterLabel).toEqual('My documents') expect(component.ownerFilterLabel).toEqual('My documents')
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me') expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
@@ -148,32 +148,41 @@ describe('PermissionsFilterDropdownComponent', () => {
it('should support reset', () => { it('should support reset', () => {
component.setFilter(OwnerFilterType.OTHERS) component.setFilter(OwnerFilterType.OTHERS)
expect(component.selectionModel.ownerFilter).not.toEqual( expect(component.selectionModel.ownerFilter()).not.toEqual(
OwnerFilterType.NONE OwnerFilterType.NONE
) )
component.reset() component.reset()
expect(component.selectionModel.ownerFilter).toEqual(OwnerFilterType.NONE) expect(component.selectionModel.ownerFilter()).toEqual(OwnerFilterType.NONE)
}) })
it('should toggle owner filter type when users selected', () => { it('should toggle owner filter type when users selected', () => {
component.selectionModel.ownerFilter = OwnerFilterType.NONE component.selectionModel.ownerFilter.set(OwnerFilterType.NONE)
// this would normally be done by select component // this would normally be done by select component
component.selectionModel.includeUsers = [12] component.selectionModel.includeUsers.set([12])
component.onUserSelect() component.onUserSelect()
expect(component.selectionModel.ownerFilter).toEqual(OwnerFilterType.OTHERS) expect(component.selectionModel.ownerFilter()).toEqual(
OwnerFilterType.OTHERS
)
// this would normally be done by select component // this would normally be done by select component
component.selectionModel.includeUsers = null component.selectionModel.includeUsers.set(null)
component.onUserSelect() component.onUserSelect()
expect(component.selectionModel.ownerFilter).toEqual(OwnerFilterType.NONE) expect(component.selectionModel.ownerFilter()).toEqual(OwnerFilterType.NONE)
}) })
it('should emit a selection model depending on the type of owner filter set', () => { it('should emit a selection model depending on the type of owner filter set', () => {
component.selectionModel.ownerFilter = OwnerFilterType.NONE const emitted = () => ({
excludeUsers: ownerFilterSetResult.excludeUsers(),
hideUnowned: ownerFilterSetResult.hideUnowned(),
includeUsers: ownerFilterSetResult.includeUsers(),
ownerFilter: ownerFilterSetResult.ownerFilter(),
userID: ownerFilterSetResult.userID(),
})
component.selectionModel.ownerFilter.set(OwnerFilterType.NONE)
component.setFilter(OwnerFilterType.SELF) component.setFilter(OwnerFilterType.SELF)
expect(ownerFilterSetResult).toEqual({ expect(emitted()).toEqual({
excludeUsers: [], excludeUsers: [],
hideUnowned: false, hideUnowned: false,
includeUsers: [], includeUsers: [],
@@ -182,7 +191,7 @@ describe('PermissionsFilterDropdownComponent', () => {
}) })
component.setFilter(OwnerFilterType.NOT_SELF) component.setFilter(OwnerFilterType.NOT_SELF)
expect(ownerFilterSetResult).toEqual({ expect(emitted()).toEqual({
excludeUsers: [currentUserID], excludeUsers: [currentUserID],
hideUnowned: false, hideUnowned: false,
includeUsers: [], includeUsers: [],
@@ -191,7 +200,7 @@ describe('PermissionsFilterDropdownComponent', () => {
}) })
component.setFilter(OwnerFilterType.NONE) component.setFilter(OwnerFilterType.NONE)
expect(ownerFilterSetResult).toEqual({ expect(emitted()).toEqual({
excludeUsers: [], excludeUsers: [],
hideUnowned: false, hideUnowned: false,
includeUsers: [], includeUsers: [],
@@ -200,7 +209,7 @@ describe('PermissionsFilterDropdownComponent', () => {
}) })
component.setFilter(OwnerFilterType.SHARED_BY_ME) component.setFilter(OwnerFilterType.SHARED_BY_ME)
expect(ownerFilterSetResult).toEqual({ expect(emitted()).toEqual({
excludeUsers: [], excludeUsers: [],
hideUnowned: false, hideUnowned: false,
includeUsers: [], includeUsers: [],
@@ -209,7 +218,7 @@ describe('PermissionsFilterDropdownComponent', () => {
}) })
component.setFilter(OwnerFilterType.UNOWNED) component.setFilter(OwnerFilterType.UNOWNED)
expect(ownerFilterSetResult).toEqual({ expect(emitted()).toEqual({
excludeUsers: [], excludeUsers: [],
hideUnowned: false, hideUnowned: false,
includeUsers: [], includeUsers: [],
@@ -25,18 +25,18 @@ import { ComponentWithPermissions } from '../../with-permissions/with-permission
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component' import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
export class PermissionsSelectionModel { export class PermissionsSelectionModel {
ownerFilter: OwnerFilterType readonly ownerFilter = signal(OwnerFilterType.NONE)
hideUnowned: boolean readonly hideUnowned = signal(false)
userID: number readonly userID = signal<number>(null)
includeUsers: number[] readonly includeUsers = signal<number[]>([])
excludeUsers: number[] readonly excludeUsers = signal<number[]>([])
clear() { clear() {
this.ownerFilter = OwnerFilterType.NONE this.ownerFilter.set(OwnerFilterType.NONE)
this.userID = null this.userID.set(null)
this.hideUnowned = false this.hideUnowned.set(false)
this.includeUsers = [] this.includeUsers.set([])
this.excludeUsers = [] this.excludeUsers.set([])
} }
} }
@@ -84,33 +84,31 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
readonly users = signal<User[]>([]) readonly users = signal<User[]>([])
hideUnowned: boolean
get isActive(): boolean { get isActive(): boolean {
return ( return (
this.selectionModel.ownerFilter !== OwnerFilterType.NONE || this.selectionModel.ownerFilter() !== OwnerFilterType.NONE ||
this.selectionModel.hideUnowned this.selectionModel.hideUnowned()
) )
} }
get ownerFilterLabel(): string { get ownerFilterLabel(): string {
if ( if (
this.selectionModel?.ownerFilter !== OwnerFilterType.SELF || this.selectionModel?.ownerFilter() !== OwnerFilterType.SELF ||
this.selectionModel?.userID === this.settingsService.currentUser()?.id this.selectionModel?.userID() === this.settingsService.currentUser()?.id
) { ) {
return $localize`My documents` return $localize`My documents`
} }
const username = this.getUsername(this.selectionModel?.userID) const username = this.getUsername(this.selectionModel?.userID())
return username return username
? $localize`Owned by ${username}` ? $localize`Owned by ${username}`
: $localize`Owned by another user` : $localize`Owned by another user`
} }
get ownerExclusionFilterLabel(): string { get ownerExclusionFilterLabel(): string {
const excludedUsers = this.selectionModel?.excludeUsers ?? [] const excludedUsers = this.selectionModel?.excludeUsers() ?? []
if ( if (
this.selectionModel?.ownerFilter !== OwnerFilterType.NOT_SELF || this.selectionModel?.ownerFilter() !== OwnerFilterType.NOT_SELF ||
(excludedUsers.length === 1 && (excludedUsers.length === 1 &&
excludedUsers[0] === this.settingsService.currentUser()?.id) excludedUsers[0] === this.settingsService.currentUser()?.id)
) { ) {
@@ -130,13 +128,13 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
get sharedByFilterLabel(): string { get sharedByFilterLabel(): string {
if ( if (
this.selectionModel?.ownerFilter !== OwnerFilterType.SHARED_BY_ME || this.selectionModel?.ownerFilter() !== OwnerFilterType.SHARED_BY_ME ||
this.selectionModel?.userID === this.settingsService.currentUser()?.id this.selectionModel?.userID() === this.settingsService.currentUser()?.id
) { ) {
return $localize`Shared by me` return $localize`Shared by me`
} }
const username = this.getUsername(this.selectionModel?.userID) const username = this.getUsername(this.selectionModel?.userID())
return username return username
? $localize`Shared by ${username}` ? $localize`Shared by ${username}`
: $localize`Shared by another user` : $localize`Shared by another user`
@@ -169,34 +167,36 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
} }
setFilter(type: OwnerFilterType) { setFilter(type: OwnerFilterType) {
this.selectionModel.ownerFilter = type this.selectionModel.ownerFilter.set(type)
if (this.selectionModel.ownerFilter === OwnerFilterType.SELF) { if (this.selectionModel.ownerFilter() === OwnerFilterType.SELF) {
this.selectionModel.includeUsers = [] this.selectionModel.includeUsers.set([])
this.selectionModel.excludeUsers = [] this.selectionModel.excludeUsers.set([])
this.selectionModel.userID = this.settingsService.currentUser().id this.selectionModel.userID.set(this.settingsService.currentUser().id)
this.selectionModel.hideUnowned = false this.selectionModel.hideUnowned.set(false)
} else if (this.selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) { } else if (this.selectionModel.ownerFilter() === OwnerFilterType.NOT_SELF) {
this.selectionModel.userID = null this.selectionModel.userID.set(null)
this.selectionModel.includeUsers = [] this.selectionModel.includeUsers.set([])
this.selectionModel.excludeUsers = [this.settingsService.currentUser().id] this.selectionModel.excludeUsers.set([
this.selectionModel.hideUnowned = false this.settingsService.currentUser().id,
} else if (this.selectionModel.ownerFilter === OwnerFilterType.NONE) { ])
this.selectionModel.userID = null this.selectionModel.hideUnowned.set(false)
this.selectionModel.includeUsers = [] } else if (this.selectionModel.ownerFilter() === OwnerFilterType.NONE) {
this.selectionModel.excludeUsers = [] this.selectionModel.userID.set(null)
this.selectionModel.hideUnowned = false this.selectionModel.includeUsers.set([])
this.selectionModel.excludeUsers.set([])
this.selectionModel.hideUnowned.set(false)
} else if ( } else if (
this.selectionModel.ownerFilter === OwnerFilterType.SHARED_BY_ME this.selectionModel.ownerFilter() === OwnerFilterType.SHARED_BY_ME
) { ) {
this.selectionModel.userID = this.settingsService.currentUser()?.id this.selectionModel.userID.set(this.settingsService.currentUser()?.id)
this.selectionModel.includeUsers = [] this.selectionModel.includeUsers.set([])
this.selectionModel.excludeUsers = [] this.selectionModel.excludeUsers.set([])
this.selectionModel.hideUnowned = false this.selectionModel.hideUnowned.set(false)
} else if (this.selectionModel.ownerFilter === OwnerFilterType.UNOWNED) { } else if (this.selectionModel.ownerFilter() === OwnerFilterType.UNOWNED) {
this.selectionModel.userID = null this.selectionModel.userID.set(null)
this.selectionModel.includeUsers = [] this.selectionModel.includeUsers.set([])
this.selectionModel.excludeUsers = [] this.selectionModel.excludeUsers.set([])
this.selectionModel.hideUnowned = false this.selectionModel.hideUnowned.set(false)
} }
this.onChange() this.onChange()
} }
@@ -206,11 +206,11 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
} }
onUserSelect() { onUserSelect() {
if (this.selectionModel.includeUsers?.length) { this.selectionModel.ownerFilter.set(
this.selectionModel.ownerFilter = OwnerFilterType.OTHERS this.selectionModel.includeUsers()?.length
} else { ? OwnerFilterType.OTHERS
this.selectionModel.ownerFilter = OwnerFilterType.NONE : OwnerFilterType.NONE
} )
this.onChange() this.onChange()
} }
@@ -1209,24 +1209,53 @@ describe('DocumentDetailComponent', () => {
expect(fixture.debugElement.queryAll(By.css('textarea.rtl'))).not.toBeNull() expect(fixture.debugElement.queryAll(By.css('textarea.rtl'))).not.toBeNull()
}) })
it('should display built-in pdf viewer if not disabled', () => { it('should display built-in pdf viewer if not disabled', async () => {
initNormally() initNormally()
component.document().archived_file_name = 'file.pdf' component.document.update((document) => ({
...document,
archived_file_name: 'file.pdf',
}))
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false) settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
expect(component.useNativePdfViewer).toBeFalsy() expect(component.useNativePdfViewer).toBeFalsy()
fixture.detectChanges() await fixture.whenStable()
expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull() expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull()
}) })
it('should display native pdf viewer if enabled', () => { it('should display native pdf viewer if enabled', () => {
initNormally() initNormally()
component.document().archived_file_name = 'file.pdf' component.document.update((document) => ({
...document,
archived_file_name: 'file.pdf',
}))
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, true) settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, true)
expect(component.useNativePdfViewer).toBeTruthy() expect(component.useNativePdfViewer).toBeTruthy()
fixture.detectChanges() fixture.detectChanges()
expect(fixture.debugElement.query(By.css('object'))).not.toBeNull() expect(fixture.debugElement.query(By.css('object'))).not.toBeNull()
}) })
it('should reflect signal-backed document detail display settings', () => {
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL, false)
settingsService.set(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS, [
component.DocumentDetailFieldID.Correspondent,
])
expect(component.showThumbnailOverlay).toBeFalsy()
expect(
component.isFieldHidden(component.DocumentDetailFieldID.Correspondent)
).toBeTruthy()
expect(
component.isFieldHidden(component.DocumentDetailFieldID.DocumentType)
).toBeFalsy()
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL, true)
settingsService.set(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS, [])
expect(component.showThumbnailOverlay).toBeTruthy()
expect(
component.isFieldHidden(component.DocumentDetailFieldID.Correspondent)
).toBeFalsy()
})
it('should attempt to retrieve metadata', () => { it('should attempt to retrieve metadata', () => {
const metadataSpy = jest.spyOn(documentService, 'getMetadata') const metadataSpy = jest.spyOn(documentService, 'getMetadata')
metadataSpy.mockReturnValue(of({ has_archive_version: true })) metadataSpy.mockReturnValue(of({ has_archive_version: true }))
@@ -1685,7 +1714,10 @@ describe('DocumentDetailComponent', () => {
it('should change preview element by render type', () => { it('should change preview element by render type', () => {
initNormally() initNormally()
component.document().archived_file_name = 'file.pdf' component.document.update((document) => ({
...document,
archived_file_name: 'file.pdf',
}))
fixture.detectChanges() fixture.detectChanges()
expect(component.archiveContentRenderType).toEqual( expect(component.archiveContentRenderType).toEqual(
component.ContentRenderType.PDF component.ContentRenderType.PDF
@@ -1694,8 +1726,11 @@ describe('DocumentDetailComponent', () => {
fixture.debugElement.query(By.css('pdf-viewer-container')) fixture.debugElement.query(By.css('pdf-viewer-container'))
).not.toBeUndefined() ).not.toBeUndefined()
component.document().archived_file_name = undefined component.document.update((document) => ({
component.document().mime_type = 'text/plain' ...document,
archived_file_name: undefined,
mime_type: 'text/plain',
}))
fixture.detectChanges() fixture.detectChanges()
expect(component.archiveContentRenderType).toEqual( expect(component.archiveContentRenderType).toEqual(
component.ContentRenderType.Text component.ContentRenderType.Text
@@ -1704,7 +1739,10 @@ describe('DocumentDetailComponent', () => {
fixture.debugElement.query(By.css('div.preview-sticky')) fixture.debugElement.query(By.css('div.preview-sticky'))
).not.toBeUndefined() ).not.toBeUndefined()
component.document().mime_type = 'image/jpeg' component.document.update((document) => ({
...document,
mime_type: 'image/jpeg',
}))
fixture.detectChanges() fixture.detectChanges()
expect(component.archiveContentRenderType).toEqual( expect(component.archiveContentRenderType).toEqual(
component.ContentRenderType.Image component.ContentRenderType.Image
@@ -1712,9 +1750,12 @@ describe('DocumentDetailComponent', () => {
expect( expect(
fixture.debugElement.query(By.css('.preview-sticky img')) fixture.debugElement.query(By.css('.preview-sticky img'))
).not.toBeUndefined() ).not.toBeUndefined()
;((component.document().mime_type = component.document.update((document) => ({
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'), ...document,
fixture.detectChanges()) mime_type:
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
}))
fixture.detectChanges()
expect(component.archiveContentRenderType).toEqual( expect(component.archiveContentRenderType).toEqual(
component.ContentRenderType.Other component.ContentRenderType.Other
) )
@@ -227,6 +227,19 @@ export class DocumentDetailComponent
private deviceDetectorService = inject(DeviceDetectorService) private deviceDetectorService = inject(DeviceDetectorService)
private savedViewService = inject(SavedViewService) private savedViewService = inject(SavedViewService)
private readonly websocketStatusService = inject(WebsocketStatusService) private readonly websocketStatusService = inject(WebsocketStatusService)
private readonly useNativePdfViewerSetting = this.settings.getSignal<boolean>(
SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER
)
private readonly aiEnabledSetting = this.settings.getSignal<boolean>(
SETTINGS_KEYS.AI_ENABLED
)
private readonly showThumbnailOverlaySetting =
this.settings.getSignal<boolean>(
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL
)
private readonly hiddenFieldsSetting = this.settings.getSignal<
DocumentDetailFieldID[]
>(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS)
@ViewChild('inputTitle') @ViewChild('inputTitle')
titleInput: TextComponent titleInput: TextComponent
@@ -333,8 +346,7 @@ export class DocumentDetailComponent
} }
get useNativePdfViewer(): boolean { get useNativePdfViewer(): boolean {
this.settings.trackChanges() return this.useNativePdfViewerSetting()
return this.settings.get(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER)
} }
get isMobile(): boolean { get isMobile(): boolean {
@@ -342,12 +354,10 @@ export class DocumentDetailComponent
} }
get aiEnabled(): boolean { get aiEnabled(): boolean {
this.settings.trackChanges() return this.aiEnabledSetting()
return this.settings.get(SETTINGS_KEYS.AI_ENABLED)
} }
get archiveContentRenderType(): ContentRenderType { get archiveContentRenderType(): ContentRenderType {
this.settings.trackChanges()
const hasArchiveVersion = const hasArchiveVersion =
this.metadata()?.has_archive_version ?? this.metadata()?.has_archive_version ??
!!this.document()?.archived_file_name !!this.document()?.archived_file_name
@@ -359,22 +369,17 @@ export class DocumentDetailComponent
} }
get originalContentRenderType(): ContentRenderType { get originalContentRenderType(): ContentRenderType {
this.settings.trackChanges()
return this.getRenderType( return this.getRenderType(
this.metadata()?.original_mime_type || this.document()?.mime_type this.metadata()?.original_mime_type || this.document()?.mime_type
) )
} }
get showThumbnailOverlay(): boolean { get showThumbnailOverlay(): boolean {
this.settings.trackChanges() return this.showThumbnailOverlaySetting()
return this.settings.get(SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL)
} }
isFieldHidden(fieldId: DocumentDetailFieldID): boolean { isFieldHidden(fieldId: DocumentDetailFieldID): boolean {
this.settings.trackChanges() return this.hiddenFieldsSetting().includes(fieldId)
return this.settings
.get(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS)
.includes(fieldId)
} }
private getRenderType(mimeType: string): ContentRenderType { private getRenderType(mimeType: string): ContentRenderType {
@@ -121,6 +121,8 @@ export class DocumentListComponent
settingsService = inject(SettingsService) settingsService = inject(SettingsService)
private hotKeyService = inject(HotKeyService) private hotKeyService = inject(HotKeyService)
permissionService = inject(PermissionsService) permissionService = inject(PermissionsService)
private readonly notesEnabledSetting =
this.settingsService.getSignal<boolean>(SETTINGS_KEYS.NOTES_ENABLED)
DisplayField = DisplayField DisplayField = DisplayField
DisplayMode = DisplayMode DisplayMode = DisplayMode
@@ -574,8 +576,7 @@ export class DocumentListComponent
} }
get notesEnabled(): boolean { get notesEnabled(): boolean {
this.settingsService.trackChanges() return this.notesEnabledSetting()
return this.settingsService.get(SETTINGS_KEYS.NOTES_ENABLED)
} }
resetFilters() { resetFilters() {
@@ -621,6 +621,43 @@ describe('FilterEditorComponent', () => {
component.toggleTag(2) // coverage component.toggleTag(2) // coverage
}) })
it('should reflect ingested tag filter rules in the dropdown toggle', () => {
const dropdown = fixture.debugElement.query(
By.css('pngx-filterable-dropdown')
)
const toggle = dropdown.nativeElement.querySelector('#dropdown_tags')
expect(toggle.classList.contains('btn-primary')).toBeFalsy()
expect(
dropdown.nativeElement.querySelector('pngx-clearable-badge')
).toBeNull()
// switching to a view with a tag filter
component.filterRules = [
{
rule_type: FILTER_HAS_TAGS_ALL,
value: '2',
},
]
fixture.detectChanges()
expect(toggle.classList.contains('btn-primary')).toBeTruthy()
expect(
dropdown.nativeElement.querySelector('pngx-clearable-badge')
).not.toBeNull()
// and back to a view without one
component.filterRules = [
{
rule_type: FILTER_HAS_CORRESPONDENT_ANY,
value: '12',
},
]
fixture.detectChanges()
expect(toggle.classList.contains('btn-primary')).toBeFalsy()
expect(
dropdown.nativeElement.querySelector('pngx-clearable-badge')
).toBeNull()
})
it('should ingest filter rules for has any tags', () => { it('should ingest filter rules for has any tags', () => {
expect(component.tagSelectionModel.getSelectedItems()).toHaveLength(0) expect(component.tagSelectionModel.getSelectedItems()).toHaveLength(0)
component.filterRules = [ component.filterRules = [
@@ -1078,7 +1115,7 @@ describe('FilterEditorComponent', () => {
}) })
it('should ingest filter rules for owner', () => { it('should ingest filter rules for owner', () => {
expect(component.permissionsSelectionModel.ownerFilter).toEqual( expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
OwnerFilterType.NONE OwnerFilterType.NONE
) )
component.filterRules = [ component.filterRules = [
@@ -1087,15 +1124,38 @@ describe('FilterEditorComponent', () => {
value: '100', value: '100',
}, },
] ]
expect(component.permissionsSelectionModel.ownerFilter).toEqual( expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
OwnerFilterType.SELF OwnerFilterType.SELF
) )
expect(component.permissionsSelectionModel.hideUnowned).toBeFalsy() expect(component.permissionsSelectionModel.hideUnowned()).toBeFalsy()
expect(component.permissionsSelectionModel.userID).toEqual(100) expect(component.permissionsSelectionModel.userID()).toEqual(100)
})
it('should reflect ingested owner filter rules in the dropdown toggle', () => {
const dropdown = fixture.debugElement.query(
By.css('pngx-permissions-filter-dropdown')
)
const toggle = dropdown.nativeElement.querySelector('button')
expect(toggle.classList.contains('btn-primary')).toBeFalsy()
// switching to a view with an owner filter
component.filterRules = [
{
rule_type: FILTER_OWNER,
value: '100',
},
]
fixture.detectChanges()
expect(toggle.classList.contains('btn-primary')).toBeTruthy()
// and back to a view without one
component.filterRules = []
fixture.detectChanges()
expect(toggle.classList.contains('btn-primary')).toBeFalsy()
}) })
it('should ingest filter rules for owner is others', () => { it('should ingest filter rules for owner is others', () => {
expect(component.permissionsSelectionModel.ownerFilter).toEqual( expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
OwnerFilterType.NONE OwnerFilterType.NONE
) )
component.filterRules = [ component.filterRules = [
@@ -1104,14 +1164,14 @@ describe('FilterEditorComponent', () => {
value: '50', value: '50',
}, },
] ]
expect(component.permissionsSelectionModel.ownerFilter).toEqual( expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
OwnerFilterType.OTHERS OwnerFilterType.OTHERS
) )
expect(component.permissionsSelectionModel.includeUsers).toContain(50) expect(component.permissionsSelectionModel.includeUsers()).toContain(50)
}) })
it('should ingest filter rules for owner does not include others', () => { it('should ingest filter rules for owner does not include others', () => {
expect(component.permissionsSelectionModel.ownerFilter).toEqual( expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
OwnerFilterType.NONE OwnerFilterType.NONE
) )
component.filterRules = [ component.filterRules = [
@@ -1120,14 +1180,14 @@ describe('FilterEditorComponent', () => {
value: '50', value: '50',
}, },
] ]
expect(component.permissionsSelectionModel.ownerFilter).toEqual( expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
OwnerFilterType.NOT_SELF OwnerFilterType.NOT_SELF
) )
expect(component.permissionsSelectionModel.excludeUsers).toContain(50) expect(component.permissionsSelectionModel.excludeUsers()).toContain(50)
}) })
it('should ingest filter rules for owner is null', () => { it('should ingest filter rules for owner is null', () => {
expect(component.permissionsSelectionModel.ownerFilter).toEqual( expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
OwnerFilterType.NONE OwnerFilterType.NONE
) )
component.filterRules = [ component.filterRules = [
@@ -1136,10 +1196,10 @@ describe('FilterEditorComponent', () => {
value: 'true', value: 'true',
}, },
] ]
expect(component.permissionsSelectionModel.ownerFilter).toEqual( expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
OwnerFilterType.UNOWNED OwnerFilterType.UNOWNED
) )
expect(component.permissionsSelectionModel.hideUnowned).toBeFalsy() expect(component.permissionsSelectionModel.hideUnowned()).toBeFalsy()
}) })
it('should ingest filter rules for owner is not null', () => { it('should ingest filter rules for owner is not null', () => {
@@ -1149,14 +1209,14 @@ describe('FilterEditorComponent', () => {
value: 'false', value: 'false',
}, },
] ]
expect(component.permissionsSelectionModel.hideUnowned).toBeTruthy() expect(component.permissionsSelectionModel.hideUnowned()).toBeTruthy()
component.filterRules = [ component.filterRules = [
{ {
rule_type: FILTER_OWNER_ISNULL, rule_type: FILTER_OWNER_ISNULL,
value: '0', value: '0',
}, },
] ]
expect(component.permissionsSelectionModel.hideUnowned).toBeTruthy() expect(component.permissionsSelectionModel.hideUnowned()).toBeTruthy()
}) })
it('should ingest filter rules for shared by me', () => { it('should ingest filter rules for shared by me', () => {
@@ -1166,7 +1226,7 @@ describe('FilterEditorComponent', () => {
value: '2', value: '2',
}, },
] ]
expect(component.permissionsSelectionModel.userID).toEqual(2) expect(component.permissionsSelectionModel.userID()).toEqual(2)
}) })
// GET filterRules // GET filterRules
@@ -1932,7 +1992,10 @@ describe('FilterEditorComponent', () => {
value: '1', value: '1',
}, },
]) ])
component.permissionsSelectionModel.excludeUsers.push(2) component.permissionsSelectionModel.excludeUsers.update((users) => [
...users,
2,
])
fixture.detectChanges() fixture.detectChanges()
expect(component.filterRules).toEqual([ expect(component.filterRules).toEqual([
{ {
@@ -1982,8 +2045,11 @@ describe('FilterEditorComponent', () => {
// TODO: mock input in code // TODO: mock input in code
// userSelect.query(By.css('input')).nativeElement.value = '3' // userSelect.query(By.css('input')).nativeElement.value = '3'
// userSelect.triggerEventHandler('change') // userSelect.triggerEventHandler('change')
component.permissionsSelectionModel.ownerFilter = OwnerFilterType.OTHERS component.permissionsSelectionModel.ownerFilter.set(OwnerFilterType.OTHERS)
component.permissionsSelectionModel.includeUsers.push(3) component.permissionsSelectionModel.includeUsers.update((users) => [
...users,
3,
])
fixture.detectChanges() fixture.detectChanges()
expect(component.filterRules).toEqual([ expect(component.filterRules).toEqual([
{ {
@@ -2003,7 +2069,7 @@ describe('FilterEditorComponent', () => {
ownerToggle.nativeElement.checked = true ownerToggle.nativeElement.checked = true
// ownerToggle.triggerEventHandler('change') // ownerToggle.triggerEventHandler('change')
// TODO: ngModel isn't doing this here // TODO: ngModel isn't doing this here
component.permissionsSelectionModel.hideUnowned = true component.permissionsSelectionModel.hideUnowned.set(true)
fixture.detectChanges() fixture.detectChanges()
expect(component.filterRules).toEqual([ expect(component.filterRules).toEqual([
{ {
@@ -735,38 +735,50 @@ export class FilterEditorComponent
this._textFilter = rule.value this._textFilter = rule.value
break break
case FILTER_OWNER: case FILTER_OWNER:
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.SELF this.permissionsSelectionModel.ownerFilter.set(OwnerFilterType.SELF)
this.permissionsSelectionModel.hideUnowned = false this.permissionsSelectionModel.hideUnowned.set(false)
if (rule.value) if (rule.value)
this.permissionsSelectionModel.userID = parseInt(rule.value, 10) this.permissionsSelectionModel.userID.set(
Number.parseInt(rule.value, 10)
)
break break
case FILTER_OWNER_ANY: case FILTER_OWNER_ANY:
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.OTHERS this.permissionsSelectionModel.ownerFilter.set(OwnerFilterType.OTHERS)
if (rule.value) if (rule.value)
this.permissionsSelectionModel.includeUsers.push( this.permissionsSelectionModel.includeUsers.update((users) => [
parseInt(rule.value, 10) ...users,
) Number.parseInt(rule.value, 10),
])
break break
case FILTER_OWNER_DOES_NOT_INCLUDE: case FILTER_OWNER_DOES_NOT_INCLUDE:
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.NOT_SELF this.permissionsSelectionModel.ownerFilter.set(
OwnerFilterType.NOT_SELF
)
if (rule.value) if (rule.value)
this.permissionsSelectionModel.excludeUsers.push( this.permissionsSelectionModel.excludeUsers.update((users) => [
parseInt(rule.value, 10) ...users,
) Number.parseInt(rule.value, 10),
])
break break
case FILTER_SHARED_BY_USER: case FILTER_SHARED_BY_USER:
this.permissionsSelectionModel.ownerFilter = this.permissionsSelectionModel.ownerFilter.set(
OwnerFilterType.SHARED_BY_ME OwnerFilterType.SHARED_BY_ME
)
if (rule.value) if (rule.value)
this.permissionsSelectionModel.userID = parseInt(rule.value, 10) this.permissionsSelectionModel.userID.set(
Number.parseInt(rule.value, 10)
)
break break
case FILTER_OWNER_ISNULL: case FILTER_OWNER_ISNULL:
if (rule.value === 'true' || rule.value === '1') { if (rule.value === 'true' || rule.value === '1') {
this.permissionsSelectionModel.hideUnowned = false this.permissionsSelectionModel.hideUnowned.set(false)
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.UNOWNED this.permissionsSelectionModel.ownerFilter.set(
OwnerFilterType.UNOWNED
)
} else { } else {
this.permissionsSelectionModel.hideUnowned = this.permissionsSelectionModel.hideUnowned.set(
rule.value === 'false' || rule.value === '0' rule.value === 'false' || rule.value === '0'
)
break break
} }
} }
@@ -1074,34 +1086,35 @@ export class FilterEditorComponent
}) })
} }
} }
if (this.permissionsSelectionModel.ownerFilter == OwnerFilterType.SELF) { if (this.permissionsSelectionModel.ownerFilter() == OwnerFilterType.SELF) {
filterRules.push({ filterRules.push({
rule_type: FILTER_OWNER, rule_type: FILTER_OWNER,
value: this.permissionsSelectionModel.userID.toString(), value: this.permissionsSelectionModel.userID().toString(),
}) })
} else if ( } else if (
this.permissionsSelectionModel.ownerFilter == OwnerFilterType.NOT_SELF this.permissionsSelectionModel.ownerFilter() == OwnerFilterType.NOT_SELF
) { ) {
filterRules.push({ filterRules.push({
rule_type: FILTER_OWNER_DOES_NOT_INCLUDE, rule_type: FILTER_OWNER_DOES_NOT_INCLUDE,
value: this.permissionsSelectionModel.excludeUsers?.join(','), value: this.permissionsSelectionModel.excludeUsers()?.join(','),
}) })
} else if ( } else if (
this.permissionsSelectionModel.ownerFilter == OwnerFilterType.OTHERS this.permissionsSelectionModel.ownerFilter() == OwnerFilterType.OTHERS
) { ) {
filterRules.push({ filterRules.push({
rule_type: FILTER_OWNER_ANY, rule_type: FILTER_OWNER_ANY,
value: this.permissionsSelectionModel.includeUsers?.join(','), value: this.permissionsSelectionModel.includeUsers()?.join(','),
}) })
} else if ( } else if (
this.permissionsSelectionModel.ownerFilter == OwnerFilterType.SHARED_BY_ME this.permissionsSelectionModel.ownerFilter() ==
OwnerFilterType.SHARED_BY_ME
) { ) {
filterRules.push({ filterRules.push({
rule_type: FILTER_SHARED_BY_USER, rule_type: FILTER_SHARED_BY_USER,
value: this.permissionsSelectionModel.userID.toString(), value: this.permissionsSelectionModel.userID().toString(),
}) })
} else if ( } else if (
this.permissionsSelectionModel.ownerFilter == OwnerFilterType.UNOWNED this.permissionsSelectionModel.ownerFilter() == OwnerFilterType.UNOWNED
) { ) {
filterRules.push({ filterRules.push({
rule_type: FILTER_OWNER_ISNULL, rule_type: FILTER_OWNER_ISNULL,
@@ -1109,7 +1122,7 @@ export class FilterEditorComponent
}) })
} }
if (this.permissionsSelectionModel.hideUnowned) { if (this.permissionsSelectionModel.hideUnowned()) {
filterRules.push({ filterRules.push({
rule_type: FILTER_OWNER_ISNULL, rule_type: FILTER_OWNER_ISNULL,
value: 'false', value: 'false',
@@ -210,6 +210,48 @@ describe('SettingsService', () => {
expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('#000000') expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('#000000')
}) })
it('provides stable signals that update when settings change', () => {
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}ui_settings/`
)
req.flush(ui_settings)
const notesEnabled = settingsService.getSignal<boolean>(
SETTINGS_KEYS.NOTES_ENABLED
)
expect(notesEnabled()).toBeTruthy()
expect(
settingsService.getSignal<boolean>(SETTINGS_KEYS.NOTES_ENABLED)
).toBe(notesEnabled)
settingsService.set(SETTINGS_KEYS.NOTES_ENABLED, false)
expect(notesEnabled()).toBeFalsy()
})
it('updates setting signals when settings are reinitialized', () => {
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}ui_settings/`
)
req.flush(ui_settings)
const appTitle = settingsService.getSignal<string>(SETTINGS_KEYS.APP_TITLE)
settingsService.initializeSettings().subscribe()
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}ui_settings/`
)
req.flush({
...ui_settings,
settings: {
...ui_settings.settings,
app_title: 'Updated title',
},
})
expect(appTitle()).toBe('Updated title')
})
it('sets django cookie for languages', () => { it('sets django cookie for languages', () => {
httpTestingController httpTestingController
.expectOne(`${environment.apiBaseUrl}ui_settings/`) .expectOne(`${environment.apiBaseUrl}ui_settings/`)
+16 -4
View File
@@ -2,6 +2,8 @@ import { HttpClient } from '@angular/common/http'
import { import {
DOCUMENT, DOCUMENT,
EventEmitter, EventEmitter,
Signal,
computed,
inject, inject,
Injectable, Injectable,
LOCALE_ID, LOCALE_ID,
@@ -297,6 +299,7 @@ export class SettingsService {
private settings: Record<string, any> = {} private settings: Record<string, any> = {}
private readonly settingsVersion = signal(0) private readonly settingsVersion = signal(0)
private readonly settingSignals = new Map<string, Signal<unknown>>()
readonly currentUser = signal<User>(undefined) readonly currentUser = signal<User>(undefined)
public settingsSaved: EventEmitter<any> = new EventEmitter() public settingsSaved: EventEmitter<any> = new EventEmitter()
@@ -326,10 +329,6 @@ export class SettingsService {
return !UNSAFE_OBJECT_KEYS.has(key) return !UNSAFE_OBJECT_KEYS.has(key)
} }
public trackChanges(): void {
this.settingsVersion()
}
private assignSafeSettings(source: Record<string, any>) { private assignSafeSettings(source: Record<string, any>) {
if (!source || typeof source !== 'object' || Array.isArray(source)) { if (!source || typeof source !== 'object' || Array.isArray(source)) {
return return
@@ -339,6 +338,7 @@ export class SettingsService {
if (!this.isSafeObjectKey(key)) continue if (!this.isSafeObjectKey(key)) continue
this.settings[key] = source[key] this.settings[key] = source[key]
} }
this.settingsVersion.update((version) => version + 1)
} }
// this is called by the app initializer in app.module // this is called by the app initializer in app.module
@@ -594,6 +594,18 @@ export class SettingsService {
} }
} }
getSignal<T = any>(key: string): Signal<T> {
let settingSignal = this.settingSignals.get(key)
if (!settingSignal) {
settingSignal = computed(() => {
this.settingsVersion()
return this.get(key)
})
this.settingSignals.set(key, settingSignal)
}
return settingSignal as Signal<T>
}
set(key: string, value: any) { set(key: string, value: any) {
// parse key:key:key into nested object // parse key:key:key into nested object
let settingObj = this.settings let settingObj = this.settings
@@ -314,7 +314,7 @@ def _consume_file(
consumption_dir: Path, consumption_dir: Path,
*, *,
subdirs_as_tags: bool, subdirs_as_tags: bool,
) -> None: ) -> bool:
""" """
Queue a file for consumption. Queue a file for consumption.
@@ -322,15 +322,20 @@ def _consume_file(
filepath: Path to the file to consume. filepath: Path to the file to consume.
consumption_dir: Base consumption directory. consumption_dir: Base consumption directory.
subdirs_as_tags: Whether to create tags from subdirectory names. subdirs_as_tags: Whether to create tags from subdirectory names.
Returns:
True if the file was successfully handed to Celery, False otherwise.
Callers must not record the file as queued on failure, or the rescan
will never retry it.
""" """
# Verify file still exists and is accessible # Verify file still exists and is accessible
try: try:
if not filepath.is_file(): if not filepath.is_file():
logger.debug(f"Not consuming {filepath}: not a file or doesn't exist") logger.debug(f"Not consuming {filepath}: not a file or doesn't exist")
return return False
except OSError as e: except OSError as e:
logger.warning(f"Not consuming {filepath}: {e}") logger.warning(f"Not consuming {filepath}: {e}")
return return False
# Get tags from path if configured # Get tags from path if configured
tag_ids: list[int] | None = None tag_ids: list[int] | None = None
@@ -355,6 +360,9 @@ def _consume_file(
) )
except Exception: except Exception:
logger.exception(f"Error while queuing document {filepath}") logger.exception(f"Error while queuing document {filepath}")
return False
return True
class Command(BaseCommand): class Command(BaseCommand):
@@ -492,12 +500,12 @@ class Command(BaseCommand):
if not consumer_filter(Change.added, str(filepath)): if not consumer_filter(Change.added, str(filepath)):
continue continue
_consume_file( if _consume_file(
filepath=filepath, filepath=filepath,
consumption_dir=directory, consumption_dir=directory,
subdirs_as_tags=subdirs_as_tags, subdirs_as_tags=subdirs_as_tags,
) ):
queued.add(filepath.resolve()) queued.add(filepath.resolve())
return queued return queued
@@ -651,14 +659,16 @@ class Command(BaseCommand):
# Check for stable files # Check for stable files
for stable_path in tracker.get_stable_files(): for stable_path in tracker.get_stable_files():
_consume_file( # Only remember files that were actually queued, so the
# rescan does not re-queue them while the consume task
# has yet to remove them from disk, but does retry a
# failed publish instead of stranding it
if _consume_file(
filepath=stable_path, filepath=stable_path,
consumption_dir=directory, consumption_dir=directory,
subdirs_as_tags=subdirs_as_tags, subdirs_as_tags=subdirs_as_tags,
) ):
# Remember it so the rescan does not re-queue it while queued.add(stable_path)
# the consume task has yet to remove it from disk
queued.add(stable_path)
# Exit watch loop to reconfigure timeout # Exit watch loop to reconfigure timeout
break break
@@ -1063,3 +1063,79 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
) )
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("non-public address", str(response.data).lower()) self.assertIn("non-public address", str(response.data).lower())
@override_settings(REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS=False)
def test_update_remote_ocr_endpoint_blocks_internal_endpoint_when_disallowed(
self,
) -> None:
"""
GIVEN:
- Internal remote OCR endpoints are disallowed
WHEN:
- The config is updated with a remote OCR endpoint resolving internally
THEN:
- The request is rejected
"""
response = self.client.patch(
f"{self.ENDPOINT}1/",
json.dumps(
{
"remote_ocr_endpoint": "http://127.0.0.1:5000",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("non-public address", str(response.data).lower())
@override_settings(REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS=True)
def test_update_remote_ocr_endpoint_allows_internal_endpoint_by_default(
self,
) -> None:
"""
GIVEN:
- Internal remote OCR endpoints are allowed (the default)
WHEN:
- The config is updated with a remote OCR endpoint resolving internally
THEN:
- The request is accepted, preserving existing self-hosted deployments
"""
response = self.client.patch(
f"{self.ENDPOINT}1/",
json.dumps(
{
"remote_ocr_endpoint": "http://127.0.0.1:5000",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
response.data["remote_ocr_endpoint"],
"http://127.0.0.1:5000",
)
@override_settings(REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS=False)
def test_update_remote_ocr_endpoint_empty_value_skips_validation(
self,
) -> None:
"""
GIVEN:
- Internal remote OCR endpoints are disallowed
WHEN:
- The config is updated with an empty remote OCR endpoint
THEN:
- The request is accepted; clearing the field never needs
outbound URL validation
"""
response = self.client.patch(
f"{self.ENDPOINT}1/",
json.dumps(
{
"remote_ocr_endpoint": "",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["remote_ocr_endpoint"], "")
@@ -445,12 +445,13 @@ class TestConsumeFile:
target = consumption_dir / "document.pdf" target = consumption_dir / "document.pdf"
shutil.copy(sample_pdf, target) shutil.copy(sample_pdf, target)
_consume_file( result = _consume_file(
filepath=target, filepath=target,
consumption_dir=consumption_dir, consumption_dir=consumption_dir,
subdirs_as_tags=False, subdirs_as_tags=False,
) )
assert result is True
mock_consume_file_delay.apply_async.assert_called_once() mock_consume_file_delay.apply_async.assert_called_once()
call_args = mock_consume_file_delay.apply_async.call_args call_args = mock_consume_file_delay.apply_async.call_args
consumable_doc = call_args.kwargs["kwargs"]["input_doc"] consumable_doc = call_args.kwargs["kwargs"]["input_doc"]
@@ -464,11 +465,12 @@ class TestConsumeFile:
mock_consume_file_delay: MagicMock, mock_consume_file_delay: MagicMock,
) -> None: ) -> None:
"""Test _consume_file handles nonexistent files gracefully.""" """Test _consume_file handles nonexistent files gracefully."""
_consume_file( result = _consume_file(
filepath=consumption_dir / "nonexistent.pdf", filepath=consumption_dir / "nonexistent.pdf",
consumption_dir=consumption_dir, consumption_dir=consumption_dir,
subdirs_as_tags=False, subdirs_as_tags=False,
) )
assert result is False
mock_consume_file_delay.apply_async.assert_not_called() mock_consume_file_delay.apply_async.assert_not_called()
def test_consume_directory( def test_consume_directory(
@@ -480,11 +482,12 @@ class TestConsumeFile:
subdir = consumption_dir / "subdir" subdir = consumption_dir / "subdir"
subdir.mkdir() subdir.mkdir()
_consume_file( result = _consume_file(
filepath=subdir, filepath=subdir,
consumption_dir=consumption_dir, consumption_dir=consumption_dir,
subdirs_as_tags=False, subdirs_as_tags=False,
) )
assert result is False
mock_consume_file_delay.apply_async.assert_not_called() mock_consume_file_delay.apply_async.assert_not_called()
def test_consume_with_permission_error( def test_consume_with_permission_error(
@@ -499,13 +502,33 @@ class TestConsumeFile:
shutil.copy(sample_pdf, target) shutil.copy(sample_pdf, target)
mocker.patch.object(Path, "is_file", side_effect=PermissionError("denied")) mocker.patch.object(Path, "is_file", side_effect=PermissionError("denied"))
_consume_file( result = _consume_file(
filepath=target, filepath=target,
consumption_dir=consumption_dir, consumption_dir=consumption_dir,
subdirs_as_tags=False, subdirs_as_tags=False,
) )
assert result is False
mock_consume_file_delay.apply_async.assert_not_called() mock_consume_file_delay.apply_async.assert_not_called()
def test_consume_with_apply_async_failure(
self,
consumption_dir: Path,
sample_pdf: Path,
mock_consume_file_delay: MagicMock,
) -> None:
"""Test _consume_file reports failure when apply_async raises."""
target = consumption_dir / "document.pdf"
shutil.copy(sample_pdf, target)
mock_consume_file_delay.apply_async.side_effect = Exception("broker down")
result = _consume_file(
filepath=target,
consumption_dir=consumption_dir,
subdirs_as_tags=False,
)
assert result is False
def test_consume_with_tags_error( def test_consume_with_tags_error(
self, self,
consumption_dir: Path, consumption_dir: Path,
@@ -522,11 +545,12 @@ class TestConsumeFile:
side_effect=DatabaseError("Something happened"), side_effect=DatabaseError("Something happened"),
) )
_consume_file( result = _consume_file(
filepath=target, filepath=target,
consumption_dir=consumption_dir, consumption_dir=consumption_dir,
subdirs_as_tags=True, subdirs_as_tags=True,
) )
assert result is True
mock_consume_file_delay.apply_async.assert_called_once() mock_consume_file_delay.apply_async.assert_called_once()
call_args = mock_consume_file_delay.apply_async.call_args call_args = mock_consume_file_delay.apply_async.call_args
overrides = call_args.kwargs["kwargs"]["overrides"] overrides = call_args.kwargs["kwargs"]["overrides"]
@@ -1249,6 +1273,52 @@ class TestProcessExistingFilesQueued:
assert target.resolve() in queued assert target.resolve() in queued
@pytest.mark.management
@pytest.mark.django_db
class TestCommandRetryAfterQueueFailure:
"""
Regression test for GH #13923.
A file whose ``apply_async`` publish fails (e.g. broker briefly down)
must not be marked as queued, so the periodic rescan retries it once
the broker recovers, instead of stranding it until the consumer
process is restarted.
"""
def test_watch_loop_retries_failed_publish_on_rescan(
self,
consumption_dir: Path,
sample_pdf: Path,
mock_consume_file_delay: MagicMock,
start_consumer: Callable[..., ConsumerThread],
) -> None:
"""A publish failure from the watch loop is retried by the rescan."""
apply_async = mock_consume_file_delay.apply_async
def fail_first_call(*args: object, **kwargs: object) -> None:
if apply_async.call_count == 1:
raise Exception("broker down")
apply_async.side_effect = fail_first_call
thread = start_consumer(stability_delay=0.1, rescan_interval=0.3)
target = consumption_dir / "document.pdf"
shutil.copy(sample_pdf, target)
deadline = monotonic() + 5.0
while apply_async.call_count < 2 and monotonic() < deadline:
sleep(0.1)
if thread.exception:
raise thread.exception
assert apply_async.call_count >= 2, (
"Expected the failed publish to be retried by the rescan, "
f"but apply_async was only called {apply_async.call_count} time(s)"
)
@pytest.mark.management @pytest.mark.management
@pytest.mark.django_db @pytest.mark.django_db
class TestCommandRescanRecovery: class TestCommandRescanRecovery:
+38
View File
@@ -32,6 +32,8 @@ if TYPE_CHECKING:
import datetime import datetime
from types import TracebackType from types import TracebackType
from azure.core.pipeline import PipelineRequest
from paperless.parsers import MetadataEntry from paperless.parsers import MetadataEntry
from paperless.parsers import ParserContext from paperless.parsers import ParserContext
@@ -436,9 +438,45 @@ class RemoteDocumentParser:
from azure.ai.documentintelligence.models import DocumentContentFormat from azure.ai.documentintelligence.models import DocumentContentFormat
from azure.core.credentials import AzureKeyCredential from azure.core.credentials import AzureKeyCredential
from paperless.network import validate_outbound_http_url
allow_internal = settings.REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS
try:
validate_outbound_http_url(config.endpoint, allow_internal=allow_internal)
except ValueError as e:
raise ParseError(f"Invalid remote OCR endpoint: {e}") from e
def _revalidate_request_host(request: PipelineRequest) -> None:
"""Re-validates the destination host of every request sent.
The check above only covers the moment the client is built. A
single analysis involves several requests spread over the
polling loop below, and any one of them can be redirected.
Wiring this through ``raw_request_hook`` (Azure's built-in
CustomHookPolicy) rather than a custom policy means it runs
*after* RedirectPolicy in the pipeline, so it sees - and
re-checks - every actual outbound URL, including redirect
targets, not just the original request.
"""
validate_outbound_http_url(
request.http_request.url,
allow_internal=allow_internal,
)
client = DocumentIntelligenceClient( client = DocumentIntelligenceClient(
endpoint=config.endpoint, endpoint=config.endpoint,
credential=AzureKeyCredential(config.api_key), credential=AzureKeyCredential(config.api_key),
raw_request_hook=_revalidate_request_host,
# AzureKeyCredential is sent as Ocp-Apim-Subscription-Key, which
# Azure's default SensitiveHeaderCleanupPolicy does not strip on
# a cross-domain redirect (only Authorization and
# x-ms-authorization-auxiliary are, by default).
blocked_redirect_headers=[
"Authorization",
"x-ms-authorization-auxiliary",
"Ocp-Apim-Subscription-Key",
],
) )
try: try:
+16
View File
@@ -305,6 +305,22 @@ class ApplicationConfigurationSerializer(
validate_llm_embedding_endpoint = validate_llm_endpoint validate_llm_embedding_endpoint = validate_llm_endpoint
def validate_remote_ocr_endpoint(self, value: str | None) -> str | None:
if not value:
return value
try:
validate_outbound_http_url(
value,
allow_internal=settings.REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS,
)
except ValueError as e:
raise serializers.ValidationError(
f"Invalid remote OCR endpoint: {e.args[0]}, see logs for details",
) from e
return value
class Meta: class Meta:
model = ApplicationConfiguration model = ApplicationConfiguration
fields = "__all__" fields = "__all__"
+10
View File
@@ -705,6 +705,12 @@ CELERY_BROKER_TRANSPORT_OPTIONS = {
CELERY_TASK_TRACK_STARTED = True CELERY_TASK_TRACK_STARTED = True
CELERY_TASK_TIME_LIMIT: Final[int] = get_int_from_env("PAPERLESS_WORKER_TIMEOUT", 1800) CELERY_TASK_TIME_LIMIT: Final[int] = get_int_from_env("PAPERLESS_WORKER_TIMEOUT", 1800)
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#std-setting-task_allow_error_cb_on_chord_header
# Without this, a failing chord header never triggers the errback, so a mail
# whose attachments all fail is never recorded and is re-fetched forever.
# The errback runs once per failed header task, so it must be idempotent.
CELERY_TASK_ALLOW_ERROR_CB_ON_CHORD_HEADER = True
CELERY_CACHE_BACKEND = "default" CELERY_CACHE_BACKEND = "default"
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#task-serializer # https://docs.celeryq.dev/en/stable/userguide/configuration.html#task-serializer
@@ -1208,6 +1214,10 @@ REMOTE_OCR_MODE = get_choice_from_env(
{"always", "workflow_only"}, {"always", "workflow_only"},
default="always", default="always",
) )
REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS = get_bool_from_env(
"PAPERLESS_REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS",
"true",
)
################################################################################ ################################################################################
# AI Settings # # AI Settings #
+11 -5
View File
@@ -334,18 +334,24 @@ def error_callback(
""" """
A shared task that is called whenever something goes wrong during A shared task that is called whenever something goes wrong during
consumption of a file. See queue_consumption_tasks. consumption of a file. See queue_consumption_tasks.
With CELERY_TASK_ALLOW_ERROR_CB_ON_CHORD_HEADER enabled this runs once per
failed header task, not once per chord, so it must be idempotent.
""" """
rule = MailRule.objects.get(pk=rule_id) rule = MailRule.objects.get(pk=rule_id)
received = make_aware(message_date) if is_naive(message_date) else message_date
ProcessedMail.objects.create( ProcessedMail.objects.get_or_create(
rule=rule, rule=rule,
folder=rule.folder, folder=rule.folder,
uid=message_uid, uid=message_uid,
uid_validity=uid_validity, uid_validity=uid_validity,
subject=message_subject, defaults={
received=make_aware(message_date) if is_naive(message_date) else message_date, "subject": message_subject,
status="FAILED", "received": received,
error=traceback.format_exc(), "status": "FAILED",
"error": traceback.format_exc(),
},
) )
+39
View File
@@ -36,6 +36,7 @@ from paperless_mail.mail import MailAccountHandler
from paperless_mail.mail import MailError from paperless_mail.mail import MailError
from paperless_mail.mail import TagMailAction from paperless_mail.mail import TagMailAction
from paperless_mail.mail import apply_mail_action from paperless_mail.mail import apply_mail_action
from paperless_mail.mail import error_callback
from paperless_mail.mail import get_mailbox from paperless_mail.mail import get_mailbox
from paperless_mail.models import MailAccount from paperless_mail.models import MailAccount
from paperless_mail.models import MailRule from paperless_mail.models import MailRule
@@ -2045,6 +2046,44 @@ class TestPostConsumeAction(TestCase):
self.assertIn("Test Exception", processed_mail.error) self.assertIn("Test Exception", processed_mail.error)
@pytest.mark.django_db
class TestErrorCallback:
def test_error_callback_is_idempotent_for_same_mail(self) -> None:
"""
GIVEN:
- A mail rule and a mail that failed to be consumed
WHEN:
- error_callback is invoked more than once for the same mail, as
happens when task_allow_error_cb_on_chord_header fires the
errback once per failed header task in a chord
THEN:
- Only one ProcessedMail row is created for that mail
"""
rule = MailRuleFactory()
message_uid = "12345"
for _ in range(2):
error_callback(
None,
Exception("Test Exception"),
None,
rule_id=rule.pk,
message_uid=message_uid,
message_subject="Test Subject",
message_date=timezone.make_aware(
timezone.datetime(2023, 1, 1, 12, 0, 0),
),
)
processed_mails = ProcessedMail.objects.filter(
rule=rule,
uid=message_uid,
folder=rule.folder,
)
assert processed_mails.count() == 1
assert processed_mails.get().status == "FAILED"
class TestManagementCommand(TestCase): class TestManagementCommand(TestCase):
@mock.patch( @mock.patch(
"paperless_mail.management.commands.mail_fetcher.tasks.process_mail_accounts", "paperless_mail.management.commands.mail_fetcher.tasks.process_mail_accounts",
Generated
+7 -7
View File
@@ -4,11 +4,11 @@ requires-python = ">=3.11"
resolution-markers = [ resolution-markers = [
"python_full_version >= '3.15' and sys_platform == 'darwin'", "python_full_version >= '3.15' and sys_platform == 'darwin'",
"python_full_version >= '3.15' and sys_platform == 'linux'", "python_full_version >= '3.15' and sys_platform == 'linux'",
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"python_full_version >= '3.12' and python_full_version < '3.15' and sys_platform == 'darwin'", "python_full_version >= '3.12' and python_full_version < '3.15' and sys_platform == 'darwin'",
"python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'", "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
"python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'linux')", "(python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'linux')",
"python_full_version < '3.12' and sys_platform == 'darwin'", "python_full_version < '3.12' and sys_platform == 'darwin'",
"python_full_version < '3.12' and sys_platform == 'linux'", "python_full_version < '3.12' and sys_platform == 'linux'",
@@ -2718,7 +2718,7 @@ wheels = [
[[package]] [[package]]
name = "nltk" name = "nltk"
version = "3.10.3" version = "3.10.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },
@@ -2727,9 +2727,9 @@ dependencies = [
{ name = "regex" }, { name = "regex" },
{ name = "tqdm" }, { name = "tqdm" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/e0/e6/fe51d2bb1a3b446f59c5c8165999a9fee208bc346af90a7cbf7657bc0d75/nltk-3.10.3.tar.gz", hash = "sha256:bb9327a461c3811c2fa4900e03840401f2126adfb30c0072827c433bd2444ea4", size = 5137152, upload-time = "2026-08-12T23:46:37.258Z" } sdist = { url = "https://files.pythonhosted.org/packages/96/02/df4f105b28a7c16b0e41423bc09cf0f1b8a305df4ef0b10ca74a2e4c648c/nltk-3.10.0.tar.gz", hash = "sha256:4fbac1d98203cbcd1b5d94a2877fb822300072d80604a5e7fae49d2c5f84e8c1", size = 3089244, upload-time = "2026-07-08T02:39:13.562Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/b6/6d/ebd2af4640b12168fdf0cb74b6118df2f32a2f62ec7e0c06fbfd80706639/nltk-3.10.3-py3-none-any.whl", hash = "sha256:ff9598a8e20518ee0d557745890cc4435b9578489e2dcbc69c4f81fa060caf7c", size = 1798643, upload-time = "2026-08-12T23:44:13.478Z" }, { url = "https://files.pythonhosted.org/packages/6e/89/a0b0f35e2820d6a99d75ea1c11977ee6d5c9e6658eceb45b0c7620881faa/nltk-3.10.0-py3-none-any.whl", hash = "sha256:54ff84d4916d3ef127e8953bee0023f6a6b320b75d634a19e06ef056d3d244bf", size = 1716144, upload-time = "2026-07-08T02:39:09.753Z" },
] ]
[[package]] [[package]]
@@ -5014,10 +5014,10 @@ version = "2.13.0+cpu"
source = { registry = "https://download.pytorch.org/whl/cpu" } source = { registry = "https://download.pytorch.org/whl/cpu" }
resolution-markers = [ resolution-markers = [
"python_full_version >= '3.15' and sys_platform == 'linux'", "python_full_version >= '3.15' and sys_platform == 'linux'",
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'", "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
"python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'linux')", "(python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'linux')",
"python_full_version < '3.12' and sys_platform == 'linux'", "python_full_version < '3.12' and sys_platform == 'linux'",
] ]