Compare commits

..

7 Commits

Author SHA1 Message Date
shamoon
7ea4893e42 Try joblib 2025-08-31 14:18:31 -07:00
shamoon
78255d0a99 Add some memory logging to suggestions (revert) 2025-08-31 14:07:48 -07:00
shamoon
fc4cb08bda Cache classifier loading with lru_cache 2025-08-30 20:06:18 -07:00
shamoon
875dc6602b Dont fetch doc content on suggestions 2025-08-30 20:06:18 -07:00
shamoon
8084ece274 Just build docker image in ci (revert me too) 2025-08-30 20:06:17 -07:00
shamoon
70b24c056b Add process to middleware (revert me too) 2025-08-30 20:06:17 -07:00
shamoon
26d2d63c26 MemLogMiddleware for testing (revert me) 2025-08-30 19:03:27 -07:00
255 changed files with 25468 additions and 32380 deletions

View File

@@ -3,7 +3,7 @@
"dockerComposeFile": "docker-compose.devcontainer.sqlite-tika.yml",
"service": "paperless-development",
"workspaceFolder": "/usr/src/paperless/paperless-ngx",
"postCreateCommand": "/bin/bash -c 'rm -rf .venv/.* && uv sync --group dev && uv run pre-commit install'",
"postCreateCommand": "/bin/bash -c 'uv sync --group dev && uv run pre-commit install'",
"customizations": {
"vscode": {
"extensions": [

View File

@@ -49,6 +49,7 @@ services:
- ./data:/usr/src/paperless/paperless-ngx/data
- ./media:/usr/src/paperless/paperless-ngx/media
- ./consume:/usr/src/paperless/paperless-ngx/consume
- ~/.gitconfig:/usr/src/paperless/.gitconfig:ro
environment:
PAPERLESS_REDIS: redis://broker:6379
PAPERLESS_TIKA_ENABLED: 1

1047
.github/workflows/ci.yml vendored

File diff suppressed because it is too large Load Diff

View File

@@ -28,7 +28,7 @@ jobs:
steps:
- name: Clean temporary images
if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/ephemeral@v0.11.0
uses: stumpylog/image-cleaner-action/ephemeral@v0.10.0
with:
token: "${{ env.TOKEN }}"
owner: "${{ github.repository_owner }}"
@@ -54,7 +54,7 @@ jobs:
steps:
- name: Clean untagged images
if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/untagged@v0.11.0
uses: stumpylog/image-cleaner-action/untagged@v0.10.0
with:
token: "${{ env.TOKEN }}"
owner: "${{ github.repository_owner }}"

View File

@@ -34,7 +34,7 @@ jobs:
# Learn more about CodeQL language support at https://git.io/codeql-language-support
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3

View File

@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
token: ${{ secrets.PNGX_BOT_PAT }}
- name: crowdin action

View File

@@ -37,7 +37,7 @@ jobs:
labels.push('bug');
} else if (/^feature/i.test(title)) {
labels.push('enhancement');
} else if (!/^(dependabot)/i.test(title) && !/^(chore)/i.test(title)) {
} else if (!/^(dependabot)/i.test(title)) {
labels.push('enhancement'); // Default fallback
}

View File

@@ -241,7 +241,6 @@ jobs:
) {
nodes {
id,
createdAt,
number,
updatedAt,
upvoteCount,

View File

@@ -11,7 +11,7 @@ jobs:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
token: ${{ secrets.PNGX_BOT_PAT }}
ref: ${{ github.head_ref }}

3
.gitignore vendored
View File

@@ -107,6 +107,3 @@ celerybeat-schedule*
/.devcontainer/data/
/.devcontainer/media/
/.devcontainer/redisdata/
# ignore pnpm package store folder created when setting up the devcontainer
.pnpm-store/

View File

@@ -4,7 +4,7 @@
repos:
# General hooks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
rev: v5.0.0
hooks:
- id: check-docstring-first
- id: check-json
@@ -18,7 +18,7 @@ repos:
exclude_types:
- svg
- pofile
exclude: "(^LICENSE$|^src/documents/static/bootstrap.min.css$)"
exclude: "(^LICENSE$)"
- id: mixed-line-ending
args:
- "--fix=lf"
@@ -49,9 +49,9 @@ repos:
- 'prettier-plugin-organize-imports@4.1.0'
# Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.13.0
rev: v0.12.2
hooks:
- id: ruff-check
- id: ruff
- id: ruff-format
- repo: https://github.com/tox-dev/pyproject-fmt
rev: "v2.6.0"
@@ -72,7 +72,7 @@ repos:
args:
- "--tab"
- repo: https://github.com/shellcheck-py/shellcheck-py
rev: "v0.11.0.1"
rev: "v0.10.0.1"
hooks:
- id: shellcheck
- repo: https://github.com/google/yamlfmt

View File

@@ -1 +0,0 @@
line_ending: lf

View File

@@ -2,11 +2,9 @@
If you feel like contributing to the project, please do! Bug fixes and improvements are always welcome.
⚠️ Please note: Pull requests that implement a new feature or enhancement _should almost always target an existing feature request_ with evidence of community interest and discussion. This is in order to balance the work of implementing and maintaining new features / enhancements. Pull requests that are opened without meeting this requirement may not be merged.
If you want to implement something big:
- As above, please start with a discussion! Maybe something similar is already in development and we can make it happen together.
- Please start a discussion about that in the issues! Maybe something similar is already in development and we can make it happen together.
- When making additions to the project, consider if the majority of users will benefit from your change. If not, you're probably better of forking the project.
- Also consider if your change will get in the way of other users. A good change is a change that enhances the experience of some users who want that change and does not affect users who do not care about the change.
- Please see the [paperless-ngx merge process](#merging-prs) below.
@@ -135,7 +133,7 @@ community members. That said, in an effort to keep the repository organized and
- Issues, pull requests and discussions that are closed will be locked after 30 days of inactivity.
- Discussions with a marked answer will be automatically closed.
- Discussions in the 'General' or 'Support' categories will be closed after 180 days of inactivity.
- Feature requests that do not meet the following thresholds will be closed: 180 days of inactivity with less than 80 "up-votes", < 5 "up-votes" after 180 days, < 20 "up-votes" after 1 year or < 40 "up-votes" at 2 years.
- Feature requests that do not meet the following thresholds will be closed: 180 days of inactivity, < 5 "up-votes" after 180 days, < 20 "up-votes" after 1 year or < 80 "up-votes" at 2 years.
In all cases, threads can be re-opened by project maintainers and, of course, users can always create a new discussion for related concerns.
Finally, remember that all information remains searchable and 'closed' feature requests can still serve as inspiration for new features.

View File

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

View File

@@ -4,7 +4,7 @@
# correct networking for the tests
services:
gotenberg:
image: docker.io/gotenberg/gotenberg:8.23
image: docker.io/gotenberg/gotenberg:8.22
hostname: gotenberg
container_name: gotenberg
network_mode: host

View File

@@ -72,7 +72,7 @@ services:
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg:
image: docker.io/gotenberg/gotenberg:8.23
image: docker.io/gotenberg/gotenberg:8.22
restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript.

View File

@@ -66,7 +66,7 @@ services:
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg:
image: docker.io/gotenberg/gotenberg:8.23
image: docker.io/gotenberg/gotenberg:8.22
restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript.

View File

@@ -55,7 +55,7 @@ services:
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg:
image: docker.io/gotenberg/gotenberg:8.23
image: docker.io/gotenberg/gotenberg:8.22
restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript.

View File

@@ -471,7 +471,7 @@ Failing to invalidate the cache after such modifications can lead to stale data
Use the following management command to clear the cache:
```
python3 manage.py invalidate_cachalot
invalidate_cachalot
```
!!! info

View File

@@ -506,7 +506,6 @@ for the possible codes and their meanings.
The `localize_date` filter formats a date or datetime object into a localized string using Babel internationalization.
This takes into account the provided locale for translation. Since this must be used on a date or datetime object,
you must access the field directly, i.e. `document.created`.
An ISO string can also be provided to control the output format.
###### Syntax
@@ -517,7 +516,7 @@ An ISO string can also be provided to control the output format.
###### Parameters
- `value` (date | datetime | str): Date, datetime object or ISO string to format (datetime should be timezone-aware)
- `value` (date | datetime): Date or datetime object to format (datetime should be timezone-aware)
- `format` (str): Format type - either a Babel preset ('short', 'medium', 'long', 'full') or custom pattern
- `locale` (str): Locale code for localization (e.g., 'en_US', 'fr_FR', 'de_DE')

View File

@@ -192,8 +192,8 @@ The endpoint supports the following optional form fields:
- `tags`: Similar to correspondent. Specify this multiple times to
have multiple tags added to the document.
- `archive_serial_number`: An optional archive serial number to set.
- `custom_fields`: Either an array of custom field ids to assign (with an empty
value) to the document or an object mapping field id -> value.
- `custom_fields`: An array of custom field ids to assign (with an empty
value) to the document.
The endpoint will immediately return HTTP 200 if the document consumption
process was started successfully, with the UUID of the consumption task

View File

@@ -1,94 +1,5 @@
# Changelog
## paperless-ngx 2.18.4
### Features / Enhancements
- Enhancement: report websocket status [@shamoon](https://github.com/shamoon) ([#10777](https://github.com/paperless-ngx/paperless-ngx/pull/10777))
### Bug Fixes
- Revert "Performance: Enable virtual scrolling for large custom field … [@shamoon](https://github.com/shamoon) ([#10803](https://github.com/paperless-ngx/paperless-ngx/pull/10803))
- Fixhancement: update sidebar view counts on save \& next also [@shamoon](https://github.com/shamoon) ([#10793](https://github.com/paperless-ngx/paperless-ngx/pull/10793))
- Performance fix: add paging for custom field select options [@shamoon](https://github.com/shamoon) ([#10755](https://github.com/paperless-ngx/paperless-ngx/pull/10755))
### Dependencies
<details>
<summary>8 changes</summary>
- Chore(deps-dev): Bump the frontend-jest-dependencies group in /src-ui with 2 updates [@shamoon](https://github.com/shamoon) ([#10770](https://github.com/paperless-ngx/paperless-ngx/pull/10770))
- Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10745](https://github.com/paperless-ngx/paperless-ngx/pull/10745))
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 22 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10744](https://github.com/paperless-ngx/paperless-ngx/pull/10744))
- Chore(deps): Bump bootstrap from 5.3.7 to 5.3.8 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10740](https://github.com/paperless-ngx/paperless-ngx/pull/10740))
- Chore(deps-dev): Bump @<!---->playwright/test from 1.54.2 to 1.55.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10743](https://github.com/paperless-ngx/paperless-ngx/pull/10743))
- Chore(deps-dev): Bump webpack from 5.101.0 to 5.101.3 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10751](https://github.com/paperless-ngx/paperless-ngx/pull/10751))
- Chore(deps-dev): Bump @<!---->types/node from 24.1.0 to 24.3.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10750](https://github.com/paperless-ngx/paperless-ngx/pull/10750))
- Chore(deps): Bump the actions group with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10757](https://github.com/paperless-ngx/paperless-ngx/pull/10757))
</details>
### All App Changes
<details>
<summary>13 changes</summary>
- Revert "Performance: Enable virtual scrolling for large custom field … @shamoon ([#10803](https://github.com/paperless-ngx/paperless-ngx/pull/10803))
- Fixhancement: update sidebar view counts on save \& next also @shamoon ([#10793](https://github.com/paperless-ngx/paperless-ngx/pull/10793))
- Enhancement: report websocket status @shamoon ([#10777](https://github.com/paperless-ngx/paperless-ngx/pull/10777))
- Chore(deps-dev): Bump the frontend-jest-dependencies group in /src-ui with 2 updates @shamoon ([#10770](https://github.com/paperless-ngx/paperless-ngx/pull/10770))
- Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10745](https://github.com/paperless-ngx/paperless-ngx/pull/10745))
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 22 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10744](https://github.com/paperless-ngx/paperless-ngx/pull/10744))
- Chore(deps): Bump bootstrap from 5.3.7 to 5.3.8 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10740](https://github.com/paperless-ngx/paperless-ngx/pull/10740))
- Chore(deps-dev): Bump @<!---->playwright/test from 1.54.2 to 1.55.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10743](https://github.com/paperless-ngx/paperless-ngx/pull/10743))
- Chore(deps-dev): Bump webpack from 5.101.0 to 5.101.3 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10751](https://github.com/paperless-ngx/paperless-ngx/pull/10751))
- Chore(deps-dev): Bump @<!---->types/node from 24.1.0 to 24.3.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10750](https://github.com/paperless-ngx/paperless-ngx/pull/10750))
- Chore: switch from os.path to pathlib.Path @gothicVI ([#10539](https://github.com/paperless-ngx/paperless-ngx/pull/10539))
- Performance fix: add paging for custom field select options @shamoon ([#10755](https://github.com/paperless-ngx/paperless-ngx/pull/10755))
</details>
## paperless-ngx 2.18.3
### Bug Fixes
- Fix: include application config language settings for dateparser auto-detection [@shamoon](https://github.com/shamoon) ([#10722](https://github.com/paperless-ngx/paperless-ngx/pull/10722))
- Fix: hide sidebar counts during saved views organization [@shamoon](https://github.com/shamoon) ([#10716](https://github.com/paperless-ngx/paperless-ngx/pull/10716))
- Fix: wrap long view titles in sidebar [@shamoon](https://github.com/shamoon) ([#10715](https://github.com/paperless-ngx/paperless-ngx/pull/10715))
- Fixhancement: more saved view count refreshes [@shamoon](https://github.com/shamoon) ([#10694](https://github.com/paperless-ngx/paperless-ngx/pull/10694))
- Fix: include pagination array items for valid openapi schema [@shamoon](https://github.com/shamoon) ([#10682](https://github.com/paperless-ngx/paperless-ngx/pull/10682))
- Fix: prevent scroll for view name in sidebar [@shamoon](https://github.com/shamoon) ([#10676](https://github.com/paperless-ngx/paperless-ngx/pull/10676))
- Tweak: center document close button in app frame [@shamoon](https://github.com/shamoon) ([#10661](https://github.com/paperless-ngx/paperless-ngx/pull/10661))
- Performance: Enable virtual scrolling for large custom field selects @david-loe ([#10708](https://github.com/paperless-ngx/paperless-ngx/pull/10708))
### Dependencies
<details>
<summary>5 changes</summary>
- Chore(deps): Update granian[uvloop] requirement from ~=2.4.1 to ~=2.5.1 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10529](https://github.com/paperless-ngx/paperless-ngx/pull/10529))
- Chore(deps): Bump the small-changes group across 1 directory with 6 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10714](https://github.com/paperless-ngx/paperless-ngx/pull/10714))
- docker-compose(deps): Bump library/mariadb from 11 to 12 in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#10621](https://github.com/paperless-ngx/paperless-ngx/pull/10621))
- docker-compose(deps): Bump gotenberg/gotenberg from 8.20 to 8.22 in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#10687](https://github.com/paperless-ngx/paperless-ngx/pull/10687))
- docker(deps): Bump astral-sh/uv from 0.8.8-python3.12-bookworm-slim to 0.8.13-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#10685](https://github.com/paperless-ngx/paperless-ngx/pull/10685))
</details>
### All App Changes
<details>
<summary>11 changes</summary>
- Fix: include application config language settings for dateparser auto-detection [@shamoon](https://github.com/shamoon) ([#10722](https://github.com/paperless-ngx/paperless-ngx/pull/10722))
- Chore(deps): Update granian[uvloop] requirement from ~=2.4.1 to ~=2.5.1 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10529](https://github.com/paperless-ngx/paperless-ngx/pull/10529))
- Chore(deps): Bump the small-changes group across 1 directory with 6 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10714](https://github.com/paperless-ngx/paperless-ngx/pull/10714))
- Fix: hide sidebar counts during saved views organization [@shamoon](https://github.com/shamoon) ([#10716](https://github.com/paperless-ngx/paperless-ngx/pull/10716))
- Fix: wrap long view titles in sidebar [@shamoon](https://github.com/shamoon) ([#10715](https://github.com/paperless-ngx/paperless-ngx/pull/10715))
- Performance: Enable virtual scrolling for large custom field selects @david-loe ([#10708](https://github.com/paperless-ngx/paperless-ngx/pull/10708))
- Chore: refactor document details component [@shamoon](https://github.com/shamoon) ([#10662](https://github.com/paperless-ngx/paperless-ngx/pull/10662))
- Fixhancement: more saved view count refreshes [@shamoon](https://github.com/shamoon) ([#10694](https://github.com/paperless-ngx/paperless-ngx/pull/10694))
- Fix: include pagination array items for valid openapi schema [@shamoon](https://github.com/shamoon) ([#10682](https://github.com/paperless-ngx/paperless-ngx/pull/10682))
- Fix: prevent scroll for view name in sidebar [@shamoon](https://github.com/shamoon) ([#10676](https://github.com/paperless-ngx/paperless-ngx/pull/10676))
- Tweak: center document close button in app frame [@shamoon](https://github.com/shamoon) ([#10661](https://github.com/paperless-ngx/paperless-ngx/pull/10661))
</details>
## paperless-ngx 2.18.2
### Bug Fixes

View File

@@ -1759,11 +1759,6 @@ started by the container.
: Path to an image file in the /media/logo directory, must include 'logo', e.g. `/logo/Atari_logo.svg`
!!! note
The logo file will be viewable by anyone with access to the Paperless instance login page,
so consider your choice of logo carefully and removing exif data from images before uploading.
#### [`PAPERLESS_ENABLE_UPDATE_CHECK=<bool>`](#PAPERLESS_ENABLE_UPDATE_CHECK) {#PAPERLESS_ENABLE_UPDATE_CHECK}
!!! note

View File

@@ -470,14 +470,9 @@ To get started:
2. VS Code will prompt you with "Reopen in container". Do so and wait for the environment to start.
3. In case your host operating system is Windows:
- The Source Control view in Visual Studio Code might show: "The detected Git repository is potentially unsafe as the folder is owned by someone other than the current user." Use "Manage Unsafe Repositories" to fix this.
- Git might have detecteded modifications for all files, because Windows is using CRLF line endings. Run `git checkout .` in the containers terminal to fix this issue.
4. Initialize the project by running the task **Project Setup: Run all Init Tasks**. This
3. Initialize the project by running the task **Project Setup: Run all Init Tasks**. This
will initialize the database tables and create a superuser. Then you can compile the front end
for production or run the frontend in debug mode.
5. The project is ready for debugging, start either run the fullstack debug or individual debug
4. The project is ready for debugging, start either run the fullstack debug or individual debug
processes. Yo spin up the project without debugging run the task **Project Start: Run all Services**

View File

@@ -92,16 +92,6 @@ and more. These areas allow you to view, add, edit, delete and manage permission
for these objects. You can also manage saved views, mail accounts, mail rules,
workflows and more from the management sections.
### Nested Tags
Paperless-ngx v2.19 introduces support for nested tags, allowing you to create a
hierarchy of tags, which may be useful for organizing your documents. Tags can
have a 'parent' tag, creating a tree-like structure, to a maximum depth of 5. When
a tag is added to a document, all of its parent tags are also added automatically
and similarly, when a tag is removed from a document, all of its child tags are
also removed. Additionally, assigning a parent to an existing tag will automatically
update all documents that have this tag assigned, adding the parent tag as well.
## Adding documents to Paperless-ngx
Once you've got Paperless setup, you need to start feeding documents
@@ -261,10 +251,6 @@ different means. These are as follows:
Paperless is set up to check your mails every 10 minutes. This can be
configured via [`PAPERLESS_EMAIL_TASK_CRON`](configuration.md#PAPERLESS_EMAIL_TASK_CRON)
#### Processed Mail
Paperless keeps track of emails it has processed in order to avoid processing the same mail multiple times. This uses the message `UID` provided by the mail server, which should be unique for each message. You can view and manage processed mails from the web UI under Mail > Processed Mails. If you need to re-process a message, you can delete the corresponding processed mail entry, which will allow Paperless-ngx to process the email again the next time the mail fetch task runs.
#### OAuth Email Setup
Paperless-ngx supports OAuth2 authentication for Gmail and Outlook email accounts. To set up an email account with OAuth2, you will need to create a 'developer' app with the respective provider and obtain the client ID and client secret and set the appropriate [configuration variables](configuration.md#email_oauth). You will also need to set either [`PAPERLESS_OAUTH_CALLBACK_BASE_URL`](configuration.md#PAPERLESS_OAUTH_CALLBACK_BASE_URL) or [`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) to the correct value for the OAuth2 flow to work correctly.
@@ -422,7 +408,7 @@ Currently, there are three events that correspond to workflow trigger 'types':
but the document content has been extracted and metadata such as document type, tags, etc. have been set, so these can now
be used for filtering.
3. **Document Updated**: when a document is updated. Similar to 'added' events, triggers can include filtering by content matching,
tags, doc type, correspondent or storage path.
tags, doc type, or correspondent.
4. **Scheduled**: a scheduled trigger that can be used to run workflows at a specific time. The date used can be either the document
added, created, updated date or you can specify a (date) custom field. You can also specify a day offset from the date (positive
offsets will trigger after the date, negative offsets will trigger before).
@@ -466,11 +452,10 @@ Workflows allow you to filter by:
- File path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for
example, automatically assigning documents to different owners based on the upload directory.
- Mail rule. Choosing this option will force 'mail fetch' to be the workflow source.
- Content matching (`Added`, `Updated` and `Scheduled` triggers only). Filter document content using the matching settings.
- Tags (`Added`, `Updated` and `Scheduled` triggers only). Filter for documents with any of the specified tags
- Document type (`Added`, `Updated` and `Scheduled` triggers only). Filter documents with this doc type
- Correspondent (`Added`, `Updated` and `Scheduled` triggers only). Filter documents with this correspondent
- Storage path (`Added`, `Updated` and `Scheduled` triggers only). Filter documents with this storage path
- Content matching (`Added` and `Updated` triggers only). Filter document content using the matching settings.
- Tags (`Added` and `Updated` triggers only). Filter for documents with any of the specified tags
- Document type (`Added` and `Updated` triggers only). Filter documents with this doc type
- Correspondent (`Added` and `Updated` triggers only). Filter documents with this correspondent
### Workflow Actions
@@ -520,52 +505,35 @@ you may want to adjust these settings to prevent abuse.
#### Workflow placeholders
Titles can be assigned by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
This allows for complex logic to be used to generate the title, including [logical structures](https://jinja.palletsprojects.com/en/3.1.x/templates/#list-of-control-structures)
and [filters](https://jinja.palletsprojects.com/en/3.1.x/templates/#id11).
The template is provided as a string.
Some workflow text can include placeholders but the available options differ depending on the type of
workflow trigger. This is because at the time of consumption (when the text is to be set), no automatic tags etc. have been
applied. You can use the following placeholders with any trigger type:
Using Jinja2 Templates is also useful for [Date localization](advanced_usage.md#Date-Localization) in the title.
The available inputs differ depending on the type of workflow trigger.
This is because at the time of consumption (when the text is to be set), no automatic tags etc. have been
applied. You can use the following placeholders in the template with any trigger type:
- `{{correspondent}}`: assigned correspondent name
- `{{document_type}}`: assigned document type name
- `{{owner_username}}`: assigned owner username
- `{{added}}`: added datetime
- `{{added_year}}`: added year
- `{{added_year_short}}`: added year
- `{{added_month}}`: added month
- `{{added_month_name}}`: added month name
- `{{added_month_name_short}}`: added month short name
- `{{added_day}}`: added day
- `{{added_time}}`: added time in HH:MM format
- `{{original_filename}}`: original file name without extension
- `{{filename}}`: current file name without extension
- `{correspondent}`: assigned correspondent name
- `{document_type}`: assigned document type name
- `{owner_username}`: assigned owner username
- `{added}`: added datetime
- `{added_year}`: added year
- `{added_year_short}`: added year
- `{added_month}`: added month
- `{added_month_name}`: added month name
- `{added_month_name_short}`: added month short name
- `{added_day}`: added day
- `{added_time}`: added time in HH:MM format
- `{original_filename}`: original file name without extension
- `{filename}`: current file name without extension
The following placeholders are only available for "added" or "updated" triggers
- `{{created}}`: created datetime
- `{{created_year}}`: created year
- `{{created_year_short}}`: created year
- `{{created_month}}`: created month
- `{{created_month_name}}`: created month name
- `{created_month_name_short}}`: created month short name
- `{{created_day}}`: created day
- `{{created_time}}`: created time in HH:MM format
- `{{doc_url}}`: URL to the document in the web UI. Requires the `PAPERLESS_URL` setting to be set.
##### Examples
```jinja2
{{ created | localize_date('MMMM', 'en_US') }}
<!-- Output: "January" -->
{{ added | localize_date('MMMM', 'de_DE') }}
<!-- Output: "Juni" --> # codespell:ignore
```
- `{created}`: created datetime
- `{created_year}`: created year
- `{created_year_short}`: created year
- `{created_month}`: created month
- `{created_month_name}`: created month name
- `{created_month_name_short}`: created month short name
- `{created_day}`: created day
- `{created_time}`: created time in HH:MM format
- `{doc_url}`: URL to the document in the web UI. Requires the `PAPERLESS_URL` setting to be set.
### Workflow permissions

View File

@@ -1,6 +1,6 @@
[project]
name = "paperless-ngx"
version = "2.18.4"
version = "2.18.2"
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
readme = "README.md"
requires-python = ">=3.10"
@@ -30,17 +30,16 @@ dependencies = [
"django-cachalot~=2.8.0",
"django-celery-results~=2.6.0",
"django-compression-middleware~=0.5.0",
"django-cors-headers~=4.9.0",
"django-cors-headers~=4.7.0",
"django-extensions~=4.1",
"django-filter~=25.1",
"django-guardian~=3.2.0",
"django-guardian~=3.0.3",
"django-multiselectfield~=1.0.1",
"django-soft-delete~=1.0.18",
"django-treenode>=0.23.2",
"djangorestframework~=3.16",
"djangorestframework-guardian~=0.4.0",
"drf-spectacular~=0.28",
"drf-spectacular-sidecar~=2025.9.1",
"drf-spectacular-sidecar~=2025.8.1",
"drf-writable-nested~=0.7.1",
"filelock~=3.19.1",
"flower~=2.0.1",
@@ -51,9 +50,10 @@ dependencies = [
"jinja2~=3.1.5",
"langdetect~=1.0.9",
"nltk~=3.9.1",
"ocrmypdf~=16.11.0",
"ocrmypdf~=16.10.0",
"pathvalidate~=3.3.1",
"pdf2image~=1.17.0",
"psutil>=7",
"psycopg-pool",
"python-dateutil~=2.9.0",
"python-dotenv~=1.1.0",
@@ -95,7 +95,7 @@ dev = [
]
docs = [
"mkdocs-glightbox~=0.5.1",
"mkdocs-glightbox~=0.4.0",
"mkdocs-material~=9.6.4",
]
@@ -104,7 +104,7 @@ testing = [
"factory-boy~=3.3.1",
"imagehash",
"pytest~=8.4.1",
"pytest-cov~=7.0.0",
"pytest-cov~=6.2.1",
"pytest-django~=4.11.1",
"pytest-env",
"pytest-httpx",
@@ -117,7 +117,7 @@ testing = [
lint = [
"pre-commit~=4.3.0",
"pre-commit-uv~=4.1.3",
"ruff~=0.13.0",
"ruff~=0.12.2",
]
typing = [
@@ -125,7 +125,6 @@ typing = [
"django-filter-stubs",
"django-stubs[compatible-mypy]",
"djangorestframework-stubs[compatible-mypy]",
"lxml-stubs",
"mypy",
"types-bleach",
"types-colorama",
@@ -133,7 +132,6 @@ typing = [
"types-markdown",
"types-pygments",
"types-python-dateutil",
"types-pytz",
"types-redis",
"types-setuptools",
"types-tqdm",
@@ -208,9 +206,18 @@ lint.per-file-ignores."docker/wait-for-redis.py" = [
"INP001",
"T201",
]
lint.per-file-ignores."src/documents/management/commands/document_consumer.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/migrations/1012_fix_archive_files.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/models.py" = [
"SIM115",
]
lint.per-file-ignores."src/documents/parsers.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/paperless_tesseract/tests/test_parser.py" = [
"RUF001",
]
@@ -273,10 +280,10 @@ exclude_also = [
]
[tool.mypy]
mypy_path = "src"
plugins = [
"mypy_django_plugin.main",
"mypy_drf_plugin.main",
"numpy.typing.mypy_plugin",
]
check_untyped_defs = true
disallow_any_generics = true

View File

@@ -174,7 +174,7 @@ test('bulk edit', async ({ page }) => {
await expect(page.locator('pngx-document-list')).toHaveText(
/Selected 61 of 61 documents/i
)
await page.getByRole('button', { name: 'None' }).click()
await page.getByRole('button', { name: 'Cancel' }).click()
await page.locator('pngx-document-card-small').nth(1).click()
await page.locator('pngx-document-card-small').nth(2).click()

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "paperless-ngx-ui",
"version": "2.18.4",
"version": "2.18.2",
"scripts": {
"preinstall": "npx only-allow pnpm",
"ng": "ng",
@@ -11,27 +11,27 @@
},
"private": true,
"dependencies": {
"@angular/cdk": "^20.2.2",
"@angular/common": "~20.2.4",
"@angular/compiler": "~20.2.4",
"@angular/core": "~20.2.4",
"@angular/forms": "~20.2.4",
"@angular/localize": "~20.2.4",
"@angular/platform-browser": "~20.2.4",
"@angular/platform-browser-dynamic": "~20.2.4",
"@angular/router": "~20.2.4",
"@angular/cdk": "^20.1.4",
"@angular/common": "~20.1.4",
"@angular/compiler": "~20.1.4",
"@angular/core": "~20.1.4",
"@angular/forms": "~20.1.4",
"@angular/localize": "~20.1.4",
"@angular/platform-browser": "~20.1.4",
"@angular/platform-browser-dynamic": "~20.1.4",
"@angular/router": "~20.1.4",
"@ng-bootstrap/ng-bootstrap": "^19.0.1",
"@ng-select/ng-select": "^20.1.3",
"@ng-select/ng-select": "^20.0.1",
"@ngneat/dirty-check-forms": "^3.0.3",
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
"bootstrap": "^5.3.7",
"file-saver": "^2.0.5",
"mime-names": "^1.0.0",
"ng2-pdf-viewer": "^10.4.0",
"ngx-bootstrap-icons": "^1.9.3",
"ngx-color": "^10.0.0",
"ngx-cookie-service": "^20.1.0",
"ngx-device-detector": "^10.1.0",
"ngx-cookie-service": "^20.0.1",
"ngx-device-detector": "^10.0.2",
"ngx-ui-tour-ng-bootstrap": "^17.0.1",
"rxjs": "^7.8.2",
"tslib": "^2.8.1",
@@ -42,33 +42,33 @@
"devDependencies": {
"@angular-builders/custom-webpack": "^20.0.0",
"@angular-builders/jest": "^20.0.0",
"@angular-devkit/core": "^20.2.2",
"@angular-devkit/schematics": "^20.2.2",
"@angular-eslint/builder": "20.2.0",
"@angular-eslint/eslint-plugin": "20.2.0",
"@angular-eslint/eslint-plugin-template": "20.2.0",
"@angular-eslint/schematics": "20.2.0",
"@angular-eslint/template-parser": "20.2.0",
"@angular/build": "^20.2.2",
"@angular/cli": "~20.2.2",
"@angular/compiler-cli": "~20.2.4",
"@angular-devkit/core": "^20.1.4",
"@angular-devkit/schematics": "^20.1.4",
"@angular-eslint/builder": "20.1.1",
"@angular-eslint/eslint-plugin": "20.1.1",
"@angular-eslint/eslint-plugin-template": "20.1.1",
"@angular-eslint/schematics": "20.1.1",
"@angular-eslint/template-parser": "20.1.1",
"@angular/build": "^20.1.4",
"@angular/cli": "~20.1.4",
"@angular/compiler-cli": "~20.1.4",
"@codecov/webpack-plugin": "^1.9.1",
"@playwright/test": "^1.55.0",
"@playwright/test": "^1.54.2",
"@types/jest": "^30.0.0",
"@types/node": "^24.3.0",
"@typescript-eslint/eslint-plugin": "^8.41.0",
"@typescript-eslint/parser": "^8.41.0",
"@typescript-eslint/utils": "^8.41.0",
"eslint": "^9.34.0",
"jest": "30.1.3",
"jest-environment-jsdom": "^30.1.2",
"@types/node": "^24.1.0",
"@typescript-eslint/eslint-plugin": "^8.38.0",
"@typescript-eslint/parser": "^8.38.0",
"@typescript-eslint/utils": "^8.38.0",
"eslint": "^9.32.0",
"jest": "30.0.5",
"jest-environment-jsdom": "^30.0.5",
"jest-junit": "^16.0.0",
"jest-preset-angular": "^15.0.0",
"jest-websocket-mock": "^2.5.0",
"prettier-plugin-organize-imports": "^4.2.0",
"ts-node": "~10.9.1",
"typescript": "^5.8.3",
"webpack": "^5.101.3"
"webpack": "^5.101.0"
},
"pnpm": {
"onlyBuiltDependencies": [

3633
src-ui/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -61,40 +61,6 @@ const groups = [
{ id: 2, name: 'group2' },
]
const status: SystemStatus = {
pngx_version: '2.4.3',
server_os: 'macOS-14.1.1-arm64-arm-64bit',
install_type: InstallType.BareMetal,
storage: { total: 494384795648, available: 13573525504 },
database: {
type: 'sqlite',
url: '/paperless-ngx/data/db.sqlite3',
status: SystemStatusItemStatus.ERROR,
error: null,
migration_status: {
latest_migration: 'socialaccount.0006_alter_socialaccount_extra_data',
unapplied_migrations: [],
},
},
tasks: {
redis_url: 'redis://localhost:6379',
redis_status: SystemStatusItemStatus.ERROR,
redis_error: 'Error 61 connecting to localhost:6379. Connection refused.',
celery_status: SystemStatusItemStatus.ERROR,
celery_url: 'celery@localhost',
celery_error: 'Error connecting to celery@localhost',
index_status: SystemStatusItemStatus.OK,
index_last_modified: new Date().toISOString(),
index_error: null,
classifier_status: SystemStatusItemStatus.OK,
classifier_last_trained: new Date().toISOString(),
classifier_error: null,
sanity_check_status: SystemStatusItemStatus.ERROR,
sanity_check_last_run: new Date().toISOString(),
sanity_check_error: 'Error running sanity check.',
},
}
describe('SettingsComponent', () => {
let component: SettingsComponent
let fixture: ComponentFixture<SettingsComponent>
@@ -324,6 +290,40 @@ describe('SettingsComponent', () => {
})
it('should load system status on initialize, show errors if needed', () => {
const status: SystemStatus = {
pngx_version: '2.4.3',
server_os: 'macOS-14.1.1-arm64-arm-64bit',
install_type: InstallType.BareMetal,
storage: { total: 494384795648, available: 13573525504 },
database: {
type: 'sqlite',
url: '/paperless-ngx/data/db.sqlite3',
status: SystemStatusItemStatus.ERROR,
error: null,
migration_status: {
latest_migration: 'socialaccount.0006_alter_socialaccount_extra_data',
unapplied_migrations: [],
},
},
tasks: {
redis_url: 'redis://localhost:6379',
redis_status: SystemStatusItemStatus.ERROR,
redis_error:
'Error 61 connecting to localhost:6379. Connection refused.',
celery_status: SystemStatusItemStatus.ERROR,
celery_url: 'celery@localhost',
celery_error: 'Error connecting to celery@localhost',
index_status: SystemStatusItemStatus.OK,
index_last_modified: new Date().toISOString(),
index_error: null,
classifier_status: SystemStatusItemStatus.OK,
classifier_last_trained: new Date().toISOString(),
classifier_error: null,
sanity_check_status: SystemStatusItemStatus.ERROR,
sanity_check_last_run: new Date().toISOString(),
sanity_check_error: 'Error running sanity check.',
},
}
jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status))
jest.spyOn(permissionsService, 'isAdmin').mockReturnValue(true)
completeSetup()
@@ -340,8 +340,6 @@ describe('SettingsComponent', () => {
it('should open system status dialog', () => {
const modalOpenSpy = jest.spyOn(modalService, 'open')
jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status))
jest.spyOn(permissionsService, 'isAdmin').mockReturnValue(true)
completeSetup()
component.showSystemStatus()
expect(modalOpenSpy).toHaveBeenCalledWith(SystemStatusDialogComponent, {

View File

@@ -185,8 +185,7 @@ export class SettingsComponent
this.systemStatus.tasks.classifier_status ===
SystemStatusItemStatus.ERROR ||
this.systemStatus.tasks.sanity_check_status ===
SystemStatusItemStatus.ERROR ||
this.systemStatus.websocket_connected === SystemStatusItemStatus.ERROR
SystemStatusItemStatus.ERROR
)
}

View File

@@ -35,9 +35,6 @@
@case (CustomFieldDataType.Select) {
<span [ngbTooltip]="nameTooltip">{{getSelectValue(field, value)}}</span>
}
@case (CustomFieldDataType.LongText) {
<p class="mb-0" [ngbTooltip]="nameTooltip">{{value | slice:0:20}}{{value.length > 20 ? '...' : ''}}</p>
}
@default {
<span [ngbTooltip]="nameTooltip">{{value}}</span>
}

View File

@@ -1,5 +1,5 @@
import { CurrencyPipe, getLocaleCurrencyCode, SlicePipe } from '@angular/common'
import { Component, inject, Input, LOCALE_ID, OnInit } from '@angular/core'
import { CurrencyPipe, getLocaleCurrencyCode } from '@angular/common'
import { Component, Input, LOCALE_ID, OnInit, inject } from '@angular/core'
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
import { takeUntil } from 'rxjs'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
@@ -14,7 +14,7 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
selector: 'pngx-custom-field-display',
templateUrl: './custom-field-display.component.html',
styleUrl: './custom-field-display.component.scss',
imports: [CustomDatePipe, CurrencyPipe, NgbTooltipModule, SlicePipe],
imports: [CustomDatePipe, CurrencyPipe, NgbTooltipModule],
})
export class CustomFieldDisplayComponent
extends LoadingComponentWithPermissions

View File

@@ -51,6 +51,7 @@
<ng-select #fieldSelects
class="paperless-input-select rounded-end"
[items]="getSelectOptionsForField(atom.field)"
[virtualScroll]="getSelectOptionsForField(atom.field)?.length > 100"
bindLabel="label"
bindValue="id"
[(ngModel)]="atom.value"

View File

@@ -28,16 +28,6 @@
</div>
}
</div>
@if (allSelectOptions.length > SELECT_OPTION_PAGE_SIZE) {
<ngb-pagination
class="d-flex justify-content-end"
[pageSize]="SELECT_OPTION_PAGE_SIZE"
[collectionSize]="allSelectOptions.length"
[(page)]="selectOptionsPage"
[maxSize]="5"
size="sm"
></ngb-pagination>
}
@if (object?.id) {
<small class="d-block mt-2" i18n>Warning: existing instances of this field will retain their current value index (e.g. option #1, #2, #3) after editing the options here</small>
}

View File

@@ -125,42 +125,4 @@ describe('CustomFieldEditDialogComponent', () => {
fixture.detectChanges()
expect(document.activeElement).toBe(selectOptionInputs.last.nativeElement)
})
it('should send all select options including those changed in form on save', () => {
component.dialogMode = EditDialogMode.EDIT
component.object = {
id: 1,
name: 'Field 1',
data_type: CustomFieldDataType.Select,
extra_data: {
select_options: Array.from({ length: 50 }, (_, i) => ({
label: `Option ${i + 1}`,
id: `${i + 1}-xyz`,
})),
},
}
fixture.detectChanges()
component.ngOnInit()
component.selectOptionsPage = 2
fixture.detectChanges()
component.objectForm
.get('extra_data')
.get('select_options')
.get('0')
.get('label')
.setValue('Updated Option 9')
const formValues = (component as any).getFormValues()
// first item unchanged
expect(formValues.extra_data.select_options[0]).toEqual({
label: 'Option 1',
id: '1-xyz',
})
// page 2 first item updated
expect(
formValues.extra_data.select_options[component.SELECT_OPTION_PAGE_SIZE]
).toEqual({
label: 'Updated Option 9',
id: '9-xyz',
})
})
})

View File

@@ -14,7 +14,6 @@ import {
FormsModule,
ReactiveFormsModule,
} from '@angular/forms'
import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { takeUntil } from 'rxjs'
import {
@@ -29,8 +28,6 @@ import { SelectComponent } from '../../input/select/select.component'
import { TextComponent } from '../../input/text/text.component'
import { EditDialogComponent, EditDialogMode } from '../edit-dialog.component'
const SELECT_OPTION_PAGE_SIZE = 8
@Component({
selector: 'pngx-custom-field-edit-dialog',
templateUrl: './custom-field-edit-dialog.component.html',
@@ -40,7 +37,6 @@ const SELECT_OPTION_PAGE_SIZE = 8
TextComponent,
FormsModule,
ReactiveFormsModule,
NgbPaginationModule,
NgxBootstrapIconsModule,
],
})
@@ -49,21 +45,6 @@ export class CustomFieldEditDialogComponent
implements OnInit, AfterViewInit
{
CustomFieldDataType = CustomFieldDataType
SELECT_OPTION_PAGE_SIZE = SELECT_OPTION_PAGE_SIZE
private _allSelectOptions: any[] = []
public get allSelectOptions(): any[] {
return this._allSelectOptions
}
private _selectOptionsPage: number
public get selectOptionsPage(): number {
return this._selectOptionsPage
}
public set selectOptionsPage(v: number) {
this._selectOptionsPage = v
this.updateSelectOptions()
}
@ViewChildren('selectOption')
private selectOptionInputs: QueryList<ElementRef>
@@ -86,10 +67,17 @@ export class CustomFieldEditDialogComponent
this.objectForm.get('data_type').disable()
}
if (this.object?.data_type === CustomFieldDataType.Select) {
this._allSelectOptions = [
...(this.object.extra_data.select_options ?? []),
]
this.selectOptionsPage = 1
this.selectOptions.clear()
this.object.extra_data.select_options
.filter((option) => option)
.forEach((option) =>
this.selectOptions.push(
new FormGroup({
label: new FormControl(option.label),
id: new FormControl(option.id),
})
)
)
}
}
@@ -99,19 +87,6 @@ export class CustomFieldEditDialogComponent
.subscribe(() => {
this.selectOptionInputs.last?.nativeElement.focus()
})
this.objectForm.valueChanges
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((change) => {
// Update the relevant select options values if changed in the form, which is only a page of the entire list
this.objectForm
.get('extra_data.select_options')
?.value.forEach((option, index) => {
this._allSelectOptions[
index + (this.selectOptionsPage - 1) * SELECT_OPTION_PAGE_SIZE
] = option
})
})
}
getCreateTitle() {
@@ -133,17 +108,6 @@ export class CustomFieldEditDialogComponent
})
}
protected getFormValues() {
const formValues = super.getFormValues()
if (
this.objectForm.get('data_type')?.value === CustomFieldDataType.Select
) {
// Make sure we send all select options, with updated values
formValues.extra_data.select_options = this._allSelectOptions
}
return formValues
}
getDataTypes() {
return DATA_TYPE_LABELS
}
@@ -152,41 +116,13 @@ export class CustomFieldEditDialogComponent
return this.dialogMode === EditDialogMode.EDIT
}
private updateSelectOptions() {
this.selectOptions.clear()
this._allSelectOptions
.slice(
(this.selectOptionsPage - 1) * SELECT_OPTION_PAGE_SIZE,
this.selectOptionsPage * SELECT_OPTION_PAGE_SIZE
)
.forEach((option) =>
this.selectOptions.push(
new FormGroup({
label: new FormControl(option.label),
id: new FormControl(option.id),
})
)
)
}
public addSelectOption() {
this._allSelectOptions.push({ label: null, id: null })
this.selectOptionsPage = Math.ceil(
this.allSelectOptions.length / SELECT_OPTION_PAGE_SIZE
this.selectOptions.push(
new FormGroup({ label: new FormControl(null), id: new FormControl(null) })
)
}
public removeSelectOption(index: number) {
const globalIndex =
index + (this.selectOptionsPage - 1) * SELECT_OPTION_PAGE_SIZE
this._allSelectOptions.splice(globalIndex, 1)
const totalPages = Math.max(
1,
Math.ceil(this._allSelectOptions.length / SELECT_OPTION_PAGE_SIZE)
)
const targetPage = Math.min(this.selectOptionsPage, totalPages)
this.selectOptionsPage = targetPage
this.selectOptions.removeAt(index)
}
}

View File

@@ -147,13 +147,9 @@ export abstract class EditDialogComponent<
)
}
protected getFormValues(): any {
return Object.assign({}, this.objectForm.value)
}
save() {
this.error = null
const formValues = this.getFormValues()
const formValues = Object.assign({}, this.objectForm.value)
const permissionsObject: PermissionsFormObject =
this.objectForm.get('permissions_form')?.value
if (permissionsObject) {

View File

@@ -12,8 +12,6 @@
<pngx-input-color i18n-title title="Color" formControlName="color" [error]="error?.color"></pngx-input-color>
<pngx-input-select i18n-title title="Parent" formControlName="parent" [items]="tags" [allowNull]="true" [error]="error?.parent"></pngx-input-select>
<pngx-input-check i18n-title title="Inbox tag" formControlName="is_inbox_tag" i18n-hint hint="Inbox tags are automatically assigned to all consumed documents."></pngx-input-check>
<pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
@if (patternRequired) {

View File

@@ -35,16 +35,11 @@ import { TextComponent } from '../../input/text/text.component'
],
})
export class TagEditDialogComponent extends EditDialogComponent<Tag> {
tags: Tag[]
constructor() {
super()
this.service = inject(TagService)
this.userService = inject(UserService)
this.settingsService = inject(SettingsService)
this.service.listAll().subscribe((result) => {
this.tags = result.results
})
}
getCreateTitle() {
@@ -60,7 +55,6 @@ export class TagEditDialogComponent extends EditDialogComponent<Tag> {
name: new FormControl(''),
color: new FormControl(randomColor()),
is_inbox_tag: new FormControl(false),
parent: new FormControl(null),
matching_algorithm: new FormControl(DEFAULT_MATCHING_ALGORITHM),
match: new FormControl(''),
is_insensitive: new FormControl(true),

View File

@@ -177,7 +177,6 @@
<pngx-input-tags [allowCreate]="false" i18n-title title="Has any of tags" formControlName="filter_has_tags"></pngx-input-tags>
<pngx-input-select i18n-title title="Has correspondent" [items]="correspondents" [allowNull]="true" formControlName="filter_has_correspondent"></pngx-input-select>
<pngx-input-select i18n-title title="Has document type" [items]="documentTypes" [allowNull]="true" formControlName="filter_has_document_type"></pngx-input-select>
<pngx-input-select i18n-title title="Has storage path" [items]="storagePaths" [allowNull]="true" formControlName="filter_has_storage_path"></pngx-input-select>
</div>
}
</div>

View File

@@ -412,9 +412,6 @@ export class WorkflowEditDialogComponent
filter_has_document_type: new FormControl(
trigger.filter_has_document_type
),
filter_has_storage_path: new FormControl(
trigger.filter_has_storage_path
),
schedule_offset_days: new FormControl(trigger.schedule_offset_days),
schedule_is_recurring: new FormControl(trigger.schedule_is_recurring),
schedule_recurring_interval_days: new FormControl(
@@ -539,7 +536,6 @@ export class WorkflowEditDialogComponent
filter_has_tags: [],
filter_has_correspondent: null,
filter_has_document_type: null,
filter_has_storage_path: null,
matching_algorithm: MATCH_NONE,
match: '',
is_insensitive: true,

View File

@@ -114,13 +114,6 @@ export class FilterableDropdownSelectionModel {
b.id == NEGATIVE_NULL_FILTER_VALUE)
) {
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 (
this.getNonTemporary(a.id) == ToggleableItemState.NotSelected &&
this.getNonTemporary(b.id) != ToggleableItemState.NotSelected

View File

@@ -15,17 +15,12 @@
<i-bs width="1em" height="1em" name="x"></i-bs>
}
</div>
<div class="me-1 name-cell" [style.--depth]="isTag ? getDepth() + 1 : 1">
@if (isTag && getDepth() > 0) {
<div class="indicator"></div>
<div class="me-1">
@if (isTag) {
<pngx-tag [tag]="item" [clickable]="false"></pngx-tag>
} @else {
<small>{{item.name}}</small>
}
<div>
@if (isTag) {
<pngx-tag [tag]="item" [clickable]="false"></pngx-tag>
} @else {
<small>{{item.name}}</small>
}
</div>
</div>
@if (!hideCount) {
<div class="badge bg-light text-dark rounded-pill ms-auto me-1">{{currentCount}}</div>

View File

@@ -2,19 +2,3 @@
min-width: 1em;
min-height: 1em;
}
.name-cell {
padding-left: calc(calc(var(--depth) - 2) * 1rem);
display: flex;
align-items: center;
.indicator {
display: inline-block;
width: .8rem;
height: .8rem;
border-left: 1px solid var(--bs-secondary);
border-bottom: 1px solid var(--bs-secondary);
margin-right: .25rem;
margin-left: .5rem;
}
}

View File

@@ -1,7 +1,6 @@
import { Component, EventEmitter, Input, Output } from '@angular/core'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { MatchingModel } from 'src/app/data/matching-model'
import { Tag } from 'src/app/data/tag'
import { TagComponent } from '../../tag/tag.component'
export enum ToggleableItemState {
@@ -46,10 +45,6 @@ export class ToggleableDropdownButtonComponent {
return 'is_inbox_tag' in this.item
}
getDepth(): number {
return (this.item as Tag).depth ?? 0
}
get currentCount(): number {
return this.count ?? this.item.document_count
}

View File

@@ -68,11 +68,6 @@
[allowNull]="true"
[horizontal]="true"></pngx-input-select>
}
@case (CustomFieldDataType.LongText) {
<pngx-input-textarea [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)"
[title]="getCustomField(fieldId)?.name"
class="flex-grow-1"></pngx-input-textarea>
}
}
<button type="button" class="btn btn-link text-danger" (click)="removeSelectedField.next(fieldId)">
<i-bs name="trash"></i-bs>

View File

@@ -24,7 +24,6 @@ import { MonetaryComponent } from '../monetary/monetary.component'
import { NumberComponent } from '../number/number.component'
import { SelectComponent } from '../select/select.component'
import { TextComponent } from '../text/text.component'
import { TextAreaComponent } from '../textarea/textarea.component'
import { UrlComponent } from '../url/url.component'
@Component({
@@ -52,7 +51,6 @@ import { UrlComponent } from '../url/url.component'
ReactiveFormsModule,
RouterModule,
NgxBootstrapIconsModule,
TextAreaComponent,
],
})
export class CustomFieldsValuesComponent extends AbstractInputComponent<Object> {

View File

@@ -19,6 +19,7 @@
[class.private]="isPrivate"
[clearable]="allowNull"
[items]="items"
[virtualScroll]="items?.length > 100"
[addTag]="allowCreateNew && addItemRef"
addTagText="Add item"
i18n-addTagText="Used for both types, correspondents, storage paths"

View File

@@ -7,14 +7,13 @@
<div class="input-group flex-nowrap">
<ng-select #tagSelect name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value"
[disabled]="disabled"
[multiple]="multiple"
[multiple]="true"
[closeOnSelect]="false"
[clearSearchOnAdd]="true"
[hideSelected]="tags.length > 0"
[addTag]="allowCreate ? createTagRef : false"
addTagText="Add tag"
i18n-addTagText
(add)="onAdd($event)"
(change)="onChange(value)">
<ng-template ng-label-tmp let-item="item">
@@ -26,20 +25,9 @@
</button>
</ng-template>
<ng-template ng-option-tmp let-item="item" let-index="index" let-search="searchTerm">
<div class="tag-option-row d-flex align-items-center">
<div class="tag-wrap">
@if (item.id && tags) {
@if (getTag(item.id)?.parent) {
<i-bs name="list-nested" class="me-1"></i-bs>
<span class="hierarchy-reveal d-flex align-items-center">
<span class="parents d-flex align-items-center">
@for (p of getParentChain(item.id); track p.id) {
<span class="badge me-1" [style.background]="p.color" [style.color]="p.text_color">{{p.name}}</span>
<i-bs name="chevron-right" width=".8em" height=".8em" class="me-1"></i-bs>
}
</span>
</span>
}
<pngx-tag class="current-tag d-flex" [tag]="getTag(item.id)"></pngx-tag>
<pngx-tag class="me-2" [tag]="getTag(item.id)"></pngx-tag>
}
</div>
</ng-template>

View File

@@ -20,33 +20,3 @@
}
}
}
// Dropdown hierarchy reveal for ng-select options
::ng-deep .ng-dropdown-panel .ng-option {
overflow-x: scroll;
.tag-option-row {
font-size: 1rem;
width: max-content;
}
.hierarchy-reveal {
overflow: hidden;
max-width: 0;
transition: max-width 200ms ease;
}
.parents .badge {
white-space: nowrap;
}
}
::ng-deep .ng-dropdown-panel .ng-option:hover .hierarchy-reveal,
::ng-deep .ng-dropdown-panel .ng-option.ng-option-marked .hierarchy-reveal {
max-width: 1000px;
}
::ng-deep .ng-dropdown-panel .ng-option:hover .hierarchy-indicator,
::ng-deep .ng-dropdown-panel .ng-option.ng-option-marked .hierarchy-indicator {
background: transparent;
}

View File

@@ -177,59 +177,4 @@ describe('TagsComponent', () => {
component.onFilterDocuments()
expect(emitSpy).toHaveBeenCalledWith([tags[2]])
})
it('should remove all descendants from selection', () => {
const c: Tag = { id: 4, name: 'c' }
const b: Tag = { id: 3, name: 'b', children: [c] }
const a: Tag = { id: 2, name: 'a' }
const root: Tag = { id: 1, name: 'root', children: [a, b] }
const inputIDs = [2, 3, 4, 99]
const result = (component as any).removeChildren(inputIDs, root)
expect(result).toEqual([99])
})
it('should append all parents recursively', () => {
const root: Tag = { id: 1, name: 'root' }
const mid: Tag = { id: 2, name: 'mid', parent: 1 }
const leaf: Tag = { id: 3, name: 'leaf', parent: 2 }
component.tags = [root, mid, leaf]
component.value = []
component.onAdd(leaf)
expect(component.value).toEqual([2, 1])
// Calling onAdd on a root should not change value
component.onAdd(root)
expect(component.value).toEqual([2, 1])
})
it('should return ancestors from root to parent using getParentChain', () => {
const root: Tag = { id: 1, name: 'root' }
const mid: Tag = { id: 2, name: 'mid', parent: 1 }
const leaf: Tag = { id: 3, name: 'leaf', parent: 2 }
component.tags = [root, mid, leaf]
expect(component.getParentChain(3).map((t) => t.id)).toEqual([1, 2])
expect(component.getParentChain(2).map((t) => t.id)).toEqual([1])
expect(component.getParentChain(1).map((t) => t.id)).toEqual([])
// Non-existent id
expect(component.getParentChain(999).map((t) => t.id)).toEqual([])
})
it('should handle cyclic parents via guard in getParentChain', () => {
const one: Tag = { id: 1, name: 'one', parent: 2 }
const two: Tag = { id: 2, name: 'two', parent: 1 }
component.tags = [one, two]
const chain = component.getParentChain(1)
// Guard avoids infinite loop; chain contains both nodes once
expect(chain.map((t) => t.id)).toEqual([1, 2])
})
it('should stop when parent does not exist in getParentChain', () => {
const lone: Tag = { id: 5, name: 'lone', parent: 999 }
component.tags = [lone]
expect(component.getParentChain(5)).toEqual([])
})
})

View File

@@ -100,9 +100,6 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
@Input()
horizontal: boolean = false
@Input()
multiple: boolean = true
@Output()
filterDocuments = new EventEmitter<Tag[]>()
@@ -127,40 +124,13 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
let index = this.value.indexOf(tagID)
if (index > -1) {
const tag = this.getTag(tagID)
// remove tag
let oldValue = this.value
oldValue.splice(index, 1)
// remove children
oldValue = this.removeChildren(oldValue, tag)
this.value = [...oldValue]
this.onChange(this.value)
}
}
private removeChildren(tagIDs: number[], tag: Tag) {
if (tag.children?.length) {
const childIDs = tag.children.map((child) => child.id)
tagIDs = tagIDs.filter((id) => !childIDs.includes(id))
for (const child of tag.children) {
tagIDs = this.removeChildren(tagIDs, child)
}
}
return tagIDs
}
public onAdd(tag: Tag) {
if (tag.parent) {
// add all parents recursively
const parent = this.getTag(tag.parent)
this.value = [...this.value, parent.id]
this.onAdd(parent)
}
}
createTag(name: string = null, add: boolean = false) {
var modal = this.modalService.open(TagEditDialogComponent, {
backdrop: 'static',
@@ -196,7 +166,6 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
addTag(id) {
this.value = [...this.value, id]
this.onAdd(this.getTag(id))
this.onChange(this.value)
}
@@ -211,20 +180,4 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
this.tags.filter((t) => this.value.includes(t.id))
)
}
getParentChain(id: number): Tag[] {
// Returns ancestors from root → immediate parent for a tag id
const chain: Tag[] = []
let current = this.getTag(id)
const guard = new Set<number>()
while (current?.parent) {
if (guard.has(current.parent)) break
guard.add(current.parent)
const parent = this.getTag(current.parent)
if (!parent) break
chain.unshift(parent)
current = parent
}
return chain
}
}

View File

@@ -4,7 +4,6 @@ import {
NG_VALUE_ACCESSOR,
ReactiveFormsModule,
} from '@angular/forms'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { AbstractInputComponent } from '../abstract-input'
@@ -19,12 +18,7 @@ import { AbstractInputComponent } from '../abstract-input'
selector: 'pngx-input-textarea',
templateUrl: './textarea.component.html',
styleUrls: ['./textarea.component.scss'],
imports: [
FormsModule,
ReactiveFormsModule,
SafeHtmlPipe,
NgxBootstrapIconsModule,
],
imports: [FormsModule, ReactiveFormsModule, SafeHtmlPipe],
})
export class TextAreaComponent extends AbstractInputComponent<string> {
@Input()

View File

@@ -30,7 +30,7 @@
<div class="page-item rounded p-2" cdkDrag (click)="toggleSelection(i)" [class.selected]="p.selected">
<div class="btn-toolbar hover-actions z-10">
<div class="btn-group me-2">
<button class="btn btn-sm btn-dark" (click)="rotate(i, true); $event.stopPropagation()" title="Rotate page counter-clockwise" i18n-title>
<button class="btn btn-sm btn-dark" (click)="rotate(i); $event.stopPropagation()" title="Rotate page counter-clockwise" i18n-title>
<i-bs name="arrow-counterclockwise"></i-bs>
</button>
<button class="btn btn-sm btn-dark" (click)="rotate(i); $event.stopPropagation()" title="Rotate page clockwise" i18n-title>

View File

@@ -67,9 +67,8 @@ export class PDFEditorComponent extends ConfirmDialogComponent {
this.pages[i].selected = !this.pages[i].selected
}
rotate(i: number, counterclockwise: boolean = false) {
this.pages[i].rotate =
(this.pages[i].rotate + (counterclockwise ? -90 : 90) + 360) % 360
rotate(i: number) {
this.pages[i].rotate = (this.pages[i].rotate + 90) % 360
}
rotateSelected(dir: number) {

View File

@@ -254,18 +254,6 @@
<h6><ng-container i18n>Error</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.sanity_check_error}}</span>
}
</ng-template>
<dt i18n>WebSocket Connection</dt>
<dd>
<span class="btn btn-sm pe-none align-items-center btn-dark text-uppercase small">
@if (status.websocket_connected === 'OK') {
<ng-container i18n>OK</ng-container>
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
} @else {
<ng-container i18n>Error</ng-container>
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
}
</span>
</dd>
</dl>
</div>
</div>

View File

@@ -24,7 +24,7 @@ import {
} from '@angular/core/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { Subject, of, throwError } from 'rxjs'
import { of, throwError } from 'rxjs'
import { PaperlessTaskName } from 'src/app/data/paperless-task'
import {
InstallType,
@@ -34,7 +34,6 @@ import {
import { SystemStatusService } from 'src/app/services/system-status.service'
import { TasksService } from 'src/app/services/tasks.service'
import { ToastService } from 'src/app/services/toast.service'
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
import { SystemStatusDialogComponent } from './system-status-dialog.component'
const status: SystemStatus = {
@@ -78,8 +77,6 @@ describe('SystemStatusDialogComponent', () => {
let tasksService: TasksService
let systemStatusService: SystemStatusService
let toastService: ToastService
let websocketStatusService: WebsocketStatusService
let websocketSubject: Subject<boolean> = new Subject<boolean>()
beforeEach(async () => {
await TestBed.configureTestingModule({
@@ -101,12 +98,6 @@ describe('SystemStatusDialogComponent', () => {
tasksService = TestBed.inject(TasksService)
systemStatusService = TestBed.inject(SystemStatusService)
toastService = TestBed.inject(ToastService)
websocketStatusService = TestBed.inject(WebsocketStatusService)
jest
.spyOn(websocketStatusService, 'onConnectionStatus')
.mockImplementation(() => {
return websocketSubject.asObservable()
})
fixture.detectChanges()
})
@@ -177,19 +168,4 @@ describe('SystemStatusDialogComponent', () => {
component.ngOnInit()
expect(component.versionMismatch).toBeFalsy()
})
it('should update websocket connection status', () => {
websocketSubject.next(true)
expect(component.status.websocket_connected).toEqual(
SystemStatusItemStatus.OK
)
websocketSubject.next(false)
expect(component.status.websocket_connected).toEqual(
SystemStatusItemStatus.ERROR
)
websocketSubject.next(true)
expect(component.status.websocket_connected).toEqual(
SystemStatusItemStatus.OK
)
})
})

View File

@@ -1,5 +1,5 @@
import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard'
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
import { Component, OnInit, inject } from '@angular/core'
import {
NgbActiveModal,
NgbModalModule,
@@ -7,7 +7,6 @@ import {
NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subject, takeUntil } from 'rxjs'
import { PaperlessTaskName } from 'src/app/data/paperless-task'
import {
SystemStatus,
@@ -19,7 +18,6 @@ import { PermissionsService } from 'src/app/services/permissions.service'
import { SystemStatusService } from 'src/app/services/system-status.service'
import { TasksService } from 'src/app/services/tasks.service'
import { ToastService } from 'src/app/services/toast.service'
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
import { environment } from 'src/environments/environment'
@Component({
@@ -36,14 +34,13 @@ import { environment } from 'src/environments/environment'
NgxBootstrapIconsModule,
],
})
export class SystemStatusDialogComponent implements OnInit, OnDestroy {
export class SystemStatusDialogComponent implements OnInit {
activeModal = inject(NgbActiveModal)
private clipboard = inject(Clipboard)
private systemStatusService = inject(SystemStatusService)
private tasksService = inject(TasksService)
private toastService = inject(ToastService)
private permissionsService = inject(PermissionsService)
private websocketStatusService = inject(WebsocketStatusService)
public SystemStatusItemStatus = SystemStatusItemStatus
public PaperlessTaskName = PaperlessTaskName
@@ -54,7 +51,6 @@ export class SystemStatusDialogComponent implements OnInit, OnDestroy {
public copied: boolean = false
private runningTasks: Set<PaperlessTaskName> = new Set()
private unsubscribeNotifier: Subject<any> = new Subject()
get currentUserIsSuperUser(): boolean {
return this.permissionsService.isSuperUser()
@@ -69,17 +65,6 @@ export class SystemStatusDialogComponent implements OnInit, OnDestroy {
if (this.versionMismatch) {
this.status.pngx_version = `${this.status.pngx_version} (frontend: ${this.frontendVersion})`
}
this.status.websocket_connected = this.websocketStatusService.isConnected()
? SystemStatusItemStatus.OK
: SystemStatusItemStatus.ERROR
this.websocketStatusService
.onConnectionStatus()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((connected) => {
this.status.websocket_connected = connected
? SystemStatusItemStatus.OK
: SystemStatusItemStatus.ERROR
})
}
public close() {
@@ -112,7 +97,7 @@ export class SystemStatusDialogComponent implements OnInit, OnDestroy {
this.runningTasks.delete(taskName)
this.systemStatusService.get().subscribe({
next: (status) => {
Object.assign(this.status, status)
this.status = status
},
})
},
@@ -125,9 +110,4 @@ export class SystemStatusDialogComponent implements OnInit, OnDestroy {
},
})
}
ngOnDestroy(): void {
this.unsubscribeNotifier.next(this)
this.unsubscribeNotifier.complete()
}
}

View File

@@ -1,8 +1,4 @@
@if (tag) {
@if (showParents && tag.parent) {
<pngx-tag [tagID]="tag.parent" [clickable]="clickable" [linkTitle]="linkTitle"></pngx-tag>
&nbsp;&gt;&nbsp;
}
@if (!clickable) {
<span class="badge" [style.background]="tag.color" [style.color]="tag.text_color">{{tag.name}}</span>
}

View File

@@ -50,7 +50,4 @@ export class TagComponent {
@Input()
clickable: boolean = false
@Input()
showParents: boolean = false
}

View File

@@ -17,7 +17,7 @@
<i-bs width="0.9em" height="0.9em" name="exclamation-triangle"></i-bs>
}
<div>
<p class="ms-2 mb-0 text-break">{{toast.content}}</p>
<p class="ms-2 mb-0">{{toast.content}}</p>
@if (toast.error) {
<details class="ms-2">
<div class="mt-2 ms-n4 me-n2 small">

View File

@@ -54,10 +54,6 @@
<i-bs width="1em" height="1em" name="arrow-counterclockwise"></i-bs>&nbsp;<span i18n>Reprocess</span>
</button>
<button ngbDropdownItem (click)="printDocument()" [hidden]="useNativePdfViewer || isMobile">
<i-bs width="1em" height="1em" name="printer"></i-bs>&nbsp;<span i18n>Print</span>
</button>
<button ngbDropdownItem (click)="moreLike()">
<i-bs width="1em" height="1em" name="diagram-3"></i-bs>&nbsp;<span i18n>More like this</span>
</button>
@@ -216,14 +212,6 @@
(removed)="removeField(fieldInstance)"
[error]="getCustomFieldError(i)"></pngx-input-select>
}
@case (CustomFieldDataType.LongText) {
<pngx-input-textarea formControlName="value"
[title]="getCustomFieldFromInstance(fieldInstance)?.name"
[removable]="userCanEdit"
(removed)="removeField(fieldInstance)"
[horizontal]="true"
[error]="getCustomFieldError(i)"></pngx-input-textarea>
}
}
</div>
}

View File

@@ -1415,151 +1415,4 @@ describe('DocumentDetailComponent', () => {
.flush('fail', { status: 500, statusText: 'Server Error' })
expect(component.previewText).toContain('An error occurred loading content')
})
it('should print document successfully', fakeAsync(() => {
initNormally()
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 = {
focus: jest.fn(),
print: jest.fn(),
onafterprint: null,
}
const mockIframe = {
style: {},
src: '',
onload: null,
contentWindow: mockContentWindow,
}
const createElementSpy = jest
.spyOn(document, 'createElement')
.mockReturnValue(mockIframe as any)
const blob = new Blob(['test'], { type: 'application/pdf' })
component.printDocument()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/${doc.id}/download/`
)
req.flush(blob)
tick()
expect(createElementSpy).toHaveBeenCalledWith('iframe')
expect(appendChildSpy).toHaveBeenCalledWith(mockIframe)
expect(createObjectURLSpy).toHaveBeenCalledWith(blob)
if (mockIframe.onload) {
mockIframe.onload({} as any)
}
expect(mockContentWindow.focus).toHaveBeenCalled()
expect(mockContentWindow.print).toHaveBeenCalled()
if (mockIframe.onload) {
mockIframe.onload(new Event('load'))
}
if (mockContentWindow.onafterprint) {
mockContentWindow.onafterprint(new Event('afterprint'))
}
expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
createElementSpy.mockRestore()
appendChildSpy.mockRestore()
removeChildSpy.mockRestore()
createObjectURLSpy.mockRestore()
revokeObjectURLSpy.mockRestore()
}))
it('should show error toast if print document fails', () => {
initNormally()
const toastSpy = jest.spyOn(toastService, 'showError')
component.printDocument()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/${doc.id}/download/`
)
req.error(new ErrorEvent('failed'))
expect(toastSpy).toHaveBeenCalledWith(
'Error loading document for printing.'
)
})
it('should show error toast if printing throws inside iframe', fakeAsync(() => {
initNormally()
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 toastSpy = jest.spyOn(toastService, 'showError')
const mockContentWindow = {
focus: jest.fn().mockImplementation(() => {
throw new Error('focus failed')
}),
print: jest.fn(),
onafterprint: null,
}
const mockIframe: any = {
style: {},
src: '',
onload: null,
contentWindow: mockContentWindow,
}
const createElementSpy = jest
.spyOn(document, 'createElement')
.mockReturnValue(mockIframe as any)
const blob = new Blob(['test'], { type: 'application/pdf' })
component.printDocument()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/${doc.id}/download/`
)
req.flush(blob)
tick()
if (mockIframe.onload) {
mockIframe.onload(new Event('load'))
}
expect(toastSpy).toHaveBeenCalled()
expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
createElementSpy.mockRestore()
appendChildSpy.mockRestore()
removeChildSpy.mockRestore()
createObjectURLSpy.mockRestore()
revokeObjectURLSpy.mockRestore()
}))
})

View File

@@ -98,7 +98,6 @@ import { PermissionsFormComponent } from '../common/input/permissions/permission
import { SelectComponent } from '../common/input/select/select.component'
import { TagsComponent } from '../common/input/tags/tags.component'
import { TextComponent } from '../common/input/text/text.component'
import { TextAreaComponent } from '../common/input/textarea/textarea.component'
import { UrlComponent } from '../common/input/url/url.component'
import { PageHeaderComponent } from '../common/page-header/page-header.component'
import {
@@ -174,7 +173,6 @@ export enum ZoomSetting {
NgbDropdownModule,
NgxBootstrapIconsModule,
PdfViewerModule,
TextAreaComponent,
],
})
export class DocumentDetailComponent
@@ -293,10 +291,6 @@ export class DocumentDetailComponent
return this.settings.get(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER)
}
get isMobile(): boolean {
return this.deviceDetectorService.isMobile()
}
get archiveContentRenderType(): ContentRenderType {
return this.document?.archived_file_name
? this.getRenderType('application/pdf')
@@ -909,7 +903,6 @@ export class DocumentDetailComponent
.patch(this.getChangedFields())
.pipe(
switchMap((updateResult) => {
this.savedViewService.maybeRefreshDocumentCounts()
return this.documentListViewService.getNext(this.documentId).pipe(
map((nextDocId) => ({ nextDocId, updateResult })),
takeUntil(this.unsubscribeNotifier)
@@ -1425,44 +1418,6 @@ export class DocumentDetailComponent
})
}
printDocument() {
const printUrl = this.documentsService.getDownloadUrl(
this.document.id,
false
)
this.http
.get(printUrl, { responseType: 'blob' })
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (blob) => {
const blobUrl = URL.createObjectURL(blob)
const iframe = document.createElement('iframe')
iframe.style.display = 'none'
iframe.src = blobUrl
document.body.appendChild(iframe)
iframe.onload = () => {
try {
iframe.contentWindow.focus()
iframe.contentWindow.print()
iframe.contentWindow.onafterprint = () => {
document.body.removeChild(iframe)
URL.revokeObjectURL(blobUrl)
}
} catch (err) {
this.toastService.showError($localize`Print failed.`, err)
document.body.removeChild(iframe)
URL.revokeObjectURL(blobUrl)
}
}
},
error: () => {
this.toastService.showError(
$localize`Error loading document for printing.`
)
},
})
}
public openShareLinks() {
const modal = this.modalService.open(ShareLinksDialogComponent)
modal.componentInstance.documentId = this.document.id

View File

@@ -1,144 +1,161 @@
<div class="d-flex flex-wrap gap-4">
<div class="d-flex flex-wrap align-items-center gap-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
<label class="me-2" i18n>Edit:</label>
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
<pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title
filterPlaceholder="Filter tags" i18n-filterPlaceholder
[disabled]="!userCanEditAll || disabled"
[editing]="true"
[applyOnClose]="applyOnClose"
[createRef]="createTag.bind(this)"
(opened)="openTagsDropdown()"
[(selectionModel)]="tagSelectionModel"
[documentCounts]="tagDocumentCounts"
(apply)="setTags($event)"
shortcutKey="t">
</pngx-filterable-dropdown>
}
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
<pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
[disabled]="!userCanEditAll || disabled"
[editing]="true"
[applyOnClose]="applyOnClose"
[createRef]="createCorrespondent.bind(this)"
(opened)="openCorrespondentDropdown()"
[(selectionModel)]="correspondentSelectionModel"
[documentCounts]="correspondentDocumentCounts"
(apply)="setCorrespondents($event)"
shortcutKey="y">
</pngx-filterable-dropdown>
}
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
<pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title
filterPlaceholder="Filter document types" i18n-filterPlaceholder
[disabled]="!userCanEditAll || disabled"
[editing]="true"
[applyOnClose]="applyOnClose"
[createRef]="createDocumentType.bind(this)"
(opened)="openDocumentTypeDropdown()"
[(selectionModel)]="documentTypeSelectionModel"
[documentCounts]="documentTypeDocumentCounts"
(apply)="setDocumentTypes($event)"
shortcutKey="u">
</pngx-filterable-dropdown>
}
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
<pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
[disabled]="!userCanEditAll || disabled"
[editing]="true"
[applyOnClose]="applyOnClose"
[createRef]="createStoragePath.bind(this)"
(opened)="openStoragePathDropdown()"
[(selectionModel)]="storagePathsSelectionModel"
[documentCounts]="storagePathDocumentCounts"
(apply)="setStoragePaths($event)"
shortcutKey="i">
</pngx-filterable-dropdown>
}
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) {
<pngx-filterable-dropdown title="Custom fields" icon="ui-radios" i18n-title
filterPlaceholder="Filter custom fields" i18n-filterPlaceholder
[disabled]="!userCanEditAll"
[editing]="true"
[applyOnClose]="applyOnClose"
[createRef]="createCustomField.bind(this)"
(opened)="openCustomFieldsDropdown()"
[(selectionModel)]="customFieldsSelectionModel"
[documentCounts]="customFieldDocumentCounts"
extraButtonTitle="Set values"
i18n-extraButtonTitle
(extraButton)="setCustomFieldValues($event)"
(apply)="setCustomFields($event)">
</pngx-filterable-dropdown>
}
<div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || !userCanEditAll">
<i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Permissions</ng-container></div>
<div class="d-flex align-items-center" role="group" aria-label="Select">
<button class="btn btn-sm btn-outline-secondary" (click)="list.selectNone()">
<i-bs name="slash-circle"></i-bs>&nbsp;<ng-container i18n>Cancel</ng-container>
</button>
</div>
</div>
<div class="d-flex align-items-center gap-2 ms-auto">
<div class="btn-toolbar">
<div ngbDropdown>
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" [disabled]="!userCanEdit && !userCanAdd" ngbDropdownToggle>
<i-bs name="three-dots"></i-bs>
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Actions</ng-container></div>
</button>
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
<button ngbDropdownItem (click)="reprocessSelected()" [disabled]="!userCanEditAll && !userCanEditAll">
<i-bs name="body-text"></i-bs>&nbsp;<ng-container i18n>Reprocess</ng-container>
<div class="d-flex align-items-center gap-2" role="group" aria-label="Select">
<label class="me-2" i18n>Select:</label>
<div class="btn-group">
<button class="btn btn-sm btn-outline-primary" (click)="list.selectPage()">
<i-bs name="file-earmark-check"></i-bs>&nbsp;<ng-container i18n>Page</ng-container>
</button>
<button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll && !userCanEditAll">
<i-bs name="arrow-clockwise"></i-bs>&nbsp;<ng-container i18n>Rotate</ng-container>
</button>
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.selected.size < 2">
<i-bs name="journals"></i-bs>&nbsp;<ng-container i18n>Merge</ng-container>
</button>
</div>
</div>
</div>
<div class="btn-group btn-group-sm">
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()">
@if (!awaitingDownload) {
<i-bs name="arrow-down"></i-bs>
}
@if (awaitingDownload) {
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Preparing download...</span>
<button class="btn btn-sm btn-outline-primary" (click)="list.selectAll()">
<i-bs name="check-all"></i-bs>&nbsp;<ng-container i18n>All</ng-container>
</button>
</div>
}
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Download</ng-container></div>
</button>
<div ngbDropdown class="me-2 d-flex btn-group" role="group">
<button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle-split rounded-end" ngbDropdownToggle></button>
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
<form [formGroup]="downloadForm" class="px-3 py-1">
<p class="mb-1" i18n>Include:</p>
<div class="form-group ps-3 mb-2">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="downloadFileType_archive" formControlName="downloadFileTypeArchive" />
<label class="form-check-label" for="downloadFileType_archive" i18n>Archived files</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="downloadFileType_originals" formControlName="downloadFileTypeOriginals" />
<label class="form-check-label" for="downloadFileType_originals" i18n>Original files</label>
</div>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="downloadUseFormatting" formControlName="downloadUseFormatting" />
<label class="form-check-label" for="downloadUseFormatting" i18n>Use formatted filename</label>
</div>
</form>
</div>
</div>
</div>
<div class="d-flex align-items-center gap-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
<label class="me-2" i18n>Edit:</label>
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
<pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title
filterPlaceholder="Filter tags" i18n-filterPlaceholder
[disabled]="!userCanEditAll || disabled"
[editing]="true"
[applyOnClose]="applyOnClose"
[createRef]="createTag.bind(this)"
(opened)="openTagsDropdown()"
[(selectionModel)]="tagSelectionModel"
[documentCounts]="tagDocumentCounts"
(apply)="setTags($event)"
shortcutKey="t">
</pngx-filterable-dropdown>
}
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
<pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
[disabled]="!userCanEditAll || disabled"
[editing]="true"
[applyOnClose]="applyOnClose"
[createRef]="createCorrespondent.bind(this)"
(opened)="openCorrespondentDropdown()"
[(selectionModel)]="correspondentSelectionModel"
[documentCounts]="correspondentDocumentCounts"
(apply)="setCorrespondents($event)"
shortcutKey="y">
</pngx-filterable-dropdown>
}
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
<pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title
filterPlaceholder="Filter document types" i18n-filterPlaceholder
[disabled]="!userCanEditAll || disabled"
[editing]="true"
[applyOnClose]="applyOnClose"
[createRef]="createDocumentType.bind(this)"
(opened)="openDocumentTypeDropdown()"
[(selectionModel)]="documentTypeSelectionModel"
[documentCounts]="documentTypeDocumentCounts"
(apply)="setDocumentTypes($event)"
shortcutKey="u">
</pngx-filterable-dropdown>
}
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
<pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
[disabled]="!userCanEditAll || disabled"
[editing]="true"
[applyOnClose]="applyOnClose"
[createRef]="createStoragePath.bind(this)"
(opened)="openStoragePathDropdown()"
[(selectionModel)]="storagePathsSelectionModel"
[documentCounts]="storagePathDocumentCounts"
(apply)="setStoragePaths($event)"
shortcutKey="i">
</pngx-filterable-dropdown>
}
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) {
<pngx-filterable-dropdown title="Custom fields" icon="ui-radios" i18n-title
filterPlaceholder="Filter custom fields" i18n-filterPlaceholder
[disabled]="!userCanEditAll"
[editing]="true"
[applyOnClose]="applyOnClose"
[createRef]="createCustomField.bind(this)"
(opened)="openCustomFieldsDropdown()"
[(selectionModel)]="customFieldsSelectionModel"
[documentCounts]="customFieldDocumentCounts"
extraButtonTitle="Set values"
i18n-extraButtonTitle
(extraButton)="setCustomFieldValues($event)"
(apply)="setCustomFields($event)">
</pngx-filterable-dropdown>
}
</div>
<div class="d-flex align-items-center gap-2 ms-auto">
<div class="btn-toolbar">
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-sm btn-outline-danger" (click)="applyDelete()" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }" [disabled]="!userOwnsAll">
<i-bs name="trash"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
</button>
</div>
</div>
</div>
<button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || !userCanEditAll">
<i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Permissions</ng-container></div>
</button>
<div ngbDropdown>
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" [disabled]="!userCanEdit && !userCanAdd" ngbDropdownToggle>
<i-bs name="three-dots"></i-bs>
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Actions</ng-container></div>
</button>
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
<button ngbDropdownItem (click)="reprocessSelected()" [disabled]="!userCanEditAll && !userCanEditAll">
<i-bs name="body-text"></i-bs>&nbsp;<ng-container i18n>Reprocess</ng-container>
</button>
<button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll && !userCanEditAll">
<i-bs name="arrow-clockwise"></i-bs>&nbsp;<ng-container i18n>Rotate</ng-container>
</button>
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.selected.size < 2">
<i-bs name="journals"></i-bs>&nbsp;<ng-container i18n>Merge</ng-container>
</button>
</div>
</div>
</div>
<div class="btn-group btn-group-sm">
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()">
@if (!awaitingDownload) {
<i-bs name="arrow-down"></i-bs>
}
@if (awaitingDownload) {
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Preparing download...</span>
</div>
}
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Download</ng-container></div>
</button>
<div ngbDropdown class="me-2 d-flex btn-group" role="group">
<button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle-split rounded-end" ngbDropdownToggle></button>
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
<form [formGroup]="downloadForm" class="px-3 py-1">
<p class="mb-1" i18n>Include:</p>
<div class="form-group ps-3 mb-2">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="downloadFileType_archive" formControlName="downloadFileTypeArchive" />
<label class="form-check-label" for="downloadFileType_archive" i18n>Archived files</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="downloadFileType_originals" formControlName="downloadFileTypeOriginals" />
<label class="form-check-label" for="downloadFileType_originals" i18n>Original files</label>
</div>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="downloadUseFormatting" formControlName="downloadUseFormatting" />
<label class="form-check-label" for="downloadUseFormatting" i18n>Use formatted filename</label>
</div>
</form>
</div>
</div>
</div>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-sm btn-outline-danger" (click)="applyDelete()" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }" [disabled]="!userOwnsAll">
<i-bs name="trash"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
</button>
</div>
</div>
</div>

View File

@@ -5,7 +5,3 @@
.dropdown-menu{
--bs-dropdown-min-width: 12rem;
}
.btn-group .btn {
white-space: nowrap;
}

View File

@@ -1204,7 +1204,7 @@ describe('BulkEditorComponent', () => {
expect(tagListAllSpy).toHaveBeenCalled()
expect(tagSelectionModelToggleSpy).toHaveBeenCalledWith(newTag.id)
expect(component.tagSelectionModel.items).toMatchObject(
expect(component.tagSelectionModel.items).toEqual(
[{ id: null, name: 'Not assigned' }].concat(tags.results as any)
)
})

View File

@@ -37,7 +37,6 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { TagService } from 'src/app/services/rest/tag.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { flattenTags } from 'src/app/utils/flatten-tags'
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
@@ -165,10 +164,7 @@ export class BulkEditorComponent
this.tagService
.listAll()
.pipe(first())
.subscribe(
(result) =>
(this.tagSelectionModel.items = flattenTags(result.results))
)
.subscribe((result) => (this.tagSelectionModel.items = result.results))
}
if (
this.permissionService.currentUserCan(
@@ -652,7 +648,7 @@ export class BulkEditorComponent
)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(({ newTag, tags }) => {
this.tagSelectionModel.items = flattenTags(tags.results)
this.tagSelectionModel.items = tags.results
this.tagSelectionModel.toggle(newTag.id)
})
}

View File

@@ -56,10 +56,6 @@
[items]="field.extra_data.select_options" bindLabel="label" [allowNull]="true" [horizontal]="true">
</pngx-input-select>
}
@case (CustomFieldDataType.LongText) {
<pngx-input-textarea formControlName="{{field.id}}" class="w-100" [title]="field.name" [horizontal]="true">
</pngx-input-textarea>
}
}
<button type="button" class="btn btn-outline-danger mb-3" (click)="removeField(field.id)">
<i-bs name="x"></i-bs>

View File

@@ -18,7 +18,6 @@ import { TextComponent } from 'src/app/components/common/input/text/text.compone
import { UrlComponent } from 'src/app/components/common/input/url/url.component'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { DocumentService } from 'src/app/services/rest/document.service'
import { TextAreaComponent } from '../../../common/input/textarea/textarea.component'
@Component({
selector: 'pngx-custom-fields-bulk-edit-dialog',
@@ -36,7 +35,6 @@ import { TextAreaComponent } from '../../../common/input/textarea/textarea.compo
FormsModule,
ReactiveFormsModule,
NgxBootstrapIconsModule,
TextAreaComponent,
],
})
export class CustomFieldsBulkEditDialogComponent {

View File

@@ -1,36 +1,16 @@
<pngx-page-header [title]="getTitle()">
<div ngbDropdown class="btn-group flex-fill d-sm-none">
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
<div ngbDropdown class="btn-group flex-fill">
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle>
<i-bs name="text-indent-left"></i-bs>
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Select</ng-container></div>
@if (list.selected.size > 0) {
<pngx-clearable-badge [selected]="list.selected.size > 0" [number]="list.selected.size" (cleared)="list.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
}
</button>
<div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow">
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
<button ngbDropdownItem (click)="list.selectNone()" i18n>Select none</button>
<button ngbDropdownItem (click)="list.selectPage()" i18n>Select page</button>
<button ngbDropdownItem (click)="list.selectAll()" i18n>Select all</button>
</div>
</div>
<div class="d-none d-sm-flex flex-fill me-3">
<div class="input-group input-group-sm">
<span class="input-group-text border-0">Select:</span>
</div>
<div class="btn-group btn-group-sm flex-nowrap">
@if (list.selected.size > 0) {
<button class="btn btn-sm btn-outline-secondary" (click)="list.selectNone()">
<i-bs name="slash-circle"></i-bs>&nbsp;<ng-container i18n>None</ng-container>
</button>
}
<button class="btn btn-sm btn-outline-primary" (click)="list.selectPage()">
<i-bs name="file-earmark-check"></i-bs>&nbsp;<ng-container i18n>Page</ng-container>
</button>
<button class="btn btn-sm btn-outline-primary" (click)="list.selectAll()">
<i-bs name="check-all"></i-bs>&nbsp;<ng-container i18n>All</ng-container>
</button>
</div>
</div>
<div ngbDropdown class="btn-group flex-fill">
<button class="btn btn-sm btn-outline-primary" id="dropdownDisplayFields" ngbDropdownToggle>
<i-bs name="card-heading"></i-bs>
@@ -146,13 +126,8 @@
@if (!list.isReloading && isFiltered) {
<button class="btn btn-link py-0" (click)="resetFilters()">
<i-bs width="1em" height="1em" name="x"></i-bs><small i18n>Reset filters</small>
</button>
}
@if (!list.isReloading && list.selected.size > 0) {
<button class="btn btn-link py-0" (click)="list.selectNone()">
<i-bs width="1em" height="1em" name="slash-circle" class="me-1"></i-bs><small i18n>Clear selection</small>
</button>
}
</button>
}
</div>
@if (list.collectionSize) {
<ngb-pagination [pageSize]="list.pageSize" [collectionSize]="list.collectionSize" [(page)]="list.currentPage" [maxSize]="5"

View File

@@ -56,7 +56,6 @@ import {
filterRulesDiffer,
isFullTextFilterRule,
} from 'src/app/utils/filter-rules'
import { ClearableBadgeComponent } from '../common/clearable-badge/clearable-badge.component'
import { CustomFieldDisplayComponent } from '../common/custom-field-display/custom-field-display.component'
import { PageHeaderComponent } from '../common/page-header/page-header.component'
import { PreviewPopupComponent } from '../common/preview-popup/preview-popup.component'
@@ -73,7 +72,6 @@ import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-vi
templateUrl: './document-list.component.html',
styleUrls: ['./document-list.component.scss'],
imports: [
ClearableBadgeComponent,
CustomFieldDisplayComponent,
PageHeaderComponent,
BulkEditorComponent,

View File

@@ -589,7 +589,7 @@ describe('FilterEditorComponent', () => {
expect(component.tagSelectionModel.logicalOperator).toEqual(
LogicalOperator.And
)
expect(component.tagSelectionModel.getSelectedItems()).toMatchObject(tags)
expect(component.tagSelectionModel.getSelectedItems()).toEqual(tags)
// coverage
component.filterRules = [
{
@@ -615,7 +615,7 @@ describe('FilterEditorComponent', () => {
expect(component.tagSelectionModel.logicalOperator).toEqual(
LogicalOperator.Or
)
expect(component.tagSelectionModel.getSelectedItems()).toMatchObject(tags)
expect(component.tagSelectionModel.getSelectedItems()).toEqual(tags)
// coverage
component.filterRules = [
{
@@ -652,7 +652,7 @@ describe('FilterEditorComponent', () => {
expect(component.tagSelectionModel.logicalOperator).toEqual(
LogicalOperator.And
)
expect(component.tagSelectionModel.getExcludedItems()).toMatchObject(tags)
expect(component.tagSelectionModel.getExcludedItems()).toEqual(tags)
// coverage
component.filterRules = [
{

View File

@@ -97,7 +97,6 @@ import {
CustomFieldQueryExpression,
} from 'src/app/utils/custom-field-query-element'
import { filterRulesDiffer } from 'src/app/utils/filter-rules'
import { flattenTags } from 'src/app/utils/flatten-tags'
import {
CustomFieldQueriesModel,
CustomFieldsQueryDropdownComponent,
@@ -1135,7 +1134,7 @@ export class FilterEditorComponent
) {
this.loadingCountTotal++
this.tagService.listAll().subscribe((result) => {
this.tagSelectionModel.items = flattenTags(result.results)
this.tagSelectionModel.items = result.results
this.maybeCompleteLoading()
})
}

View File

@@ -1,4 +1,4 @@
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
import { NgClass, TitleCasePipe } from '@angular/common'
import { Component, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import {
@@ -30,7 +30,6 @@ import { ManagementListComponent } from '../management-list/management-list.comp
FormsModule,
ReactiveFormsModule,
NgClass,
NgTemplateOutlet,
NgbDropdownModule,
NgbPaginationModule,
NgxBootstrapIconsModule,

View File

@@ -1,4 +1,4 @@
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
import { NgClass, TitleCasePipe } from '@angular/common'
import { Component, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import {
@@ -28,7 +28,6 @@ import { ManagementListComponent } from '../management-list/management-list.comp
FormsModule,
ReactiveFormsModule,
NgClass,
NgTemplateOutlet,
NgbDropdownModule,
NgbPaginationModule,
NgxBootstrapIconsModule,

View File

@@ -109,11 +109,10 @@
<li class="list-group-item">
<div class="row">
<div class="col" i18n>Name</div>
<div class="col-1 d-none d-sm-block" i18n>Sort Order</div>
<div class="col-2" i18n>Account</div>
<div class="col-2 d-none d-sm-block" i18n>Status</div>
<div class="col d-none d-sm-block" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.ProcessedMail }">Processed Mail</div>
<div class="col-3" i18n>Actions</div>
<div class="col d-none d-sm-block" i18n>Sort Order</div>
<div class="col" i18n>Account</div>
<div class="col d-none d-sm-block" i18n>Status</div>
<div class="col" i18n>Actions</div>
</div>
</li>
@@ -128,9 +127,9 @@
<li class="list-group-item">
<div class="row fade" [class.show]="showRules">
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editMailRule(rule)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailRule) || !userCanEdit(rule)">{{rule.name}}</button></div>
<div class="col-1 d-flex align-items-center d-none d-sm-flex">{{rule.order}}</div>
<div class="col-2 d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div>
<div class="col-2 d-flex align-items-center d-none d-sm-flex">
<div class="col d-flex align-items-center d-none d-sm-flex">{{rule.order}}</div>
<div class="col d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div>
<div class="col d-flex align-items-center d-none d-sm-flex">
<div class="form-check form-switch mb-0">
<input #inputField type="checkbox" class="form-check-input cursor-pointer" [id]="rule.id+'_enable'" [(ngModel)]="rule.enabled" (change)="onMailRuleEnableToggled(rule)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailRule }">
<label class="form-check-label cursor-pointer" [for]="rule.id+'_enable'">
@@ -138,12 +137,7 @@
</label>
</div>
</div>
<div class="col d-flex align-items-center d-none d-sm-flex" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.ProcessedMail }">
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="viewProcessedMail(rule)">
<i-bs width="1em" height="1em" name="clock-history"></i-bs>&nbsp;<ng-container i18n>View Processed Mail</ng-container>
</button>
</div>
<div class="col-3">
<div class="col">
<div class="btn-group d-block d-sm-none">
<div ngbDropdown container="body" class="d-inline-block">
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>

View File

@@ -409,13 +409,4 @@ describe('MailComponent', () => {
jest.advanceTimersByTime(200)
expect(editSpy).toHaveBeenCalled()
})
it('should open processed mails dialog', () => {
completeSetup()
let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
component.viewProcessedMail(mailRules[0] as MailRule)
const dialog = modal.componentInstance as any
expect(dialog.rule).toEqual(mailRules[0])
})
})

View File

@@ -27,7 +27,6 @@ import { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule-
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
import { ProcessedMailDialogComponent } from './processed-mail-dialog/processed-mail-dialog.component'
@Component({
selector: 'pngx-mail',
@@ -348,14 +347,6 @@ export class MailComponent
)
}
viewProcessedMail(rule: MailRule) {
const modal = this.modalService.open(ProcessedMailDialogComponent, {
backdrop: 'static',
size: 'xl',
})
modal.componentInstance.rule = rule
}
userCanEdit(obj: ObjectWithPermissions): boolean {
return this.permissionsService.currentUserHasObjectPermissions(
PermissionAction.Change,

View File

@@ -1,107 +0,0 @@
<div class="modal-header">
<h6 class="modal-title" id="modal-basic-title" i18n>Processed Mail for <em>{{ rule.name }}</em></h6>
<button class="btn btn-sm btn-link text-muted me-auto p-0 p-md-2" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true">
<i-bs name="question-circle"></i-bs>
</button>
<ng-template #infoPopover>
<a href="https://docs.paperless-ngx.com/usage#processed-mail" target="_blank" referrerpolicy="noopener noreferrer" i18n>Read more</a>
<i-bs class="ms-1" width=".8em" height=".8em" name="box-arrow-up-right"></i-bs>
</ng-template>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body">
@if (loading) {
<div class="text-center my-5">
<div class="spinner-border" role="status">
<span class="visually-hidden" i18n>Loading...</span>
</div>
</div>
} @else if (processedMails.length === 0) {
<span i18n>No processed email messages found.</span>
} @else {
<div class="table-responsive">
<table class="table table-hover table-sm align-middle">
<thead>
<tr>
<th scope="col" style="width: 40px;">
<div class="form-check m-0 ms-2 me-n2">
<input type="checkbox" class="form-check-input" id="all-objects" [(ngModel)]="toggleAllEnabled" [disabled]="processedMails.length === 0" (click)="toggleAll($event); $event.stopPropagation();">
<label class="form-check-label" for="all-objects"></label>
</div>
</th>
<th scope="col" i18n>Subject</th>
<th scope="col" i18n>Received</th>
<th scope="col" i18n>Processed</th>
<th scope="col" i18n>Status</th>
<th scope="col" i18n>Error</th>
</tr>
</thead>
<tbody>
@for (mail of processedMails; track mail.id) {
<ng-template #statusTooltip>
<div class="small text-light font-monospace">
{{mail.status}}
</div>
</ng-template>
<tr>
<td>
<div class="form-check m-0 ms-2 me-n2">
<input type="checkbox" class="form-check-input" [id]="mail.id" [checked]="selectedMailIds.has(mail.id)" (click)="toggleSelected(mail); $event.stopPropagation();">
<label class="form-check-label" [for]="mail.id"></label>
</div>
</td>
<td>{{ mail.subject }}</td>
<td>{{ mail.received | customDate:'longDate' }}</td>
<td>{{ mail.processed | customDate:'longDate' }}</td>
<td>
@switch (mail.status) {
@case ('SUCCESS') {
<i-bs name="check-circle" title="SUCCESS" class="text-success" [ngbTooltip]="statusTooltip"></i-bs>
}
@case ('FAILED') {
<i-bs name="exclamation-triangle" title="FAILED" class="text-danger" [ngbTooltip]="statusTooltip"></i-bs>
}
@default {
<i-bs name="slash-circle" title="{{ mail.status }}" class="text-muted" [ngbTooltip]="statusTooltip"></i-bs>
}
}
</td>
<td>
<ng-template #errorPopover>
<pre class="small text-light">
{{ mail.error }}
</pre>
</ng-template>
@if (mail.error) {
<span class="text-danger" triggers="mouseenter:mouseleave" [ngbPopover]="errorPopover">{{ mail.error | slice:0:20 }}</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="btn-toolbar">
<button type="button" class="btn btn-outline-secondary me-2" (click)="clearSelection()" [disabled]="selectedMailIds.size === 0" i18n>Clear</button>
<pngx-confirm-button
label="Delete selected"
i18n-label
title="Delete selected"
i18n-title
buttonClasses="btn-outline-danger"
iconName="trash"
[disabled]="selectedMailIds.size === 0"
(confirm)="deleteSelected()">
</pngx-confirm-button>
<div class="ms-auto">
<ngb-pagination
[collectionSize]="processedMails.length"
[(page)]="page"
[pageSize]="50"
[maxSize]="5"
(pageChange)="loadProcessedMails()">
</ngb-pagination>
</div>
</div>
}
</div>

View File

@@ -1,8 +0,0 @@
::ng-deep .popover {
max-width: 350px;
pre {
white-space: pre-wrap;
word-break: break-word;
}
}

View File

@@ -1,150 +0,0 @@
import { DatePipe } from '@angular/common'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule } from '@angular/forms'
import { By } from '@angular/platform-browser'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment'
import { ProcessedMailDialogComponent } from './processed-mail-dialog.component'
describe('ProcessedMailDialogComponent', () => {
let component: ProcessedMailDialogComponent
let fixture: ComponentFixture<ProcessedMailDialogComponent>
let httpTestingController: HttpTestingController
let toastService: ToastService
const rule: any = { id: 10, name: 'Mail Rule' } // minimal rule object for tests
const mails = [
{
id: 1,
rule: rule.id,
folder: 'INBOX',
uid: 111,
subject: 'A',
received: new Date().toISOString(),
processed: new Date().toISOString(),
status: 'SUCCESS',
error: null,
},
{
id: 2,
rule: rule.id,
folder: 'INBOX',
uid: 222,
subject: 'B',
received: new Date().toISOString(),
processed: new Date().toISOString(),
status: 'FAILED',
error: 'Oops',
},
]
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
ProcessedMailDialogComponent,
FormsModule,
NgxBootstrapIconsModule.pick(allIcons),
],
providers: [
DatePipe,
NgbActiveModal,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
}).compileComponents()
httpTestingController = TestBed.inject(HttpTestingController)
toastService = TestBed.inject(ToastService)
fixture = TestBed.createComponent(ProcessedMailDialogComponent)
component = fixture.componentInstance
component.rule = rule
})
afterEach(() => {
httpTestingController.verify()
})
function expectListRequest(ruleId: number) {
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}processed_mail/?page=1&page_size=50&ordering=-processed_at&rule=${ruleId}`
)
expect(req.request.method).toEqual('GET')
return req
}
it('should load processed mails on init', () => {
fixture.detectChanges()
const req = expectListRequest(rule.id)
req.flush({ count: 2, results: mails })
expect(component.loading).toBeFalsy()
expect(component.processedMails).toEqual(mails)
})
it('should delete selected mails and reload', () => {
fixture.detectChanges()
// initial load
const initialReq = expectListRequest(rule.id)
initialReq.flush({ count: 0, results: [] })
// select a couple of mails and delete
component.selectedMailIds.add(5)
component.selectedMailIds.add(6)
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
component.deleteSelected()
const delReq = httpTestingController.expectOne(
`${environment.apiBaseUrl}processed_mail/bulk_delete/`
)
expect(delReq.request.method).toEqual('POST')
expect(delReq.request.body).toEqual({ mail_ids: [5, 6] })
delReq.flush({})
// reload after delete
const reloadReq = expectListRequest(rule.id)
reloadReq.flush({ count: 0, results: [] })
expect(toastInfoSpy).toHaveBeenCalled()
})
it('should toggle all, toggle selected, and clear selection', () => {
fixture.detectChanges()
// initial load with two mails
const req = expectListRequest(rule.id)
req.flush({ count: 2, results: mails })
fixture.detectChanges()
// toggle all via header checkbox
const inputs = fixture.debugElement.queryAll(
By.css('input.form-check-input')
)
const header = inputs[0].nativeElement as HTMLInputElement
header.dispatchEvent(new Event('click'))
header.checked = true
header.dispatchEvent(new Event('click'))
expect(component.selectedMailIds.size).toEqual(mails.length)
// toggle a single mail
component.toggleSelected(mails[0] as any)
expect(component.selectedMailIds.has(mails[0].id)).toBeFalsy()
component.toggleSelected(mails[0] as any)
expect(component.selectedMailIds.has(mails[0].id)).toBeTruthy()
// clear selection
component.clearSelection()
expect(component.selectedMailIds.size).toEqual(0)
expect(component.toggleAllEnabled).toBeFalsy()
})
it('should close the dialog', () => {
const activeModal = TestBed.inject(NgbActiveModal)
const closeSpy = jest.spyOn(activeModal, 'close')
component.close()
expect(closeSpy).toHaveBeenCalled()
})
})

View File

@@ -1,96 +0,0 @@
import { SlicePipe } from '@angular/common'
import { Component, inject, Input, OnInit } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import {
NgbActiveModal,
NgbPagination,
NgbPopoverModule,
NgbTooltipModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { ConfirmButtonComponent } from 'src/app/components/common/confirm-button/confirm-button.component'
import { MailRule } from 'src/app/data/mail-rule'
import { ProcessedMail } from 'src/app/data/processed-mail'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { ProcessedMailService } from 'src/app/services/rest/processed-mail.service'
import { ToastService } from 'src/app/services/toast.service'
@Component({
selector: 'pngx-processed-mail-dialog',
imports: [
ConfirmButtonComponent,
CustomDatePipe,
NgbPagination,
NgbPopoverModule,
NgbTooltipModule,
NgxBootstrapIconsModule,
FormsModule,
ReactiveFormsModule,
SlicePipe,
],
templateUrl: './processed-mail-dialog.component.html',
styleUrl: './processed-mail-dialog.component.scss',
})
export class ProcessedMailDialogComponent implements OnInit {
private readonly activeModal = inject(NgbActiveModal)
private readonly processedMailService = inject(ProcessedMailService)
private readonly toastService = inject(ToastService)
public processedMails: ProcessedMail[] = []
public loading: boolean = true
public toggleAllEnabled: boolean = false
public readonly selectedMailIds: Set<number> = new Set<number>()
public page: number = 1
@Input() rule: MailRule
ngOnInit(): void {
this.loadProcessedMails()
}
public close() {
this.activeModal.close()
}
private loadProcessedMails(): void {
this.loading = true
this.clearSelection()
this.processedMailService
.list(this.page, 50, 'processed_at', true, { rule: this.rule.id })
.subscribe((result) => {
this.processedMails = result.results
this.loading = false
})
}
public deleteSelected(): void {
this.processedMailService
.bulk_delete(Array.from(this.selectedMailIds))
.subscribe(() => {
this.toastService.showInfo($localize`Processed mail(s) deleted`)
this.loadProcessedMails()
})
}
public toggleAll(event: PointerEvent) {
if ((event.target as HTMLInputElement).checked) {
this.selectedMailIds.clear()
this.processedMails.forEach((mail) => this.selectedMailIds.add(mail.id))
} else {
this.clearSelection()
}
}
public clearSelection() {
this.toggleAllEnabled = false
this.selectedMailIds.clear()
}
public toggleSelected(mail: ProcessedMail) {
this.selectedMailIds.has(mail.id)
? this.selectedMailIds.delete(mail.id)
: this.selectedMailIds.add(mail.id)
}
}

View File

@@ -54,7 +54,61 @@
</tr>
}
@for (object of data; track object) {
<ng-container [ngTemplateOutlet]="objectRow" [ngTemplateOutletContext]="{ object: object, depth: 0 }"></ng-container>
<tr (click)="toggleSelected(object); $event.stopPropagation();" class="data-row fade" [class.show]="show">
<td>
<div class="form-check m-0 ms-2 me-n2">
<input type="checkbox" class="form-check-input" id="{{typeName}}{{object.id}}" [checked]="selectedObjects.has(object.id)" (click)="toggleSelected(object); $event.stopPropagation();">
<label class="form-check-label" for="{{typeName}}{{object.id}}"></label>
</div>
</td>
<td scope="row"><button class="btn btn-link ms-0 ps-0 text-start" (click)="userCanEdit(object) ? openEditDialog(object) : null; $event.stopPropagation()">{{ object.name }}</button> </td>
<td scope="row" class="d-none d-sm-table-cell">{{ getMatching(object) }}</td>
<td scope="row">{{ object.document_count }}</td>
@for (column of extraColumns; track column) {
<td scope="row" [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }">
@if (column.rendersHtml) {
<div [innerHtml]="column.valueFn.call(null, object) | safeHtml"></div>
} @else if (column.monospace) {
<span class="font-monospace">{{ column.valueFn.call(null, object) }}</span>
} @else {
{{ column.valueFn.call(null, object) }}
}
</td>
}
<td scope="row">
<div class="btn-toolbar gap-2">
<div class="btn-group d-block d-sm-none">
<div ngbDropdown container="body" class="d-inline-block">
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
<i-bs name="three-dots-vertical"></i-bs>
</button>
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
<button (click)="openEditDialog(object)" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" ngbDropdownItem i18n>Edit</button>
<button class="text-danger" (click)="openDeleteDialog(object)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" ngbDropdownItem i18n>Delete</button>
@if (object.document_count > 0) {
<button (click)="filterDocuments(object)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }" ngbDropdownItem i18n>Filter Documents ({{ object.document_count }})</button>
}
</div>
</div>
</div>
<div class="btn-group d-none d-sm-inline-block">
<button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" [disabled]="!userCanEdit(object)">
<i-bs width="1em" height="1em" name="pencil"></i-bs>&nbsp;<ng-container i18n>Edit</ng-container>
</button>
<button class="btn btn-sm btn-outline-danger" (click)="openDeleteDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" [disabled]="!userCanDelete(object)">
<i-bs width="1em" height="1em" name="trash"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
</button>
</div>
@if (object.document_count > 0) {
<div class="btn-group d-none d-sm-inline-block">
<button class="btn btn-sm btn-outline-secondary" (click)="filterDocuments(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<i-bs width="1em" height="1em" name="filter"></i-bs>&nbsp;<ng-container i18n>Documents</ng-container><span class="badge bg-light text-secondary ms-2">{{ object.document_count }}</span>
</button>
</div>
}
</div>
</td>
</tr>
}
</tbody>
</table>
@@ -75,72 +129,3 @@
}
</div>
}
<ng-template #objectRow let-object="object" let-depth="depth">
<tr (click)="toggleSelected(object); $event.stopPropagation();" class="data-row fade" [class.show]="show">
<td>
<div class="form-check m-0 ms-2 me-n2">
<input type="checkbox" class="form-check-input" id="{{typeName}}{{object.id}}" [checked]="selectedObjects.has(object.id)" (click)="toggleSelected(object); $event.stopPropagation();">
<label class="form-check-label" for="{{typeName}}{{object.id}}"></label>
</div>
</td>
<td scope="row" class="name-cell" style="--depth: {{depth}}">
@if (depth > 0) {
<div class="indicator"></div>
}
<button class="btn btn-link ms-0 ps-0 text-start" (click)="userCanEdit(object) ? openEditDialog(object) : null; $event.stopPropagation()">{{ object.name }}</button>
</td>
<td scope="row" class="d-none d-sm-table-cell">{{ getMatching(object) }}</td>
<td scope="row">{{ getDocumentCount(object) }}</td>
@for (column of extraColumns; track column) {
<td scope="row" [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }">
@if (column.rendersHtml) {
<div [innerHtml]="column.valueFn.call(null, object) | safeHtml"></div>
} @else if (column.monospace) {
<span class="font-monospace">{{ column.valueFn.call(null, object) }}</span>
} @else {
{{ column.valueFn.call(null, object) }}
}
</td>
}
<td scope="row">
<div class="btn-toolbar gap-2">
<div class="btn-group d-block d-sm-none">
<div ngbDropdown container="body" class="d-inline-block">
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
<i-bs name="three-dots-vertical"></i-bs>
</button>
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
<button (click)="openEditDialog(object)" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" ngbDropdownItem i18n>Edit</button>
<button class="text-danger" (click)="openDeleteDialog(object)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" ngbDropdownItem i18n>Delete</button>
@if (getDocumentCount(object) > 0) {
<button (click)="filterDocuments(object)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }" ngbDropdownItem i18n>Filter Documents ({{ getDocumentCount(object) }})</button>
}
</div>
</div>
</div>
<div class="btn-group d-none d-sm-inline-block">
<button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" [disabled]="!userCanEdit(object)">
<i-bs width="1em" height="1em" name="pencil"></i-bs>&nbsp;<ng-container i18n>Edit</ng-container>
</button>
<button class="btn btn-sm btn-outline-danger" (click)="openDeleteDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" [disabled]="!userCanDelete(object)">
<i-bs width="1em" height="1em" name="trash"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
</button>
</div>
@if (getDocumentCount(object) > 0) {
<div class="btn-group d-none d-sm-inline-block">
<button class="btn btn-sm btn-outline-secondary" (click)="filterDocuments(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<i-bs width="1em" height="1em" name="filter"></i-bs>&nbsp;<ng-container i18n>Documents</ng-container><span class="badge bg-light text-secondary ms-2">{{ getDocumentCount(object) }}</span>
</button>
</div>
}
</div>
</td>
</tr>
@if (object.children && object.children.length > 0) {
@for (child of object.children; track child) {
<ng-container [ngTemplateOutlet]="objectRow" [ngTemplateOutletContext]="{ object: child, depth: depth + 1 }"></ng-container>
}
}
</ng-template>

View File

@@ -10,17 +10,3 @@ tbody tr:last-child td {
.form-check {
min-height: 0;
}
td.name-cell {
padding-left: calc(calc(var(--depth) - 1) * 1.1rem);
.indicator {
display: inline-block;
width: .8rem;
height: .8rem;
border-left: 1px solid var(--bs-secondary);
border-bottom: 1px solid var(--bs-secondary);
margin-right: .25rem;
margin-left: .5rem;
}
}

View File

@@ -79,7 +79,6 @@ export abstract class ManagementListComponent<T extends MatchingModel>
@ViewChildren(SortableDirective) headers: QueryList<SortableDirective>
public data: T[] = []
private unfilteredData: T[] = []
public page = 1
@@ -133,18 +132,6 @@ export abstract class ManagementListComponent<T extends MatchingModel>
this.reloadData()
}
protected filterData(data: T[]): T[] {
return data
}
getDocumentCount(object: MatchingModel): number {
return (
object.document_count ??
this.unfilteredData.find((d) => d.id == object.id)?.document_count ??
0
)
}
reloadData(extraParams: { [key: string]: any } = null) {
this.loading = true
this.clearSelection()
@@ -161,8 +148,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
.pipe(
takeUntil(this.unsubscribeNotifier),
tap((c) => {
this.unfilteredData = c.results
this.data = this.filterData(c.results)
this.data = c.results
this.collectionSize = c.count
}),
delay(100)

View File

@@ -1,4 +1,4 @@
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
import { NgClass, TitleCasePipe } from '@angular/common'
import { Component, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import {
@@ -30,7 +30,6 @@ import { ManagementListComponent } from '../management-list/management-list.comp
FormsModule,
ReactiveFormsModule,
NgClass,
NgTemplateOutlet,
NgbDropdownModule,
NgbPaginationModule,
NgxBootstrapIconsModule,

View File

@@ -71,20 +71,4 @@ describe('TagListComponent', () => {
'Do you really want to delete the tag "Tag1"?'
)
})
it('should filter out child tags if name filter is empty, otherwise show all', () => {
const tags = [
{ id: 1, name: 'Tag1', parent: null },
{ id: 2, name: 'Tag2', parent: 1 },
{ id: 3, name: 'Tag3', parent: null },
]
component['_nameFilter'] = null // Simulate empty name filter
const filtered = component.filterData(tags as any)
expect(filtered.length).toBe(2)
expect(filtered.find((t) => t.id === 2)).toBeUndefined()
component['_nameFilter'] = 'Tag2' // Simulate non-empty name filter
const filteredWithName = component.filterData(tags as any)
expect(filteredWithName.length).toBe(3)
})
})

View File

@@ -1,4 +1,4 @@
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
import { NgClass, TitleCasePipe } from '@angular/common'
import { Component, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import {
@@ -30,7 +30,6 @@ import { ManagementListComponent } from '../management-list/management-list.comp
FormsModule,
ReactiveFormsModule,
NgClass,
NgTemplateOutlet,
NgbDropdownModule,
NgbPaginationModule,
NgxBootstrapIconsModule,
@@ -60,10 +59,4 @@ export class TagListComponent extends ManagementListComponent<Tag> {
getDeleteMessage(object: Tag) {
return $localize`Do you really want to delete the tag "${object.name}"?`
}
filterData(data: Tag[]) {
return this.nameFilter?.length
? [...data]
: data.filter((tag) => !tag.parent)
}
}

View File

@@ -114,10 +114,6 @@ export const CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE = {
CustomFieldQueryOperatorGroups.Exact,
CustomFieldQueryOperatorGroups.Subset,
],
[CustomFieldDataType.LongText]: [
CustomFieldQueryOperatorGroups.Basic,
CustomFieldQueryOperatorGroups.String,
],
}
export const CUSTOM_FIELD_QUERY_VALUE_TYPES_BY_OPERATOR = {

View File

@@ -10,7 +10,6 @@ export enum CustomFieldDataType {
Monetary = 'monetary',
DocumentLink = 'documentlink',
Select = 'select',
LongText = 'longtext',
}
export const DATA_TYPE_LABELS = [
@@ -50,10 +49,6 @@ export const DATA_TYPE_LABELS = [
id: CustomFieldDataType.Select,
name: $localize`Select`,
},
{
id: CustomFieldDataType.LongText,
name: $localize`Long Text`,
},
]
export interface CustomField extends ObjectWithId {

View File

@@ -1,12 +0,0 @@
import { ObjectWithId } from './object-with-id'
export interface ProcessedMail extends ObjectWithId {
rule: number // MailRule.id
folder: string
uid: number
subject: string
received: Date
processed: Date
status: string
error: string
}

View File

@@ -44,5 +44,4 @@ export interface SystemStatus {
sanity_check_last_run: string // ISO date string
sanity_check_error: string
}
websocket_connected?: SystemStatusItemStatus // added client-side
}

View File

@@ -6,12 +6,4 @@ export interface Tag extends MatchingModel {
text_color?: string
is_inbox_tag?: boolean
parent?: number // Tag ID
children?: Tag[] // read-only
// UI-only: computed depth and order for hierarchical dropdowns
depth?: number
orderIndex?: number
}

View File

@@ -44,8 +44,6 @@ export interface WorkflowTrigger extends ObjectWithId {
filter_has_document_type?: number // DocumentType.id
filter_has_storage_path?: number // StoragePath.id
schedule_offset_days?: number
schedule_is_recurring?: boolean

View File

@@ -28,7 +28,6 @@ export enum PermissionType {
ShareLink = '%s_sharelink',
CustomField = '%s_customfield',
Workflow = '%s_workflow',
ProcessedMail = '%s_processedmail',
}
@Injectable({

View File

@@ -1,39 +0,0 @@
import { HttpTestingController } from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing'
import { Subscription } from 'rxjs'
import { environment } from 'src/environments/environment'
import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
import { ProcessedMailService } from './processed-mail.service'
let httpTestingController: HttpTestingController
let service: ProcessedMailService
let subscription: Subscription
const endpoint = 'processed_mail'
// run common tests
commonAbstractPaperlessServiceTests(endpoint, ProcessedMailService)
describe('Additional service tests for ProcessedMailService', () => {
beforeEach(() => {
// Dont need to setup again
httpTestingController = TestBed.inject(HttpTestingController)
service = TestBed.inject(ProcessedMailService)
})
afterEach(() => {
subscription?.unsubscribe()
httpTestingController.verify()
})
it('should call appropriate api endpoint for bulk delete', () => {
const ids = [1, 2, 3]
subscription = service.bulk_delete(ids).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/bulk_delete/`
)
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({ mail_ids: ids })
req.flush({})
})
})

View File

@@ -1,19 +0,0 @@
import { Injectable } from '@angular/core'
import { ProcessedMail } from 'src/app/data/processed-mail'
import { AbstractPaperlessService } from './abstract-paperless-service'
@Injectable({
providedIn: 'root',
})
export class ProcessedMailService extends AbstractPaperlessService<ProcessedMail> {
constructor() {
super()
this.resourceName = 'processed_mail'
}
public bulk_delete(mailIds: number[]) {
return this.http.post(`${this.getResourceUrl()}bulk_delete/`, {
mail_ids: mailIds,
})
}
}

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