Compare commits

..
Author SHA1 Message Date
dependabot[bot] 47288d4559 Chore(deps): Bump the pre-commit-dependencies group with 3 updates
Bumps the pre-commit-dependencies group with 3 updates: [https://github.com/astral-sh/ruff-pre-commit](https://github.com/astral-sh/ruff-pre-commit), [https://github.com/tox-dev/pyproject-fmt](https://github.com/tox-dev/pyproject-fmt) and [https://github.com/AleksaC/hadolint-py](https://github.com/AleksaC/hadolint-py).


Updates `https://github.com/astral-sh/ruff-pre-commit` from v0.16.1 to 0.16.4
- [Release notes](https://github.com/astral-sh/ruff-pre-commit/releases)
- [Commits](https://github.com/astral-sh/ruff-pre-commit/compare/v0.16.1...v0.16.4)

Updates `https://github.com/tox-dev/pyproject-fmt` from v2.26.0 to 2.28.1
- [Release notes](https://github.com/tox-dev/pyproject-fmt/releases)
- [Commits](https://github.com/tox-dev/pyproject-fmt/compare/v2.26.0...v2.28.1)

Updates `https://github.com/AleksaC/hadolint-py` from v2.14.0 to 2.15.1
- [Commits](https://github.com/AleksaC/hadolint-py/compare/v2.14.0...v2.15.1)

---
updated-dependencies:
- dependency-name: https://github.com/astral-sh/ruff-pre-commit
  dependency-version: 0.16.4
  dependency-type: direct:production
  dependency-group: pre-commit-dependencies
- dependency-name: https://github.com/tox-dev/pyproject-fmt
  dependency-version: 2.28.1
  dependency-type: direct:production
  dependency-group: pre-commit-dependencies
- dependency-name: https://github.com/AleksaC/hadolint-py
  dependency-version: 2.15.1
  dependency-type: direct:production
  dependency-group: pre-commit-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-01 20:23:31 +00:00
157 changed files with 28049 additions and 28516 deletions
+1
View File
@@ -38,6 +38,7 @@ body:
label: Installation method
options:
- Docker - official image
- Docker - linuxserver.io image
- Bare metal
- Other (please describe above)
- type: textarea
+2
View File
@@ -87,8 +87,10 @@ body:
label: Installation method
options:
- Docker - official image
- Docker - linuxserver.io image
- Bare metal
- Other (please describe above)
description: Note there are significant differences from the official image and linuxserver.io, please check if your issue is specific to the third-party image.
validations:
required: true
- type: textarea
+3 -3
View File
@@ -50,18 +50,18 @@ repos:
- 'prettier-plugin-organize-imports@4.3.0'
# Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.1
rev: v0.16.4
hooks:
- id: ruff-check
- id: ruff-format
- repo: https://github.com/tox-dev/pyproject-fmt
rev: "v2.26.0"
rev: "v2.28.1"
hooks:
- id: pyproject-fmt
additional_dependencies: [tomli]
# Dockerfile hooks
- repo: https://github.com/AleksaC/hadolint-py
rev: v2.14.0
rev: v2.15.1
hooks:
- id: hadolint
# Shell script hooks
+1 -1
View File
@@ -30,7 +30,7 @@ RUN set -eux \
# Purpose: Installs s6-overlay and rootfs
# Comments:
# - Don't leave anything extra in here either
FROM ghcr.io/astral-sh/uv:0.12.9-python3.14-trixie-slim AS s6-overlay-base
FROM ghcr.io/astral-sh/uv:0.12.5-python3.14-trixie-slim AS s6-overlay-base
WORKDIR /usr/src/s6
+3 -3
View File
@@ -4,7 +4,7 @@
# correct networking for the tests
services:
gotenberg:
image: docker.io/gotenberg/gotenberg:8.36
image: docker.io/gotenberg/gotenberg:8.34
hostname: gotenberg
container_name: gotenberg
network_mode: host
@@ -24,7 +24,7 @@ services:
network_mode: host
restart: unless-stopped
greenmail:
image: docker.io/greenmail/standalone:2.1.13
image: docker.io/greenmail/standalone:2.1.11
hostname: greenmail
container_name: greenmail
environment:
@@ -35,7 +35,7 @@ services:
- "3143:3143" # IMAP
restart: unless-stopped
nginx:
image: docker.io/nginx:1.31.5-alpine
image: docker.io/nginx:1.31.3-alpine
hostname: nginx
container_name: nginx
ports:
@@ -72,7 +72,7 @@ services:
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg:
image: docker.io/gotenberg/gotenberg:8.36
image: docker.io/gotenberg/gotenberg:8.34
restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript.
@@ -67,7 +67,7 @@ services:
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg:
image: docker.io/gotenberg/gotenberg:8.36
image: docker.io/gotenberg/gotenberg:8.34
restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript.
@@ -56,7 +56,7 @@ services:
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg:
image: docker.io/gotenberg/gotenberg:8.36
image: docker.io/gotenberg/gotenberg:8.34
restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript.
-64
View File
@@ -1,69 +1,5 @@
# Changelog
## paperless-ngx 3.1.3
### Bug Fixes
- Fix: use the header loading indicator on tasks page [@shamoon](https://github.com/shamoon) ([#13949](https://github.com/paperless-ngx/paperless-ngx/pull/13949))
- Fix: fix load sidebar size animating [@shamoon](https://github.com/shamoon) ([#13947](https://github.com/paperless-ngx/paperless-ngx/pull/13947))
- Fix: wrap long words without spaces in dropdowns [@shamoon](https://github.com/shamoon) ([#13945](https://github.com/paperless-ngx/paperless-ngx/pull/13945))
- Fix: tweak tool calling localization prompt [@shamoon](https://github.com/shamoon) ([#13943](https://github.com/paperless-ngx/paperless-ngx/pull/13943))
- Fix: skip vector store document id filter for unrestricted chat users [@stumpylog](https://github.com/stumpylog) ([#13937](https://github.com/paperless-ngx/paperless-ngx/pull/13937))
- Fix: adopt the request stream when pinning an outbound host [@ThomasSteinbach](https://github.com/ThomasSteinbach) ([#13927](https://github.com/paperless-ngx/paperless-ngx/pull/13927))
- Fix: ensure apply ai suggestions always runs after document created [@shamoon](https://github.com/shamoon) ([#13940](https://github.com/paperless-ngx/paperless-ngx/pull/13940))
- Fix: Handle failures when enqueuing files for consumption [@stumpylog](https://github.com/stumpylog) ([#13935](https://github.com/paperless-ngx/paperless-ngx/pull/13935))
- Fix: Handle Celery mail task chord errors [@stumpylog](https://github.com/stumpylog) ([#13936](https://github.com/paperless-ngx/paperless-ngx/pull/13936))
- Fix/chore: refactor some signal-backed conversion technical debt [@shamoon](https://github.com/shamoon) ([#13902](https://github.com/paperless-ngx/paperless-ngx/pull/13902))
- Fix: fix slim sidebar saved view dragging appearance [@shamoon](https://github.com/shamoon) ([#13906](https://github.com/paperless-ngx/paperless-ngx/pull/13906))
- Fix: use signal-backed queries input in CF dropdown to reflect changes immediately under zoneless [@shamoon](https://github.com/shamoon) ([#13901](https://github.com/paperless-ngx/paperless-ngx/pull/13901))
- Fix: use root doc metadata for filename generation [@shamoon](https://github.com/shamoon) ([#13893](https://github.com/paperless-ngx/paperless-ngx/pull/13893))
- Fix: some css cleanup [@shamoon](https://github.com/shamoon) ([#13891](https://github.com/paperless-ngx/paperless-ngx/pull/13891))
### Dependencies
<details>
<summary>7 changes</summary>
- Chore(deps): Bump the uv group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13958](https://github.com/paperless-ngx/paperless-ngx/pull/13958))
- docker-compose(deps): bump nginx from 1.31.3-alpine to 1.31.5-alpine in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#13909](https://github.com/paperless-ngx/paperless-ngx/pull/13909))
- docker-compose(deps): Bump greenmail/standalone from 2.1.11 to 2.1.13 in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#13907](https://github.com/paperless-ngx/paperless-ngx/pull/13907))
- Chore(deps-dev): Bump postcss-selector-parser from 6.1.2 to 6.1.4 in /src/paperless\_mail/templates in the npm\_and\_yarn group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13905](https://github.com/paperless-ngx/paperless-ngx/pull/13905))
- Chore(deps): Bump nltk from 3.10.0 to 3.10.3 in the data-nlp-search group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13917](https://github.com/paperless-ngx/paperless-ngx/pull/13917))
- Chore(deps): Bump the frontend-angular-dependencies group across 1 directory with 14 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13915](https://github.com/paperless-ngx/paperless-ngx/pull/13915))
- Chore(deps): Bump djangorestframework from 3.17.1 to 3.17.2 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13904](https://github.com/paperless-ngx/paperless-ngx/pull/13904))
</details>
### All App Changes
<details>
<summary>22 changes</summary>
- Chore(deps): Bump the uv group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13958](https://github.com/paperless-ngx/paperless-ngx/pull/13958))
- Chore(deps-dev): Bump postcss-selector-parser from 6.1.2 to 6.1.4 in /src/paperless\_mail/templates in the npm\_and\_yarn group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13905](https://github.com/paperless-ngx/paperless-ngx/pull/13905))
- Chore(deps): Bump nltk from 3.10.0 to 3.10.3 in the data-nlp-search group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13917](https://github.com/paperless-ngx/paperless-ngx/pull/13917))
- Fix: use the header loading indicator on tasks page [@shamoon](https://github.com/shamoon) ([#13949](https://github.com/paperless-ngx/paperless-ngx/pull/13949))
- Chore(deps): Bump the frontend-angular-dependencies group across 1 directory with 14 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13915](https://github.com/paperless-ngx/paperless-ngx/pull/13915))
- Chore(deps): Bump djangorestframework from 3.17.1 to 3.17.2 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13904](https://github.com/paperless-ngx/paperless-ngx/pull/13904))
- Fix: fix load sidebar size animating [@shamoon](https://github.com/shamoon) ([#13947](https://github.com/paperless-ngx/paperless-ngx/pull/13947))
- Fix: wrap long words without spaces in dropdowns [@shamoon](https://github.com/shamoon) ([#13945](https://github.com/paperless-ngx/paperless-ngx/pull/13945))
- Fix: tweak tool calling localization prompt [@shamoon](https://github.com/shamoon) ([#13943](https://github.com/paperless-ngx/paperless-ngx/pull/13943))
- Fix: skip vector store document id filter for unrestricted chat users [@stumpylog](https://github.com/stumpylog) ([#13937](https://github.com/paperless-ngx/paperless-ngx/pull/13937))
- Fix: adopt the request stream when pinning an outbound host [@ThomasSteinbach](https://github.com/ThomasSteinbach) ([#13927](https://github.com/paperless-ngx/paperless-ngx/pull/13927))
- Fix: ensure apply ai suggestions always runs after document created [@shamoon](https://github.com/shamoon) ([#13940](https://github.com/paperless-ngx/paperless-ngx/pull/13940))
- Fix: Handle failures when enqueuing files for consumption [@stumpylog](https://github.com/stumpylog) ([#13935](https://github.com/paperless-ngx/paperless-ngx/pull/13935))
- Fix: Handle Celery mail task chord errors [@stumpylog](https://github.com/stumpylog) ([#13936](https://github.com/paperless-ngx/paperless-ngx/pull/13936))
- Fix/chore: refactor some signal-backed conversion technical debt [@shamoon](https://github.com/shamoon) ([#13902](https://github.com/paperless-ngx/paperless-ngx/pull/13902))
- Security: validate remote OCR endpoint [@stumpylog](https://github.com/stumpylog) ([#13897](https://github.com/paperless-ngx/paperless-ngx/pull/13897))
- Fix: fix slim sidebar saved view dragging appearance [@shamoon](https://github.com/shamoon) ([#13906](https://github.com/paperless-ngx/paperless-ngx/pull/13906))
- Security: Minor additional hardening [@stumpylog](https://github.com/stumpylog) ([#13898](https://github.com/paperless-ngx/paperless-ngx/pull/13898))
- Chore: consolidate pickle hmac signing [@shamoon](https://github.com/shamoon) ([#13899](https://github.com/paperless-ngx/paperless-ngx/pull/13899))
- Fix: use signal-backed queries input in CF dropdown to reflect changes immediately under zoneless [@shamoon](https://github.com/shamoon) ([#13901](https://github.com/paperless-ngx/paperless-ngx/pull/13901))
- Fix: use root doc metadata for filename generation [@shamoon](https://github.com/shamoon) ([#13893](https://github.com/paperless-ngx/paperless-ngx/pull/13893))
- Fix: some css cleanup [@shamoon](https://github.com/shamoon) ([#13891](https://github.com/paperless-ngx/paperless-ngx/pull/13891))
</details>
## paperless-ngx 3.1.2
### Bug Fixes
+2 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "paperless-ngx"
version = "3.1.3"
version = "3.1.2"
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
readme = "README.md"
requires-python = ">=3.11"
@@ -247,7 +247,7 @@ per-file-ignores."src/documents/models.py" = [
isort.force-single-line = true
[tool.codespell]
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish,NIN,nin"
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish"
skip = """\
src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/paperless/tests/samples\
/mail/*,src/documents/tests/samples/*,*.po,*.json\
+205 -201
View File
File diff suppressed because it is too large Load Diff
+14 -14
View File
@@ -1,6 +1,6 @@
{
"name": "paperless-ngx-ui",
"version": "3.1.3",
"version": "3.1.2",
"scripts": {
"preinstall": "npx only-allow pnpm",
"ng": "ng",
@@ -15,14 +15,14 @@
},
"private": true,
"dependencies": {
"@angular/cdk": "^22.1.4",
"@angular/common": "~22.1.3",
"@angular/compiler": "~22.1.3",
"@angular/core": "~22.1.3",
"@angular/forms": "~22.1.3",
"@angular/localize": "~22.1.3",
"@angular/platform-browser": "~22.1.3",
"@angular/router": "~22.1.3",
"@angular/cdk": "^22.1.1",
"@angular/common": "~22.1.1",
"@angular/compiler": "~22.1.1",
"@angular/core": "~22.1.1",
"@angular/forms": "~22.1.1",
"@angular/localize": "~22.1.1",
"@angular/platform-browser": "~22.1.1",
"@angular/router": "~22.1.1",
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
"@ng-select/ng-select": "~23.6.0",
"@ngneat/dirty-check-forms": "^3.0.3",
@@ -45,16 +45,16 @@
},
"devDependencies": {
"@angular-builders/jest": "^22.0.1",
"@angular-devkit/core": "^22.1.6",
"@angular-devkit/schematics": "^22.1.6",
"@angular-devkit/core": "^22.1.3",
"@angular-devkit/schematics": "^22.1.3",
"@angular-eslint/builder": "22.1.0",
"@angular-eslint/eslint-plugin": "22.1.0",
"@angular-eslint/eslint-plugin-template": "22.1.0",
"@angular-eslint/schematics": "22.1.0",
"@angular-eslint/template-parser": "22.1.0",
"@angular/build": "22.1.6",
"@angular/cli": "22.1.6",
"@angular/compiler-cli": "~22.1.3",
"@angular/build": "22.1.3",
"@angular/cli": "22.1.3",
"@angular/compiler-cli": "~22.1.1",
"@playwright/test": "^1.62.1",
"@types/jest": "^30.0.0",
"@types/node": "^26.2.0",
+629 -437
View File
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,6 @@
i18n-title
info="Tasks shows detailed information about document consumption and system tasks."
i18n-info
[loading]="loading() && pagedTasks().length === 0"
>
<div class="btn-toolbar col col-md-auto align-items-center gap-2">
<button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedTasks.size === 0">
@@ -22,6 +21,11 @@
</div>
</pngx-page-header>
@if (loading() && pagedTasks().length === 0) {
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>
}
<div class="task-controls mb-3 gap-3 btn-toolbar align-items-center" role="toolbar">
<div class="task-view-scope btn-group btn-group-sm" role="group">
<input
@@ -281,6 +285,6 @@
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks: tasksForSection(section), section: section}"></ng-container>
</div>
}
} @else if (!loading() || pagedTasks().length > 0) {
} @else {
<div class="alert alert-secondary fst-italic" i18n>No tasks match the current filters.</div>
}
@@ -41,8 +41,6 @@ export class TrashComponent
private modalService = inject(NgbModal)
private settingsService = inject(SettingsService)
private router = inject(Router)
private readonly emptyTrashDelaySetting =
this.settingsService.getSignal<number>(SETTINGS_KEYS.EMPTY_TRASH_DELAY)
readonly documentsInTrash = signal<Document[]>([])
readonly selectedDocuments = signal<Set<number>>(new Set())
@@ -202,7 +200,8 @@ export class TrashComponent
}
getDaysRemaining(document: Document): number {
const delay = this.emptyTrashDelaySetting()
this.settingsService.trackChanges()
const delay = this.settingsService.get(SETTINGS_KEYS.EMPTY_TRASH_DELAY)
const diff = new Date().getTime() - new Date(document.deleted_at).getTime()
const days = Math.ceil(diff / (1000 * 3600 * 24))
return delay - days
@@ -34,7 +34,9 @@
}
@media (min-width: 768px) {
--pngx-sidebar-width: var(--pngx-sidebar-expanded-width);
&.expanded {
--pngx-sidebar-width: var(--pngx-sidebar-expanded-width);
}
}
}
@media (max-width: 767.98px) {
@@ -111,13 +113,6 @@ main {
}
}
// only animate when the user toggles slim mode
.sidebar:not(.animating),
.sidebar:not(.animating) ~ main,
.sidebar:not(.animating) .sidebar-slim-toggler {
transition: none;
}
.sidebar.slim {
max-width: 55px;
@@ -128,6 +123,8 @@ main {
}
.sidebar.slim:not(.animating) {
transition: none;
li.nav-item span,
.sidebar-heading span {
display: none;
@@ -147,6 +144,10 @@ main {
}
}
.sidebar.slim:not(.animating) ~ main.col-slim {
transition: none;
}
.sidebar.animating {
li.nav-item span,
.sidebar-heading span {
@@ -195,26 +196,6 @@ main {
--bs-popover-body-padding-y: .5rem;
}
@media (prefers-reduced-motion: no-preference) {
.sidebar-sticky > ul,
.sidebar-sticky > .nav-group {
animation: sidebar-nav-in .3s cubic-bezier(.22, .61, .36, 1) backwards;
}
@for $i from 2 through 5 {
.sidebar-sticky > :nth-child(#{$i}) {
animation-delay: #{($i - 1) * 0.04}s;
}
}
}
@keyframes sidebar-nav-in {
from {
opacity: 0;
transform: translateY(6px);
}
}
.sidebar-sticky {
position: relative;
top: 0;
@@ -193,23 +193,6 @@ describe('AppFrameComponent', () => {
expect(savedViewSpy).toHaveBeenCalled()
})
it('should update reinitialized signal-backed settings without manual change detection', async () => {
settingsService.initializeSettings().subscribe()
httpTestingController
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
.flush({
settings: { app_title: 'Reactive title' },
user: {},
permissions: [],
})
await fixture.whenStable()
expect(
fixture.nativeElement.querySelector('.brand-title').textContent
).toBe('Reactive title')
})
it('should check for update if enabled', () => {
const updateCheckSpy = jest.spyOn(remoteVersionService, 'checkForUpdates')
updateCheckSpy.mockImplementation(() => {
@@ -98,29 +98,6 @@ export class AppFrameComponent
readonly isMenuCollapsed = signal(true)
readonly slimSidebarAnimating = signal(false)
readonly mobileSearchHidden = signal(false)
private readonly versionSetting = this.settingsService.getSignal<string>(
SETTINGS_KEYS.VERSION
)
private readonly appTitleSetting = this.settingsService.getSignal<string>(
SETTINGS_KEYS.APP_TITLE
)
private readonly appLogoSetting = this.settingsService.getSignal<string>(
SETTINGS_KEYS.APP_LOGO
)
private readonly slimSidebarSetting = this.settingsService.getSignal<boolean>(
SETTINGS_KEYS.SLIM_SIDEBAR
)
private readonly attributesSectionsCollapsedSetting =
this.settingsService.getSignal<CollapsibleSection[]>(
SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED
)
private readonly aiEnabledSetting = this.settingsService.getSignal<boolean>(
SETTINGS_KEYS.AI_ENABLED
)
private readonly sidebarViewsShowCountSetting =
this.settingsService.getSignal<boolean>(
SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT
)
private lastScrollY: number = 0
constructor() {
@@ -214,23 +191,33 @@ export class AppFrameComponent
}
get versionString(): string {
return `${environment.appTitle} v${this.versionSetting()}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
this.settingsService.trackChanges()
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
}
get appTitle(): string {
return this.appTitleSetting() || environment.appTitle
this.settingsService.trackChanges()
return (
this.settingsService.get(SETTINGS_KEYS.APP_TITLE) || environment.appTitle
)
}
get customAppTitle(): string {
return this.appTitleSetting()
this.settingsService.trackChanges()
return this.settingsService.get(SETTINGS_KEYS.APP_TITLE)
}
get hasCustomBranding(): boolean {
return !!(this.appTitleSetting()?.length || this.appLogoSetting()?.length)
this.settingsService.trackChanges()
return !!(
this.settingsService.get(SETTINGS_KEYS.APP_TITLE)?.length ||
this.settingsService.get(SETTINGS_KEYS.APP_LOGO)?.length
)
}
get customAppLogo(): string {
const logo = this.appLogoSetting()
this.settingsService.trackChanges()
const logo = this.settingsService.get(SETTINGS_KEYS.APP_LOGO)
return logo?.length
? environment.apiBaseUrl.replace(/\/api\/$/, logo)
: null
@@ -275,7 +262,8 @@ export class AppFrameComponent
}
get slimSidebarEnabled(): boolean {
return this.slimSidebarSetting()
this.settingsService.trackChanges()
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
}
set slimSidebarEnabled(enabled: boolean) {
@@ -298,9 +286,10 @@ export class AppFrameComponent
}
get attributesSectionsCollapsed(): boolean {
return this.attributesSectionsCollapsedSetting()?.includes(
CollapsibleSection.ATTRIBUTES
)
this.settingsService.trackChanges()
return this.settingsService
.get(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED)
?.includes(CollapsibleSection.ATTRIBUTES)
}
set attributesSectionsCollapsed(collapsed: boolean) {
@@ -323,7 +312,8 @@ export class AppFrameComponent
}
get aiEnabled(): boolean {
return this.aiEnabledSetting()
this.settingsService.trackChanges()
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
}
@HostListener('window:resize')
@@ -490,8 +480,9 @@ export class AppFrameComponent
}
get showSidebarCounts(): boolean {
this.settingsService.trackChanges()
return (
this.sidebarViewsShowCountSetting() &&
this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT) &&
!this.settingsService.organizingSidebarSavedViews()
)
}
@@ -81,10 +81,6 @@ export class GlobalSearchComponent implements OnInit {
private hotkeyService = inject(HotKeyService)
private settingsService = inject(SettingsService)
private locationStrategy = inject(LocationStrategy)
private readonly searchFullTypeSetting =
this.settingsService.getSignal<GlobalSearchType>(
SETTINGS_KEYS.SEARCH_FULL_TYPE
)
public DataType = DataType
readonly query = signal<string>(null)
@@ -101,7 +97,11 @@ export class GlobalSearchComponent implements OnInit {
@ViewChildren('secondaryButton') secondaryButtons: QueryList<ElementRef>
get useAdvancedForFullSearch(): boolean {
return this.searchFullTypeSetting() === GlobalSearchType.ADVANCED
this.settingsService.trackChanges()
return (
this.settingsService.get(SETTINGS_KEYS.SEARCH_FULL_TYPE) ===
GlobalSearchType.ADVANCED
)
}
constructor() {
@@ -196,16 +196,6 @@ describe('WorkflowEditDialogComponent', () => {
fixture.detectChanges()
})
function setActionSettings({
email = true,
remoteOcr = true,
ai = true,
} = {}) {
settingsService.set(SETTINGS_KEYS.EMAIL_ENABLED, email)
settingsService.set(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED, remoteOcr)
settingsService.set(SETTINGS_KEYS.AI_ENABLED, ai)
}
it('should support create and edit modes, support adding triggers and actions on new workflow', () => {
component.dialogMode.set(EditDialogMode.CREATE)
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
@@ -228,7 +218,7 @@ describe('WorkflowEditDialogComponent', () => {
})
it('should return source options, type options, type name, schedule date field options', () => {
setActionSettings()
jest.spyOn(settingsService, 'get').mockReturnValue(true)
component.ngOnInit()
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS)
expect(component.triggerTypeOptions).toEqual(WORKFLOW_TYPE_OPTIONS)
@@ -252,7 +242,7 @@ describe('WorkflowEditDialogComponent', () => {
)
// Email, remote OCR and AI all disabled
setActionSettings({ email: false, remoteOcr: false, ai: false })
jest.spyOn(settingsService, 'get').mockReturnValue(false)
component.ngOnInit()
expect(component.actionTypeOptions).toEqual(
WORKFLOW_ACTION_OPTIONS.filter(
@@ -265,7 +255,7 @@ describe('WorkflowEditDialogComponent', () => {
})
it('should offer remote OCR only for consumption workflows', () => {
setActionSettings()
jest.spyOn(settingsService, 'get').mockReturnValue(true)
// A consumption trigger makes the action reachable
component.object = {
@@ -295,7 +285,7 @@ describe('WorkflowEditDialogComponent', () => {
})
it('should offer remote OCR on a trigger added to a new workflow', () => {
setActionSettings()
jest.spyOn(settingsService, 'get').mockReturnValue(true)
component.ngOnInit()
// Nothing for the action to apply to yet
@@ -321,7 +311,7 @@ describe('WorkflowEditDialogComponent', () => {
})
it('should keep remote OCR listed when an action already uses it', () => {
setActionSettings()
jest.spyOn(settingsService, 'get').mockReturnValue(true)
// Otherwise changing the trigger would silently blank the selection
component.object = {
@@ -339,7 +329,9 @@ describe('WorkflowEditDialogComponent', () => {
})
it('should not offer remote OCR when no engine is configured', () => {
setActionSettings({ remoteOcr: false })
jest
.spyOn(settingsService, 'get')
.mockImplementation((key) => key !== SETTINGS_KEYS.REMOTE_OCR_CONFIGURED)
component.object = {
name: 'Workflow 1',
@@ -356,7 +348,7 @@ describe('WorkflowEditDialogComponent', () => {
})
it('should offer apply AI suggestions unless every trigger is consumption', () => {
setActionSettings()
jest.spyOn(settingsService, 'get').mockReturnValue(true)
// Consumption runs before the document has been parsed, so there would be
// no content to make suggestions from
@@ -390,7 +382,7 @@ describe('WorkflowEditDialogComponent', () => {
})
it('should keep apply AI suggestions listed when an action already uses it', () => {
setActionSettings()
jest.spyOn(settingsService, 'get').mockReturnValue(true)
// Otherwise changing the trigger would silently blank the selection
component.object = {
@@ -408,7 +400,9 @@ describe('WorkflowEditDialogComponent', () => {
})
it('should not offer apply AI suggestions when AI is disabled', () => {
setActionSettings({ ai: false })
jest
.spyOn(settingsService, 'get')
.mockImplementation((key) => key !== SETTINGS_KEYS.AI_ENABLED)
component.object = {
name: 'Workflow 1',
@@ -537,13 +537,6 @@ export class WorkflowEditDialogComponent
readonly dateCustomFields = computed(() =>
this.customFields()?.filter((f) => f.data_type === CustomFieldDataType.Date)
)
private readonly emailEnabledSetting =
this.settingsService.getSignal<boolean>(SETTINGS_KEYS.EMAIL_ENABLED)
private readonly remoteOcrConfiguredSetting =
this.settingsService.getSignal<boolean>(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED)
private readonly aiEnabledSetting = this.settingsService.getSignal<boolean>(
SETTINGS_KEYS.AI_ENABLED
)
expandedItem: number = null
@@ -596,7 +589,7 @@ export class WorkflowEditDialogComponent
private getAllowedActionTypes() {
let allowed = WORKFLOW_ACTION_OPTIONS
if (!this.emailEnabledSetting()) {
if (!this.settingsService.get(SETTINGS_KEYS.EMAIL_ENABLED)) {
allowed = allowed.filter((a) => a.id !== WorkflowActionType.Email)
}
@@ -604,7 +597,7 @@ export class WorkflowEditDialogComponent
// offered for workflows that run at consumption.
const formWorkflow: Workflow = this.objectForm?.value
const remoteOcrUsable =
this.remoteOcrConfiguredSetting() &&
this.settingsService.get(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED) &&
(formWorkflow?.triggers?.some(
(trigger) => trigger.type === WorkflowTriggerType.Consumption
) ||
@@ -619,7 +612,7 @@ export class WorkflowEditDialogComponent
// once every trigger is consumption, so it stays offered on a workflow
// that has no triggers yet.
const aiSuggestionsUsable =
this.aiEnabledSetting() &&
this.settingsService.get(SETTINGS_KEYS.AI_ENABLED) &&
(!formWorkflow?.triggers?.length ||
formWorkflow.triggers.some(
(trigger) => trigger.type !== WorkflowTriggerType.Consumption
@@ -1369,6 +1362,7 @@ export class WorkflowEditDialogComponent
}
get actionTypeOptions() {
this.settingsService.trackChanges()
// Computed on read rather than cached
return this.getAllowedActionTypes()
}
@@ -703,40 +703,6 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(selectionModel.getSelectedItems()).toEqual([other])
})
it('re-selects ancestors when a child is re-selected while editing', () => {
// https://github.com/paperless-ngx/paperless-ngx/issues/13970
const inbox: Tag = { id: 200, name: 'Inbox' }
const parent: Tag = { id: 201, name: 'Parent Tag' }
const child: Tag = { id: 202, name: 'Child Tag', parent: parent.id }
selectionModel.editing = true
selectionModel.items = [inbox, parent, child]
selectionModel.init(
new Map([
[inbox.id, ToggleableItemState.Selected],
[parent.id, ToggleableItemState.Selected],
[child.id, ToggleableItemState.Selected],
])
)
// deselecting the parent also deselects the child
selectionModel.toggle(parent.id, false)
expect(selectionModel.getSelectedItems()).toEqual([inbox])
// re-selecting the child brings its parent back, so nothing is changed
selectionModel.toggle(child.id, false)
expect(
selectionModel
.getSelectedItems()
.map((item) => item.id)
.sort((a, b) => a - b)
).toEqual([inbox.id, parent.id, child.id])
expect(selectionModel.diff()).toEqual({
itemsToAdd: [],
itemsToRemove: [],
})
})
it('un-excluding a parent clears excluded descendants', () => {
const root: Tag = { id: 110, name: 'Root Tag' }
const child: Tag = { id: 111, name: 'Child Tag', parent: root.id }
@@ -773,7 +739,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
const apple: Tag = { id: 55, name: 'Apple' }
const zebra: Tag = { id: 56, name: 'Zebra' }
selectionModel.editing = true
selectionModel.documentCountSortingEnabled = true
selectionModel.items = [apple, zebra]
expect(selectionModel.items.map((item) => item?.id ?? null)).toEqual([
null,
@@ -873,9 +839,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
selectionModel.items = [memoRoot]
selectionModel.documentCounts = [{ id: memoRoot.id, document_count: 9 }]
const getRootDocCount = (selectionModel as any).createRootDocCounter(
selectionModel.items
)
const getRootDocCount = (selectionModel as any).createRootDocCounter()
expect(getRootDocCount(memoRoot.id)).toEqual(9)
selectionModel.documentCounts = []
@@ -891,9 +855,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
selectionModel.items = [rootWithoutSelection]
selectionModel.documentCounts = []
const getRootDocCount = (selectionModel as any).createRootDocCounter(
selectionModel.items
)
const getRootDocCount = (selectionModel as any).createRootDocCounter()
expect(getRootDocCount(rootWithoutSelection.id)).toEqual(4)
})
@@ -903,9 +865,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
selectionModel.items = [rootWithoutCounts]
selectionModel.documentCounts = []
const getRootDocCount = (selectionModel as any).createRootDocCounter(
selectionModel.items
)
const getRootDocCount = (selectionModel as any).createRootDocCounter()
expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0)
})
@@ -1006,7 +966,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
component.selectionModel['temporarySelectionStates'].set(id, state)
const changedSpy = jest.spyOn(component.selectionModel.changed, 'next')
component.selectionModel.exclude(id)
expect(component.selectionModel.temporaryLogicalOperator()).toBe(
expect(component.selectionModel.temporaryLogicalOperator).toBe(
LogicalOperator.And
)
expect(component.selectionModel['temporarySelectionStates'].get(id)).toBe(
@@ -64,56 +64,43 @@ export class FilterableDropdownSelectionModel {
manyToOne = false
singleSelect = false
private _logicalOperator: LogicalOperator = LogicalOperator.And
temporaryLogicalOperator: LogicalOperator = this._logicalOperator
private _intersection: Intersection = Intersection.Include
temporaryIntersection: Intersection = this._intersection
private readonly _logicalOperator = signal(LogicalOperator.And)
readonly temporaryLogicalOperator = signal(LogicalOperator.And)
private readonly _intersection = signal(Intersection.Include)
readonly temporaryIntersection = signal(Intersection.Include)
private readonly _documentCounts = signal<SelectionDataItem[]>([])
private readonly _items = signal<MatchingModel[]>([])
private readonly _selectionStates = signal(
new Map<number, ToggleableItemState>()
)
private readonly _temporarySelectionStates = signal(
new Map<number, ToggleableItemState>()
)
public editing = false
private get selectionStates(): ReadonlyMap<number, ToggleableItemState> {
return this._selectionStates()
}
private get temporarySelectionStates(): ReadonlyMap<
number,
ToggleableItemState
> {
return this._temporarySelectionStates()
}
private _documentCounts: SelectionDataItem[] = []
public documentCountSortingEnabled = false
public set documentCounts(counts: SelectionDataItem[]) {
this._documentCounts.set(counts)
if (this.editing) {
this._items.set(this.sortItems(this.items))
this._documentCounts = counts
if (this.documentCountSortingEnabled) {
this.sortItems()
}
}
private _items: MatchingModel[] = []
get items(): MatchingModel[] {
return this._items()
return this._items
}
set items(items: MatchingModel[]) {
if (items) {
this._items.set(this.withNullItem(this.sortItems(Array.from(items))))
this._items = Array.from(items)
this.sortItems()
this.setNullItem()
}
}
private withNullItem(items: MatchingModel[]): MatchingModel[] {
private setNullItem() {
if (this.manyToOne && this.logicalOperator === LogicalOperator.Or) {
return items[0]?.id === null ? items.slice(1) : items
if (this._items[0]?.id === null) {
this._items.shift()
}
return
}
const nullItem = {
const item = {
name: $localize`:Filter drop down element to filter for documents with no correspondent/type/tag assigned:Not assigned`,
id:
this.manyToOne || this.intersection === Intersection.Include
@@ -121,17 +108,22 @@ export class FilterableDropdownSelectionModel {
: NEGATIVE_NULL_FILTER_VALUE,
}
return items[0]?.id === null || items[0]?.id === NEGATIVE_NULL_FILTER_VALUE
? [nullItem, ...items.slice(1)]
: [nullItem, ...items]
if (
this._items[0]?.id === null ||
this._items[0]?.id === NEGATIVE_NULL_FILTER_VALUE
) {
this._items[0] = item
} else if (this._items) {
this._items.unshift(item)
}
}
constructor(manyToOne: boolean = false) {
this.manyToOne = manyToOne
}
private sortItems(items: MatchingModel[]): MatchingModel[] {
const sorted = [...items].sort((a, b) => {
private sortItems() {
this._items.sort((a, b) => {
if (
(a.id == null && b.id != null) ||
(a.id == NEGATIVE_NULL_FILTER_VALUE &&
@@ -162,13 +154,13 @@ export class FilterableDropdownSelectionModel {
) {
return -1
} else if (
this._documentCounts().length &&
this._documentCounts.length &&
this.getDocumentCount(b.id) === 0 &&
this.getDocumentCount(a.id) > this.getDocumentCount(b.id)
) {
return -1
} else if (
this._documentCounts().length &&
this._documentCounts.length &&
this.getDocumentCount(a.id) === 0 &&
this.getDocumentCount(a.id) < this.getDocumentCount(b.id)
) {
@@ -178,11 +170,15 @@ export class FilterableDropdownSelectionModel {
}
})
return this._documentCounts().length
? this.promoteBranchesWithDocumentCounts(sorted)
: sorted
if (this._documentCounts.length) {
this.promoteBranchesWithDocumentCounts()
}
}
private selectionStates = new Map<number, ToggleableItemState>()
private temporarySelectionStates = new Map<number, ToggleableItemState>()
getSelectedItems() {
return this.items.filter(
(i) =>
@@ -198,33 +194,30 @@ export class FilterableDropdownSelectionModel {
}
set(id: number, state: ToggleableItemState, fireEvent = true) {
const states = new Map(this.temporarySelectionStates)
if (state == ToggleableItemState.NotSelected) {
states.delete(id)
this.temporarySelectionStates.delete(id)
} else {
states.set(id, state)
this.temporarySelectionStates.set(id, state)
}
this._temporarySelectionStates.set(states)
if (fireEvent) {
this.changed.next(this)
}
}
toggle(id: number, fireEvent = true) {
const states = new Map(this.temporarySelectionStates)
let state = states.get(id)
let state = this.temporarySelectionStates.get(id)
if (
state == undefined ||
(state != ToggleableItemState.Selected &&
state != ToggleableItemState.Excluded)
) {
if (this.manyToOne || this.singleSelect) {
states.set(id, ToggleableItemState.Selected)
this.temporarySelectionStates.set(id, ToggleableItemState.Selected)
if (this.singleSelect) {
for (let key of states.keys()) {
for (let key of this.temporarySelectionStates.keys()) {
if (key != id) {
states.delete(key)
this.temporarySelectionStates.delete(key)
}
}
}
@@ -240,29 +233,25 @@ export class FilterableDropdownSelectionModel {
) {
newState = ToggleableItemState.NotSelected
}
states.set(id, newState)
}
if (this.editing && states.get(id) == ToggleableItemState.Selected) {
this.addAncestorSelections(states, id)
this.temporarySelectionStates.set(id, newState)
}
} else if (
state == ToggleableItemState.Selected ||
state == ToggleableItemState.Excluded
) {
states.delete(id)
this.clearDescendantSelections(states, id)
this.temporarySelectionStates.delete(id)
this.clearDescendantSelections(id)
}
if (!id) {
for (let key of states.keys()) {
for (let key of this.temporarySelectionStates.keys()) {
if (key) {
states.delete(key)
this.temporarySelectionStates.delete(key)
}
}
} else {
states.delete(null)
this.temporarySelectionStates.delete(null)
}
this._temporarySelectionStates.set(states)
if (fireEvent) {
this.changed.next(this)
@@ -270,21 +259,20 @@ export class FilterableDropdownSelectionModel {
}
exclude(id: number, fireEvent: boolean = true) {
const states = new Map(this.temporarySelectionStates)
let state = states.get(id)
let state = this.temporarySelectionStates.get(id)
if (id && (state == null || state != ToggleableItemState.Excluded)) {
const operator = this.manyToOne ? LogicalOperator.And : LogicalOperator.Or
this.temporaryLogicalOperator.set(operator)
this._logicalOperator.set(operator)
this.temporaryLogicalOperator = this._logicalOperator = this.manyToOne
? LogicalOperator.And
: LogicalOperator.Or
if (this.manyToOne || this.singleSelect) {
states.set(id, ToggleableItemState.Excluded)
this.clearDescendantSelections(states, id)
this.temporarySelectionStates.set(id, ToggleableItemState.Excluded)
this.clearDescendantSelections(id)
if (this.singleSelect) {
for (let key of states.keys()) {
for (let key of this.temporarySelectionStates.keys()) {
if (key != id) {
states.delete(key)
this.temporarySelectionStates.delete(key)
}
}
}
@@ -299,18 +287,17 @@ export class FilterableDropdownSelectionModel {
) {
newState = ToggleableItemState.NotSelected
}
states.set(id, newState)
this.temporarySelectionStates.set(id, newState)
if (newState == ToggleableItemState.Excluded) {
this.clearDescendantSelections(states, id)
this.clearDescendantSelections(id)
}
}
} else if (!id || state == ToggleableItemState.Excluded) {
states.delete(id)
this.temporarySelectionStates.delete(id)
if (id) {
this.clearDescendantSelections(states, id)
this.clearDescendantSelections(id)
}
}
this._temporarySelectionStates.set(states)
if (fireEvent) {
this.changed.next(this)
@@ -321,27 +308,9 @@ export class FilterableDropdownSelectionModel {
return this.selectionStates.get(id) || ToggleableItemState.NotSelected
}
private clearDescendantSelections(
states: Map<number, ToggleableItemState>,
id: number
) {
private clearDescendantSelections(id: number) {
for (const descendantID of this.getDescendantIDs(id)) {
states.delete(descendantID)
}
}
private addAncestorSelections(
states: Map<number, ToggleableItemState>,
id: number
) {
const parentById = this.buildParentById(this.items)
const seen = new Set<number>([id])
let parentID = parentById.get(id)
while (typeof parentID === 'number' && !seen.has(parentID)) {
seen.add(parentID)
states.set(parentID, ToggleableItemState.Selected)
parentID = parentById.get(parentID)
this.temporarySelectionStates.delete(descendantID)
}
}
@@ -351,7 +320,7 @@ export class FilterableDropdownSelectionModel {
while (queue.length) {
const parentID = queue.shift()
for (const item of this.items) {
for (const item of this._items) {
if (
typeof item?.id === 'number' &&
typeof (item as any)['parent'] === 'number' &&
@@ -367,12 +336,12 @@ export class FilterableDropdownSelectionModel {
}
get logicalOperator(): LogicalOperator {
return this.temporaryLogicalOperator()
return this.temporaryLogicalOperator
}
set logicalOperator(operator: LogicalOperator) {
this.temporaryLogicalOperator.set(operator)
this._items.set(this.withNullItem(this.items))
this.temporaryLogicalOperator = operator
this.setNullItem()
}
toggleOperator() {
@@ -380,12 +349,12 @@ export class FilterableDropdownSelectionModel {
}
get intersection(): Intersection {
return this.temporaryIntersection()
return this.temporaryIntersection
}
set intersection(intersection: Intersection) {
this.temporaryIntersection.set(intersection)
this._items.set(this.withNullItem(this.items))
this.temporaryIntersection = intersection
this.setNullItem()
}
toggleIntersection() {
@@ -395,20 +364,18 @@ export class FilterableDropdownSelectionModel {
? ToggleableItemState.Selected
: ToggleableItemState.Excluded
const states = new Map(this.temporarySelectionStates)
states.forEach((state, key) => {
this.temporarySelectionStates.forEach((state, key) => {
if (key === null && this.intersection === Intersection.Exclude) {
states.set(NEGATIVE_NULL_FILTER_VALUE, newState)
this.temporarySelectionStates.set(NEGATIVE_NULL_FILTER_VALUE, newState)
} else if (
key === NEGATIVE_NULL_FILTER_VALUE &&
this.intersection === Intersection.Include
) {
states.set(null, newState)
this.temporarySelectionStates.set(null, newState)
} else {
states.set(key, newState)
this.temporarySelectionStates.set(key, newState)
}
})
this._temporarySelectionStates.set(states)
this.changed.next(this)
}
@@ -428,12 +395,10 @@ export class FilterableDropdownSelectionModel {
}
clear(fireEvent = true) {
this._temporarySelectionStates.set(new Map())
this.temporaryLogicalOperator.set(LogicalOperator.And)
this._logicalOperator.set(LogicalOperator.And)
this.temporaryIntersection.set(Intersection.Include)
this._intersection.set(Intersection.Include)
this._items.set(this.withNullItem(this.items))
this.temporarySelectionStates.clear()
this.temporaryLogicalOperator = this._logicalOperator = LogicalOperator.And
this.temporaryIntersection = this._intersection = Intersection.Include
this.setNullItem()
if (fireEvent) {
this.changed.next(this)
}
@@ -454,9 +419,9 @@ export class FilterableDropdownSelectionModel {
)
) {
return true
} else if (this.temporaryLogicalOperator() !== this._logicalOperator()) {
} else if (this.temporaryLogicalOperator !== this._logicalOperator) {
return true
} else if (this.temporaryIntersection() !== this._intersection()) {
} else if (this.temporaryIntersection !== this._intersection) {
return true
} else {
return false
@@ -473,29 +438,23 @@ export class FilterableDropdownSelectionModel {
}
getDocumentCount(id: number) {
return this._documentCounts().find((c) => c.id === id)?.document_count
return this._documentCounts.find((c) => c.id === id)?.document_count
}
private promoteBranchesWithDocumentCounts(
items: MatchingModel[]
): MatchingModel[] {
const parentById = this.buildParentById(items)
private promoteBranchesWithDocumentCounts() {
const parentById = this.buildParentById()
const findRootId = this.createRootFinder(parentById)
const getRootDocCount = this.createRootDocCounter(items)
const summaries = this.buildBranchSummaries(
items,
findRootId,
getRootDocCount
)
const getRootDocCount = this.createRootDocCounter()
const summaries = this.buildBranchSummaries(findRootId, getRootDocCount)
const orderedBranches = this.orderBranchesByPriority(summaries)
return orderedBranches.flatMap((summary) => summary.items)
this._items = orderedBranches.flatMap((summary) => summary.items)
}
private buildParentById(items: MatchingModel[]): Map<number, number | null> {
private buildParentById(): Map<number, number | null> {
const parentById = new Map<number, number | null>()
for (const item of items) {
for (const item of this._items) {
if (typeof item?.id === 'number') {
const parentValue = (item as any)['parent']
parentById.set(
@@ -533,9 +492,7 @@ export class FilterableDropdownSelectionModel {
return findRootId
}
private createRootDocCounter(
items: MatchingModel[]
): (rootId: number) => number {
private createRootDocCounter(): (rootId: number) => number {
const docCountMemo = new Map<number, number>()
return (rootId: number): number => {
@@ -550,7 +507,7 @@ export class FilterableDropdownSelectionModel {
return explicit
}
const rootItem = items.find((i) => i.id === rootId)
const rootItem = this._items.find((i) => i.id === rootId)
const fallback =
typeof (rootItem as any)?.['document_count'] === 'number'
? (rootItem as any)['document_count']
@@ -562,13 +519,12 @@ export class FilterableDropdownSelectionModel {
}
private buildBranchSummaries(
items: MatchingModel[],
findRootId: (id: number) => number,
getRootDocCount: (rootId: number) => number
): Map<string, BranchSummary> {
const summaries = new Map<string, BranchSummary>()
for (const [index, item] of items.entries()) {
for (const [index, item] of this._items.entries()) {
const { key, special, rootId } = this.describeBranchItem(
item,
index,
@@ -660,23 +616,28 @@ export class FilterableDropdownSelectionModel {
}
init(map: Map<number, ToggleableItemState>) {
this._temporarySelectionStates.set(new Map(map))
this.temporarySelectionStates = map
this.apply()
}
apply() {
this._selectionStates.set(new Map(this.temporarySelectionStates))
this._logicalOperator.set(this.temporaryLogicalOperator())
this._intersection.set(this.temporaryIntersection())
this._items.set(this.sortItems(this.items))
this.selectionStates.clear()
this.temporarySelectionStates.forEach((value, key) => {
this.selectionStates.set(key, value)
})
this._logicalOperator = this.temporaryLogicalOperator
this._intersection = this.temporaryIntersection
this.sortItems()
}
reset(complete: boolean = false) {
this.temporarySelectionStates.clear()
if (complete) {
this._selectionStates.set(new Map())
this._temporarySelectionStates.set(new Map())
this.selectionStates.clear()
} else {
this._temporarySelectionStates.set(new Map(this.selectionStates))
this.selectionStates.forEach((value, key) => {
this.temporarySelectionStates.set(key, value)
})
}
}
@@ -749,7 +710,7 @@ export class FilterableDropdownComponent
model.manyToOne = this.selectionModel.manyToOne
model.singleSelect = this._editing && !model.manyToOne
}
model.editing = this._editing
model.documentCountSortingEnabled = this._editing
model.changed.subscribe((updatedModel) => {
this.selectionModelChange.next(updatedModel)
})
@@ -787,7 +748,7 @@ export class FilterableDropdownComponent
if (this.selectionModel) {
this.selectionModel.singleSelect =
this._editing && !this.selectionModel.manyToOne
this.selectionModel.editing = this._editing
this.selectionModel.documentCountSortingEnabled = this._editing
}
}
@@ -7,8 +7,6 @@
padding-left: calc(calc(var(--depth) - 2) * 1rem);
display: flex;
align-items: center;
min-width: 0;
overflow-wrap: anywhere;
.indicator {
display: inline-block;
@@ -20,7 +18,3 @@
margin-left: .5rem;
}
}
.badge {
flex-shrink: 0;
}
@@ -207,21 +207,6 @@ describe('TagsComponent', () => {
expect(component.value).toEqual([2, 1])
})
it('should propagate parents added via ng-select selection', () => {
const parent: Tag = { id: 1, name: 'parent' }
const child: Tag = { id: 2, name: 'child', parent: 1 }
component.tags = [parent, child]
fixture.detectChanges()
let propagated: number[]
component.registerOnChange((value: number[]) => (propagated = value))
component.value = []
component.select.select(component.select.itemsList.findItem(child.id))
expect(component.value).toEqual([2, 1])
expect(propagated).toEqual([2, 1])
})
it('should not duplicate parents when adding sibling nested tags', () => {
const root: Tag = { id: 1, name: 'root' }
const parent: Tag = { id: 2, name: 'parent', parent: 1 }
@@ -173,7 +173,6 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
if (parent && !this.value.includes(parent.id)) {
this.value = [...this.value, parent.id]
this.onAdd(parent)
this.onChange(this.value) // re-emit for ng-select
}
}
}
@@ -7,7 +7,7 @@
<div class="list-group list-group-flush">
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NONE)" [disabled]="disabled">
<div class="selected-icon me-1">
@if (selectionModel.ownerFilter() === OwnerFilterType.NONE) {
@if (selectionModel.ownerFilter === OwnerFilterType.NONE) {
<i-bs width="1em" height="1em" name="check"></i-bs>
}
</div>
@@ -17,7 +17,7 @@
</button>
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SELF)" [disabled]="disabled">
<div class="selected-icon me-1">
@if (selectionModel.ownerFilter() === OwnerFilterType.SELF) {
@if (selectionModel.ownerFilter === OwnerFilterType.SELF) {
<i-bs width="1em" height="1em" name="check"></i-bs>
}
</div>
@@ -27,7 +27,7 @@
</button>
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NOT_SELF)" [disabled]="disabled">
<div class="selected-icon me-1">
@if (selectionModel.ownerFilter() === OwnerFilterType.NOT_SELF) {
@if (selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) {
<i-bs width="1em" height="1em" name="check"></i-bs>
}
</div>
@@ -37,7 +37,7 @@
</button>
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SHARED_BY_ME)" [disabled]="disabled">
<div class="selected-icon me-1">
@if (selectionModel.ownerFilter() === OwnerFilterType.SHARED_BY_ME) {
@if (selectionModel.ownerFilter === OwnerFilterType.SHARED_BY_ME) {
<i-bs width="1em" height="1em" name="check"></i-bs>
}
</div>
@@ -47,7 +47,7 @@
</button>
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.UNOWNED)" [disabled]="disabled">
<div class="selected-icon me-1">
@if (selectionModel.ownerFilter() === OwnerFilterType.UNOWNED) {
@if (selectionModel.ownerFilter === OwnerFilterType.UNOWNED) {
<i-bs width="1em" height="1em" name="check"></i-bs>
}
</div>
@@ -57,7 +57,7 @@
</button>
<button *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }" class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" [disabled]="disabled">
<div class="selected-icon me-1">
@if (selectionModel.ownerFilter() === OwnerFilterType.OTHERS) {
@if (selectionModel.ownerFilter === OwnerFilterType.OTHERS) {
<i-bs width="1em" height="1em" name="check"></i-bs>
}
</div>
@@ -65,8 +65,7 @@
<ng-select
name="user"
class="user-select small"
[ngModel]="selectionModel.includeUsers()"
(ngModelChange)="selectionModel.includeUsers.set($event)"
[(ngModel)]="selectionModel.includeUsers"
[disabled]="disabled"
[clearable]="false"
[items]="users()"
@@ -79,10 +78,10 @@
</ng-select>
</div>
</button>
@if (selectionModel.ownerFilter() === OwnerFilterType.NONE || selectionModel.ownerFilter() === OwnerFilterType.NOT_SELF) {
@if (selectionModel.ownerFilter === OwnerFilterType.NONE || selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) {
<div class="list-group-item list-group-item-action d-flex align-items-center p-2 ps-3 border-bottom-0 border-start-0 border-end-0">
<div class="form-check form-switch w-100">
<input type="checkbox" class="form-check-input" id="hideUnowned" [ngModel]="selectionModel.hideUnowned()" (ngModelChange)="selectionModel.hideUnowned.set($event)" (change)="onChange()" [disabled]="disabled">
<input type="checkbox" class="form-check-input" id="hideUnowned" [(ngModel)]="this.selectionModel.hideUnowned" (change)="onChange()" [disabled]="disabled">
<label class="form-check-label w-100" for="hideUnowned"><small i18n>Hide unowned</small></label>
</div>
</div>
@@ -90,56 +90,56 @@ describe('PermissionsFilterDropdownComponent', () => {
component.setFilter(OwnerFilterType.OTHERS)
expect(component.isActive).toBeTruthy()
component.setFilter(OwnerFilterType.NONE)
component.selectionModel.hideUnowned.set(true)
component.selectionModel.hideUnowned = true
expect(component.isActive).toBeTruthy()
})
it('should describe concrete user filters honestly', () => {
component.selectionModel.ownerFilter.set(OwnerFilterType.SELF)
component.selectionModel.userID.set(1)
component.selectionModel.ownerFilter = OwnerFilterType.SELF
component.selectionModel.userID = 1
expect(component.ownerFilterLabel).toEqual('Owned by user1')
component.selectionModel.ownerFilter.set(OwnerFilterType.NOT_SELF)
component.selectionModel.excludeUsers.set([1])
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
component.selectionModel.excludeUsers = [1]
expect(component.ownerExclusionFilterLabel).toEqual('Not owned by user1')
component.selectionModel.ownerFilter.set(OwnerFilterType.SHARED_BY_ME)
component.selectionModel.userID.set(1)
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME
component.selectionModel.userID = 1
expect(component.sharedByFilterLabel).toEqual('Shared by user1')
})
it('should describe concrete filters when usernames are unavailable', () => {
component.selectionModel.ownerFilter.set(OwnerFilterType.SELF)
component.selectionModel.userID.set(99)
component.selectionModel.ownerFilter = OwnerFilterType.SELF
component.selectionModel.userID = 99
expect(component.ownerFilterLabel).toEqual('Owned by another user')
component.selectionModel.ownerFilter.set(OwnerFilterType.NOT_SELF)
component.selectionModel.excludeUsers.set([99])
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
component.selectionModel.excludeUsers = [99]
expect(component.ownerExclusionFilterLabel).toEqual(
'Not owned by another user'
)
component.selectionModel.excludeUsers.set([98, 99])
component.selectionModel.excludeUsers = [98, 99]
expect(component.ownerExclusionFilterLabel).toEqual(
'Not owned by selected users'
)
component.selectionModel.ownerFilter.set(OwnerFilterType.SHARED_BY_ME)
component.selectionModel.userID.set(99)
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME
component.selectionModel.userID = 99
expect(component.sharedByFilterLabel).toEqual('Shared by another user')
})
it('should retain relative labels for filters bound to the current user', () => {
component.selectionModel.userID.set(currentUserID)
component.selectionModel.userID = currentUserID
expect(component.ownerFilterLabel).toEqual('My documents')
expect(component.sharedByFilterLabel).toEqual('Shared by me')
component.selectionModel.excludeUsers.set([currentUserID])
component.selectionModel.excludeUsers = [currentUserID]
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
})
it('should retain relative labels for inactive filter choices', () => {
component.selectionModel.ownerFilter.set(OwnerFilterType.NONE)
component.selectionModel.ownerFilter = OwnerFilterType.NONE
expect(component.ownerFilterLabel).toEqual('My documents')
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
@@ -148,41 +148,32 @@ describe('PermissionsFilterDropdownComponent', () => {
it('should support reset', () => {
component.setFilter(OwnerFilterType.OTHERS)
expect(component.selectionModel.ownerFilter()).not.toEqual(
expect(component.selectionModel.ownerFilter).not.toEqual(
OwnerFilterType.NONE
)
component.reset()
expect(component.selectionModel.ownerFilter()).toEqual(OwnerFilterType.NONE)
expect(component.selectionModel.ownerFilter).toEqual(OwnerFilterType.NONE)
})
it('should toggle owner filter type when users selected', () => {
component.selectionModel.ownerFilter.set(OwnerFilterType.NONE)
component.selectionModel.ownerFilter = OwnerFilterType.NONE
// this would normally be done by select component
component.selectionModel.includeUsers.set([12])
component.selectionModel.includeUsers = [12]
component.onUserSelect()
expect(component.selectionModel.ownerFilter()).toEqual(
OwnerFilterType.OTHERS
)
expect(component.selectionModel.ownerFilter).toEqual(OwnerFilterType.OTHERS)
// this would normally be done by select component
component.selectionModel.includeUsers.set(null)
component.selectionModel.includeUsers = null
component.onUserSelect()
expect(component.selectionModel.ownerFilter()).toEqual(OwnerFilterType.NONE)
expect(component.selectionModel.ownerFilter).toEqual(OwnerFilterType.NONE)
})
it('should emit a selection model depending on the type of owner filter set', () => {
const emitted = () => ({
excludeUsers: ownerFilterSetResult.excludeUsers(),
hideUnowned: ownerFilterSetResult.hideUnowned(),
includeUsers: ownerFilterSetResult.includeUsers(),
ownerFilter: ownerFilterSetResult.ownerFilter(),
userID: ownerFilterSetResult.userID(),
})
component.selectionModel.ownerFilter.set(OwnerFilterType.NONE)
component.selectionModel.ownerFilter = OwnerFilterType.NONE
component.setFilter(OwnerFilterType.SELF)
expect(emitted()).toEqual({
expect(ownerFilterSetResult).toEqual({
excludeUsers: [],
hideUnowned: false,
includeUsers: [],
@@ -191,7 +182,7 @@ describe('PermissionsFilterDropdownComponent', () => {
})
component.setFilter(OwnerFilterType.NOT_SELF)
expect(emitted()).toEqual({
expect(ownerFilterSetResult).toEqual({
excludeUsers: [currentUserID],
hideUnowned: false,
includeUsers: [],
@@ -200,7 +191,7 @@ describe('PermissionsFilterDropdownComponent', () => {
})
component.setFilter(OwnerFilterType.NONE)
expect(emitted()).toEqual({
expect(ownerFilterSetResult).toEqual({
excludeUsers: [],
hideUnowned: false,
includeUsers: [],
@@ -209,7 +200,7 @@ describe('PermissionsFilterDropdownComponent', () => {
})
component.setFilter(OwnerFilterType.SHARED_BY_ME)
expect(emitted()).toEqual({
expect(ownerFilterSetResult).toEqual({
excludeUsers: [],
hideUnowned: false,
includeUsers: [],
@@ -218,7 +209,7 @@ describe('PermissionsFilterDropdownComponent', () => {
})
component.setFilter(OwnerFilterType.UNOWNED)
expect(emitted()).toEqual({
expect(ownerFilterSetResult).toEqual({
excludeUsers: [],
hideUnowned: false,
includeUsers: [],
@@ -25,18 +25,18 @@ import { ComponentWithPermissions } from '../../with-permissions/with-permission
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
export class PermissionsSelectionModel {
readonly ownerFilter = signal(OwnerFilterType.NONE)
readonly hideUnowned = signal(false)
readonly userID = signal<number>(null)
readonly includeUsers = signal<number[]>([])
readonly excludeUsers = signal<number[]>([])
ownerFilter: OwnerFilterType
hideUnowned: boolean
userID: number
includeUsers: number[]
excludeUsers: number[]
clear() {
this.ownerFilter.set(OwnerFilterType.NONE)
this.userID.set(null)
this.hideUnowned.set(false)
this.includeUsers.set([])
this.excludeUsers.set([])
this.ownerFilter = OwnerFilterType.NONE
this.userID = null
this.hideUnowned = false
this.includeUsers = []
this.excludeUsers = []
}
}
@@ -84,31 +84,33 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
readonly users = signal<User[]>([])
hideUnowned: boolean
get isActive(): boolean {
return (
this.selectionModel.ownerFilter() !== OwnerFilterType.NONE ||
this.selectionModel.hideUnowned()
this.selectionModel.ownerFilter !== OwnerFilterType.NONE ||
this.selectionModel.hideUnowned
)
}
get ownerFilterLabel(): string {
if (
this.selectionModel?.ownerFilter() !== OwnerFilterType.SELF ||
this.selectionModel?.userID() === this.settingsService.currentUser()?.id
this.selectionModel?.ownerFilter !== OwnerFilterType.SELF ||
this.selectionModel?.userID === this.settingsService.currentUser()?.id
) {
return $localize`My documents`
}
const username = this.getUsername(this.selectionModel?.userID())
const username = this.getUsername(this.selectionModel?.userID)
return username
? $localize`Owned by ${username}`
: $localize`Owned by another user`
}
get ownerExclusionFilterLabel(): string {
const excludedUsers = this.selectionModel?.excludeUsers() ?? []
const excludedUsers = this.selectionModel?.excludeUsers ?? []
if (
this.selectionModel?.ownerFilter() !== OwnerFilterType.NOT_SELF ||
this.selectionModel?.ownerFilter !== OwnerFilterType.NOT_SELF ||
(excludedUsers.length === 1 &&
excludedUsers[0] === this.settingsService.currentUser()?.id)
) {
@@ -128,13 +130,13 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
get sharedByFilterLabel(): string {
if (
this.selectionModel?.ownerFilter() !== OwnerFilterType.SHARED_BY_ME ||
this.selectionModel?.userID() === this.settingsService.currentUser()?.id
this.selectionModel?.ownerFilter !== OwnerFilterType.SHARED_BY_ME ||
this.selectionModel?.userID === this.settingsService.currentUser()?.id
) {
return $localize`Shared by me`
}
const username = this.getUsername(this.selectionModel?.userID())
const username = this.getUsername(this.selectionModel?.userID)
return username
? $localize`Shared by ${username}`
: $localize`Shared by another user`
@@ -167,36 +169,34 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
}
setFilter(type: OwnerFilterType) {
this.selectionModel.ownerFilter.set(type)
if (this.selectionModel.ownerFilter() === OwnerFilterType.SELF) {
this.selectionModel.includeUsers.set([])
this.selectionModel.excludeUsers.set([])
this.selectionModel.userID.set(this.settingsService.currentUser().id)
this.selectionModel.hideUnowned.set(false)
} else if (this.selectionModel.ownerFilter() === OwnerFilterType.NOT_SELF) {
this.selectionModel.userID.set(null)
this.selectionModel.includeUsers.set([])
this.selectionModel.excludeUsers.set([
this.settingsService.currentUser().id,
])
this.selectionModel.hideUnowned.set(false)
} else if (this.selectionModel.ownerFilter() === OwnerFilterType.NONE) {
this.selectionModel.userID.set(null)
this.selectionModel.includeUsers.set([])
this.selectionModel.excludeUsers.set([])
this.selectionModel.hideUnowned.set(false)
this.selectionModel.ownerFilter = type
if (this.selectionModel.ownerFilter === OwnerFilterType.SELF) {
this.selectionModel.includeUsers = []
this.selectionModel.excludeUsers = []
this.selectionModel.userID = this.settingsService.currentUser().id
this.selectionModel.hideUnowned = false
} else if (this.selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) {
this.selectionModel.userID = null
this.selectionModel.includeUsers = []
this.selectionModel.excludeUsers = [this.settingsService.currentUser().id]
this.selectionModel.hideUnowned = false
} else if (this.selectionModel.ownerFilter === OwnerFilterType.NONE) {
this.selectionModel.userID = null
this.selectionModel.includeUsers = []
this.selectionModel.excludeUsers = []
this.selectionModel.hideUnowned = false
} else if (
this.selectionModel.ownerFilter() === OwnerFilterType.SHARED_BY_ME
this.selectionModel.ownerFilter === OwnerFilterType.SHARED_BY_ME
) {
this.selectionModel.userID.set(this.settingsService.currentUser()?.id)
this.selectionModel.includeUsers.set([])
this.selectionModel.excludeUsers.set([])
this.selectionModel.hideUnowned.set(false)
} else if (this.selectionModel.ownerFilter() === OwnerFilterType.UNOWNED) {
this.selectionModel.userID.set(null)
this.selectionModel.includeUsers.set([])
this.selectionModel.excludeUsers.set([])
this.selectionModel.hideUnowned.set(false)
this.selectionModel.userID = this.settingsService.currentUser()?.id
this.selectionModel.includeUsers = []
this.selectionModel.excludeUsers = []
this.selectionModel.hideUnowned = false
} else if (this.selectionModel.ownerFilter === OwnerFilterType.UNOWNED) {
this.selectionModel.userID = null
this.selectionModel.includeUsers = []
this.selectionModel.excludeUsers = []
this.selectionModel.hideUnowned = false
}
this.onChange()
}
@@ -206,11 +206,11 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
}
onUserSelect() {
this.selectionModel.ownerFilter.set(
this.selectionModel.includeUsers()?.length
? OwnerFilterType.OTHERS
: OwnerFilterType.NONE
)
if (this.selectionModel.includeUsers?.length) {
this.selectionModel.ownerFilter = OwnerFilterType.OTHERS
} else {
this.selectionModel.ownerFilter = OwnerFilterType.NONE
}
this.onChange()
}
@@ -1209,53 +1209,24 @@ describe('DocumentDetailComponent', () => {
expect(fixture.debugElement.queryAll(By.css('textarea.rtl'))).not.toBeNull()
})
it('should display built-in pdf viewer if not disabled', async () => {
it('should display built-in pdf viewer if not disabled', () => {
initNormally()
component.document.update((document) => ({
...document,
archived_file_name: 'file.pdf',
}))
component.document().archived_file_name = 'file.pdf'
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
expect(component.useNativePdfViewer).toBeFalsy()
await fixture.whenStable()
fixture.detectChanges()
expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull()
})
it('should display native pdf viewer if enabled', () => {
initNormally()
component.document.update((document) => ({
...document,
archived_file_name: 'file.pdf',
}))
component.document().archived_file_name = 'file.pdf'
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, true)
expect(component.useNativePdfViewer).toBeTruthy()
fixture.detectChanges()
expect(fixture.debugElement.query(By.css('object'))).not.toBeNull()
})
it('should reflect signal-backed document detail display settings', () => {
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL, false)
settingsService.set(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS, [
component.DocumentDetailFieldID.Correspondent,
])
expect(component.showThumbnailOverlay).toBeFalsy()
expect(
component.isFieldHidden(component.DocumentDetailFieldID.Correspondent)
).toBeTruthy()
expect(
component.isFieldHidden(component.DocumentDetailFieldID.DocumentType)
).toBeFalsy()
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL, true)
settingsService.set(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS, [])
expect(component.showThumbnailOverlay).toBeTruthy()
expect(
component.isFieldHidden(component.DocumentDetailFieldID.Correspondent)
).toBeFalsy()
})
it('should attempt to retrieve metadata', () => {
const metadataSpy = jest.spyOn(documentService, 'getMetadata')
metadataSpy.mockReturnValue(of({ has_archive_version: true }))
@@ -1714,10 +1685,7 @@ describe('DocumentDetailComponent', () => {
it('should change preview element by render type', () => {
initNormally()
component.document.update((document) => ({
...document,
archived_file_name: 'file.pdf',
}))
component.document().archived_file_name = 'file.pdf'
fixture.detectChanges()
expect(component.archiveContentRenderType).toEqual(
component.ContentRenderType.PDF
@@ -1726,11 +1694,8 @@ describe('DocumentDetailComponent', () => {
fixture.debugElement.query(By.css('pdf-viewer-container'))
).not.toBeUndefined()
component.document.update((document) => ({
...document,
archived_file_name: undefined,
mime_type: 'text/plain',
}))
component.document().archived_file_name = undefined
component.document().mime_type = 'text/plain'
fixture.detectChanges()
expect(component.archiveContentRenderType).toEqual(
component.ContentRenderType.Text
@@ -1739,10 +1704,7 @@ describe('DocumentDetailComponent', () => {
fixture.debugElement.query(By.css('div.preview-sticky'))
).not.toBeUndefined()
component.document.update((document) => ({
...document,
mime_type: 'image/jpeg',
}))
component.document().mime_type = 'image/jpeg'
fixture.detectChanges()
expect(component.archiveContentRenderType).toEqual(
component.ContentRenderType.Image
@@ -1750,12 +1712,9 @@ describe('DocumentDetailComponent', () => {
expect(
fixture.debugElement.query(By.css('.preview-sticky img'))
).not.toBeUndefined()
component.document.update((document) => ({
...document,
mime_type:
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
}))
fixture.detectChanges()
;((component.document().mime_type =
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'),
fixture.detectChanges())
expect(component.archiveContentRenderType).toEqual(
component.ContentRenderType.Other
)
@@ -227,19 +227,6 @@ export class DocumentDetailComponent
private deviceDetectorService = inject(DeviceDetectorService)
private savedViewService = inject(SavedViewService)
private readonly websocketStatusService = inject(WebsocketStatusService)
private readonly useNativePdfViewerSetting = this.settings.getSignal<boolean>(
SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER
)
private readonly aiEnabledSetting = this.settings.getSignal<boolean>(
SETTINGS_KEYS.AI_ENABLED
)
private readonly showThumbnailOverlaySetting =
this.settings.getSignal<boolean>(
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL
)
private readonly hiddenFieldsSetting = this.settings.getSignal<
DocumentDetailFieldID[]
>(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS)
@ViewChild('inputTitle')
titleInput: TextComponent
@@ -346,7 +333,8 @@ export class DocumentDetailComponent
}
get useNativePdfViewer(): boolean {
return this.useNativePdfViewerSetting()
this.settings.trackChanges()
return this.settings.get(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER)
}
get isMobile(): boolean {
@@ -354,10 +342,12 @@ export class DocumentDetailComponent
}
get aiEnabled(): boolean {
return this.aiEnabledSetting()
this.settings.trackChanges()
return this.settings.get(SETTINGS_KEYS.AI_ENABLED)
}
get archiveContentRenderType(): ContentRenderType {
this.settings.trackChanges()
const hasArchiveVersion =
this.metadata()?.has_archive_version ??
!!this.document()?.archived_file_name
@@ -369,17 +359,22 @@ export class DocumentDetailComponent
}
get originalContentRenderType(): ContentRenderType {
this.settings.trackChanges()
return this.getRenderType(
this.metadata()?.original_mime_type || this.document()?.mime_type
)
}
get showThumbnailOverlay(): boolean {
return this.showThumbnailOverlaySetting()
this.settings.trackChanges()
return this.settings.get(SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL)
}
isFieldHidden(fieldId: DocumentDetailFieldID): boolean {
return this.hiddenFieldsSetting().includes(fieldId)
this.settings.trackChanges()
return this.settings
.get(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS)
.includes(fieldId)
}
private getRenderType(mimeType: string): ContentRenderType {
@@ -60,16 +60,16 @@
<div class="list-group list-group-flush border-0 pt-1 pb-2 card-info">
@if (document()) {
@if (displayFields().includes(DisplayField.DOCUMENT_TYPE) && document().document_type) {
<button type="button" class="list-group-item list-group-item-action bg-transparent ps-0 p-1 border-0 d-flex align-items-start" title="Toggle document type filter" i18n-title
<button type="button" class="list-group-item list-group-item-action bg-transparent ps-0 p-1 border-0" title="Toggle document type filter" i18n-title
(click)="clickDocumentType.emit(document().document_type);$event.stopPropagation()">
<i-bs width="1em" height="1em" class="me-2 text-muted flex-shrink-0" name="file-earmark"></i-bs>
<i-bs width="1em" height="1em" class="me-2 text-muted" name="file-earmark"></i-bs>
<small>{{document().document_type | documentTypeName | async}}</small>
</button>
}
@if (displayFields().includes(DisplayField.STORAGE_PATH) && document().storage_path) {
<button type="button" class="list-group-item list-group-item-action bg-transparent ps-0 p-1 border-0 d-flex align-items-start" title="Toggle storage path filter" i18n-title
<button type="button" class="list-group-item list-group-item-action bg-transparent ps-0 p-1 border-0" title="Toggle storage path filter" i18n-title
(click)="clickStoragePath.emit(document().storage_path);$event.stopPropagation()">
<i-bs width="1em" height="1em" class="me-2 text-muted flex-shrink-0" name="folder"></i-bs>
<i-bs width="1em" height="1em" class="me-2 text-muted" name="folder"></i-bs>
<small>{{document().storage_path | storagePathName | async}}</small>
</button>
}
@@ -69,11 +69,6 @@
button {
line-height: 1;
small {
min-width: 0;
overflow-wrap: anywhere;
}
&:hover,
&:focus {
background-color: transparent !important;
@@ -121,8 +121,6 @@ export class DocumentListComponent
settingsService = inject(SettingsService)
private hotKeyService = inject(HotKeyService)
permissionService = inject(PermissionsService)
private readonly notesEnabledSetting =
this.settingsService.getSignal<boolean>(SETTINGS_KEYS.NOTES_ENABLED)
DisplayField = DisplayField
DisplayMode = DisplayMode
@@ -576,7 +574,8 @@ export class DocumentListComponent
}
get notesEnabled(): boolean {
return this.notesEnabledSetting()
this.settingsService.trackChanges()
return this.settingsService.get(SETTINGS_KEYS.NOTES_ENABLED)
}
resetFilters() {
@@ -621,43 +621,6 @@ describe('FilterEditorComponent', () => {
component.toggleTag(2) // coverage
})
it('should reflect ingested tag filter rules in the dropdown toggle', () => {
const dropdown = fixture.debugElement.query(
By.css('pngx-filterable-dropdown')
)
const toggle = dropdown.nativeElement.querySelector('#dropdown_tags')
expect(toggle.classList.contains('btn-primary')).toBeFalsy()
expect(
dropdown.nativeElement.querySelector('pngx-clearable-badge')
).toBeNull()
// switching to a view with a tag filter
component.filterRules = [
{
rule_type: FILTER_HAS_TAGS_ALL,
value: '2',
},
]
fixture.detectChanges()
expect(toggle.classList.contains('btn-primary')).toBeTruthy()
expect(
dropdown.nativeElement.querySelector('pngx-clearable-badge')
).not.toBeNull()
// and back to a view without one
component.filterRules = [
{
rule_type: FILTER_HAS_CORRESPONDENT_ANY,
value: '12',
},
]
fixture.detectChanges()
expect(toggle.classList.contains('btn-primary')).toBeFalsy()
expect(
dropdown.nativeElement.querySelector('pngx-clearable-badge')
).toBeNull()
})
it('should ingest filter rules for has any tags', () => {
expect(component.tagSelectionModel.getSelectedItems()).toHaveLength(0)
component.filterRules = [
@@ -1115,7 +1078,7 @@ describe('FilterEditorComponent', () => {
})
it('should ingest filter rules for owner', () => {
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
OwnerFilterType.NONE
)
component.filterRules = [
@@ -1124,38 +1087,15 @@ describe('FilterEditorComponent', () => {
value: '100',
},
]
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
OwnerFilterType.SELF
)
expect(component.permissionsSelectionModel.hideUnowned()).toBeFalsy()
expect(component.permissionsSelectionModel.userID()).toEqual(100)
})
it('should reflect ingested owner filter rules in the dropdown toggle', () => {
const dropdown = fixture.debugElement.query(
By.css('pngx-permissions-filter-dropdown')
)
const toggle = dropdown.nativeElement.querySelector('button')
expect(toggle.classList.contains('btn-primary')).toBeFalsy()
// switching to a view with an owner filter
component.filterRules = [
{
rule_type: FILTER_OWNER,
value: '100',
},
]
fixture.detectChanges()
expect(toggle.classList.contains('btn-primary')).toBeTruthy()
// and back to a view without one
component.filterRules = []
fixture.detectChanges()
expect(toggle.classList.contains('btn-primary')).toBeFalsy()
expect(component.permissionsSelectionModel.hideUnowned).toBeFalsy()
expect(component.permissionsSelectionModel.userID).toEqual(100)
})
it('should ingest filter rules for owner is others', () => {
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
OwnerFilterType.NONE
)
component.filterRules = [
@@ -1164,14 +1104,14 @@ describe('FilterEditorComponent', () => {
value: '50',
},
]
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
OwnerFilterType.OTHERS
)
expect(component.permissionsSelectionModel.includeUsers()).toContain(50)
expect(component.permissionsSelectionModel.includeUsers).toContain(50)
})
it('should ingest filter rules for owner does not include others', () => {
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
OwnerFilterType.NONE
)
component.filterRules = [
@@ -1180,14 +1120,14 @@ describe('FilterEditorComponent', () => {
value: '50',
},
]
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
OwnerFilterType.NOT_SELF
)
expect(component.permissionsSelectionModel.excludeUsers()).toContain(50)
expect(component.permissionsSelectionModel.excludeUsers).toContain(50)
})
it('should ingest filter rules for owner is null', () => {
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
OwnerFilterType.NONE
)
component.filterRules = [
@@ -1196,10 +1136,10 @@ describe('FilterEditorComponent', () => {
value: 'true',
},
]
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
OwnerFilterType.UNOWNED
)
expect(component.permissionsSelectionModel.hideUnowned()).toBeFalsy()
expect(component.permissionsSelectionModel.hideUnowned).toBeFalsy()
})
it('should ingest filter rules for owner is not null', () => {
@@ -1209,14 +1149,14 @@ describe('FilterEditorComponent', () => {
value: 'false',
},
]
expect(component.permissionsSelectionModel.hideUnowned()).toBeTruthy()
expect(component.permissionsSelectionModel.hideUnowned).toBeTruthy()
component.filterRules = [
{
rule_type: FILTER_OWNER_ISNULL,
value: '0',
},
]
expect(component.permissionsSelectionModel.hideUnowned()).toBeTruthy()
expect(component.permissionsSelectionModel.hideUnowned).toBeTruthy()
})
it('should ingest filter rules for shared by me', () => {
@@ -1226,7 +1166,7 @@ describe('FilterEditorComponent', () => {
value: '2',
},
]
expect(component.permissionsSelectionModel.userID()).toEqual(2)
expect(component.permissionsSelectionModel.userID).toEqual(2)
})
// GET filterRules
@@ -1992,10 +1932,7 @@ describe('FilterEditorComponent', () => {
value: '1',
},
])
component.permissionsSelectionModel.excludeUsers.update((users) => [
...users,
2,
])
component.permissionsSelectionModel.excludeUsers.push(2)
fixture.detectChanges()
expect(component.filterRules).toEqual([
{
@@ -2045,11 +1982,8 @@ describe('FilterEditorComponent', () => {
// TODO: mock input in code
// userSelect.query(By.css('input')).nativeElement.value = '3'
// userSelect.triggerEventHandler('change')
component.permissionsSelectionModel.ownerFilter.set(OwnerFilterType.OTHERS)
component.permissionsSelectionModel.includeUsers.update((users) => [
...users,
3,
])
component.permissionsSelectionModel.ownerFilter = OwnerFilterType.OTHERS
component.permissionsSelectionModel.includeUsers.push(3)
fixture.detectChanges()
expect(component.filterRules).toEqual([
{
@@ -2069,7 +2003,7 @@ describe('FilterEditorComponent', () => {
ownerToggle.nativeElement.checked = true
// ownerToggle.triggerEventHandler('change')
// TODO: ngModel isn't doing this here
component.permissionsSelectionModel.hideUnowned.set(true)
component.permissionsSelectionModel.hideUnowned = true
fixture.detectChanges()
expect(component.filterRules).toEqual([
{
@@ -735,50 +735,38 @@ export class FilterEditorComponent
this._textFilter = rule.value
break
case FILTER_OWNER:
this.permissionsSelectionModel.ownerFilter.set(OwnerFilterType.SELF)
this.permissionsSelectionModel.hideUnowned.set(false)
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.SELF
this.permissionsSelectionModel.hideUnowned = false
if (rule.value)
this.permissionsSelectionModel.userID.set(
Number.parseInt(rule.value, 10)
)
this.permissionsSelectionModel.userID = parseInt(rule.value, 10)
break
case FILTER_OWNER_ANY:
this.permissionsSelectionModel.ownerFilter.set(OwnerFilterType.OTHERS)
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.OTHERS
if (rule.value)
this.permissionsSelectionModel.includeUsers.update((users) => [
...users,
Number.parseInt(rule.value, 10),
])
this.permissionsSelectionModel.includeUsers.push(
parseInt(rule.value, 10)
)
break
case FILTER_OWNER_DOES_NOT_INCLUDE:
this.permissionsSelectionModel.ownerFilter.set(
OwnerFilterType.NOT_SELF
)
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.NOT_SELF
if (rule.value)
this.permissionsSelectionModel.excludeUsers.update((users) => [
...users,
Number.parseInt(rule.value, 10),
])
this.permissionsSelectionModel.excludeUsers.push(
parseInt(rule.value, 10)
)
break
case FILTER_SHARED_BY_USER:
this.permissionsSelectionModel.ownerFilter.set(
this.permissionsSelectionModel.ownerFilter =
OwnerFilterType.SHARED_BY_ME
)
if (rule.value)
this.permissionsSelectionModel.userID.set(
Number.parseInt(rule.value, 10)
)
this.permissionsSelectionModel.userID = parseInt(rule.value, 10)
break
case FILTER_OWNER_ISNULL:
if (rule.value === 'true' || rule.value === '1') {
this.permissionsSelectionModel.hideUnowned.set(false)
this.permissionsSelectionModel.ownerFilter.set(
OwnerFilterType.UNOWNED
)
this.permissionsSelectionModel.hideUnowned = false
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.UNOWNED
} else {
this.permissionsSelectionModel.hideUnowned.set(
this.permissionsSelectionModel.hideUnowned =
rule.value === 'false' || rule.value === '0'
)
break
}
}
@@ -1086,35 +1074,34 @@ export class FilterEditorComponent
})
}
}
if (this.permissionsSelectionModel.ownerFilter() == OwnerFilterType.SELF) {
if (this.permissionsSelectionModel.ownerFilter == OwnerFilterType.SELF) {
filterRules.push({
rule_type: FILTER_OWNER,
value: this.permissionsSelectionModel.userID().toString(),
value: this.permissionsSelectionModel.userID.toString(),
})
} else if (
this.permissionsSelectionModel.ownerFilter() == OwnerFilterType.NOT_SELF
this.permissionsSelectionModel.ownerFilter == OwnerFilterType.NOT_SELF
) {
filterRules.push({
rule_type: FILTER_OWNER_DOES_NOT_INCLUDE,
value: this.permissionsSelectionModel.excludeUsers()?.join(','),
value: this.permissionsSelectionModel.excludeUsers?.join(','),
})
} else if (
this.permissionsSelectionModel.ownerFilter() == OwnerFilterType.OTHERS
this.permissionsSelectionModel.ownerFilter == OwnerFilterType.OTHERS
) {
filterRules.push({
rule_type: FILTER_OWNER_ANY,
value: this.permissionsSelectionModel.includeUsers()?.join(','),
value: this.permissionsSelectionModel.includeUsers?.join(','),
})
} else if (
this.permissionsSelectionModel.ownerFilter() ==
OwnerFilterType.SHARED_BY_ME
this.permissionsSelectionModel.ownerFilter == OwnerFilterType.SHARED_BY_ME
) {
filterRules.push({
rule_type: FILTER_SHARED_BY_USER,
value: this.permissionsSelectionModel.userID().toString(),
value: this.permissionsSelectionModel.userID.toString(),
})
} else if (
this.permissionsSelectionModel.ownerFilter() == OwnerFilterType.UNOWNED
this.permissionsSelectionModel.ownerFilter == OwnerFilterType.UNOWNED
) {
filterRules.push({
rule_type: FILTER_OWNER_ISNULL,
@@ -1122,7 +1109,7 @@ export class FilterEditorComponent
})
}
if (this.permissionsSelectionModel.hideUnowned()) {
if (this.permissionsSelectionModel.hideUnowned) {
filterRules.push({
rule_type: FILTER_OWNER_ISNULL,
value: 'false',
@@ -210,48 +210,6 @@ describe('SettingsService', () => {
expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('#000000')
})
it('provides stable signals that update when settings change', () => {
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}ui_settings/`
)
req.flush(ui_settings)
const notesEnabled = settingsService.getSignal<boolean>(
SETTINGS_KEYS.NOTES_ENABLED
)
expect(notesEnabled()).toBeTruthy()
expect(
settingsService.getSignal<boolean>(SETTINGS_KEYS.NOTES_ENABLED)
).toBe(notesEnabled)
settingsService.set(SETTINGS_KEYS.NOTES_ENABLED, false)
expect(notesEnabled()).toBeFalsy()
})
it('updates setting signals when settings are reinitialized', () => {
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}ui_settings/`
)
req.flush(ui_settings)
const appTitle = settingsService.getSignal<string>(SETTINGS_KEYS.APP_TITLE)
settingsService.initializeSettings().subscribe()
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}ui_settings/`
)
req.flush({
...ui_settings,
settings: {
...ui_settings.settings,
app_title: 'Updated title',
},
})
expect(appTitle()).toBe('Updated title')
})
it('sets django cookie for languages', () => {
httpTestingController
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
+4 -16
View File
@@ -2,8 +2,6 @@ import { HttpClient } from '@angular/common/http'
import {
DOCUMENT,
EventEmitter,
Signal,
computed,
inject,
Injectable,
LOCALE_ID,
@@ -299,7 +297,6 @@ export class SettingsService {
private settings: Record<string, any> = {}
private readonly settingsVersion = signal(0)
private readonly settingSignals = new Map<string, Signal<unknown>>()
readonly currentUser = signal<User>(undefined)
public settingsSaved: EventEmitter<any> = new EventEmitter()
@@ -329,6 +326,10 @@ export class SettingsService {
return !UNSAFE_OBJECT_KEYS.has(key)
}
public trackChanges(): void {
this.settingsVersion()
}
private assignSafeSettings(source: Record<string, any>) {
if (!source || typeof source !== 'object' || Array.isArray(source)) {
return
@@ -338,7 +339,6 @@ export class SettingsService {
if (!this.isSafeObjectKey(key)) continue
this.settings[key] = source[key]
}
this.settingsVersion.update((version) => version + 1)
}
// this is called by the app initializer in app.module
@@ -594,18 +594,6 @@ export class SettingsService {
}
}
getSignal<T = any>(key: string): Signal<T> {
let settingSignal = this.settingSignals.get(key)
if (!settingSignal) {
settingSignal = computed(() => {
this.settingsVersion()
return this.get(key)
})
this.settingSignals.set(key, settingSignal)
}
return settingSignal as Signal<T>
}
set(key: string, value: any) {
// parse key:key:key into nested object
let settingObj = this.settings
+1 -1
View File
@@ -8,7 +8,7 @@ export const environment = {
apiVersion: '10', // match src/paperless/settings.py
appTitle: DEFAULT_APP_TITLE,
tag: 'prod',
version: '3.1.3',
version: '3.1.2',
webSocketHost: window.location.host,
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
webSocketBaseUrl: base_url.pathname + 'ws/',
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -314,7 +314,7 @@ def _consume_file(
consumption_dir: Path,
*,
subdirs_as_tags: bool,
) -> bool:
) -> None:
"""
Queue a file for consumption.
@@ -322,20 +322,15 @@ def _consume_file(
filepath: Path to the file to consume.
consumption_dir: Base consumption directory.
subdirs_as_tags: Whether to create tags from subdirectory names.
Returns:
True if the file was successfully handed to Celery, False otherwise.
Callers must not record the file as queued on failure, or the rescan
will never retry it.
"""
# Verify file still exists and is accessible
try:
if not filepath.is_file():
logger.debug(f"Not consuming {filepath}: not a file or doesn't exist")
return False
return
except OSError as e:
logger.warning(f"Not consuming {filepath}: {e}")
return False
return
# Get tags from path if configured
tag_ids: list[int] | None = None
@@ -360,9 +355,6 @@ def _consume_file(
)
except Exception:
logger.exception(f"Error while queuing document {filepath}")
return False
return True
class Command(BaseCommand):
@@ -500,12 +492,12 @@ class Command(BaseCommand):
if not consumer_filter(Change.added, str(filepath)):
continue
if _consume_file(
_consume_file(
filepath=filepath,
consumption_dir=directory,
subdirs_as_tags=subdirs_as_tags,
):
queued.add(filepath.resolve())
)
queued.add(filepath.resolve())
return queued
@@ -659,16 +651,14 @@ class Command(BaseCommand):
# Check for stable files
for stable_path in tracker.get_stable_files():
# Only remember files that were actually queued, so the
# rescan does not re-queue them while the consume task
# has yet to remove them from disk, but does retry a
# failed publish instead of stranding it
if _consume_file(
_consume_file(
filepath=stable_path,
consumption_dir=directory,
subdirs_as_tags=subdirs_as_tags,
):
queued.add(stable_path)
)
# Remember it so the rescan does not re-queue it while
# the consume task has yet to remove it from disk
queued.add(stable_path)
# Exit watch loop to reconfigure timeout
break
+1 -9
View File
@@ -1220,19 +1220,11 @@ class DocumentSerializer(
prev_tags = set(instance.tags.all())
requested_tags = set(validated_data["tags"])
# Tags newly added in this update and the ancestors they require
added_tags = requested_tags - prev_tags
required_by_add_tags = set(added_tags)
for t in added_tags:
required_by_add_tags.update(t.get_ancestors())
# Tags being removed in this update and all descendants, except
# those required by a tag that is being added in this same update
# Tags being removed in this update and all descendants
removed_tags = prev_tags - requested_tags
blocked_tags = set(removed_tags)
for t in removed_tags:
blocked_tags.update(t.get_descendants())
blocked_tags.difference_update(required_by_add_tags)
# Add all parent tags
final_tags = set(requested_tags)
+1 -1
View File
@@ -1003,7 +1003,7 @@ def run_workflows(
# kwargs so the PaperlessTask record can note the
# document, see _extract_input_data
apply_ai_suggestions.delay_on_commit(
apply_ai_suggestions.delay(
action_id=action.pk,
document_id=document.pk,
)
-77
View File
@@ -1,18 +1,11 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest import mock
import pytest
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User
from rest_framework import status
from rest_framework.test import APIClient
from rest_framework.test import APITestCase
if TYPE_CHECKING:
from pytest_mock import MockerFixture
class TestChatStreamingViewInputValidation(APITestCase):
def setUp(self) -> None:
@@ -49,73 +42,3 @@ class TestChatStreamingViewInputValidation(APITestCase):
format="json",
)
assert resp.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.django_db
class TestChatStreamingViewUnrestrictedFlag:
"""The document id filter may only be skipped (``unrestricted=True``) for
an active superuser, never for a regular user -- regardless of what
permissions that user holds.
"""
@pytest.fixture
def mocked_stream_chat(self, mocker: MockerFixture) -> mock.MagicMock:
"""AI enabled, with stream_chat_with_documents patched so the view
never touches the real vector store; returns the patched callable so
tests can inspect how it was called.
"""
mocker.patch("documents.views.AIConfig").return_value.ai_enabled = True
return mocker.patch(
"documents.views.stream_chat_with_documents",
return_value=iter(()),
)
@pytest.fixture
def viewer_client(self, user_client: APIClient, regular_user: User) -> APIClient:
"""The conftest regular-user client, granted the global
view_document permission -- the minimum ViewDocumentsPermissions
needs to reach the view at all. Model-level only: says nothing
about which documents (if any) this user can actually see.
"""
regular_user.user_permissions.add(
*Permission.objects.filter(codename="view_document"),
)
return user_client
@pytest.mark.parametrize(
("client_fixture", "expected_unrestricted"),
[
pytest.param("admin_client", True, id="superuser_is_unrestricted"),
pytest.param("viewer_client", False, id="regular_user_is_restricted"),
],
)
def test_unrestricted_only_for_superuser(
self,
request: pytest.FixtureRequest,
mocked_stream_chat: mock.MagicMock,
client_fixture: str,
*,
expected_unrestricted: bool,
) -> None:
"""
GIVEN:
- A superuser, or a regular user holding the global
view_document permission (but no object-level document access)
WHEN:
- They post a chat question with no document_id
THEN:
- stream_chat_with_documents is called with unrestricted=True
only for the superuser; the regular user is always
unrestricted=False, regardless of their permissions
"""
client: APIClient = request.getfixturevalue(client_fixture)
client.post(
"/api/documents/chat/",
data={"q": "What's in these documents?"},
format="json",
)
assert (
mocked_stream_chat.call_args.kwargs["unrestricted"] is expected_unrestricted
)
@@ -445,13 +445,12 @@ class TestConsumeFile:
target = consumption_dir / "document.pdf"
shutil.copy(sample_pdf, target)
result = _consume_file(
_consume_file(
filepath=target,
consumption_dir=consumption_dir,
subdirs_as_tags=False,
)
assert result is True
mock_consume_file_delay.apply_async.assert_called_once()
call_args = mock_consume_file_delay.apply_async.call_args
consumable_doc = call_args.kwargs["kwargs"]["input_doc"]
@@ -465,12 +464,11 @@ class TestConsumeFile:
mock_consume_file_delay: MagicMock,
) -> None:
"""Test _consume_file handles nonexistent files gracefully."""
result = _consume_file(
_consume_file(
filepath=consumption_dir / "nonexistent.pdf",
consumption_dir=consumption_dir,
subdirs_as_tags=False,
)
assert result is False
mock_consume_file_delay.apply_async.assert_not_called()
def test_consume_directory(
@@ -482,12 +480,11 @@ class TestConsumeFile:
subdir = consumption_dir / "subdir"
subdir.mkdir()
result = _consume_file(
_consume_file(
filepath=subdir,
consumption_dir=consumption_dir,
subdirs_as_tags=False,
)
assert result is False
mock_consume_file_delay.apply_async.assert_not_called()
def test_consume_with_permission_error(
@@ -502,33 +499,13 @@ class TestConsumeFile:
shutil.copy(sample_pdf, target)
mocker.patch.object(Path, "is_file", side_effect=PermissionError("denied"))
result = _consume_file(
_consume_file(
filepath=target,
consumption_dir=consumption_dir,
subdirs_as_tags=False,
)
assert result is False
mock_consume_file_delay.apply_async.assert_not_called()
def test_consume_with_apply_async_failure(
self,
consumption_dir: Path,
sample_pdf: Path,
mock_consume_file_delay: MagicMock,
) -> None:
"""Test _consume_file reports failure when apply_async raises."""
target = consumption_dir / "document.pdf"
shutil.copy(sample_pdf, target)
mock_consume_file_delay.apply_async.side_effect = Exception("broker down")
result = _consume_file(
filepath=target,
consumption_dir=consumption_dir,
subdirs_as_tags=False,
)
assert result is False
def test_consume_with_tags_error(
self,
consumption_dir: Path,
@@ -545,12 +522,11 @@ class TestConsumeFile:
side_effect=DatabaseError("Something happened"),
)
result = _consume_file(
_consume_file(
filepath=target,
consumption_dir=consumption_dir,
subdirs_as_tags=True,
)
assert result is True
mock_consume_file_delay.apply_async.assert_called_once()
call_args = mock_consume_file_delay.apply_async.call_args
overrides = call_args.kwargs["kwargs"]["overrides"]
@@ -1273,52 +1249,6 @@ class TestProcessExistingFilesQueued:
assert target.resolve() in queued
@pytest.mark.management
@pytest.mark.django_db
class TestCommandRetryAfterQueueFailure:
"""
Regression test for GH #13923.
A file whose ``apply_async`` publish fails (e.g. broker briefly down)
must not be marked as queued, so the periodic rescan retries it once
the broker recovers, instead of stranding it until the consumer
process is restarted.
"""
def test_watch_loop_retries_failed_publish_on_rescan(
self,
consumption_dir: Path,
sample_pdf: Path,
mock_consume_file_delay: MagicMock,
start_consumer: Callable[..., ConsumerThread],
) -> None:
"""A publish failure from the watch loop is retried by the rescan."""
apply_async = mock_consume_file_delay.apply_async
def fail_first_call(*args: object, **kwargs: object) -> None:
if apply_async.call_count == 1:
raise Exception("broker down")
apply_async.side_effect = fail_first_call
thread = start_consumer(stability_delay=0.1, rescan_interval=0.3)
target = consumption_dir / "document.pdf"
shutil.copy(sample_pdf, target)
deadline = monotonic() + 5.0
while apply_async.call_count < 2 and monotonic() < deadline:
sleep(0.1)
if thread.exception:
raise thread.exception
assert apply_async.call_count >= 2, (
"Expected the failed publish to be retried by the rescan, "
f"but apply_async was only called {apply_async.call_count} time(s)"
)
@pytest.mark.management
@pytest.mark.django_db
class TestCommandRescanRecovery:
-13
View File
@@ -75,19 +75,6 @@ class TestTagHierarchy(DirectoriesMixin, APITestCase):
tags = set(self.document.tags.values_list("pk", flat=True))
assert tags == {self.parent.pk, self.child.pk}
def test_document_api_add_child_keeps_parent_already_assigned(self) -> None:
# https://github.com/paperless-ngx/paperless-ngx/issues/13970
inbox = Tag.objects.create(name="Inbox", is_inbox_tag=True)
self.document.add_nested_tags([inbox, self.parent])
self.client.patch(
f"/api/documents/{self.document.pk}/",
{"tags": [self.child.pk]},
format="json",
)
self.document.refresh_from_db()
tags = set(self.document.tags.values_list("pk", flat=True))
assert tags == {self.parent.pk, self.child.pk}
def test_document_api_remove_parent_removes_children(self) -> None:
self.document.add_nested_tags([self.parent, self.child])
self.client.patch(
+1 -5
View File
@@ -5621,15 +5621,11 @@ class TestApplyAISuggestionsWorkflowAction(
action = self.make_action()
self.make_workflow(action, WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED)
with (
mock.patch("documents.tasks.apply_ai_suggestions.delay") as delay,
self.captureOnCommitCallbacks(execute=True),
):
with mock.patch("documents.tasks.apply_ai_suggestions.delay") as delay:
run_workflows(
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
self.doc,
)
delay.assert_not_called()
delay.assert_called_once_with(action_id=action.pk, document_id=self.doc.pk)
-4
View File
@@ -180,7 +180,6 @@ from documents.permissions import has_system_status_permission
from documents.permissions import permitted_document_ids
from documents.permissions import permitted_object_ids
from documents.permissions import set_permissions_for_object
from documents.permissions import user_is_unrestricted
from documents.plugins.date_parsing import get_date_parser
from documents.schema import generate_object_with_permissions_schema
from documents.search import SearchHit
@@ -2330,12 +2329,10 @@ class ChatStreamingView(GenericAPIView[Any]):
return HttpResponseForbidden("Insufficient permissions")
documents = Document.objects.filter(pk=document.pk)
unrestricted = False
else:
documents = Document.objects.filter(
id__in=permitted_document_ids(request.user),
)
unrestricted = user_is_unrestricted(request.user)
output_language = get_llm_output_language(
ai_config=ai_config,
@@ -2346,7 +2343,6 @@ class ChatStreamingView(GenericAPIView[Any]):
stream_chat_with_documents(
query_str=question,
documents=documents,
unrestricted=unrestricted,
output_language=output_language,
),
content_type="text/event-stream",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More