Compare commits

...
Author SHA1 Message Date
stumpylog 7c0d3c2f86 If the filter will exceed SQLite IN limits, load to a temporary table and use a subquery to filter instead 2026-09-02 08:33:34 -07:00
stumpylog ec7745e71f Minor improvements from a Claude review 2026-09-02 08:14:41 -07:00
stumpylog 98e87d91ad Fix: skip vector store document id filter for unrestricted chat users
ChatStreamingView built an IN filter from every permitted document id
for the "chat over all documents" case, which exceeds the vector
store's SQLite bound-parameter safety limit on installs with more
than ~32700 documents, silently returning no context. For a user who
can see every document (an active superuser), that filter never
narrows anything, so skip it and let the retriever search the whole
index instead.
2026-09-02 08:14:23 -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
shamoon 5c5b1ee6b5 Fix: fix slim sidebar saved view dragging appearance (#13906) 2026-09-01 13:02:57 -07:00
GitHub Actions 08f2f4bfe2 Auto translate strings 2026-09-01 19:54:47 +00:00
Trenton H f993462973 Security: Minor additional hardening (#13898)
* Security: bump jinja2 floor to 3.1.6 (CVE-2025-27516)

* Security: anchor the /share/ URL pattern

* Security: handle missing file on public share view without 500

* Security: scope correspondent last_correspondence to permitted documents

* Security: disable PUT/PATCH on share link bundles
2026-09-01 19:53:28 +00:00
shamoon ae70b8d60f Chore: consolidate pickle hmac signing (#13899) 2026-09-01 12:41:45 -07:00
shamoon 38db6b51db Fix: use signal-backed queries input in CF dropdown to reflect changes immediately under zoneless (#13901) 2026-09-01 11:52:53 -07:00
GitHub Actions 31e9f4272c Auto translate strings 2026-09-01 16:56:33 +00:00
shamoon b8659c1af3 Fix: use root doc metadata for filename generation (#13893) 2026-09-01 09:55:04 -07:00
shamoon 741115b36b Fix: some css cleanup (#13891) 2026-09-01 09:17:27 -07:00
53 changed files with 1893 additions and 966 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}
+1 -1
View File
@@ -47,7 +47,7 @@ dependencies = [
"httpx-oauth~=0.17", "httpx-oauth~=0.17",
"ijson>=3.5.1", "ijson>=3.5.1",
"imap-tools~=1.14.0", "imap-tools~=1.14.0",
"jinja2~=3.1.5", "jinja2~=3.1.6",
"langdetect~=1.0.9", "langdetect~=1.0.9",
"llama-index-core>=0.14.23", "llama-index-core>=0.14.23",
"llama-index-embeddings-huggingface>=0.6.1", "llama-index-embeddings-huggingface>=0.6.1",
+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
@@ -111,7 +111,7 @@
</h6> </h6>
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)"> <ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
@for (view of savedViewService.sidebarViews; track view.id) { @for (view of savedViewService.sidebarViews; track view.id) {
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews() || !canSaveSettings" <li class="nav-item app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews() || !canSaveSettings"
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)" cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
(cdkDragEnded)="onDragEnd($event)"> (cdkDragEnded)="onDragEnd($event)">
<a class="nav-link" routerLink="view/{{view.id}}" <a class="nav-link" routerLink="view/{{view.id}}"
@@ -128,7 +128,7 @@
} }
</a> </a>
@if (settingsService.organizingSidebarSavedViews() && canSaveSettings) { @if (settingsService.organizingSidebarSavedViews() && canSaveSettings) {
<div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle> <div class="position-absolute end-0 top-0 px-1 py-2" [class.me-n2]="slimSidebarEnabled" cdkDragHandle>
<i-bs name="grip-vertical"></i-bs> <i-bs name="grip-vertical"></i-bs>
</div> </div>
} }
@@ -332,7 +332,7 @@
</li> </li>
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled"> <li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
<div class="text-muted small d-flex align-items-center flex-wrap nav-label"> <div class="text-muted small d-flex align-items-center flex-wrap nav-label">
<div class="me-3"> <div class="me-2">
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer" <a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer"
href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover
[disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body" [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
@@ -341,7 +341,7 @@
</a> </a>
</div> </div>
@if (!settingsService.updateCheckingIsSet || appRemoteVersion()) { @if (!settingsService.updateCheckingIsSet || appRemoteVersion()) {
<div class="version-check"> <div class="version-check d-flex align-items-center">
<ng-template #updateAvailablePopContent> <ng-template #updateAvailablePopContent>
<span class="small">Paperless-ngx {{ appRemoteVersion().version }} <ng-container i18n>is <span class="small">Paperless-ngx {{ appRemoteVersion().version }} <ng-container i18n>is
available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span> available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span>
@@ -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() {
@@ -1,6 +1,6 @@
@if (useDropdown) { @if (useDropdown) {
<div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions"> <div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions">
<button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled" [aria-label]="title"> <button class="btn btn-sm" [ngClass]="!editing && isActive ? 'btn-primary' : 'btn-outline-primary'" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled" [aria-label]="title">
<i-bs name="{{icon}}"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div> <i-bs name="{{icon}}"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
@if (isActive) { @if (isActive) {
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge> <pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge>
@@ -1,5 +1,6 @@
import { import {
getLocaleNumberSymbol, getLocaleNumberSymbol,
NgClass,
NgTemplateOutlet, NgTemplateOutlet,
NumberSymbol, NumberSymbol,
} from '@angular/common' } from '@angular/common'
@@ -48,25 +49,26 @@ import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.comp
import { DocumentLinkComponent } from '../input/document-link/document-link.component' import { DocumentLinkComponent } from '../input/document-link/document-link.component'
export class CustomFieldQueriesModel { export class CustomFieldQueriesModel {
private _queries: CustomFieldQueryElement[] = [] private readonly _queries = signal<CustomFieldQueryElement[]>([])
private rootSubscriptions: Subscription[] = [] private rootSubscriptions: Subscription[] = []
public readonly changed = new Subject<CustomFieldQueriesModel>() public readonly changed = new Subject<CustomFieldQueriesModel>()
public get queries(): CustomFieldQueryElement[] { public get queries(): CustomFieldQueryElement[] {
return this._queries return this._queries()
} }
public set queries(value: CustomFieldQueryElement[]) { public set queries(value: CustomFieldQueryElement[]) {
this.teardownRootSubscriptions() this.teardownRootSubscriptions()
this._queries = value ?? [] const queries = value ?? []
for (const element of this._queries) { for (const element of queries) {
this.rootSubscriptions.push( this.rootSubscriptions.push(
element.changed.subscribe(() => { element.changed.subscribe(() => {
this.changed.next(this) this.changed.next(this)
}) })
) )
} }
this._queries.set(queries)
} }
public clear(fireEvent = true) { public clear(fireEvent = true) {
@@ -209,6 +211,7 @@ export class CustomFieldQueriesModel {
DocumentLinkComponent, DocumentLinkComponent,
ReactiveFormsModule, ReactiveFormsModule,
NgbDatepickerModule, NgbDatepickerModule,
NgClass,
NgTemplateOutlet, NgTemplateOutlet,
NgSelectModule, NgSelectModule,
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
@@ -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 {
@@ -116,7 +116,7 @@
</pngx-page-header> </pngx-page-header>
<div class="row sticky-top py-3 mt-n2 mt-md-n3 bg-body"> <div class="row sticky-top py-3 mt-n2 mt-md-n3 bg-body rounded shadow-sm">
<pngx-filter-editor [hidden]="isBulkEditing" [disabled]="isBulkEditing" [filterRules]="list.filterRules" (filterRulesChange)="onFilterRulesChange($event)" (resetFilterRules)="onFilterRulesReset($event)" [unmodifiedFilterRules]="unmodifiedFilterRules()" [selectionData]="list.selectionData" #filterEditor></pngx-filter-editor> <pngx-filter-editor [hidden]="isBulkEditing" [disabled]="isBulkEditing" [filterRules]="list.filterRules" (filterRulesChange)="onFilterRulesChange($event)" (resetFilterRules)="onFilterRulesReset($event)" [unmodifiedFilterRules]="unmodifiedFilterRules()" [selectionData]="list.selectionData" #filterEditor></pngx-filter-editor>
<pngx-bulk-editor [hidden]="!isBulkEditing" [disabled]="!isBulkEditing"></pngx-bulk-editor> <pngx-bulk-editor [hidden]="!isBulkEditing" [disabled]="!isBulkEditing"></pngx-bulk-editor>
</div> </div>
@@ -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 = [
@@ -1034,8 +1071,51 @@ describe('FilterEditorComponent', () => {
).toEqual([42, CustomFieldQueryOperator.Exists, 'true']) ).toEqual([42, CustomFieldQueryOperator.Exists, 'true'])
}) })
it('should reflect ingested custom field query rules in the dropdown toggle', () => {
const dropdown = fixture.debugElement.query(
By.css('pngx-custom-fields-query-dropdown')
)
expect(
dropdown.nativeElement.querySelector('pngx-clearable-badge')
).toBeNull()
// switching to a view with a custom field query
component.filterRules = [
{
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
value: '["OR",[[42,"exists","true"]]]',
},
]
fixture.detectChanges()
expect(
dropdown.nativeElement.querySelector('pngx-clearable-badge')
).not.toBeNull()
expect(
dropdown.nativeElement
.querySelector('#dropdown_toggle')
.classList.contains('btn-primary')
).toBeTruthy()
// and back to a view without one
component.filterRules = [
{
rule_type: FILTER_HAS_TAGS_ALL,
value: '19',
},
]
fixture.detectChanges()
expect(
dropdown.nativeElement.querySelector('pngx-clearable-badge')
).toBeNull()
expect(
dropdown.nativeElement
.querySelector('#dropdown_toggle')
.classList.contains('btn-primary')
).toBeFalsy()
})
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 = [
@@ -1044,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 = [
@@ -1061,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 = [
@@ -1077,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 = [
@@ -1093,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', () => {
@@ -1106,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', () => {
@@ -1123,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
@@ -1889,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([
{ {
@@ -1939,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([
{ {
@@ -1960,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
+5 -16
View File
@@ -47,6 +47,8 @@ $grid-breakpoints: (
); );
:root { :root {
--bs-border-radius: #{$border-radius};
@each $name, $value in $grid-breakpoints { @each $name, $value in $grid-breakpoints {
--bs-breakpoint-#{$name}: #{$value}; --bs-breakpoint-#{$name}: #{$value};
} }
@@ -78,19 +80,12 @@ body {
} }
.btn { .btn {
--bs-btn-border-radius: .425rem; --bs-border-radius-sm: #{$border-radius};
--bs-border-radius-sm: .425rem;
font-weight: 500; font-weight: 500;
} }
.form-control,
.form-select,
.input-group-text {
border-radius: .425rem;
}
.pagination, .input-group { .pagination, .input-group {
--bs-border-radius-sm: .425rem; --bs-border-radius-sm: #{$border-radius};
} }
@media(min-width: 768px) { @media(min-width: 768px) {
@@ -689,10 +684,6 @@ table.table {
--bs-toast-max-width: var(--pngx-toast-max-width); --bs-toast-max-width: var(--pngx-toast-max-width);
} }
.alert {
--bs-border-radius: .425rem;
}
.alert-primary { .alert-primary {
--bs-alert-color: var(--bs-primary); --bs-alert-color: var(--bs-primary);
--bs-alert-bg: var(--pngx-primary-faded); --bs-alert-bg: var(--pngx-primary-faded);
@@ -824,8 +815,6 @@ code {
--bs-accordion-bg: var(--bs-light); --bs-accordion-bg: var(--bs-light);
--bs-accordion-active-color: var(--bs-primary); --bs-accordion-active-color: var(--bs-primary);
--bs-accordion-active-bg: var(--pngx-bg-alt); --bs-accordion-active-bg: var(--pngx-bg-alt);
--bs-border-radius: .425rem;
--bs-accordion-inner-border-radius: calc(.425rem - 1px);
} }
.accordion-button::after { .accordion-button::after {
@@ -849,7 +838,7 @@ code {
} }
/* Animate items as they're being sorted. */ /* Animate items as they're being sorted. */
.cdk-drop-list-dragging .cdk-drag { .cdk-drop-list-dragging .cdk-drag:not(.cdk-drag-preview) {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
} }
+1
View File
@@ -113,6 +113,7 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,%3csvg xmlns='h
--bs-tertiary-bg: var(--pngx-bg-darker); --bs-tertiary-bg: var(--pngx-bg-darker);
--bs-dark-border-subtle: var(--pngx-bg-darker); --bs-dark-border-subtle: var(--pngx-bg-darker);
--bs-border-color-translucent: rgba(0, 0, 0, .175); // override bs --bs-border-color-translucent: rgba(0, 0, 0, .175); // override bs
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.15); // slightly darker than bs default
.text-dark, .text-light { .text-dark, .text-light {
color: var(--bs-body-color) !important; color: var(--bs-body-color) !important;
+8 -3
View File
@@ -16,6 +16,9 @@ from django.core.cache import cache
from django.core.cache import caches from django.core.cache import caches
from documents.models import Document from documents.models import Document
from paperless.signed_pickle import SignedPickleError
from paperless.signed_pickle import signed_pickle_dumps
from paperless.signed_pickle import signed_pickle_loads
if TYPE_CHECKING: if TYPE_CHECKING:
from django.core.cache.backends.base import BaseCache from django.core.cache.backends.base import BaseCache
@@ -118,9 +121,11 @@ class StoredLRUCache(LRUCache):
serialized_data = self._backend.get(self._backend_key) serialized_data = self._backend.get(self._backend_key)
try: try:
self._data = ( self._data = (
pickle.loads(serialized_data) if serialized_data else OrderedDict() signed_pickle_loads(serialized_data)
if serialized_data
else OrderedDict()
) )
except pickle.PickleError: except (SignedPickleError, pickle.PickleError):
logger.warning( logger.warning(
"Cache exists in backend but could not be read (possibly invalid format)", "Cache exists in backend but could not be read (possibly invalid format)",
) )
@@ -132,7 +137,7 @@ class StoredLRUCache(LRUCache):
""" """
self._backend.set( self._backend.set(
self._backend_key, self._backend_key,
pickle.dumps(self._data), signed_pickle_dumps(self._data),
self.backend_ttl, self.backend_ttl,
) )
+13 -3
View File
@@ -28,6 +28,9 @@ from documents.caching import CLASSIFIER_VERSION_KEY
from documents.caching import StoredLRUCache from documents.caching import StoredLRUCache
from documents.models import Document from documents.models import Document
from documents.models import MatchingModel from documents.models import MatchingModel
from paperless.signed_pickle import SignedPickleError
from paperless.signed_pickle import signed_pickle_dumps
from paperless.signed_pickle import signed_pickle_loads
logger = logging.getLogger("paperless.classifier") logger = logging.getLogger("paperless.classifier")
@@ -527,10 +530,17 @@ class DocumentClassifier:
serialized_result = read_cache.get(key) serialized_result = read_cache.get(key)
if serialized_result is None: if serialized_result is None:
result = self.data_vectorizer.transform([self.preprocess_content(content)]) result = self.data_vectorizer.transform([self.preprocess_content(content)])
read_cache.set(key, pickle.dumps(result), CACHE_5_MINUTES) read_cache.set(key, signed_pickle_dumps(result), CACHE_5_MINUTES)
else: else:
read_cache.touch(key, CACHE_5_MINUTES) try:
result = pickle.loads(serialized_result) result = signed_pickle_loads(serialized_result)
except SignedPickleError:
result = self.data_vectorizer.transform(
[self.preprocess_content(content)],
)
read_cache.set(key, signed_pickle_dumps(result), CACHE_5_MINUTES)
else:
read_cache.touch(key, CACHE_5_MINUTES)
return result return result
def predict_correspondent(self, content: str) -> int | None: def predict_correspondent(self, content: str) -> int | None:
+5 -1
View File
@@ -462,7 +462,11 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
""" """
Returns a sanitized filename for the document, not including any paths. Returns a sanitized filename for the document, not including any paths.
""" """
result = str(self) # Root owns metadata for all versions
context_document = (
self.root_document if self.root_document_id is not None else self
)
result = str(context_document)
if counter: if counter:
result += f"_{counter:02}" result += f"_{counter:02}"
@@ -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"], "")
+74
View File
@@ -1,11 +1,18 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
from unittest import mock from unittest import mock
import pytest
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User from django.contrib.auth.models import User
from rest_framework import status from rest_framework import status
from rest_framework.test import APIClient
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
if TYPE_CHECKING:
from pytest_mock import MockerFixture
class TestChatStreamingViewInputValidation(APITestCase): class TestChatStreamingViewInputValidation(APITestCase):
def setUp(self) -> None: def setUp(self) -> None:
@@ -42,3 +49,70 @@ class TestChatStreamingViewInputValidation(APITestCase):
format="json", format="json",
) )
assert resp.status_code == status.HTTP_400_BAD_REQUEST assert resp.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.django_db
class TestChatStreamingViewUnrestrictedFlag:
"""The document id filter may only be skipped (``unrestricted=True``) for
a caller who can see every document, i.e. an active superuser.
"""
@pytest.fixture
def mocked_stream_chat(self, mocker: MockerFixture) -> mock.MagicMock:
"""AI enabled, with stream_chat_with_documents patched so the view
never touches the real vector store; returns the patched callable so
tests can inspect how it was called.
"""
mocker.patch("documents.views.AIConfig").return_value.ai_enabled = True
return mocker.patch(
"documents.views.stream_chat_with_documents",
return_value=iter(()),
)
@pytest.fixture
def viewer_client(self, user_client: APIClient, regular_user: User) -> APIClient:
"""The conftest regular-user client, additionally granted
view_document -- able to see every document without being a
superuser.
"""
regular_user.user_permissions.add(
*Permission.objects.filter(codename="view_document"),
)
return user_client
@pytest.mark.parametrize(
("client_fixture", "expected_unrestricted"),
[
pytest.param("admin_client", True, id="superuser_is_unrestricted"),
pytest.param("viewer_client", False, id="regular_user_is_restricted"),
],
)
def test_unrestricted_only_for_superuser(
self,
request: pytest.FixtureRequest,
mocked_stream_chat: mock.MagicMock,
client_fixture: str,
*,
expected_unrestricted: bool,
) -> None:
"""
GIVEN:
- A superuser, or a regular user holding view_document
WHEN:
- They post a chat question with no document_id
THEN:
- stream_chat_with_documents is called with unrestricted=True for
the superuser and unrestricted=False for the regular user, even
though that user can view every document
"""
client: APIClient = request.getfixturevalue(client_fixture)
client.post(
"/api/documents/chat/",
data={"q": "What's in these documents?"},
format="json",
)
assert (
mocked_stream_chat.call_args.kwargs["unrestricted"] is expected_unrestricted
)
+27
View File
@@ -102,6 +102,7 @@ class TestApiObjects(DirectoriesMixin, APITestCase):
- API is called - API is called
THEN: THEN:
- Last correspondence date is returned only if requested for list, and for detail - Last correspondence date is returned only if requested for list, and for detail
- The date is scoped to documents the requesting user may view
""" """
Document.objects.create( Document.objects.create(
@@ -145,6 +146,32 @@ class TestApiObjects(DirectoriesMixin, APITestCase):
response.data["last_correspondence"], response.data["last_correspondence"],
) )
# A newer document owned by another user must not leak through the
# aggregate for a non-superuser who cannot view it
other = User.objects.create_user(username="other")
Document.objects.create(
mime_type="application/pdf",
correspondent=self.c1,
created=datetime.date(2023, 6, 1),
checksum="hidden",
owner=other,
)
user = User.objects.create_user(username="regular")
user.user_permissions.add(
Permission.objects.get(codename="view_correspondent"),
)
self.client.force_authenticate(user=user)
response = self.client.get("/api/correspondents/?last_correspondence=true")
self.assertEqual(response.status_code, status.HTTP_200_OK)
result = next(r for r in response.data["results"] if r["id"] == self.c1.id)
self.assertIn("2022-01-02", result["last_correspondence"])
response = self.client.get(f"/api/correspondents/{self.c1.id}/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn("2022-01-02", response.data["last_correspondence"])
def test_paginated_objects_include_all_only_for_legacy_version(self) -> None: def test_paginated_objects_include_all_only_for_legacy_version(self) -> None:
response_v10 = self.client.get("/api/correspondents/") response_v10 = self.client.get("/api/correspondents/")
self.assertEqual(response_v10.status_code, status.HTTP_200_OK) self.assertEqual(response_v10.status_code, status.HTTP_200_OK)
+16 -3
View File
@@ -1,6 +1,7 @@
import pickle
from documents.caching import StoredLRUCache from documents.caching import StoredLRUCache
from paperless.signed_pickle import HMAC_SIZE
from paperless.signed_pickle import signed_pickle_dumps
from paperless.signed_pickle import signed_pickle_loads
def test_lru_cache_entries() -> None: def test_lru_cache_entries() -> None:
@@ -42,4 +43,16 @@ def test_stored_lru_cache_key_ttl(mocker) -> None:
key, data, timeout = mock_backend.set.call_args[0] key, data, timeout = mock_backend.set.call_args[0]
assert key == "test_key" assert key == "test_key"
assert timeout == 321 assert timeout == 321
assert pickle.loads(data) == {"x": "X", "y": "Y"} assert signed_pickle_loads(data) == {"x": "X", "y": "Y"}
def test_stored_lru_cache_rejects_tampered_data(mocker) -> None:
serialized_data = bytearray(signed_pickle_dumps({"x": "X"}))
serialized_data[HMAC_SIZE] ^= 0xFF
mock_backend = mocker.Mock()
mock_backend.get.return_value = bytes(serialized_data)
cache = StoredLRUCache("test_key", backend=mock_backend)
cache.load()
assert cache.get("x") is None
+23
View File
@@ -19,6 +19,8 @@ from documents.models import MatchingModel
from documents.models import StoragePath from documents.models import StoragePath
from documents.models import Tag from documents.models import Tag
from documents.tests.utils import DirectoriesMixin from documents.tests.utils import DirectoriesMixin
from paperless.signed_pickle import HMAC_SIZE
from paperless.signed_pickle import signed_pickle_dumps
def dummy_preprocess(content: str, **kwargs): def dummy_preprocess(content: str, **kwargs):
@@ -265,6 +267,27 @@ class TestClassifier(DirectoriesMixin, TestCase):
self.assertEqual(mock_preprocess_content.call_count, 2) self.assertEqual(mock_preprocess_content.call_count, 2)
self.assertEqual(mock_transform.call_count, 2) self.assertEqual(mock_transform.call_count, 2)
def test_vectorize_recomputes_tampered_cache_entry(self) -> None:
cached = bytearray(signed_pickle_dumps(["cached vector"]))
cached[HMAC_SIZE] ^= 0xFF
self.classifier.data_vectorizer = mock.Mock()
self.classifier.data_vectorizer.transform.return_value = ["fresh vector"]
with (
mock.patch(
"documents.classifier.read_cache.get",
return_value=bytes(cached),
),
mock.patch("documents.classifier.read_cache.set") as cache_set,
mock.patch("documents.classifier.read_cache.touch") as cache_touch,
):
result = self.classifier._vectorize("content")
self.assertEqual(result, ["fresh vector"])
self.classifier.data_vectorizer.transform.assert_called_once()
cache_set.assert_called_once()
cache_touch.assert_not_called()
def test_no_retrain_if_no_change(self) -> None: def test_no_retrain_if_no_change(self) -> None:
""" """
GIVEN: GIVEN:
@@ -156,6 +156,40 @@ class TestDocument(TestCase):
) )
self.assertEqual(doc.get_public_filename(), "2020-12-25 test") self.assertEqual(doc.get_public_filename(), "2020-12-25 test")
def test_version_file_name_uses_root_document_metadata(self) -> None:
root_correspondent = Correspondent.objects.create(name="Root correspondent")
version_correspondent = Correspondent.objects.create(
name="Version correspondent",
)
root = Document.objects.create(
mime_type="application/pdf",
title="Root title",
created=date(2020, 12, 25),
correspondent=root_correspondent,
)
version = Document.objects.create(
mime_type="application/pdf",
title="Version title",
created=date(1990, 1, 1),
correspondent=version_correspondent,
root_document=root,
version_index=1,
)
self.assertEqual(
version.get_public_filename(),
"2020-12-25 Root correspondent Root title.pdf",
)
root.title = "Updated root title"
root.save(update_fields=("title",))
version.refresh_from_db()
self.assertEqual(
version.get_public_filename(),
"2020-12-25 Root correspondent Updated root title.pdf",
)
def test_suggestion_content_uses_latest_version_content_for_root_documents( def test_suggestion_content_uses_latest_version_content_for_root_documents(
self, self,
) -> None: ) -> None:
@@ -192,6 +192,50 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_302_FOUND) self.assertEqual(response.status_code, status.HTTP_302_FOUND)
self.assertIn("sharelink_notfound=1", response["Location"]) self.assertIn("sharelink_notfound=1", response["Location"])
def test_share_link_missing_file_redirects(self) -> None:
"""
GIVEN:
- A share link whose document file is missing from disk
WHEN:
- The public share link is requested anonymously
THEN:
- The user is redirected to login instead of a 500 error
"""
doc = DocumentFactory.create(filename="missing-original.pdf")
share_link = ShareLink.objects.create(
slug="missingfilelink",
document=doc,
file_version=ShareLink.FileVersion.ORIGINAL,
)
self.client.logout()
response = self.client.get(f"/share/{share_link.slug}/")
self.assertEqual(response.status_code, status.HTTP_302_FOUND)
self.assertIn("sharelink_notfound=1", response["Location"])
def test_download_ready_bundle_missing_file_returns_503(self) -> None:
"""
GIVEN:
- A READY bundle whose zip file is missing from disk
WHEN:
- The public share link is requested anonymously
THEN:
- A 503 is returned instead of a 500 error
"""
bundle = ShareLinkBundle.objects.create(
slug="missingbundlefile",
file_version=ShareLink.FileVersion.ARCHIVE,
status=ShareLinkBundle.Status.READY,
file_path="bundles/gone.zip",
)
bundle.documents.set([self.document])
self.client.logout()
response = self.client.get(f"/share/{bundle.slug}/")
self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE)
class ShareLinkBundleTaskTests(DirectoriesMixin, APITestCase): class ShareLinkBundleTaskTests(DirectoriesMixin, APITestCase):
def setUp(self) -> None: def setUp(self) -> None:
+30 -9
View File
@@ -180,6 +180,7 @@ from documents.permissions import has_system_status_permission
from documents.permissions import permitted_document_ids from documents.permissions import permitted_document_ids
from documents.permissions import permitted_object_ids from documents.permissions import permitted_object_ids
from documents.permissions import set_permissions_for_object from documents.permissions import set_permissions_for_object
from documents.permissions import user_is_unrestricted
from documents.plugins.date_parsing import get_date_parser from documents.plugins.date_parsing import get_date_parser
from documents.schema import generate_object_with_permissions_schema from documents.schema import generate_object_with_permissions_schema
from documents.search import SearchHit from documents.search import SearchHit
@@ -577,13 +578,19 @@ class CorrespondentViewSet(
def list(self, request, *args, **kwargs): def list(self, request, *args, **kwargs):
if request.query_params.get("last_correspondence", None): if request.query_params.get("last_correspondence", None):
self.queryset = self.queryset.annotate( self.queryset = self.queryset.annotate(
last_correspondence=Max("documents__created"), last_correspondence=Max(
"documents__created",
filter=self.get_document_count_filter(),
),
) )
return super().list(request, *args, **kwargs) return super().list(request, *args, **kwargs)
def retrieve(self, request, *args, **kwargs): def retrieve(self, request, *args, **kwargs):
self.queryset = self.queryset.annotate( self.queryset = self.queryset.annotate(
last_correspondence=Max("documents__created"), last_correspondence=Max(
"documents__created",
filter=self.get_document_count_filter(),
),
) )
return super().retrieve(request, *args, **kwargs) return super().retrieve(request, *args, **kwargs)
@@ -2323,10 +2330,12 @@ class ChatStreamingView(GenericAPIView[Any]):
return HttpResponseForbidden("Insufficient permissions") return HttpResponseForbidden("Insufficient permissions")
documents = Document.objects.filter(pk=document.pk) documents = Document.objects.filter(pk=document.pk)
unrestricted = False
else: else:
documents = Document.objects.filter( documents = Document.objects.filter(
id__in=permitted_document_ids(request.user), id__in=permitted_document_ids(request.user),
) )
unrestricted = user_is_unrestricted(request.user)
output_language = get_llm_output_language( output_language = get_llm_output_language(
ai_config=ai_config, ai_config=ai_config,
@@ -2337,6 +2346,7 @@ class ChatStreamingView(GenericAPIView[Any]):
stream_chat_with_documents( stream_chat_with_documents(
query_str=question, query_str=question,
documents=documents, documents=documents,
unrestricted=unrestricted,
output_language=output_language, output_language=output_language,
), ),
content_type="text/event-stream", content_type="text/event-stream",
@@ -4573,6 +4583,10 @@ class ShareLinkViewSet(
class ShareLinkBundleViewSet(PassUserMixin, ModelViewSet[ShareLinkBundle]): class ShareLinkBundleViewSet(PassUserMixin, ModelViewSet[ShareLinkBundle]):
model = ShareLinkBundle model = ShareLinkBundle
# Bundles are immutable once created; rebuild via the dedicated action
# rather than PUT/PATCH.
http_method_names = ["get", "post", "delete", "head", "options"]
queryset = ShareLinkBundle.objects.all() queryset = ShareLinkBundle.objects.all()
serializer_class = ShareLinkBundleSerializer serializer_class = ShareLinkBundleSerializer
@@ -4707,12 +4721,15 @@ class SharedLinkView(View):
and share_link.expiration < timezone.now() and share_link.expiration < timezone.now()
): ):
return HttpResponseRedirect("/accounts/login/?sharelink_expired=1") return HttpResponseRedirect("/accounts/login/?sharelink_expired=1")
return serve_file( try:
doc=share_link.document, return serve_file(
use_archive=share_link.file_version == ShareLink.FileVersion.ARCHIVE doc=share_link.document,
and share_link.document.has_archive_version, use_archive=share_link.file_version == ShareLink.FileVersion.ARCHIVE
disposition="inline", and share_link.document.has_archive_version,
) disposition="inline",
)
except FileNotFoundError:
return HttpResponseRedirect("/accounts/login/?sharelink_notfound=1")
bundle = ShareLinkBundle.objects.filter(slug=slug).first() bundle = ShareLinkBundle.objects.filter(slug=slug).first()
if bundle is None: if bundle is None:
@@ -4734,7 +4751,11 @@ class SharedLinkView(View):
file_path = bundle.absolute_file_path file_path = bundle.absolute_file_path
if bundle.status == ShareLinkBundle.Status.FAILED or file_path is None: if (
bundle.status == ShareLinkBundle.Status.FAILED
or file_path is None
or not file_path.exists()
):
return HttpResponse( return HttpResponse(
_( _(
"The share link bundle is unavailable.", "The share link bundle is unavailable.",
File diff suppressed because it is too large Load Diff
+3 -31
View File
@@ -1,12 +1,12 @@
import hmac
import os import os
import pickle
from hashlib import sha256
from celery import Celery from celery import Celery
from celery.signals import worker_process_init from celery.signals import worker_process_init
from kombu.serialization import register from kombu.serialization import register
from paperless.signed_pickle import signed_pickle_dumps
from paperless.signed_pickle import signed_pickle_loads
# Set the default Django settings module for the 'celery' program. # Set the default Django settings module for the 'celery' program.
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "paperless.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "paperless.settings")
@@ -18,34 +18,6 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "paperless.settings")
# on the worker side using Django's SECRET_KEY. # on the worker side using Django's SECRET_KEY.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
HMAC_SIZE = 32 # SHA-256 digest length
def _get_signing_key() -> bytes:
from django.conf import settings
return settings.SECRET_KEY.encode()
def signed_pickle_dumps(obj: object) -> bytes:
data = pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL)
signature = hmac.new(_get_signing_key(), data, sha256).digest()
return signature + data
def signed_pickle_loads(payload: bytes) -> object:
if len(payload) < HMAC_SIZE:
msg = "Signed-pickle payload too short"
raise ValueError(msg)
signature = payload[:HMAC_SIZE]
data = payload[HMAC_SIZE:]
expected = hmac.new(_get_signing_key(), data, sha256).digest()
if not hmac.compare_digest(signature, expected):
msg = "Signed-pickle HMAC verification failed — message may have been tampered with"
raise ValueError(msg)
return pickle.loads(data)
register( register(
"signed-pickle", "signed-pickle",
signed_pickle_dumps, signed_pickle_dumps,
+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__"
+4
View File
@@ -1208,6 +1208,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 #
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
import hmac
import pickle
from hashlib import sha256
from typing import Any
from django.conf import settings
HMAC_SIZE = sha256().digest_size
class SignedPickleError(ValueError):
"""Raised when a signed pickle payload cannot be authenticated."""
def _get_signing_key() -> bytes:
return settings.SECRET_KEY.encode()
def signed_pickle_dumps(obj: object) -> bytes:
data = pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL)
signature = hmac.new(_get_signing_key(), data, sha256).digest()
return signature + data
def signed_pickle_loads(payload: bytes) -> Any:
if len(payload) <= HMAC_SIZE:
msg = "Signed-pickle payload too short"
raise SignedPickleError(msg)
signature = payload[:HMAC_SIZE]
data = payload[HMAC_SIZE:]
expected = hmac.new(_get_signing_key(), data, sha256).digest()
if not hmac.compare_digest(signature, expected):
msg = "Signed-pickle HMAC verification failed; payload may have been tampered with"
raise SignedPickleError(msg)
return pickle.loads(data)
+1 -1
View File
@@ -6,9 +6,9 @@ from pathlib import Path
import pytest import pytest
from django.test import override_settings from django.test import override_settings
from paperless.celery import HMAC_SIZE
from paperless.celery import signed_pickle_dumps from paperless.celery import signed_pickle_dumps
from paperless.celery import signed_pickle_loads from paperless.celery import signed_pickle_loads
from paperless.signed_pickle import HMAC_SIZE
class TestSignedPickleSerializer: class TestSignedPickleSerializer:
+1 -1
View File
@@ -295,7 +295,7 @@ urlpatterns = [
], ],
), ),
), ),
re_path(r"share/(?P<slug>\w+)/?$", SharedLinkView.as_view()), re_path(r"^share/(?P<slug>\w+)/?$", SharedLinkView.as_view()),
re_path(r"^favicon.ico$", FaviconView.as_view(), name="favicon"), re_path(r"^favicon.ico$", FaviconView.as_view(), name="favicon"),
re_path(r"admin/", admin.site.urls), re_path(r"admin/", admin.site.urls),
re_path( re_path(
+14 -3
View File
@@ -95,12 +95,15 @@ def _format_chat_metadata_trailer(references: list[dict[str, int | str]]) -> str
def stream_chat_with_documents( def stream_chat_with_documents(
query_str: str, query_str: str,
documents: QuerySet[Document], documents: QuerySet[Document],
*,
unrestricted: bool = False,
output_language: str | None = None, output_language: str | None = None,
): ):
try: try:
yield from _stream_chat_with_documents( yield from _stream_chat_with_documents(
query_str, query_str,
documents, documents,
unrestricted=unrestricted,
output_language=output_language, output_language=output_language,
) )
except Exception as e: except Exception as e:
@@ -111,6 +114,8 @@ def stream_chat_with_documents(
def _stream_chat_with_documents( def _stream_chat_with_documents(
query_str: str, query_str: str,
documents: QuerySet[Document], documents: QuerySet[Document],
*,
unrestricted: bool = False,
output_language: str | None = None, output_language: str | None = None,
): ):
if not documents.exists(): if not documents.exists():
@@ -123,9 +128,15 @@ def _stream_chat_with_documents(
from llama_index.core.retrievers import VectorIndexRetriever from llama_index.core.retrievers import VectorIndexRetriever
config = AIConfig() config = AIConfig()
filters = _document_id_filters( if unrestricted:
str(pk) for pk in documents.values_list("pk", flat=True) # The caller can see every document, so an id filter would never narrow
) # the search, only risk exceeding the vector store's bound parameter
# limit (_MAX_IN_VALUES in vector_store.py) on large installs.
filters = None
else:
filters = _document_id_filters(
str(pk) for pk in documents.values_list("pk", flat=True)
)
# Hold the shared read lock for the whole operation: the query engine # Hold the shared read lock for the whole operation: the query engine
# retrieves from the vector store again during synthesis, so the connection # retrieves from the vector store again during synthesis, so the connection
+29
View File
@@ -154,6 +154,35 @@ class DocumentMetaTable:
} }
class PermittedIdsTable:
"""Per-connection scratch space for an oversized IN-filter id list.
A literal ``IN (?,?,...)`` list binds one SQL parameter per id, capped by
SQLite's own SQLITE_MAX_VARIABLE_NUMBER (see _MAX_IN_VALUES in
vector_store.py). Loading the ids into a TEMP TABLE and filtering via a
subquery instead has no such limit. TEMP tables live in a
connection-private namespace -- never visible to another connection,
even under this identical name -- so this is safe under the vector
store's one-connection-per-request model without any extra locking or
per-call naming scheme.
"""
TABLE_NAME = "permitted_document_ids"
@staticmethod
def load(conn: sqlite3.Connection, ids: Iterable[int]) -> None:
"""Replace this connection's scratch table with ``ids``."""
conn.execute(f"DROP TABLE IF EXISTS temp.{PermittedIdsTable.TABLE_NAME}")
conn.execute(
f"CREATE TEMP TABLE {PermittedIdsTable.TABLE_NAME} "
"(id INTEGER PRIMARY KEY)",
)
conn.executemany(
f"INSERT INTO {PermittedIdsTable.TABLE_NAME} (id) VALUES (?)",
((i,) for i in ids),
)
class IndexMetaTable: class IndexMetaTable:
"""Typed accessors over index_meta's key/value rows -- replaces """Typed accessors over index_meta's key/value rows -- replaces
PaperlessSqliteVecVectorStore._meta_get_on/_meta_set_on, which returned PaperlessSqliteVecVectorStore._meta_get_on/_meta_set_on, which returned
+66 -19
View File
@@ -1,4 +1,8 @@
from __future__ import annotations
import json import json
from typing import TYPE_CHECKING
from typing import Any
from unittest.mock import MagicMock from unittest.mock import MagicMock
from unittest.mock import patch from unittest.mock import patch
@@ -18,6 +22,11 @@ from paperless_ai.chat import _build_chat_prompt
from paperless_ai.chat import _build_refine_prompt from paperless_ai.chat import _build_refine_prompt
from paperless_ai.chat import stream_chat_with_documents from paperless_ai.chat import stream_chat_with_documents
if TYPE_CHECKING:
from pathlib import Path
import pytest_mock
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def patch_embed_model(): def patch_embed_model():
@@ -312,6 +321,30 @@ def test_stream_chat_unexpected_failure_returns_generic_error(caplog) -> None:
@pytest.mark.django_db @pytest.mark.django_db
class TestStreamChatRetrieval: class TestStreamChatRetrieval:
@pytest.fixture
def captured_filters(self, mocker: pytest_mock.MockerFixture) -> list[Any]:
"""Stub out the AI client and the retriever, capturing the ``filters``
kwarg of every VectorIndexRetriever construction.
VectorIndexRetriever is imported inside _stream_chat_with_documents,
so it is patched at the llama_index source for the lazy import to
pick it up.
"""
captured: list[Any] = []
retriever = mocker.MagicMock()
retriever.retrieve.return_value = []
def capture_retriever(*args, **kwargs) -> pytest_mock.MockType:
captured.append(kwargs.get("filters"))
return retriever
mocker.patch("paperless_ai.chat.AIClient")
mocker.patch(
"llama_index.core.retrievers.VectorIndexRetriever",
side_effect=capture_retriever,
)
return captured
def test_no_nodes_yields_no_content_message( def test_no_nodes_yields_no_content_message(
self, self,
temp_llm_index_dir, temp_llm_index_dir,
@@ -329,9 +362,9 @@ class TestStreamChatRetrieval:
def test_chat_filter_contains_only_requested_document_ids( def test_chat_filter_contains_only_requested_document_ids(
self, self,
temp_llm_index_dir, temp_llm_index_dir: Path,
mock_embed_model, mock_embed_model: pytest_mock.MockType,
mocker, captured_filters: list[Any],
) -> None: ) -> None:
"""The MetadataFilter passed to the retriever must be scoped to the """The MetadataFilter passed to the retriever must be scoped to the
requested documents only content from other indexed documents must requested documents only content from other indexed documents must
@@ -342,22 +375,6 @@ class TestStreamChatRetrieval:
indexing.llm_index_add_or_update_document(included) indexing.llm_index_add_or_update_document(included)
indexing.llm_index_add_or_update_document(excluded) indexing.llm_index_add_or_update_document(excluded)
# VectorIndexRetriever is imported inside _stream_chat_with_documents;
# patch it at the llama_index source so the lazy import picks it up.
captured_filters = []
mock_retriever = mocker.MagicMock()
mock_retriever.retrieve.return_value = []
def capture_retriever(*args, **kwargs):
captured_filters.append(kwargs.get("filters"))
return mock_retriever
mocker.patch("paperless_ai.chat.AIClient")
mocker.patch(
"llama_index.core.retrievers.VectorIndexRetriever",
side_effect=capture_retriever,
)
list( list(
chat.stream_chat_with_documents( chat.stream_chat_with_documents(
"question?", "question?",
@@ -372,6 +389,36 @@ class TestStreamChatRetrieval:
assert str(included.pk) in filter_values assert str(included.pk) in filter_values
assert str(excluded.pk) not in filter_values assert str(excluded.pk) not in filter_values
def test_unrestricted_chat_skips_document_id_filter(
self,
temp_llm_index_dir: Path,
mock_embed_model: pytest_mock.MockType,
captured_filters: list[Any],
) -> None:
"""
GIVEN:
- A document indexed in the vector store
WHEN:
- stream_chat_with_documents is called with unrestricted=True
THEN:
- The retriever receives no document id filter (filters=None), so
the whole index is searched instead of an IN-list that risks the
vector store's safety limit on large installs
"""
document = DocumentFactory.create(content="indexed document content")
indexing.llm_index_add_or_update_document(document)
list(
chat.stream_chat_with_documents(
"question?",
Document.objects.filter(pk=document.pk),
unrestricted=True,
),
)
assert captured_filters, "VectorIndexRetriever was never constructed"
assert captured_filters[0] is None
@pytest.mark.django_db @pytest.mark.django_db
def test_get_document_references_only_queries_referenced_documents( def test_get_document_references_only_queries_referenced_documents(
self, self,
+83
View File
@@ -9,6 +9,7 @@ from paperless_ai.tables import DocumentChunksTable
from paperless_ai.tables import DocumentMetaRow from paperless_ai.tables import DocumentMetaRow
from paperless_ai.tables import DocumentMetaTable from paperless_ai.tables import DocumentMetaTable
from paperless_ai.tables import IndexMetaTable from paperless_ai.tables import IndexMetaTable
from paperless_ai.tables import PermittedIdsTable
@pytest.fixture @pytest.fixture
@@ -338,3 +339,85 @@ class TestIndexMetaTable:
IndexMetaTable.increment_total_inserts(conn, 100) IndexMetaTable.increment_total_inserts(conn, 100)
IndexMetaTable.reset_total_inserts(conn, 7) IndexMetaTable.reset_total_inserts(conn, 7)
assert IndexMetaTable.get_total_inserts(conn) == 7 assert IndexMetaTable.get_total_inserts(conn) == 7
class TestPermittedIdsTable:
def _loaded_ids(self, conn: sqlite3.Connection) -> list[int]:
return [
row["id"]
for row in conn.execute(
f"SELECT id FROM {PermittedIdsTable.TABLE_NAME} ORDER BY id",
)
]
def test_load_then_read_back_all_ids(self, conn: sqlite3.Connection) -> None:
"""
GIVEN:
- A bare sqlite3 connection
WHEN:
- load() is called with a set of ids
THEN:
- Every id is present in the TEMP TABLE, and only those ids
"""
PermittedIdsTable.load(conn, [3, 1, 2])
assert self._loaded_ids(conn) == [1, 2, 3]
def test_load_replaces_previous_contents(self, conn: sqlite3.Connection) -> None:
"""
GIVEN:
- A connection whose PermittedIdsTable already holds one id set
WHEN:
- load() is called again with a different id set
THEN:
- Only the new ids are present -- a connection reused across
multiple queries in one request never leaks a stale filter
"""
PermittedIdsTable.load(conn, [1, 2, 3])
PermittedIdsTable.load(conn, [4, 5])
assert self._loaded_ids(conn) == [4, 5]
def test_load_is_connection_private(self) -> None:
"""
GIVEN:
- Two separate connections
WHEN:
- Each loads PermittedIdsTable with a different id set, under
the identical TABLE_NAME
THEN:
- Each connection sees only its own ids -- TEMP TABLE is
connection-private, so concurrent requests never collide or
cross-contaminate despite sharing the same table name (the
vector store opens one connection per request; see
PaperlessSqliteVecVectorStore)
"""
conn_a = sqlite3.connect(":memory:")
conn_a.row_factory = sqlite3.Row
conn_b = sqlite3.connect(":memory:")
conn_b.row_factory = sqlite3.Row
try:
PermittedIdsTable.load(conn_a, [1, 2, 3])
PermittedIdsTable.load(conn_b, [4, 5, 6])
assert self._loaded_ids(conn_a) == [1, 2, 3]
assert self._loaded_ids(conn_b) == [4, 5, 6]
finally:
conn_a.close()
conn_b.close()
def test_load_handles_more_ids_than_a_bound_parameter_list_could(
self,
conn: sqlite3.Connection,
) -> None:
"""
GIVEN:
- An id count over SQLite's own bound-parameter limit
(SQLITE_MAX_VARIABLE_NUMBER, 32766 by default) -- more than a
literal IN(?,?,...) list could ever bind in one statement
WHEN:
- load() is called with that many ids
THEN:
- Every id is loaded without error, since executemany() binds
one row at a time rather than one statement with N parameters
"""
ids = list(range(40_000))
PermittedIdsTable.load(conn, ids)
assert self._loaded_ids(conn) == ids
+74 -16
View File
@@ -17,6 +17,7 @@ from paperless_ai.migrations import Migration
from paperless_ai.migrations import m0001_v1_to_v2 from paperless_ai.migrations import m0001_v1_to_v2
from paperless_ai.tables import DocumentChunksTable from paperless_ai.tables import DocumentChunksTable
from paperless_ai.tables import DocumentMetaTable from paperless_ai.tables import DocumentMetaTable
from paperless_ai.tables import PermittedIdsTable
from paperless_ai.vector_store import _MAX_IN_VALUES from paperless_ai.vector_store import _MAX_IN_VALUES
from paperless_ai.vector_store import DB_FILENAME from paperless_ai.vector_store import DB_FILENAME
from paperless_ai.vector_store import DEFAULT_TABLE_NAME from paperless_ai.vector_store import DEFAULT_TABLE_NAME
@@ -267,8 +268,23 @@ class TestCrud:
class TestBuildWhere: class TestBuildWhere:
def test_ne_filter_translates_to_not_equal_clause(self) -> None: @pytest.fixture
where, params = _build_where(_ne_filter(1)) def conn(self) -> Generator[sqlite3.Connection, None, None]:
"""A bare connection, sufficient for _build_where(): it only ever
touches the connection via PermittedIdsTable, which needs no vec0
extension loaded.
"""
connection = sqlite3.connect(":memory:")
try:
yield connection
finally:
connection.close()
def test_ne_filter_translates_to_not_equal_clause(
self,
conn: sqlite3.Connection,
) -> None:
where, params = _build_where(conn, _ne_filter(1))
assert where == "(document_id != ?)" assert where == "(document_id != ?)"
assert params == [1] assert params == [1]
@@ -280,7 +296,10 @@ class TestBuildWhere:
"b1", "b1",
] ]
def test_fails_closed_when_no_filter_is_translatable(self) -> None: def test_fails_closed_when_no_filter_is_translatable(
self,
conn: sqlite3.Connection,
) -> None:
# A nested MetadataFilters is not a MetadataFilter, so it is skipped. # A nested MetadataFilters is not a MetadataFilter, so it is skipped.
# With no translatable clauses, the function must fail closed rather # With no translatable clauses, the function must fail closed rather
# than emit "()" (invalid SQL) and never widen document access. # than emit "()" (invalid SQL) and never widen document access.
@@ -293,35 +312,74 @@ class TestBuildWhere:
), ),
], ],
) )
where, params = _build_where(MetadataFilters(filters=[nested])) where, params = _build_where(conn, MetadataFilters(filters=[nested]))
assert where == "1 = 0" assert where == "1 = 0"
assert params == [] assert params == []
def test_fails_closed_when_in_filter_exceeds_max_values( def test_in_filter_over_max_values_uses_permitted_ids_table(
self, self,
caplog: pytest.LogCaptureFixture, conn: sqlite3.Connection,
) -> None: ) -> None:
""" """
GIVEN: GIVEN:
- An IN filter with more values than _MAX_IN_VALUES (SQLite's - An IN filter with more values than _MAX_IN_VALUES (SQLite's
own bound-parameter limit is 32766; this guard sits below own bound-parameter limit is 32766; this threshold sits below
that with headroom for the query's other bound parameters) that with headroom for the query's other bound parameters)
WHEN: WHEN:
- _build_where() translates it to SQL - _build_where() translates it to SQL
THEN: THEN:
- It fails closed ("1 = 0", no params) instead of building an - It builds a subquery against PermittedIdsTable's TEMP TABLE,
IN clause SQLite would reject, and logs a warning -- this loaded with every id, instead of a literal IN(...) list that
filter scopes document access, so refusing to build it must SQLite would reject past its own limit -- the filter still
never widen the scope to "everything" by accident scopes document access to exactly the requested ids, never
widening the scope to "everything"
""" """
oversized = _in_filter([str(i) for i in range(_MAX_IN_VALUES + 1)]) ids = list(range(_MAX_IN_VALUES + 1))
oversized = _in_filter([str(i) for i in ids])
with caplog.at_level("WARNING"): where, params = _build_where(conn, oversized)
where, params = _build_where(oversized)
assert where == "(1 = 0)" assert where == (
f"(document_id IN (SELECT id FROM {PermittedIdsTable.TABLE_NAME}))"
)
assert params == [] assert params == []
assert "document_id" in caplog.text loaded = [
row[0]
for row in conn.execute(
f"SELECT id FROM {PermittedIdsTable.TABLE_NAME} ORDER BY id",
)
]
assert loaded == ids
def test_query_and_get_nodes_scope_correctly_when_in_filter_exceeds_max_values(
self,
store: PaperlessSqliteVecVectorStore,
mocker: MockerFixture,
) -> None:
"""
GIVEN:
- _MAX_IN_VALUES lowered so a small IN filter exceeds it
WHEN:
- query() and get_nodes() are called with that filter
THEN:
- Both still correctly scope results to the permitted ids -- the
PermittedIdsTable temp-table path behaves identically to the
literal IN(...) path it replaces above the threshold
"""
mocker.patch("paperless_ai.vector_store._MAX_IN_VALUES", 1)
store.add(
[
make_node("a1", 1, seed=0.0),
make_node("b1", 2, seed=1.0),
make_node("c1", 3, seed=2.0),
],
)
result = _query(store, [0.0] * DIM, top_k=10, filters=_in_filter([2, 3]))
nodes = store.get_nodes(filters=_in_filter([2, 3]))
assert sorted(result.ids) == ["b1", "c1"]
assert sorted(n.node_id for n in nodes) == ["b1", "c1"]
def test_query_with_untranslatable_filter_returns_no_rows( def test_query_with_untranslatable_filter_returns_no_rows(
self, self,
+24 -23
View File
@@ -30,6 +30,7 @@ from paperless_ai.tables import DocumentChunksTable
from paperless_ai.tables import DocumentMetaRow from paperless_ai.tables import DocumentMetaRow
from paperless_ai.tables import DocumentMetaTable from paperless_ai.tables import DocumentMetaTable
from paperless_ai.tables import IndexMetaTable from paperless_ai.tables import IndexMetaTable
from paperless_ai.tables import PermittedIdsTable
logger = logging.getLogger("paperless_ai.vector_store") logger = logging.getLogger("paperless_ai.vector_store")
@@ -75,14 +76,12 @@ class _Row(NamedTuple):
embedding: bytes embedding: bytes
# _build_where(): the largest IN value list translated into bound SQL # _build_where(): the largest IN value list translated into a literal
# parameters. SQLite's own hard limit (SQLITE_MAX_VARIABLE_NUMBER) is 32766 # IN (?,?,...) clause. SQLite's own hard limit (SQLITE_MAX_VARIABLE_NUMBER)
# by default; this leaves headroom below that for the query's other bound # is 32766 by default; this leaves headroom below that for the query's other
# parameters (the embedding blob, k, and any NE clause) and for the limit # bound parameters (the embedding blob, k, and any NE clause) and for the
# itself to move. An IN filter this large should not happen in practice -- # limit itself to move. Above this threshold _build_where() switches to
# callers are expected to pass None (no filter) rather than every id when # PermittedIdsTable instead of failing closed -- see its docstring.
# the filter would not actually narrow anything -- so this is a guard
# against a future regression, not a normal code path.
_MAX_IN_VALUES = 32700 _MAX_IN_VALUES = 32700
@@ -106,13 +105,20 @@ def _vec0_params(rows: list[_Row]) -> list[tuple[str, int, str, bytes]]:
return [(r.chunk_id, r.document_id, r.node_content, r.embedding) for r in rows] return [(r.chunk_id, r.document_id, r.node_content, r.embedding) for r in rows]
def _build_where(filters: MetadataFilters | None) -> tuple[str, list[int]]: def _build_where(
conn: sqlite3.Connection,
filters: MetadataFilters | None,
) -> tuple[str, list[int]]:
"""Translate the EQ / IN / NE filters we use into a parameterized SQL """Translate the EQ / IN / NE filters we use into a parameterized SQL
clause on vec0 metadata columns. Returns ("", []) when there is nothing clause on vec0 metadata columns. Returns ("", []) when there is nothing
to filter. document_id is vec0's only filterable column and is INTEGER; to filter. document_id is vec0's only filterable column and is INTEGER;
every value is coerced via int() here so callers (which today still pass every value is coerced via int() here so callers (which today still pass
strings in places, e.g. indexing.py's MetadataFilter construction) don't strings in places, e.g. indexing.py's MetadataFilter construction) don't
have to be individually correct -- vec0 doesn't coerce types itself. have to be individually correct -- vec0 doesn't coerce types itself.
``conn`` is only used for an IN filter over _MAX_IN_VALUES: it loads the
ids into PermittedIdsTable's TEMP TABLE on that connection rather than
binding them as SQL parameters.
""" """
if filters is None or not filters.filters: if filters is None or not filters.filters:
return "", [] return "", []
@@ -131,19 +137,14 @@ def _build_where(filters: MetadataFilters | None) -> tuple[str, list[int]]:
clauses.append("1 = 0") clauses.append("1 = 0")
continue continue
if len(values) > _MAX_IN_VALUES: if len(values) > _MAX_IN_VALUES:
# Fail closed (see the empty-clauses case below) rather than # A literal IN(...) list this large would exceed SQLite's own
# let SQLite raise "too many SQL variables" past its own # bound-parameter limit. Load the ids into a TEMP TABLE on
# limit: this filter scopes document access, so an IN list # this connection instead and filter via subquery, which has
# too large to safely bind must match no rows, never widen # no such limit -- see PermittedIdsTable.
# the scope to "everything" by accident. PermittedIdsTable.load(conn, values)
logger.warning( clauses.append(
"Refusing to build an IN filter on %r with %d values " f"{f.key} IN (SELECT id FROM {PermittedIdsTable.TABLE_NAME})",
"(over the %d-value safety limit); returning no rows.",
f.key,
len(values),
_MAX_IN_VALUES,
) )
clauses.append("1 = 0")
continue continue
placeholders = ",".join("?" for _ in values) placeholders = ",".join("?" for _ in values)
clauses.append(f"{f.key} IN ({placeholders})") clauses.append(f"{f.key} IN ({placeholders})")
@@ -482,7 +483,7 @@ class PaperlessSqliteVecVectorStore(BasePydanticVectorStore):
) )
if not self.table_exists(): if not self.table_exists():
return [] return []
where, params = _build_where(filters) where, params = _build_where(self._conn, filters)
sql = "SELECT node_content, embedding FROM " + DEFAULT_TABLE_NAME sql = "SELECT node_content, embedding FROM " + DEFAULT_TABLE_NAME
if where: if where:
sql += " WHERE " + where sql += " WHERE " + where
@@ -498,7 +499,7 @@ class PaperlessSqliteVecVectorStore(BasePydanticVectorStore):
if query.query_embedding is None: # pragma: no cover if query.query_embedding is None: # pragma: no cover
return VectorStoreQueryResult(nodes=[], similarities=[], ids=[]) return VectorStoreQueryResult(nodes=[], similarities=[], ids=[])
top_k = query.similarity_top_k if query.similarity_top_k is not None else 10 top_k = query.similarity_top_k if query.similarity_top_k is not None else 10
where, params = _build_where(query.filters) where, params = _build_where(self._conn, query.filters)
sql = ( sql = (
"SELECT id, node_content, embedding, distance FROM " "SELECT id, node_content, embedding, distance FROM "
+ DEFAULT_TABLE_NAME + DEFAULT_TABLE_NAME
Generated
+1 -1
View File
@@ -3053,7 +3053,7 @@ requires-dist = [
{ name = "httpx-oauth", specifier = "~=0.17" }, { name = "httpx-oauth", specifier = "~=0.17" },
{ name = "ijson", specifier = ">=3.5.1" }, { name = "ijson", specifier = ">=3.5.1" },
{ name = "imap-tools", specifier = "~=1.14.0" }, { name = "imap-tools", specifier = "~=1.14.0" },
{ name = "jinja2", specifier = "~=3.1.5" }, { name = "jinja2", specifier = "~=3.1.6" },
{ name = "langdetect", specifier = "~=1.0.9" }, { name = "langdetect", specifier = "~=1.0.9" },
{ name = "llama-index-core", specifier = ">=0.14.23" }, { name = "llama-index-core", specifier = ">=0.14.23" },
{ name = "llama-index-embeddings-huggingface", specifier = ">=0.6.1" }, { name = "llama-index-embeddings-huggingface", specifier = ">=0.6.1" },