mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-02 07:57:15 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
912c6eb52e | ||
|
|
73ef14f37a | ||
|
|
d78754bff1 |
@@ -2088,6 +2088,12 @@ password. All of these options come from their similarly-named [Django settings]
|
|||||||
|
|
||||||
Defaults to "always".
|
Defaults to "always".
|
||||||
|
|
||||||
|
#### [`PAPERLESS_REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS=<bool>`](#PAPERLESS_REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS) {#PAPERLESS_REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS}
|
||||||
|
|
||||||
|
: If set to false, Paperless blocks remote OCR endpoint URLs that resolve to non-public addresses (e.g., localhost, etc).
|
||||||
|
|
||||||
|
Defaults to True.
|
||||||
|
|
||||||
## AI {#ai}
|
## AI {#ai}
|
||||||
|
|
||||||
#### [`PAPERLESS_AI_ENABLED=<bool>`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED}
|
#### [`PAPERLESS_AI_ENABLED=<bool>`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED}
|
||||||
|
|||||||
+140
-140
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,8 @@ export class TrashComponent
|
|||||||
private modalService = inject(NgbModal)
|
private modalService = inject(NgbModal)
|
||||||
private settingsService = inject(SettingsService)
|
private settingsService = inject(SettingsService)
|
||||||
private router = inject(Router)
|
private router = inject(Router)
|
||||||
|
private readonly emptyTrashDelaySetting =
|
||||||
|
this.settingsService.getSignal<number>(SETTINGS_KEYS.EMPTY_TRASH_DELAY)
|
||||||
|
|
||||||
readonly documentsInTrash = signal<Document[]>([])
|
readonly documentsInTrash = signal<Document[]>([])
|
||||||
readonly selectedDocuments = signal<Set<number>>(new Set())
|
readonly selectedDocuments = signal<Set<number>>(new Set())
|
||||||
@@ -200,8 +202,7 @@ export class TrashComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
getDaysRemaining(document: Document): number {
|
getDaysRemaining(document: Document): number {
|
||||||
this.settingsService.trackChanges()
|
const delay = this.emptyTrashDelaySetting()
|
||||||
const delay = this.settingsService.get(SETTINGS_KEYS.EMPTY_TRASH_DELAY)
|
|
||||||
const diff = new Date().getTime() - new Date(document.deleted_at).getTime()
|
const diff = new Date().getTime() - new Date(document.deleted_at).getTime()
|
||||||
const days = Math.ceil(diff / (1000 * 3600 * 24))
|
const days = Math.ceil(diff / (1000 * 3600 * 24))
|
||||||
return delay - days
|
return delay - days
|
||||||
|
|||||||
@@ -193,6 +193,23 @@ describe('AppFrameComponent', () => {
|
|||||||
expect(savedViewSpy).toHaveBeenCalled()
|
expect(savedViewSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should update reinitialized signal-backed settings without manual change detection', async () => {
|
||||||
|
settingsService.initializeSettings().subscribe()
|
||||||
|
httpTestingController
|
||||||
|
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
|
||||||
|
.flush({
|
||||||
|
settings: { app_title: 'Reactive title' },
|
||||||
|
user: {},
|
||||||
|
permissions: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
await fixture.whenStable()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('.brand-title').textContent
|
||||||
|
).toBe('Reactive title')
|
||||||
|
})
|
||||||
|
|
||||||
it('should check for update if enabled', () => {
|
it('should check for update if enabled', () => {
|
||||||
const updateCheckSpy = jest.spyOn(remoteVersionService, 'checkForUpdates')
|
const updateCheckSpy = jest.spyOn(remoteVersionService, 'checkForUpdates')
|
||||||
updateCheckSpy.mockImplementation(() => {
|
updateCheckSpy.mockImplementation(() => {
|
||||||
|
|||||||
@@ -98,6 +98,29 @@ export class AppFrameComponent
|
|||||||
readonly isMenuCollapsed = signal(true)
|
readonly isMenuCollapsed = signal(true)
|
||||||
readonly slimSidebarAnimating = signal(false)
|
readonly slimSidebarAnimating = signal(false)
|
||||||
readonly mobileSearchHidden = signal(false)
|
readonly mobileSearchHidden = signal(false)
|
||||||
|
private readonly versionSetting = this.settingsService.getSignal<string>(
|
||||||
|
SETTINGS_KEYS.VERSION
|
||||||
|
)
|
||||||
|
private readonly appTitleSetting = this.settingsService.getSignal<string>(
|
||||||
|
SETTINGS_KEYS.APP_TITLE
|
||||||
|
)
|
||||||
|
private readonly appLogoSetting = this.settingsService.getSignal<string>(
|
||||||
|
SETTINGS_KEYS.APP_LOGO
|
||||||
|
)
|
||||||
|
private readonly slimSidebarSetting = this.settingsService.getSignal<boolean>(
|
||||||
|
SETTINGS_KEYS.SLIM_SIDEBAR
|
||||||
|
)
|
||||||
|
private readonly attributesSectionsCollapsedSetting =
|
||||||
|
this.settingsService.getSignal<CollapsibleSection[]>(
|
||||||
|
SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED
|
||||||
|
)
|
||||||
|
private readonly aiEnabledSetting = this.settingsService.getSignal<boolean>(
|
||||||
|
SETTINGS_KEYS.AI_ENABLED
|
||||||
|
)
|
||||||
|
private readonly sidebarViewsShowCountSetting =
|
||||||
|
this.settingsService.getSignal<boolean>(
|
||||||
|
SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT
|
||||||
|
)
|
||||||
private lastScrollY: number = 0
|
private lastScrollY: number = 0
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -191,33 +214,23 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get versionString(): string {
|
get versionString(): string {
|
||||||
this.settingsService.trackChanges()
|
return `${environment.appTitle} v${this.versionSetting()}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
|
||||||
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get appTitle(): string {
|
get appTitle(): string {
|
||||||
this.settingsService.trackChanges()
|
return this.appTitleSetting() || environment.appTitle
|
||||||
return (
|
|
||||||
this.settingsService.get(SETTINGS_KEYS.APP_TITLE) || environment.appTitle
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get customAppTitle(): string {
|
get customAppTitle(): string {
|
||||||
this.settingsService.trackChanges()
|
return this.appTitleSetting()
|
||||||
return this.settingsService.get(SETTINGS_KEYS.APP_TITLE)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get hasCustomBranding(): boolean {
|
get hasCustomBranding(): boolean {
|
||||||
this.settingsService.trackChanges()
|
return !!(this.appTitleSetting()?.length || this.appLogoSetting()?.length)
|
||||||
return !!(
|
|
||||||
this.settingsService.get(SETTINGS_KEYS.APP_TITLE)?.length ||
|
|
||||||
this.settingsService.get(SETTINGS_KEYS.APP_LOGO)?.length
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get customAppLogo(): string {
|
get customAppLogo(): string {
|
||||||
this.settingsService.trackChanges()
|
const logo = this.appLogoSetting()
|
||||||
const logo = this.settingsService.get(SETTINGS_KEYS.APP_LOGO)
|
|
||||||
return logo?.length
|
return logo?.length
|
||||||
? environment.apiBaseUrl.replace(/\/api\/$/, logo)
|
? environment.apiBaseUrl.replace(/\/api\/$/, logo)
|
||||||
: null
|
: null
|
||||||
@@ -262,8 +275,7 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get slimSidebarEnabled(): boolean {
|
get slimSidebarEnabled(): boolean {
|
||||||
this.settingsService.trackChanges()
|
return this.slimSidebarSetting()
|
||||||
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
set slimSidebarEnabled(enabled: boolean) {
|
set slimSidebarEnabled(enabled: boolean) {
|
||||||
@@ -286,10 +298,9 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get attributesSectionsCollapsed(): boolean {
|
get attributesSectionsCollapsed(): boolean {
|
||||||
this.settingsService.trackChanges()
|
return this.attributesSectionsCollapsedSetting()?.includes(
|
||||||
return this.settingsService
|
CollapsibleSection.ATTRIBUTES
|
||||||
.get(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED)
|
)
|
||||||
?.includes(CollapsibleSection.ATTRIBUTES)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
set attributesSectionsCollapsed(collapsed: boolean) {
|
set attributesSectionsCollapsed(collapsed: boolean) {
|
||||||
@@ -312,8 +323,7 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get aiEnabled(): boolean {
|
get aiEnabled(): boolean {
|
||||||
this.settingsService.trackChanges()
|
return this.aiEnabledSetting()
|
||||||
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('window:resize')
|
@HostListener('window:resize')
|
||||||
@@ -480,9 +490,8 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get showSidebarCounts(): boolean {
|
get showSidebarCounts(): boolean {
|
||||||
this.settingsService.trackChanges()
|
|
||||||
return (
|
return (
|
||||||
this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT) &&
|
this.sidebarViewsShowCountSetting() &&
|
||||||
!this.settingsService.organizingSidebarSavedViews()
|
!this.settingsService.organizingSidebarSavedViews()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
private hotkeyService = inject(HotKeyService)
|
private hotkeyService = inject(HotKeyService)
|
||||||
private settingsService = inject(SettingsService)
|
private settingsService = inject(SettingsService)
|
||||||
private locationStrategy = inject(LocationStrategy)
|
private locationStrategy = inject(LocationStrategy)
|
||||||
|
private readonly searchFullTypeSetting =
|
||||||
|
this.settingsService.getSignal<GlobalSearchType>(
|
||||||
|
SETTINGS_KEYS.SEARCH_FULL_TYPE
|
||||||
|
)
|
||||||
|
|
||||||
public DataType = DataType
|
public DataType = DataType
|
||||||
readonly query = signal<string>(null)
|
readonly query = signal<string>(null)
|
||||||
@@ -97,11 +101,7 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
@ViewChildren('secondaryButton') secondaryButtons: QueryList<ElementRef>
|
@ViewChildren('secondaryButton') secondaryButtons: QueryList<ElementRef>
|
||||||
|
|
||||||
get useAdvancedForFullSearch(): boolean {
|
get useAdvancedForFullSearch(): boolean {
|
||||||
this.settingsService.trackChanges()
|
return this.searchFullTypeSetting() === GlobalSearchType.ADVANCED
|
||||||
return (
|
|
||||||
this.settingsService.get(SETTINGS_KEYS.SEARCH_FULL_TYPE) ===
|
|
||||||
GlobalSearchType.ADVANCED
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|||||||
+19
-13
@@ -196,6 +196,16 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function setActionSettings({
|
||||||
|
email = true,
|
||||||
|
remoteOcr = true,
|
||||||
|
ai = true,
|
||||||
|
} = {}) {
|
||||||
|
settingsService.set(SETTINGS_KEYS.EMAIL_ENABLED, email)
|
||||||
|
settingsService.set(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED, remoteOcr)
|
||||||
|
settingsService.set(SETTINGS_KEYS.AI_ENABLED, ai)
|
||||||
|
}
|
||||||
|
|
||||||
it('should support create and edit modes, support adding triggers and actions on new workflow', () => {
|
it('should support create and edit modes, support adding triggers and actions on new workflow', () => {
|
||||||
component.dialogMode.set(EditDialogMode.CREATE)
|
component.dialogMode.set(EditDialogMode.CREATE)
|
||||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||||
@@ -218,7 +228,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should return source options, type options, type name, schedule date field options', () => {
|
it('should return source options, type options, type name, schedule date field options', () => {
|
||||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
setActionSettings()
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS)
|
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS)
|
||||||
expect(component.triggerTypeOptions).toEqual(WORKFLOW_TYPE_OPTIONS)
|
expect(component.triggerTypeOptions).toEqual(WORKFLOW_TYPE_OPTIONS)
|
||||||
@@ -242,7 +252,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Email, remote OCR and AI all disabled
|
// Email, remote OCR and AI all disabled
|
||||||
jest.spyOn(settingsService, 'get').mockReturnValue(false)
|
setActionSettings({ email: false, remoteOcr: false, ai: false })
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
expect(component.actionTypeOptions).toEqual(
|
expect(component.actionTypeOptions).toEqual(
|
||||||
WORKFLOW_ACTION_OPTIONS.filter(
|
WORKFLOW_ACTION_OPTIONS.filter(
|
||||||
@@ -255,7 +265,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should offer remote OCR only for consumption workflows', () => {
|
it('should offer remote OCR only for consumption workflows', () => {
|
||||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
setActionSettings()
|
||||||
|
|
||||||
// A consumption trigger makes the action reachable
|
// A consumption trigger makes the action reachable
|
||||||
component.object = {
|
component.object = {
|
||||||
@@ -285,7 +295,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should offer remote OCR on a trigger added to a new workflow', () => {
|
it('should offer remote OCR on a trigger added to a new workflow', () => {
|
||||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
setActionSettings()
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
|
|
||||||
// Nothing for the action to apply to yet
|
// Nothing for the action to apply to yet
|
||||||
@@ -311,7 +321,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should keep remote OCR listed when an action already uses it', () => {
|
it('should keep remote OCR listed when an action already uses it', () => {
|
||||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
setActionSettings()
|
||||||
|
|
||||||
// Otherwise changing the trigger would silently blank the selection
|
// Otherwise changing the trigger would silently blank the selection
|
||||||
component.object = {
|
component.object = {
|
||||||
@@ -329,9 +339,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should not offer remote OCR when no engine is configured', () => {
|
it('should not offer remote OCR when no engine is configured', () => {
|
||||||
jest
|
setActionSettings({ remoteOcr: false })
|
||||||
.spyOn(settingsService, 'get')
|
|
||||||
.mockImplementation((key) => key !== SETTINGS_KEYS.REMOTE_OCR_CONFIGURED)
|
|
||||||
|
|
||||||
component.object = {
|
component.object = {
|
||||||
name: 'Workflow 1',
|
name: 'Workflow 1',
|
||||||
@@ -348,7 +356,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should offer apply AI suggestions unless every trigger is consumption', () => {
|
it('should offer apply AI suggestions unless every trigger is consumption', () => {
|
||||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
setActionSettings()
|
||||||
|
|
||||||
// Consumption runs before the document has been parsed, so there would be
|
// Consumption runs before the document has been parsed, so there would be
|
||||||
// no content to make suggestions from
|
// no content to make suggestions from
|
||||||
@@ -382,7 +390,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should keep apply AI suggestions listed when an action already uses it', () => {
|
it('should keep apply AI suggestions listed when an action already uses it', () => {
|
||||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
setActionSettings()
|
||||||
|
|
||||||
// Otherwise changing the trigger would silently blank the selection
|
// Otherwise changing the trigger would silently blank the selection
|
||||||
component.object = {
|
component.object = {
|
||||||
@@ -400,9 +408,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should not offer apply AI suggestions when AI is disabled', () => {
|
it('should not offer apply AI suggestions when AI is disabled', () => {
|
||||||
jest
|
setActionSettings({ ai: false })
|
||||||
.spyOn(settingsService, 'get')
|
|
||||||
.mockImplementation((key) => key !== SETTINGS_KEYS.AI_ENABLED)
|
|
||||||
|
|
||||||
component.object = {
|
component.object = {
|
||||||
name: 'Workflow 1',
|
name: 'Workflow 1',
|
||||||
|
|||||||
+10
-4
@@ -537,6 +537,13 @@ export class WorkflowEditDialogComponent
|
|||||||
readonly dateCustomFields = computed(() =>
|
readonly dateCustomFields = computed(() =>
|
||||||
this.customFields()?.filter((f) => f.data_type === CustomFieldDataType.Date)
|
this.customFields()?.filter((f) => f.data_type === CustomFieldDataType.Date)
|
||||||
)
|
)
|
||||||
|
private readonly emailEnabledSetting =
|
||||||
|
this.settingsService.getSignal<boolean>(SETTINGS_KEYS.EMAIL_ENABLED)
|
||||||
|
private readonly remoteOcrConfiguredSetting =
|
||||||
|
this.settingsService.getSignal<boolean>(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED)
|
||||||
|
private readonly aiEnabledSetting = this.settingsService.getSignal<boolean>(
|
||||||
|
SETTINGS_KEYS.AI_ENABLED
|
||||||
|
)
|
||||||
|
|
||||||
expandedItem: number = null
|
expandedItem: number = null
|
||||||
|
|
||||||
@@ -589,7 +596,7 @@ export class WorkflowEditDialogComponent
|
|||||||
private getAllowedActionTypes() {
|
private getAllowedActionTypes() {
|
||||||
let allowed = WORKFLOW_ACTION_OPTIONS
|
let allowed = WORKFLOW_ACTION_OPTIONS
|
||||||
|
|
||||||
if (!this.settingsService.get(SETTINGS_KEYS.EMAIL_ENABLED)) {
|
if (!this.emailEnabledSetting()) {
|
||||||
allowed = allowed.filter((a) => a.id !== WorkflowActionType.Email)
|
allowed = allowed.filter((a) => a.id !== WorkflowActionType.Email)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -597,7 +604,7 @@ export class WorkflowEditDialogComponent
|
|||||||
// offered for workflows that run at consumption.
|
// offered for workflows that run at consumption.
|
||||||
const formWorkflow: Workflow = this.objectForm?.value
|
const formWorkflow: Workflow = this.objectForm?.value
|
||||||
const remoteOcrUsable =
|
const remoteOcrUsable =
|
||||||
this.settingsService.get(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED) &&
|
this.remoteOcrConfiguredSetting() &&
|
||||||
(formWorkflow?.triggers?.some(
|
(formWorkflow?.triggers?.some(
|
||||||
(trigger) => trigger.type === WorkflowTriggerType.Consumption
|
(trigger) => trigger.type === WorkflowTriggerType.Consumption
|
||||||
) ||
|
) ||
|
||||||
@@ -612,7 +619,7 @@ export class WorkflowEditDialogComponent
|
|||||||
// once every trigger is consumption, so it stays offered on a workflow
|
// once every trigger is consumption, so it stays offered on a workflow
|
||||||
// that has no triggers yet.
|
// that has no triggers yet.
|
||||||
const aiSuggestionsUsable =
|
const aiSuggestionsUsable =
|
||||||
this.settingsService.get(SETTINGS_KEYS.AI_ENABLED) &&
|
this.aiEnabledSetting() &&
|
||||||
(!formWorkflow?.triggers?.length ||
|
(!formWorkflow?.triggers?.length ||
|
||||||
formWorkflow.triggers.some(
|
formWorkflow.triggers.some(
|
||||||
(trigger) => trigger.type !== WorkflowTriggerType.Consumption
|
(trigger) => trigger.type !== WorkflowTriggerType.Consumption
|
||||||
@@ -1362,7 +1369,6 @@ export class WorkflowEditDialogComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get actionTypeOptions() {
|
get actionTypeOptions() {
|
||||||
this.settingsService.trackChanges()
|
|
||||||
// Computed on read rather than cached
|
// Computed on read rather than cached
|
||||||
return this.getAllowedActionTypes()
|
return this.getAllowedActionTypes()
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-4
@@ -839,7 +839,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
selectionModel.items = [memoRoot]
|
selectionModel.items = [memoRoot]
|
||||||
selectionModel.documentCounts = [{ id: memoRoot.id, document_count: 9 }]
|
selectionModel.documentCounts = [{ id: memoRoot.id, document_count: 9 }]
|
||||||
|
|
||||||
const getRootDocCount = (selectionModel as any).createRootDocCounter()
|
const getRootDocCount = (selectionModel as any).createRootDocCounter(
|
||||||
|
selectionModel.items
|
||||||
|
)
|
||||||
|
|
||||||
expect(getRootDocCount(memoRoot.id)).toEqual(9)
|
expect(getRootDocCount(memoRoot.id)).toEqual(9)
|
||||||
selectionModel.documentCounts = []
|
selectionModel.documentCounts = []
|
||||||
@@ -855,7 +857,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
selectionModel.items = [rootWithoutSelection]
|
selectionModel.items = [rootWithoutSelection]
|
||||||
selectionModel.documentCounts = []
|
selectionModel.documentCounts = []
|
||||||
|
|
||||||
const getRootDocCount = (selectionModel as any).createRootDocCounter()
|
const getRootDocCount = (selectionModel as any).createRootDocCounter(
|
||||||
|
selectionModel.items
|
||||||
|
)
|
||||||
|
|
||||||
expect(getRootDocCount(rootWithoutSelection.id)).toEqual(4)
|
expect(getRootDocCount(rootWithoutSelection.id)).toEqual(4)
|
||||||
})
|
})
|
||||||
@@ -865,7 +869,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
selectionModel.items = [rootWithoutCounts]
|
selectionModel.items = [rootWithoutCounts]
|
||||||
selectionModel.documentCounts = []
|
selectionModel.documentCounts = []
|
||||||
|
|
||||||
const getRootDocCount = (selectionModel as any).createRootDocCounter()
|
const getRootDocCount = (selectionModel as any).createRootDocCounter(
|
||||||
|
selectionModel.items
|
||||||
|
)
|
||||||
|
|
||||||
expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0)
|
expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0)
|
||||||
})
|
})
|
||||||
@@ -966,7 +972,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
component.selectionModel['temporarySelectionStates'].set(id, state)
|
component.selectionModel['temporarySelectionStates'].set(id, state)
|
||||||
const changedSpy = jest.spyOn(component.selectionModel.changed, 'next')
|
const changedSpy = jest.spyOn(component.selectionModel.changed, 'next')
|
||||||
component.selectionModel.exclude(id)
|
component.selectionModel.exclude(id)
|
||||||
expect(component.selectionModel.temporaryLogicalOperator).toBe(
|
expect(component.selectionModel.temporaryLogicalOperator()).toBe(
|
||||||
LogicalOperator.And
|
LogicalOperator.And
|
||||||
)
|
)
|
||||||
expect(component.selectionModel['temporarySelectionStates'].get(id)).toBe(
|
expect(component.selectionModel['temporarySelectionStates'].get(id)).toBe(
|
||||||
|
|||||||
+125
-104
@@ -64,43 +64,56 @@ export class FilterableDropdownSelectionModel {
|
|||||||
|
|
||||||
manyToOne = false
|
manyToOne = false
|
||||||
singleSelect = false
|
singleSelect = false
|
||||||
private _logicalOperator: LogicalOperator = LogicalOperator.And
|
|
||||||
temporaryLogicalOperator: LogicalOperator = this._logicalOperator
|
|
||||||
private _intersection: Intersection = Intersection.Include
|
|
||||||
temporaryIntersection: Intersection = this._intersection
|
|
||||||
|
|
||||||
private _documentCounts: SelectionDataItem[] = []
|
private readonly _logicalOperator = signal(LogicalOperator.And)
|
||||||
|
readonly temporaryLogicalOperator = signal(LogicalOperator.And)
|
||||||
|
private readonly _intersection = signal(Intersection.Include)
|
||||||
|
readonly temporaryIntersection = signal(Intersection.Include)
|
||||||
|
private readonly _documentCounts = signal<SelectionDataItem[]>([])
|
||||||
|
private readonly _items = signal<MatchingModel[]>([])
|
||||||
|
private readonly _selectionStates = signal(
|
||||||
|
new Map<number, ToggleableItemState>()
|
||||||
|
)
|
||||||
|
private readonly _temporarySelectionStates = signal(
|
||||||
|
new Map<number, ToggleableItemState>()
|
||||||
|
)
|
||||||
|
|
||||||
public documentCountSortingEnabled = false
|
public documentCountSortingEnabled = false
|
||||||
|
|
||||||
|
private get selectionStates(): ReadonlyMap<number, ToggleableItemState> {
|
||||||
|
return this._selectionStates()
|
||||||
|
}
|
||||||
|
|
||||||
|
private get temporarySelectionStates(): ReadonlyMap<
|
||||||
|
number,
|
||||||
|
ToggleableItemState
|
||||||
|
> {
|
||||||
|
return this._temporarySelectionStates()
|
||||||
|
}
|
||||||
|
|
||||||
public set documentCounts(counts: SelectionDataItem[]) {
|
public set documentCounts(counts: SelectionDataItem[]) {
|
||||||
this._documentCounts = counts
|
this._documentCounts.set(counts)
|
||||||
if (this.documentCountSortingEnabled) {
|
if (this.documentCountSortingEnabled) {
|
||||||
this.sortItems()
|
this._items.set(this.sortItems(this.items))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _items: MatchingModel[] = []
|
|
||||||
get items(): MatchingModel[] {
|
get items(): MatchingModel[] {
|
||||||
return this._items
|
return this._items()
|
||||||
}
|
}
|
||||||
|
|
||||||
set items(items: MatchingModel[]) {
|
set items(items: MatchingModel[]) {
|
||||||
if (items) {
|
if (items) {
|
||||||
this._items = Array.from(items)
|
this._items.set(this.withNullItem(this.sortItems(Array.from(items))))
|
||||||
this.sortItems()
|
|
||||||
this.setNullItem()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private setNullItem() {
|
private withNullItem(items: MatchingModel[]): MatchingModel[] {
|
||||||
if (this.manyToOne && this.logicalOperator === LogicalOperator.Or) {
|
if (this.manyToOne && this.logicalOperator === LogicalOperator.Or) {
|
||||||
if (this._items[0]?.id === null) {
|
return items[0]?.id === null ? items.slice(1) : items
|
||||||
this._items.shift()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const item = {
|
const nullItem = {
|
||||||
name: $localize`:Filter drop down element to filter for documents with no correspondent/type/tag assigned:Not assigned`,
|
name: $localize`:Filter drop down element to filter for documents with no correspondent/type/tag assigned:Not assigned`,
|
||||||
id:
|
id:
|
||||||
this.manyToOne || this.intersection === Intersection.Include
|
this.manyToOne || this.intersection === Intersection.Include
|
||||||
@@ -108,22 +121,17 @@ export class FilterableDropdownSelectionModel {
|
|||||||
: NEGATIVE_NULL_FILTER_VALUE,
|
: NEGATIVE_NULL_FILTER_VALUE,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
return items[0]?.id === null || items[0]?.id === NEGATIVE_NULL_FILTER_VALUE
|
||||||
this._items[0]?.id === null ||
|
? [nullItem, ...items.slice(1)]
|
||||||
this._items[0]?.id === NEGATIVE_NULL_FILTER_VALUE
|
: [nullItem, ...items]
|
||||||
) {
|
|
||||||
this._items[0] = item
|
|
||||||
} else if (this._items) {
|
|
||||||
this._items.unshift(item)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(manyToOne: boolean = false) {
|
constructor(manyToOne: boolean = false) {
|
||||||
this.manyToOne = manyToOne
|
this.manyToOne = manyToOne
|
||||||
}
|
}
|
||||||
|
|
||||||
private sortItems() {
|
private sortItems(items: MatchingModel[]): MatchingModel[] {
|
||||||
this._items.sort((a, b) => {
|
const sorted = [...items].sort((a, b) => {
|
||||||
if (
|
if (
|
||||||
(a.id == null && b.id != null) ||
|
(a.id == null && b.id != null) ||
|
||||||
(a.id == NEGATIVE_NULL_FILTER_VALUE &&
|
(a.id == NEGATIVE_NULL_FILTER_VALUE &&
|
||||||
@@ -154,13 +162,13 @@ export class FilterableDropdownSelectionModel {
|
|||||||
) {
|
) {
|
||||||
return -1
|
return -1
|
||||||
} else if (
|
} else if (
|
||||||
this._documentCounts.length &&
|
this._documentCounts().length &&
|
||||||
this.getDocumentCount(b.id) === 0 &&
|
this.getDocumentCount(b.id) === 0 &&
|
||||||
this.getDocumentCount(a.id) > this.getDocumentCount(b.id)
|
this.getDocumentCount(a.id) > this.getDocumentCount(b.id)
|
||||||
) {
|
) {
|
||||||
return -1
|
return -1
|
||||||
} else if (
|
} else if (
|
||||||
this._documentCounts.length &&
|
this._documentCounts().length &&
|
||||||
this.getDocumentCount(a.id) === 0 &&
|
this.getDocumentCount(a.id) === 0 &&
|
||||||
this.getDocumentCount(a.id) < this.getDocumentCount(b.id)
|
this.getDocumentCount(a.id) < this.getDocumentCount(b.id)
|
||||||
) {
|
) {
|
||||||
@@ -170,14 +178,10 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (this._documentCounts.length) {
|
return this._documentCounts().length
|
||||||
this.promoteBranchesWithDocumentCounts()
|
? this.promoteBranchesWithDocumentCounts(sorted)
|
||||||
|
: sorted
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private selectionStates = new Map<number, ToggleableItemState>()
|
|
||||||
|
|
||||||
private temporarySelectionStates = new Map<number, ToggleableItemState>()
|
|
||||||
|
|
||||||
getSelectedItems() {
|
getSelectedItems() {
|
||||||
return this.items.filter(
|
return this.items.filter(
|
||||||
@@ -194,30 +198,33 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
set(id: number, state: ToggleableItemState, fireEvent = true) {
|
set(id: number, state: ToggleableItemState, fireEvent = true) {
|
||||||
|
const states = new Map(this.temporarySelectionStates)
|
||||||
if (state == ToggleableItemState.NotSelected) {
|
if (state == ToggleableItemState.NotSelected) {
|
||||||
this.temporarySelectionStates.delete(id)
|
states.delete(id)
|
||||||
} else {
|
} else {
|
||||||
this.temporarySelectionStates.set(id, state)
|
states.set(id, state)
|
||||||
}
|
}
|
||||||
|
this._temporarySelectionStates.set(states)
|
||||||
if (fireEvent) {
|
if (fireEvent) {
|
||||||
this.changed.next(this)
|
this.changed.next(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggle(id: number, fireEvent = true) {
|
toggle(id: number, fireEvent = true) {
|
||||||
let state = this.temporarySelectionStates.get(id)
|
const states = new Map(this.temporarySelectionStates)
|
||||||
|
let state = states.get(id)
|
||||||
if (
|
if (
|
||||||
state == undefined ||
|
state == undefined ||
|
||||||
(state != ToggleableItemState.Selected &&
|
(state != ToggleableItemState.Selected &&
|
||||||
state != ToggleableItemState.Excluded)
|
state != ToggleableItemState.Excluded)
|
||||||
) {
|
) {
|
||||||
if (this.manyToOne || this.singleSelect) {
|
if (this.manyToOne || this.singleSelect) {
|
||||||
this.temporarySelectionStates.set(id, ToggleableItemState.Selected)
|
states.set(id, ToggleableItemState.Selected)
|
||||||
|
|
||||||
if (this.singleSelect) {
|
if (this.singleSelect) {
|
||||||
for (let key of this.temporarySelectionStates.keys()) {
|
for (let key of states.keys()) {
|
||||||
if (key != id) {
|
if (key != id) {
|
||||||
this.temporarySelectionStates.delete(key)
|
states.delete(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,25 +240,26 @@ export class FilterableDropdownSelectionModel {
|
|||||||
) {
|
) {
|
||||||
newState = ToggleableItemState.NotSelected
|
newState = ToggleableItemState.NotSelected
|
||||||
}
|
}
|
||||||
this.temporarySelectionStates.set(id, newState)
|
states.set(id, newState)
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
state == ToggleableItemState.Selected ||
|
state == ToggleableItemState.Selected ||
|
||||||
state == ToggleableItemState.Excluded
|
state == ToggleableItemState.Excluded
|
||||||
) {
|
) {
|
||||||
this.temporarySelectionStates.delete(id)
|
states.delete(id)
|
||||||
this.clearDescendantSelections(id)
|
this.clearDescendantSelections(states, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
for (let key of this.temporarySelectionStates.keys()) {
|
for (let key of states.keys()) {
|
||||||
if (key) {
|
if (key) {
|
||||||
this.temporarySelectionStates.delete(key)
|
states.delete(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.temporarySelectionStates.delete(null)
|
states.delete(null)
|
||||||
}
|
}
|
||||||
|
this._temporarySelectionStates.set(states)
|
||||||
|
|
||||||
if (fireEvent) {
|
if (fireEvent) {
|
||||||
this.changed.next(this)
|
this.changed.next(this)
|
||||||
@@ -259,20 +267,21 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
exclude(id: number, fireEvent: boolean = true) {
|
exclude(id: number, fireEvent: boolean = true) {
|
||||||
let state = this.temporarySelectionStates.get(id)
|
const states = new Map(this.temporarySelectionStates)
|
||||||
|
let state = states.get(id)
|
||||||
if (id && (state == null || state != ToggleableItemState.Excluded)) {
|
if (id && (state == null || state != ToggleableItemState.Excluded)) {
|
||||||
this.temporaryLogicalOperator = this._logicalOperator = this.manyToOne
|
const operator = this.manyToOne ? LogicalOperator.And : LogicalOperator.Or
|
||||||
? LogicalOperator.And
|
this.temporaryLogicalOperator.set(operator)
|
||||||
: LogicalOperator.Or
|
this._logicalOperator.set(operator)
|
||||||
|
|
||||||
if (this.manyToOne || this.singleSelect) {
|
if (this.manyToOne || this.singleSelect) {
|
||||||
this.temporarySelectionStates.set(id, ToggleableItemState.Excluded)
|
states.set(id, ToggleableItemState.Excluded)
|
||||||
this.clearDescendantSelections(id)
|
this.clearDescendantSelections(states, id)
|
||||||
|
|
||||||
if (this.singleSelect) {
|
if (this.singleSelect) {
|
||||||
for (let key of this.temporarySelectionStates.keys()) {
|
for (let key of states.keys()) {
|
||||||
if (key != id) {
|
if (key != id) {
|
||||||
this.temporarySelectionStates.delete(key)
|
states.delete(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -287,17 +296,18 @@ export class FilterableDropdownSelectionModel {
|
|||||||
) {
|
) {
|
||||||
newState = ToggleableItemState.NotSelected
|
newState = ToggleableItemState.NotSelected
|
||||||
}
|
}
|
||||||
this.temporarySelectionStates.set(id, newState)
|
states.set(id, newState)
|
||||||
if (newState == ToggleableItemState.Excluded) {
|
if (newState == ToggleableItemState.Excluded) {
|
||||||
this.clearDescendantSelections(id)
|
this.clearDescendantSelections(states, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (!id || state == ToggleableItemState.Excluded) {
|
} else if (!id || state == ToggleableItemState.Excluded) {
|
||||||
this.temporarySelectionStates.delete(id)
|
states.delete(id)
|
||||||
if (id) {
|
if (id) {
|
||||||
this.clearDescendantSelections(id)
|
this.clearDescendantSelections(states, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this._temporarySelectionStates.set(states)
|
||||||
|
|
||||||
if (fireEvent) {
|
if (fireEvent) {
|
||||||
this.changed.next(this)
|
this.changed.next(this)
|
||||||
@@ -308,9 +318,12 @@ export class FilterableDropdownSelectionModel {
|
|||||||
return this.selectionStates.get(id) || ToggleableItemState.NotSelected
|
return this.selectionStates.get(id) || ToggleableItemState.NotSelected
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearDescendantSelections(id: number) {
|
private clearDescendantSelections(
|
||||||
|
states: Map<number, ToggleableItemState>,
|
||||||
|
id: number
|
||||||
|
) {
|
||||||
for (const descendantID of this.getDescendantIDs(id)) {
|
for (const descendantID of this.getDescendantIDs(id)) {
|
||||||
this.temporarySelectionStates.delete(descendantID)
|
states.delete(descendantID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,7 +333,7 @@ export class FilterableDropdownSelectionModel {
|
|||||||
|
|
||||||
while (queue.length) {
|
while (queue.length) {
|
||||||
const parentID = queue.shift()
|
const parentID = queue.shift()
|
||||||
for (const item of this._items) {
|
for (const item of this.items) {
|
||||||
if (
|
if (
|
||||||
typeof item?.id === 'number' &&
|
typeof item?.id === 'number' &&
|
||||||
typeof (item as any)['parent'] === 'number' &&
|
typeof (item as any)['parent'] === 'number' &&
|
||||||
@@ -336,12 +349,12 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get logicalOperator(): LogicalOperator {
|
get logicalOperator(): LogicalOperator {
|
||||||
return this.temporaryLogicalOperator
|
return this.temporaryLogicalOperator()
|
||||||
}
|
}
|
||||||
|
|
||||||
set logicalOperator(operator: LogicalOperator) {
|
set logicalOperator(operator: LogicalOperator) {
|
||||||
this.temporaryLogicalOperator = operator
|
this.temporaryLogicalOperator.set(operator)
|
||||||
this.setNullItem()
|
this._items.set(this.withNullItem(this.items))
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleOperator() {
|
toggleOperator() {
|
||||||
@@ -349,12 +362,12 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get intersection(): Intersection {
|
get intersection(): Intersection {
|
||||||
return this.temporaryIntersection
|
return this.temporaryIntersection()
|
||||||
}
|
}
|
||||||
|
|
||||||
set intersection(intersection: Intersection) {
|
set intersection(intersection: Intersection) {
|
||||||
this.temporaryIntersection = intersection
|
this.temporaryIntersection.set(intersection)
|
||||||
this.setNullItem()
|
this._items.set(this.withNullItem(this.items))
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleIntersection() {
|
toggleIntersection() {
|
||||||
@@ -364,18 +377,20 @@ export class FilterableDropdownSelectionModel {
|
|||||||
? ToggleableItemState.Selected
|
? ToggleableItemState.Selected
|
||||||
: ToggleableItemState.Excluded
|
: ToggleableItemState.Excluded
|
||||||
|
|
||||||
this.temporarySelectionStates.forEach((state, key) => {
|
const states = new Map(this.temporarySelectionStates)
|
||||||
|
states.forEach((state, key) => {
|
||||||
if (key === null && this.intersection === Intersection.Exclude) {
|
if (key === null && this.intersection === Intersection.Exclude) {
|
||||||
this.temporarySelectionStates.set(NEGATIVE_NULL_FILTER_VALUE, newState)
|
states.set(NEGATIVE_NULL_FILTER_VALUE, newState)
|
||||||
} else if (
|
} else if (
|
||||||
key === NEGATIVE_NULL_FILTER_VALUE &&
|
key === NEGATIVE_NULL_FILTER_VALUE &&
|
||||||
this.intersection === Intersection.Include
|
this.intersection === Intersection.Include
|
||||||
) {
|
) {
|
||||||
this.temporarySelectionStates.set(null, newState)
|
states.set(null, newState)
|
||||||
} else {
|
} else {
|
||||||
this.temporarySelectionStates.set(key, newState)
|
states.set(key, newState)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
this._temporarySelectionStates.set(states)
|
||||||
|
|
||||||
this.changed.next(this)
|
this.changed.next(this)
|
||||||
}
|
}
|
||||||
@@ -395,10 +410,12 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clear(fireEvent = true) {
|
clear(fireEvent = true) {
|
||||||
this.temporarySelectionStates.clear()
|
this._temporarySelectionStates.set(new Map())
|
||||||
this.temporaryLogicalOperator = this._logicalOperator = LogicalOperator.And
|
this.temporaryLogicalOperator.set(LogicalOperator.And)
|
||||||
this.temporaryIntersection = this._intersection = Intersection.Include
|
this._logicalOperator.set(LogicalOperator.And)
|
||||||
this.setNullItem()
|
this.temporaryIntersection.set(Intersection.Include)
|
||||||
|
this._intersection.set(Intersection.Include)
|
||||||
|
this._items.set(this.withNullItem(this.items))
|
||||||
if (fireEvent) {
|
if (fireEvent) {
|
||||||
this.changed.next(this)
|
this.changed.next(this)
|
||||||
}
|
}
|
||||||
@@ -419,9 +436,9 @@ export class FilterableDropdownSelectionModel {
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return true
|
return true
|
||||||
} else if (this.temporaryLogicalOperator !== this._logicalOperator) {
|
} else if (this.temporaryLogicalOperator() !== this._logicalOperator()) {
|
||||||
return true
|
return true
|
||||||
} else if (this.temporaryIntersection !== this._intersection) {
|
} else if (this.temporaryIntersection() !== this._intersection()) {
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
return false
|
return false
|
||||||
@@ -438,23 +455,29 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getDocumentCount(id: number) {
|
getDocumentCount(id: number) {
|
||||||
return this._documentCounts.find((c) => c.id === id)?.document_count
|
return this._documentCounts().find((c) => c.id === id)?.document_count
|
||||||
}
|
}
|
||||||
|
|
||||||
private promoteBranchesWithDocumentCounts() {
|
private promoteBranchesWithDocumentCounts(
|
||||||
const parentById = this.buildParentById()
|
items: MatchingModel[]
|
||||||
|
): MatchingModel[] {
|
||||||
|
const parentById = this.buildParentById(items)
|
||||||
const findRootId = this.createRootFinder(parentById)
|
const findRootId = this.createRootFinder(parentById)
|
||||||
const getRootDocCount = this.createRootDocCounter()
|
const getRootDocCount = this.createRootDocCounter(items)
|
||||||
const summaries = this.buildBranchSummaries(findRootId, getRootDocCount)
|
const summaries = this.buildBranchSummaries(
|
||||||
|
items,
|
||||||
|
findRootId,
|
||||||
|
getRootDocCount
|
||||||
|
)
|
||||||
const orderedBranches = this.orderBranchesByPriority(summaries)
|
const orderedBranches = this.orderBranchesByPriority(summaries)
|
||||||
|
|
||||||
this._items = orderedBranches.flatMap((summary) => summary.items)
|
return orderedBranches.flatMap((summary) => summary.items)
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildParentById(): Map<number, number | null> {
|
private buildParentById(items: MatchingModel[]): Map<number, number | null> {
|
||||||
const parentById = new Map<number, number | null>()
|
const parentById = new Map<number, number | null>()
|
||||||
|
|
||||||
for (const item of this._items) {
|
for (const item of items) {
|
||||||
if (typeof item?.id === 'number') {
|
if (typeof item?.id === 'number') {
|
||||||
const parentValue = (item as any)['parent']
|
const parentValue = (item as any)['parent']
|
||||||
parentById.set(
|
parentById.set(
|
||||||
@@ -492,7 +515,9 @@ export class FilterableDropdownSelectionModel {
|
|||||||
return findRootId
|
return findRootId
|
||||||
}
|
}
|
||||||
|
|
||||||
private createRootDocCounter(): (rootId: number) => number {
|
private createRootDocCounter(
|
||||||
|
items: MatchingModel[]
|
||||||
|
): (rootId: number) => number {
|
||||||
const docCountMemo = new Map<number, number>()
|
const docCountMemo = new Map<number, number>()
|
||||||
|
|
||||||
return (rootId: number): number => {
|
return (rootId: number): number => {
|
||||||
@@ -507,7 +532,7 @@ export class FilterableDropdownSelectionModel {
|
|||||||
return explicit
|
return explicit
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootItem = this._items.find((i) => i.id === rootId)
|
const rootItem = items.find((i) => i.id === rootId)
|
||||||
const fallback =
|
const fallback =
|
||||||
typeof (rootItem as any)?.['document_count'] === 'number'
|
typeof (rootItem as any)?.['document_count'] === 'number'
|
||||||
? (rootItem as any)['document_count']
|
? (rootItem as any)['document_count']
|
||||||
@@ -519,12 +544,13 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildBranchSummaries(
|
private buildBranchSummaries(
|
||||||
|
items: MatchingModel[],
|
||||||
findRootId: (id: number) => number,
|
findRootId: (id: number) => number,
|
||||||
getRootDocCount: (rootId: number) => number
|
getRootDocCount: (rootId: number) => number
|
||||||
): Map<string, BranchSummary> {
|
): Map<string, BranchSummary> {
|
||||||
const summaries = new Map<string, BranchSummary>()
|
const summaries = new Map<string, BranchSummary>()
|
||||||
|
|
||||||
for (const [index, item] of this._items.entries()) {
|
for (const [index, item] of items.entries()) {
|
||||||
const { key, special, rootId } = this.describeBranchItem(
|
const { key, special, rootId } = this.describeBranchItem(
|
||||||
item,
|
item,
|
||||||
index,
|
index,
|
||||||
@@ -616,28 +642,23 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init(map: Map<number, ToggleableItemState>) {
|
init(map: Map<number, ToggleableItemState>) {
|
||||||
this.temporarySelectionStates = map
|
this._temporarySelectionStates.set(new Map(map))
|
||||||
this.apply()
|
this.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
apply() {
|
apply() {
|
||||||
this.selectionStates.clear()
|
this._selectionStates.set(new Map(this.temporarySelectionStates))
|
||||||
this.temporarySelectionStates.forEach((value, key) => {
|
this._logicalOperator.set(this.temporaryLogicalOperator())
|
||||||
this.selectionStates.set(key, value)
|
this._intersection.set(this.temporaryIntersection())
|
||||||
})
|
this._items.set(this.sortItems(this.items))
|
||||||
this._logicalOperator = this.temporaryLogicalOperator
|
|
||||||
this._intersection = this.temporaryIntersection
|
|
||||||
this.sortItems()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
reset(complete: boolean = false) {
|
reset(complete: boolean = false) {
|
||||||
this.temporarySelectionStates.clear()
|
|
||||||
if (complete) {
|
if (complete) {
|
||||||
this.selectionStates.clear()
|
this._selectionStates.set(new Map())
|
||||||
|
this._temporarySelectionStates.set(new Map())
|
||||||
} else {
|
} else {
|
||||||
this.selectionStates.forEach((value, key) => {
|
this._temporarySelectionStates.set(new Map(this.selectionStates))
|
||||||
this.temporarySelectionStates.set(key, value)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-9
@@ -7,7 +7,7 @@
|
|||||||
<div class="list-group list-group-flush">
|
<div class="list-group list-group-flush">
|
||||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NONE)" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NONE)" [disabled]="disabled">
|
||||||
<div class="selected-icon me-1">
|
<div class="selected-icon me-1">
|
||||||
@if (selectionModel.ownerFilter === OwnerFilterType.NONE) {
|
@if (selectionModel.ownerFilter() === OwnerFilterType.NONE) {
|
||||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SELF)" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SELF)" [disabled]="disabled">
|
||||||
<div class="selected-icon me-1">
|
<div class="selected-icon me-1">
|
||||||
@if (selectionModel.ownerFilter === OwnerFilterType.SELF) {
|
@if (selectionModel.ownerFilter() === OwnerFilterType.SELF) {
|
||||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NOT_SELF)" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NOT_SELF)" [disabled]="disabled">
|
||||||
<div class="selected-icon me-1">
|
<div class="selected-icon me-1">
|
||||||
@if (selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) {
|
@if (selectionModel.ownerFilter() === OwnerFilterType.NOT_SELF) {
|
||||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SHARED_BY_ME)" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SHARED_BY_ME)" [disabled]="disabled">
|
||||||
<div class="selected-icon me-1">
|
<div class="selected-icon me-1">
|
||||||
@if (selectionModel.ownerFilter === OwnerFilterType.SHARED_BY_ME) {
|
@if (selectionModel.ownerFilter() === OwnerFilterType.SHARED_BY_ME) {
|
||||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.UNOWNED)" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.UNOWNED)" [disabled]="disabled">
|
||||||
<div class="selected-icon me-1">
|
<div class="selected-icon me-1">
|
||||||
@if (selectionModel.ownerFilter === OwnerFilterType.UNOWNED) {
|
@if (selectionModel.ownerFilter() === OwnerFilterType.UNOWNED) {
|
||||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }" class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" [disabled]="disabled">
|
<button *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }" class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" [disabled]="disabled">
|
||||||
<div class="selected-icon me-1">
|
<div class="selected-icon me-1">
|
||||||
@if (selectionModel.ownerFilter === OwnerFilterType.OTHERS) {
|
@if (selectionModel.ownerFilter() === OwnerFilterType.OTHERS) {
|
||||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -65,7 +65,8 @@
|
|||||||
<ng-select
|
<ng-select
|
||||||
name="user"
|
name="user"
|
||||||
class="user-select small"
|
class="user-select small"
|
||||||
[(ngModel)]="selectionModel.includeUsers"
|
[ngModel]="selectionModel.includeUsers()"
|
||||||
|
(ngModelChange)="selectionModel.includeUsers.set($event)"
|
||||||
[disabled]="disabled"
|
[disabled]="disabled"
|
||||||
[clearable]="false"
|
[clearable]="false"
|
||||||
[items]="users()"
|
[items]="users()"
|
||||||
@@ -78,10 +79,10 @@
|
|||||||
</ng-select>
|
</ng-select>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@if (selectionModel.ownerFilter === OwnerFilterType.NONE || selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) {
|
@if (selectionModel.ownerFilter() === OwnerFilterType.NONE || selectionModel.ownerFilter() === OwnerFilterType.NOT_SELF) {
|
||||||
<div class="list-group-item list-group-item-action d-flex align-items-center p-2 ps-3 border-bottom-0 border-start-0 border-end-0">
|
<div class="list-group-item list-group-item-action d-flex align-items-center p-2 ps-3 border-bottom-0 border-start-0 border-end-0">
|
||||||
<div class="form-check form-switch w-100">
|
<div class="form-check form-switch w-100">
|
||||||
<input type="checkbox" class="form-check-input" id="hideUnowned" [(ngModel)]="this.selectionModel.hideUnowned" (change)="onChange()" [disabled]="disabled">
|
<input type="checkbox" class="form-check-input" id="hideUnowned" [ngModel]="selectionModel.hideUnowned()" (ngModelChange)="selectionModel.hideUnowned.set($event)" (change)="onChange()" [disabled]="disabled">
|
||||||
<label class="form-check-label w-100" for="hideUnowned"><small i18n>Hide unowned</small></label>
|
<label class="form-check-label w-100" for="hideUnowned"><small i18n>Hide unowned</small></label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+39
-30
@@ -90,56 +90,56 @@ describe('PermissionsFilterDropdownComponent', () => {
|
|||||||
component.setFilter(OwnerFilterType.OTHERS)
|
component.setFilter(OwnerFilterType.OTHERS)
|
||||||
expect(component.isActive).toBeTruthy()
|
expect(component.isActive).toBeTruthy()
|
||||||
component.setFilter(OwnerFilterType.NONE)
|
component.setFilter(OwnerFilterType.NONE)
|
||||||
component.selectionModel.hideUnowned = true
|
component.selectionModel.hideUnowned.set(true)
|
||||||
expect(component.isActive).toBeTruthy()
|
expect(component.isActive).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should describe concrete user filters honestly', () => {
|
it('should describe concrete user filters honestly', () => {
|
||||||
component.selectionModel.ownerFilter = OwnerFilterType.SELF
|
component.selectionModel.ownerFilter.set(OwnerFilterType.SELF)
|
||||||
component.selectionModel.userID = 1
|
component.selectionModel.userID.set(1)
|
||||||
expect(component.ownerFilterLabel).toEqual('Owned by user1')
|
expect(component.ownerFilterLabel).toEqual('Owned by user1')
|
||||||
|
|
||||||
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
|
component.selectionModel.ownerFilter.set(OwnerFilterType.NOT_SELF)
|
||||||
component.selectionModel.excludeUsers = [1]
|
component.selectionModel.excludeUsers.set([1])
|
||||||
expect(component.ownerExclusionFilterLabel).toEqual('Not owned by user1')
|
expect(component.ownerExclusionFilterLabel).toEqual('Not owned by user1')
|
||||||
|
|
||||||
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME
|
component.selectionModel.ownerFilter.set(OwnerFilterType.SHARED_BY_ME)
|
||||||
component.selectionModel.userID = 1
|
component.selectionModel.userID.set(1)
|
||||||
expect(component.sharedByFilterLabel).toEqual('Shared by user1')
|
expect(component.sharedByFilterLabel).toEqual('Shared by user1')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should describe concrete filters when usernames are unavailable', () => {
|
it('should describe concrete filters when usernames are unavailable', () => {
|
||||||
component.selectionModel.ownerFilter = OwnerFilterType.SELF
|
component.selectionModel.ownerFilter.set(OwnerFilterType.SELF)
|
||||||
component.selectionModel.userID = 99
|
component.selectionModel.userID.set(99)
|
||||||
expect(component.ownerFilterLabel).toEqual('Owned by another user')
|
expect(component.ownerFilterLabel).toEqual('Owned by another user')
|
||||||
|
|
||||||
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
|
component.selectionModel.ownerFilter.set(OwnerFilterType.NOT_SELF)
|
||||||
component.selectionModel.excludeUsers = [99]
|
component.selectionModel.excludeUsers.set([99])
|
||||||
expect(component.ownerExclusionFilterLabel).toEqual(
|
expect(component.ownerExclusionFilterLabel).toEqual(
|
||||||
'Not owned by another user'
|
'Not owned by another user'
|
||||||
)
|
)
|
||||||
|
|
||||||
component.selectionModel.excludeUsers = [98, 99]
|
component.selectionModel.excludeUsers.set([98, 99])
|
||||||
expect(component.ownerExclusionFilterLabel).toEqual(
|
expect(component.ownerExclusionFilterLabel).toEqual(
|
||||||
'Not owned by selected users'
|
'Not owned by selected users'
|
||||||
)
|
)
|
||||||
|
|
||||||
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME
|
component.selectionModel.ownerFilter.set(OwnerFilterType.SHARED_BY_ME)
|
||||||
component.selectionModel.userID = 99
|
component.selectionModel.userID.set(99)
|
||||||
expect(component.sharedByFilterLabel).toEqual('Shared by another user')
|
expect(component.sharedByFilterLabel).toEqual('Shared by another user')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should retain relative labels for filters bound to the current user', () => {
|
it('should retain relative labels for filters bound to the current user', () => {
|
||||||
component.selectionModel.userID = currentUserID
|
component.selectionModel.userID.set(currentUserID)
|
||||||
expect(component.ownerFilterLabel).toEqual('My documents')
|
expect(component.ownerFilterLabel).toEqual('My documents')
|
||||||
expect(component.sharedByFilterLabel).toEqual('Shared by me')
|
expect(component.sharedByFilterLabel).toEqual('Shared by me')
|
||||||
|
|
||||||
component.selectionModel.excludeUsers = [currentUserID]
|
component.selectionModel.excludeUsers.set([currentUserID])
|
||||||
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
|
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should retain relative labels for inactive filter choices', () => {
|
it('should retain relative labels for inactive filter choices', () => {
|
||||||
component.selectionModel.ownerFilter = OwnerFilterType.NONE
|
component.selectionModel.ownerFilter.set(OwnerFilterType.NONE)
|
||||||
|
|
||||||
expect(component.ownerFilterLabel).toEqual('My documents')
|
expect(component.ownerFilterLabel).toEqual('My documents')
|
||||||
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
|
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
|
||||||
@@ -148,32 +148,41 @@ describe('PermissionsFilterDropdownComponent', () => {
|
|||||||
|
|
||||||
it('should support reset', () => {
|
it('should support reset', () => {
|
||||||
component.setFilter(OwnerFilterType.OTHERS)
|
component.setFilter(OwnerFilterType.OTHERS)
|
||||||
expect(component.selectionModel.ownerFilter).not.toEqual(
|
expect(component.selectionModel.ownerFilter()).not.toEqual(
|
||||||
OwnerFilterType.NONE
|
OwnerFilterType.NONE
|
||||||
)
|
)
|
||||||
component.reset()
|
component.reset()
|
||||||
expect(component.selectionModel.ownerFilter).toEqual(OwnerFilterType.NONE)
|
expect(component.selectionModel.ownerFilter()).toEqual(OwnerFilterType.NONE)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should toggle owner filter type when users selected', () => {
|
it('should toggle owner filter type when users selected', () => {
|
||||||
component.selectionModel.ownerFilter = OwnerFilterType.NONE
|
component.selectionModel.ownerFilter.set(OwnerFilterType.NONE)
|
||||||
|
|
||||||
// this would normally be done by select component
|
// this would normally be done by select component
|
||||||
component.selectionModel.includeUsers = [12]
|
component.selectionModel.includeUsers.set([12])
|
||||||
component.onUserSelect()
|
component.onUserSelect()
|
||||||
expect(component.selectionModel.ownerFilter).toEqual(OwnerFilterType.OTHERS)
|
expect(component.selectionModel.ownerFilter()).toEqual(
|
||||||
|
OwnerFilterType.OTHERS
|
||||||
|
)
|
||||||
|
|
||||||
// this would normally be done by select component
|
// this would normally be done by select component
|
||||||
component.selectionModel.includeUsers = null
|
component.selectionModel.includeUsers.set(null)
|
||||||
component.onUserSelect()
|
component.onUserSelect()
|
||||||
|
|
||||||
expect(component.selectionModel.ownerFilter).toEqual(OwnerFilterType.NONE)
|
expect(component.selectionModel.ownerFilter()).toEqual(OwnerFilterType.NONE)
|
||||||
})
|
})
|
||||||
it('should emit a selection model depending on the type of owner filter set', () => {
|
it('should emit a selection model depending on the type of owner filter set', () => {
|
||||||
component.selectionModel.ownerFilter = OwnerFilterType.NONE
|
const emitted = () => ({
|
||||||
|
excludeUsers: ownerFilterSetResult.excludeUsers(),
|
||||||
|
hideUnowned: ownerFilterSetResult.hideUnowned(),
|
||||||
|
includeUsers: ownerFilterSetResult.includeUsers(),
|
||||||
|
ownerFilter: ownerFilterSetResult.ownerFilter(),
|
||||||
|
userID: ownerFilterSetResult.userID(),
|
||||||
|
})
|
||||||
|
component.selectionModel.ownerFilter.set(OwnerFilterType.NONE)
|
||||||
|
|
||||||
component.setFilter(OwnerFilterType.SELF)
|
component.setFilter(OwnerFilterType.SELF)
|
||||||
expect(ownerFilterSetResult).toEqual({
|
expect(emitted()).toEqual({
|
||||||
excludeUsers: [],
|
excludeUsers: [],
|
||||||
hideUnowned: false,
|
hideUnowned: false,
|
||||||
includeUsers: [],
|
includeUsers: [],
|
||||||
@@ -182,7 +191,7 @@ describe('PermissionsFilterDropdownComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
component.setFilter(OwnerFilterType.NOT_SELF)
|
component.setFilter(OwnerFilterType.NOT_SELF)
|
||||||
expect(ownerFilterSetResult).toEqual({
|
expect(emitted()).toEqual({
|
||||||
excludeUsers: [currentUserID],
|
excludeUsers: [currentUserID],
|
||||||
hideUnowned: false,
|
hideUnowned: false,
|
||||||
includeUsers: [],
|
includeUsers: [],
|
||||||
@@ -191,7 +200,7 @@ describe('PermissionsFilterDropdownComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
component.setFilter(OwnerFilterType.NONE)
|
component.setFilter(OwnerFilterType.NONE)
|
||||||
expect(ownerFilterSetResult).toEqual({
|
expect(emitted()).toEqual({
|
||||||
excludeUsers: [],
|
excludeUsers: [],
|
||||||
hideUnowned: false,
|
hideUnowned: false,
|
||||||
includeUsers: [],
|
includeUsers: [],
|
||||||
@@ -200,7 +209,7 @@ describe('PermissionsFilterDropdownComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
component.setFilter(OwnerFilterType.SHARED_BY_ME)
|
component.setFilter(OwnerFilterType.SHARED_BY_ME)
|
||||||
expect(ownerFilterSetResult).toEqual({
|
expect(emitted()).toEqual({
|
||||||
excludeUsers: [],
|
excludeUsers: [],
|
||||||
hideUnowned: false,
|
hideUnowned: false,
|
||||||
includeUsers: [],
|
includeUsers: [],
|
||||||
@@ -209,7 +218,7 @@ describe('PermissionsFilterDropdownComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
component.setFilter(OwnerFilterType.UNOWNED)
|
component.setFilter(OwnerFilterType.UNOWNED)
|
||||||
expect(ownerFilterSetResult).toEqual({
|
expect(emitted()).toEqual({
|
||||||
excludeUsers: [],
|
excludeUsers: [],
|
||||||
hideUnowned: false,
|
hideUnowned: false,
|
||||||
includeUsers: [],
|
includeUsers: [],
|
||||||
|
|||||||
+53
-53
@@ -25,18 +25,18 @@ import { ComponentWithPermissions } from '../../with-permissions/with-permission
|
|||||||
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
|
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
|
||||||
|
|
||||||
export class PermissionsSelectionModel {
|
export class PermissionsSelectionModel {
|
||||||
ownerFilter: OwnerFilterType
|
readonly ownerFilter = signal(OwnerFilterType.NONE)
|
||||||
hideUnowned: boolean
|
readonly hideUnowned = signal(false)
|
||||||
userID: number
|
readonly userID = signal<number>(null)
|
||||||
includeUsers: number[]
|
readonly includeUsers = signal<number[]>([])
|
||||||
excludeUsers: number[]
|
readonly excludeUsers = signal<number[]>([])
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
this.ownerFilter = OwnerFilterType.NONE
|
this.ownerFilter.set(OwnerFilterType.NONE)
|
||||||
this.userID = null
|
this.userID.set(null)
|
||||||
this.hideUnowned = false
|
this.hideUnowned.set(false)
|
||||||
this.includeUsers = []
|
this.includeUsers.set([])
|
||||||
this.excludeUsers = []
|
this.excludeUsers.set([])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,33 +84,31 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
|||||||
|
|
||||||
readonly users = signal<User[]>([])
|
readonly users = signal<User[]>([])
|
||||||
|
|
||||||
hideUnowned: boolean
|
|
||||||
|
|
||||||
get isActive(): boolean {
|
get isActive(): boolean {
|
||||||
return (
|
return (
|
||||||
this.selectionModel.ownerFilter !== OwnerFilterType.NONE ||
|
this.selectionModel.ownerFilter() !== OwnerFilterType.NONE ||
|
||||||
this.selectionModel.hideUnowned
|
this.selectionModel.hideUnowned()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
get ownerFilterLabel(): string {
|
get ownerFilterLabel(): string {
|
||||||
if (
|
if (
|
||||||
this.selectionModel?.ownerFilter !== OwnerFilterType.SELF ||
|
this.selectionModel?.ownerFilter() !== OwnerFilterType.SELF ||
|
||||||
this.selectionModel?.userID === this.settingsService.currentUser()?.id
|
this.selectionModel?.userID() === this.settingsService.currentUser()?.id
|
||||||
) {
|
) {
|
||||||
return $localize`My documents`
|
return $localize`My documents`
|
||||||
}
|
}
|
||||||
|
|
||||||
const username = this.getUsername(this.selectionModel?.userID)
|
const username = this.getUsername(this.selectionModel?.userID())
|
||||||
return username
|
return username
|
||||||
? $localize`Owned by ${username}`
|
? $localize`Owned by ${username}`
|
||||||
: $localize`Owned by another user`
|
: $localize`Owned by another user`
|
||||||
}
|
}
|
||||||
|
|
||||||
get ownerExclusionFilterLabel(): string {
|
get ownerExclusionFilterLabel(): string {
|
||||||
const excludedUsers = this.selectionModel?.excludeUsers ?? []
|
const excludedUsers = this.selectionModel?.excludeUsers() ?? []
|
||||||
if (
|
if (
|
||||||
this.selectionModel?.ownerFilter !== OwnerFilterType.NOT_SELF ||
|
this.selectionModel?.ownerFilter() !== OwnerFilterType.NOT_SELF ||
|
||||||
(excludedUsers.length === 1 &&
|
(excludedUsers.length === 1 &&
|
||||||
excludedUsers[0] === this.settingsService.currentUser()?.id)
|
excludedUsers[0] === this.settingsService.currentUser()?.id)
|
||||||
) {
|
) {
|
||||||
@@ -130,13 +128,13 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
|||||||
|
|
||||||
get sharedByFilterLabel(): string {
|
get sharedByFilterLabel(): string {
|
||||||
if (
|
if (
|
||||||
this.selectionModel?.ownerFilter !== OwnerFilterType.SHARED_BY_ME ||
|
this.selectionModel?.ownerFilter() !== OwnerFilterType.SHARED_BY_ME ||
|
||||||
this.selectionModel?.userID === this.settingsService.currentUser()?.id
|
this.selectionModel?.userID() === this.settingsService.currentUser()?.id
|
||||||
) {
|
) {
|
||||||
return $localize`Shared by me`
|
return $localize`Shared by me`
|
||||||
}
|
}
|
||||||
|
|
||||||
const username = this.getUsername(this.selectionModel?.userID)
|
const username = this.getUsername(this.selectionModel?.userID())
|
||||||
return username
|
return username
|
||||||
? $localize`Shared by ${username}`
|
? $localize`Shared by ${username}`
|
||||||
: $localize`Shared by another user`
|
: $localize`Shared by another user`
|
||||||
@@ -169,34 +167,36 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
|||||||
}
|
}
|
||||||
|
|
||||||
setFilter(type: OwnerFilterType) {
|
setFilter(type: OwnerFilterType) {
|
||||||
this.selectionModel.ownerFilter = type
|
this.selectionModel.ownerFilter.set(type)
|
||||||
if (this.selectionModel.ownerFilter === OwnerFilterType.SELF) {
|
if (this.selectionModel.ownerFilter() === OwnerFilterType.SELF) {
|
||||||
this.selectionModel.includeUsers = []
|
this.selectionModel.includeUsers.set([])
|
||||||
this.selectionModel.excludeUsers = []
|
this.selectionModel.excludeUsers.set([])
|
||||||
this.selectionModel.userID = this.settingsService.currentUser().id
|
this.selectionModel.userID.set(this.settingsService.currentUser().id)
|
||||||
this.selectionModel.hideUnowned = false
|
this.selectionModel.hideUnowned.set(false)
|
||||||
} else if (this.selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) {
|
} else if (this.selectionModel.ownerFilter() === OwnerFilterType.NOT_SELF) {
|
||||||
this.selectionModel.userID = null
|
this.selectionModel.userID.set(null)
|
||||||
this.selectionModel.includeUsers = []
|
this.selectionModel.includeUsers.set([])
|
||||||
this.selectionModel.excludeUsers = [this.settingsService.currentUser().id]
|
this.selectionModel.excludeUsers.set([
|
||||||
this.selectionModel.hideUnowned = false
|
this.settingsService.currentUser().id,
|
||||||
} else if (this.selectionModel.ownerFilter === OwnerFilterType.NONE) {
|
])
|
||||||
this.selectionModel.userID = null
|
this.selectionModel.hideUnowned.set(false)
|
||||||
this.selectionModel.includeUsers = []
|
} else if (this.selectionModel.ownerFilter() === OwnerFilterType.NONE) {
|
||||||
this.selectionModel.excludeUsers = []
|
this.selectionModel.userID.set(null)
|
||||||
this.selectionModel.hideUnowned = false
|
this.selectionModel.includeUsers.set([])
|
||||||
|
this.selectionModel.excludeUsers.set([])
|
||||||
|
this.selectionModel.hideUnowned.set(false)
|
||||||
} else if (
|
} else if (
|
||||||
this.selectionModel.ownerFilter === OwnerFilterType.SHARED_BY_ME
|
this.selectionModel.ownerFilter() === OwnerFilterType.SHARED_BY_ME
|
||||||
) {
|
) {
|
||||||
this.selectionModel.userID = this.settingsService.currentUser()?.id
|
this.selectionModel.userID.set(this.settingsService.currentUser()?.id)
|
||||||
this.selectionModel.includeUsers = []
|
this.selectionModel.includeUsers.set([])
|
||||||
this.selectionModel.excludeUsers = []
|
this.selectionModel.excludeUsers.set([])
|
||||||
this.selectionModel.hideUnowned = false
|
this.selectionModel.hideUnowned.set(false)
|
||||||
} else if (this.selectionModel.ownerFilter === OwnerFilterType.UNOWNED) {
|
} else if (this.selectionModel.ownerFilter() === OwnerFilterType.UNOWNED) {
|
||||||
this.selectionModel.userID = null
|
this.selectionModel.userID.set(null)
|
||||||
this.selectionModel.includeUsers = []
|
this.selectionModel.includeUsers.set([])
|
||||||
this.selectionModel.excludeUsers = []
|
this.selectionModel.excludeUsers.set([])
|
||||||
this.selectionModel.hideUnowned = false
|
this.selectionModel.hideUnowned.set(false)
|
||||||
}
|
}
|
||||||
this.onChange()
|
this.onChange()
|
||||||
}
|
}
|
||||||
@@ -206,11 +206,11 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
|||||||
}
|
}
|
||||||
|
|
||||||
onUserSelect() {
|
onUserSelect() {
|
||||||
if (this.selectionModel.includeUsers?.length) {
|
this.selectionModel.ownerFilter.set(
|
||||||
this.selectionModel.ownerFilter = OwnerFilterType.OTHERS
|
this.selectionModel.includeUsers()?.length
|
||||||
} else {
|
? OwnerFilterType.OTHERS
|
||||||
this.selectionModel.ownerFilter = OwnerFilterType.NONE
|
: OwnerFilterType.NONE
|
||||||
}
|
)
|
||||||
this.onChange()
|
this.onChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1209,24 +1209,53 @@ describe('DocumentDetailComponent', () => {
|
|||||||
expect(fixture.debugElement.queryAll(By.css('textarea.rtl'))).not.toBeNull()
|
expect(fixture.debugElement.queryAll(By.css('textarea.rtl'))).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should display built-in pdf viewer if not disabled', () => {
|
it('should display built-in pdf viewer if not disabled', async () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
component.document().archived_file_name = 'file.pdf'
|
component.document.update((document) => ({
|
||||||
|
...document,
|
||||||
|
archived_file_name: 'file.pdf',
|
||||||
|
}))
|
||||||
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
|
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
|
||||||
expect(component.useNativePdfViewer).toBeFalsy()
|
expect(component.useNativePdfViewer).toBeFalsy()
|
||||||
fixture.detectChanges()
|
await fixture.whenStable()
|
||||||
expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull()
|
expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should display native pdf viewer if enabled', () => {
|
it('should display native pdf viewer if enabled', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
component.document().archived_file_name = 'file.pdf'
|
component.document.update((document) => ({
|
||||||
|
...document,
|
||||||
|
archived_file_name: 'file.pdf',
|
||||||
|
}))
|
||||||
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, true)
|
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, true)
|
||||||
expect(component.useNativePdfViewer).toBeTruthy()
|
expect(component.useNativePdfViewer).toBeTruthy()
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(fixture.debugElement.query(By.css('object'))).not.toBeNull()
|
expect(fixture.debugElement.query(By.css('object'))).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should reflect signal-backed document detail display settings', () => {
|
||||||
|
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL, false)
|
||||||
|
settingsService.set(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS, [
|
||||||
|
component.DocumentDetailFieldID.Correspondent,
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(component.showThumbnailOverlay).toBeFalsy()
|
||||||
|
expect(
|
||||||
|
component.isFieldHidden(component.DocumentDetailFieldID.Correspondent)
|
||||||
|
).toBeTruthy()
|
||||||
|
expect(
|
||||||
|
component.isFieldHidden(component.DocumentDetailFieldID.DocumentType)
|
||||||
|
).toBeFalsy()
|
||||||
|
|
||||||
|
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL, true)
|
||||||
|
settingsService.set(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS, [])
|
||||||
|
|
||||||
|
expect(component.showThumbnailOverlay).toBeTruthy()
|
||||||
|
expect(
|
||||||
|
component.isFieldHidden(component.DocumentDetailFieldID.Correspondent)
|
||||||
|
).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
it('should attempt to retrieve metadata', () => {
|
it('should attempt to retrieve metadata', () => {
|
||||||
const metadataSpy = jest.spyOn(documentService, 'getMetadata')
|
const metadataSpy = jest.spyOn(documentService, 'getMetadata')
|
||||||
metadataSpy.mockReturnValue(of({ has_archive_version: true }))
|
metadataSpy.mockReturnValue(of({ has_archive_version: true }))
|
||||||
@@ -1685,7 +1714,10 @@ describe('DocumentDetailComponent', () => {
|
|||||||
|
|
||||||
it('should change preview element by render type', () => {
|
it('should change preview element by render type', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
component.document().archived_file_name = 'file.pdf'
|
component.document.update((document) => ({
|
||||||
|
...document,
|
||||||
|
archived_file_name: 'file.pdf',
|
||||||
|
}))
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(component.archiveContentRenderType).toEqual(
|
expect(component.archiveContentRenderType).toEqual(
|
||||||
component.ContentRenderType.PDF
|
component.ContentRenderType.PDF
|
||||||
@@ -1694,8 +1726,11 @@ describe('DocumentDetailComponent', () => {
|
|||||||
fixture.debugElement.query(By.css('pdf-viewer-container'))
|
fixture.debugElement.query(By.css('pdf-viewer-container'))
|
||||||
).not.toBeUndefined()
|
).not.toBeUndefined()
|
||||||
|
|
||||||
component.document().archived_file_name = undefined
|
component.document.update((document) => ({
|
||||||
component.document().mime_type = 'text/plain'
|
...document,
|
||||||
|
archived_file_name: undefined,
|
||||||
|
mime_type: 'text/plain',
|
||||||
|
}))
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(component.archiveContentRenderType).toEqual(
|
expect(component.archiveContentRenderType).toEqual(
|
||||||
component.ContentRenderType.Text
|
component.ContentRenderType.Text
|
||||||
@@ -1704,7 +1739,10 @@ describe('DocumentDetailComponent', () => {
|
|||||||
fixture.debugElement.query(By.css('div.preview-sticky'))
|
fixture.debugElement.query(By.css('div.preview-sticky'))
|
||||||
).not.toBeUndefined()
|
).not.toBeUndefined()
|
||||||
|
|
||||||
component.document().mime_type = 'image/jpeg'
|
component.document.update((document) => ({
|
||||||
|
...document,
|
||||||
|
mime_type: 'image/jpeg',
|
||||||
|
}))
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(component.archiveContentRenderType).toEqual(
|
expect(component.archiveContentRenderType).toEqual(
|
||||||
component.ContentRenderType.Image
|
component.ContentRenderType.Image
|
||||||
@@ -1712,9 +1750,12 @@ describe('DocumentDetailComponent', () => {
|
|||||||
expect(
|
expect(
|
||||||
fixture.debugElement.query(By.css('.preview-sticky img'))
|
fixture.debugElement.query(By.css('.preview-sticky img'))
|
||||||
).not.toBeUndefined()
|
).not.toBeUndefined()
|
||||||
;((component.document().mime_type =
|
component.document.update((document) => ({
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'),
|
...document,
|
||||||
fixture.detectChanges())
|
mime_type:
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
}))
|
||||||
|
fixture.detectChanges()
|
||||||
expect(component.archiveContentRenderType).toEqual(
|
expect(component.archiveContentRenderType).toEqual(
|
||||||
component.ContentRenderType.Other
|
component.ContentRenderType.Other
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -227,6 +227,19 @@ export class DocumentDetailComponent
|
|||||||
private deviceDetectorService = inject(DeviceDetectorService)
|
private deviceDetectorService = inject(DeviceDetectorService)
|
||||||
private savedViewService = inject(SavedViewService)
|
private savedViewService = inject(SavedViewService)
|
||||||
private readonly websocketStatusService = inject(WebsocketStatusService)
|
private readonly websocketStatusService = inject(WebsocketStatusService)
|
||||||
|
private readonly useNativePdfViewerSetting = this.settings.getSignal<boolean>(
|
||||||
|
SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER
|
||||||
|
)
|
||||||
|
private readonly aiEnabledSetting = this.settings.getSignal<boolean>(
|
||||||
|
SETTINGS_KEYS.AI_ENABLED
|
||||||
|
)
|
||||||
|
private readonly showThumbnailOverlaySetting =
|
||||||
|
this.settings.getSignal<boolean>(
|
||||||
|
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL
|
||||||
|
)
|
||||||
|
private readonly hiddenFieldsSetting = this.settings.getSignal<
|
||||||
|
DocumentDetailFieldID[]
|
||||||
|
>(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS)
|
||||||
|
|
||||||
@ViewChild('inputTitle')
|
@ViewChild('inputTitle')
|
||||||
titleInput: TextComponent
|
titleInput: TextComponent
|
||||||
@@ -333,8 +346,7 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get useNativePdfViewer(): boolean {
|
get useNativePdfViewer(): boolean {
|
||||||
this.settings.trackChanges()
|
return this.useNativePdfViewerSetting()
|
||||||
return this.settings.get(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get isMobile(): boolean {
|
get isMobile(): boolean {
|
||||||
@@ -342,12 +354,10 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get aiEnabled(): boolean {
|
get aiEnabled(): boolean {
|
||||||
this.settings.trackChanges()
|
return this.aiEnabledSetting()
|
||||||
return this.settings.get(SETTINGS_KEYS.AI_ENABLED)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get archiveContentRenderType(): ContentRenderType {
|
get archiveContentRenderType(): ContentRenderType {
|
||||||
this.settings.trackChanges()
|
|
||||||
const hasArchiveVersion =
|
const hasArchiveVersion =
|
||||||
this.metadata()?.has_archive_version ??
|
this.metadata()?.has_archive_version ??
|
||||||
!!this.document()?.archived_file_name
|
!!this.document()?.archived_file_name
|
||||||
@@ -359,22 +369,17 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get originalContentRenderType(): ContentRenderType {
|
get originalContentRenderType(): ContentRenderType {
|
||||||
this.settings.trackChanges()
|
|
||||||
return this.getRenderType(
|
return this.getRenderType(
|
||||||
this.metadata()?.original_mime_type || this.document()?.mime_type
|
this.metadata()?.original_mime_type || this.document()?.mime_type
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
get showThumbnailOverlay(): boolean {
|
get showThumbnailOverlay(): boolean {
|
||||||
this.settings.trackChanges()
|
return this.showThumbnailOverlaySetting()
|
||||||
return this.settings.get(SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isFieldHidden(fieldId: DocumentDetailFieldID): boolean {
|
isFieldHidden(fieldId: DocumentDetailFieldID): boolean {
|
||||||
this.settings.trackChanges()
|
return this.hiddenFieldsSetting().includes(fieldId)
|
||||||
return this.settings
|
|
||||||
.get(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS)
|
|
||||||
.includes(fieldId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getRenderType(mimeType: string): ContentRenderType {
|
private getRenderType(mimeType: string): ContentRenderType {
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ export class DocumentListComponent
|
|||||||
settingsService = inject(SettingsService)
|
settingsService = inject(SettingsService)
|
||||||
private hotKeyService = inject(HotKeyService)
|
private hotKeyService = inject(HotKeyService)
|
||||||
permissionService = inject(PermissionsService)
|
permissionService = inject(PermissionsService)
|
||||||
|
private readonly notesEnabledSetting =
|
||||||
|
this.settingsService.getSignal<boolean>(SETTINGS_KEYS.NOTES_ENABLED)
|
||||||
|
|
||||||
DisplayField = DisplayField
|
DisplayField = DisplayField
|
||||||
DisplayMode = DisplayMode
|
DisplayMode = DisplayMode
|
||||||
@@ -574,8 +576,7 @@ export class DocumentListComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get notesEnabled(): boolean {
|
get notesEnabled(): boolean {
|
||||||
this.settingsService.trackChanges()
|
return this.notesEnabledSetting()
|
||||||
return this.settingsService.get(SETTINGS_KEYS.NOTES_ENABLED)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resetFilters() {
|
resetFilters() {
|
||||||
|
|||||||
+86
-20
@@ -621,6 +621,43 @@ describe('FilterEditorComponent', () => {
|
|||||||
component.toggleTag(2) // coverage
|
component.toggleTag(2) // coverage
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should reflect ingested tag filter rules in the dropdown toggle', () => {
|
||||||
|
const dropdown = fixture.debugElement.query(
|
||||||
|
By.css('pngx-filterable-dropdown')
|
||||||
|
)
|
||||||
|
const toggle = dropdown.nativeElement.querySelector('#dropdown_tags')
|
||||||
|
expect(toggle.classList.contains('btn-primary')).toBeFalsy()
|
||||||
|
expect(
|
||||||
|
dropdown.nativeElement.querySelector('pngx-clearable-badge')
|
||||||
|
).toBeNull()
|
||||||
|
|
||||||
|
// switching to a view with a tag filter
|
||||||
|
component.filterRules = [
|
||||||
|
{
|
||||||
|
rule_type: FILTER_HAS_TAGS_ALL,
|
||||||
|
value: '2',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(toggle.classList.contains('btn-primary')).toBeTruthy()
|
||||||
|
expect(
|
||||||
|
dropdown.nativeElement.querySelector('pngx-clearable-badge')
|
||||||
|
).not.toBeNull()
|
||||||
|
|
||||||
|
// and back to a view without one
|
||||||
|
component.filterRules = [
|
||||||
|
{
|
||||||
|
rule_type: FILTER_HAS_CORRESPONDENT_ANY,
|
||||||
|
value: '12',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(toggle.classList.contains('btn-primary')).toBeFalsy()
|
||||||
|
expect(
|
||||||
|
dropdown.nativeElement.querySelector('pngx-clearable-badge')
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it('should ingest filter rules for has any tags', () => {
|
it('should ingest filter rules for has any tags', () => {
|
||||||
expect(component.tagSelectionModel.getSelectedItems()).toHaveLength(0)
|
expect(component.tagSelectionModel.getSelectedItems()).toHaveLength(0)
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
@@ -1078,7 +1115,7 @@ describe('FilterEditorComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should ingest filter rules for owner', () => {
|
it('should ingest filter rules for owner', () => {
|
||||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||||
OwnerFilterType.NONE
|
OwnerFilterType.NONE
|
||||||
)
|
)
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
@@ -1087,15 +1124,38 @@ describe('FilterEditorComponent', () => {
|
|||||||
value: '100',
|
value: '100',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||||
OwnerFilterType.SELF
|
OwnerFilterType.SELF
|
||||||
)
|
)
|
||||||
expect(component.permissionsSelectionModel.hideUnowned).toBeFalsy()
|
expect(component.permissionsSelectionModel.hideUnowned()).toBeFalsy()
|
||||||
expect(component.permissionsSelectionModel.userID).toEqual(100)
|
expect(component.permissionsSelectionModel.userID()).toEqual(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reflect ingested owner filter rules in the dropdown toggle', () => {
|
||||||
|
const dropdown = fixture.debugElement.query(
|
||||||
|
By.css('pngx-permissions-filter-dropdown')
|
||||||
|
)
|
||||||
|
const toggle = dropdown.nativeElement.querySelector('button')
|
||||||
|
expect(toggle.classList.contains('btn-primary')).toBeFalsy()
|
||||||
|
|
||||||
|
// switching to a view with an owner filter
|
||||||
|
component.filterRules = [
|
||||||
|
{
|
||||||
|
rule_type: FILTER_OWNER,
|
||||||
|
value: '100',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(toggle.classList.contains('btn-primary')).toBeTruthy()
|
||||||
|
|
||||||
|
// and back to a view without one
|
||||||
|
component.filterRules = []
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(toggle.classList.contains('btn-primary')).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should ingest filter rules for owner is others', () => {
|
it('should ingest filter rules for owner is others', () => {
|
||||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||||
OwnerFilterType.NONE
|
OwnerFilterType.NONE
|
||||||
)
|
)
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
@@ -1104,14 +1164,14 @@ describe('FilterEditorComponent', () => {
|
|||||||
value: '50',
|
value: '50',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||||
OwnerFilterType.OTHERS
|
OwnerFilterType.OTHERS
|
||||||
)
|
)
|
||||||
expect(component.permissionsSelectionModel.includeUsers).toContain(50)
|
expect(component.permissionsSelectionModel.includeUsers()).toContain(50)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should ingest filter rules for owner does not include others', () => {
|
it('should ingest filter rules for owner does not include others', () => {
|
||||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||||
OwnerFilterType.NONE
|
OwnerFilterType.NONE
|
||||||
)
|
)
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
@@ -1120,14 +1180,14 @@ describe('FilterEditorComponent', () => {
|
|||||||
value: '50',
|
value: '50',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||||
OwnerFilterType.NOT_SELF
|
OwnerFilterType.NOT_SELF
|
||||||
)
|
)
|
||||||
expect(component.permissionsSelectionModel.excludeUsers).toContain(50)
|
expect(component.permissionsSelectionModel.excludeUsers()).toContain(50)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should ingest filter rules for owner is null', () => {
|
it('should ingest filter rules for owner is null', () => {
|
||||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||||
OwnerFilterType.NONE
|
OwnerFilterType.NONE
|
||||||
)
|
)
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
@@ -1136,10 +1196,10 @@ describe('FilterEditorComponent', () => {
|
|||||||
value: 'true',
|
value: 'true',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||||
OwnerFilterType.UNOWNED
|
OwnerFilterType.UNOWNED
|
||||||
)
|
)
|
||||||
expect(component.permissionsSelectionModel.hideUnowned).toBeFalsy()
|
expect(component.permissionsSelectionModel.hideUnowned()).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should ingest filter rules for owner is not null', () => {
|
it('should ingest filter rules for owner is not null', () => {
|
||||||
@@ -1149,14 +1209,14 @@ describe('FilterEditorComponent', () => {
|
|||||||
value: 'false',
|
value: 'false',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
expect(component.permissionsSelectionModel.hideUnowned).toBeTruthy()
|
expect(component.permissionsSelectionModel.hideUnowned()).toBeTruthy()
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
{
|
{
|
||||||
rule_type: FILTER_OWNER_ISNULL,
|
rule_type: FILTER_OWNER_ISNULL,
|
||||||
value: '0',
|
value: '0',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
expect(component.permissionsSelectionModel.hideUnowned).toBeTruthy()
|
expect(component.permissionsSelectionModel.hideUnowned()).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should ingest filter rules for shared by me', () => {
|
it('should ingest filter rules for shared by me', () => {
|
||||||
@@ -1166,7 +1226,7 @@ describe('FilterEditorComponent', () => {
|
|||||||
value: '2',
|
value: '2',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
expect(component.permissionsSelectionModel.userID).toEqual(2)
|
expect(component.permissionsSelectionModel.userID()).toEqual(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
// GET filterRules
|
// GET filterRules
|
||||||
@@ -1932,7 +1992,10 @@ describe('FilterEditorComponent', () => {
|
|||||||
value: '1',
|
value: '1',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
component.permissionsSelectionModel.excludeUsers.push(2)
|
component.permissionsSelectionModel.excludeUsers.update((users) => [
|
||||||
|
...users,
|
||||||
|
2,
|
||||||
|
])
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(component.filterRules).toEqual([
|
expect(component.filterRules).toEqual([
|
||||||
{
|
{
|
||||||
@@ -1982,8 +2045,11 @@ describe('FilterEditorComponent', () => {
|
|||||||
// TODO: mock input in code
|
// TODO: mock input in code
|
||||||
// userSelect.query(By.css('input')).nativeElement.value = '3'
|
// userSelect.query(By.css('input')).nativeElement.value = '3'
|
||||||
// userSelect.triggerEventHandler('change')
|
// userSelect.triggerEventHandler('change')
|
||||||
component.permissionsSelectionModel.ownerFilter = OwnerFilterType.OTHERS
|
component.permissionsSelectionModel.ownerFilter.set(OwnerFilterType.OTHERS)
|
||||||
component.permissionsSelectionModel.includeUsers.push(3)
|
component.permissionsSelectionModel.includeUsers.update((users) => [
|
||||||
|
...users,
|
||||||
|
3,
|
||||||
|
])
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(component.filterRules).toEqual([
|
expect(component.filterRules).toEqual([
|
||||||
{
|
{
|
||||||
@@ -2003,7 +2069,7 @@ describe('FilterEditorComponent', () => {
|
|||||||
ownerToggle.nativeElement.checked = true
|
ownerToggle.nativeElement.checked = true
|
||||||
// ownerToggle.triggerEventHandler('change')
|
// ownerToggle.triggerEventHandler('change')
|
||||||
// TODO: ngModel isn't doing this here
|
// TODO: ngModel isn't doing this here
|
||||||
component.permissionsSelectionModel.hideUnowned = true
|
component.permissionsSelectionModel.hideUnowned.set(true)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(component.filterRules).toEqual([
|
expect(component.filterRules).toEqual([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -735,38 +735,50 @@ export class FilterEditorComponent
|
|||||||
this._textFilter = rule.value
|
this._textFilter = rule.value
|
||||||
break
|
break
|
||||||
case FILTER_OWNER:
|
case FILTER_OWNER:
|
||||||
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.SELF
|
this.permissionsSelectionModel.ownerFilter.set(OwnerFilterType.SELF)
|
||||||
this.permissionsSelectionModel.hideUnowned = false
|
this.permissionsSelectionModel.hideUnowned.set(false)
|
||||||
if (rule.value)
|
if (rule.value)
|
||||||
this.permissionsSelectionModel.userID = parseInt(rule.value, 10)
|
this.permissionsSelectionModel.userID.set(
|
||||||
|
Number.parseInt(rule.value, 10)
|
||||||
|
)
|
||||||
break
|
break
|
||||||
case FILTER_OWNER_ANY:
|
case FILTER_OWNER_ANY:
|
||||||
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.OTHERS
|
this.permissionsSelectionModel.ownerFilter.set(OwnerFilterType.OTHERS)
|
||||||
if (rule.value)
|
if (rule.value)
|
||||||
this.permissionsSelectionModel.includeUsers.push(
|
this.permissionsSelectionModel.includeUsers.update((users) => [
|
||||||
parseInt(rule.value, 10)
|
...users,
|
||||||
)
|
Number.parseInt(rule.value, 10),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
case FILTER_OWNER_DOES_NOT_INCLUDE:
|
case FILTER_OWNER_DOES_NOT_INCLUDE:
|
||||||
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.NOT_SELF
|
this.permissionsSelectionModel.ownerFilter.set(
|
||||||
if (rule.value)
|
OwnerFilterType.NOT_SELF
|
||||||
this.permissionsSelectionModel.excludeUsers.push(
|
|
||||||
parseInt(rule.value, 10)
|
|
||||||
)
|
)
|
||||||
|
if (rule.value)
|
||||||
|
this.permissionsSelectionModel.excludeUsers.update((users) => [
|
||||||
|
...users,
|
||||||
|
Number.parseInt(rule.value, 10),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
case FILTER_SHARED_BY_USER:
|
case FILTER_SHARED_BY_USER:
|
||||||
this.permissionsSelectionModel.ownerFilter =
|
this.permissionsSelectionModel.ownerFilter.set(
|
||||||
OwnerFilterType.SHARED_BY_ME
|
OwnerFilterType.SHARED_BY_ME
|
||||||
|
)
|
||||||
if (rule.value)
|
if (rule.value)
|
||||||
this.permissionsSelectionModel.userID = parseInt(rule.value, 10)
|
this.permissionsSelectionModel.userID.set(
|
||||||
|
Number.parseInt(rule.value, 10)
|
||||||
|
)
|
||||||
break
|
break
|
||||||
case FILTER_OWNER_ISNULL:
|
case FILTER_OWNER_ISNULL:
|
||||||
if (rule.value === 'true' || rule.value === '1') {
|
if (rule.value === 'true' || rule.value === '1') {
|
||||||
this.permissionsSelectionModel.hideUnowned = false
|
this.permissionsSelectionModel.hideUnowned.set(false)
|
||||||
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.UNOWNED
|
this.permissionsSelectionModel.ownerFilter.set(
|
||||||
|
OwnerFilterType.UNOWNED
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
this.permissionsSelectionModel.hideUnowned =
|
this.permissionsSelectionModel.hideUnowned.set(
|
||||||
rule.value === 'false' || rule.value === '0'
|
rule.value === 'false' || rule.value === '0'
|
||||||
|
)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1074,34 +1086,35 @@ export class FilterEditorComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this.permissionsSelectionModel.ownerFilter == OwnerFilterType.SELF) {
|
if (this.permissionsSelectionModel.ownerFilter() == OwnerFilterType.SELF) {
|
||||||
filterRules.push({
|
filterRules.push({
|
||||||
rule_type: FILTER_OWNER,
|
rule_type: FILTER_OWNER,
|
||||||
value: this.permissionsSelectionModel.userID.toString(),
|
value: this.permissionsSelectionModel.userID().toString(),
|
||||||
})
|
})
|
||||||
} else if (
|
} else if (
|
||||||
this.permissionsSelectionModel.ownerFilter == OwnerFilterType.NOT_SELF
|
this.permissionsSelectionModel.ownerFilter() == OwnerFilterType.NOT_SELF
|
||||||
) {
|
) {
|
||||||
filterRules.push({
|
filterRules.push({
|
||||||
rule_type: FILTER_OWNER_DOES_NOT_INCLUDE,
|
rule_type: FILTER_OWNER_DOES_NOT_INCLUDE,
|
||||||
value: this.permissionsSelectionModel.excludeUsers?.join(','),
|
value: this.permissionsSelectionModel.excludeUsers()?.join(','),
|
||||||
})
|
})
|
||||||
} else if (
|
} else if (
|
||||||
this.permissionsSelectionModel.ownerFilter == OwnerFilterType.OTHERS
|
this.permissionsSelectionModel.ownerFilter() == OwnerFilterType.OTHERS
|
||||||
) {
|
) {
|
||||||
filterRules.push({
|
filterRules.push({
|
||||||
rule_type: FILTER_OWNER_ANY,
|
rule_type: FILTER_OWNER_ANY,
|
||||||
value: this.permissionsSelectionModel.includeUsers?.join(','),
|
value: this.permissionsSelectionModel.includeUsers()?.join(','),
|
||||||
})
|
})
|
||||||
} else if (
|
} else if (
|
||||||
this.permissionsSelectionModel.ownerFilter == OwnerFilterType.SHARED_BY_ME
|
this.permissionsSelectionModel.ownerFilter() ==
|
||||||
|
OwnerFilterType.SHARED_BY_ME
|
||||||
) {
|
) {
|
||||||
filterRules.push({
|
filterRules.push({
|
||||||
rule_type: FILTER_SHARED_BY_USER,
|
rule_type: FILTER_SHARED_BY_USER,
|
||||||
value: this.permissionsSelectionModel.userID.toString(),
|
value: this.permissionsSelectionModel.userID().toString(),
|
||||||
})
|
})
|
||||||
} else if (
|
} else if (
|
||||||
this.permissionsSelectionModel.ownerFilter == OwnerFilterType.UNOWNED
|
this.permissionsSelectionModel.ownerFilter() == OwnerFilterType.UNOWNED
|
||||||
) {
|
) {
|
||||||
filterRules.push({
|
filterRules.push({
|
||||||
rule_type: FILTER_OWNER_ISNULL,
|
rule_type: FILTER_OWNER_ISNULL,
|
||||||
@@ -1109,7 +1122,7 @@ export class FilterEditorComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.permissionsSelectionModel.hideUnowned) {
|
if (this.permissionsSelectionModel.hideUnowned()) {
|
||||||
filterRules.push({
|
filterRules.push({
|
||||||
rule_type: FILTER_OWNER_ISNULL,
|
rule_type: FILTER_OWNER_ISNULL,
|
||||||
value: 'false',
|
value: 'false',
|
||||||
|
|||||||
@@ -210,6 +210,48 @@ describe('SettingsService', () => {
|
|||||||
expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('#000000')
|
expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('#000000')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('provides stable signals that update when settings change', () => {
|
||||||
|
const req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}ui_settings/`
|
||||||
|
)
|
||||||
|
req.flush(ui_settings)
|
||||||
|
|
||||||
|
const notesEnabled = settingsService.getSignal<boolean>(
|
||||||
|
SETTINGS_KEYS.NOTES_ENABLED
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(notesEnabled()).toBeTruthy()
|
||||||
|
expect(
|
||||||
|
settingsService.getSignal<boolean>(SETTINGS_KEYS.NOTES_ENABLED)
|
||||||
|
).toBe(notesEnabled)
|
||||||
|
|
||||||
|
settingsService.set(SETTINGS_KEYS.NOTES_ENABLED, false)
|
||||||
|
|
||||||
|
expect(notesEnabled()).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates setting signals when settings are reinitialized', () => {
|
||||||
|
let req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}ui_settings/`
|
||||||
|
)
|
||||||
|
req.flush(ui_settings)
|
||||||
|
const appTitle = settingsService.getSignal<string>(SETTINGS_KEYS.APP_TITLE)
|
||||||
|
|
||||||
|
settingsService.initializeSettings().subscribe()
|
||||||
|
req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}ui_settings/`
|
||||||
|
)
|
||||||
|
req.flush({
|
||||||
|
...ui_settings,
|
||||||
|
settings: {
|
||||||
|
...ui_settings.settings,
|
||||||
|
app_title: 'Updated title',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(appTitle()).toBe('Updated title')
|
||||||
|
})
|
||||||
|
|
||||||
it('sets django cookie for languages', () => {
|
it('sets django cookie for languages', () => {
|
||||||
httpTestingController
|
httpTestingController
|
||||||
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
|
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { HttpClient } from '@angular/common/http'
|
|||||||
import {
|
import {
|
||||||
DOCUMENT,
|
DOCUMENT,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
|
Signal,
|
||||||
|
computed,
|
||||||
inject,
|
inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
LOCALE_ID,
|
LOCALE_ID,
|
||||||
@@ -297,6 +299,7 @@ export class SettingsService {
|
|||||||
|
|
||||||
private settings: Record<string, any> = {}
|
private settings: Record<string, any> = {}
|
||||||
private readonly settingsVersion = signal(0)
|
private readonly settingsVersion = signal(0)
|
||||||
|
private readonly settingSignals = new Map<string, Signal<unknown>>()
|
||||||
readonly currentUser = signal<User>(undefined)
|
readonly currentUser = signal<User>(undefined)
|
||||||
|
|
||||||
public settingsSaved: EventEmitter<any> = new EventEmitter()
|
public settingsSaved: EventEmitter<any> = new EventEmitter()
|
||||||
@@ -326,10 +329,6 @@ export class SettingsService {
|
|||||||
return !UNSAFE_OBJECT_KEYS.has(key)
|
return !UNSAFE_OBJECT_KEYS.has(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
public trackChanges(): void {
|
|
||||||
this.settingsVersion()
|
|
||||||
}
|
|
||||||
|
|
||||||
private assignSafeSettings(source: Record<string, any>) {
|
private assignSafeSettings(source: Record<string, any>) {
|
||||||
if (!source || typeof source !== 'object' || Array.isArray(source)) {
|
if (!source || typeof source !== 'object' || Array.isArray(source)) {
|
||||||
return
|
return
|
||||||
@@ -339,6 +338,7 @@ export class SettingsService {
|
|||||||
if (!this.isSafeObjectKey(key)) continue
|
if (!this.isSafeObjectKey(key)) continue
|
||||||
this.settings[key] = source[key]
|
this.settings[key] = source[key]
|
||||||
}
|
}
|
||||||
|
this.settingsVersion.update((version) => version + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// this is called by the app initializer in app.module
|
// this is called by the app initializer in app.module
|
||||||
@@ -594,6 +594,18 @@ export class SettingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSignal<T = any>(key: string): Signal<T> {
|
||||||
|
let settingSignal = this.settingSignals.get(key)
|
||||||
|
if (!settingSignal) {
|
||||||
|
settingSignal = computed(() => {
|
||||||
|
this.settingsVersion()
|
||||||
|
return this.get(key)
|
||||||
|
})
|
||||||
|
this.settingSignals.set(key, settingSignal)
|
||||||
|
}
|
||||||
|
return settingSignal as Signal<T>
|
||||||
|
}
|
||||||
|
|
||||||
set(key: string, value: any) {
|
set(key: string, value: any) {
|
||||||
// parse key:key:key into nested object
|
// parse key:key:key into nested object
|
||||||
let settingObj = this.settings
|
let settingObj = this.settings
|
||||||
|
|||||||
@@ -1063,3 +1063,79 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn("non-public address", str(response.data).lower())
|
self.assertIn("non-public address", str(response.data).lower())
|
||||||
|
|
||||||
|
@override_settings(REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS=False)
|
||||||
|
def test_update_remote_ocr_endpoint_blocks_internal_endpoint_when_disallowed(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Internal remote OCR endpoints are disallowed
|
||||||
|
WHEN:
|
||||||
|
- The config is updated with a remote OCR endpoint resolving internally
|
||||||
|
THEN:
|
||||||
|
- The request is rejected
|
||||||
|
"""
|
||||||
|
response = self.client.patch(
|
||||||
|
f"{self.ENDPOINT}1/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"remote_ocr_endpoint": "http://127.0.0.1:5000",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn("non-public address", str(response.data).lower())
|
||||||
|
|
||||||
|
@override_settings(REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS=True)
|
||||||
|
def test_update_remote_ocr_endpoint_allows_internal_endpoint_by_default(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Internal remote OCR endpoints are allowed (the default)
|
||||||
|
WHEN:
|
||||||
|
- The config is updated with a remote OCR endpoint resolving internally
|
||||||
|
THEN:
|
||||||
|
- The request is accepted, preserving existing self-hosted deployments
|
||||||
|
"""
|
||||||
|
response = self.client.patch(
|
||||||
|
f"{self.ENDPOINT}1/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"remote_ocr_endpoint": "http://127.0.0.1:5000",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(
|
||||||
|
response.data["remote_ocr_endpoint"],
|
||||||
|
"http://127.0.0.1:5000",
|
||||||
|
)
|
||||||
|
|
||||||
|
@override_settings(REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS=False)
|
||||||
|
def test_update_remote_ocr_endpoint_empty_value_skips_validation(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Internal remote OCR endpoints are disallowed
|
||||||
|
WHEN:
|
||||||
|
- The config is updated with an empty remote OCR endpoint
|
||||||
|
THEN:
|
||||||
|
- The request is accepted; clearing the field never needs
|
||||||
|
outbound URL validation
|
||||||
|
"""
|
||||||
|
response = self.client.patch(
|
||||||
|
f"{self.ENDPOINT}1/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"remote_ocr_endpoint": "",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(response.data["remote_ocr_endpoint"], "")
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ if TYPE_CHECKING:
|
|||||||
import datetime
|
import datetime
|
||||||
from types import TracebackType
|
from types import TracebackType
|
||||||
|
|
||||||
|
from azure.core.pipeline import PipelineRequest
|
||||||
|
|
||||||
from paperless.parsers import MetadataEntry
|
from paperless.parsers import MetadataEntry
|
||||||
from paperless.parsers import ParserContext
|
from paperless.parsers import ParserContext
|
||||||
|
|
||||||
@@ -436,9 +438,45 @@ class RemoteDocumentParser:
|
|||||||
from azure.ai.documentintelligence.models import DocumentContentFormat
|
from azure.ai.documentintelligence.models import DocumentContentFormat
|
||||||
from azure.core.credentials import AzureKeyCredential
|
from azure.core.credentials import AzureKeyCredential
|
||||||
|
|
||||||
|
from paperless.network import validate_outbound_http_url
|
||||||
|
|
||||||
|
allow_internal = settings.REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS
|
||||||
|
|
||||||
|
try:
|
||||||
|
validate_outbound_http_url(config.endpoint, allow_internal=allow_internal)
|
||||||
|
except ValueError as e:
|
||||||
|
raise ParseError(f"Invalid remote OCR endpoint: {e}") from e
|
||||||
|
|
||||||
|
def _revalidate_request_host(request: PipelineRequest) -> None:
|
||||||
|
"""Re-validates the destination host of every request sent.
|
||||||
|
|
||||||
|
The check above only covers the moment the client is built. A
|
||||||
|
single analysis involves several requests spread over the
|
||||||
|
polling loop below, and any one of them can be redirected.
|
||||||
|
Wiring this through ``raw_request_hook`` (Azure's built-in
|
||||||
|
CustomHookPolicy) rather than a custom policy means it runs
|
||||||
|
*after* RedirectPolicy in the pipeline, so it sees - and
|
||||||
|
re-checks - every actual outbound URL, including redirect
|
||||||
|
targets, not just the original request.
|
||||||
|
"""
|
||||||
|
validate_outbound_http_url(
|
||||||
|
request.http_request.url,
|
||||||
|
allow_internal=allow_internal,
|
||||||
|
)
|
||||||
|
|
||||||
client = DocumentIntelligenceClient(
|
client = DocumentIntelligenceClient(
|
||||||
endpoint=config.endpoint,
|
endpoint=config.endpoint,
|
||||||
credential=AzureKeyCredential(config.api_key),
|
credential=AzureKeyCredential(config.api_key),
|
||||||
|
raw_request_hook=_revalidate_request_host,
|
||||||
|
# AzureKeyCredential is sent as Ocp-Apim-Subscription-Key, which
|
||||||
|
# Azure's default SensitiveHeaderCleanupPolicy does not strip on
|
||||||
|
# a cross-domain redirect (only Authorization and
|
||||||
|
# x-ms-authorization-auxiliary are, by default).
|
||||||
|
blocked_redirect_headers=[
|
||||||
|
"Authorization",
|
||||||
|
"x-ms-authorization-auxiliary",
|
||||||
|
"Ocp-Apim-Subscription-Key",
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -305,6 +305,22 @@ class ApplicationConfigurationSerializer(
|
|||||||
|
|
||||||
validate_llm_embedding_endpoint = validate_llm_endpoint
|
validate_llm_embedding_endpoint = validate_llm_endpoint
|
||||||
|
|
||||||
|
def validate_remote_ocr_endpoint(self, value: str | None) -> str | None:
|
||||||
|
if not value:
|
||||||
|
return value
|
||||||
|
|
||||||
|
try:
|
||||||
|
validate_outbound_http_url(
|
||||||
|
value,
|
||||||
|
allow_internal=settings.REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
f"Invalid remote OCR endpoint: {e.args[0]}, see logs for details",
|
||||||
|
) from e
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ApplicationConfiguration
|
model = ApplicationConfiguration
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
|
|||||||
@@ -1208,6 +1208,10 @@ REMOTE_OCR_MODE = get_choice_from_env(
|
|||||||
{"always", "workflow_only"},
|
{"always", "workflow_only"},
|
||||||
default="always",
|
default="always",
|
||||||
)
|
)
|
||||||
|
REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS = get_bool_from_env(
|
||||||
|
"PAPERLESS_REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS",
|
||||||
|
"true",
|
||||||
|
)
|
||||||
|
|
||||||
################################################################################
|
################################################################################
|
||||||
# AI Settings #
|
# AI Settings #
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ requires-python = ">=3.11"
|
|||||||
resolution-markers = [
|
resolution-markers = [
|
||||||
"python_full_version >= '3.15' and sys_platform == 'darwin'",
|
"python_full_version >= '3.15' and sys_platform == 'darwin'",
|
||||||
"python_full_version >= '3.15' and sys_platform == 'linux'",
|
"python_full_version >= '3.15' and sys_platform == 'linux'",
|
||||||
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
|
||||||
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
|
||||||
"python_full_version >= '3.12' and python_full_version < '3.15' and sys_platform == 'darwin'",
|
"python_full_version >= '3.12' and python_full_version < '3.15' and sys_platform == 'darwin'",
|
||||||
"python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
"python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||||
"python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
"python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||||
|
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||||
|
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||||
"(python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'linux')",
|
"(python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'linux')",
|
||||||
"python_full_version < '3.12' and sys_platform == 'darwin'",
|
"python_full_version < '3.12' and sys_platform == 'darwin'",
|
||||||
"python_full_version < '3.12' and sys_platform == 'linux'",
|
"python_full_version < '3.12' and sys_platform == 'linux'",
|
||||||
@@ -4397,24 +4397,24 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.16.4"
|
version = "0.16.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc", size = 4899731, upload-time = "2026-08-20T17:43:59.196Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/70/25/7113f6d5498888c5fb7db34081cba7d5971c4cb1bfb26819966eee68f003/ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7", size = 4877500, upload-time = "2026-07-30T19:37:01.379Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", hash = "sha256:df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7", size = 10006909, upload-time = "2026-08-20T17:43:16.888Z" },
|
{ url = "https://files.pythonhosted.org/packages/1b/bd/694da69368e0973de65df2ddc73ab18d43c469d5963d9b150911de6bc513/ruff-0.16.1-py3-none-linux_armv6l.whl", hash = "sha256:58edb313b88f0c5460a26adf5f39a37a3be789494a15e3e411e35fa78b89f9a0", size = 10839126, upload-time = "2026-07-30T19:36:13.697Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604", size = 10240201, upload-time = "2026-08-20T17:43:19.337Z" },
|
{ url = "https://files.pythonhosted.org/packages/3f/f0/b626e5d5bd0dd9576263658ef12885e2288afd1029a48e26ffed65ec1ac1/ruff-0.16.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fde5a99e2f97479af66edd6622c6d5a2a7592c77cf4153d9e4428f5eeb55b60c", size = 11070253, upload-time = "2026-07-30T19:36:17.14Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255", size = 9835122, upload-time = "2026-08-20T17:43:21.708Z" },
|
{ url = "https://files.pythonhosted.org/packages/83/63/f40acfb6b35b88623e71684942b552c3edd96035f5d98f313815f7b277de/ruff-0.16.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d4c20532fca4f7fa609369161d968dd28f65d83dabbd61d8e9c7edbf7001f6", size = 10561425, upload-time = "2026-07-30T19:36:20.04Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade", size = 9977162, upload-time = "2026-08-20T17:43:24.236Z" },
|
{ url = "https://files.pythonhosted.org/packages/aa/dd/14ec0e9c2b4d315547dd38765004b4863e354e1b52cb308272215d9f6f6d/ruff-0.16.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30affbcedf59ad5703d9c91f82266e02b47739f797e1a7b6e158e5526a6dae38", size = 10948879, upload-time = "2026-07-30T19:36:22.476Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff", size = 9829789, upload-time = "2026-08-20T17:43:26.966Z" },
|
{ url = "https://files.pythonhosted.org/packages/33/e9/9d870cbae575030fdef595f04b4b97573c525b5497cce4f4498cf2f85446/ruff-0.16.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24e9c631573cbca9d20f1283f8f479b2afa4a8503504822bd71a293889f16743", size = 10643691, upload-time = "2026-07-30T19:36:24.914Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80", size = 10527949, upload-time = "2026-08-20T17:43:29.384Z" },
|
{ url = "https://files.pythonhosted.org/packages/c4/09/12743d544e2173f53ecd27217c65f90d2bc0f8424a66a60339e56bbc0457/ruff-0.16.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b41bdd48fb420987a9b5212e4957c26ad4abce401fa9ea9d4d85843727945f4f", size = 11435354, upload-time = "2026-07-30T19:36:28.447Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07", size = 11333695, upload-time = "2026-08-20T17:43:31.872Z" },
|
{ url = "https://files.pythonhosted.org/packages/7f/89/a1652b2daee52083c9554a6333b678a8b01d0400f976827bb87857f9449a/ruff-0.16.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0d1e1393b7648079e13669de1c1f4fde06d4583e84d8fd5c1551e0a77a2aa75", size = 12259033, upload-time = "2026-07-30T19:36:31.326Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7", size = 10727741, upload-time = "2026-08-20T17:43:34.596Z" },
|
{ url = "https://files.pythonhosted.org/packages/16/96/ecdcb8c54ee7b123b487f807eb014e6e019155a0b81dfb669acd52f28ce3/ruff-0.16.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07bf434b1c95f4e093be4532068ef4fcf00924eb2ade8796075980902d6fd54a", size = 11667981, upload-time = "2026-07-30T19:36:34.394Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3", size = 10286522, upload-time = "2026-08-20T17:43:37.288Z" },
|
{ url = "https://files.pythonhosted.org/packages/cd/90/c52e12e0d862e9572f2a33aa227409143520abe53111e9a6babbac7b4af8/ruff-0.16.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c", size = 11468183, upload-time = "2026-07-30T19:36:37.339Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454", size = 10584182, upload-time = "2026-08-20T17:43:39.984Z" },
|
{ url = "https://files.pythonhosted.org/packages/2c/6b/4ffb7ad1d83eb16cf8cbb3c8815d3f11c88460fd162d4b372a2059be1c2a/ruff-0.16.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82ae3c0c0d74daf17b968a10b7b3bb3ef297ab7de0c1f749646b25e690ccb150", size = 11470071, upload-time = "2026-07-30T19:36:39.91Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b", size = 10134195, upload-time = "2026-08-20T17:43:42.351Z" },
|
{ url = "https://files.pythonhosted.org/packages/9c/72/32ae7db4c0b5e32ab611787caa19d1546800676d79f7483b7100a3561bf4/ruff-0.16.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4d5f2ed10f8242d83fc08d521301089364e3375375705356f20c0e31606ef3ef", size = 10919503, upload-time = "2026-07-30T19:36:42.65Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d", size = 9825821, upload-time = "2026-08-20T17:43:44.532Z" },
|
{ url = "https://files.pythonhosted.org/packages/f7/ca/3d901ba6ad6fc38da39c3448fc6c59ac945679293a17c3ceb6d6c1cba13e/ruff-0.16.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a4665b309891f83f3e3c25447935f1213e9abbd4b5640af7a1f2def9f8d413c1", size = 10649861, upload-time = "2026-07-30T19:36:45.18Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0", size = 10267658, upload-time = "2026-08-20T17:43:46.989Z" },
|
{ url = "https://files.pythonhosted.org/packages/92/79/894ef1ced26552d5f8c9cf6d85b0687840e1128c55aeab7b9c2d54a0d880/ruff-0.16.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26e9ca5c9bc3971f20d3cf18a957f52ffd6a5f6564ff15c4912a144dcac22494", size = 11148137, upload-time = "2026-07-30T19:36:47.936Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d", size = 10697071, upload-time = "2026-08-20T17:43:49.891Z" },
|
{ url = "https://files.pythonhosted.org/packages/2d/69/3609a09fa1cb46cc28b762363e440a354204e5dff01bd0c8d7437874d6b9/ruff-0.16.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:67e1e1e3fa4f0c82f0e36d4cd61e661f6e7a6196cb1aa92fe0828fa7b8f257cd", size = 11559211, upload-time = "2026-07-30T19:36:50.448Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5014,10 +5014,10 @@ version = "2.13.0+cpu"
|
|||||||
source = { registry = "https://download.pytorch.org/whl/cpu" }
|
source = { registry = "https://download.pytorch.org/whl/cpu" }
|
||||||
resolution-markers = [
|
resolution-markers = [
|
||||||
"python_full_version >= '3.15' and sys_platform == 'linux'",
|
"python_full_version >= '3.15' and sys_platform == 'linux'",
|
||||||
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
|
||||||
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
|
||||||
"python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
"python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||||
"python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
"python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||||
|
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||||
|
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||||
"(python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'linux')",
|
"(python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'linux')",
|
||||||
"python_full_version < '3.12' and sys_platform == 'linux'",
|
"python_full_version < '3.12' and sys_platform == 'linux'",
|
||||||
]
|
]
|
||||||
@@ -5809,7 +5809,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zensical"
|
name = "zensical"
|
||||||
version = "0.0.57"
|
version = "0.0.51"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
@@ -5821,18 +5821,18 @@ dependencies = [
|
|||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
{ name = "tomli" },
|
{ name = "tomli" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/83/f4/fa40086c46a2e59e3d9239031f76623622e60e0d79f3df1282df2797a5c4/zensical-0.0.57.tar.gz", hash = "sha256:25fcbdf89a57153cc3ad1108a89d17c7226da5d3c551a8839c69cbd9c472a9d8", size = 4000458, upload-time = "2026-08-21T20:43:49.5Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/b8/f7/d07ffb268ca86afb26b7f32dbabe25dec03d3aa63ba4d876720c84681d33/zensical-0.0.51.tar.gz", hash = "sha256:de25de067bedfa18f916d7f366fd64a7fbf09bfcc615b44d1ddbe3b5fe02ab49", size = 3979640, upload-time = "2026-07-17T18:08:03.445Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/92/b9/49c37dc65105d1ca4a8b600a02c84ece00218d2293b2630611c620185ca3/zensical-0.0.57-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:98867d1a6ea2c57f1ebcf4902f61601f427350f2df0c04e30cfac8ba6163cd29", size = 12888507, upload-time = "2026-08-21T20:43:20.365Z" },
|
{ url = "https://files.pythonhosted.org/packages/48/21/02db3e1fb3904016bfac310037c95b9f1eaaf0ffe7b4a84f14263a7d95df/zensical-0.0.51-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:134d776afa526098e05e34713e2f577c075e57a232e01b97842bb0206716afce", size = 12791154, upload-time = "2026-07-17T18:07:20.748Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/05/f7/54539984418de11387bbace39a744195555d32c98c95bf4d112b432548f5/zensical-0.0.57-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:0d7935d77d73a279545052e05d89d31960f30c1f33f53933f4c101fa271aee74", size = 12778169, upload-time = "2026-08-21T20:43:22.879Z" },
|
{ url = "https://files.pythonhosted.org/packages/a2/35/b0d96f58253514cb3d08f5779020ab01ee5472334fb984b92e3fc9e9c9ac/zensical-0.0.51-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e97ab39668ae3b452c550634e921a0336443743aae5e1fe031c7bb57d049e535", size = 12692190, upload-time = "2026-07-17T18:07:24.553Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/40/16/74aa60aa4cfecd5bd31ce60cb6a092cb56f1bc1aaadcc173463861ea4eb5/zensical-0.0.57-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7046d433511d97aa603915f0f6792d15b7f839793abc2b66ab7b7ff753ecff5", size = 13230823, upload-time = "2026-08-21T20:43:25.141Z" },
|
{ url = "https://files.pythonhosted.org/packages/2e/90/7a60e126a10c37c6b789938ff17e73fe76bba707fa029cb40ac659aeaa82/zensical-0.0.51-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c9579809f88608e7aa2cff516fff9d267d74a843cf6088a5f4227de2f092bb5", size = 13139337, upload-time = "2026-07-17T18:07:27.885Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/d1/742d2487dd65dd18277daebcd37db56d5bd4a2408df02bde703ef8fb7b64/zensical-0.0.57-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ab85c5066b95e3a877cf8971e4ce30abb1ca1459fbfcc631f0a5a2bab56351a4", size = 13170523, upload-time = "2026-08-21T20:43:27.456Z" },
|
{ url = "https://files.pythonhosted.org/packages/ae/c3/9101c97b90d4713ef2816db03366a45ae4762efebffd296737a2dd2df325/zensical-0.0.51-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296dc7a14aa28b81a58eb57df2d5c9c9a4b0de7e90c11d99c943354287952925", size = 13069851, upload-time = "2026-07-17T18:07:31.814Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/56/6f/12b570775d344f1a3d77e26d4ae0160bcac9e41ca38f7135352ccdf9b2c8/zensical-0.0.57-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f13d1b57ad3c8b8634933a93ea870ebac11245fe0c968d27fd2a059ee1c6311", size = 13549941, upload-time = "2026-08-21T20:43:29.964Z" },
|
{ url = "https://files.pythonhosted.org/packages/d2/79/0474df9e15a2c18f6281a786e10177c1b6e16feac1c568e7f36ad39b339c/zensical-0.0.51-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f779d2d87b4bf228cf2e279bc0ae6bcf3b36a9335ff283a317d01f7c15ae46b2", size = 13451083, upload-time = "2026-07-17T18:07:35.543Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7b/4e/436e6fc76674244c084ef7f6f17dc5ff85c76b15aef77c48b703fd0a2dda/zensical-0.0.57-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:021dd8fb70d1816cd012684fcf45d32b8f88a0cd28b7cbe71e5f8564f6d5764d", size = 13210086, upload-time = "2026-08-21T20:43:32.098Z" },
|
{ url = "https://files.pythonhosted.org/packages/fe/6f/91bbf78f704d5fd4c0c9be27d6bce3b6e4c2c339e4dcd6e7cf19ecda643c/zensical-0.0.51-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f813a1514a90890ca86248a8d54b81b2164bcbff11a6bcf11b01e1c01a1454", size = 13110446, upload-time = "2026-07-17T18:07:38.783Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ef/52/20f3aeda9af1090f24241670a5cc20fff7494545fea9f5fa094c82f3dbdf/zensical-0.0.57-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7e10f3c27fdc3eac3a9ae6ddcd87f3f00edc9f332050923313c95537961bfadd", size = 13408253, upload-time = "2026-08-21T20:43:34.258Z" },
|
{ url = "https://files.pythonhosted.org/packages/d9/89/aa9a95f81771614c37bdc52b8ab21fcdef4c8de7c9cedf34e9bf62674281/zensical-0.0.51-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:186ef37e0eee0e969e2cfae47b1b97775e3164e2cba95c71faa4dd6ef47ed009", size = 13315871, upload-time = "2026-07-17T18:07:42.43Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e1/f2/2b18ba2f19674dbfcf745f3b66e005cc8efa66a1bcaba5e1b4f79467868a/zensical-0.0.57-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:78c85fee55c5aac3bdf8157e980c56397dca835167a5577c5429b5eb24ed990c", size = 13446689, upload-time = "2026-08-21T20:43:36.527Z" },
|
{ url = "https://files.pythonhosted.org/packages/08/11/1bf6e9ded29d376f8c12644cc4de04676b010fee8caa17f682606b1f16d5/zensical-0.0.51-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f5d91ce246ed930224603083cef02ae8947132fc7c52901d72015ea03526fa58", size = 13344382, upload-time = "2026-07-17T18:07:46.066Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/05/ba/68cdba447a9097e5f97742eef046020c6fa42d82972849b3a46a0718e890/zensical-0.0.57-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:478d252e1924f3876e72cf7806967cb62e50d86eddb3da04bf43e882b532fa1b", size = 13598580, upload-time = "2026-08-21T20:43:38.646Z" },
|
{ url = "https://files.pythonhosted.org/packages/d6/ec/663f16ff82d08b212e7c3236a88bd332f73331f94ddac1c91aaf882bbd1e/zensical-0.0.51-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:b1108eae82c6e8ffc33026f60b485c1512647a5333be4f547166b7c8877b98af", size = 13499628, upload-time = "2026-07-17T18:07:49.196Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/89/6358a4df272328bed5bea90b04d43e73758bc45ff058c5cb2665e1147314/zensical-0.0.57-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66a9ca6b5f625b2a2b215eec2f3c72843a92d5d512042045ac6351d5dee9b339", size = 13557609, upload-time = "2026-08-21T20:43:40.866Z" },
|
{ url = "https://files.pythonhosted.org/packages/60/b4/7f1b6c3cf06d9f6ff5216523168a5d6ccc693444d5ceeb911eca97b30d98/zensical-0.0.51-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6fa0ecaf14f56841bfc595fa141396350c72aafbec73a016ebe3c824ed21ac72", size = 13451420, upload-time = "2026-07-17T18:07:52.563Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Reference in New Issue
Block a user