Merge branch 'dev' into dev

This commit is contained in:
Trenton H 2025-11-17 12:48:48 -08:00 committed by GitHub
commit 6088575fdb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
175 changed files with 27174 additions and 11059 deletions

View file

@ -51,5 +51,5 @@ body:
id: logs id: logs
attributes: attributes:
label: Relevant logs or output label: Relevant logs or output
description: If you have logs, errors that might help, paste it here. description: If you have logs, errors that might help, paste it here. For example other containers or services (database, redis, etc).
render: bash render: bash

View file

@ -6,8 +6,8 @@ body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
### ⚠️ Please remember: issues are for *bugs* ### ⚠️ Please remember: issues are for *bugs* only! ⚠️
That is, something you believe affects every single user of Paperless-ngx, not just you. If you're not sure, start with one of the other options below. That is, something you believe affects every single user of Paperless-ngx (and the demo, for example), not just you. If you are not sure, start with one of the other options below.
Also, note that **Paperless-ngx does not perform OCR or archive file creation itself**, those are handled by other tools. Problems with OCR or archive versions of specific files should likely be raised 'upstream', see https://github.com/ocrmypdf/OCRmyPDF/issues or https://github.com/tesseract-ocr/tesseract/issues Also, note that **Paperless-ngx does not perform OCR or archive file creation itself**, those are handled by other tools. Problems with OCR or archive versions of specific files should likely be raised 'upstream', see https://github.com/ocrmypdf/OCRmyPDF/issues or https://github.com/tesseract-ocr/tesseract/issues
- type: markdown - type: markdown
@ -59,6 +59,12 @@ body:
label: Browser logs label: Browser logs
description: Logs from the web browser related to your issue, if needed description: Logs from the web browser related to your issue, if needed
render: bash render: bash
- type: textarea
id: logs_services
attributes:
label: Services logs
description: Logs from other services (or containers) related to your issue, if needed. For example, the database or redis logs.
render: bash
- type: input - type: input
id: version id: version
attributes: attributes:

View file

