mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-12-09 00:05:21 +01:00
Merge branch 'dev' into dev
This commit is contained in:
commit
6088575fdb
175 changed files with 27174 additions and 11059 deletions
2
.github/DISCUSSION_TEMPLATE/support.yml
vendored
2
.github/DISCUSSION_TEMPLATE/support.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
10
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
10
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
|
|
@ -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:
|
||||||
|
|
|
||||||
4
.github/PULL_REQUEST_TEMPLATE.md
vendored
4
.github/PULL_REQUEST_TEMPLATE.md
vendored
|
|
@ -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.
|
||||||
|
|
|
||||||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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
12
src-ui/pnpm-lock.yaml
generated
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()"
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -53,4 +53,8 @@ export class TagComponent {
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
showParents: boolean = false
|
showParents: boolean = false
|
||||||
|
|
||||||
|
public get loading(): boolean {
|
||||||
|
return this.tagService.loading
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
}))
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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> <ng-container i18n>Merge</ng-container>
|
<i-bs name="journals"></i-bs> <ng-container i18n>Merge</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button ngbDropdownItem (click)="emailSelected()" [disabled]="!userCanEdit">
|
@if (emailEnabled) {
|
||||||
<i-bs name="envelope"></i-bs> <ng-container i18n>Email</ng-container>
|
<button ngbDropdownItem (click)="emailSelected()">
|
||||||
</button>
|
<i-bs name="envelope"></i-bs> <ng-container i18n>Email</ng-container>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
11444
src-ui/src/locale/messages.mk_MK.xlf
Normal file
11444
src-ui/src/locale/messages.mk_MK.xlf
Normal file
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
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue