mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-03 08:27:15 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e0345300f |
@@ -24,7 +24,7 @@ services:
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
greenmail:
|
||||
image: docker.io/greenmail/standalone:2.1.11
|
||||
image: docker.io/greenmail/standalone:2.1.13
|
||||
hostname: greenmail
|
||||
container_name: greenmail
|
||||
environment:
|
||||
|
||||
@@ -2088,12 +2088,6 @@ password. All of these options come from their similarly-named [Django settings]
|
||||
|
||||
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}
|
||||
|
||||
#### [`PAPERLESS_AI_ENABLED=<bool>`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED}
|
||||
|
||||
+1
-1
@@ -247,7 +247,7 @@ per-file-ignores."src/documents/models.py" = [
|
||||
isort.force-single-line = true
|
||||
|
||||
[tool.codespell]
|
||||
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish,NIN,nin"
|
||||
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish"
|
||||
skip = """\
|
||||
src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/paperless/tests/samples\
|
||||
/mail/*,src/documents/tests/samples/*,*.po,*.json\
|
||||
|
||||
+205
-201
File diff suppressed because it is too large
Load Diff
+13
-13
@@ -15,14 +15,14 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^22.1.4",
|
||||
"@angular/common": "~22.1.3",
|
||||
"@angular/compiler": "~22.1.3",
|
||||
"@angular/core": "~22.1.3",
|
||||
"@angular/forms": "~22.1.3",
|
||||
"@angular/localize": "~22.1.3",
|
||||
"@angular/platform-browser": "~22.1.3",
|
||||
"@angular/router": "~22.1.3",
|
||||
"@angular/cdk": "^22.1.1",
|
||||
"@angular/common": "~22.1.1",
|
||||
"@angular/compiler": "~22.1.1",
|
||||
"@angular/core": "~22.1.1",
|
||||
"@angular/forms": "~22.1.1",
|
||||
"@angular/localize": "~22.1.1",
|
||||
"@angular/platform-browser": "~22.1.1",
|
||||
"@angular/router": "~22.1.1",
|
||||
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
||||
"@ng-select/ng-select": "~23.6.0",
|
||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||
@@ -45,16 +45,16 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-builders/jest": "^22.0.1",
|
||||
"@angular-devkit/core": "^22.1.6",
|
||||
"@angular-devkit/schematics": "^22.1.6",
|
||||
"@angular-devkit/core": "^22.1.3",
|
||||
"@angular-devkit/schematics": "^22.1.3",
|
||||
"@angular-eslint/builder": "22.1.0",
|
||||
"@angular-eslint/eslint-plugin": "22.1.0",
|
||||
"@angular-eslint/eslint-plugin-template": "22.1.0",
|
||||
"@angular-eslint/schematics": "22.1.0",
|
||||
"@angular-eslint/template-parser": "22.1.0",
|
||||
"@angular/build": "22.1.6",
|
||||
"@angular/cli": "22.1.6",
|
||||
"@angular/compiler-cli": "~22.1.3",
|
||||
"@angular/build": "22.1.3",
|
||||
"@angular/cli": "22.1.3",
|
||||
"@angular/compiler-cli": "~22.1.1",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^26.2.0",
|
||||
|
||||
Generated
+629
-437
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,6 @@
|
||||
i18n-title
|
||||
info="Tasks shows detailed information about document consumption and system tasks."
|
||||
i18n-info
|
||||
[loading]="loading() && pagedTasks().length === 0"
|
||||
>
|
||||
<div class="btn-toolbar col col-md-auto align-items-center gap-2">
|
||||
<button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedTasks.size === 0">
|
||||
@@ -22,6 +21,11 @@
|
||||
</div>
|
||||
</pngx-page-header>
|
||||
|
||||
@if (loading() && pagedTasks().length === 0) {
|
||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||
<div class="visually-hidden" i18n>Loading...</div>
|
||||
}
|
||||
|
||||
<div class="task-controls mb-3 gap-3 btn-toolbar align-items-center" role="toolbar">
|
||||
<div class="task-view-scope btn-group btn-group-sm" role="group">
|
||||
<input
|
||||
@@ -281,6 +285,6 @@
|
||||
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks: tasksForSection(section), section: section}"></ng-container>
|
||||
</div>
|
||||
}
|
||||
} @else if (!loading() || pagedTasks().length > 0) {
|
||||
} @else {
|
||||
<div class="alert alert-secondary fst-italic" i18n>No tasks match the current filters.</div>
|
||||
}
|
||||
|
||||
@@ -41,8 +41,6 @@ export class TrashComponent
|
||||
private modalService = inject(NgbModal)
|
||||
private settingsService = inject(SettingsService)
|
||||
private router = inject(Router)
|
||||
private readonly emptyTrashDelaySetting =
|
||||
this.settingsService.getSignal<number>(SETTINGS_KEYS.EMPTY_TRASH_DELAY)
|
||||
|
||||
readonly documentsInTrash = signal<Document[]>([])
|
||||
readonly selectedDocuments = signal<Set<number>>(new Set())
|
||||
@@ -202,7 +200,8 @@ export class TrashComponent
|
||||
}
|
||||
|
||||
getDaysRemaining(document: Document): number {
|
||||
const delay = this.emptyTrashDelaySetting()
|
||||
this.settingsService.trackChanges()
|
||||
const delay = this.settingsService.get(SETTINGS_KEYS.EMPTY_TRASH_DELAY)
|
||||
const diff = new Date().getTime() - new Date(document.deleted_at).getTime()
|
||||
const days = Math.ceil(diff / (1000 * 3600 * 24))
|
||||
return delay - days
|
||||
|
||||
@@ -34,7 +34,9 @@
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
--pngx-sidebar-width: var(--pngx-sidebar-expanded-width);
|
||||
&.expanded {
|
||||
--pngx-sidebar-width: var(--pngx-sidebar-expanded-width);
|
||||
}
|
||||
}
|
||||
}
|
||||
@media (max-width: 767.98px) {
|
||||
@@ -111,13 +113,6 @@ main {
|
||||
}
|
||||
}
|
||||
|
||||
// only animate when the user toggles slim mode
|
||||
.sidebar:not(.animating),
|
||||
.sidebar:not(.animating) ~ main,
|
||||
.sidebar:not(.animating) .sidebar-slim-toggler {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.sidebar.slim {
|
||||
max-width: 55px;
|
||||
|
||||
@@ -128,6 +123,8 @@ main {
|
||||
}
|
||||
|
||||
.sidebar.slim:not(.animating) {
|
||||
transition: none;
|
||||
|
||||
li.nav-item span,
|
||||
.sidebar-heading span {
|
||||
display: none;
|
||||
@@ -147,6 +144,10 @@ main {
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar.slim:not(.animating) ~ main.col-slim {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.sidebar.animating {
|
||||
li.nav-item span,
|
||||
.sidebar-heading span {
|
||||
@@ -195,26 +196,6 @@ main {
|
||||
--bs-popover-body-padding-y: .5rem;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.sidebar-sticky > ul,
|
||||
.sidebar-sticky > .nav-group {
|
||||
animation: sidebar-nav-in .3s cubic-bezier(.22, .61, .36, 1) backwards;
|
||||
}
|
||||
|
||||
@for $i from 2 through 5 {
|
||||
.sidebar-sticky > :nth-child(#{$i}) {
|
||||
animation-delay: #{($i - 1) * 0.04}s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sidebar-nav-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-sticky {
|
||||
position: relative;
|
||||
top: 0;
|
||||
|
||||
@@ -193,23 +193,6 @@ describe('AppFrameComponent', () => {
|
||||
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', () => {
|
||||
const updateCheckSpy = jest.spyOn(remoteVersionService, 'checkForUpdates')
|
||||
updateCheckSpy.mockImplementation(() => {
|
||||
|
||||
@@ -98,29 +98,6 @@ export class AppFrameComponent
|
||||
readonly isMenuCollapsed = signal(true)
|
||||
readonly slimSidebarAnimating = 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
|
||||
|
||||
constructor() {
|
||||
@@ -214,23 +191,33 @@ export class AppFrameComponent
|
||||
}
|
||||
|
||||
get versionString(): string {
|
||||
return `${environment.appTitle} v${this.versionSetting()}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
|
||||
this.settingsService.trackChanges()
|
||||
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
|
||||
}
|
||||
|
||||
get appTitle(): string {
|
||||
return this.appTitleSetting() || environment.appTitle
|
||||
this.settingsService.trackChanges()
|
||||
return (
|
||||
this.settingsService.get(SETTINGS_KEYS.APP_TITLE) || environment.appTitle
|
||||
)
|
||||
}
|
||||
|
||||
get customAppTitle(): string {
|
||||
return this.appTitleSetting()
|
||||
this.settingsService.trackChanges()
|
||||
return this.settingsService.get(SETTINGS_KEYS.APP_TITLE)
|
||||
}
|
||||
|
||||
get hasCustomBranding(): boolean {
|
||||
return !!(this.appTitleSetting()?.length || this.appLogoSetting()?.length)
|
||||
this.settingsService.trackChanges()
|
||||
return !!(
|
||||
this.settingsService.get(SETTINGS_KEYS.APP_TITLE)?.length ||
|
||||
this.settingsService.get(SETTINGS_KEYS.APP_LOGO)?.length
|
||||
)
|
||||
}
|
||||
|
||||
get customAppLogo(): string {
|
||||
const logo = this.appLogoSetting()
|
||||
this.settingsService.trackChanges()
|
||||
const logo = this.settingsService.get(SETTINGS_KEYS.APP_LOGO)
|
||||
return logo?.length
|
||||
? environment.apiBaseUrl.replace(/\/api\/$/, logo)
|
||||
: null
|
||||
@@ -275,7 +262,8 @@ export class AppFrameComponent
|
||||
}
|
||||
|
||||
get slimSidebarEnabled(): boolean {
|
||||
return this.slimSidebarSetting()
|
||||
this.settingsService.trackChanges()
|
||||
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
|
||||
}
|
||||
|
||||
set slimSidebarEnabled(enabled: boolean) {
|
||||
@@ -298,9 +286,10 @@ export class AppFrameComponent
|
||||
}
|
||||
|
||||
get attributesSectionsCollapsed(): boolean {
|
||||
return this.attributesSectionsCollapsedSetting()?.includes(
|
||||
CollapsibleSection.ATTRIBUTES
|
||||
)
|
||||
this.settingsService.trackChanges()
|
||||
return this.settingsService
|
||||
.get(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED)
|
||||
?.includes(CollapsibleSection.ATTRIBUTES)
|
||||
}
|
||||
|
||||
set attributesSectionsCollapsed(collapsed: boolean) {
|
||||
@@ -323,7 +312,8 @@ export class AppFrameComponent
|
||||
}
|
||||
|
||||
get aiEnabled(): boolean {
|
||||
return this.aiEnabledSetting()
|
||||
this.settingsService.trackChanges()
|
||||
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
|
||||
}
|
||||
|
||||
@HostListener('window:resize')
|
||||
@@ -490,8 +480,9 @@ export class AppFrameComponent
|
||||
}
|
||||
|
||||
get showSidebarCounts(): boolean {
|
||||
this.settingsService.trackChanges()
|
||||
return (
|
||||
this.sidebarViewsShowCountSetting() &&
|
||||
this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT) &&
|
||||
!this.settingsService.organizingSidebarSavedViews()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -81,10 +81,6 @@ export class GlobalSearchComponent implements OnInit {
|
||||
private hotkeyService = inject(HotKeyService)
|
||||
private settingsService = inject(SettingsService)
|
||||
private locationStrategy = inject(LocationStrategy)
|
||||
private readonly searchFullTypeSetting =
|
||||
this.settingsService.getSignal<GlobalSearchType>(
|
||||
SETTINGS_KEYS.SEARCH_FULL_TYPE
|
||||
)
|
||||
|
||||
public DataType = DataType
|
||||
readonly query = signal<string>(null)
|
||||
@@ -101,7 +97,11 @@ export class GlobalSearchComponent implements OnInit {
|
||||
@ViewChildren('secondaryButton') secondaryButtons: QueryList<ElementRef>
|
||||
|
||||
get useAdvancedForFullSearch(): boolean {
|
||||
return this.searchFullTypeSetting() === GlobalSearchType.ADVANCED
|
||||
this.settingsService.trackChanges()
|
||||
return (
|
||||
this.settingsService.get(SETTINGS_KEYS.SEARCH_FULL_TYPE) ===
|
||||
GlobalSearchType.ADVANCED
|
||||
)
|
||||
}
|
||||
|
||||
constructor() {
|
||||
|
||||
+13
-19
@@ -196,16 +196,6 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
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', () => {
|
||||
component.dialogMode.set(EditDialogMode.CREATE)
|
||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||
@@ -228,7 +218,7 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
})
|
||||
|
||||
it('should return source options, type options, type name, schedule date field options', () => {
|
||||
setActionSettings()
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||
component.ngOnInit()
|
||||
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS)
|
||||
expect(component.triggerTypeOptions).toEqual(WORKFLOW_TYPE_OPTIONS)
|
||||
@@ -252,7 +242,7 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
)
|
||||
|
||||
// Email, remote OCR and AI all disabled
|
||||
setActionSettings({ email: false, remoteOcr: false, ai: false })
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue(false)
|
||||
component.ngOnInit()
|
||||
expect(component.actionTypeOptions).toEqual(
|
||||
WORKFLOW_ACTION_OPTIONS.filter(
|
||||
@@ -265,7 +255,7 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
})
|
||||
|
||||
it('should offer remote OCR only for consumption workflows', () => {
|
||||
setActionSettings()
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||
|
||||
// A consumption trigger makes the action reachable
|
||||
component.object = {
|
||||
@@ -295,7 +285,7 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
})
|
||||
|
||||
it('should offer remote OCR on a trigger added to a new workflow', () => {
|
||||
setActionSettings()
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||
component.ngOnInit()
|
||||
|
||||
// Nothing for the action to apply to yet
|
||||
@@ -321,7 +311,7 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
})
|
||||
|
||||
it('should keep remote OCR listed when an action already uses it', () => {
|
||||
setActionSettings()
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||
|
||||
// Otherwise changing the trigger would silently blank the selection
|
||||
component.object = {
|
||||
@@ -339,7 +329,9 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
})
|
||||
|
||||
it('should not offer remote OCR when no engine is configured', () => {
|
||||
setActionSettings({ remoteOcr: false })
|
||||
jest
|
||||
.spyOn(settingsService, 'get')
|
||||
.mockImplementation((key) => key !== SETTINGS_KEYS.REMOTE_OCR_CONFIGURED)
|
||||
|
||||
component.object = {
|
||||
name: 'Workflow 1',
|
||||
@@ -356,7 +348,7 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
})
|
||||
|
||||
it('should offer apply AI suggestions unless every trigger is consumption', () => {
|
||||
setActionSettings()
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||
|
||||
// Consumption runs before the document has been parsed, so there would be
|
||||
// no content to make suggestions from
|
||||
@@ -390,7 +382,7 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
})
|
||||
|
||||
it('should keep apply AI suggestions listed when an action already uses it', () => {
|
||||
setActionSettings()
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||
|
||||
// Otherwise changing the trigger would silently blank the selection
|
||||
component.object = {
|
||||
@@ -408,7 +400,9 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
})
|
||||
|
||||
it('should not offer apply AI suggestions when AI is disabled', () => {
|
||||
setActionSettings({ ai: false })
|
||||
jest
|
||||
.spyOn(settingsService, 'get')
|
||||
.mockImplementation((key) => key !== SETTINGS_KEYS.AI_ENABLED)
|
||||
|
||||
component.object = {
|
||||
name: 'Workflow 1',
|
||||
|
||||
+4
-10
@@ -537,13 +537,6 @@ export class WorkflowEditDialogComponent
|
||||
readonly dateCustomFields = computed(() =>
|
||||
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
|
||||
|
||||
@@ -596,7 +589,7 @@ export class WorkflowEditDialogComponent
|
||||
private getAllowedActionTypes() {
|
||||
let allowed = WORKFLOW_ACTION_OPTIONS
|
||||
|
||||
if (!this.emailEnabledSetting()) {
|
||||
if (!this.settingsService.get(SETTINGS_KEYS.EMAIL_ENABLED)) {
|
||||
allowed = allowed.filter((a) => a.id !== WorkflowActionType.Email)
|
||||
}
|
||||
|
||||
@@ -604,7 +597,7 @@ export class WorkflowEditDialogComponent
|
||||
// offered for workflows that run at consumption.
|
||||
const formWorkflow: Workflow = this.objectForm?.value
|
||||
const remoteOcrUsable =
|
||||
this.remoteOcrConfiguredSetting() &&
|
||||
this.settingsService.get(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED) &&
|
||||
(formWorkflow?.triggers?.some(
|
||||
(trigger) => trigger.type === WorkflowTriggerType.Consumption
|
||||
) ||
|
||||
@@ -619,7 +612,7 @@ export class WorkflowEditDialogComponent
|
||||
// once every trigger is consumption, so it stays offered on a workflow
|
||||
// that has no triggers yet.
|
||||
const aiSuggestionsUsable =
|
||||
this.aiEnabledSetting() &&
|
||||
this.settingsService.get(SETTINGS_KEYS.AI_ENABLED) &&
|
||||
(!formWorkflow?.triggers?.length ||
|
||||
formWorkflow.triggers.some(
|
||||
(trigger) => trigger.type !== WorkflowTriggerType.Consumption
|
||||
@@ -1369,6 +1362,7 @@ export class WorkflowEditDialogComponent
|
||||
}
|
||||
|
||||
get actionTypeOptions() {
|
||||
this.settingsService.trackChanges()
|
||||
// Computed on read rather than cached
|
||||
return this.getAllowedActionTypes()
|
||||
}
|
||||
|
||||
+4
-10
@@ -839,9 +839,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
selectionModel.items = [memoRoot]
|
||||
selectionModel.documentCounts = [{ id: memoRoot.id, document_count: 9 }]
|
||||
|
||||
const getRootDocCount = (selectionModel as any).createRootDocCounter(
|
||||
selectionModel.items
|
||||
)
|
||||
const getRootDocCount = (selectionModel as any).createRootDocCounter()
|
||||
|
||||
expect(getRootDocCount(memoRoot.id)).toEqual(9)
|
||||
selectionModel.documentCounts = []
|
||||
@@ -857,9 +855,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
selectionModel.items = [rootWithoutSelection]
|
||||
selectionModel.documentCounts = []
|
||||
|
||||
const getRootDocCount = (selectionModel as any).createRootDocCounter(
|
||||
selectionModel.items
|
||||
)
|
||||
const getRootDocCount = (selectionModel as any).createRootDocCounter()
|
||||
|
||||
expect(getRootDocCount(rootWithoutSelection.id)).toEqual(4)
|
||||
})
|
||||
@@ -869,9 +865,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
selectionModel.items = [rootWithoutCounts]
|
||||
selectionModel.documentCounts = []
|
||||
|
||||
const getRootDocCount = (selectionModel as any).createRootDocCounter(
|
||||
selectionModel.items
|
||||
)
|
||||
const getRootDocCount = (selectionModel as any).createRootDocCounter()
|
||||
|
||||
expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0)
|
||||
})
|
||||
@@ -972,7 +966,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
component.selectionModel['temporarySelectionStates'].set(id, state)
|
||||
const changedSpy = jest.spyOn(component.selectionModel.changed, 'next')
|
||||
component.selectionModel.exclude(id)
|
||||
expect(component.selectionModel.temporaryLogicalOperator()).toBe(
|
||||
expect(component.selectionModel.temporaryLogicalOperator).toBe(
|
||||
LogicalOperator.And
|
||||
)
|
||||
expect(component.selectionModel['temporarySelectionStates'].get(id)).toBe(
|
||||
|
||||
+104
-125
@@ -64,56 +64,43 @@ export class FilterableDropdownSelectionModel {
|
||||
|
||||
manyToOne = false
|
||||
singleSelect = false
|
||||
private _logicalOperator: LogicalOperator = LogicalOperator.And
|
||||
temporaryLogicalOperator: LogicalOperator = this._logicalOperator
|
||||
private _intersection: Intersection = Intersection.Include
|
||||
temporaryIntersection: Intersection = this._intersection
|
||||
|
||||
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>()
|
||||
)
|
||||
|
||||
private _documentCounts: SelectionDataItem[] = []
|
||||
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[]) {
|
||||
this._documentCounts.set(counts)
|
||||
this._documentCounts = counts
|
||||
if (this.documentCountSortingEnabled) {
|
||||
this._items.set(this.sortItems(this.items))
|
||||
this.sortItems()
|
||||
}
|
||||
}
|
||||
|
||||
private _items: MatchingModel[] = []
|
||||
get items(): MatchingModel[] {
|
||||
return this._items()
|
||||
return this._items
|
||||
}
|
||||
|
||||
set items(items: MatchingModel[]) {
|
||||
if (items) {
|
||||
this._items.set(this.withNullItem(this.sortItems(Array.from(items))))
|
||||
this._items = Array.from(items)
|
||||
this.sortItems()
|
||||
this.setNullItem()
|
||||
}
|
||||
}
|
||||
|
||||
private withNullItem(items: MatchingModel[]): MatchingModel[] {
|
||||
private setNullItem() {
|
||||
if (this.manyToOne && this.logicalOperator === LogicalOperator.Or) {
|
||||
return items[0]?.id === null ? items.slice(1) : items
|
||||
if (this._items[0]?.id === null) {
|
||||
this._items.shift()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const nullItem = {
|
||||
const item = {
|
||||
name: $localize`:Filter drop down element to filter for documents with no correspondent/type/tag assigned:Not assigned`,
|
||||
id:
|
||||
this.manyToOne || this.intersection === Intersection.Include
|
||||
@@ -121,17 +108,22 @@ export class FilterableDropdownSelectionModel {
|
||||
: NEGATIVE_NULL_FILTER_VALUE,
|
||||
}
|
||||
|
||||
return items[0]?.id === null || items[0]?.id === NEGATIVE_NULL_FILTER_VALUE
|
||||
? [nullItem, ...items.slice(1)]
|
||||
: [nullItem, ...items]
|
||||
if (
|
||||
this._items[0]?.id === null ||
|
||||
this._items[0]?.id === NEGATIVE_NULL_FILTER_VALUE
|
||||
) {
|
||||
this._items[0] = item
|
||||
} else if (this._items) {
|
||||
this._items.unshift(item)
|
||||
}
|
||||
}
|
||||
|
||||
constructor(manyToOne: boolean = false) {
|
||||
this.manyToOne = manyToOne
|
||||
}
|
||||
|
||||
private sortItems(items: MatchingModel[]): MatchingModel[] {
|
||||
const sorted = [...items].sort((a, b) => {
|
||||
private sortItems() {
|
||||
this._items.sort((a, b) => {
|
||||
if (
|
||||
(a.id == null && b.id != null) ||
|
||||
(a.id == NEGATIVE_NULL_FILTER_VALUE &&
|
||||
@@ -162,13 +154,13 @@ export class FilterableDropdownSelectionModel {
|
||||
) {
|
||||
return -1
|
||||
} else if (
|
||||
this._documentCounts().length &&
|
||||
this._documentCounts.length &&
|
||||
this.getDocumentCount(b.id) === 0 &&
|
||||
this.getDocumentCount(a.id) > this.getDocumentCount(b.id)
|
||||
) {
|
||||
return -1
|
||||
} else if (
|
||||
this._documentCounts().length &&
|
||||
this._documentCounts.length &&
|
||||
this.getDocumentCount(a.id) === 0 &&
|
||||
this.getDocumentCount(a.id) < this.getDocumentCount(b.id)
|
||||
) {
|
||||
@@ -178,11 +170,15 @@ export class FilterableDropdownSelectionModel {
|
||||
}
|
||||
})
|
||||
|
||||
return this._documentCounts().length
|
||||
? this.promoteBranchesWithDocumentCounts(sorted)
|
||||
: sorted
|
||||
if (this._documentCounts.length) {
|
||||
this.promoteBranchesWithDocumentCounts()
|
||||
}
|
||||
}
|
||||
|
||||
private selectionStates = new Map<number, ToggleableItemState>()
|
||||
|
||||
private temporarySelectionStates = new Map<number, ToggleableItemState>()
|
||||
|
||||
getSelectedItems() {
|
||||
return this.items.filter(
|
||||
(i) =>
|
||||
@@ -198,33 +194,30 @@ export class FilterableDropdownSelectionModel {
|
||||
}
|
||||
|
||||
set(id: number, state: ToggleableItemState, fireEvent = true) {
|
||||
const states = new Map(this.temporarySelectionStates)
|
||||
if (state == ToggleableItemState.NotSelected) {
|
||||
states.delete(id)
|
||||
this.temporarySelectionStates.delete(id)
|
||||
} else {
|
||||
states.set(id, state)
|
||||
this.temporarySelectionStates.set(id, state)
|
||||
}
|
||||
this._temporarySelectionStates.set(states)
|
||||
if (fireEvent) {
|
||||
this.changed.next(this)
|
||||
}
|
||||
}
|
||||
|
||||
toggle(id: number, fireEvent = true) {
|
||||
const states = new Map(this.temporarySelectionStates)
|
||||
let state = states.get(id)
|
||||
let state = this.temporarySelectionStates.get(id)
|
||||
if (
|
||||
state == undefined ||
|
||||
(state != ToggleableItemState.Selected &&
|
||||
state != ToggleableItemState.Excluded)
|
||||
) {
|
||||
if (this.manyToOne || this.singleSelect) {
|
||||
states.set(id, ToggleableItemState.Selected)
|
||||
this.temporarySelectionStates.set(id, ToggleableItemState.Selected)
|
||||
|
||||
if (this.singleSelect) {
|
||||
for (let key of states.keys()) {
|
||||
for (let key of this.temporarySelectionStates.keys()) {
|
||||
if (key != id) {
|
||||
states.delete(key)
|
||||
this.temporarySelectionStates.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,26 +233,25 @@ export class FilterableDropdownSelectionModel {
|
||||
) {
|
||||
newState = ToggleableItemState.NotSelected
|
||||
}
|
||||
states.set(id, newState)
|
||||
this.temporarySelectionStates.set(id, newState)
|
||||
}
|
||||
} else if (
|
||||
state == ToggleableItemState.Selected ||
|
||||
state == ToggleableItemState.Excluded
|
||||
) {
|
||||
states.delete(id)
|
||||
this.clearDescendantSelections(states, id)
|
||||
this.temporarySelectionStates.delete(id)
|
||||
this.clearDescendantSelections(id)
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
for (let key of states.keys()) {
|
||||
for (let key of this.temporarySelectionStates.keys()) {
|
||||
if (key) {
|
||||
states.delete(key)
|
||||
this.temporarySelectionStates.delete(key)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
states.delete(null)
|
||||
this.temporarySelectionStates.delete(null)
|
||||
}
|
||||
this._temporarySelectionStates.set(states)
|
||||
|
||||
if (fireEvent) {
|
||||
this.changed.next(this)
|
||||
@@ -267,21 +259,20 @@ export class FilterableDropdownSelectionModel {
|
||||
}
|
||||
|
||||
exclude(id: number, fireEvent: boolean = true) {
|
||||
const states = new Map(this.temporarySelectionStates)
|
||||
let state = states.get(id)
|
||||
let state = this.temporarySelectionStates.get(id)
|
||||
if (id && (state == null || state != ToggleableItemState.Excluded)) {
|
||||
const operator = this.manyToOne ? LogicalOperator.And : LogicalOperator.Or
|
||||
this.temporaryLogicalOperator.set(operator)
|
||||
this._logicalOperator.set(operator)
|
||||
this.temporaryLogicalOperator = this._logicalOperator = this.manyToOne
|
||||
? LogicalOperator.And
|
||||
: LogicalOperator.Or
|
||||
|
||||
if (this.manyToOne || this.singleSelect) {
|
||||
states.set(id, ToggleableItemState.Excluded)
|
||||
this.clearDescendantSelections(states, id)
|
||||
this.temporarySelectionStates.set(id, ToggleableItemState.Excluded)
|
||||
this.clearDescendantSelections(id)
|
||||
|
||||
if (this.singleSelect) {
|
||||
for (let key of states.keys()) {
|
||||
for (let key of this.temporarySelectionStates.keys()) {
|
||||
if (key != id) {
|
||||
states.delete(key)
|
||||
this.temporarySelectionStates.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -296,18 +287,17 @@ export class FilterableDropdownSelectionModel {
|
||||
) {
|
||||
newState = ToggleableItemState.NotSelected
|
||||
}
|
||||
states.set(id, newState)
|
||||
this.temporarySelectionStates.set(id, newState)
|
||||
if (newState == ToggleableItemState.Excluded) {
|
||||
this.clearDescendantSelections(states, id)
|
||||
this.clearDescendantSelections(id)
|
||||
}
|
||||
}
|
||||
} else if (!id || state == ToggleableItemState.Excluded) {
|
||||
states.delete(id)
|
||||
this.temporarySelectionStates.delete(id)
|
||||
if (id) {
|
||||
this.clearDescendantSelections(states, id)
|
||||
this.clearDescendantSelections(id)
|
||||
}
|
||||
}
|
||||
this._temporarySelectionStates.set(states)
|
||||
|
||||
if (fireEvent) {
|
||||
this.changed.next(this)
|
||||
@@ -318,12 +308,9 @@ export class FilterableDropdownSelectionModel {
|
||||
return this.selectionStates.get(id) || ToggleableItemState.NotSelected
|
||||
}
|
||||
|
||||
private clearDescendantSelections(
|
||||
states: Map<number, ToggleableItemState>,
|
||||
id: number
|
||||
) {
|
||||
private clearDescendantSelections(id: number) {
|
||||
for (const descendantID of this.getDescendantIDs(id)) {
|
||||
states.delete(descendantID)
|
||||
this.temporarySelectionStates.delete(descendantID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,7 +320,7 @@ export class FilterableDropdownSelectionModel {
|
||||
|
||||
while (queue.length) {
|
||||
const parentID = queue.shift()
|
||||
for (const item of this.items) {
|
||||
for (const item of this._items) {
|
||||
if (
|
||||
typeof item?.id === 'number' &&
|
||||
typeof (item as any)['parent'] === 'number' &&
|
||||
@@ -349,12 +336,12 @@ export class FilterableDropdownSelectionModel {
|
||||
}
|
||||
|
||||
get logicalOperator(): LogicalOperator {
|
||||
return this.temporaryLogicalOperator()
|
||||
return this.temporaryLogicalOperator
|
||||
}
|
||||
|
||||
set logicalOperator(operator: LogicalOperator) {
|
||||
this.temporaryLogicalOperator.set(operator)
|
||||
this._items.set(this.withNullItem(this.items))
|
||||
this.temporaryLogicalOperator = operator
|
||||
this.setNullItem()
|
||||
}
|
||||
|
||||
toggleOperator() {
|
||||
@@ -362,12 +349,12 @@ export class FilterableDropdownSelectionModel {
|
||||
}
|
||||
|
||||
get intersection(): Intersection {
|
||||
return this.temporaryIntersection()
|
||||
return this.temporaryIntersection
|
||||
}
|
||||
|
||||
set intersection(intersection: Intersection) {
|
||||
this.temporaryIntersection.set(intersection)
|
||||
this._items.set(this.withNullItem(this.items))
|
||||
this.temporaryIntersection = intersection
|
||||
this.setNullItem()
|
||||
}
|
||||
|
||||
toggleIntersection() {
|
||||
@@ -377,20 +364,18 @@ export class FilterableDropdownSelectionModel {
|
||||
? ToggleableItemState.Selected
|
||||
: ToggleableItemState.Excluded
|
||||
|
||||
const states = new Map(this.temporarySelectionStates)
|
||||
states.forEach((state, key) => {
|
||||
this.temporarySelectionStates.forEach((state, key) => {
|
||||
if (key === null && this.intersection === Intersection.Exclude) {
|
||||
states.set(NEGATIVE_NULL_FILTER_VALUE, newState)
|
||||
this.temporarySelectionStates.set(NEGATIVE_NULL_FILTER_VALUE, newState)
|
||||
} else if (
|
||||
key === NEGATIVE_NULL_FILTER_VALUE &&
|
||||
this.intersection === Intersection.Include
|
||||
) {
|
||||
states.set(null, newState)
|
||||
this.temporarySelectionStates.set(null, newState)
|
||||
} else {
|
||||
states.set(key, newState)
|
||||
this.temporarySelectionStates.set(key, newState)
|
||||
}
|
||||
})
|
||||
this._temporarySelectionStates.set(states)
|
||||
|
||||
this.changed.next(this)
|
||||
}
|
||||
@@ -410,12 +395,10 @@ export class FilterableDropdownSelectionModel {
|
||||
}
|
||||
|
||||
clear(fireEvent = true) {
|
||||
this._temporarySelectionStates.set(new Map())
|
||||
this.temporaryLogicalOperator.set(LogicalOperator.And)
|
||||
this._logicalOperator.set(LogicalOperator.And)
|
||||
this.temporaryIntersection.set(Intersection.Include)
|
||||
this._intersection.set(Intersection.Include)
|
||||
this._items.set(this.withNullItem(this.items))
|
||||
this.temporarySelectionStates.clear()
|
||||
this.temporaryLogicalOperator = this._logicalOperator = LogicalOperator.And
|
||||
this.temporaryIntersection = this._intersection = Intersection.Include
|
||||
this.setNullItem()
|
||||
if (fireEvent) {
|
||||
this.changed.next(this)
|
||||
}
|
||||
@@ -436,9 +419,9 @@ export class FilterableDropdownSelectionModel {
|
||||
)
|
||||
) {
|
||||
return true
|
||||
} else if (this.temporaryLogicalOperator() !== this._logicalOperator()) {
|
||||
} else if (this.temporaryLogicalOperator !== this._logicalOperator) {
|
||||
return true
|
||||
} else if (this.temporaryIntersection() !== this._intersection()) {
|
||||
} else if (this.temporaryIntersection !== this._intersection) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
@@ -455,29 +438,23 @@ export class FilterableDropdownSelectionModel {
|
||||
}
|
||||
|
||||
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(
|
||||
items: MatchingModel[]
|
||||
): MatchingModel[] {
|
||||
const parentById = this.buildParentById(items)
|
||||
private promoteBranchesWithDocumentCounts() {
|
||||
const parentById = this.buildParentById()
|
||||
const findRootId = this.createRootFinder(parentById)
|
||||
const getRootDocCount = this.createRootDocCounter(items)
|
||||
const summaries = this.buildBranchSummaries(
|
||||
items,
|
||||
findRootId,
|
||||
getRootDocCount
|
||||
)
|
||||
const getRootDocCount = this.createRootDocCounter()
|
||||
const summaries = this.buildBranchSummaries(findRootId, getRootDocCount)
|
||||
const orderedBranches = this.orderBranchesByPriority(summaries)
|
||||
|
||||
return orderedBranches.flatMap((summary) => summary.items)
|
||||
this._items = orderedBranches.flatMap((summary) => summary.items)
|
||||
}
|
||||
|
||||
private buildParentById(items: MatchingModel[]): Map<number, number | null> {
|
||||
private buildParentById(): Map<number, number | null> {
|
||||
const parentById = new Map<number, number | null>()
|
||||
|
||||
for (const item of items) {
|
||||
for (const item of this._items) {
|
||||
if (typeof item?.id === 'number') {
|
||||
const parentValue = (item as any)['parent']
|
||||
parentById.set(
|
||||
@@ -515,9 +492,7 @@ export class FilterableDropdownSelectionModel {
|
||||
return findRootId
|
||||
}
|
||||
|
||||
private createRootDocCounter(
|
||||
items: MatchingModel[]
|
||||
): (rootId: number) => number {
|
||||
private createRootDocCounter(): (rootId: number) => number {
|
||||
const docCountMemo = new Map<number, number>()
|
||||
|
||||
return (rootId: number): number => {
|
||||
@@ -532,7 +507,7 @@ export class FilterableDropdownSelectionModel {
|
||||
return explicit
|
||||
}
|
||||
|
||||
const rootItem = items.find((i) => i.id === rootId)
|
||||
const rootItem = this._items.find((i) => i.id === rootId)
|
||||
const fallback =
|
||||
typeof (rootItem as any)?.['document_count'] === 'number'
|
||||
? (rootItem as any)['document_count']
|
||||
@@ -544,13 +519,12 @@ export class FilterableDropdownSelectionModel {
|
||||
}
|
||||
|
||||
private buildBranchSummaries(
|
||||
items: MatchingModel[],
|
||||
findRootId: (id: number) => number,
|
||||
getRootDocCount: (rootId: number) => number
|
||||
): Map<string, BranchSummary> {
|
||||
const summaries = new Map<string, BranchSummary>()
|
||||
|
||||
for (const [index, item] of items.entries()) {
|
||||
for (const [index, item] of this._items.entries()) {
|
||||
const { key, special, rootId } = this.describeBranchItem(
|
||||
item,
|
||||
index,
|
||||
@@ -642,23 +616,28 @@ export class FilterableDropdownSelectionModel {
|
||||
}
|
||||
|
||||
init(map: Map<number, ToggleableItemState>) {
|
||||
this._temporarySelectionStates.set(new Map(map))
|
||||
this.temporarySelectionStates = map
|
||||
this.apply()
|
||||
}
|
||||
|
||||
apply() {
|
||||
this._selectionStates.set(new Map(this.temporarySelectionStates))
|
||||
this._logicalOperator.set(this.temporaryLogicalOperator())
|
||||
this._intersection.set(this.temporaryIntersection())
|
||||
this._items.set(this.sortItems(this.items))
|
||||
this.selectionStates.clear()
|
||||
this.temporarySelectionStates.forEach((value, key) => {
|
||||
this.selectionStates.set(key, value)
|
||||
})
|
||||
this._logicalOperator = this.temporaryLogicalOperator
|
||||
this._intersection = this.temporaryIntersection
|
||||
this.sortItems()
|
||||
}
|
||||
|
||||
reset(complete: boolean = false) {
|
||||
this.temporarySelectionStates.clear()
|
||||
if (complete) {
|
||||
this._selectionStates.set(new Map())
|
||||
this._temporarySelectionStates.set(new Map())
|
||||
this.selectionStates.clear()
|
||||
} else {
|
||||
this._temporarySelectionStates.set(new Map(this.selectionStates))
|
||||
this.selectionStates.forEach((value, key) => {
|
||||
this.temporarySelectionStates.set(key, value)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-6
@@ -7,8 +7,6 @@
|
||||
padding-left: calc(calc(var(--depth) - 2) * 1rem);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
.indicator {
|
||||
display: inline-block;
|
||||
@@ -20,7 +18,3 @@
|
||||
margin-left: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
+9
-10
@@ -7,7 +7,7 @@
|
||||
<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">
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
@@ -17,7 +17,7 @@
|
||||
</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">
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
@@ -27,7 +27,7 @@
|
||||
</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">
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
@@ -37,7 +37,7 @@
|
||||
</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">
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
@@ -47,7 +47,7 @@
|
||||
</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">
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
@@ -57,7 +57,7 @@
|
||||
</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">
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
@@ -65,8 +65,7 @@
|
||||
<ng-select
|
||||
name="user"
|
||||
class="user-select small"
|
||||
[ngModel]="selectionModel.includeUsers()"
|
||||
(ngModelChange)="selectionModel.includeUsers.set($event)"
|
||||
[(ngModel)]="selectionModel.includeUsers"
|
||||
[disabled]="disabled"
|
||||
[clearable]="false"
|
||||
[items]="users()"
|
||||
@@ -79,10 +78,10 @@
|
||||
</ng-select>
|
||||
</div>
|
||||
</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="form-check form-switch w-100">
|
||||
<input type="checkbox" class="form-check-input" id="hideUnowned" [ngModel]="selectionModel.hideUnowned()" (ngModelChange)="selectionModel.hideUnowned.set($event)" (change)="onChange()" [disabled]="disabled">
|
||||
<input type="checkbox" class="form-check-input" id="hideUnowned" [(ngModel)]="this.selectionModel.hideUnowned" (change)="onChange()" [disabled]="disabled">
|
||||
<label class="form-check-label w-100" for="hideUnowned"><small i18n>Hide unowned</small></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+30
-39
@@ -90,56 +90,56 @@ describe('PermissionsFilterDropdownComponent', () => {
|
||||
component.setFilter(OwnerFilterType.OTHERS)
|
||||
expect(component.isActive).toBeTruthy()
|
||||
component.setFilter(OwnerFilterType.NONE)
|
||||
component.selectionModel.hideUnowned.set(true)
|
||||
component.selectionModel.hideUnowned = true
|
||||
expect(component.isActive).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should describe concrete user filters honestly', () => {
|
||||
component.selectionModel.ownerFilter.set(OwnerFilterType.SELF)
|
||||
component.selectionModel.userID.set(1)
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.SELF
|
||||
component.selectionModel.userID = 1
|
||||
expect(component.ownerFilterLabel).toEqual('Owned by user1')
|
||||
|
||||
component.selectionModel.ownerFilter.set(OwnerFilterType.NOT_SELF)
|
||||
component.selectionModel.excludeUsers.set([1])
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
|
||||
component.selectionModel.excludeUsers = [1]
|
||||
expect(component.ownerExclusionFilterLabel).toEqual('Not owned by user1')
|
||||
|
||||
component.selectionModel.ownerFilter.set(OwnerFilterType.SHARED_BY_ME)
|
||||
component.selectionModel.userID.set(1)
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME
|
||||
component.selectionModel.userID = 1
|
||||
expect(component.sharedByFilterLabel).toEqual('Shared by user1')
|
||||
})
|
||||
|
||||
it('should describe concrete filters when usernames are unavailable', () => {
|
||||
component.selectionModel.ownerFilter.set(OwnerFilterType.SELF)
|
||||
component.selectionModel.userID.set(99)
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.SELF
|
||||
component.selectionModel.userID = 99
|
||||
expect(component.ownerFilterLabel).toEqual('Owned by another user')
|
||||
|
||||
component.selectionModel.ownerFilter.set(OwnerFilterType.NOT_SELF)
|
||||
component.selectionModel.excludeUsers.set([99])
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
|
||||
component.selectionModel.excludeUsers = [99]
|
||||
expect(component.ownerExclusionFilterLabel).toEqual(
|
||||
'Not owned by another user'
|
||||
)
|
||||
|
||||
component.selectionModel.excludeUsers.set([98, 99])
|
||||
component.selectionModel.excludeUsers = [98, 99]
|
||||
expect(component.ownerExclusionFilterLabel).toEqual(
|
||||
'Not owned by selected users'
|
||||
)
|
||||
|
||||
component.selectionModel.ownerFilter.set(OwnerFilterType.SHARED_BY_ME)
|
||||
component.selectionModel.userID.set(99)
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME
|
||||
component.selectionModel.userID = 99
|
||||
expect(component.sharedByFilterLabel).toEqual('Shared by another user')
|
||||
})
|
||||
|
||||
it('should retain relative labels for filters bound to the current user', () => {
|
||||
component.selectionModel.userID.set(currentUserID)
|
||||
component.selectionModel.userID = currentUserID
|
||||
expect(component.ownerFilterLabel).toEqual('My documents')
|
||||
expect(component.sharedByFilterLabel).toEqual('Shared by me')
|
||||
|
||||
component.selectionModel.excludeUsers.set([currentUserID])
|
||||
component.selectionModel.excludeUsers = [currentUserID]
|
||||
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
|
||||
})
|
||||
|
||||
it('should retain relative labels for inactive filter choices', () => {
|
||||
component.selectionModel.ownerFilter.set(OwnerFilterType.NONE)
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.NONE
|
||||
|
||||
expect(component.ownerFilterLabel).toEqual('My documents')
|
||||
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
|
||||
@@ -148,41 +148,32 @@ describe('PermissionsFilterDropdownComponent', () => {
|
||||
|
||||
it('should support reset', () => {
|
||||
component.setFilter(OwnerFilterType.OTHERS)
|
||||
expect(component.selectionModel.ownerFilter()).not.toEqual(
|
||||
expect(component.selectionModel.ownerFilter).not.toEqual(
|
||||
OwnerFilterType.NONE
|
||||
)
|
||||
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', () => {
|
||||
component.selectionModel.ownerFilter.set(OwnerFilterType.NONE)
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.NONE
|
||||
|
||||
// this would normally be done by select component
|
||||
component.selectionModel.includeUsers.set([12])
|
||||
component.selectionModel.includeUsers = [12]
|
||||
component.onUserSelect()
|
||||
expect(component.selectionModel.ownerFilter()).toEqual(
|
||||
OwnerFilterType.OTHERS
|
||||
)
|
||||
expect(component.selectionModel.ownerFilter).toEqual(OwnerFilterType.OTHERS)
|
||||
|
||||
// this would normally be done by select component
|
||||
component.selectionModel.includeUsers.set(null)
|
||||
component.selectionModel.includeUsers = null
|
||||
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', () => {
|
||||
const emitted = () => ({
|
||||
excludeUsers: ownerFilterSetResult.excludeUsers(),
|
||||
hideUnowned: ownerFilterSetResult.hideUnowned(),
|
||||
includeUsers: ownerFilterSetResult.includeUsers(),
|
||||
ownerFilter: ownerFilterSetResult.ownerFilter(),
|
||||
userID: ownerFilterSetResult.userID(),
|
||||
})
|
||||
component.selectionModel.ownerFilter.set(OwnerFilterType.NONE)
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.NONE
|
||||
|
||||
component.setFilter(OwnerFilterType.SELF)
|
||||
expect(emitted()).toEqual({
|
||||
expect(ownerFilterSetResult).toEqual({
|
||||
excludeUsers: [],
|
||||
hideUnowned: false,
|
||||
includeUsers: [],
|
||||
@@ -191,7 +182,7 @@ describe('PermissionsFilterDropdownComponent', () => {
|
||||
})
|
||||
|
||||
component.setFilter(OwnerFilterType.NOT_SELF)
|
||||
expect(emitted()).toEqual({
|
||||
expect(ownerFilterSetResult).toEqual({
|
||||
excludeUsers: [currentUserID],
|
||||
hideUnowned: false,
|
||||
includeUsers: [],
|
||||
@@ -200,7 +191,7 @@ describe('PermissionsFilterDropdownComponent', () => {
|
||||
})
|
||||
|
||||
component.setFilter(OwnerFilterType.NONE)
|
||||
expect(emitted()).toEqual({
|
||||
expect(ownerFilterSetResult).toEqual({
|
||||
excludeUsers: [],
|
||||
hideUnowned: false,
|
||||
includeUsers: [],
|
||||
@@ -209,7 +200,7 @@ describe('PermissionsFilterDropdownComponent', () => {
|
||||
})
|
||||
|
||||
component.setFilter(OwnerFilterType.SHARED_BY_ME)
|
||||
expect(emitted()).toEqual({
|
||||
expect(ownerFilterSetResult).toEqual({
|
||||
excludeUsers: [],
|
||||
hideUnowned: false,
|
||||
includeUsers: [],
|
||||
@@ -218,7 +209,7 @@ describe('PermissionsFilterDropdownComponent', () => {
|
||||
})
|
||||
|
||||
component.setFilter(OwnerFilterType.UNOWNED)
|
||||
expect(emitted()).toEqual({
|
||||
expect(ownerFilterSetResult).toEqual({
|
||||
excludeUsers: [],
|
||||
hideUnowned: false,
|
||||
includeUsers: [],
|
||||
|
||||
+53
-53
@@ -25,18 +25,18 @@ import { ComponentWithPermissions } from '../../with-permissions/with-permission
|
||||
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
|
||||
|
||||
export class PermissionsSelectionModel {
|
||||
readonly ownerFilter = signal(OwnerFilterType.NONE)
|
||||
readonly hideUnowned = signal(false)
|
||||
readonly userID = signal<number>(null)
|
||||
readonly includeUsers = signal<number[]>([])
|
||||
readonly excludeUsers = signal<number[]>([])
|
||||
ownerFilter: OwnerFilterType
|
||||
hideUnowned: boolean
|
||||
userID: number
|
||||
includeUsers: number[]
|
||||
excludeUsers: number[]
|
||||
|
||||
clear() {
|
||||
this.ownerFilter.set(OwnerFilterType.NONE)
|
||||
this.userID.set(null)
|
||||
this.hideUnowned.set(false)
|
||||
this.includeUsers.set([])
|
||||
this.excludeUsers.set([])
|
||||
this.ownerFilter = OwnerFilterType.NONE
|
||||
this.userID = null
|
||||
this.hideUnowned = false
|
||||
this.includeUsers = []
|
||||
this.excludeUsers = []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,31 +84,33 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
||||
|
||||
readonly users = signal<User[]>([])
|
||||
|
||||
hideUnowned: boolean
|
||||
|
||||
get isActive(): boolean {
|
||||
return (
|
||||
this.selectionModel.ownerFilter() !== OwnerFilterType.NONE ||
|
||||
this.selectionModel.hideUnowned()
|
||||
this.selectionModel.ownerFilter !== OwnerFilterType.NONE ||
|
||||
this.selectionModel.hideUnowned
|
||||
)
|
||||
}
|
||||
|
||||
get ownerFilterLabel(): string {
|
||||
if (
|
||||
this.selectionModel?.ownerFilter() !== OwnerFilterType.SELF ||
|
||||
this.selectionModel?.userID() === this.settingsService.currentUser()?.id
|
||||
this.selectionModel?.ownerFilter !== OwnerFilterType.SELF ||
|
||||
this.selectionModel?.userID === this.settingsService.currentUser()?.id
|
||||
) {
|
||||
return $localize`My documents`
|
||||
}
|
||||
|
||||
const username = this.getUsername(this.selectionModel?.userID())
|
||||
const username = this.getUsername(this.selectionModel?.userID)
|
||||
return username
|
||||
? $localize`Owned by ${username}`
|
||||
: $localize`Owned by another user`
|
||||
}
|
||||
|
||||
get ownerExclusionFilterLabel(): string {
|
||||
const excludedUsers = this.selectionModel?.excludeUsers() ?? []
|
||||
const excludedUsers = this.selectionModel?.excludeUsers ?? []
|
||||
if (
|
||||
this.selectionModel?.ownerFilter() !== OwnerFilterType.NOT_SELF ||
|
||||
this.selectionModel?.ownerFilter !== OwnerFilterType.NOT_SELF ||
|
||||
(excludedUsers.length === 1 &&
|
||||
excludedUsers[0] === this.settingsService.currentUser()?.id)
|
||||
) {
|
||||
@@ -128,13 +130,13 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
||||
|
||||
get sharedByFilterLabel(): string {
|
||||
if (
|
||||
this.selectionModel?.ownerFilter() !== OwnerFilterType.SHARED_BY_ME ||
|
||||
this.selectionModel?.userID() === this.settingsService.currentUser()?.id
|
||||
this.selectionModel?.ownerFilter !== OwnerFilterType.SHARED_BY_ME ||
|
||||
this.selectionModel?.userID === this.settingsService.currentUser()?.id
|
||||
) {
|
||||
return $localize`Shared by me`
|
||||
}
|
||||
|
||||
const username = this.getUsername(this.selectionModel?.userID())
|
||||
const username = this.getUsername(this.selectionModel?.userID)
|
||||
return username
|
||||
? $localize`Shared by ${username}`
|
||||
: $localize`Shared by another user`
|
||||
@@ -167,36 +169,34 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
||||
}
|
||||
|
||||
setFilter(type: OwnerFilterType) {
|
||||
this.selectionModel.ownerFilter.set(type)
|
||||
if (this.selectionModel.ownerFilter() === OwnerFilterType.SELF) {
|
||||
this.selectionModel.includeUsers.set([])
|
||||
this.selectionModel.excludeUsers.set([])
|
||||
this.selectionModel.userID.set(this.settingsService.currentUser().id)
|
||||
this.selectionModel.hideUnowned.set(false)
|
||||
} else if (this.selectionModel.ownerFilter() === OwnerFilterType.NOT_SELF) {
|
||||
this.selectionModel.userID.set(null)
|
||||
this.selectionModel.includeUsers.set([])
|
||||
this.selectionModel.excludeUsers.set([
|
||||
this.settingsService.currentUser().id,
|
||||
])
|
||||
this.selectionModel.hideUnowned.set(false)
|
||||
} else if (this.selectionModel.ownerFilter() === OwnerFilterType.NONE) {
|
||||
this.selectionModel.userID.set(null)
|
||||
this.selectionModel.includeUsers.set([])
|
||||
this.selectionModel.excludeUsers.set([])
|
||||
this.selectionModel.hideUnowned.set(false)
|
||||
this.selectionModel.ownerFilter = type
|
||||
if (this.selectionModel.ownerFilter === OwnerFilterType.SELF) {
|
||||
this.selectionModel.includeUsers = []
|
||||
this.selectionModel.excludeUsers = []
|
||||
this.selectionModel.userID = this.settingsService.currentUser().id
|
||||
this.selectionModel.hideUnowned = false
|
||||
} else if (this.selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) {
|
||||
this.selectionModel.userID = null
|
||||
this.selectionModel.includeUsers = []
|
||||
this.selectionModel.excludeUsers = [this.settingsService.currentUser().id]
|
||||
this.selectionModel.hideUnowned = false
|
||||
} else if (this.selectionModel.ownerFilter === OwnerFilterType.NONE) {
|
||||
this.selectionModel.userID = null
|
||||
this.selectionModel.includeUsers = []
|
||||
this.selectionModel.excludeUsers = []
|
||||
this.selectionModel.hideUnowned = false
|
||||
} else if (
|
||||
this.selectionModel.ownerFilter() === OwnerFilterType.SHARED_BY_ME
|
||||
this.selectionModel.ownerFilter === OwnerFilterType.SHARED_BY_ME
|
||||
) {
|
||||
this.selectionModel.userID.set(this.settingsService.currentUser()?.id)
|
||||
this.selectionModel.includeUsers.set([])
|
||||
this.selectionModel.excludeUsers.set([])
|
||||
this.selectionModel.hideUnowned.set(false)
|
||||
} else if (this.selectionModel.ownerFilter() === OwnerFilterType.UNOWNED) {
|
||||
this.selectionModel.userID.set(null)
|
||||
this.selectionModel.includeUsers.set([])
|
||||
this.selectionModel.excludeUsers.set([])
|
||||
this.selectionModel.hideUnowned.set(false)
|
||||
this.selectionModel.userID = this.settingsService.currentUser()?.id
|
||||
this.selectionModel.includeUsers = []
|
||||
this.selectionModel.excludeUsers = []
|
||||
this.selectionModel.hideUnowned = false
|
||||
} else if (this.selectionModel.ownerFilter === OwnerFilterType.UNOWNED) {
|
||||
this.selectionModel.userID = null
|
||||
this.selectionModel.includeUsers = []
|
||||
this.selectionModel.excludeUsers = []
|
||||
this.selectionModel.hideUnowned = false
|
||||
}
|
||||
this.onChange()
|
||||
}
|
||||
@@ -206,11 +206,11 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
||||
}
|
||||
|
||||
onUserSelect() {
|
||||
this.selectionModel.ownerFilter.set(
|
||||
this.selectionModel.includeUsers()?.length
|
||||
? OwnerFilterType.OTHERS
|
||||
: OwnerFilterType.NONE
|
||||
)
|
||||
if (this.selectionModel.includeUsers?.length) {
|
||||
this.selectionModel.ownerFilter = OwnerFilterType.OTHERS
|
||||
} else {
|
||||
this.selectionModel.ownerFilter = OwnerFilterType.NONE
|
||||
}
|
||||
this.onChange()
|
||||
}
|
||||
|
||||
|
||||
@@ -1209,53 +1209,24 @@ describe('DocumentDetailComponent', () => {
|
||||
expect(fixture.debugElement.queryAll(By.css('textarea.rtl'))).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should display built-in pdf viewer if not disabled', async () => {
|
||||
it('should display built-in pdf viewer if not disabled', () => {
|
||||
initNormally()
|
||||
component.document.update((document) => ({
|
||||
...document,
|
||||
archived_file_name: 'file.pdf',
|
||||
}))
|
||||
component.document().archived_file_name = 'file.pdf'
|
||||
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
|
||||
expect(component.useNativePdfViewer).toBeFalsy()
|
||||
await fixture.whenStable()
|
||||
fixture.detectChanges()
|
||||
expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should display native pdf viewer if enabled', () => {
|
||||
initNormally()
|
||||
component.document.update((document) => ({
|
||||
...document,
|
||||
archived_file_name: 'file.pdf',
|
||||
}))
|
||||
component.document().archived_file_name = 'file.pdf'
|
||||
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, true)
|
||||
expect(component.useNativePdfViewer).toBeTruthy()
|
||||
fixture.detectChanges()
|
||||
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', () => {
|
||||
const metadataSpy = jest.spyOn(documentService, 'getMetadata')
|
||||
metadataSpy.mockReturnValue(of({ has_archive_version: true }))
|
||||
@@ -1714,10 +1685,7 @@ describe('DocumentDetailComponent', () => {
|
||||
|
||||
it('should change preview element by render type', () => {
|
||||
initNormally()
|
||||
component.document.update((document) => ({
|
||||
...document,
|
||||
archived_file_name: 'file.pdf',
|
||||
}))
|
||||
component.document().archived_file_name = 'file.pdf'
|
||||
fixture.detectChanges()
|
||||
expect(component.archiveContentRenderType).toEqual(
|
||||
component.ContentRenderType.PDF
|
||||
@@ -1726,11 +1694,8 @@ describe('DocumentDetailComponent', () => {
|
||||
fixture.debugElement.query(By.css('pdf-viewer-container'))
|
||||
).not.toBeUndefined()
|
||||
|
||||
component.document.update((document) => ({
|
||||
...document,
|
||||
archived_file_name: undefined,
|
||||
mime_type: 'text/plain',
|
||||
}))
|
||||
component.document().archived_file_name = undefined
|
||||
component.document().mime_type = 'text/plain'
|
||||
fixture.detectChanges()
|
||||
expect(component.archiveContentRenderType).toEqual(
|
||||
component.ContentRenderType.Text
|
||||
@@ -1739,10 +1704,7 @@ describe('DocumentDetailComponent', () => {
|
||||
fixture.debugElement.query(By.css('div.preview-sticky'))
|
||||
).not.toBeUndefined()
|
||||
|
||||
component.document.update((document) => ({
|
||||
...document,
|
||||
mime_type: 'image/jpeg',
|
||||
}))
|
||||
component.document().mime_type = 'image/jpeg'
|
||||
fixture.detectChanges()
|
||||
expect(component.archiveContentRenderType).toEqual(
|
||||
component.ContentRenderType.Image
|
||||
@@ -1750,12 +1712,9 @@ describe('DocumentDetailComponent', () => {
|
||||
expect(
|
||||
fixture.debugElement.query(By.css('.preview-sticky img'))
|
||||
).not.toBeUndefined()
|
||||
component.document.update((document) => ({
|
||||
...document,
|
||||
mime_type:
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
}))
|
||||
fixture.detectChanges()
|
||||
;((component.document().mime_type =
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'),
|
||||
fixture.detectChanges())
|
||||
expect(component.archiveContentRenderType).toEqual(
|
||||
component.ContentRenderType.Other
|
||||
)
|
||||
|
||||
@@ -227,19 +227,6 @@ export class DocumentDetailComponent
|
||||
private deviceDetectorService = inject(DeviceDetectorService)
|
||||
private savedViewService = inject(SavedViewService)
|
||||
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')
|
||||
titleInput: TextComponent
|
||||
@@ -346,7 +333,8 @@ export class DocumentDetailComponent
|
||||
}
|
||||
|
||||
get useNativePdfViewer(): boolean {
|
||||
return this.useNativePdfViewerSetting()
|
||||
this.settings.trackChanges()
|
||||
return this.settings.get(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER)
|
||||
}
|
||||
|
||||
get isMobile(): boolean {
|
||||
@@ -354,10 +342,12 @@ export class DocumentDetailComponent
|
||||
}
|
||||
|
||||
get aiEnabled(): boolean {
|
||||
return this.aiEnabledSetting()
|
||||
this.settings.trackChanges()
|
||||
return this.settings.get(SETTINGS_KEYS.AI_ENABLED)
|
||||
}
|
||||
|
||||
get archiveContentRenderType(): ContentRenderType {
|
||||
this.settings.trackChanges()
|
||||
const hasArchiveVersion =
|
||||
this.metadata()?.has_archive_version ??
|
||||
!!this.document()?.archived_file_name
|
||||
@@ -369,17 +359,22 @@ export class DocumentDetailComponent
|
||||
}
|
||||
|
||||
get originalContentRenderType(): ContentRenderType {
|
||||
this.settings.trackChanges()
|
||||
return this.getRenderType(
|
||||
this.metadata()?.original_mime_type || this.document()?.mime_type
|
||||
)
|
||||
}
|
||||
|
||||
get showThumbnailOverlay(): boolean {
|
||||
return this.showThumbnailOverlaySetting()
|
||||
this.settings.trackChanges()
|
||||
return this.settings.get(SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL)
|
||||
}
|
||||
|
||||
isFieldHidden(fieldId: DocumentDetailFieldID): boolean {
|
||||
return this.hiddenFieldsSetting().includes(fieldId)
|
||||
this.settings.trackChanges()
|
||||
return this.settings
|
||||
.get(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS)
|
||||
.includes(fieldId)
|
||||
}
|
||||
|
||||
private getRenderType(mimeType: string): ContentRenderType {
|
||||
|
||||
@@ -121,8 +121,6 @@ export class DocumentListComponent
|
||||
settingsService = inject(SettingsService)
|
||||
private hotKeyService = inject(HotKeyService)
|
||||
permissionService = inject(PermissionsService)
|
||||
private readonly notesEnabledSetting =
|
||||
this.settingsService.getSignal<boolean>(SETTINGS_KEYS.NOTES_ENABLED)
|
||||
|
||||
DisplayField = DisplayField
|
||||
DisplayMode = DisplayMode
|
||||
@@ -576,7 +574,8 @@ export class DocumentListComponent
|
||||
}
|
||||
|
||||
get notesEnabled(): boolean {
|
||||
return this.notesEnabledSetting()
|
||||
this.settingsService.trackChanges()
|
||||
return this.settingsService.get(SETTINGS_KEYS.NOTES_ENABLED)
|
||||
}
|
||||
|
||||
resetFilters() {
|
||||
|
||||
+20
-86
@@ -621,43 +621,6 @@ describe('FilterEditorComponent', () => {
|
||||
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', () => {
|
||||
expect(component.tagSelectionModel.getSelectedItems()).toHaveLength(0)
|
||||
component.filterRules = [
|
||||
@@ -1115,7 +1078,7 @@ describe('FilterEditorComponent', () => {
|
||||
})
|
||||
|
||||
it('should ingest filter rules for owner', () => {
|
||||
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
||||
OwnerFilterType.NONE
|
||||
)
|
||||
component.filterRules = [
|
||||
@@ -1124,38 +1087,15 @@ describe('FilterEditorComponent', () => {
|
||||
value: '100',
|
||||
},
|
||||
]
|
||||
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
||||
OwnerFilterType.SELF
|
||||
)
|
||||
expect(component.permissionsSelectionModel.hideUnowned()).toBeFalsy()
|
||||
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()
|
||||
expect(component.permissionsSelectionModel.hideUnowned).toBeFalsy()
|
||||
expect(component.permissionsSelectionModel.userID).toEqual(100)
|
||||
})
|
||||
|
||||
it('should ingest filter rules for owner is others', () => {
|
||||
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
||||
OwnerFilterType.NONE
|
||||
)
|
||||
component.filterRules = [
|
||||
@@ -1164,14 +1104,14 @@ describe('FilterEditorComponent', () => {
|
||||
value: '50',
|
||||
},
|
||||
]
|
||||
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
||||
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', () => {
|
||||
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
||||
OwnerFilterType.NONE
|
||||
)
|
||||
component.filterRules = [
|
||||
@@ -1180,14 +1120,14 @@ describe('FilterEditorComponent', () => {
|
||||
value: '50',
|
||||
},
|
||||
]
|
||||
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
||||
OwnerFilterType.NOT_SELF
|
||||
)
|
||||
expect(component.permissionsSelectionModel.excludeUsers()).toContain(50)
|
||||
expect(component.permissionsSelectionModel.excludeUsers).toContain(50)
|
||||
})
|
||||
|
||||
it('should ingest filter rules for owner is null', () => {
|
||||
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
||||
OwnerFilterType.NONE
|
||||
)
|
||||
component.filterRules = [
|
||||
@@ -1196,10 +1136,10 @@ describe('FilterEditorComponent', () => {
|
||||
value: 'true',
|
||||
},
|
||||
]
|
||||
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
||||
OwnerFilterType.UNOWNED
|
||||
)
|
||||
expect(component.permissionsSelectionModel.hideUnowned()).toBeFalsy()
|
||||
expect(component.permissionsSelectionModel.hideUnowned).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should ingest filter rules for owner is not null', () => {
|
||||
@@ -1209,14 +1149,14 @@ describe('FilterEditorComponent', () => {
|
||||
value: 'false',
|
||||
},
|
||||
]
|
||||
expect(component.permissionsSelectionModel.hideUnowned()).toBeTruthy()
|
||||
expect(component.permissionsSelectionModel.hideUnowned).toBeTruthy()
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_OWNER_ISNULL,
|
||||
value: '0',
|
||||
},
|
||||
]
|
||||
expect(component.permissionsSelectionModel.hideUnowned()).toBeTruthy()
|
||||
expect(component.permissionsSelectionModel.hideUnowned).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should ingest filter rules for shared by me', () => {
|
||||
@@ -1226,7 +1166,7 @@ describe('FilterEditorComponent', () => {
|
||||
value: '2',
|
||||
},
|
||||
]
|
||||
expect(component.permissionsSelectionModel.userID()).toEqual(2)
|
||||
expect(component.permissionsSelectionModel.userID).toEqual(2)
|
||||
})
|
||||
|
||||
// GET filterRules
|
||||
@@ -1992,10 +1932,7 @@ describe('FilterEditorComponent', () => {
|
||||
value: '1',
|
||||
},
|
||||
])
|
||||
component.permissionsSelectionModel.excludeUsers.update((users) => [
|
||||
...users,
|
||||
2,
|
||||
])
|
||||
component.permissionsSelectionModel.excludeUsers.push(2)
|
||||
fixture.detectChanges()
|
||||
expect(component.filterRules).toEqual([
|
||||
{
|
||||
@@ -2045,11 +1982,8 @@ describe('FilterEditorComponent', () => {
|
||||
// TODO: mock input in code
|
||||
// userSelect.query(By.css('input')).nativeElement.value = '3'
|
||||
// userSelect.triggerEventHandler('change')
|
||||
component.permissionsSelectionModel.ownerFilter.set(OwnerFilterType.OTHERS)
|
||||
component.permissionsSelectionModel.includeUsers.update((users) => [
|
||||
...users,
|
||||
3,
|
||||
])
|
||||
component.permissionsSelectionModel.ownerFilter = OwnerFilterType.OTHERS
|
||||
component.permissionsSelectionModel.includeUsers.push(3)
|
||||
fixture.detectChanges()
|
||||
expect(component.filterRules).toEqual([
|
||||
{
|
||||
@@ -2069,7 +2003,7 @@ describe('FilterEditorComponent', () => {
|
||||
ownerToggle.nativeElement.checked = true
|
||||
// ownerToggle.triggerEventHandler('change')
|
||||
// TODO: ngModel isn't doing this here
|
||||
component.permissionsSelectionModel.hideUnowned.set(true)
|
||||
component.permissionsSelectionModel.hideUnowned = true
|
||||
fixture.detectChanges()
|
||||
expect(component.filterRules).toEqual([
|
||||
{
|
||||
|
||||
@@ -735,50 +735,38 @@ export class FilterEditorComponent
|
||||
this._textFilter = rule.value
|
||||
break
|
||||
case FILTER_OWNER:
|
||||
this.permissionsSelectionModel.ownerFilter.set(OwnerFilterType.SELF)
|
||||
this.permissionsSelectionModel.hideUnowned.set(false)
|
||||
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.SELF
|
||||
this.permissionsSelectionModel.hideUnowned = false
|
||||
if (rule.value)
|
||||
this.permissionsSelectionModel.userID.set(
|
||||
Number.parseInt(rule.value, 10)
|
||||
)
|
||||
this.permissionsSelectionModel.userID = parseInt(rule.value, 10)
|
||||
break
|
||||
case FILTER_OWNER_ANY:
|
||||
this.permissionsSelectionModel.ownerFilter.set(OwnerFilterType.OTHERS)
|
||||
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.OTHERS
|
||||
if (rule.value)
|
||||
this.permissionsSelectionModel.includeUsers.update((users) => [
|
||||
...users,
|
||||
Number.parseInt(rule.value, 10),
|
||||
])
|
||||
this.permissionsSelectionModel.includeUsers.push(
|
||||
parseInt(rule.value, 10)
|
||||
)
|
||||
break
|
||||
case FILTER_OWNER_DOES_NOT_INCLUDE:
|
||||
this.permissionsSelectionModel.ownerFilter.set(
|
||||
OwnerFilterType.NOT_SELF
|
||||
)
|
||||
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.NOT_SELF
|
||||
if (rule.value)
|
||||
this.permissionsSelectionModel.excludeUsers.update((users) => [
|
||||
...users,
|
||||
Number.parseInt(rule.value, 10),
|
||||
])
|
||||
this.permissionsSelectionModel.excludeUsers.push(
|
||||
parseInt(rule.value, 10)
|
||||
)
|
||||
break
|
||||
case FILTER_SHARED_BY_USER:
|
||||
this.permissionsSelectionModel.ownerFilter.set(
|
||||
this.permissionsSelectionModel.ownerFilter =
|
||||
OwnerFilterType.SHARED_BY_ME
|
||||
)
|
||||
if (rule.value)
|
||||
this.permissionsSelectionModel.userID.set(
|
||||
Number.parseInt(rule.value, 10)
|
||||
)
|
||||
this.permissionsSelectionModel.userID = parseInt(rule.value, 10)
|
||||
break
|
||||
case FILTER_OWNER_ISNULL:
|
||||
if (rule.value === 'true' || rule.value === '1') {
|
||||
this.permissionsSelectionModel.hideUnowned.set(false)
|
||||
this.permissionsSelectionModel.ownerFilter.set(
|
||||
OwnerFilterType.UNOWNED
|
||||
)
|
||||
this.permissionsSelectionModel.hideUnowned = false
|
||||
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.UNOWNED
|
||||
} else {
|
||||
this.permissionsSelectionModel.hideUnowned.set(
|
||||
this.permissionsSelectionModel.hideUnowned =
|
||||
rule.value === 'false' || rule.value === '0'
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -1086,35 +1074,34 @@ export class FilterEditorComponent
|
||||
})
|
||||
}
|
||||
}
|
||||
if (this.permissionsSelectionModel.ownerFilter() == OwnerFilterType.SELF) {
|
||||
if (this.permissionsSelectionModel.ownerFilter == OwnerFilterType.SELF) {
|
||||
filterRules.push({
|
||||
rule_type: FILTER_OWNER,
|
||||
value: this.permissionsSelectionModel.userID().toString(),
|
||||
value: this.permissionsSelectionModel.userID.toString(),
|
||||
})
|
||||
} else if (
|
||||
this.permissionsSelectionModel.ownerFilter() == OwnerFilterType.NOT_SELF
|
||||
this.permissionsSelectionModel.ownerFilter == OwnerFilterType.NOT_SELF
|
||||
) {
|
||||
filterRules.push({
|
||||
rule_type: FILTER_OWNER_DOES_NOT_INCLUDE,
|
||||
value: this.permissionsSelectionModel.excludeUsers()?.join(','),
|
||||
value: this.permissionsSelectionModel.excludeUsers?.join(','),
|
||||
})
|
||||
} else if (
|
||||
this.permissionsSelectionModel.ownerFilter() == OwnerFilterType.OTHERS
|
||||
this.permissionsSelectionModel.ownerFilter == OwnerFilterType.OTHERS
|
||||
) {
|
||||
filterRules.push({
|
||||
rule_type: FILTER_OWNER_ANY,
|
||||
value: this.permissionsSelectionModel.includeUsers()?.join(','),
|
||||
value: this.permissionsSelectionModel.includeUsers?.join(','),
|
||||
})
|
||||
} else if (
|
||||
this.permissionsSelectionModel.ownerFilter() ==
|
||||
OwnerFilterType.SHARED_BY_ME
|
||||
this.permissionsSelectionModel.ownerFilter == OwnerFilterType.SHARED_BY_ME
|
||||
) {
|
||||
filterRules.push({
|
||||
rule_type: FILTER_SHARED_BY_USER,
|
||||
value: this.permissionsSelectionModel.userID().toString(),
|
||||
value: this.permissionsSelectionModel.userID.toString(),
|
||||
})
|
||||
} else if (
|
||||
this.permissionsSelectionModel.ownerFilter() == OwnerFilterType.UNOWNED
|
||||
this.permissionsSelectionModel.ownerFilter == OwnerFilterType.UNOWNED
|
||||
) {
|
||||
filterRules.push({
|
||||
rule_type: FILTER_OWNER_ISNULL,
|
||||
@@ -1122,7 +1109,7 @@ export class FilterEditorComponent
|
||||
})
|
||||
}
|
||||
|
||||
if (this.permissionsSelectionModel.hideUnowned()) {
|
||||
if (this.permissionsSelectionModel.hideUnowned) {
|
||||
filterRules.push({
|
||||
rule_type: FILTER_OWNER_ISNULL,
|
||||
value: 'false',
|
||||
|
||||
@@ -210,48 +210,6 @@ describe('SettingsService', () => {
|
||||
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', () => {
|
||||
httpTestingController
|
||||
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
|
||||
|
||||
@@ -2,8 +2,6 @@ import { HttpClient } from '@angular/common/http'
|
||||
import {
|
||||
DOCUMENT,
|
||||
EventEmitter,
|
||||
Signal,
|
||||
computed,
|
||||
inject,
|
||||
Injectable,
|
||||
LOCALE_ID,
|
||||
@@ -299,7 +297,6 @@ export class SettingsService {
|
||||
|
||||
private settings: Record<string, any> = {}
|
||||
private readonly settingsVersion = signal(0)
|
||||
private readonly settingSignals = new Map<string, Signal<unknown>>()
|
||||
readonly currentUser = signal<User>(undefined)
|
||||
|
||||
public settingsSaved: EventEmitter<any> = new EventEmitter()
|
||||
@@ -329,6 +326,10 @@ export class SettingsService {
|
||||
return !UNSAFE_OBJECT_KEYS.has(key)
|
||||
}
|
||||
|
||||
public trackChanges(): void {
|
||||
this.settingsVersion()
|
||||
}
|
||||
|
||||
private assignSafeSettings(source: Record<string, any>) {
|
||||
if (!source || typeof source !== 'object' || Array.isArray(source)) {
|
||||
return
|
||||
@@ -338,7 +339,6 @@ export class SettingsService {
|
||||
if (!this.isSafeObjectKey(key)) continue
|
||||
this.settings[key] = source[key]
|
||||
}
|
||||
this.settingsVersion.update((version) => version + 1)
|
||||
}
|
||||
|
||||
// this is called by the app initializer in app.module
|
||||
@@ -594,18 +594,6 @@ 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) {
|
||||
// parse key:key:key into nested object
|
||||
let settingObj = this.settings
|
||||
|
||||
@@ -314,7 +314,7 @@ def _consume_file(
|
||||
consumption_dir: Path,
|
||||
*,
|
||||
subdirs_as_tags: bool,
|
||||
) -> bool:
|
||||
) -> None:
|
||||
"""
|
||||
Queue a file for consumption.
|
||||
|
||||
@@ -322,20 +322,15 @@ def _consume_file(
|
||||
filepath: Path to the file to consume.
|
||||
consumption_dir: Base consumption directory.
|
||||
subdirs_as_tags: Whether to create tags from subdirectory names.
|
||||
|
||||
Returns:
|
||||
True if the file was successfully handed to Celery, False otherwise.
|
||||
Callers must not record the file as queued on failure, or the rescan
|
||||
will never retry it.
|
||||
"""
|
||||
# Verify file still exists and is accessible
|
||||
try:
|
||||
if not filepath.is_file():
|
||||
logger.debug(f"Not consuming {filepath}: not a file or doesn't exist")
|
||||
return False
|
||||
return
|
||||
except OSError as e:
|
||||
logger.warning(f"Not consuming {filepath}: {e}")
|
||||
return False
|
||||
return
|
||||
|
||||
# Get tags from path if configured
|
||||
tag_ids: list[int] | None = None
|
||||
@@ -360,9 +355,6 @@ def _consume_file(
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(f"Error while queuing document {filepath}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -500,12 +492,12 @@ class Command(BaseCommand):
|
||||
if not consumer_filter(Change.added, str(filepath)):
|
||||
continue
|
||||
|
||||
if _consume_file(
|
||||
_consume_file(
|
||||
filepath=filepath,
|
||||
consumption_dir=directory,
|
||||
subdirs_as_tags=subdirs_as_tags,
|
||||
):
|
||||
queued.add(filepath.resolve())
|
||||
)
|
||||
queued.add(filepath.resolve())
|
||||
|
||||
return queued
|
||||
|
||||
@@ -659,16 +651,14 @@ class Command(BaseCommand):
|
||||
|
||||
# Check for stable files
|
||||
for stable_path in tracker.get_stable_files():
|
||||
# Only remember files that were actually queued, so the
|
||||
# rescan does not re-queue them while the consume task
|
||||
# has yet to remove them from disk, but does retry a
|
||||
# failed publish instead of stranding it
|
||||
if _consume_file(
|
||||
_consume_file(
|
||||
filepath=stable_path,
|
||||
consumption_dir=directory,
|
||||
subdirs_as_tags=subdirs_as_tags,
|
||||
):
|
||||
queued.add(stable_path)
|
||||
)
|
||||
# Remember it so the rescan does not re-queue it while
|
||||
# the consume task has yet to remove it from disk
|
||||
queued.add(stable_path)
|
||||
|
||||
# Exit watch loop to reconfigure timeout
|
||||
break
|
||||
|
||||
@@ -1003,7 +1003,7 @@ def run_workflows(
|
||||
|
||||
# kwargs so the PaperlessTask record can note the
|
||||
# document, see _extract_input_data
|
||||
apply_ai_suggestions.delay_on_commit(
|
||||
apply_ai_suggestions.delay(
|
||||
action_id=action.pk,
|
||||
document_id=document.pk,
|
||||
)
|
||||
|
||||
@@ -1063,79 +1063,3 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
||||
)
|
||||
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=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"], "")
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
|
||||
class TestChatStreamingViewInputValidation(APITestCase):
|
||||
def setUp(self) -> None:
|
||||
@@ -49,73 +42,3 @@ class TestChatStreamingViewInputValidation(APITestCase):
|
||||
format="json",
|
||||
)
|
||||
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
|
||||
an active superuser, never for a regular user -- regardless of what
|
||||
permissions that user holds.
|
||||
"""
|
||||
|
||||
@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, granted the global
|
||||
view_document permission -- the minimum ViewDocumentsPermissions
|
||||
needs to reach the view at all. Model-level only: says nothing
|
||||
about which documents (if any) this user can actually see.
|
||||
"""
|
||||
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 the global
|
||||
view_document permission (but no object-level document access)
|
||||
WHEN:
|
||||
- They post a chat question with no document_id
|
||||
THEN:
|
||||
- stream_chat_with_documents is called with unrestricted=True
|
||||
only for the superuser; the regular user is always
|
||||
unrestricted=False, regardless of their permissions
|
||||
"""
|
||||
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
|
||||
)
|
||||
|
||||
@@ -445,13 +445,12 @@ class TestConsumeFile:
|
||||
target = consumption_dir / "document.pdf"
|
||||
shutil.copy(sample_pdf, target)
|
||||
|
||||
result = _consume_file(
|
||||
_consume_file(
|
||||
filepath=target,
|
||||
consumption_dir=consumption_dir,
|
||||
subdirs_as_tags=False,
|
||||
)
|
||||
|
||||
assert result is True
|
||||
mock_consume_file_delay.apply_async.assert_called_once()
|
||||
call_args = mock_consume_file_delay.apply_async.call_args
|
||||
consumable_doc = call_args.kwargs["kwargs"]["input_doc"]
|
||||
@@ -465,12 +464,11 @@ class TestConsumeFile:
|
||||
mock_consume_file_delay: MagicMock,
|
||||
) -> None:
|
||||
"""Test _consume_file handles nonexistent files gracefully."""
|
||||
result = _consume_file(
|
||||
_consume_file(
|
||||
filepath=consumption_dir / "nonexistent.pdf",
|
||||
consumption_dir=consumption_dir,
|
||||
subdirs_as_tags=False,
|
||||
)
|
||||
assert result is False
|
||||
mock_consume_file_delay.apply_async.assert_not_called()
|
||||
|
||||
def test_consume_directory(
|
||||
@@ -482,12 +480,11 @@ class TestConsumeFile:
|
||||
subdir = consumption_dir / "subdir"
|
||||
subdir.mkdir()
|
||||
|
||||
result = _consume_file(
|
||||
_consume_file(
|
||||
filepath=subdir,
|
||||
consumption_dir=consumption_dir,
|
||||
subdirs_as_tags=False,
|
||||
)
|
||||
assert result is False
|
||||
mock_consume_file_delay.apply_async.assert_not_called()
|
||||
|
||||
def test_consume_with_permission_error(
|
||||
@@ -502,33 +499,13 @@ class TestConsumeFile:
|
||||
shutil.copy(sample_pdf, target)
|
||||
|
||||
mocker.patch.object(Path, "is_file", side_effect=PermissionError("denied"))
|
||||
result = _consume_file(
|
||||
_consume_file(
|
||||
filepath=target,
|
||||
consumption_dir=consumption_dir,
|
||||
subdirs_as_tags=False,
|
||||
)
|
||||
assert result is False
|
||||
mock_consume_file_delay.apply_async.assert_not_called()
|
||||
|
||||
def test_consume_with_apply_async_failure(
|
||||
self,
|
||||
consumption_dir: Path,
|
||||
sample_pdf: Path,
|
||||
mock_consume_file_delay: MagicMock,
|
||||
) -> None:
|
||||
"""Test _consume_file reports failure when apply_async raises."""
|
||||
target = consumption_dir / "document.pdf"
|
||||
shutil.copy(sample_pdf, target)
|
||||
|
||||
mock_consume_file_delay.apply_async.side_effect = Exception("broker down")
|
||||
|
||||
result = _consume_file(
|
||||
filepath=target,
|
||||
consumption_dir=consumption_dir,
|
||||
subdirs_as_tags=False,
|
||||
)
|
||||
assert result is False
|
||||
|
||||
def test_consume_with_tags_error(
|
||||
self,
|
||||
consumption_dir: Path,
|
||||
@@ -545,12 +522,11 @@ class TestConsumeFile:
|
||||
side_effect=DatabaseError("Something happened"),
|
||||
)
|
||||
|
||||
result = _consume_file(
|
||||
_consume_file(
|
||||
filepath=target,
|
||||
consumption_dir=consumption_dir,
|
||||
subdirs_as_tags=True,
|
||||
)
|
||||
assert result is True
|
||||
mock_consume_file_delay.apply_async.assert_called_once()
|
||||
call_args = mock_consume_file_delay.apply_async.call_args
|
||||
overrides = call_args.kwargs["kwargs"]["overrides"]
|
||||
@@ -1273,52 +1249,6 @@ class TestProcessExistingFilesQueued:
|
||||
assert target.resolve() in queued
|
||||
|
||||
|
||||
@pytest.mark.management
|
||||
@pytest.mark.django_db
|
||||
class TestCommandRetryAfterQueueFailure:
|
||||
"""
|
||||
Regression test for GH #13923.
|
||||
|
||||
A file whose ``apply_async`` publish fails (e.g. broker briefly down)
|
||||
must not be marked as queued, so the periodic rescan retries it once
|
||||
the broker recovers, instead of stranding it until the consumer
|
||||
process is restarted.
|
||||
"""
|
||||
|
||||
def test_watch_loop_retries_failed_publish_on_rescan(
|
||||
self,
|
||||
consumption_dir: Path,
|
||||
sample_pdf: Path,
|
||||
mock_consume_file_delay: MagicMock,
|
||||
start_consumer: Callable[..., ConsumerThread],
|
||||
) -> None:
|
||||
"""A publish failure from the watch loop is retried by the rescan."""
|
||||
apply_async = mock_consume_file_delay.apply_async
|
||||
|
||||
def fail_first_call(*args: object, **kwargs: object) -> None:
|
||||
if apply_async.call_count == 1:
|
||||
raise Exception("broker down")
|
||||
|
||||
apply_async.side_effect = fail_first_call
|
||||
|
||||
thread = start_consumer(stability_delay=0.1, rescan_interval=0.3)
|
||||
|
||||
target = consumption_dir / "document.pdf"
|
||||
shutil.copy(sample_pdf, target)
|
||||
|
||||
deadline = monotonic() + 5.0
|
||||
while apply_async.call_count < 2 and monotonic() < deadline:
|
||||
sleep(0.1)
|
||||
|
||||
if thread.exception:
|
||||
raise thread.exception
|
||||
|
||||
assert apply_async.call_count >= 2, (
|
||||
"Expected the failed publish to be retried by the rescan, "
|
||||
f"but apply_async was only called {apply_async.call_count} time(s)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.management
|
||||
@pytest.mark.django_db
|
||||
class TestCommandRescanRecovery:
|
||||
|
||||
@@ -5621,15 +5621,11 @@ class TestApplyAISuggestionsWorkflowAction(
|
||||
action = self.make_action()
|
||||
self.make_workflow(action, WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED)
|
||||
|
||||
with (
|
||||
mock.patch("documents.tasks.apply_ai_suggestions.delay") as delay,
|
||||
self.captureOnCommitCallbacks(execute=True),
|
||||
):
|
||||
with mock.patch("documents.tasks.apply_ai_suggestions.delay") as delay:
|
||||
run_workflows(
|
||||
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||
self.doc,
|
||||
)
|
||||
delay.assert_not_called()
|
||||
|
||||
delay.assert_called_once_with(action_id=action.pk, document_id=self.doc.pk)
|
||||
|
||||
|
||||
@@ -180,7 +180,6 @@ from documents.permissions import has_system_status_permission
|
||||
from documents.permissions import permitted_document_ids
|
||||
from documents.permissions import permitted_object_ids
|
||||
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.schema import generate_object_with_permissions_schema
|
||||
from documents.search import SearchHit
|
||||
@@ -2330,12 +2329,10 @@ class ChatStreamingView(GenericAPIView[Any]):
|
||||
return HttpResponseForbidden("Insufficient permissions")
|
||||
|
||||
documents = Document.objects.filter(pk=document.pk)
|
||||
unrestricted = False
|
||||
else:
|
||||
documents = Document.objects.filter(
|
||||
id__in=permitted_document_ids(request.user),
|
||||
)
|
||||
unrestricted = user_is_unrestricted(request.user)
|
||||
|
||||
output_language = get_llm_output_language(
|
||||
ai_config=ai_config,
|
||||
@@ -2346,7 +2343,6 @@ class ChatStreamingView(GenericAPIView[Any]):
|
||||
stream_chat_with_documents(
|
||||
query_str=question,
|
||||
documents=documents,
|
||||
unrestricted=unrestricted,
|
||||
output_language=output_language,
|
||||
),
|
||||
content_type="text/event-stream",
|
||||
|
||||
@@ -2,7 +2,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-09-02 18:09+0000\n"
|
||||
"POT-Creation-Date: 2026-09-01 19:54+0000\n"
|
||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
@@ -1628,7 +1628,7 @@ msgid "workflow runs"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:524 documents/serialisers.py:878
|
||||
#: documents/serialisers.py:2830 documents/views.py:314 documents/views.py:2623
|
||||
#: documents/serialisers.py:2830 documents/views.py:313 documents/views.py:2619
|
||||
#: paperless_mail/serialisers.py:156
|
||||
msgid "Insufficient permissions."
|
||||
msgstr ""
|
||||
@@ -1669,7 +1669,7 @@ msgstr ""
|
||||
msgid "Duplicate document identifiers are not allowed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2916 documents/views.py:4624
|
||||
#: documents/serialisers.py:2916 documents/views.py:4620
|
||||
#, python-format
|
||||
msgid "Documents not found: %(ids)s"
|
||||
msgstr ""
|
||||
@@ -1937,36 +1937,36 @@ msgstr ""
|
||||
msgid "Unable to parse URI {value}"
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:307 documents/views.py:2620
|
||||
#: documents/views.py:306 documents/views.py:2616
|
||||
msgid "Invalid more_like_id"
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:1591
|
||||
#: documents/views.py:1590
|
||||
msgid "Invalid AI configuration."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:1602
|
||||
#: documents/views.py:1601
|
||||
msgid "AI backend request timed out."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:2445 documents/views.py:2766
|
||||
#: documents/views.py:2441 documents/views.py:2762
|
||||
msgid "Specify only one of text, title_search, query, or more_like_id."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4637
|
||||
#: documents/views.py:4633
|
||||
#, python-format
|
||||
msgid "Insufficient permissions to share document %(id)s."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4683
|
||||
#: documents/views.py:4679
|
||||
msgid "Bundle is already being processed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4747
|
||||
#: documents/views.py:4743
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4761
|
||||
#: documents/views.py:4757
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ def _rewrite_request_to_pinned_ip(
|
||||
method=request.method,
|
||||
url=new_url,
|
||||
headers=new_headers,
|
||||
stream=request.stream,
|
||||
content=request.stream,
|
||||
extensions=request.extensions,
|
||||
)
|
||||
rewritten_request.extensions["sni_hostname"] = hostname
|
||||
|
||||
@@ -32,8 +32,6 @@ if TYPE_CHECKING:
|
||||
import datetime
|
||||
from types import TracebackType
|
||||
|
||||
from azure.core.pipeline import PipelineRequest
|
||||
|
||||
from paperless.parsers import MetadataEntry
|
||||
from paperless.parsers import ParserContext
|
||||
|
||||
@@ -438,45 +436,9 @@ class RemoteDocumentParser:
|
||||
from azure.ai.documentintelligence.models import DocumentContentFormat
|
||||
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(
|
||||
endpoint=config.endpoint,
|
||||
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:
|
||||
|
||||
@@ -305,22 +305,6 @@ class ApplicationConfigurationSerializer(
|
||||
|
||||
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:
|
||||
model = ApplicationConfiguration
|
||||
fields = "__all__"
|
||||
|
||||
@@ -705,12 +705,6 @@ CELERY_BROKER_TRANSPORT_OPTIONS = {
|
||||
CELERY_TASK_TRACK_STARTED = True
|
||||
CELERY_TASK_TIME_LIMIT: Final[int] = get_int_from_env("PAPERLESS_WORKER_TIMEOUT", 1800)
|
||||
|
||||
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#std-setting-task_allow_error_cb_on_chord_header
|
||||
# Without this, a failing chord header never triggers the errback, so a mail
|
||||
# whose attachments all fail is never recorded and is re-fetched forever.
|
||||
# The errback runs once per failed header task, so it must be idempotent.
|
||||
CELERY_TASK_ALLOW_ERROR_CB_ON_CHORD_HEADER = True
|
||||
|
||||
CELERY_CACHE_BACKEND = "default"
|
||||
|
||||
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#task-serializer
|
||||
@@ -1214,10 +1208,6 @@ REMOTE_OCR_MODE = get_choice_from_env(
|
||||
{"always", "workflow_only"},
|
||||
default="always",
|
||||
)
|
||||
REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS = get_bool_from_env(
|
||||
"PAPERLESS_REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS",
|
||||
"true",
|
||||
)
|
||||
|
||||
################################################################################
|
||||
# AI Settings #
|
||||
|
||||
@@ -8,8 +8,7 @@ from documents.models import Document
|
||||
from paperless.config import AIConfig
|
||||
from paperless_ai.client import AIClient
|
||||
from paperless_ai.db import db_connection_released
|
||||
from paperless_ai.indexing import document_id_filters
|
||||
from paperless_ai.indexing import exclude_document_ids_filter
|
||||
from paperless_ai.indexing import _document_id_filters
|
||||
from paperless_ai.indexing import get_rag_prompt_helper
|
||||
from paperless_ai.indexing import load_or_build_index
|
||||
from paperless_ai.indexing import read_store
|
||||
@@ -96,15 +95,12 @@ def _format_chat_metadata_trailer(references: list[dict[str, int | str]]) -> str
|
||||
def stream_chat_with_documents(
|
||||
query_str: str,
|
||||
documents: QuerySet[Document],
|
||||
*,
|
||||
unrestricted: bool = False,
|
||||
output_language: str | None = None,
|
||||
):
|
||||
try:
|
||||
yield from _stream_chat_with_documents(
|
||||
query_str,
|
||||
documents,
|
||||
unrestricted=unrestricted,
|
||||
output_language=output_language,
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -115,8 +111,6 @@ def stream_chat_with_documents(
|
||||
def _stream_chat_with_documents(
|
||||
query_str: str,
|
||||
documents: QuerySet[Document],
|
||||
*,
|
||||
unrestricted: bool = False,
|
||||
output_language: str | None = None,
|
||||
):
|
||||
if not documents.exists():
|
||||
@@ -129,18 +123,9 @@ def _stream_chat_with_documents(
|
||||
from llama_index.core.retrievers import VectorIndexRetriever
|
||||
|
||||
config = AIConfig()
|
||||
if unrestricted:
|
||||
# Exclude trashed ids (usually few) instead of an IN filter over the
|
||||
# full permitted set, which risks the vector store's bound parameter
|
||||
# limit (_MAX_IN_VALUES) on large installs. Trashed documents stay
|
||||
# indexed until permanent deletion (delete_document_from_llm_index
|
||||
# hangs off post_delete, not trash), so must be excluded explicitly.
|
||||
trashed_ids = Document.deleted_objects.values_list("pk", flat=True)
|
||||
filters = exclude_document_ids_filter(str(pk) for pk in trashed_ids)
|
||||
else:
|
||||
filters = document_id_filters(
|
||||
str(pk) for pk in documents.values_list("pk", flat=True)
|
||||
)
|
||||
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
|
||||
# retrieves from the vector store again during synthesis, so the connection
|
||||
|
||||
@@ -131,10 +131,11 @@ class AIClient:
|
||||
|
||||
from llama_index.core.llms import ChatMessage
|
||||
|
||||
user_msg = ChatMessage(role="user", content=prompt)
|
||||
if self.settings.llm_backend == LLMBackend.OLLAMA:
|
||||
with self._normalize_timeouts():
|
||||
result = self.llm.chat(
|
||||
[ChatMessage(role="user", content=prompt)],
|
||||
[user_msg],
|
||||
format=DocumentClassifierSchema.model_json_schema(),
|
||||
think=False,
|
||||
)
|
||||
@@ -148,11 +149,6 @@ class AIClient:
|
||||
from llama_index.core.program.function_program import get_function_tool
|
||||
|
||||
tool = get_function_tool(DocumentClassifierSchema)
|
||||
user_msg = ChatMessage(
|
||||
role="user",
|
||||
content=f"{prompt}\n\n"
|
||||
f"Answer by calling the {tool.metadata.name} tool. Do not write the answer as text.",
|
||||
)
|
||||
with self._normalize_timeouts():
|
||||
result = self.llm.chat_with_tools(
|
||||
tools=[tool],
|
||||
|
||||
@@ -362,7 +362,7 @@ def _embed_nodes(nodes: list["BaseNode"], embed_model) -> None:
|
||||
node.embedding = emb
|
||||
|
||||
|
||||
def document_id_filters(doc_ids):
|
||||
def _document_id_filters(doc_ids):
|
||||
"""Return a MetadataFilters IN filter scoped to ``doc_ids``."""
|
||||
from llama_index.core.vector_stores.types import FilterOperator
|
||||
from llama_index.core.vector_stores.types import MetadataFilter
|
||||
@@ -396,23 +396,6 @@ def _exclude_document_id_filter(document_id: int | str):
|
||||
)
|
||||
|
||||
|
||||
def exclude_document_ids_filter(doc_ids):
|
||||
"""Return a MetadataFilters NIN filter excluding every id in ``doc_ids``."""
|
||||
from llama_index.core.vector_stores.types import FilterOperator
|
||||
from llama_index.core.vector_stores.types import MetadataFilter
|
||||
from llama_index.core.vector_stores.types import MetadataFilters
|
||||
|
||||
return MetadataFilters(
|
||||
filters=[
|
||||
MetadataFilter(
|
||||
key="document_id",
|
||||
operator=FilterOperator.NIN,
|
||||
value=list(doc_ids),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def update_llm_index(
|
||||
*,
|
||||
iter_wrapper: IterWrapper[Document] = identity,
|
||||
@@ -677,7 +660,7 @@ def retrieve_similar_nodes(
|
||||
|
||||
filter_parts = []
|
||||
if allowed_document_ids is not None:
|
||||
filter_parts.extend(document_id_filters(allowed_document_ids).filters)
|
||||
filter_parts.extend(_document_id_filters(allowed_document_ids).filters)
|
||||
if document.pk is not None:
|
||||
filter_parts.extend(_exclude_document_id_filter(document.pk).filters)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Rewrite only the "title", "tags", "document_types", and "storage_paths" fields i
|
||||
|
||||
Do not translate correspondents or dates.
|
||||
Preserve proper nouns, organization names, product names, and exact official document names. Translate generic category words when a {{ language_name }} equivalent exists.
|
||||
Keep every entry you were given in those four fields, in the same order, using the original wording where no translation applies.
|
||||
Return the same JSON schema with all fields present.
|
||||
|
||||
Suggestions:
|
||||
{{ suggestions_json }}
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.db.models.signals import post_init
|
||||
from django.utils import timezone
|
||||
from llama_index.core import settings as llama_settings
|
||||
from llama_index.core.embeddings.mock_embed_model import MockEmbedding
|
||||
from llama_index.core.schema import TextNode
|
||||
@@ -23,11 +18,6 @@ from paperless_ai.chat import _build_chat_prompt
|
||||
from paperless_ai.chat import _build_refine_prompt
|
||||
from paperless_ai.chat import stream_chat_with_documents
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
import pytest_mock
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_embed_model():
|
||||
@@ -320,40 +310,8 @@ def test_stream_chat_unexpected_failure_returns_generic_error(caplog) -> None:
|
||||
assert "private provider detail" in caplog.text
|
||||
|
||||
|
||||
def _retriever_filter_values(captured_filters: list[Any]) -> list[str]:
|
||||
"""The value list of the single MetadataFilter the retriever received."""
|
||||
assert captured_filters, "VectorIndexRetriever was never constructed"
|
||||
filt = captured_filters[0]
|
||||
assert filt is not None, "Retriever must receive a MetadataFilters"
|
||||
return filt.filters[0].value
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
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(
|
||||
self,
|
||||
temp_llm_index_dir,
|
||||
@@ -371,9 +329,9 @@ class TestStreamChatRetrieval:
|
||||
|
||||
def test_chat_filter_contains_only_requested_document_ids(
|
||||
self,
|
||||
temp_llm_index_dir: Path,
|
||||
mock_embed_model: pytest_mock.MockType,
|
||||
captured_filters: list[Any],
|
||||
temp_llm_index_dir,
|
||||
mock_embed_model,
|
||||
mocker,
|
||||
) -> None:
|
||||
"""The MetadataFilter passed to the retriever must be scoped to the
|
||||
requested documents only — content from other indexed documents must
|
||||
@@ -384,6 +342,22 @@ class TestStreamChatRetrieval:
|
||||
indexing.llm_index_add_or_update_document(included)
|
||||
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(
|
||||
chat.stream_chat_with_documents(
|
||||
"question?",
|
||||
@@ -391,78 +365,13 @@ class TestStreamChatRetrieval:
|
||||
),
|
||||
)
|
||||
|
||||
filter_values = _retriever_filter_values(captured_filters)
|
||||
assert captured_filters, "VectorIndexRetriever was never constructed"
|
||||
filt = captured_filters[0]
|
||||
assert filt is not None, "Retriever must receive a MetadataFilters"
|
||||
filter_values = filt.filters[0].value
|
||||
assert str(included.pk) in filter_values
|
||||
assert str(excluded.pk) not in filter_values
|
||||
|
||||
def test_unrestricted_chat_excludes_nothing_when_no_documents_are_trashed(
|
||||
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, nothing trashed
|
||||
WHEN:
|
||||
- stream_chat_with_documents is called with unrestricted=True
|
||||
THEN:
|
||||
- The retriever receives a NOT IN filter excluding zero ids, so
|
||||
the whole index is effectively searched -- and no IN-list is
|
||||
built from the full permitted set, which is what 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 _retriever_filter_values(captured_filters) == []
|
||||
|
||||
def test_unrestricted_chat_excludes_trashed_documents(
|
||||
self,
|
||||
temp_llm_index_dir: Path,
|
||||
mock_embed_model: pytest_mock.MockType,
|
||||
captured_filters: list[Any],
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Two indexed documents, one of them trashed -- trashed documents
|
||||
stay in the vector index until permanently deleted, since
|
||||
delete_document_from_llm_index is wired to post_delete
|
||||
WHEN:
|
||||
- stream_chat_with_documents is called with unrestricted=True
|
||||
THEN:
|
||||
- The retriever receives a NOT IN filter excluding the trashed
|
||||
document's id, so an unrestricted caller (e.g. a superuser)
|
||||
never has trashed content surfaced in a chat answer
|
||||
"""
|
||||
kept = DocumentFactory.create(content="kept document content")
|
||||
trashed = DocumentFactory.create(content="trashed document content")
|
||||
indexing.llm_index_add_or_update_document(kept)
|
||||
indexing.llm_index_add_or_update_document(trashed)
|
||||
Document.global_objects.filter(pk=trashed.pk).update(
|
||||
deleted_at=timezone.now(),
|
||||
)
|
||||
|
||||
list(
|
||||
chat.stream_chat_with_documents(
|
||||
"question?",
|
||||
Document.objects.filter(pk=kept.pk),
|
||||
unrestricted=True,
|
||||
),
|
||||
)
|
||||
|
||||
filter_values = _retriever_filter_values(captured_filters)
|
||||
assert str(trashed.pk) in filter_values
|
||||
assert str(kept.pk) not in filter_values
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_document_references_only_queries_referenced_documents(
|
||||
self,
|
||||
|
||||
@@ -146,8 +146,6 @@ def test_run_llm_query_ollama_uses_structured_json(mock_ai_config, mock_ollama_l
|
||||
format=ANY,
|
||||
think=False,
|
||||
)
|
||||
messages = mock_llm_instance.chat.call_args.args[0]
|
||||
assert messages[0].content == "test_prompt"
|
||||
|
||||
|
||||
def test_run_llm_query_openai_uses_tools(mock_ai_config, mock_openai_llm):
|
||||
@@ -185,13 +183,6 @@ def test_run_llm_query_openai_uses_tools(mock_ai_config, mock_openai_llm):
|
||||
assert result["title"] == "Test Title"
|
||||
assert result["tags"] == {"existing_ids": [1], "new_names": []}
|
||||
mock_llm_instance.chat_with_tools.assert_called_once()
|
||||
kwargs = mock_llm_instance.chat_with_tools.call_args.kwargs
|
||||
offered_tool_name = kwargs["tools"][0].metadata.name
|
||||
assert kwargs["user_msg"].content == (
|
||||
"test_prompt\n\n"
|
||||
f"Answer by calling the {offered_tool_name} tool. "
|
||||
"Do not write the answer as text."
|
||||
)
|
||||
|
||||
|
||||
def test_run_llm_query_openai_timeout_raises_local_error(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import inspect
|
||||
import sqlite3
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
|
||||
@@ -98,18 +97,6 @@ def _ne_filter(document_id: int):
|
||||
)
|
||||
|
||||
|
||||
def _nin_filter(document_ids: list[int]):
|
||||
return MetadataFilters(
|
||||
filters=[
|
||||
MetadataFilter(
|
||||
key="document_id",
|
||||
operator=FilterOperator.NIN,
|
||||
value=document_ids,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class TestCrud:
|
||||
def test_add_then_query_returns_node(self, store) -> None:
|
||||
node = make_node("n1", 1)
|
||||
@@ -293,47 +280,6 @@ class TestBuildWhere:
|
||||
"b1",
|
||||
]
|
||||
|
||||
def test_nin_filter_translates_to_not_in_clause(self) -> None:
|
||||
where, params = _build_where(_nin_filter([1, 2]))
|
||||
assert where == "(document_id NOT IN (?,?))"
|
||||
assert params == [1, 2]
|
||||
|
||||
def test_query_with_nin_filter_excludes_matching_documents(self, store) -> None:
|
||||
store.add([make_node("a1", 1), make_node("b1", 2), make_node("c1", 3)])
|
||||
assert sorted(
|
||||
_query(store, [0.0] * DIM, top_k=5, filters=_nin_filter([1, 2])).ids,
|
||||
) == ["c1"]
|
||||
|
||||
def test_empty_in_filter_excludes_everything(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- An IN filter with an empty value list
|
||||
WHEN:
|
||||
- _build_where() translates it to SQL
|
||||
THEN:
|
||||
- It excludes everything (the opposite of an empty NOT IN
|
||||
filter) -- an empty inclusion list must never widen results
|
||||
"""
|
||||
where, params = _build_where(_in_filter([]))
|
||||
assert where == "(1 = 0)"
|
||||
assert params == []
|
||||
|
||||
def test_empty_nin_filter_excludes_nothing(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A NOT IN filter with an empty value list -- e.g. an
|
||||
unrestricted chat caller when nothing is currently trashed
|
||||
WHEN:
|
||||
- _build_where() translates it to SQL
|
||||
THEN:
|
||||
- It excludes nothing (unlike an empty IN filter, which
|
||||
excludes everything) -- an empty exclusion list must never
|
||||
narrow results
|
||||
"""
|
||||
where, params = _build_where(_nin_filter([]))
|
||||
assert where == "(1 = 1)"
|
||||
assert params == []
|
||||
|
||||
def test_fails_closed_when_no_filter_is_translatable(self) -> None:
|
||||
# A nested MetadataFilters is not a MetadataFilter, so it is skipped.
|
||||
# With no translatable clauses, the function must fail closed rather
|
||||
@@ -351,31 +297,24 @@ class TestBuildWhere:
|
||||
assert where == "1 = 0"
|
||||
assert params == []
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"build_filter",
|
||||
[_in_filter, _nin_filter],
|
||||
ids=["in", "nin"],
|
||||
)
|
||||
def test_fails_closed_when_filter_exceeds_max_values(
|
||||
def test_fails_closed_when_in_filter_exceeds_max_values(
|
||||
self,
|
||||
build_filter: Callable[[list[str]], MetadataFilters],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- An IN or NOT IN filter with more values than _MAX_IN_VALUES
|
||||
(SQLite's own bound-parameter limit is 32766; this guard sits
|
||||
below that with headroom for the query's other bound parameters)
|
||||
- An IN filter with more values than _MAX_IN_VALUES (SQLite's
|
||||
own bound-parameter limit is 32766; this guard sits below
|
||||
that with headroom for the query's other bound parameters)
|
||||
WHEN:
|
||||
- _build_where() translates it to SQL
|
||||
THEN:
|
||||
- It fails closed ("1 = 0", no params) instead of building a
|
||||
clause SQLite would reject, and logs a warning -- this filter
|
||||
scopes document access, so refusing to build it must never
|
||||
widen the scope to "everything" by accident. Failing open on
|
||||
a NOT IN would surface exactly the excluded rows
|
||||
- It fails closed ("1 = 0", no params) instead of building an
|
||||
IN clause SQLite would reject, and logs a warning -- this
|
||||
filter scopes document access, so refusing to build it must
|
||||
never widen the scope to "everything" by accident
|
||||
"""
|
||||
oversized = build_filter([str(i) for i in range(_MAX_IN_VALUES + 1)])
|
||||
oversized = _in_filter([str(i) for i in range(_MAX_IN_VALUES + 1)])
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
where, params = _build_where(oversized)
|
||||
|
||||
@@ -107,13 +107,12 @@ def _vec0_params(rows: list[_Row]) -> list[tuple[str, int, str, bytes]]:
|
||||
|
||||
|
||||
def _build_where(filters: MetadataFilters | None) -> tuple[str, list[int]]:
|
||||
"""Translate the EQ / IN / NIN / NE filters we use into a parameterized
|
||||
SQL clause on vec0 metadata columns. Returns ("", []) when there is
|
||||
nothing 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 strings in places, e.g. indexing.py's MetadataFilter
|
||||
construction) don't have to be individually correct -- vec0 doesn't
|
||||
coerce types itself.
|
||||
"""Translate the EQ / IN / NE filters we use into a parameterized SQL
|
||||
clause on vec0 metadata columns. Returns ("", []) when there is nothing
|
||||
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
|
||||
strings in places, e.g. indexing.py's MetadataFilter construction) don't
|
||||
have to be individually correct -- vec0 doesn't coerce types itself.
|
||||
"""
|
||||
if filters is None or not filters.filters:
|
||||
return "", []
|
||||
@@ -126,25 +125,20 @@ def _build_where(filters: MetadataFilters | None) -> tuple[str, list[int]]:
|
||||
continue
|
||||
if f.key not in _FILTER_COLUMNS: # pragma: no cover - we build the keys
|
||||
raise NotImplementedError(f"Unsupported filter column: {f.key}")
|
||||
if f.operator in (FilterOperator.IN, FilterOperator.NIN):
|
||||
is_in = f.operator == FilterOperator.IN
|
||||
sql_op = "IN" if is_in else "NOT IN"
|
||||
if f.operator == FilterOperator.IN:
|
||||
values = [int(v) for v in f.value] # type: ignore[union-attr]
|
||||
if not values:
|
||||
# An empty IN list matches nothing; an empty NOT IN list
|
||||
# excludes nothing, so it matches everything.
|
||||
clauses.append("1 = 0" if is_in else "1 = 1")
|
||||
if not values: # pragma: no cover
|
||||
clauses.append("1 = 0")
|
||||
continue
|
||||
if len(values) > _MAX_IN_VALUES:
|
||||
# Refuse rather than risk SQLite's own bound-parameter limit
|
||||
# ("too many SQL variables"): a list this large must match no
|
||||
# rows, never widen the scope to "everything" -- true for
|
||||
# NOT IN too, where failing open would surface every
|
||||
# excluded row.
|
||||
# Fail closed (see the empty-clauses case below) rather than
|
||||
# let SQLite raise "too many SQL variables" past its own
|
||||
# limit: this filter scopes document access, so an IN list
|
||||
# too large to safely bind must match no rows, never widen
|
||||
# the scope to "everything" by accident.
|
||||
logger.warning(
|
||||
"Refusing to build a %s filter on %r with %d values "
|
||||
"Refusing to build an IN filter on %r with %d values "
|
||||
"(over the %d-value safety limit); returning no rows.",
|
||||
sql_op,
|
||||
f.key,
|
||||
len(values),
|
||||
_MAX_IN_VALUES,
|
||||
@@ -152,7 +146,7 @@ def _build_where(filters: MetadataFilters | None) -> tuple[str, list[int]]:
|
||||
clauses.append("1 = 0")
|
||||
continue
|
||||
placeholders = ",".join("?" for _ in values)
|
||||
clauses.append(f"{f.key} {sql_op} ({placeholders})")
|
||||
clauses.append(f"{f.key} IN ({placeholders})")
|
||||
params.extend(values)
|
||||
elif f.operator == FilterOperator.EQ:
|
||||
clauses.append(f"{f.key} = ?")
|
||||
@@ -160,7 +154,7 @@ def _build_where(filters: MetadataFilters | None) -> tuple[str, list[int]]:
|
||||
elif f.operator == FilterOperator.NE:
|
||||
clauses.append(f"{f.key} != ?")
|
||||
params.append(int(f.value))
|
||||
else: # pragma: no cover - we only ever build EQ/IN/NIN/NE filters
|
||||
else: # pragma: no cover - we only ever build EQ/IN/NE filters
|
||||
raise NotImplementedError(f"Unsupported filter operator: {f.operator}")
|
||||
if not clauses:
|
||||
# Filters were requested but none could be translated. Fail closed
|
||||
|
||||
@@ -334,24 +334,18 @@ def error_callback(
|
||||
"""
|
||||
A shared task that is called whenever something goes wrong during
|
||||
consumption of a file. See queue_consumption_tasks.
|
||||
|
||||
With CELERY_TASK_ALLOW_ERROR_CB_ON_CHORD_HEADER enabled this runs once per
|
||||
failed header task, not once per chord, so it must be idempotent.
|
||||
"""
|
||||
rule = MailRule.objects.get(pk=rule_id)
|
||||
received = make_aware(message_date) if is_naive(message_date) else message_date
|
||||
|
||||
ProcessedMail.objects.get_or_create(
|
||||
ProcessedMail.objects.create(
|
||||
rule=rule,
|
||||
folder=rule.folder,
|
||||
uid=message_uid,
|
||||
uid_validity=uid_validity,
|
||||
defaults={
|
||||
"subject": message_subject,
|
||||
"received": received,
|
||||
"status": "FAILED",
|
||||
"error": traceback.format_exc(),
|
||||
},
|
||||
subject=message_subject,
|
||||
received=make_aware(message_date) if is_naive(message_date) else message_date,
|
||||
status="FAILED",
|
||||
error=traceback.format_exc(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+3
-3
@@ -708,9 +708,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-selector-parser": {
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz",
|
||||
"integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==",
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
|
||||
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -36,7 +36,6 @@ from paperless_mail.mail import MailAccountHandler
|
||||
from paperless_mail.mail import MailError
|
||||
from paperless_mail.mail import TagMailAction
|
||||
from paperless_mail.mail import apply_mail_action
|
||||
from paperless_mail.mail import error_callback
|
||||
from paperless_mail.mail import get_mailbox
|
||||
from paperless_mail.models import MailAccount
|
||||
from paperless_mail.models import MailRule
|
||||
@@ -2046,44 +2045,6 @@ class TestPostConsumeAction(TestCase):
|
||||
self.assertIn("Test Exception", processed_mail.error)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestErrorCallback:
|
||||
def test_error_callback_is_idempotent_for_same_mail(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A mail rule and a mail that failed to be consumed
|
||||
WHEN:
|
||||
- error_callback is invoked more than once for the same mail, as
|
||||
happens when task_allow_error_cb_on_chord_header fires the
|
||||
errback once per failed header task in a chord
|
||||
THEN:
|
||||
- Only one ProcessedMail row is created for that mail
|
||||
"""
|
||||
rule = MailRuleFactory()
|
||||
message_uid = "12345"
|
||||
|
||||
for _ in range(2):
|
||||
error_callback(
|
||||
None,
|
||||
Exception("Test Exception"),
|
||||
None,
|
||||
rule_id=rule.pk,
|
||||
message_uid=message_uid,
|
||||
message_subject="Test Subject",
|
||||
message_date=timezone.make_aware(
|
||||
timezone.datetime(2023, 1, 1, 12, 0, 0),
|
||||
),
|
||||
)
|
||||
|
||||
processed_mails = ProcessedMail.objects.filter(
|
||||
rule=rule,
|
||||
uid=message_uid,
|
||||
folder=rule.folder,
|
||||
)
|
||||
assert processed_mails.count() == 1
|
||||
assert processed_mails.get().status == "FAILED"
|
||||
|
||||
|
||||
class TestManagementCommand(TestCase):
|
||||
@mock.patch(
|
||||
"paperless_mail.management.commands.mail_fetcher.tasks.process_mail_accounts",
|
||||
|
||||
@@ -4,11 +4,11 @@ requires-python = ">=3.11"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.15' and sys_platform == 'darwin'",
|
||||
"python_full_version >= '3.15' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"python_full_version >= '3.12' and python_full_version < '3.15' and sys_platform == 'darwin'",
|
||||
"python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'linux')",
|
||||
"python_full_version < '3.12' and sys_platform == 'darwin'",
|
||||
"python_full_version < '3.12' and sys_platform == 'linux'",
|
||||
@@ -1116,14 +1116,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "djangorestframework"
|
||||
version = "3.17.2"
|
||||
version = "3.17.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/35/c96055e700fdff25da3a7b7756cfd1d4dc54f38b9bc6d6c5e19e3a0fdc20/djangorestframework-3.17.2.tar.gz", hash = "sha256:89ed713b6dc83e1539f214b7d10808ae19bb8511004beba886225da6d5c9dafa", size = 906683, upload-time = "2026-08-05T07:47:22.5Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ca/d7/c016e69fac19ff8afdc89db9d31d9ae43ae031e4d1993b20aca179b8301a/djangorestframework-3.17.1.tar.gz", hash = "sha256:a6def5f447fe78ff853bff1d47a3c59bf38f5434b031780b351b0c73a62db1a5", size = 905742, upload-time = "2026-03-24T16:58:33.705Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/46/c14108e400b208c394325eb63fbae06c81341b6447fa1a6f9da718b17fe7/djangorestframework-3.17.2-py3-none-any.whl", hash = "sha256:cb0546a7415d5b46c04e0f4fe0a54b2109f4fdd5e83ca773c8c6183a6493d042", size = 899109, upload-time = "2026-08-05T07:47:20.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/e1/2c516bdc83652b1a60c6119366ac2c0607b479ed05cd6093f916ca8928f8/djangorestframework-3.17.1-py3-none-any.whl", hash = "sha256:c3c74dd3e83a5a3efc37b3c18d92bd6f86a6791c7b7d4dff62bb068500e76457", size = 898844, upload-time = "2026-03-24T16:58:31.845Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2718,7 +2718,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "nltk"
|
||||
version = "3.10.3"
|
||||
version = "3.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -2727,9 +2727,9 @@ dependencies = [
|
||||
{ name = "regex" },
|
||||
{ name = "tqdm" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e0/e6/fe51d2bb1a3b446f59c5c8165999a9fee208bc346af90a7cbf7657bc0d75/nltk-3.10.3.tar.gz", hash = "sha256:bb9327a461c3811c2fa4900e03840401f2126adfb30c0072827c433bd2444ea4", size = 5137152, upload-time = "2026-08-12T23:46:37.258Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/02/df4f105b28a7c16b0e41423bc09cf0f1b8a305df4ef0b10ca74a2e4c648c/nltk-3.10.0.tar.gz", hash = "sha256:4fbac1d98203cbcd1b5d94a2877fb822300072d80604a5e7fae49d2c5f84e8c1", size = 3089244, upload-time = "2026-07-08T02:39:13.562Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/6d/ebd2af4640b12168fdf0cb74b6118df2f32a2f62ec7e0c06fbfd80706639/nltk-3.10.3-py3-none-any.whl", hash = "sha256:ff9598a8e20518ee0d557745890cc4435b9578489e2dcbc69c4f81fa060caf7c", size = 1798643, upload-time = "2026-08-12T23:44:13.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/89/a0b0f35e2820d6a99d75ea1c11977ee6d5c9e6658eceb45b0c7620881faa/nltk-3.10.0-py3-none-any.whl", hash = "sha256:54ff84d4916d3ef127e8953bee0023f6a6b320b75d634a19e06ef056d3d244bf", size = 1716144, upload-time = "2026-07-08T02:39:09.753Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5014,10 +5014,10 @@ version = "2.13.0+cpu"
|
||||
source = { registry = "https://download.pytorch.org/whl/cpu" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.15' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'linux')",
|
||||
"python_full_version < '3.12' and sys_platform == 'linux'",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user