@ -35,8 +35,8 @@ NOTE: PRs that do not address the following will not be merged, please do not sk
- [ ] I have read & agree with the [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/main/CONTRIBUTING.md). - [ ] I have read & agree with the [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/main/CONTRIBUTING.md).
- [ ] If applicable, I have included testing coverage for new code in this PR, for [backend](https://docs.paperless-ngx.com/development/#testing) and / or [front-end](https://docs.paperless-ngx.com/development/#testing-and-code-style) changes. - [ ] If applicable, I have included testing coverage for new code in this PR, for [backend](https://docs.paperless-ngx.com/development/#testing) and / or [front-end](https://docs.paperless-ngx.com/development/#testing-and-code-style) changes.
- [ ] If applicable, I have tested my code for new features & regressions on both mobile & desktop devices, using the latest version of major browsers. - [ ] If applicable, I have tested my code for breaking changes & regressions on both mobile & desktop devices, using the latest version of major browsers.
- [ ] If applicable, I have checked that all tests pass, see [documentation](https://docs.paperless-ngx.com/development/#back-end-development). - [ ] If applicable, I have checked that all tests pass, see [documentation](https://docs.paperless-ngx.com/development/#back-end-development).
- [ ] I have run all `pre-commit` hooks, see [documentation](https://docs.paperless-ngx.com/development/#code-formatting-with-pre-commit-hooks). - [ ] I have run all `pre-commit` hooks, see [documentation](https://docs.paperless-ngx.com/development/#code-formatting-with-pre-commit-hooks).
- [ ] I have made corresponding changes to the documentation as needed. - [ ] I have made corresponding changes to the documentation as needed.
- [ ] I have checked my modifications for any breaking changes. - [ ] In the description of the PR above I have disclosed the use of AI tools in the coding of this PR.

View file

@ -181,10 +181,11 @@ jobs:
pytest pytest
- name: Upload backend test results to Codecov - name: Upload backend test results to Codecov
if: always() if: always()
uses: codecov/test-results-action@v1 uses: codecov/codecov-action@v5
with: with:
flags: backend-python-${{ matrix.python-version }} flags: backend-python-${{ matrix.python-version }}
files: junit.xml files: junit.xml
report_type: test_results
- name: Upload backend coverage to Codecov - name: Upload backend coverage to Codecov
uses: codecov/codecov-action@v5 uses: codecov/codecov-action@v5
with: with:
@ -260,11 +261,12 @@ jobs:
- name: Run Jest unit tests - name: Run Jest unit tests
run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }} run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
- name: Upload frontend test results to Codecov - name: Upload frontend test results to Codecov
uses: codecov/test-results-action@v1
if: always() if: always()
uses: codecov/codecov-action@v5
with: with:
flags: frontend-node-${{ matrix.node-version }} flags: frontend-node-${{ matrix.node-version }}
directory: src-ui/ directory: src-ui/
report_type: test_results
- name: Upload frontend coverage to Codecov - name: Upload frontend coverage to Codecov
uses: codecov/codecov-action@v5 uses: codecov/codecov-action@v5
with: with:

View file

@ -32,7 +32,7 @@ RUN set -eux \
# Purpose: Installs s6-overlay and rootfs # Purpose: Installs s6-overlay and rootfs
# Comments: # Comments:
# - Don't leave anything extra in here either # - Don't leave anything extra in here either
FROM ghcr.io/astral-sh/uv:0.9.4-python3.12-bookworm-slim AS s6-overlay-base FROM ghcr.io/astral-sh/uv:0.9.9-python3.12-bookworm-slim AS s6-overlay-base
WORKDIR /usr/src/s6 WORKDIR /usr/src/s6

View file

@ -1,5 +1,167 @@
# Changelog # Changelog
## paperless-ngx 2.19.6
### Bug Fixes
- Chore: include password validation on user edit [@shamoon](https://github.com/shamoon) ([#11308](https://github.com/paperless-ngx/paperless-ngx/pull/11308))
- Fix: include BASE_URL when constructing for workflows [@ebardsley](https://github.com/ebardsley) ([#11360](https://github.com/paperless-ngx/paperless-ngx/pull/11360))
- Fixhancement: refactor email attachment logic [@shamoon](https://github.com/shamoon) ([#11336](https://github.com/paperless-ngx/paperless-ngx/pull/11336))
- Fixhancement: trim whitespace for some text searches [@shamoon](https://github.com/shamoon) ([#11357](https://github.com/paperless-ngx/paperless-ngx/pull/11357))
- Fix: update Outlook refresh token when refreshed [@shamoon](https://github.com/shamoon) ([#11341](https://github.com/paperless-ngx/paperless-ngx/pull/11341))
- Fix: only cache remote version data for version checking [@shamoon](https://github.com/shamoon) ([#11320](https://github.com/paperless-ngx/paperless-ngx/pull/11320))
- Fix: include replace none logic in storage path preview, improve jinja conditionals for empty metadata [@shamoon](https://github.com/shamoon) ([#11315](https://github.com/paperless-ngx/paperless-ngx/pull/11315))
### Dependencies
- docker(deps): bump astral-sh/uv from 0.9.7-python3.12-bookworm-slim to 0.9.9-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#11338](https://github.com/paperless-ngx/paperless-ngx/pull/11338))
### All App Changes
<details>
<summary>7 changes</summary>
- Fix: include BASE_URL when constructing for workflows [@ebardsley](https://github.com/ebardsley) ([#11360](https://github.com/paperless-ngx/paperless-ngx/pull/11360))
- Fixhancement: refactor email attachment logic [@shamoon](https://github.com/shamoon) ([#11336](https://github.com/paperless-ngx/paperless-ngx/pull/11336))
- Fixhancement: trim whitespace for some text searches [@shamoon](https://github.com/shamoon) ([#11357](https://github.com/paperless-ngx/paperless-ngx/pull/11357))
- Fix: update Outlook refresh token when refreshed [@shamoon](https://github.com/shamoon) ([#11341](https://github.com/paperless-ngx/paperless-ngx/pull/11341))
- Fix: only cache remote version data for version checking [@shamoon](https://github.com/shamoon) ([#11320](https://github.com/paperless-ngx/paperless-ngx/pull/11320))
- Fix: include replace none logic in storage path preview, improve jinja conditionals for empty metadata [@shamoon](https://github.com/shamoon) ([#11315](https://github.com/paperless-ngx/paperless-ngx/pull/11315))
- Chore: include password validation on user edit [@shamoon](https://github.com/shamoon) ([#11308](https://github.com/paperless-ngx/paperless-ngx/pull/11308))
</details>
## paperless-ngx 2.19.5
### Bug Fixes
- Fix: ensure custom field query propagation, change detection [@shamoon](https://github.com/shamoon) ([#11291](https://github.com/paperless-ngx/paperless-ngx/pull/11291))
### Dependencies
- docker(deps): Bump astral-sh/uv from 0.9.4-python3.12-bookworm-slim to 0.9.7-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#11283](https://github.com/paperless-ngx/paperless-ngx/pull/11283))
### All App Changes
- Fix: ensure custom field query propagation, change detection [@shamoon](https://github.com/shamoon) ([#11291](https://github.com/paperless-ngx/paperless-ngx/pull/11291))
## paperless-ngx 2.19.4
### Bug Fixes
- Fix: use original_file when attaching docs to workflow emails with added trigger [@shamoon](https://github.com/shamoon) ([#11266](https://github.com/paperless-ngx/paperless-ngx/pull/11266))
- Fix: mark 'Select' button in doc list for translation [@shamoon](https://github.com/shamoon) ([#11278](https://github.com/paperless-ngx/paperless-ngx/pull/11278))
- Fix: respect fields parameter for created field [@shamoon](https://github.com/shamoon) ([#11251](https://github.com/paperless-ngx/paperless-ngx/pull/11251))
- Fix: improve legibility of processed mail error popover in light mode [@shamoon](https://github.com/shamoon) ([#11258](https://github.com/paperless-ngx/paperless-ngx/pull/11258))
- Fixhancement: truncate large logs, improve auto-scroll [@shamoon](https://github.com/shamoon) ([#11239](https://github.com/paperless-ngx/paperless-ngx/pull/11239))
- Chore: add max-height and overflow to processedmail error popover [@shamoon](https://github.com/shamoon) ([#11252](https://github.com/paperless-ngx/paperless-ngx/pull/11252))
- Fix: delay iframe DOM removal, handle onafterprint error for print in FF [@shamoon](https://github.com/shamoon) ([#11237](https://github.com/paperless-ngx/paperless-ngx/pull/11237))
- Fix: de-deduplicate children in tag list when filtering [@shamoon](https://github.com/shamoon) ([#11229](https://github.com/paperless-ngx/paperless-ngx/pull/11229))
### Performance
- Performance: re-enable virtual scroll, bump ng-select [@shamoon](https://github.com/shamoon) ([#11279](https://github.com/paperless-ngx/paperless-ngx/pull/11279))
- Performance: use virtual scroll container and log level parsing for logs view [@MickLesk](https://github.com/MickLesk) ([#11233](https://github.com/paperless-ngx/paperless-ngx/pull/11233))
### All App Changes
<details>
<summary>11 changes</summary>
- Performance: re-enable virtual scroll, bump ng-select [@shamoon](https://github.com/shamoon) ([#11279](https://github.com/paperless-ngx/paperless-ngx/pull/11279))
- Fix: use original_file when attaching docs to workflow emails with added trigger [@shamoon](https://github.com/shamoon) ([#11266](https://github.com/paperless-ngx/paperless-ngx/pull/11266))
- Fix: mark 'Select' button in doc list for translation [@shamoon](https://github.com/shamoon) ([#11278](https://github.com/paperless-ngx/paperless-ngx/pull/11278))
- Fix: respect fields parameter for created field [@shamoon](https://github.com/shamoon) ([#11251](https://github.com/paperless-ngx/paperless-ngx/pull/11251))
- Fix: improve legibility of processed mail error popover in light mode [@shamoon](https://github.com/shamoon) ([#11258](https://github.com/paperless-ngx/paperless-ngx/pull/11258))
- Fixhancement: truncate large logs, improve auto-scroll [@shamoon](https://github.com/shamoon) ([#11239](https://github.com/paperless-ngx/paperless-ngx/pull/11239))
- Chore: add max-height and overflow to processedmail error popover [@shamoon](https://github.com/shamoon) ([#11252](https://github.com/paperless-ngx/paperless-ngx/pull/11252))
- Fix: delay iframe DOM removal, handle onafterprint error for print in FF [@shamoon](https://github.com/shamoon) ([#11237](https://github.com/paperless-ngx/paperless-ngx/pull/11237))
- Performance: use virtual scroll container and log level parsing for logs view [@MickLesk](https://github.com/MickLesk) ([#11233](https://github.com/paperless-ngx/paperless-ngx/pull/11233))
- Chore: cache Github version check for 15 minutes [@shamoon](https://github.com/shamoon) ([#11235](https://github.com/paperless-ngx/paperless-ngx/pull/11235))
- Fix: de-deduplicate children in tag list when filtering [@shamoon](https://github.com/shamoon) ([#11229](https://github.com/paperless-ngx/paperless-ngx/pull/11229))
</details>
## paperless-ngx 2.19.3
### Bug Fixes
- Fix: remove unnecessary permission requirements for new email endpoint [@shamoon](https://github.com/shamoon) ([#11215](https://github.com/paperless-ngx/paperless-ngx/pull/11215))
- Fix: refactor nested sorting in filterable dropdowns [@shamoon](https://github.com/shamoon) ([#11214](https://github.com/paperless-ngx/paperless-ngx/pull/11214))
- Fix: add root tag filtering for tag list page consistency, fix toggle all [@shamoon](https://github.com/shamoon) ([#11208](https://github.com/paperless-ngx/paperless-ngx/pull/11208))
- Fix: support ConsumableDocument in email attachments [@shamoon](https://github.com/shamoon) ([#11196](https://github.com/paperless-ngx/paperless-ngx/pull/11196))
- Fix: add missing import for ConfirmButtonComponent in user-edit-dialog [@shamoon](https://github.com/shamoon) ([#11167](https://github.com/paperless-ngx/paperless-ngx/pull/11167))
- Fix: resolve migration warning in 2.19.2 [@shamoon](https://github.com/shamoon) ([#11157](https://github.com/paperless-ngx/paperless-ngx/pull/11157))
### Changes
- Change: make workflow action only title draggable [@shamoon](https://github.com/shamoon) ([#11209](https://github.com/paperless-ngx/paperless-ngx/pull/11209))
- Change: change workflowrun to softdeletemodel [@shamoon](https://github.com/shamoon) ([#11194](https://github.com/paperless-ngx/paperless-ngx/pull/11194))
### Dependencies
- Chore(deps): Bump django from 5.2.6 to 5.2.7 @[dependabot[bot]](https://github.com/apps/dependabot) ([#11200](https://github.com/paperless-ngx/paperless-ngx/pull/11200))
### All App Changes
<details>
<summary>9 changes</summary>
- Chore(deps): Bump django from 5.2.6 to 5.2.7 @[dependabot[bot]](https://github.com/apps/dependabot) ([#11200](https://github.com/paperless-ngx/paperless-ngx/pull/11200))
- Fix: remove unnecessary permission requirements for new email endpoint [@shamoon](https://github.com/shamoon) ([#11215](https://github.com/paperless-ngx/paperless-ngx/pull/11215))
- Fix: refactor nested sorting in filterable dropdowns [@shamoon](https://github.com/shamoon) ([#11214](https://github.com/paperless-ngx/paperless-ngx/pull/11214))
- Fix: add root tag filtering for tag list page consistency, fix toggle all [@shamoon](https://github.com/shamoon) ([#11208](https://github.com/paperless-ngx/paperless-ngx/pull/11208))
- Change: make workflow action only title draggable [@shamoon](https://github.com/shamoon) ([#11209](https://github.com/paperless-ngx/paperless-ngx/pull/11209))
- Change: change workflowrun to softdeletemodel [@shamoon](https://github.com/shamoon) ([#11194](https://github.com/paperless-ngx/paperless-ngx/pull/11194))
- Chore: Minor migration optimization for workflow titles [@stumpylog](https://github.com/stumpylog) ([#11197](https://github.com/paperless-ngx/paperless-ngx/pull/11197))
- Fix: support ConsumableDocument in email attachments [@shamoon](https://github.com/shamoon) ([#11196](https://github.com/paperless-ngx/paperless-ngx/pull/11196))
- Fix: add missing import for ConfirmButtonComponent in user-edit-dialog [@shamoon](https://github.com/shamoon) ([#11167](https://github.com/paperless-ngx/paperless-ngx/pull/11167))
- Fix: resolve migration warning in 2.19.2 [@shamoon](https://github.com/shamoon) ([#11157](https://github.com/paperless-ngx/paperless-ngx/pull/11157))
</details>
## paperless-ngx 2.19.2
### Features / Enhancements
- Fixhancement: display loading status for tags instead of 'Private' [@shamoon](https://github.com/shamoon) ([#11140](https://github.com/paperless-ngx/paperless-ngx/pull/11140))
### Bug Fixes
- Fix: Remove edit requirement for bulk email, show based on setting [@shamoon](https://github.com/shamoon) ([#11149](https://github.com/paperless-ngx/paperless-ngx/pull/11149))
- Fix: handle undefined IDs in getOriginalObject [@shamoon](https://github.com/shamoon) ([#11147](https://github.com/paperless-ngx/paperless-ngx/pull/11147))
### All App Changes
<details>
<summary>3 changes</summary>
- Fix: Remove edit requirement for bulk email, show based on setting [@shamoon](https://github.com/shamoon) ([#11149](https://github.com/paperless-ngx/paperless-ngx/pull/11149))
- Fix: handle undefined IDs in getOriginalObject [@shamoon](https://github.com/shamoon) ([#11147](https://github.com/paperless-ngx/paperless-ngx/pull/11147))
- Fixhancement: display loading status for tags instead of 'Private' [@shamoon](https://github.com/shamoon) ([#11140](https://github.com/paperless-ngx/paperless-ngx/pull/11140))
</details>
## paperless-ngx 2.19.1
### Bug Fixes
- Fix: skip workflow title migration for empty titles [@shamoon](https://github.com/shamoon) ([#11136](https://github.com/paperless-ngx/paperless-ngx/pull/11136))
- Fix: restore workflow title migration [@shamoon](https://github.com/shamoon) ([#11131](https://github.com/paperless-ngx/paperless-ngx/pull/11131))
- Fix: retrieve document_count for tag children [@shamoon](https://github.com/shamoon) ([#11125](https://github.com/paperless-ngx/paperless-ngx/pull/11125))
- Fix: move hierarchical order logic in dropdown sorting [@shamoon](https://github.com/shamoon) ([#11128](https://github.com/paperless-ngx/paperless-ngx/pull/11128))
- Fix: use original object for children in tag list [@shamoon](https://github.com/shamoon) ([#11127](https://github.com/paperless-ngx/paperless-ngx/pull/11127))
- Fix: dont display or fetch users or groups with insufficient perms [@shamoon](https://github.com/shamoon) ([#11111](https://github.com/paperless-ngx/paperless-ngx/pull/11111))
### All App Changes
<details>
<summary>6 changes</summary>
- Fix: skip workflow title migration for empty titles [@shamoon](https://github.com/shamoon) ([#11136](https://github.com/paperless-ngx/paperless-ngx/pull/11136))
- Fix: restore workflow title migration [@shamoon](https://github.com/shamoon) ([#11131](https://github.com/paperless-ngx/paperless-ngx/pull/11131))
- Fix: retrieve document_count for tag children [@shamoon](https://github.com/shamoon) ([#11125](https://github.com/paperless-ngx/paperless-ngx/pull/11125))
- Fix: move hierarchical order logic in dropdown sorting [@shamoon](https://github.com/shamoon) ([#11128](https://github.com/paperless-ngx/paperless-ngx/pull/11128))
- Fix: use original object for children in tag list [@shamoon](https://github.com/shamoon) ([#11127](https://github.com/paperless-ngx/paperless-ngx/pull/11127))
- Fix: dont display or fetch users or groups with insufficient perms [@shamoon](https://github.com/shamoon) ([#11111](https://github.com/paperless-ngx/paperless-ngx/pull/11111))
</details>
## paperless-ngx 2.19.0 ## paperless-ngx 2.19.0
### Notable Changes ### Notable Changes

View file

@ -326,7 +326,7 @@ are released, dependency support is confirmed, etc.
!!! warning !!! warning
Ensure your Redis instance [is secured](https://redis.io/docs/getting-started/#securing-redis). Ensure your Redis instance [is secured](https://redis.io/docs/latest/operate/oss_and_stack/management/security/).
7. Create the following directories if they are missing: 7. Create the following directories if they are missing:

View file

@ -553,6 +553,7 @@ applied. You can use the following placeholders in the template with any trigger
- `{{added_time}}`: added time in HH:MM format - `{{added_time}}`: added time in HH:MM format
- `{{original_filename}}`: original file name without extension - `{{original_filename}}`: original file name without extension
- `{{filename}}`: current file name without extension - `{{filename}}`: current file name without extension
- `{{doc_title}}`: current document title
The following placeholders are only available for "added" or "updated" triggers The following placeholders are only available for "added" or "updated" triggers

View file

@ -374,7 +374,7 @@ fi
# of the provided folder # of the provided folder
if [[ -n $DATABASE_FOLDER ]] ; then if [[ -n $DATABASE_FOLDER ]] ; then
if [[ "$DATABASE_BACKEND" == "postgres" ]] ; then if [[ "$DATABASE_BACKEND" == "postgres" ]] ; then
sed -i "s#- pgdata:/var/lib/postgresql/data#- $DATABASE_FOLDER:/var/lib/postgresql/data#g" docker-compose.yml sed -i "s#- pgdata:/var/lib/postgresql#- $DATABASE_FOLDER:/var/lib/postgresql#g" docker-compose.yml
sed -i "/^\s*pgdata:/d" docker-compose.yml sed -i "/^\s*pgdata:/d" docker-compose.yml
elif [[ "$DATABASE_BACKEND" == "mariadb" ]]; then elif [[ "$DATABASE_BACKEND" == "mariadb" ]]; then
sed -i "s#- dbdata:/var/lib/mysql#- $DATABASE_FOLDER:/var/lib/mysql#g" docker-compose.yml sed -i "s#- dbdata:/var/lib/mysql#- $DATABASE_FOLDER:/var/lib/mysql#g" docker-compose.yml

View file

@ -1,6 +1,6 @@
[project] [project]
name = "paperless-ngx" name = "paperless-ngx"
version = "2.19.0" version = "2.19.6"
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents" description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{ {
"name": "paperless-ngx-ui", "name": "paperless-ngx-ui",
"version": "2.19.0", "version": "2.19.6",
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm", "preinstall": "npx only-allow pnpm",
"ng": "ng", "ng": "ng",
@ -21,7 +21,7 @@
"@angular/platform-browser-dynamic": "~20.3.2", "@angular/platform-browser-dynamic": "~20.3.2",
"@angular/router": "~20.3.2", "@angular/router": "~20.3.2",
"@ng-bootstrap/ng-bootstrap": "^19.0.1", "@ng-bootstrap/ng-bootstrap": "^19.0.1",
"@ng-select/ng-select": "^20.2.2", "@ng-select/ng-select": "^20.6.3",
"@ngneat/dirty-check-forms": "^3.0.3", "@ngneat/dirty-check-forms": "^3.0.3",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",

12
src-ui/pnpm-lock.yaml generated
View file

@ -39,8 +39,8 @@ importers:
specifier: ^19.0.1 specifier: ^19.0.1
version: 19.0.1(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/forms@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2))(@angular/localize@20.3.2(@angular/compiler-cli@20.3.2(@angular/compiler@20.3.2)(typescript@5.8.3))(@angular/compiler@20.3.2))(@popperjs/core@2.11.8)(rxjs@7.8.2) version: 19.0.1(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/forms@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2))(@angular/localize@20.3.2(@angular/compiler-cli@20.3.2(@angular/compiler@20.3.2)(typescript@5.8.3))(@angular/compiler@20.3.2))(@popperjs/core@2.11.8)(rxjs@7.8.2)
'@ng-select/ng-select': '@ng-select/ng-select':
specifier: ^20.2.2 specifier: ^20.6.3
version: 20.2.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/forms@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2)) version: 20.6.3(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/forms@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2))
'@ngneat/dirty-check-forms': '@ngneat/dirty-check-forms':
specifier: ^3.0.3 specifier: ^3.0.3
version: 3.0.3(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/forms@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2))(@angular/router@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2))(lodash-es@4.17.21)(rxjs@7.8.2) version: 3.0.3(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/forms@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2))(@angular/router@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2))(lodash-es@4.17.21)(rxjs@7.8.2)
@ -2356,9 +2356,9 @@ packages:
'@popperjs/core': ^2.11.8 '@popperjs/core': ^2.11.8
rxjs: ^6.5.3 || ^7.4.0 rxjs: ^6.5.3 || ^7.4.0
'@ng-select/ng-select@20.2.2': '@ng-select/ng-select@20.6.3':
resolution: {integrity: sha512-7mctt04/q9yquE4Ec1dQG+SkY6fZ2BQnJLsWmb05TCxYKAYAzDrDTgJJruPDuWrpYx+f3SwejpaI+z/GDrwYdw==} resolution: {integrity: sha512-+aX2l3OshyPsyMCAuiA3ND5c6X1DG5jQjdlP8PBIyYEoQWlxEcgJWrMsPa7mHVFRpp+5KZZhnXhyosUE4CEc3w==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} engines: {node: ^22.12.0 || >=24.0.0}
peerDependencies: peerDependencies:
'@angular/common': ^20.0.0 '@angular/common': ^20.0.0
'@angular/core': ^20.0.0 '@angular/core': ^20.0.0
@ -9413,7 +9413,7 @@ snapshots:
rxjs: 7.8.2 rxjs: 7.8.2
tslib: 2.8.1 tslib: 2.8.1
'@ng-select/ng-select@20.2.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/forms@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2))': '@ng-select/ng-select@20.6.3(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/forms@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2))':
dependencies: dependencies:
'@angular/common': 20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) '@angular/common': 20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2)
'@angular/core': 20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1) '@angular/core': 20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1)

View file

@ -3,9 +3,23 @@
i18n-title i18n-title
info="Review the log files for the application and for email checking." info="Review the log files for the application and for email checking."
i18n-info> i18n-info>
<div class="form-check form-switch"> <div class="input-group input-group-sm align-items-center">
<input class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled"> <div class="input-group input-group-sm me-3">
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label> <span class="input-group-text text-muted" i18n>Show</span>
<input
class="form-control"
type="number"
min="100"
step="100"
[(ngModel)]="limit"
(ngModelChange)="onLimitChange($event)"
style="width: 100px;">
<span class="input-group-text text-muted" i18n>lines</span>
</div>
<div class="form-check form-switch mt-1">
<input class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled">
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
</div>
</div> </div>
</pngx-page-header> </pngx-page-header>
@ -29,14 +43,19 @@
<div [ngbNavOutlet]="nav" class="mt-2"></div> <div [ngbNavOutlet]="nav" class="mt-2"></div>
<div class="bg-dark p-3 text-light font-monospace log-container" #logContainer> <cdk-virtual-scroll-viewport
@if (loading && logFiles.length) { itemSize="20"
class="bg-dark p-3 text-light font-monospace log-container"
#logContainer>
@if (loading && !logFiles.length) {
<div> <div>
<div class="spinner-border spinner-border-sm me-2" role="status"></div> <div class="spinner-border spinner-border-sm me-2" role="status"></div>
<ng-container i18n>Loading...</ng-container> <ng-container i18n>Loading...</ng-container>
</div> </div>
} }
@for (log of logs; track $index) { <p *cdkVirtualFor="let log of logs"
<p class="m-0 p-0 log-entry-{{getLogLevel(log)}}">{{log}}</p> class="m-0 p-0"
} [ngClass]="'log-entry-' + log.level">
</div> {{log.message}}
</p>
</cdk-virtual-scroll-viewport>

View file

@ -18,7 +18,7 @@
.log-container { .log-container {
overflow-y: scroll; overflow-y: scroll;
height: calc(100vh - 200px); height: calc(100vh - 200px);
top: 70px; top: 0;
p { p {
white-space: pre-wrap; white-space: pre-wrap;

View file

@ -1,3 +1,8 @@
import {
CdkVirtualScrollViewport,
ScrollingModule,
} from '@angular/cdk/scrolling'
import { CommonModule } from '@angular/common'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing' import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
@ -38,6 +43,9 @@ describe('LogsComponent', () => {
NgxBootstrapIconsModule.pick(allIcons), NgxBootstrapIconsModule.pick(allIcons),
LogsComponent, LogsComponent,
PageHeaderComponent, PageHeaderComponent,
CommonModule,
CdkVirtualScrollViewport,
ScrollingModule,
], ],
providers: [ providers: [
provideHttpClient(withInterceptorsFromDi()), provideHttpClient(withInterceptorsFromDi()),
@ -54,13 +62,12 @@ describe('LogsComponent', () => {
fixture = TestBed.createComponent(LogsComponent) fixture = TestBed.createComponent(LogsComponent)
component = fixture.componentInstance component = fixture.componentInstance
reloadSpy = jest.spyOn(component, 'reloadLogs') reloadSpy = jest.spyOn(component, 'reloadLogs')
window.HTMLElement.prototype.scroll = function () {} // mock scroll
jest.useFakeTimers() jest.useFakeTimers()
fixture.detectChanges() fixture.detectChanges()
}) })
it('should display logs with first log initially', () => { it('should display logs with first log initially', () => {
expect(logSpy).toHaveBeenCalledWith('paperless') expect(logSpy).toHaveBeenCalledWith('paperless', 5000)
fixture.detectChanges() fixture.detectChanges()
expect(fixture.debugElement.nativeElement.textContent).toContain( expect(fixture.debugElement.nativeElement.textContent).toContain(
paperless_logs[0] paperless_logs[0]
@ -71,7 +78,7 @@ describe('LogsComponent', () => {
fixture.debugElement fixture.debugElement
.queryAll(By.directive(NgbNavLink))[1] .queryAll(By.directive(NgbNavLink))[1]
.nativeElement.dispatchEvent(new MouseEvent('click')) .nativeElement.dispatchEvent(new MouseEvent('click'))
expect(logSpy).toHaveBeenCalledWith('mail') expect(logSpy).toHaveBeenCalledWith('mail', 5000)
}) })
it('should handle error with no logs', () => { it('should handle error with no logs', () => {
@ -83,6 +90,10 @@ describe('LogsComponent', () => {
}) })
it('should auto refresh, allow toggle', () => { it('should auto refresh, allow toggle', () => {
jest
.spyOn(CdkVirtualScrollViewport.prototype, 'scrollToIndex')
.mockImplementation(() => undefined)
jest.advanceTimersByTime(6000) jest.advanceTimersByTime(6000)
expect(reloadSpy).toHaveBeenCalledTimes(2) expect(reloadSpy).toHaveBeenCalledTimes(2)
@ -90,4 +101,13 @@ describe('LogsComponent', () => {
jest.advanceTimersByTime(6000) jest.advanceTimersByTime(6000)
expect(reloadSpy).toHaveBeenCalledTimes(2) expect(reloadSpy).toHaveBeenCalledTimes(2)
}) })
it('should debounce limit changes before reloading logs', () => {
const initialCalls = reloadSpy.mock.calls.length
component.onLimitChange(6000)
jest.advanceTimersByTime(299)
expect(reloadSpy).toHaveBeenCalledTimes(initialCalls)
jest.advanceTimersByTime(1)
expect(reloadSpy).toHaveBeenCalledTimes(initialCalls + 1)
})
}) })

View file

@ -1,7 +1,11 @@
import {
CdkVirtualScrollViewport,
ScrollingModule,
} from '@angular/cdk/scrolling'
import { CommonModule } from '@angular/common'
import { import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
ElementRef,
OnDestroy, OnDestroy,
OnInit, OnInit,
ViewChild, ViewChild,
@ -9,7 +13,7 @@ import {
} from '@angular/core' } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap' import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'
import { filter, takeUntil, timer } from 'rxjs' import { Subject, debounceTime, filter, takeUntil, timer } from 'rxjs'
import { LogService } from 'src/app/services/rest/log.service' import { LogService } from 'src/app/services/rest/log.service'
import { PageHeaderComponent } from '../../common/page-header/page-header.component' import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
@ -21,8 +25,11 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
imports: [ imports: [
PageHeaderComponent, PageHeaderComponent,
NgbNavModule, NgbNavModule,
CommonModule,
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
CdkVirtualScrollViewport,
ScrollingModule,
], ],
}) })
export class LogsComponent export class LogsComponent
@ -32,7 +39,7 @@ export class LogsComponent
private logService = inject(LogService) private logService = inject(LogService)
private changedetectorRef = inject(ChangeDetectorRef) private changedetectorRef = inject(ChangeDetectorRef)
public logs: string[] = [] public logs: Array<{ message: string; level: number }> = []
public logFiles: string[] = [] public logFiles: string[] = []
@ -40,9 +47,17 @@ export class LogsComponent
public autoRefreshEnabled: boolean = true public autoRefreshEnabled: boolean = true
@ViewChild('logContainer') logContainer: ElementRef public limit: number = 5000
private readonly limitChange$ = new Subject<number>()
@ViewChild('logContainer') logContainer: CdkVirtualScrollViewport
ngOnInit(): void { ngOnInit(): void {
this.limitChange$
.pipe(debounceTime(300), takeUntil(this.unsubscribeNotifier))
.subscribe(() => this.reloadLogs())
this.logService this.logService
.list() .list()
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
@ -68,16 +83,33 @@ export class LogsComponent
super.ngOnDestroy() super.ngOnDestroy()
} }
onLimitChange(limit: number): void {
this.limitChange$.next(limit)
}
reloadLogs() { reloadLogs() {
this.loading = true this.loading = true
this.logService this.logService
.get(this.activeLog) .get(this.activeLog, this.limit)
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: (result) => { next: (result) => {
this.logs = result
this.loading = false this.loading = false
this.scrollToBottom() const parsed = this.parseLogsWithLevel(result)
const hasChanges =
parsed.length !== this.logs.length ||
parsed.some((log, idx) => {
const current = this.logs[idx]
return (
!current ||
current.message !== log.message ||
current.level !== log.level
)
})
if (hasChanges) {
this.logs = parsed
this.scrollToBottom()
}
}, },
error: () => { error: () => {
this.logs = [] this.logs = []
@ -100,12 +132,19 @@ export class LogsComponent
} }
} }
private parseLogsWithLevel(
logs: string[]
): Array<{ message: string; level: number }> {
return logs.map((log) => ({
message: log,
level: this.getLogLevel(log),
}))
}
scrollToBottom(): void { scrollToBottom(): void {
this.changedetectorRef.detectChanges() this.changedetectorRef.detectChanges()
this.logContainer?.nativeElement.scroll({ if (this.logContainer) {
top: this.logContainer.nativeElement.scrollHeight, this.logContainer.scrollToIndex(this.logs.length - 1)
left: 0, }
behavior: 'auto',
})
} }
} }

View file

@ -68,13 +68,15 @@
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse" <nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
[ngClass]="slimSidebarEnabled ? 'slim' : 'col-md-3 col-lg-2 col-xxxl-1'" [class.animating]="slimSidebarAnimating" [ngClass]="slimSidebarEnabled ? 'slim' : 'col-md-3 col-lg-2 col-xxxl-1'" [class.animating]="slimSidebarAnimating"
[ngbCollapse]="isMenuCollapsed"> [ngbCollapse]="isMenuCollapsed">
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()"> @if (canSaveSettings) {
@if (slimSidebarEnabled) { <button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()">
<i-bs width="0.9em" height="0.9em" name="chevron-double-right"></i-bs> @if (slimSidebarEnabled) {
} @else { <i-bs width="0.9em" height="0.9em" name="chevron-double-right"></i-bs>
<i-bs width="0.9em" height="0.9em" name="chevron-double-left"></i-bs> } @else {
} <i-bs width="0.9em" height="0.9em" name="chevron-double-left"></i-bs>
</button> }
</button>
}
<div class="sidebar-sticky pt-3 d-flex flex-column justify-space-around"> <div class="sidebar-sticky pt-3 d-flex flex-column justify-space-around">
<ul class="nav flex-column"> <ul class="nav flex-column">
<li class="nav-item app-link"> <li class="nav-item app-link">

View file

@ -152,6 +152,19 @@ export class AppFrameComponent
return this.settingsService.get(SETTINGS_KEYS.APP_TITLE) return this.settingsService.get(SETTINGS_KEYS.APP_TITLE)
} }
get canSaveSettings(): boolean {
return (
this.permissionsService.currentUserCan(
PermissionAction.Change,
PermissionType.UISettings
) &&
this.permissionsService.currentUserCan(
PermissionAction.Add,
PermissionType.UISettings
)
)
}
get slimSidebarEnabled(): boolean { get slimSidebarEnabled(): boolean {
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR) return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
} }

View file

@ -63,6 +63,7 @@
bindValue="id" bindValue="id"
[(ngModel)]="atom.value" [(ngModel)]="atom.value"
[disabled]="disabled" [disabled]="disabled"
[virtualScroll]="getSelectOptionsForField(atom.field)?.length > 100"
(mousedown)="$event.stopImmediatePropagation()" (mousedown)="$event.stopImmediatePropagation()"
></ng-select> ></ng-select>
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.DocumentLink) { } @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.DocumentLink) {

View file

@ -354,5 +354,13 @@ describe('CustomFieldsQueryDropdownComponent', () => {
model.removeElement(atom) model.removeElement(atom)
expect(completeSpy).toHaveBeenCalled() expect(completeSpy).toHaveBeenCalled()
}) })
it('should subscribe to existing elements when queries are assigned', () => {
const expression = new CustomFieldQueryExpression()
const nextSpy = jest.spyOn(model.changed, 'next')
model.queries = [expression]
expression.changed.next(expression)
expect(nextSpy).toHaveBeenCalledWith(model)
})
}) })
}) })

View file

@ -17,7 +17,7 @@ import {
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select' import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { first, Subject, takeUntil } from 'rxjs' import { first, Subject, Subscription, takeUntil } from 'rxjs'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { import {
CUSTOM_FIELD_QUERY_MAX_ATOMS, CUSTOM_FIELD_QUERY_MAX_ATOMS,
@ -41,10 +41,27 @@ import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.comp
import { DocumentLinkComponent } from '../input/document-link/document-link.component' import { DocumentLinkComponent } from '../input/document-link/document-link.component'
export class CustomFieldQueriesModel { export class CustomFieldQueriesModel {
public queries: CustomFieldQueryElement[] = [] private _queries: CustomFieldQueryElement[] = []
private rootSubscriptions: Subscription[] = []
public readonly changed = new Subject<CustomFieldQueriesModel>() public readonly changed = new Subject<CustomFieldQueriesModel>()
public get queries(): CustomFieldQueryElement[] {
return this._queries
}
public set queries(value: CustomFieldQueryElement[]) {
this.teardownRootSubscriptions()
this._queries = value ?? []
for (const element of this._queries) {
this.rootSubscriptions.push(
element.changed.subscribe(() => {
this.changed.next(this)
})
)
}
}
public clear(fireEvent = true) { public clear(fireEvent = true) {
this.queries = [] this.queries = []
if (fireEvent) { if (fireEvent) {
@ -107,14 +124,14 @@ export class CustomFieldQueriesModel {
public addExpression( public addExpression(
expression: CustomFieldQueryExpression = new CustomFieldQueryExpression() expression: CustomFieldQueryExpression = new CustomFieldQueryExpression()
) { ) {
if (this.queries.length > 0) { if (this.queries.length === 0) {
;( this.queries = [expression]
(this.queries[0] as CustomFieldQueryExpression) return
.value as CustomFieldQueryElement[]
).push(expression)
} else {
this.queries.push(expression)
} }
;(
(this.queries[0] as CustomFieldQueryExpression)
.value as CustomFieldQueryElement[]
).push(expression)
expression.changed.subscribe(() => { expression.changed.subscribe(() => {
this.changed.next(this) this.changed.next(this)
}) })
@ -166,6 +183,13 @@ export class CustomFieldQueriesModel {
this.changed.next(this) this.changed.next(this)
} }
} }
private teardownRootSubscriptions() {
for (const subscription of this.rootSubscriptions) {
subscription.unsubscribe()
}
this.rootSubscriptions = []
}
} }
@Component({ @Component({

View file

@ -14,6 +14,7 @@ import { GroupService } from 'src/app/services/rest/group.service'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
import { PasswordComponent } from '../../input/password/password.component' import { PasswordComponent } from '../../input/password/password.component'
import { SelectComponent } from '../../input/select/select.component' import { SelectComponent } from '../../input/select/select.component'
import { TextComponent } from '../../input/text/text.component' import { TextComponent } from '../../input/text/text.component'
@ -28,6 +29,7 @@ import { PermissionsSelectComponent } from '../../permissions-select/permissions
SelectComponent, SelectComponent,
TextComponent, TextComponent,
PasswordComponent, PasswordComponent,
ConfirmButtonComponent,
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
], ],

View file

@ -77,9 +77,11 @@
</div> </div>
<div ngbAccordion [closeOthers]="true" cdkDropList (cdkDropListDropped)="onActionDrop($event)"> <div ngbAccordion [closeOthers]="true" cdkDropList (cdkDropListDropped)="onActionDrop($event)">
@for (action of object?.actions; track action; let i = $index){ @for (action of object?.actions; track action; let i = $index){
<div ngbAccordionItem cdkDrag [formGroup]="actionFields.controls[i]"> <div ngbAccordionItem [formGroup]="actionFields.controls[i]">
<div ngbAccordionHeader> <div ngbAccordionHeader cdkDrag>
<button ngbAccordionButton>{{i + 1}}. {{getActionTypeOptionName(actionFields.controls[i].value.type)}} <button ngbAccordionButton>
<i-bs name="grip-vertical" class="ms-n3 pe-1"></i-bs>
{{i + 1}}. {{getActionTypeOptionName(actionFields.controls[i].value.type)}}
@if(action.id) { @if(action.id) {
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{action.id}}</span> <span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{action.id}}</span>
} }

View file

@ -11,3 +11,7 @@
:host ::ng-deep .filters .paperless-input-select.mb-3 { :host ::ng-deep .filters .paperless-input-select.mb-3 {
margin-bottom: 0 !important; margin-bottom: 0 !important;
} }
.ms-n3 {
margin-left: -1rem !important;
}

View file

@ -564,6 +564,167 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
]) ])
}) })
it('keeps children with their parent when parent has document count', () => {
const parent: Tag = {
id: 10,
name: 'Parent Tag',
orderIndex: 0,
document_count: 2,
}
const child: Tag = {
id: 11,
name: 'Child Tag',
parent: parent.id,
orderIndex: 1,
document_count: 0,
}
const otherRoot: Tag = {
id: 20,
name: 'Other Tag',
orderIndex: 2,
document_count: 0,
}
component.selectionModel.items = [parent, child, otherRoot]
component.selectionModel = selectionModel
component.documentCounts = [
{ id: parent.id, document_count: 2 },
{ id: otherRoot.id, document_count: 0 },
]
selectionModel.apply()
expect(component.selectionModel.items).toEqual([
nullItem,
parent,
child,
otherRoot,
])
})
it('keeps selected branches ahead of document-based ordering', () => {
const selectedRoot: Tag = {
id: 30,
name: 'Selected Root',
orderIndex: 0,
document_count: 0,
}
const otherRoot: Tag = {
id: 40,
name: 'Other Root',
orderIndex: 1,
document_count: 2,
}
component.selectionModel.items = [selectedRoot, otherRoot]
component.selectionModel = selectionModel
selectionModel.set(selectedRoot.id, ToggleableItemState.Selected)
component.documentCounts = [
{ id: selectedRoot.id, document_count: 0 },
{ id: otherRoot.id, document_count: 2 },
]
selectionModel.apply()
expect(component.selectionModel.items).toEqual([
nullItem,
selectedRoot,
otherRoot,
])
})
it('uses fallback document counts when selection data is missing', () => {
const fallbackRoot: Tag = {
id: 50,
name: 'Fallback Root',
orderIndex: 0,
document_count: 3,
}
const fallbackChild: Tag = {
id: 51,
name: 'Fallback Child',
parent: fallbackRoot.id,
orderIndex: 1,
document_count: 0,
}
const otherRoot: Tag = {
id: 60,
name: 'Other Root',
orderIndex: 2,
document_count: 0,
}
component.selectionModel = selectionModel
selectionModel.items = [fallbackRoot, fallbackChild, otherRoot]
component.documentCounts = [{ id: otherRoot.id, document_count: 0 }]
selectionModel.apply()
expect(selectionModel.items).toEqual([
nullItem,
fallbackRoot,
fallbackChild,
otherRoot,
])
})
it('handles special and non-numeric ids when promoting branches', () => {
const rootWithDocs: Tag = {
id: 70,
name: 'Root With Docs',
orderIndex: 0,
document_count: 1,
}
const miscItem: any = { id: 'misc', name: 'Misc Item' }
component.selectionModel = selectionModel
selectionModel.intersection = Intersection.Exclude
selectionModel.items = [rootWithDocs, miscItem as any]
component.documentCounts = [{ id: rootWithDocs.id, document_count: 1 }]
selectionModel.apply()
expect(selectionModel.items.map((item) => item.id)).toEqual([
NEGATIVE_NULL_FILTER_VALUE,
rootWithDocs.id,
'misc',
])
})
it('memoizes root document counts between lookups', () => {
const memoRoot: Tag = { id: 80, name: 'Memo Root' }
selectionModel.items = [memoRoot]
selectionModel.documentCounts = [{ id: memoRoot.id, document_count: 9 }]
const getRootDocCount = (selectionModel as any).createRootDocCounter()
expect(getRootDocCount(memoRoot.id)).toEqual(9)
selectionModel.documentCounts = []
expect(getRootDocCount(memoRoot.id)).toEqual(9)
})
it('falls back to model stored document counts if selection data missing entry', () => {
const rootWithoutSelection: Tag = {
id: 90,
name: 'Fallback Root',
document_count: 4,
}
selectionModel.items = [rootWithoutSelection]
selectionModel.documentCounts = []
const getRootDocCount = (selectionModel as any).createRootDocCounter()
expect(getRootDocCount(rootWithoutSelection.id)).toEqual(4)
})
it('defaults to zero document count when neither selection nor model provide it', () => {
const rootWithoutCounts: Tag = { id: 91, name: 'Fallback Zero Root' }
selectionModel.items = [rootWithoutCounts]
selectionModel.documentCounts = []
const getRootDocCount = (selectionModel as any).createRootDocCounter()
expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0)
})
it('should set support create, keep open model and call createRef method', fakeAsync(() => { it('should set support create, keep open model and call createRef method', fakeAsync(() => {
component.selectionModel.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'

View file

@ -32,6 +32,14 @@ export interface ChangedItems {
itemsToRemove: MatchingModel[] itemsToRemove: MatchingModel[]
} }
type BranchSummary = {
items: MatchingModel[]
firstIndex: number
special: boolean
selected: boolean
hasDocs: boolean
}
export enum LogicalOperator { export enum LogicalOperator {
And = 'and', And = 'and',
Or = 'or', Or = 'or',
@ -114,6 +122,13 @@ export class FilterableDropdownSelectionModel {
b.id == NEGATIVE_NULL_FILTER_VALUE) b.id == NEGATIVE_NULL_FILTER_VALUE)
) { ) {
return 1 return 1
}
// Preserve hierarchical order when provided (e.g., Tags)
const ao = (a as any)['orderIndex']
const bo = (b as any)['orderIndex']
if (ao !== undefined && bo !== undefined) {
return ao - bo
} else if ( } else if (
this.getNonTemporary(a.id) == ToggleableItemState.NotSelected && this.getNonTemporary(a.id) == ToggleableItemState.NotSelected &&
this.getNonTemporary(b.id) != ToggleableItemState.NotSelected this.getNonTemporary(b.id) != ToggleableItemState.NotSelected
@ -136,17 +151,14 @@ export class FilterableDropdownSelectionModel {
this.getDocumentCount(a.id) < this.getDocumentCount(b.id) this.getDocumentCount(a.id) < this.getDocumentCount(b.id)
) { ) {
return 1 return 1
}
// Preserve hierarchical order when provided (e.g., Tags)
const ao = (a as any)['orderIndex']
const bo = (b as any)['orderIndex']
if (ao !== undefined && bo !== undefined) {
return ao - bo
} else { } else {
return a.name.localeCompare(b.name) return a.name.localeCompare(b.name)
} }
}) })
if (this._documentCounts.length) {
this.promoteBranchesWithDocumentCounts()
}
} }
private selectionStates = new Map<number, ToggleableItemState>() private selectionStates = new Map<number, ToggleableItemState>()
@ -380,6 +392,180 @@ export class FilterableDropdownSelectionModel {
return this._documentCounts.find((c) => c.id === id)?.document_count return this._documentCounts.find((c) => c.id === id)?.document_count
} }
private promoteBranchesWithDocumentCounts() {
const parentById = this.buildParentById()
const findRootId = this.createRootFinder(parentById)
const getRootDocCount = this.createRootDocCounter()
const summaries = this.buildBranchSummaries(findRootId, getRootDocCount)
const orderedBranches = this.orderBranchesByPriority(summaries)
this._items = orderedBranches.flatMap((summary) => summary.items)
}
private buildParentById(): Map<number, number | null> {
const parentById = new Map<number, number | null>()
for (const item of this._items) {
if (typeof item?.id === 'number') {
const parentValue = (item as any)['parent']
parentById.set(
item.id,
typeof parentValue === 'number' ? parentValue : null
)
}
}
return parentById
}
private createRootFinder(
parentById: Map<number, number | null>
): (id: number) => number {
const rootMemo = new Map<number, number>()
const findRootId = (id: number): number => {
const cached = rootMemo.get(id)
if (cached !== undefined) {
return cached
}
const parentId = parentById.get(id)
if (parentId === undefined || parentId === null) {
rootMemo.set(id, id)
return id
}
const rootId = findRootId(parentId)
rootMemo.set(id, rootId)
return rootId
}
return findRootId
}
private createRootDocCounter(): (rootId: number) => number {
const docCountMemo = new Map<number, number>()
return (rootId: number): number => {
const cached = docCountMemo.get(rootId)
if (cached !== undefined) {
return cached
}
const explicit = this.getDocumentCount(rootId)
if (typeof explicit === 'number') {
docCountMemo.set(rootId, explicit)
return explicit
}
const rootItem = this._items.find((i) => i.id === rootId)
const fallback =
typeof (rootItem as any)?.['document_count'] === 'number'
? (rootItem as any)['document_count']
: 0
docCountMemo.set(rootId, fallback)
return fallback
}
}
private buildBranchSummaries(
findRootId: (id: number) => number,
getRootDocCount: (rootId: number) => number
): Map<string, BranchSummary> {
const summaries = new Map<string, BranchSummary>()
for (const [index, item] of this._items.entries()) {
const { key, special, rootId } = this.describeBranchItem(
item,
index,
findRootId
)
let summary = summaries.get(key)
if (!summary) {
summary = {
items: [],
firstIndex: index,
special,
selected: false,
hasDocs:
special || rootId === null ? false : getRootDocCount(rootId) > 0,
}
summaries.set(key, summary)
}
summary.items.push(item)
if (this.shouldMarkSummarySelected(summary, item)) {
summary.selected = true
}
}
return summaries
}
private describeBranchItem(
item: MatchingModel,
index: number,
findRootId: (id: number) => number
): { key: string; special: boolean; rootId: number | null } {
if (item?.id === null) {
return { key: 'null', special: true, rootId: null }
}
if (item?.id === NEGATIVE_NULL_FILTER_VALUE) {
return { key: 'neg-null', special: true, rootId: null }
}
if (typeof item?.id === 'number') {
const rootId = findRootId(item.id)
return { key: `root-${rootId}`, special: false, rootId }
}
return { key: `misc-${index}`, special: false, rootId: null }
}
private shouldMarkSummarySelected(
summary: BranchSummary,
item: MatchingModel
): boolean {
if (summary.special) {
return false
}
if (typeof item?.id !== 'number') {
return false
}
return this.getNonTemporary(item.id) !== ToggleableItemState.NotSelected
}
private orderBranchesByPriority(
summaries: Map<string, BranchSummary>
): BranchSummary[] {
return Array.from(summaries.values()).sort((a, b) => {
const rankDiff = this.branchRank(a) - this.branchRank(b)
if (rankDiff !== 0) {
return rankDiff
}
if (a.hasDocs !== b.hasDocs) {
return a.hasDocs ? -1 : 1
}
return a.firstIndex - b.firstIndex
})
}
private branchRank(summary: BranchSummary): number {
if (summary.special) {
return -1
}
if (summary.selected) {
return 0
}
return 1
}
init(map: Map<number, ToggleableItemState>) { init(map: Map<number, ToggleableItemState>) {
this.temporarySelectionStates = map this.temporarySelectionStates = map
this.apply() this.apply()

View file

@ -29,6 +29,7 @@
[multiple]="multiple" [multiple]="multiple"
[bindLabel]="bindLabel" [bindLabel]="bindLabel"
bindValue="id" bindValue="id"
[virtualScroll]="items?.length > 100"
(change)="onChange(value)" (change)="onChange(value)"
(search)="onSearch($event)" (search)="onSearch($event)"
(focus)="clearLastSearchTerm()" (focus)="clearLastSearchTerm()"

View file

@ -106,6 +106,7 @@ describe('TagsComponent', () => {
modalService = TestBed.inject(NgbModal) modalService = TestBed.inject(NgbModal)
settingsService = TestBed.inject(SettingsService) settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = { id: 1 }
fixture = TestBed.createComponent(TagsComponent) fixture = TestBed.createComponent(TagsComponent)
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR) fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
component = fixture.componentInstance component = fixture.componentInstance
@ -138,7 +139,7 @@ describe('TagsComponent', () => {
settingsService.currentUser = { id: 1 } settingsService.currentUser = { id: 1 }
let activeInstances: NgbModalRef[] let activeInstances: NgbModalRef[]
modalService.activeInstances.subscribe((v) => (activeInstances = v)) modalService.activeInstances.subscribe((v) => (activeInstances = v))
component.select.searchTerm = 'foobar' component.select.filter('foobar')
component.createTag() component.createTag()
expect(modalService.hasOpenModals()).toBeTruthy() expect(modalService.hasOpenModals()).toBeTruthy()
expect(activeInstances[0].componentInstance.object.name).toEqual('foobar') expect(activeInstances[0].componentInstance.object.name).toEqual('foobar')

View file

@ -169,7 +169,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
if (name) modal.componentInstance.object = { name: name } if (name) modal.componentInstance.object = { name: name }
else if (this.select.searchTerm) else if (this.select.searchTerm)
modal.componentInstance.object = { name: this.select.searchTerm } modal.componentInstance.object = { name: this.select.searchTerm }
this.select.searchTerm = null this.select.filter(null)
this.select.detectChanges() this.select.detectChanges()
return firstValueFrom( return firstValueFrom(
(modal.componentInstance as TagEditDialogComponent).succeeded.pipe( (modal.componentInstance as TagEditDialogComponent).succeeded.pipe(

View file

@ -183,6 +183,7 @@ export class ProfileEditDialogComponent
this.newPassword && this.currentPassword !== this.newPassword this.newPassword && this.currentPassword !== this.newPassword
const profile = Object.assign({}, this.form.value) const profile = Object.assign({}, this.form.value)
delete profile.totp_code delete profile.totp_code
this.error = null
this.networkActive = true this.networkActive = true
this.profileService this.profileService
.update(profile) .update(profile)
@ -204,6 +205,7 @@ export class ProfileEditDialogComponent
}, },
error: (error) => { error: (error) => {
this.toastService.showError($localize`Error saving profile`, error) this.toastService.showError($localize`Error saving profile`, error)
this.error = error?.error
this.networkActive = false this.networkActive = false
}, },
}) })

View file

@ -9,6 +9,12 @@
@if (clickable) { @if (clickable) {
<a [title]="linkTitle" class="badge" [style.background]="tag.color" [style.color]="tag.text_color">{{tag.name}}</a> <a [title]="linkTitle" class="badge" [style.background]="tag.color" [style.color]="tag.text_color">{{tag.name}}</a>
} }
} @else if (loading) {
<span class="placeholder-glow">
<span class="placeholder badge private">
<span class="text-dark">Loading...</span>
</span>
</span>
} @else { } @else {
@if (!clickable) { @if (!clickable) {
<span class="badge private" i18n>Private</span> <span class="badge private" i18n>Private</span>

View file

@ -53,4 +53,8 @@ export class TagComponent {
@Input() @Input()
showParents: boolean = false showParents: boolean = false
public get loading(): boolean {
return this.tagService.loading
}
} }

View file

@ -1489,6 +1489,8 @@ describe('DocumentDetailComponent', () => {
mockContentWindow.onafterprint(new Event('afterprint')) mockContentWindow.onafterprint(new Event('afterprint'))
} }
tick(500)
expect(removeChildSpy).toHaveBeenCalledWith(mockIframe) expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url') expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
@ -1512,65 +1514,97 @@ describe('DocumentDetailComponent', () => {
) )
}) })
it('should show error toast if printing throws inside iframe', fakeAsync(() => { const iframePrintErrorCases: Array<{
initNormally() description: string
thrownError: Error
expectToast: boolean
}> = [
{
description: 'should show error toast if printing throws inside iframe',
thrownError: new Error('focus failed'),
expectToast: true,
},
{
description:
'should suppress toast if cross-origin afterprint error occurs',
thrownError: new DOMException(
'Accessing onafterprint triggered a cross-origin violation',
'SecurityError'
),
expectToast: false,
},
]
const appendChildSpy = jest iframePrintErrorCases.forEach(({ description, thrownError, expectToast }) => {
.spyOn(document.body, 'appendChild') it(
.mockImplementation((node: Node) => node) description,
const removeChildSpy = jest fakeAsync(() => {
.spyOn(document.body, 'removeChild') initNormally()
.mockImplementation((node: Node) => node)
const createObjectURLSpy = jest
.spyOn(URL, 'createObjectURL')
.mockReturnValue('blob:mock-url')
const revokeObjectURLSpy = jest
.spyOn(URL, 'revokeObjectURL')
.mockImplementation(() => {})
const toastSpy = jest.spyOn(toastService, 'showError') const appendChildSpy = jest
.spyOn(document.body, 'appendChild')
.mockImplementation((node: Node) => node)
const removeChildSpy = jest
.spyOn(document.body, 'removeChild')
.mockImplementation((node: Node) => node)
const createObjectURLSpy = jest
.spyOn(URL, 'createObjectURL')
.mockReturnValue('blob:mock-url')
const revokeObjectURLSpy = jest
.spyOn(URL, 'revokeObjectURL')
.mockImplementation(() => {})
const mockContentWindow = { const toastSpy = jest.spyOn(toastService, 'showError')
focus: jest.fn().mockImplementation(() => {
throw new Error('focus failed')
}),
print: jest.fn(),
onafterprint: null,
}
const mockIframe: any = { const mockContentWindow = {
style: {}, focus: jest.fn().mockImplementation(() => {
src: '', throw thrownError
onload: null, }),
contentWindow: mockContentWindow, print: jest.fn(),
} onafterprint: null,
}
const createElementSpy = jest const mockIframe: any = {
.spyOn(document, 'createElement') style: {},
.mockReturnValue(mockIframe as any) src: '',
onload: null,
contentWindow: mockContentWindow,
}
const blob = new Blob(['test'], { type: 'application/pdf' }) const createElementSpy = jest
component.printDocument() .spyOn(document, 'createElement')
.mockReturnValue(mockIframe as any)
const req = httpTestingController.expectOne( const blob = new Blob(['test'], { type: 'application/pdf' })
`${environment.apiBaseUrl}documents/${doc.id}/download/` component.printDocument()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/${doc.id}/download/`
)
req.flush(blob)
tick()
if (mockIframe.onload) {
mockIframe.onload(new Event('load'))
}
tick(200)
if (expectToast) {
expect(toastSpy).toHaveBeenCalled()
} else {
expect(toastSpy).not.toHaveBeenCalled()
}
expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
createElementSpy.mockRestore()
appendChildSpy.mockRestore()
removeChildSpy.mockRestore()
createObjectURLSpy.mockRestore()
revokeObjectURLSpy.mockRestore()
})
) )
req.flush(blob) })
tick()
if (mockIframe.onload) {
mockIframe.onload(new Event('load'))
}
expect(toastSpy).toHaveBeenCalled()
expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
createElementSpy.mockRestore()
appendChildSpy.mockRestore()
removeChildSpy.mockRestore()
createObjectURLSpy.mockRestore()
revokeObjectURLSpy.mockRestore()
}))
}) })

View file

@ -21,7 +21,7 @@ import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms'
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer' import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { DeviceDetectorService } from 'ngx-device-detector' import { DeviceDetectorService } from 'ngx-device-detector'
import { BehaviorSubject, Observable, of, Subject } from 'rxjs' import { BehaviorSubject, Observable, of, Subject, timer } from 'rxjs'
import { import {
catchError, catchError,
debounceTime, debounceTime,
@ -1452,9 +1452,18 @@ export class DocumentDetailComponent
URL.revokeObjectURL(blobUrl) URL.revokeObjectURL(blobUrl)
} }
} catch (err) { } catch (err) {
this.toastService.showError($localize`Print failed.`, err) // FF throws cross-origin error on onafterprint
document.body.removeChild(iframe) const isCrossOriginAfterPrintError =
URL.revokeObjectURL(blobUrl) err instanceof DOMException &&
err.message.includes('onafterprint')
if (!isCrossOriginAfterPrintError) {
this.toastService.showError($localize`Print failed.`, err)
}
timer(100).subscribe(() => {
// delay to avoid FF print failure
document.body.removeChild(iframe)
URL.revokeObjectURL(blobUrl)
})
} }
} }
}, },

View file

@ -96,9 +96,11 @@
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.selected.size < 2"> <button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.selected.size < 2">
<i-bs name="journals"></i-bs>&nbsp;<ng-container i18n>Merge</ng-container> <i-bs name="journals"></i-bs>&nbsp;<ng-container i18n>Merge</ng-container>
</button> </button>
<button ngbDropdownItem (click)="emailSelected()" [disabled]="!userCanEdit"> @if (emailEnabled) {
<i-bs name="envelope"></i-bs>&nbsp;<ng-container i18n>Email</ng-container> <button ngbDropdownItem (click)="emailSelected()">
</button> <i-bs name="envelope"></i-bs>&nbsp;<ng-container i18n>Email</ng-container>
</button>
}
</div> </div>
</div> </div>
</div> </div>

View file

@ -904,6 +904,10 @@ export class BulkEditorComponent
}) })
} }
public get emailEnabled(): boolean {
return this.settings.get(SETTINGS_KEYS.EMAIL_ENABLED)
}
emailSelected() { emailSelected() {
const allHaveArchiveVersion = this.list.documents const allHaveArchiveVersion = this.list.documents
.filter((d) => this.list.selected.has(d.id)) .filter((d) => this.list.selected.has(d.id))

View file

@ -15,7 +15,7 @@
</div> </div>
<div class="d-none d-sm-flex flex-fill me-3"> <div class="d-none d-sm-flex flex-fill me-3">
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<span class="input-group-text border-0">Select:</span> <span class="input-group-text border-0" i18n>Select:</span>
</div> </div>
<div class="btn-group btn-group-sm flex-nowrap"> <div class="btn-group btn-group-sm flex-nowrap">
@if (list.selected.size > 0) { @if (list.selected.size > 0) {

View file

@ -747,7 +747,7 @@ export class FilterEditorComponent
) { ) {
filterRules.push({ filterRules.push({
rule_type: FILTER_TITLE_CONTENT, rule_type: FILTER_TITLE_CONTENT,
value: this._textFilter, value: this._textFilter.trim(),
}) })
} }
if (this._textFilter && this.textFilterTarget == TEXT_FILTER_TARGET_TITLE) { if (this._textFilter && this.textFilterTarget == TEXT_FILTER_TARGET_TITLE) {
@ -805,7 +805,7 @@ export class FilterEditorComponent
) { ) {
filterRules.push({ filterRules.push({
rule_type: FILTER_FULLTEXT_QUERY, rule_type: FILTER_FULLTEXT_QUERY,
value: this._textFilter, value: this._textFilter.trim(),
}) })
} }
if ( if (

View file

@ -68,7 +68,7 @@
</td> </td>
<td> <td>
<ng-template #errorPopover> <ng-template #errorPopover>
<pre class="small text-light"> <pre class="small">
{{ mail.error }} {{ mail.error }}
</pre> </pre>
</ng-template> </ng-template>

View file

@ -1,5 +1,7 @@
::ng-deep .popover { ::ng-deep .popover {
max-width: 350px; max-width: 350px;
max-height: 600px;
overflow: hidden;
pre { pre {
white-space: pre-wrap; white-space: pre-wrap;

View file

@ -361,4 +361,11 @@ describe('ManagementListComponent', () => {
const original = component.getOriginalObject({ id: 4 } as Tag) const original = component.getOriginalObject({ id: 4 } as Tag)
expect(original).toEqual(childTag) expect(original).toEqual(childTag)
}) })
it('getSelectableIDs should return flat ids when not overridden', () => {
const ids = (
ManagementListComponent.prototype as any
).getSelectableIDs.call({}, [{ id: 1 }, { id: 5 }] as any)
expect(ids).toEqual([1, 5])
})
}) })

View file

@ -146,7 +146,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
} }
public getOriginalObject(object: T): T { public getOriginalObject(object: T): T {
return this.unfilteredData.find((d) => d.id == object.id) return this.unfilteredData.find((d) => d?.id == object?.id) || object
} }
reloadData(extraParams: { [key: string]: any } = null) { reloadData(extraParams: { [key: string]: any } = null) {
@ -297,13 +297,19 @@ export abstract class ManagementListComponent<T extends MatchingModel>
} }
toggleAll(event: PointerEvent) { toggleAll(event: PointerEvent) {
if ((event.target as HTMLInputElement).checked) { const checked = (event.target as HTMLInputElement).checked
this.selectedObjects = new Set(this.data.map((o) => o.id)) this.togggleAll = checked
if (checked) {
this.selectedObjects = new Set(this.getSelectableIDs(this.data))
} else { } else {
this.clearSelection() this.clearSelection()
} }
} }
protected getSelectableIDs(objects: T[]): number[] {
return objects.map((o) => o.id)
}
clearSelection() { clearSelection() {
this.togggleAll = false this.togggleAll = false
this.selectedObjects.clear() this.selectedObjects.clear()

View file

@ -17,6 +17,7 @@ describe('TagListComponent', () => {
let component: TagListComponent let component: TagListComponent
let fixture: ComponentFixture<TagListComponent> let fixture: ComponentFixture<TagListComponent>
let tagService: TagService let tagService: TagService
let listFilteredSpy: jest.SpyInstance
beforeEach(async () => { beforeEach(async () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@ -39,7 +40,7 @@ describe('TagListComponent', () => {
}).compileComponents() }).compileComponents()
tagService = TestBed.inject(TagService) tagService = TestBed.inject(TagService)
jest.spyOn(tagService, 'listFiltered').mockReturnValue( listFilteredSpy = jest.spyOn(tagService, 'listFiltered').mockReturnValue(
of({ of({
count: 3, count: 3,
all: [1, 2, 3], all: [1, 2, 3],
@ -72,9 +73,14 @@ describe('TagListComponent', () => {
) )
}) })
it('should filter out child tags if name filter is empty, otherwise show all', () => { it('should omit matching children from top level when their parent is present', () => {
const tags = [ const tags = [
{ id: 1, name: 'Tag1', parent: null }, {
id: 1,
name: 'Tag1',
parent: null,
children: [{ id: 2, name: 'Tag2', parent: 1 }],
},
{ id: 2, name: 'Tag2', parent: 1 }, { id: 2, name: 'Tag2', parent: 1 },
{ id: 3, name: 'Tag3', parent: null }, { id: 3, name: 'Tag3', parent: null },
] ]
@ -85,6 +91,65 @@ describe('TagListComponent', () => {
component['_nameFilter'] = 'Tag2' // Simulate non-empty name filter component['_nameFilter'] = 'Tag2' // Simulate non-empty name filter
const filteredWithName = component.filterData(tags as any) const filteredWithName = component.filterData(tags as any)
expect(filteredWithName.length).toBe(3) expect(filteredWithName.length).toBe(2)
expect(filteredWithName.find((t) => t.id === 2)).toBeUndefined()
expect(
filteredWithName
.find((t) => t.id === 1)
?.children?.some((c) => c.id === 2)
).toBe(true)
})
it('should request only parent tags when no name filter is applied', () => {
expect(tagService.listFiltered).toHaveBeenCalledWith(
1,
null,
undefined,
undefined,
undefined,
true,
{ is_root: true }
)
})
it('should include child tags when a name filter is applied', () => {
listFilteredSpy.mockClear()
component['_nameFilter'] = 'Tag'
component.reloadData()
expect(tagService.listFiltered).toHaveBeenCalledWith(
1,
null,
undefined,
undefined,
'Tag',
true,
null
)
})
it('should include child tags when selecting all', () => {
const parent = {
id: 10,
name: 'Parent',
children: [
{
id: 11,
name: 'Child',
},
],
}
component.data = [parent as any]
const selectEvent = { target: { checked: true } } as unknown as PointerEvent
component.toggleAll(selectEvent)
expect(component.selectedObjects.has(10)).toBe(true)
expect(component.selectedObjects.has(11)).toBe(true)
const deselectEvent = {
target: { checked: false },
} as unknown as PointerEvent
component.toggleAll(deselectEvent)
expect(component.selectedObjects.size).toBe(0)
}) })
}) })

View file

@ -61,9 +61,33 @@ export class TagListComponent extends ManagementListComponent<Tag> {
return $localize`Do you really want to delete the tag "${object.name}"?` return $localize`Do you really want to delete the tag "${object.name}"?`
} }
override reloadData(extraParams: { [key: string]: any } = null) {
const params = this.nameFilter?.length
? extraParams
: { ...extraParams, is_root: true }
super.reloadData(params)
}
filterData(data: Tag[]) { filterData(data: Tag[]) {
return this.nameFilter?.length if (!this.nameFilter?.length) {
? [...data] return data.filter((tag) => !tag.parent)
: data.filter((tag) => !tag.parent) }
// When filtering by name, exclude children if their parent is also present
const availableIds = new Set(data.map((tag) => tag.id))
return data.filter((tag) => !tag.parent || !availableIds.has(tag.parent))
}
protected override getSelectableIDs(tags: Tag[]): number[] {
const ids: number[] = []
for (const tag of tags.filter(Boolean)) {
if (tag.id != null) {
ids.push(tag.id)
}
if (Array.isArray(tag.children) && tag.children.length) {
ids.push(...this.getSelectableIDs(tag.children))
}
}
return ids
} }
} }

View file

@ -1,7 +1,7 @@
import { HttpClient, HttpParams } from '@angular/common/http' import { HttpClient, HttpParams } from '@angular/common/http'
import { inject, Injectable } from '@angular/core' import { inject, Injectable } from '@angular/core'
import { Observable } from 'rxjs' import { Observable } from 'rxjs'
import { map, publishReplay, refCount } from 'rxjs/operators' import { map, publishReplay, refCount, tap } from 'rxjs/operators'
import { ObjectWithId } from 'src/app/data/object-with-id' import { ObjectWithId } from 'src/app/data/object-with-id'
import { Results } from 'src/app/data/results' import { Results } from 'src/app/data/results'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
@ -13,6 +13,11 @@ export abstract class AbstractPaperlessService<T extends ObjectWithId> {
protected http: HttpClient protected http: HttpClient
protected resourceName: string protected resourceName: string
protected _loading: boolean = false
public get loading(): boolean {
return this._loading
}
constructor() { constructor() {
this.http = inject(HttpClient) this.http = inject(HttpClient)
} }
@ -43,6 +48,7 @@ export abstract class AbstractPaperlessService<T extends ObjectWithId> {
sortReverse?: boolean, sortReverse?: boolean,
extraParams? extraParams?
): Observable<Results<T>> { ): Observable<Results<T>> {
this._loading = true
let httpParams = new HttpParams() let httpParams = new HttpParams()
if (page) { if (page) {
httpParams = httpParams.set('page', page.toString()) httpParams = httpParams.set('page', page.toString())
@ -59,9 +65,15 @@ export abstract class AbstractPaperlessService<T extends ObjectWithId> {
httpParams = httpParams.set(extraParamKey, extraParams[extraParamKey]) httpParams = httpParams.set(extraParamKey, extraParams[extraParamKey])
} }
} }
return this.http.get<Results<T>>(this.getResourceUrl(), { return this.http
params: httpParams, .get<Results<T>>(this.getResourceUrl(), {
}) params: httpParams,
})
.pipe(
tap(() => {
this._loading = false
})
)
} }
private _listAll: Observable<Results<T>> private _listAll: Observable<Results<T>>
@ -96,6 +108,7 @@ export abstract class AbstractPaperlessService<T extends ObjectWithId> {
} }
getFew(ids: number[], extraParams?): Observable<Results<T>> { getFew(ids: number[], extraParams?): Observable<Results<T>> {
this._loading = true
let httpParams = new HttpParams() let httpParams = new HttpParams()
httpParams = httpParams.set('id__in', ids.join(',')) httpParams = httpParams.set('id__in', ids.join(','))
httpParams = httpParams.set('ordering', '-id') httpParams = httpParams.set('ordering', '-id')
@ -105,9 +118,15 @@ export abstract class AbstractPaperlessService<T extends ObjectWithId> {
httpParams = httpParams.set(extraParamKey, extraParams[extraParamKey]) httpParams = httpParams.set(extraParamKey, extraParams[extraParamKey])
} }
} }
return this.http.get<Results<T>>(this.getResourceUrl(), { return this.http
params: httpParams, .get<Results<T>>(this.getResourceUrl(), {
}) params: httpParams,
})
.pipe(
tap(() => {
this._loading = false
})
)
} }
clearCache() { clearCache() {
@ -115,7 +134,12 @@ export abstract class AbstractPaperlessService<T extends ObjectWithId> {
} }
get(id: number): Observable<T> { get(id: number): Observable<T> {
return this.http.get<T>(this.getResourceUrl(id)) this._loading = true
return this.http.get<T>(this.getResourceUrl(id)).pipe(
tap(() => {
this._loading = false
})
)
} }
create(o: T): Observable<T> { create(o: T): Observable<T> {

View file

@ -49,4 +49,14 @@ describe('LogService', () => {
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
}) })
it('should pass limit param on logs get when provided', () => {
const id: string = 'mail'
const limit: number = 100
subscription = service.get(id, limit).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/${id}/?limit=${limit}`
)
expect(req.request.method).toEqual('GET')
})
}) })

View file

@ -1,4 +1,4 @@
import { HttpClient } from '@angular/common/http' import { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable, inject } from '@angular/core' import { Injectable, inject } from '@angular/core'
import { Observable } from 'rxjs' import { Observable } from 'rxjs'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
@ -13,7 +13,13 @@ export class LogService {
return this.http.get<string[]>(`${environment.apiBaseUrl}logs/`) return this.http.get<string[]>(`${environment.apiBaseUrl}logs/`)
} }
get(id: string): Observable<string[]> { get(id: string, limit?: number): Observable<string[]> {
return this.http.get<string[]>(`${environment.apiBaseUrl}logs/${id}/`) let params = new HttpParams()
if (limit !== undefined) {
params = params.set('limit', limit.toString())
}
return this.http.get<string[]>(`${environment.apiBaseUrl}logs/${id}/`, {
params,
})
} }
} }

View file

@ -7,18 +7,16 @@ import { AbstractPaperlessService } from './abstract-paperless-service'
providedIn: 'root', providedIn: 'root',
}) })
export class MailAccountService extends AbstractPaperlessService<MailAccount> { export class MailAccountService extends AbstractPaperlessService<MailAccount> {
loading: boolean
constructor() { constructor() {
super() super()
this.resourceName = 'mail_accounts' this.resourceName = 'mail_accounts'
} }
private reload() { private reload() {
this.loading = true this._loading = true
this.listAll().subscribe((r) => { this.listAll().subscribe((r) => {
this.mailAccounts = r.results this.mailAccounts = r.results
this.loading = false this._loading = false
}) })
} }

View file

@ -7,18 +7,16 @@ import { AbstractPaperlessService } from './abstract-paperless-service'
providedIn: 'root', providedIn: 'root',
}) })
export class MailRuleService extends AbstractPaperlessService<MailRule> { export class MailRuleService extends AbstractPaperlessService<MailRule> {
loading: boolean
constructor() { constructor() {
super() super()
this.resourceName = 'mail_rules' this.resourceName = 'mail_rules'
} }
private reload() { private reload() {
this.loading = true this._loading = true
this.listAll().subscribe((r) => { this.listAll().subscribe((r) => {
this.mailRules = r.results this.mailRules = r.results
this.loading = false this._loading = false
}) })
} }

View file

@ -17,7 +17,6 @@ export class SavedViewService extends AbstractPaperlessService<SavedView> {
private settingsService = inject(SettingsService) private settingsService = inject(SettingsService)
private documentService = inject(DocumentService) private documentService = inject(DocumentService)
public loading: boolean = true
private savedViews: SavedView[] = [] private savedViews: SavedView[] = []
private savedViewDocumentCounts: Map<number, number> = new Map() private savedViewDocumentCounts: Map<number, number> = new Map()
private unsubscribeNotifier: Subject<void> = new Subject<void>() private unsubscribeNotifier: Subject<void> = new Subject<void>()
@ -38,12 +37,12 @@ export class SavedViewService extends AbstractPaperlessService<SavedView> {
tap({ tap({
next: (r) => { next: (r) => {
this.savedViews = r.results this.savedViews = r.results
this.loading = false this._loading = false
this.settingsService.dashboardIsEmpty = this.settingsService.dashboardIsEmpty =
this.dashboardViews.length === 0 this.dashboardViews.length === 0
}, },
error: () => { error: () => {
this.loading = false this._loading = false
this.settingsService.dashboardIsEmpty = true this.settingsService.dashboardIsEmpty = true
}, },
}) })

View file

@ -7,18 +7,16 @@ import { AbstractPaperlessService } from './abstract-paperless-service'
providedIn: 'root', providedIn: 'root',
}) })
export class WorkflowService extends AbstractPaperlessService<Workflow> { export class WorkflowService extends AbstractPaperlessService<Workflow> {
loading: boolean
constructor() { constructor() {
super() super()
this.resourceName = 'workflows' this.resourceName = 'workflows'
} }
public reload() { public reload() {
this.loading = true this._loading = true
this.listAll().subscribe((r) => { this.listAll().subscribe((r) => {
this.workflows = r.results this.workflows = r.results
this.loading = false this._loading = false
}) })
} }

View file

@ -1,4 +1,3 @@
import { fakeAsync, tick } from '@angular/core/testing'
import { import {
CustomFieldQueryElementType, CustomFieldQueryElementType,
CustomFieldQueryLogicalOperator, CustomFieldQueryLogicalOperator,
@ -111,13 +110,38 @@ describe('CustomFieldQueryAtom', () => {
expect(atom.serialize()).toEqual([1, 'operator', 'value']) expect(atom.serialize()).toEqual([1, 'operator', 'value'])
}) })
it('should emit changed on value change after debounce', fakeAsync(() => { it('should emit changed on value change immediately', () => {
const atom = new CustomFieldQueryAtom() const atom = new CustomFieldQueryAtom()
const changeSpy = jest.spyOn(atom.changed, 'next') const changeSpy = jest.spyOn(atom.changed, 'next')
atom.value = 'new value' atom.value = 'new value'
tick(1000)
expect(changeSpy).toHaveBeenCalled() expect(changeSpy).toHaveBeenCalled()
})) })
it('should ignore duplicate array emissions', () => {
const atom = new CustomFieldQueryAtom()
atom.operator = CustomFieldQueryOperator.In
const changeSpy = jest.fn()
atom.changed.subscribe(changeSpy)
atom.value = [1, 2]
expect(changeSpy).toHaveBeenCalledTimes(1)
changeSpy.mockClear()
atom.value = [1, 2]
expect(changeSpy).not.toHaveBeenCalled()
})
it('should emit when array values differ while length matches', () => {
const atom = new CustomFieldQueryAtom()
atom.operator = CustomFieldQueryOperator.In
const changeSpy = jest.fn()
atom.changed.subscribe(changeSpy)
atom.value = [1, 2]
changeSpy.mockClear()
atom.value = [1, 3]
expect(changeSpy).toHaveBeenCalledTimes(1)
})
}) })
describe('CustomFieldQueryExpression', () => { describe('CustomFieldQueryExpression', () => {

View file

@ -1,4 +1,4 @@
import { Subject, debounceTime, distinctUntilChanged } from 'rxjs' import { Subject, distinctUntilChanged } from 'rxjs'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { import {
CUSTOM_FIELD_QUERY_VALUE_TYPES_BY_OPERATOR, CUSTOM_FIELD_QUERY_VALUE_TYPES_BY_OPERATOR,
@ -110,7 +110,22 @@ export class CustomFieldQueryAtom extends CustomFieldQueryElement {
protected override connectValueModelChanged(): void { protected override connectValueModelChanged(): void {
this.valueModelChanged this.valueModelChanged
.pipe(debounceTime(1000), distinctUntilChanged()) .pipe(
distinctUntilChanged((previous, current) => {
if (Array.isArray(previous) && Array.isArray(current)) {
if (previous.length !== current.length) {
return false
}
for (let i = 0; i < previous.length; i++) {
if (previous[i] !== current[i]) {
return false
}
}
return true
}
return previous === current
})
)
.subscribe(() => { .subscribe(() => {
this.changed.next(this) this.changed.next(this)
}) })

View file

@ -6,7 +6,7 @@ export const environment = {
apiVersion: '9', // match src/paperless/settings.py apiVersion: '9', // match src/paperless/settings.py
appTitle: 'Paperless-ngx', appTitle: 'Paperless-ngx',
tag: 'prod', tag: 'prod',
version: '2.19.0', version: '2.19.6',
webSocketHost: window.location.host, webSocketHost: window.location.host,
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:', webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
webSocketBaseUrl: base_url.pathname + '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

View file

@ -99,6 +99,29 @@ def generate_unique_filename(doc, *, archive_filename=False) -> Path:
return new_filename return new_filename
def format_filename(document: Document, template_str: str) -> str | None:
rendered_filename = validate_filepath_template_and_render(
template_str,
document,
)
if rendered_filename is None:
return None
# Apply this setting. It could become a filter in the future (or users could use |default)
if settings.FILENAME_FORMAT_REMOVE_NONE:
rendered_filename = rendered_filename.replace("/-none-/", "/")
rendered_filename = rendered_filename.replace(" -none-", "")
rendered_filename = rendered_filename.replace("-none-", "")
rendered_filename = rendered_filename.strip(os.sep)
rendered_filename = rendered_filename.replace(
"-none-",
"none",
) # backward compatibility
return rendered_filename
def generate_filename( def generate_filename(
doc: Document, doc: Document,
*, *,
@ -108,28 +131,6 @@ def generate_filename(
) -> Path: ) -> Path:
base_path: Path | None = None base_path: Path | None = None
def format_filename(document: Document, template_str: str) -> str | None:
rendered_filename = validate_filepath_template_and_render(
template_str,
document,
)
if rendered_filename is None:
return None
# Apply this setting. It could become a filter in the future (or users could use |default)
if settings.FILENAME_FORMAT_REMOVE_NONE:
rendered_filename = rendered_filename.replace("/-none-/", "/")
rendered_filename = rendered_filename.replace(" -none-", "")
rendered_filename = rendered_filename.replace("-none-", "")
rendered_filename = rendered_filename.strip(os.sep)
rendered_filename = rendered_filename.replace(
"-none-",
"none",
) # backward compatibility
return rendered_filename
# Determine the source of the format string # Determine the source of the format string
if doc.storage_path is not None: if doc.storage_path is not None:
filename_format = doc.storage_path.path filename_format = doc.storage_path.path

View file

@ -92,6 +92,12 @@ class TagFilterSet(FilterSet):
"name": CHAR_KWARGS, "name": CHAR_KWARGS,
} }
is_root = BooleanFilter(
label="Is root tag",
field_name="tn_parent",
lookup_expr="isnull",
)
class DocumentTypeFilterSet(FilterSet): class DocumentTypeFilterSet(FilterSet):
class Meta: class Meta:
@ -154,6 +160,7 @@ class InboxFilter(Filter):
@extend_schema_field(serializers.CharField) @extend_schema_field(serializers.CharField)
class TitleContentFilter(Filter): class TitleContentFilter(Filter):
def filter(self, qs, value): def filter(self, qs, value):
value = value.strip() if isinstance(value, str) else value
if value: if value:
return qs.filter(Q(title__icontains=value) | Q(content__icontains=value)) return qs.filter(Q(title__icontains=value) | Q(content__icontains=value))
else: else:
@ -208,6 +215,7 @@ class CustomFieldFilterSet(FilterSet):
@extend_schema_field(serializers.CharField) @extend_schema_field(serializers.CharField)
class CustomFieldsFilter(Filter): class CustomFieldsFilter(Filter):
def filter(self, qs, value): def filter(self, qs, value):
value = value.strip() if isinstance(value, str) else value
if value: if value:
fields_with_matching_selects = CustomField.objects.filter( fields_with_matching_selects = CustomField.objects.filter(
extra_data__icontains=value, extra_data__icontains=value,
@ -238,6 +246,7 @@ class CustomFieldsFilter(Filter):
class MimeTypeFilter(Filter): class MimeTypeFilter(Filter):
def filter(self, qs, value): def filter(self, qs, value):
value = value.strip() if isinstance(value, str) else value
if value: if value:
return qs.filter(mime_type__icontains=value) return qs.filter(mime_type__icontains=value)
else: else:

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