Compare commits

..

16 Commits

Author SHA1 Message Date
shamoon
4feedf2add Merge branch 'dev' into feature-remote-ocr-2 2025-08-06 16:04:25 -04:00
shamoon
2f76cf9831 Merge branch 'dev' into feature-remote-ocr-2 2025-08-01 23:55:49 -04:00
shamoon
1002d37f6b Update test_parser.py 2025-07-09 11:05:37 -07:00
shamoon
d260a94740 Update parsers.py 2025-07-09 11:02:57 -07:00
shamoon
88c69b83ea Update index.md 2025-07-09 11:00:12 -07:00
shamoon
2557ee2014 Update docs to mention remote OCR with Azure AI 2025-07-09 09:53:30 -07:00
shamoon
3c75deed80 Add paperless_remote tests to testpaths 2025-07-08 14:19:45 -07:00
shamoon
d05343c927 Test fixes / coverage 2025-07-08 14:19:45 -07:00
shamoon
e7972b7eaf Coverage 2025-07-08 14:19:45 -07:00
shamoon
75a091cc0d Fix test 2025-07-08 14:19:44 -07:00
shamoon
dca74803fd Use output_content_format poller.result to get clean content 2025-07-08 14:19:44 -07:00
shamoon
3cf3d868d0 Some docs 2025-07-08 14:19:43 -07:00
shamoon
bf4fc6604a Test 2025-07-08 14:19:43 -07:00
shamoon
e8c1eb86fa This actually works
[ci skip]
2025-07-08 14:19:43 -07:00
shamoon
c3dad3cf69 Basic parse 2025-07-08 14:19:42 -07:00
shamoon
811bd66088 Ok, restart implementing this with just azure
[ci skip]
2025-07-08 14:19:42 -07:00
273 changed files with 59110 additions and 68472 deletions

View File

@@ -10,8 +10,10 @@ component_management:
paths:
- src-ui/**
# https://docs.codecov.com/docs/pull-request-comments
# codecov will only comment if coverage changes
comment:
layout: "header, diff, components, flags, files"
require_changes: true
# https://docs.codecov.com/docs/javascript-bundle-analysis
require_bundle_changes: true
bundle_change_threshold: "50Kb"

3
.codespellrc Normal file
View File

@@ -0,0 +1,3 @@
[codespell]
write-changes = True
ignore-words-list = criterias,afterall,valeu,ureue,equest,ure,assertIn

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

View File

@@ -1,10 +1,11 @@
{
"python.testing.pytestArgs": [],
"python.testing.pytestArgs": [
"src"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
"files.watcherExclude": {
"**/.venv/**": true,
"**/pytest_cache/**": true
},
"python.testing.cwd": "${workspaceFolder}/src"
}
}

View File

@@ -26,7 +26,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Install python
uses: actions/setup-python@v5
with:
@@ -40,7 +40,7 @@ jobs:
- pre-commit
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Set up Python
id: setup-python
uses: actions/setup-python@v5
@@ -90,7 +90,7 @@ jobs:
fail-fast: false
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Start containers
run: |
docker compose --file ${{ github.workspace }}/docker/compose/docker-compose.ci-test.yml pull --quiet
@@ -162,7 +162,7 @@ jobs:
needs:
- pre-commit
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
@@ -195,7 +195,7 @@ jobs:
shard-index: [1, 2, 3, 4]
shard-count: [4]
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
@@ -245,7 +245,7 @@ jobs:
shard-index: [1, 2]
shard-count: [2]
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
@@ -288,7 +288,7 @@ jobs:
- tests-frontend
- tests-frontend-e2e
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
@@ -316,7 +316,7 @@ jobs:
build-docker-image:
name: Build Docker image for ${{ github.ref_name }}
runs-on: ubuntu-24.04
if: github.event_name == 'push' && (startsWith(github.ref, 'refs/heads/feature-') || startsWith(github.ref, 'refs/heads/fix-') || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/beta' || contains(github.ref, 'beta.rc') || startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/heads/l10n_'))
if: github.event_name == 'push' && (startsWith(github.ref, 'refs/heads/feature-') || startsWith(github.ref, 'refs/heads/fix-') || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/beta' || contains(github.ref, 'beta.rc') || startsWith(github.ref, 'refs/tags/v'))
concurrency:
group: ${{ github.workflow }}-build-docker-image-${{ github.ref_name }}
cancel-in-progress: true
@@ -363,7 +363,7 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
# If https://github.com/docker/buildx/issues/1044 is resolved,
# the append input with a native arm64 arch could be used to
# significantly speed up building
@@ -433,7 +433,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Set up Python
id: setup-python
uses: actions/setup-python@v5
@@ -453,12 +453,12 @@ jobs:
sudo apt-get update -qq
sudo apt-get install -qq --no-install-recommends gettext liblept5
- name: Download frontend artifact
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
name: frontend-compiled
path: src/documents/static/frontend/
- name: Download documentation artifact
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
name: documentation
path: docs/_build/html/
@@ -538,7 +538,7 @@ jobs:
if: github.ref_type == 'tag' && (startsWith(github.ref_name, 'v') || contains(github.ref_name, '-beta.rc'))
steps:
- name: Download release artifact
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
name: release
path: ./
@@ -579,7 +579,7 @@ jobs:
if: needs.publish-release.outputs.prerelease == 'false'
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
ref: main
- name: Set up Python

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

@@ -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"
@@ -31,7 +31,7 @@ repos:
rev: v2.4.1
hooks:
- id: codespell
additional_dependencies: [tomli]
exclude: "(^src-ui/src/locale/)|(^src-ui/pnpm-lock.yaml)|(^src-ui/e2e/)|(^src/paperless_mail/tests/samples/)|(^src/documents/tests/samples/)"
exclude_types:
- pofile
- json
@@ -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

@@ -37,8 +37,6 @@ Before you can run `pytest`, ensure to [properly set up your local environment](
Once you have submitted a **P**ull **R**equest it will be reviewed, approved, and merged by one or more community members of any team. Automated code tests and formatting checks must be passed.
Important: 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. Instead of opening a PR which does not meet this requirement, please open a feature request instead, to gather feedback from both users and the project maintainers.
## Non-Trivial Requests
PRs deemed `non-trivial` will go through a stricter review process before being merged into `dev`. This is to ensure code quality and complete functionality (free of side effects).
@@ -111,12 +109,28 @@ Paperless-ngx is a community project. We do our best to delegate permission and
## Structure
There are currently 2 members in paperless-ngx with complete administrative privileges to the repo:
As of writing, there are 21 members in paperless-ngx. 4 of these people have complete administrative privileges to the repo:
- [@shamoon](https://github.com/shamoon)
- [@stumpylog](https://github.com/stumpylog)
- [@bauerj](https://github.com/bauerj)
- [@qcasey](https://github.com/qcasey)
- [@FrankStrieter](https://github.com/FrankStrieter)
There are other members who occasionally contribute but we are actively seeking more dedicated maintainers of the project. Please reach out if you are interested.
There are 5 teams collaborating on specific tasks within paperless-ngx:
- @paperless-ngx/backend (Python / django)
- @paperless-ngx/frontend (JavaScript / Typescript)
- @paperless-ngx/ci-cd (GitHub Actions / Deployment)
- @paperless-ngx/issues (Issue triage)
- @paperless-ngx/test (General testing for larger PRs)
## Permissions
All team members are notified when mentioned or assigned to a relevant issue or pull request. Additionally, each team has slightly different access to paperless-ngx:
- The **test** team has no special permissions.
- The **issues** team has `triage` access. This means they can organize issues and pull requests.
- The **backend**, **frontend**, and **ci-cd** teams have `write` access. This means they can approve PRs and push code, containers, releases, and more.
## Joining

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.17-python3.12-bookworm-slim AS s6-overlay-base
FROM ghcr.io/astral-sh/uv:0.8.4-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.20
hostname: gotenberg
container_name: gotenberg
network_mode: host

View File

@@ -35,7 +35,7 @@ services:
volumes:
- redisdata:/data
db:
image: docker.io/library/mariadb:12
image: docker.io/library/mariadb:11
restart: unless-stopped
volumes:
- dbdata:/var/lib/mysql
@@ -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.20
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

@@ -31,7 +31,7 @@ services:
volumes:
- redisdata:/data
db:
image: docker.io/library/mariadb:12
image: docker.io/library/mariadb:11
restart: unless-stopped
volumes:
- dbdata:/var/lib/mysql

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.20
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.20
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

@@ -179,14 +179,10 @@ following:
### Database Upgrades
Paperless-ngx is compatible with Django-supported versions of PostgreSQL and MariaDB and it is generally
In general, paperless does not require a specific version of PostgreSQL or MariaDB and it is
safe to update them to newer versions. However, you should always take a backup and follow
the instructions from your database's documentation for how to upgrade between major versions.
!!! note
As of Paperless-ngx v2.18, the minimum supported version of PostgreSQL is 14.
For PostgreSQL, refer to [Upgrading a PostgreSQL Cluster](https://www.postgresql.org/docs/current/upgrading.html).
For MariaDB, refer to [Upgrading MariaDB](https://mariadb.com/kb/en/upgrading/)
@@ -471,7 +467,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

@@ -434,134 +434,6 @@ provided. The template is provided as a string, potentially multiline, and rende
In addition, the entire Document instance is available to be utilized in a more advanced way, as well as some variables which only make sense to be accessed
with more complex logic.
#### Custom Jinja2 Filters
##### Custom Field Access
The `get_cf_value` filter retrieves a value from custom field data with optional default fallback.
###### Syntax
```jinja2
{{ custom_fields | get_cf_value('field_name') }}
{{ custom_fields | get_cf_value('field_name', 'default_value') }}
```
###### Parameters
- `custom_fields`: This _must_ be the provided custom field data
- `name` (str): Name of the custom field to retrieve
- `default` (str, optional): Default value to return if field is not found or has no value
###### Returns
- `str | None`: The field value, default value, or `None` if neither exists
###### Examples
```jinja2
<!-- Basic usage -->
{{ custom_fields | get_cf_value('department') }}
<!-- With default value -->
{{ custom_fields | get_cf_value('phone', 'Not provided') }}
```
##### Datetime Formatting
The `datetime` filter formats a datetime string or datetime object using Python's strftime formatting.
###### Syntax
```jinja2
{{ datetime_value | datetime('%Y-%m-%d %H:%M:%S') }}
```
###### Parameters
- `value` (str | datetime): Date/time value to format (strings will be parsed automatically)
- `format` (str): Python strftime format string
###### Returns
- `str`: Formatted datetime string
###### Examples
```jinja2
<!-- Format datetime object -->
{{ created | datetime('%B %d, %Y at %I:%M %p') }}
<!-- Output: "January 15, 2024 at 02:30 PM" -->
<!-- Custom formatting -->
{{ custom_fields | get_cf_value('Date Field') | datetime('%A, %B %d, %Y') }}
<!-- Output: "Monday, January 15, 2024" -->
```
See the [strftime format code documentation](https://docs.python.org/3.13/library/datetime.html#strftime-and-strptime-format-codes)
for the possible codes and their meanings.
##### Date Localization
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
```jinja2
{{ date_value | localize_date('medium', 'en_US') }}
{{ datetime_value | localize_date('short', 'fr_FR') }}
```
###### Parameters
- `value` (date | datetime | str): Date, datetime object or ISO string 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')
###### Returns
- `str`: Localized, formatted date string
###### Examples
```jinja2
<!-- Preset formats -->
{{ document.created | localize_date('short', 'en_US') }}
<!-- Output: "1/15/24" -->
{{ document.created | localize_date('medium', 'en_US') }}
<!-- Output: "Jan 15, 2024" -->
{{ document.created | localize_date('long', 'en_US') }}
<!-- Output: "January 15, 2024" -->
{{ document.created | localize_date('full', 'en_US') }}
<!-- Output: "Monday, January 15, 2024" -->
<!-- Different locales -->
{{ document.created | localize_date('medium', 'fr_FR') }}
<!-- Output: "15 janv. 2024" -->
{{ document.created | localize_date('medium', 'de_DE') }}
<!-- Output: "15.01.2024" -->
<!-- Custom patterns -->
{{ document.created | localize_date('dd/MM/yyyy', 'en_GB') }}
<!-- Output: "15/01/2024" -->
```
See the [supported format codes](https://unicode.org/reports/tr35/tr35-dates.html#Date_Format_Patterns) for more options.
### Format Presets
- **short**: Abbreviated format (e.g., "1/15/24")
- **medium**: Medium-length format (e.g., "Jan 15, 2024")
- **long**: Long format with full month name (e.g., "January 15, 2024")
- **full**: Full format including day of week (e.g., "Monday, January 15, 2024")
#### Additional Variables
- `{{ tag_name_list }}`: A list of tag names applied to the document, ordered by the tag name. Note this is a list, not a single string

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
@@ -282,18 +282,6 @@ The following methods are supported:
- `"merge": true or false` (defaults to false)
- The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including
removing them) or be merged with existing permissions.
- `edit_pdf`
- Requires `parameters`:
- `"doc_ids": [DOCUMENT_ID]` A list of a single document ID to edit.
- `"operations": [OPERATION, ...]` A list of operations to perform on the documents. Each operation is a dictionary
with the following keys:
- `"page": PAGE_NUMBER` The page number to edit (1-based).
- `"rotate": DEGREES` Optional rotation in degrees (90, 180, 270).
- `"doc": OUTPUT_DOCUMENT_INDEX` Optional index of the output document for split operations.
- Optional `parameters`:
- `"delete_original": true` to delete the original documents after editing.
- `"update_document": true` to update the existing document with the edited PDF.
- `"include_metadata": true` to copy metadata from the original document to the edited document.
- `merge`
- No additional `parameters` required.
- The ordering of the merged document is determined by the list of IDs.

View File

@@ -1,281 +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
- Fix: prevent loss of changes when switching between open docs [@shamoon](https://github.com/shamoon) ([#10659](https://github.com/paperless-ngx/paperless-ngx/pull/10659))
- Fix: ignore incomplete tasks for system status 'last run' [@shamoon](https://github.com/shamoon) ([#10641](https://github.com/paperless-ngx/paperless-ngx/pull/10641))
- Fix: increase legibility of date filter clear button in light mode [@shamoon](https://github.com/shamoon) ([#10649](https://github.com/paperless-ngx/paperless-ngx/pull/10649))
- Fix: ensure saved view count is visible with long names [@shamoon](https://github.com/shamoon) ([#10616](https://github.com/paperless-ngx/paperless-ngx/pull/10616))
- Tweak: improve dateparser auto-detection messages [@shamoon](https://github.com/shamoon) ([#10640](https://github.com/paperless-ngx/paperless-ngx/pull/10640))
### Dependencies
- Chore(deps): Bump the development group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10578](https://github.com/paperless-ngx/paperless-ngx/pull/10578))
### All App Changes
<details>
<summary>6 changes</summary>
- Fix: prevent loss of changes when switching between open docs [@shamoon](https://github.com/shamoon) ([#10659](https://github.com/paperless-ngx/paperless-ngx/pull/10659))
- Fix: ignore incomplete tasks for system status 'last run' [@shamoon](https://github.com/shamoon) ([#10641](https://github.com/paperless-ngx/paperless-ngx/pull/10641))
- Tweak: improve dateparser auto-detection messages [@shamoon](https://github.com/shamoon) ([#10640](https://github.com/paperless-ngx/paperless-ngx/pull/10640))
- Fix: increase legibility of date filter clear button in light mode [@shamoon](https://github.com/shamoon) ([#10649](https://github.com/paperless-ngx/paperless-ngx/pull/10649))
- Fix: ensure saved view count is visible with long names [@shamoon](https://github.com/shamoon) ([#10616](https://github.com/paperless-ngx/paperless-ngx/pull/10616))
- Chore(deps): Bump the development group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10578](https://github.com/paperless-ngx/paperless-ngx/pull/10578))
</details>
## paperless-ngx 2.18.1
### Features / Enhancements
- Tweak: fix some button consistency [@shamoon](https://github.com/shamoon) ([#10593](https://github.com/paperless-ngx/paperless-ngx/pull/10593))
- Fixhancement: mobile layout improvements for pdf editor [@shamoon](https://github.com/shamoon) ([#10588](https://github.com/paperless-ngx/paperless-ngx/pull/10588))
### Bug Fixes
- Fix: fix app logo validation with no file [@shamoon](https://github.com/shamoon) ([#10599](https://github.com/paperless-ngx/paperless-ngx/pull/10599))
### Documentation
- Documentation: fix filters docs [@shamoon](https://github.com/shamoon) ([#10600](https://github.com/paperless-ngx/paperless-ngx/pull/10600))
### All App Changes
<details>
<summary>4 changes</summary>
- Fix: fix app logo validation with no file [@shamoon](https://github.com/shamoon) ([#10599](https://github.com/paperless-ngx/paperless-ngx/pull/10599))
- Tweak: fix some button consistency [@shamoon](https://github.com/shamoon) ([#10593](https://github.com/paperless-ngx/paperless-ngx/pull/10593))
- Development: restore version tag display [@shamoon](https://github.com/shamoon) ([#10592](https://github.com/paperless-ngx/paperless-ngx/pull/10592))
- Fixhancement: mobile layout improvements for pdf editor [@shamoon](https://github.com/shamoon) ([#10588](https://github.com/paperless-ngx/paperless-ngx/pull/10588))
</details>
## paperless-ngx 2.18.0
### Notable Changes
- Feature: PDF editor [@shamoon](https://github.com/shamoon) ([#10318](https://github.com/paperless-ngx/paperless-ngx/pull/10318))
### Features / Enhancements
- Feature: Add filter to localize dates for filepath templating [@stumpylog](https://github.com/stumpylog) ([#10559](https://github.com/paperless-ngx/paperless-ngx/pull/10559))
- Feature: PDF editor [@shamoon](https://github.com/shamoon) ([#10318](https://github.com/paperless-ngx/paperless-ngx/pull/10318))
- Enhancement: support webhook restrictions [@shamoon](https://github.com/shamoon) ([#10555](https://github.com/paperless-ngx/paperless-ngx/pull/10555))
- Performance: Classifier performance optimizations [@Merinorus](https://github.com/Merinorus) ([#10363](https://github.com/paperless-ngx/paperless-ngx/pull/10363))
- Performance: add setting to enable DB connection pooling for PostgreSQL [@Merinorus](https://github.com/Merinorus) ([#10354](https://github.com/paperless-ngx/paperless-ngx/pull/10354))
- Fixhancement: improve text thumbnail generation for large files [@shamoon](https://github.com/shamoon) ([#10483](https://github.com/paperless-ngx/paperless-ngx/pull/10483))
- Enhancement: disable auto spellcheck on filtering dropdowns [@TheDodger](https://github.com/TheDodger) ([#10487](https://github.com/paperless-ngx/paperless-ngx/pull/10487))
- Enhancement: display saved view counts [@shamoon](https://github.com/shamoon) ([#10246](https://github.com/paperless-ngx/paperless-ngx/pull/10246))
- Fixhancement: add missing exact operator for boolean CF queries [@shamoon](https://github.com/shamoon) ([#10402](https://github.com/paperless-ngx/paperless-ngx/pull/10402))
- Feature: add Vietnamese translation [@shamoon](https://github.com/shamoon) ([#10352](https://github.com/paperless-ngx/paperless-ngx/pull/10352))
- Performance: Add support for configuring date parser languages [@Merinorus](https://github.com/Merinorus) ([#10181](https://github.com/paperless-ngx/paperless-ngx/pull/10181))
- Enhancement: Add a database caching for improved performance [@Merinorus](https://github.com/Merinorus) ([#9784](https://github.com/paperless-ngx/paperless-ngx/pull/9784))
### Bug Fixes
- Fix: include ignore for config logos in sanity checker [@shamoon](https://github.com/shamoon) ([#10473](https://github.com/paperless-ngx/paperless-ngx/pull/10473))
- Fix: track and restore changed document fields from session storage [@shamoon](https://github.com/shamoon) ([#10468](https://github.com/paperless-ngx/paperless-ngx/pull/10468))
- Fix: Make some natural keyword date searches timezone-aware [@shamoon](https://github.com/shamoon) ([#10416](https://github.com/paperless-ngx/paperless-ngx/pull/10416))
- Fixhancement: follow redirects in curl health check [@V0idC0de](https://github.com/V0idC0de) ([#10415](https://github.com/paperless-ngx/paperless-ngx/pull/10415))
- Fix: dont use translated verbose_name for getting object perms [@shamoon](https://github.com/shamoon) ([#10399](https://github.com/paperless-ngx/paperless-ngx/pull/10399))
- Fix: fix date format for 'today' in DateComponent [@shamoon](https://github.com/shamoon) ([#10369](https://github.com/paperless-ngx/paperless-ngx/pull/10369))
- Fix: default to empty permissions for group creation [@shamoon](https://github.com/shamoon) ([#10337](https://github.com/paperless-ngx/paperless-ngx/pull/10337))
- Fix: correct api created coercion with timezone [@shamoon](https://github.com/shamoon) ([#10287](https://github.com/paperless-ngx/paperless-ngx/pull/10287))
- Fix: reset search query for preview on reset filter [@shamoon](https://github.com/shamoon) ([#10279](https://github.com/paperless-ngx/paperless-ngx/pull/10279))
- Chore: reject absurd max age values [@shamoon](https://github.com/shamoon) ([#10243](https://github.com/paperless-ngx/paperless-ngx/pull/10243))
- Chore: add tasks task_id param to openapi spec [@shamoon](https://github.com/shamoon) ([#10469](https://github.com/paperless-ngx/paperless-ngx/pull/10469))
- Chore: include advanced search query param in API spec [@shamoon](https://github.com/shamoon) ([#10449](https://github.com/paperless-ngx/paperless-ngx/pull/10449))
### Security
- Address XSS vulnerability GHSA-6p53-hqqw-8j62
### Maintenance
- docker(deps): Bump astral-sh/uv from 0.8.4-python3.12-bookworm-slim to 0.8.8-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#10564](https://github.com/paperless-ngx/paperless-ngx/pull/10564))
- docker(deps): Bump astral-sh/uv from 0.7.9-python3.12-bookworm-slim to 0.7.19-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#10343](https://github.com/paperless-ngx/paperless-ngx/pull/10343))
- Chore(deps): Bump the small-changes group across 1 directory with 7 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10347](https://github.com/paperless-ngx/paperless-ngx/pull/10347))
- Chore(deps-dev): Bump @types/node from 22.15.29 to 24.0.10 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10306](https://github.com/paperless-ngx/paperless-ngx/pull/10306))
- Chore(deps): Bump the small-changes group across 1 directory with 8 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10481](https://github.com/paperless-ngx/paperless-ngx/pull/10481))
- docker(deps): bump astral-sh/uv from 0.7.19-python3.12-bookworm-slim to 0.8.3-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#10465](https://github.com/paperless-ngx/paperless-ngx/pull/10465))
- Chore: switch from os.path to pathlib.Path [@gothicVI](https://github.com/gothicVI) ([#10397](https://github.com/paperless-ngx/paperless-ngx/pull/10397))
- Chore(deps): Bump the small-changes group with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10528](https://github.com/paperless-ngx/paperless-ngx/pull/10528))
- Chore(deps): Bump the django group across 1 directory with 9 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10538](https://github.com/paperless-ngx/paperless-ngx/pull/10538))
- Chore(deps): Bump stefanzweifel/git-auto-commit-action from 5 to 6 in the actions group @[dependabot[bot]](https://github.com/apps/dependabot) ([#10302](https://github.com/paperless-ngx/paperless-ngx/pull/10302))
### Dependencies
<details>
<summary>23 changes</summary>
- chore: Small targeted upgrades to dependencies [@stumpylog](https://github.com/stumpylog) ([#10561](https://github.com/paperless-ngx/paperless-ngx/pull/10561))
- docker(deps): Bump astral-sh/uv from 0.8.4-python3.12-bookworm-slim to 0.8.8-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#10564](https://github.com/paperless-ngx/paperless-ngx/pull/10564))
- Chore(deps): Bump the django group across 1 directory with 9 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10538](https://github.com/paperless-ngx/paperless-ngx/pull/10538))
- Chore(deps): Bump the small-changes group with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10528](https://github.com/paperless-ngx/paperless-ngx/pull/10528))
- Chore(deps-dev): Bump the frontend-jest-dependencies group in /src-ui with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10497](https://github.com/paperless-ngx/paperless-ngx/pull/10497))
- Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10498](https://github.com/paperless-ngx/paperless-ngx/pull/10498))
- Chore(deps-dev): Bump @playwright/test from 1.53.2 to 1.54.2 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10499](https://github.com/paperless-ngx/paperless-ngx/pull/10499))
- Chore(deps-dev): Bump webpack from 5.99.9 to 5.101.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10501](https://github.com/paperless-ngx/paperless-ngx/pull/10501))
- Chore(deps-dev): Bump prettier-plugin-organize-imports from 4.1.0 to 4.2.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10500](https://github.com/paperless-ngx/paperless-ngx/pull/10500))
- Chore(deps-dev): Bump @types/node from 24.0.10 to 24.1.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10502](https://github.com/paperless-ngx/paperless-ngx/pull/10502))
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 16 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10496](https://github.com/paperless-ngx/paperless-ngx/pull/10496))
- Chore(deps): Bump the small-changes group across 1 directory with 8 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10481](https://github.com/paperless-ngx/paperless-ngx/pull/10481))
- docker(deps): bump astral-sh/uv from 0.7.19-python3.12-bookworm-slim to 0.8.3-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#10465](https://github.com/paperless-ngx/paperless-ngx/pull/10465))
- docker(deps): Bump astral-sh/uv from 0.7.9-python3.12-bookworm-slim to 0.7.19-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#10343](https://github.com/paperless-ngx/paperless-ngx/pull/10343))
- Chore(deps): Bump the small-changes group across 1 directory with 7 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10347](https://github.com/paperless-ngx/paperless-ngx/pull/10347))
- Chore(deps): Bump stefanzweifel/git-auto-commit-action from 5 to 6 in the actions group @[dependabot[bot]](https://github.com/apps/dependabot) ([#10302](https://github.com/paperless-ngx/paperless-ngx/pull/10302))
- Chore(deps-dev): Bump the frontend-eslint-dependencies group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10311](https://github.com/paperless-ngx/paperless-ngx/pull/10311))
- Chore(deps-dev): Bump @types/node from 22.15.29 to 24.0.10 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10306](https://github.com/paperless-ngx/paperless-ngx/pull/10306))
- Chore(deps): Bump bootstrap from 5.3.6 to 5.3.7 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10308](https://github.com/paperless-ngx/paperless-ngx/pull/10308))
- Chore(deps-dev): Bump webpack from 5.98.0 to 5.99.9 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10309](https://github.com/paperless-ngx/paperless-ngx/pull/10309))
- Chore(deps-dev): Bump @playwright/test from 1.51.1 to 1.53.2 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10307](https://github.com/paperless-ngx/paperless-ngx/pull/10307))
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 13 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10303](https://github.com/paperless-ngx/paperless-ngx/pull/10303))
- Chore: update to Angular 20 [@shamoon](https://github.com/shamoon) ([#10273](https://github.com/paperless-ngx/paperless-ngx/pull/10273))
</details>
### All App Changes
<details>
<summary>44 changes</summary>
- chore: Small targeted upgrades to dependencies [@stumpylog](https://github.com/stumpylog) ([#10561](https://github.com/paperless-ngx/paperless-ngx/pull/10561))
- Feature: Add filter to localize dates for filepath templating [@stumpylog](https://github.com/stumpylog) ([#10559](https://github.com/paperless-ngx/paperless-ngx/pull/10559))
- Chore: Removes duplication and spread out config for codespell [@stumpylog](https://github.com/stumpylog) ([#10560](https://github.com/paperless-ngx/paperless-ngx/pull/10560))
- Chore(deps): Bump the django group across 1 directory with 9 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10538](https://github.com/paperless-ngx/paperless-ngx/pull/10538))
- Feature: PDF editor [@shamoon](https://github.com/shamoon) ([#10318](https://github.com/paperless-ngx/paperless-ngx/pull/10318))
- Enhancement: support webhook restrictions [@shamoon](https://github.com/shamoon) ([#10555](https://github.com/paperless-ngx/paperless-ngx/pull/10555))
- Performance: Classifier performance optimizations [@Merinorus](https://github.com/Merinorus) ([#10363](https://github.com/paperless-ngx/paperless-ngx/pull/10363))
- Chore: switch from os.path to pathlib.Path [@gothicVI](https://github.com/gothicVI) ([#10397](https://github.com/paperless-ngx/paperless-ngx/pull/10397))
- Chore(deps): Bump the small-changes group with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10528](https://github.com/paperless-ngx/paperless-ngx/pull/10528))
- Performance: add setting to enable DB connection pooling for PostgreSQL [@Merinorus](https://github.com/Merinorus) ([#10354](https://github.com/paperless-ngx/paperless-ngx/pull/10354))
- Chore(deps-dev): Bump the frontend-jest-dependencies group in /src-ui with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10497](https://github.com/paperless-ngx/paperless-ngx/pull/10497))
- Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10498](https://github.com/paperless-ngx/paperless-ngx/pull/10498))
- Chore(deps-dev): Bump @playwright/test from 1.53.2 to 1.54.2 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10499](https://github.com/paperless-ngx/paperless-ngx/pull/10499))
- Chore(deps-dev): Bump webpack from 5.99.9 to 5.101.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10501](https://github.com/paperless-ngx/paperless-ngx/pull/10501))
- Chore(deps-dev): Bump prettier-plugin-organize-imports from 4.1.0 to 4.2.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10500](https://github.com/paperless-ngx/paperless-ngx/pull/10500))
- Chore(deps-dev): Bump @types/node from 24.0.10 to 24.1.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10502](https://github.com/paperless-ngx/paperless-ngx/pull/10502))
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 16 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10496](https://github.com/paperless-ngx/paperless-ngx/pull/10496))
- Fixhancement: improve text thumbnail generation for large files [@shamoon](https://github.com/shamoon) ([#10483](https://github.com/paperless-ngx/paperless-ngx/pull/10483))
- Enhancement: disable auto spellcheck on filtering dropdowns [@TheDodger](https://github.com/TheDodger) ([#10487](https://github.com/paperless-ngx/paperless-ngx/pull/10487))
- Chore(deps): Bump the small-changes group across 1 directory with 8 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10481](https://github.com/paperless-ngx/paperless-ngx/pull/10481))
- Fix: include ignore for config logos in sanity checker [@shamoon](https://github.com/shamoon) ([#10473](https://github.com/paperless-ngx/paperless-ngx/pull/10473))
- Chore: add tasks task_id param to openapi spec [@shamoon](https://github.com/shamoon) ([#10469](https://github.com/paperless-ngx/paperless-ngx/pull/10469))
- Fix: track and restore changed document fields from session storage [@shamoon](https://github.com/shamoon) ([#10468](https://github.com/paperless-ngx/paperless-ngx/pull/10468))
- Chore: include advanced search query param in API spec [@shamoon](https://github.com/shamoon) ([#10449](https://github.com/paperless-ngx/paperless-ngx/pull/10449))
- Enhancement: display saved view counts [@shamoon](https://github.com/shamoon) ([#10246](https://github.com/paperless-ngx/paperless-ngx/pull/10246))
- Fix: Make some natural keyword date searches timezone-aware [@shamoon](https://github.com/shamoon) ([#10416](https://github.com/paperless-ngx/paperless-ngx/pull/10416))
- Fixhancement: add missing exact operator for boolean CF queries [@shamoon](https://github.com/shamoon) ([#10402](https://github.com/paperless-ngx/paperless-ngx/pull/10402))
- Fix: dont use translated verbose_name for getting object perms [@shamoon](https://github.com/shamoon) ([#10399](https://github.com/paperless-ngx/paperless-ngx/pull/10399))
- Fix: fix date format for 'today' in DateComponent [@shamoon](https://github.com/shamoon) ([#10369](https://github.com/paperless-ngx/paperless-ngx/pull/10369))
- Feature: add Vietnamese translation [@shamoon](https://github.com/shamoon) ([#10352](https://github.com/paperless-ngx/paperless-ngx/pull/10352))
- Chore(deps): Bump the small-changes group across 1 directory with 7 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10347](https://github.com/paperless-ngx/paperless-ngx/pull/10347))
- Fix: default to empty permissions for group creation [@shamoon](https://github.com/shamoon) ([#10337](https://github.com/paperless-ngx/paperless-ngx/pull/10337))
- Chore(deps-dev): Bump the frontend-eslint-dependencies group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10311](https://github.com/paperless-ngx/paperless-ngx/pull/10311))
- Chore(deps-dev): Bump @types/node from 22.15.29 to 24.0.10 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10306](https://github.com/paperless-ngx/paperless-ngx/pull/10306))
- Chore(deps): Bump bootstrap from 5.3.6 to 5.3.7 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10308](https://github.com/paperless-ngx/paperless-ngx/pull/10308))
- Chore(deps-dev): Bump webpack from 5.98.0 to 5.99.9 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10309](https://github.com/paperless-ngx/paperless-ngx/pull/10309))
- Chore(deps-dev): Bump @playwright/test from 1.51.1 to 1.53.2 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10307](https://github.com/paperless-ngx/paperless-ngx/pull/10307))
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 13 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10303](https://github.com/paperless-ngx/paperless-ngx/pull/10303))
- Performance: Add support for configuring date parser languages [@Merinorus](https://github.com/Merinorus) ([#10181](https://github.com/paperless-ngx/paperless-ngx/pull/10181))
- Enhancement: Add a database caching for improved performance [@Merinorus](https://github.com/Merinorus) ([#9784](https://github.com/paperless-ngx/paperless-ngx/pull/9784))
- Fix: correct api created coercion with timezone [@shamoon](https://github.com/shamoon) ([#10287](https://github.com/paperless-ngx/paperless-ngx/pull/10287))
- Fix: reset search query for preview on reset filter [@shamoon](https://github.com/shamoon) ([#10279](https://github.com/paperless-ngx/paperless-ngx/pull/10279))
- Chore: update to Angular 20 [@shamoon](https://github.com/shamoon) ([#10273](https://github.com/paperless-ngx/paperless-ngx/pull/10273))
- Chore: reject absurd max age values [@shamoon](https://github.com/shamoon) ([#10243](https://github.com/paperless-ngx/paperless-ngx/pull/10243))
</details>
## paperless-ngx 2.17.1
### Bug Fixes
@@ -5699,6 +5423,9 @@ This release contains new database migrations.
Paperless will continue to work with WSGI, but you will not get any
status notifications.
Apache `mod_wsgi` users, see
[this note](faq.md#how-do-i-get-websocket-support-with-apache-mod_wsgi).
- Paperless now offers suggestions for tags, correspondents and types
on the document detail page.
@@ -6500,12 +6227,11 @@ primarily.
who are doing active development on Paperless using the Docker
environment:
[#376](https://github.com/the-paperless-project/paperless/pull/376).
- ~~You now also have the ability to customise the interface to your
- You now also have the ability to customise the interface to your
heart's content by creating a file called `overrides.css` and/or
`overrides.js` in the root of your media directory. Thanks to [Mark
McFate](https://github.com/SummittDweller) for this idea:
[#371](https://github.com/the-paperless-project/paperless/issues/371)~~
(Not supported by Paperless-ngx)
[#371](https://github.com/the-paperless-project/paperless/issues/371)
### 2.0.0

View File

@@ -1282,30 +1282,6 @@ within your documents.
Defaults to false.
## Workflow webhooks
#### [`PAPERLESS_WEBHOOKS_ALLOWED_SCHEMES=<str>`](#PAPERLESS_WEBHOOKS_ALLOWED_SCHEMES) {#PAPERLESS_WEBHOOKS_ALLOWED_SCHEMES}
: A comma-separated list of allowed schemes for webhooks. This setting
controls which URL schemes are permitted for webhook URLs.
Defaults to `http,https`.
#### [`PAPERLESS_WEBHOOKS_ALLOWED_PORTS=<str>`](#PAPERLESS_WEBHOOKS_ALLOWED_PORTS) {#PAPERLESS_WEBHOOKS_ALLOWED_PORTS}
: A comma-separated list of allowed ports for webhooks. This setting
controls which ports are permitted for webhook URLs. For example, if you
set this to `80,443`, webhooks will only be sent to URLs that use these
ports.
Defaults to empty list, which allows all ports.
#### [`PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS=<bool>`](#PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS) {#PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS}
: If set to false, webhooks cannot be sent to internal URLs (e.g., localhost).
Defaults to true, which allows internal requests.
### Polling {#polling}
#### [`PAPERLESS_CONSUMER_POLLING=<num>`](#PAPERLESS_CONSUMER_POLLING) {#PAPERLESS_CONSUMER_POLLING}
@@ -1800,3 +1776,23 @@ password. All of these options come from their similarly-named [Django settings]
#### [`PAPERLESS_EMAIL_USE_SSL=<bool>`](#PAPERLESS_EMAIL_USE_SSL) {#PAPERLESS_EMAIL_USE_SSL}
: Defaults to false.
## Remote OCR
#### [`PAPERLESS_REMOTE_OCR_ENGINE=<str>`](#PAPERLESS_REMOTE_OCR_ENGINE) {#PAPERLESS_REMOTE_OCR_ENGINE}
: The remote OCR engine to use. Currently only Azure AI is supported as "azureai".
Defaults to None, which disables remote OCR.
#### [`PAPERLESS_REMOTE_OCR_API_KEY=<str>`](#PAPERLESS_REMOTE_OCR_API_KEY) {#PAPERLESS_REMOTE_OCR_API_KEY}
: The API key to use for the remote OCR engine.
Defaults to None.
#### [`PAPERLESS_REMOTE_OCR_ENDPOINT=<str>`](#PAPERLESS_REMOTE_OCR_ENDPOINT) {#PAPERLESS_REMOTE_OCR_ENDPOINT}
: The endpoint to use for the remote OCR engine. This is required for Azure AI.
Defaults to None.

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

@@ -25,9 +25,10 @@ physical documents into a searchable online archive so you can keep, well, _less
## Features
- **Organize and index** your scanned documents with tags, correspondents, types, and more.
- _Your_ data is stored locally on _your_ server and is never transmitted or shared in any way.
- _Your_ data is stored locally on _your_ server and is never transmitted or shared in any way, unless you explicitly choose to do so.
- Performs **OCR** on your documents, adding searchable and selectable text, even to documents scanned with only images.
- Utilizes the open-source Tesseract engine to recognize more than 100 languages.
- Utilizes the open-source Tesseract engine to recognize more than 100 languages.
- _New!_ Supports remote OCR with Azure AI (opt-in).
- Documents are saved as PDF/A format which is designed for long term storage, alongside the unaltered originals.
- Uses machine-learning to automatically add tags, correspondents and document types to your documents.
- Supports PDF documents, images, plain text files, Office documents (Word, Excel, PowerPoint, and LibreOffice equivalents)[^1] and more.

View File

@@ -33,7 +33,7 @@ warns that
`OCR for XX failed, but we're going to stick with what we've got since FORGIVING_OCR is enabled`,
then you might need to install the [Tesseract language
files](https://packages.ubuntu.com/search?keywords=tesseract-ocr)
matching your document's languages.
marching your document's languages.
As an example, if you are running Paperless-ngx from any Ubuntu or
Debian box, and your documents are written in Spanish you may need to

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
@@ -418,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).
@@ -462,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
@@ -510,58 +499,37 @@ The following workflow action types are available:
- Encoding for the request body, either JSON or form data
- The request headers as key-value pairs
For security reasons, webhooks can be limited to specific ports and disallowed from connecting to local URLs. See the relevant
[configuration settings](configuration.md#workflow-webhooks) to change this behavior. If you are allowing non-admins to create workflows,
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
@@ -608,14 +576,12 @@ The following custom field types are supported:
## PDF Actions
Paperless-ngx supports basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files). When viewing an individual document you can
open the 'PDF Editor' to use a simple UI for re-arranging, rotating, deleting pages and splitting documents.
Paperless-ngx supports four basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files):
- Merging documents: available when selecting multiple documents for 'bulk editing'.
- Rotating documents: available when selecting multiple documents for 'bulk editing' and via the pdf editor on an individual document's details page.
- Splitting documents: via the pdf editor on an individual document's details page.
- Deleting pages: via the pdf editor on an individual document's details page.
- Re-arranging pages: via the pdf editor on an individual document's details page.
- Rotating documents: available when selecting multiple documents for 'bulk editing' and from an individual document's details page.
- Splitting documents: available from an individual document's details page.
- Deleting pages: available from an individual document's details page.
!!! important
@@ -878,6 +844,18 @@ how regularly you intend to scan documents and use paperless.
performed the task associated with the document, move it to the
inbox.
## Remote OCR
!!! important
This feature is disabled by default and will always remain strictly "opt-in".
Paperless-ngx supports performing OCR on documents using remote services. At the moment, this is limited to
[Microsoft's Azure "Document Intelligence" service](https://azure.microsoft.com/en-us/products/ai-services/ai-document-intelligence).
This is of course a paid service (with a free tier) which requires an Azure account and subscription. Azure AI is not affiliated with
Paperless-ngx in any way. When enabled, Paperless-ngx will automatically send appropriate documents to Azure for OCR processing, bypassing
the local OCR engine. See the [configuration](configuration.md#PAPERLESS_REMOTE_OCR_ENGINE) options for more details.
## Architecture
Paperless-ngx consists of the following components:

View File

@@ -47,7 +47,6 @@ markdown_extensions:
- pymdownx.superfences
- pymdownx.inlinehilite
- pymdownx.snippets
- pymdownx.tilde
- footnotes
- pymdownx.superfences:
custom_fences:

View File

@@ -1,7 +1,7 @@
[project]
name = "paperless-ngx"
version = "2.18.4"
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
version = "2.17.1"
description = "A community-supported supercharged version of paperless: scan, index and archive all your physical documents"
readme = "README.md"
requires-python = ">=3.10"
classifiers = [
@@ -15,7 +15,7 @@ classifiers = [
# This will allow testing to not install a webserver, mysql, etc
dependencies = [
"babel>=2.17",
"azure-ai-documentintelligence>=1.0.2",
"bleach~=6.2.0",
"celery[redis]~=5.5.1",
"channels~=4.2",
@@ -24,34 +24,33 @@ dependencies = [
"dateparser~=1.2",
# WARNING: django does not use semver.
# Only patch versions are guaranteed to not introduce breaking changes.
"django~=5.2.5",
"django~=5.1.7",
"django-allauth[socialaccount,mfa]~=65.4.0",
"django-auditlog~=3.2.1",
"django-auditlog~=3.1.2",
"django-cachalot~=2.8.0",
"django-celery-results~=2.6.0",
"django-compression-middleware~=0.5.0",
"django-cors-headers~=4.8.0",
"django-cors-headers~=4.7.0",
"django-extensions~=4.1",
"django-filter~=25.1",
"django-guardian~=3.1.2",
"django-multiselectfield~=1.0.1",
"django-guardian~=2.4.0",
"django-multiselectfield~=0.1.13",
"django-soft-delete~=1.0.18",
"django-treenode>=0.23.2",
"djangorestframework~=3.16",
"djangorestframework-guardian~=0.4.0",
"djangorestframework~=3.15",
"djangorestframework-guardian~=0.3.0",
"drf-spectacular~=0.28",
"drf-spectacular-sidecar~=2025.9.1",
"drf-spectacular-sidecar~=2025.4.1",
"drf-writable-nested~=0.7.1",
"filelock~=3.19.1",
"filelock~=3.18.0",
"flower~=2.0.1",
"gotenberg-client~=0.11.0",
"gotenberg-client~=0.10.0",
"httpx-oauth~=0.16",
"imap-tools~=1.11.0",
"inotifyrecursive~=0.3",
"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",
"psycopg-pool",
@@ -61,7 +60,7 @@ dependencies = [
"python-ipware~=3.0.0",
"python-magic~=0.4.27",
"pyzbar~=0.1.9",
"rapidfuzz~=3.14.0",
"rapidfuzz~=3.13.0",
"redis[hiredis]~=5.2.1",
"scikit-learn~=1.7.0",
"setproctitle~=1.3.4",
@@ -83,7 +82,7 @@ optional-dependencies.postgres = [
"psycopg-pool==3.2.6",
]
optional-dependencies.webserver = [
"granian[uvloop]~=2.5.1",
"granian[uvloop]~=2.4.1",
]
[dependency-groups]
@@ -95,7 +94,7 @@ dev = [
]
docs = [
"mkdocs-glightbox~=0.5.1",
"mkdocs-glightbox~=0.4.0",
"mkdocs-material~=9.6.4",
]
@@ -104,8 +103,8 @@ testing = [
"factory-boy~=3.3.1",
"imagehash",
"pytest~=8.4.1",
"pytest-cov~=7.0.0",
"pytest-django~=4.11.1",
"pytest-cov~=6.2.1",
"pytest-django~=4.10.0",
"pytest-env",
"pytest-httpx",
"pytest-mock",
@@ -115,9 +114,9 @@ testing = [
]
lint = [
"pre-commit~=4.3.0",
"pre-commit~=4.2.0",
"pre-commit-uv~=4.1.3",
"ruff~=0.13.0",
"ruff~=0.12.2",
]
typing = [
@@ -125,7 +124,6 @@ typing = [
"django-filter-stubs",
"django-stubs[compatible-mypy]",
"djangorestframework-stubs[compatible-mypy]",
"lxml-stubs",
"mypy",
"types-bleach",
"types-colorama",
@@ -133,7 +131,6 @@ typing = [
"types-markdown",
"types-pygments",
"types-python-dateutil",
"types-pytz",
"types-redis",
"types-setuptools",
"types-tqdm",
@@ -208,19 +205,23 @@ 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",
]
lint.isort.force-single-line = true
[tool.codespell]
write-changes = true
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober"
skip = "src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/documents/tests/samples/*,*.po,*.json"
[tool.pytest.ini_options]
minversion = "8.0"
pythonpath = [
@@ -233,6 +234,7 @@ testpaths = [
"src/paperless_tesseract/tests/",
"src/paperless_tika/tests",
"src/paperless_text/tests/",
"src/paperless_remote/tests/",
]
addopts = [
"--pythonwarnings=all",
@@ -273,10 +275,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

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.17.1",
"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

@@ -121,26 +121,6 @@ if (!URL.revokeObjectURL) {
}
Object.defineProperty(window, 'ResizeObserver', { value: mock() })
if (typeof IntersectionObserver === 'undefined') {
class MockIntersectionObserver {
constructor(
public callback: IntersectionObserverCallback,
public options?: IntersectionObserverInit
) {}
observe = jest.fn()
unobserve = jest.fn()
disconnect = jest.fn()
takeRecords = jest.fn()
}
Object.defineProperty(window, 'IntersectionObserver', {
writable: true,
configurable: true,
value: MockIntersectionObserver,
})
}
HTMLCanvasElement.prototype.getContext = <
typeof HTMLCanvasElement.prototype.getContext
>jest.fn()

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

@@ -108,16 +108,15 @@
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews"
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
(cdkDragEnded)="onDragEnd($event)">
<a class="nav-link" routerLink="view/{{view.id}}"
<a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="view/{{view.id}}"
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name"
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
popoverClass="popover-slim">
<i-bs class="me-1" name="funnel"></i-bs>
<span>&nbsp;<div class="d-inline-flex view-name"><span class="overflow-hidden" [class.text-wrap]="!slimSidebarEnabled">{{view.name}}</span></div>
@if (showSidebarCounts && !slimSidebarEnabled) {
<span class="badge bg-info text-dark ms-2 d-inline">{{ savedViewService.getDocumentCount(view) }}</span>
}
</span>
<i-bs class="me-1" name="funnel"></i-bs><span>&nbsp;{{view.name}}
@if (showSidebarCounts && !slimSidebarEnabled) {
<span><span class="badge bg-info text-dark ms-2 d-inline">{{ savedViewService.getDocumentCount(view) }}</span></span>
}
</span>
@if (showSidebarCounts && slimSidebarEnabled) {
<span class="badge bg-info text-dark position-absolute top-0 end-0 d-none d-md-block">{{ savedViewService.getDocumentCount(view) }}</span>
}
@@ -147,7 +146,7 @@
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
popoverClass="popover-slim">
<i-bs class="me-1" name="file-text"></i-bs><span>&nbsp;{{d.title | documentTitle}}</span>
<span class="close flex-column justify-content-center" (click)="closeDocument(d); $event.preventDefault()">
<span class="close" (click)="closeDocument(d); $event.preventDefault()">
<i-bs name="x"></i-bs>
</span>
</a>
@@ -166,13 +165,10 @@
</div>
<div class="nav-group mt-3 mb-1">
<h6 class="sidebar-heading px-3 text-muted d-flex align-items-center">
<h6 class="sidebar-heading px-3 text-muted">
<span i18n>Manage</span>
<button class="btn btn-link p-2 py-0" (click)="manageCollapse.toggle()">
<i-bs width="0.9em" height="0.9em" [name]="isManageMenuCollapsed ? 'chevron-down' : 'chevron-up'"></i-bs>
</button>
</h6>
<ul class="nav flex-column mb-2" #manageCollapse="ngbCollapse" [(ngbCollapse)]="isManageMenuCollapsed">
<ul class="nav flex-column mb-2">
<li class="nav-item app-link"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
<a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()"
@@ -246,124 +242,117 @@
</div>
<div class="nav-group mt-auto mb-1">
<h6 class="sidebar-heading px-3 pt-4 text-muted d-flex align-items-center">
<h6 class="sidebar-heading px-3 pt-4 text-muted">
<span i18n>Administration</span>
<button class="btn btn-link p-2 py-0" (click)="adminCollapse.toggle()">
<i-bs width="0.9em" height="0.9em" [name]="isAdminMenuCollapsed ? 'chevron-down' : 'chevron-up'"></i-bs>
</button>
</h6>
<div class="mb-2">
<ul class="nav flex-column" #adminCollapse="ngbCollapse" [(ngbCollapse)]="isAdminMenuCollapsed">
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }"
tourAnchor="tour.settings">
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="gear"></i-bs><span>&nbsp;<ng-container i18n>Settings</ng-container></span>
</a>
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }">
<a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="sliders2-vertical"></i-bs><span>&nbsp;<ng-container i18n>Configuration</ng-container></span>
</a>
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="people"></i-bs><span>&nbsp;<ng-container i18n>Users & Groups</ng-container></span>
</a>
</li>
<li class="nav-item app-link"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }"
tourAnchor="tour.file-tasks">
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="File Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="list-task"></i-bs><span>&nbsp;<ng-container i18n>File Tasks</ng-container>@if (tasksService.failedFileTasks.length > 0) {
<span><span class="badge bg-danger ms-2 d-inline">{{tasksService.failedFileTasks.length}}</span></span>
}</span>
@if (tasksService.failedFileTasks.length > 0 && slimSidebarEnabled) {
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.failedFileTasks.length}}</span>
}
</a>
</li>
@if (permissionsService.isAdmin()) {
<li class="nav-item app-link">
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="text-left"></i-bs><span>&nbsp;<ng-container i18n>Logs</ng-container></span>
</a>
</li>
}
</ul>
<ul class="nav flex-column">
<li class="nav-item mt-2" tourAnchor="tour.outro">
<a class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap text-decoration-none"
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
<ul class="nav flex-column mb-2">
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }"
tourAnchor="tour.settings">
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="gear"></i-bs><span>&nbsp;<ng-container i18n>Settings</ng-container></span>
</a>
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }">
<a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="sliders2-vertical"></i-bs><span>&nbsp;<ng-container i18n>Configuration</ng-container></span>
</a>
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="people"></i-bs><span>&nbsp;<ng-container i18n>Users & Groups</ng-container></span>
</a>
</li>
<li class="nav-item app-link"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }"
tourAnchor="tour.file-tasks">
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="File Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="list-task"></i-bs><span>&nbsp;<ng-container i18n>File Tasks</ng-container>@if (tasksService.failedFileTasks.length > 0) {
<span><span class="badge bg-danger ms-2 d-inline">{{tasksService.failedFileTasks.length}}</span></span>
}</span>
@if (tasksService.failedFileTasks.length > 0 && slimSidebarEnabled) {
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.failedFileTasks.length}}</span>
}
</a>
</li>
@if (permissionsService.isAdmin()) {
<li class="nav-item app-link">
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="d-flex" name="question-circle"></i-bs><span class="ms-1">&nbsp;<ng-container i18n>Documentation</ng-container></span>
<i-bs class="me-1" name="text-left"></i-bs><span>&nbsp;<ng-container i18n>Logs</ng-container></span>
</a>
</li>
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
<div class="px-3 py-0 text-muted small d-flex align-items-center flex-wrap">
<div class="me-3">
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer"
href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover
[disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
{{ versionString }}
</a>
</div>
@if (!settingsService.updateCheckingIsSet || appRemoteVersion) {
<div class="version-check">
<ng-template #updateAvailablePopContent>
<span class="small">Paperless-ngx {{ appRemoteVersion.version }} <ng-container i18n>is
available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span>
</ng-template>
<ng-template #updateCheckingNotEnabledPopContent>
<p class="small mb-2">
<ng-container i18n>Paperless-ngx can automatically check for updates</ng-container>
</p>
<div class="btn-group btn-group-xs flex-fill w-100">
<button class="btn btn-outline-primary" (click)="setUpdateChecking(true)">Enable</button>
<button class="btn btn-outline-secondary" (click)="setUpdateChecking(false)">Disable</button>
</div>
<p class="small mb-0 mt-2">
<a class="small text-decoration-none fst-italic" routerLink="/settings" fragment="update-checking" i18n>
How does this work?
</a>
</p>
</ng-template>
@if (settingsService.updateCheckingIsSet) {
@if (appRemoteVersion.update_available) {
<a class="small text-decoration-none" target="_blank" rel="noopener noreferrer"
href="https://github.com/paperless-ngx/paperless-ngx/releases"
[ngbPopover]="updateAvailablePopContent" popoverClass="shadow" triggers="mouseenter:mouseleave"
container="body">
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
@if (appRemoteVersion?.update_available) {
&nbsp;<ng-container i18n>Update available</ng-container>
}
</a>
}
} @else {
<a *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }" class="small text-decoration-none" routerLink="/settings" fragment="update-checking"
[ngbPopover]="updateCheckingNotEnabledPopContent" popoverClass="shadow" triggers="mouseenter"
}
<li class="nav-item mt-2" tourAnchor="tour.outro">
<a class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap text-decoration-none"
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="d-flex" name="question-circle"></i-bs><span class="ms-1">&nbsp;<ng-container i18n>Documentation</ng-container></span>
</a>
</li>
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
<div class="px-3 py-0 text-muted small d-flex align-items-center flex-wrap">
<div class="me-3">
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer"
href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover
[disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
{{ versionString }}
</a>
</div>
@if (!settingsService.updateCheckingIsSet || appRemoteVersion) {
<div class="version-check">
<ng-template #updateAvailablePopContent>
<span class="small">Paperless-ngx {{ appRemoteVersion.version }} <ng-container i18n>is
available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span>
</ng-template>
<ng-template #updateCheckingNotEnabledPopContent>
<p class="small mb-2">
<ng-container i18n>Paperless-ngx can automatically check for updates</ng-container>
</p>
<div class="btn-group btn-group-xs flex-fill w-100">
<button class="btn btn-outline-primary" (click)="setUpdateChecking(true)">Enable</button>
<button class="btn btn-outline-secondary" (click)="setUpdateChecking(false)">Disable</button>
</div>
<p class="small mb-0 mt-2">
<a class="small text-decoration-none fst-italic" routerLink="/settings" fragment="update-checking" i18n>
How does this work?
</a>
</p>
</ng-template>
@if (settingsService.updateCheckingIsSet) {
@if (appRemoteVersion.update_available) {
<a class="small text-decoration-none" target="_blank" rel="noopener noreferrer"
href="https://github.com/paperless-ngx/paperless-ngx/releases"
[ngbPopover]="updateAvailablePopContent" popoverClass="shadow" triggers="mouseenter:mouseleave"
container="body">
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
@if (appRemoteVersion?.update_available) {
&nbsp;<ng-container i18n>Update available</ng-container>
}
</a>
}
</div>
}
</div>
</li>
</ul>
</div>
} @else {
<a *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }" class="small text-decoration-none" routerLink="/settings" fragment="update-checking"
[ngbPopover]="updateCheckingNotEnabledPopContent" popoverClass="shadow" triggers="mouseenter"
container="body">
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
</a>
}
</div>
}
</div>
</li>
</ul>
</div>
</div>
</nav>

View File

@@ -19,10 +19,6 @@
height: 0.8em;
}
.view-name {
max-width: calc(100% - 50px)
}
.nav-group:not(:has(.app-link)) .sidebar-heading {
display: none !important;
}
@@ -191,7 +187,7 @@ main {
list-style-type: none;
&:hover .close {
display: flex;
display: block;
}
.close {

View File

@@ -89,8 +89,6 @@ export class AppFrameComponent
appRemoteVersion: AppRemoteVersion
isMenuCollapsed: boolean = true
isManageMenuCollapsed: boolean = false
isAdminMenuCollapsed: boolean = false
slimSidebarAnimating: boolean = false
@@ -147,7 +145,7 @@ export class AppFrameComponent
}
get versionString(): string {
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.production ? '' : ` #${environment.tag}`}`
}
get customAppTitle(): string {
@@ -289,9 +287,6 @@ export class AppFrameComponent
}
get showSidebarCounts(): boolean {
return (
this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT) &&
!this.settingsService.organizingSidebarSavedViews
)
return this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT)
}
}

View File

@@ -0,0 +1,54 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
</button>
</div>
<div class="modal-body">
<div class="row">
<div class="col">
<div class="btn-toolbar flex-nowrap">
<div class="input-group input-group-sm">
<div class="input-group-text" i18n>Page</div>
<input class="form-control mw-60" type="number" min="1" [(ngModel)]="currentPage" />
<div class="input-group-text" i18n>of {{totalPages}}</div>
</div>
<div class="input-group input-group-sm ms-auto">
<span class="input-group-text" i18n>Pages to remove</span>
<input [ngModel]="pagesString" class="form-control" disabled />
</div>
</div>
<div class="pdf-viewer-container w-100 mt-3">
<pdf-viewer #pdfViewer [src]="pdfSrc" [(page)]="currentPage"
[original-size]="false"
[zoom]="1"
zoom-scale="page-fit"
[render-text]="false"
(pagerendered)="pageRendered($event)"
(after-load-complete)="pdfPreviewLoaded($event)">
</pdf-viewer>
</div>
</div>
</div>
</div>
<div class="modal-footer flex-nowrap">
<div>
@if (message) {
<p [innerHTML]="message | safeHtml"></p>
}
@if (messageBold) {
<p class="mb-0 small"><b [innerHTML]="messageBold | safeHtml"></b></p>
}
</div>
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
{{btnCaption}}
</button>
</div>
<ng-template #pageCheckOverlay let-page="page" let-pages="pages">
<div class="position-absolute top-0 start-0 w-100 h-100 p-2" (click)="pageCheckChanged(page)">
<input type="checkbox" class="form-check-input" />
</div>
</ng-template>

View File

@@ -0,0 +1,28 @@
.pdf-viewer-container {
background-color: gray;
height: 550px;
pdf-viewer {
width: 100%;
height: 100%;
}
}
.mw-60 {
max-width: 60px;
}
div.position-absolute:has(.form-check-input:checked) {
background-color: rgba(var(--bs-dark-rgb), 0.4);
}
.form-check-input {
&:checked {
background-color: var(--bs-danger);
border-color: var(--bs-danger);
}
&:focus {
box-shadow: 0 0 0 0.25rem rgba(var(--bs-danger-rgb), var(--pngx-focus-alpha));
border-color: var(--bs-danger);
}
}

View File

@@ -0,0 +1,60 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { DeletePagesConfirmDialogComponent } from './delete-pages-confirm-dialog.component'
describe('DeletePagesConfirmDialogComponent', () => {
let component: DeletePagesConfirmDialogComponent
let fixture: ComponentFixture<DeletePagesConfirmDialogComponent>
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [],
imports: [
NgxBootstrapIconsModule.pick(allIcons),
FormsModule,
ReactiveFormsModule,
DeletePagesConfirmDialogComponent,
],
providers: [
NgbActiveModal,
SafeHtmlPipe,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
}).compileComponents()
fixture = TestBed.createComponent(DeletePagesConfirmDialogComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should return a string with comma-separated pages', () => {
component.pages = [1, 2, 3, 4]
expect(component.pagesString).toEqual('1, 2, 3, 4')
})
it('should update totalPages when pdf is loaded', () => {
component.pdfPreviewLoaded({ numPages: 5 } as any)
expect(component.totalPages).toEqual(5)
})
it('should update checks when page is rendered', () => {
const event = {
target: document.createElement('div'),
detail: { pageNumber: 1 },
} as any
component.pageRendered(event)
expect(component['checks'].length).toEqual(1)
})
it('should update pages when page check is changed', () => {
component.pageCheckChanged(1)
expect(component.pages).toEqual([1])
component.pageCheckChanged(1)
expect(component.pages).toEqual([])
})
})

View File

@@ -0,0 +1,69 @@
import { Component, TemplateRef, ViewChild, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import {
PDFDocumentProxy,
PdfViewerComponent,
PdfViewerModule,
} from 'ng2-pdf-viewer'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { DocumentService } from 'src/app/services/rest/document.service'
import { ConfirmDialogComponent } from '../confirm-dialog.component'
@Component({
selector: 'pngx-delete-pages-confirm-dialog',
templateUrl: './delete-pages-confirm-dialog.component.html',
styleUrl: './delete-pages-confirm-dialog.component.scss',
imports: [PdfViewerModule, FormsModule, ReactiveFormsModule, SafeHtmlPipe],
})
export class DeletePagesConfirmDialogComponent extends ConfirmDialogComponent {
private documentService = inject(DocumentService)
public documentID: number
public pages: number[] = []
public currentPage: number = 1
public totalPages: number
@ViewChild('pdfViewer') pdfViewer: PdfViewerComponent
@ViewChild('pageCheckOverlay') pageCheckOverlay!: TemplateRef<any>
private checks: HTMLElement[] = []
public get pagesString(): string {
return this.pages.join(', ')
}
public get pdfSrc(): string {
return this.documentService.getPreviewUrl(this.documentID)
}
constructor() {
super()
}
public pdfPreviewLoaded(pdf: PDFDocumentProxy) {
this.totalPages = pdf.numPages
}
pageRendered(event: CustomEvent) {
const pageDiv = event.target as HTMLDivElement
const check = this.pageCheckOverlay.createEmbeddedView({
page: event.detail.pageNumber,
})
this.checks[event.detail.pageNumber - 1] = check.rootNodes[0]
pageDiv?.insertBefore(check.rootNodes[0], pageDiv.firstChild)
this.updateChecks()
}
pageCheckChanged(pageNumber: number) {
if (!this.pages.includes(pageNumber)) this.pages.push(pageNumber)
else if (this.pages.includes(pageNumber))
this.pages.splice(this.pages.indexOf(pageNumber), 1)
this.updateChecks()
}
private updateChecks() {
this.checks.forEach((check, i) => {
const input = check.getElementsByTagName('input')[0]
input.checked = this.pages.includes(i + 1)
})
}
}

View File

@@ -0,0 +1,59 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
</button>
</div>
<div class="modal-body">
<p>{{message}}</p>
<div class="row mb-2">
<div class="col-7">
<div class="input-group input-group-sm">
<div class="input-group-text" i18n>Page</div>
<input class="form-control" type="number" min="1" [(ngModel)]="page" />
<div class="input-group-text" i18n>of {{totalPages}}</div>
</div>
<div class="pdf-viewer-container w-100 mt-3">
<pdf-viewer [src]="pdfSrc" [(page)]="page"
[original-size]="false"
[zoom]="1"
zoom-scale="page-fit"
(after-load-complete)="pdfPreviewLoaded($event)">
</pdf-viewer>
</div>
</div>
<div class="col-5">
<div class="d-grid">
<button class="btn btn-sm btn-primary" (click)="addSplit()" [disabled]="!canSplit">
<i-bs name="plus-circle"></i-bs>&nbsp;
<span i18n>Add Split</span>
</button>
</div>
<ul class="list-group mt-3">
@for (pageStr of pagesString.split(','); track pageStr; let i = $index) {
<li class="list-group-item d-flex align-items-center">
{{pageStr}}
@if (pagesString.split(',').length > 1) {
&nbsp;
<button class="btn btn-sm btn-danger ms-auto" (click)="removeSplit(i)">
<i-bs name="trash"></i-bs>
</button>
}
</li>
}
</ul>
</div>
</div>
</div>
<div class="modal-footer">
<div class="form-check form-switch me-auto">
<input class="form-check-input" type="checkbox" role="switch" id="deleteOriginalSwitch" [(ngModel)]="deleteOriginal" [disabled]="!userOwnsDocument">
<label class="form-check-label" for="deleteOriginalSwitch" i18n>Delete original document after successful split</label>
</div>
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
{{btnCaption}}
</button>
</div>

View File

@@ -0,0 +1,9 @@
.pdf-viewer-container {
background-color: gray;
height: 500px;
pdf-viewer {
width: 100%;
height: 100%;
}
}

View File

@@ -0,0 +1,107 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { PdfViewerModule } from 'ng2-pdf-viewer'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of } from 'rxjs'
import { DocumentService } from 'src/app/services/rest/document.service'
import { SplitConfirmDialogComponent } from './split-confirm-dialog.component'
describe('SplitConfirmDialogComponent', () => {
let component: SplitConfirmDialogComponent
let fixture: ComponentFixture<SplitConfirmDialogComponent>
let documentService: DocumentService
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
NgxBootstrapIconsModule.pick(allIcons),
ReactiveFormsModule,
FormsModule,
PdfViewerModule,
SplitConfirmDialogComponent,
],
providers: [
NgbActiveModal,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
}).compileComponents()
fixture = TestBed.createComponent(SplitConfirmDialogComponent)
documentService = TestBed.inject(DocumentService)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should load document on init', () => {
const getSpy = jest.spyOn(documentService, 'get')
component.documentID = 1
getSpy.mockReturnValue(of({ id: 1 } as any))
component.ngOnInit()
expect(documentService.get).toHaveBeenCalledWith(1)
})
it('should update pagesString when pages are added', () => {
component.totalPages = 5
component.page = 2
component.addSplit()
expect(component.pagesString).toEqual('1-2,3-5')
component.page = 4
component.addSplit()
expect(component.pagesString).toEqual('1-2,3-4,5')
})
it('should update pagesString when pages are removed', () => {
component.totalPages = 5
component.page = 2
component.addSplit()
component.page = 4
component.addSplit()
expect(component.pagesString).toEqual('1-2,3-4,5')
component.removeSplit(0)
expect(component.pagesString).toEqual('1-4,5')
})
it('should enable confirm button when pages are added', () => {
component.totalPages = 5
component.page = 2
component.addSplit()
expect(component.confirmButtonEnabled).toBeTruthy()
})
it('should disable confirm button when all pages are removed', () => {
component.totalPages = 5
component.page = 2
component.addSplit()
component.removeSplit(0)
expect(component.confirmButtonEnabled).toBeFalsy()
})
it('should not add split if page is the last page', () => {
component.totalPages = 5
component.page = 5
component.addSplit()
expect(component.pagesString).toEqual('1-5')
})
it('should update totalPages when pdf is loaded', () => {
component.pdfPreviewLoaded({ numPages: 5 } as any)
expect(component.totalPages).toEqual(5)
})
it('should correctly disable split button', () => {
component.totalPages = 5
component.page = 1
expect(component.canSplit).toBeTruthy()
component.page = 5
expect(component.canSplit).toBeFalsy()
component.page = 4
expect(component.canSplit).toBeTruthy()
component['pages'] = new Set([1, 2, 3, 4])
expect(component.canSplit).toBeFalsy()
})
})

View File

@@ -0,0 +1,98 @@
import { Component, OnInit, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Document } from 'src/app/data/document'
import { PermissionsService } from 'src/app/services/permissions.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import { ConfirmDialogComponent } from '../confirm-dialog.component'
@Component({
selector: 'pngx-split-confirm-dialog',
templateUrl: './split-confirm-dialog.component.html',
styleUrl: './split-confirm-dialog.component.scss',
imports: [
FormsModule,
ReactiveFormsModule,
NgxBootstrapIconsModule,
PdfViewerModule,
],
})
export class SplitConfirmDialogComponent
extends ConfirmDialogComponent
implements OnInit
{
private documentService = inject(DocumentService)
private permissionService = inject(PermissionsService)
public get pagesString(): string {
let pagesStr = ''
let lastPage = 1
for (let i = 1; i <= this.totalPages; i++) {
if (this.pages.has(i) || i === this.totalPages) {
if (lastPage === i) {
pagesStr += `${i},`
lastPage = Math.min(i + 1, this.totalPages)
} else {
pagesStr += `${lastPage}-${i},`
lastPage = Math.min(i + 1, this.totalPages)
}
}
}
return pagesStr.replace(/,$/, '')
}
private pages: Set<number> = new Set()
public documentID: number
private document: Document
public page: number = 1
public totalPages: number
public deleteOriginal: boolean = false
public get canSplit(): boolean {
return (
this.page < this.totalPages &&
this.pages.size < this.totalPages - 1 &&
!this.pages.has(this.page)
)
}
public get pdfSrc(): string {
return this.documentService.getPreviewUrl(this.documentID)
}
constructor() {
super()
this.confirmButtonEnabled = this.pages.size > 0
}
ngOnInit(): void {
this.documentService.get(this.documentID).subscribe((r) => {
this.document = r
})
}
pdfPreviewLoaded(pdf: PDFDocumentProxy) {
this.totalPages = pdf.numPages
}
addSplit() {
if (this.page === this.totalPages) return
this.pages.add(this.page)
this.pages = new Set(Array.from(this.pages).sort((a, b) => a - b))
this.confirmButtonEnabled = this.pages.size > 0
}
removeSplit(i: number) {
let page = Array.from(this.pages)[Math.min(i, this.pages.size - 1)]
this.pages.delete(page)
this.confirmButtonEnabled = this.pages.size > 0
}
get userOwnsDocument(): boolean {
return this.permissionService.currentUserOwnsObject(this.document)
}
}

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

@@ -11,7 +11,7 @@
<div class="selected-icon">
@if (createdRelativeDate) {
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedRelativeDate()">
<i-bs width="1em" height="1em" name="check" class="variant-unfocused text-dark"></i-bs>
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
</a>
}

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,35 +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) {
this.selectOptions.removeAt(index)
this._allSelectOptions.splice(
index + (this.selectOptionsPage - 1) * SELECT_OPTION_PAGE_SIZE,
1
)
}
}

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

@@ -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

@@ -1,107 +0,0 @@
<pdf-viewer [src]="pdfSrc" [render-text]="false" zoom="0.4" (after-load-complete)="pdfLoaded($event)"></pdf-viewer>
<div class="modal-header">
<h4 class="modal-title">{{ title }}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button>
</div>
<div class="modal-body">
<div class="btn-toolbar mb-2">
<div class="btn-group me-3">
<button class="btn btn-sm btn-secondary" (click)="selectAll()" title="Select all pages" i18n-title>
<i-bs name="check-all"></i-bs>
</button>
<button class="btn btn-sm btn-secondary" (click)="deselectAll()" [disabled]="!hasSelection()" title="Deselect all pages" i18n-title>
<i-bs name="x"></i-bs>
</button>
</div>
<div class="btn-group">
<button class="btn btn-sm btn-secondary" (click)="rotateSelected(-90)" [disabled]="!hasSelection()" title="Rotate selected pages counter-clockwise" i18n-title>
<i-bs name="arrow-counterclockwise"></i-bs>
</button>
<button class="btn btn-sm btn-secondary" (click)="rotateSelected(90)" [disabled]="!hasSelection()" title="Rotate selected pages clockwise" i18n-title>
<i-bs name="arrow-clockwise"></i-bs>
</button>
<button class="btn btn-sm btn-danger" (click)="deleteSelected()" [disabled]="!hasSelection()" title="Delete selected pages" i18n-title>
<i-bs name="trash"></i-bs>
</button>
</div>
</div>
<div cdkDropList (cdkDropListDropped)="drop($event)" cdkDropListOrientation="mixed" class="d-flex flex-wrap row-cols-2 row-cols-md-5">
@for (p of pages; track p.page; let i = $index) {
<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>
<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>
<i-bs name="arrow-clockwise"></i-bs>
</button>
</div>
<div class="btn-group">
<button class="btn btn-sm btn-dark text-danger" (click)="remove(i); $event.stopPropagation()" title="Delete page" i18n-title>
<i-bs name="trash"></i-bs>
</button>
<button class="btn btn-sm btn-dark" (click)="toggleSplit(i); $event.stopPropagation()" title="Add / remove document split here" i18n-title>
<i-bs name="scissors"></i-bs>
</button>
</div>
</div>
<div class="border-end border-bottom bg-light py-1 px-2 document-check z-10">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="page{{i}}" [checked]="p.selected" (click)="toggleSelection(i); $event.stopPropagation()">
<label class="form-check-label" for="page{{i}}"></label>
</div>
</div>
<div class="pdf-viewer-container w-100" [class.selected]="p.selected">
@defer (on viewport) {
@if (!p.loaded) {
<div class="placeholder-glow w-100 h-100 z-10">
<span class="placeholder w-100 h-100"></span>
</div>
}
<pdf-viewer class="fade" [class.show]="p.loaded" [src]="pdfSrc" [page]="p.page" [rotation]="p.rotate" [original-size]="false" [show-all]="false" [render-text]="false" (page-rendered)="p.loaded = true"></pdf-viewer>
} @placeholder {
<div class="placeholder-glow w-100 h-100 z-10">
<span class="placeholder w-100 h-100"></span>
</div>
}
</div>
@if (p.splitAfter) {
<div class="split-after rounded position-absolute top-0 end-0 bg-dark text-uppercase text-center h-100 px-1 small fw-bold">&mdash; <span i18n>Split here</span> &mdash;</div>
}
</div>
}
</div>
</div>
<div class="modal-footer">
<div class="d-flex flex-column flex-md-row w-100 gap-3 align-items-center">
<div class="btn-group" role="group">
<input type="radio" class="btn-check" [(ngModel)]="editMode" [value]="PdfEditorEditMode.Create" id="editModeCreate" name="editmode">
<label for="editModeCreate" class="btn btn-outline-primary btn-sm">
<i-bs name="plus"></i-bs>
<span class="form-check-label ms-1" i18n>Create new document(s)</span>
</label>
<input type="radio" class="btn-check" [(ngModel)]="editMode" [value]="PdfEditorEditMode.Update" id="editModeUpdate" name="editmode" [disabled]="hasSplit()">
<label for="editModeUpdate" class="btn btn-outline-primary btn-sm">
<i-bs name="pencil"></i-bs>
<span class="form-check-label ms-2" i18n>Update existing document</span>
</label>
</div>
@if (editMode === PdfEditorEditMode.Create) {
<div class="form-group d-flex">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="copyMeta" [(ngModel)]="includeMetadata">
<label class="form-check-label" for="copyMeta" i18n>Copy metadata</label>
</div>
<div class="form-check ms-3">
<input class="form-check-input" type="checkbox" id="deleteOriginal" [(ngModel)]="deleteOriginal">
<label class="form-check-label" for="deleteOriginal" i18n>Delete original</label>
</div>
</div>
}
<div class="form-group ms-md-auto">
<button type="button" class="btn me-2" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">{{ cancelBtnCaption }}</button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="pages.length === 0">{{ btnCaption }}</button>
</div>
</div>
</div>

View File

@@ -1,70 +0,0 @@
.page-item {
position: relative;
cursor: pointer;
border: 1px solid transparent;
background-origin: border-box;
&.selected {
background-color: var(--pngx-primary-darken-5);
}
}
.pdf-viewer-container {
background-color: gray;
height: 240px;
pdf-viewer {
width: 100%;
height: 100%;
}
}
::ng-deep .ng2-pdf-viewer-container {
overflow: hidden;
}
.hover-actions {
position: absolute;
top: 0;
right: 0;
display: none;
}
.page-item:hover .hover-actions {
display: block;
}
.document-check {
display: none;
position: absolute;
top: 0;
left: 0;
padding: 0.5rem;
border-top-left-radius: 0.25rem;
border-bottom-right-radius: 0.25rem;
pointer-events: none;
.form-check {
padding: 0;
min-height: 0;
margin-bottom: 0;
.form-check-input {
margin-left: 0;
}
}
}
.page-item:hover .document-check, .selected .document-check {
display: block;
}
.z-10 {
z-index: 10;
}
.split-after {
writing-mode: vertical-rl;
}

View File

@@ -1,142 +0,0 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { PDFEditorComponent } from './pdf-editor.component'
describe('PDFEditorComponent', () => {
let component: PDFEditorComponent
let fixture: ComponentFixture<PDFEditorComponent>
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PDFEditorComponent, NgxBootstrapIconsModule.pick(allIcons)],
providers: [
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
{ provide: NgbActiveModal, useValue: {} },
],
}).compileComponents()
fixture = TestBed.createComponent(PDFEditorComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should return correct operations with no changes', () => {
component.pages = [
{ page: 1, rotate: 0, splitAfter: false },
{ page: 2, rotate: 0, splitAfter: false },
{ page: 3, rotate: 0, splitAfter: false },
]
const ops = component.getOperations()
expect(ops).toEqual([
{ page: 1, rotate: 0, doc: 0 },
{ page: 2, rotate: 0, doc: 0 },
{ page: 3, rotate: 0, doc: 0 },
])
})
it('should rotate, delete and reorder pages', () => {
component.pages = [
{ page: 1, rotate: 0, splitAfter: false, selected: false },
{ page: 2, rotate: 0, splitAfter: false, selected: false },
]
component.toggleSelection(0)
component.rotateSelected(90)
expect(component.pages[0].rotate).toBe(90)
component.toggleSelection(0) // deselect
component.toggleSelection(1)
component.deleteSelected()
expect(component.pages.length).toBe(1)
component.pages.push({ page: 2, rotate: 0, splitAfter: false })
component.drop({ previousIndex: 0, currentIndex: 1 } as any)
expect(component.pages[0].page).toBe(2)
component.rotate(0)
expect(component.pages[0].rotate).toBe(90)
})
it('should handle empty pages array', () => {
component.pages = []
expect(component.getOperations()).toEqual([])
})
it('should increment doc index after splitAfter', () => {
component.pages = [
{ page: 1, rotate: 0, splitAfter: true },
{ page: 2, rotate: 0, splitAfter: false },
{ page: 3, rotate: 0, splitAfter: true },
{ page: 4, rotate: 0, splitAfter: false },
]
const ops = component.getOperations()
expect(ops).toEqual([
{ page: 1, rotate: 0, doc: 0 },
{ page: 2, rotate: 0, doc: 1 },
{ page: 3, rotate: 0, doc: 1 },
{ page: 4, rotate: 0, doc: 2 },
])
})
it('should include rotations in operations', () => {
component.pages = [
{ page: 1, rotate: 90, splitAfter: false },
{ page: 2, rotate: 180, splitAfter: true },
{ page: 3, rotate: 270, splitAfter: false },
]
const ops = component.getOperations()
expect(ops).toEqual([
{ page: 1, rotate: 90, doc: 0 },
{ page: 2, rotate: 180, doc: 0 },
{ page: 3, rotate: 270, doc: 1 },
])
})
it('should handle remove operation', () => {
component.pages = [
{ page: 1, rotate: 0, splitAfter: false, selected: false },
{ page: 2, rotate: 0, splitAfter: false, selected: true },
{ page: 3, rotate: 0, splitAfter: false, selected: false },
]
component.remove(1) // remove page 2
expect(component.pages.length).toBe(2)
expect(component.pages[0].page).toBe(1)
expect(component.pages[1].page).toBe(3)
})
it('should toggle splitAfter correctly', () => {
component.pages = [
{ page: 1, rotate: 0, splitAfter: false },
{ page: 2, rotate: 0, splitAfter: false },
]
component.toggleSplit(0)
expect(component.pages[0].splitAfter).toBeTruthy()
component.toggleSplit(1)
expect(component.pages[1].splitAfter).toBeTruthy()
})
it('should select and deselect all pages', () => {
component.pages = [
{ page: 1, rotate: 0, splitAfter: false, selected: false },
{ page: 2, rotate: 0, splitAfter: false, selected: false },
]
component.selectAll()
expect(component.pages.every((p) => p.selected)).toBeTruthy()
expect(component.hasSelection()).toBeTruthy()
component.deselectAll()
expect(component.pages.every((p) => !p.selected)).toBeTruthy()
expect(component.hasSelection()).toBeFalsy()
})
it('should handle pdf loading and page generation', () => {
const mockPdf = {
numPages: 3,
getPage: (pageNum: number) => Promise.resolve({ pageNumber: pageNum }),
}
component.pdfLoaded(mockPdf as any)
expect(component.totalPages).toBe(3)
expect(component.pages.length).toBe(3)
expect(component.pages[0].page).toBe(1)
expect(component.pages[1].page).toBe(2)
expect(component.pages[2].page).toBe(3)
})
})

View File

@@ -1,134 +0,0 @@
import {
CdkDragDrop,
DragDropModule,
moveItemInArray,
} from '@angular/cdk/drag-drop'
import { Component, inject } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { DocumentService } from 'src/app/services/rest/document.service'
import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component'
interface PageOperation {
page: number
rotate: number
splitAfter: boolean
selected?: boolean
loaded?: boolean
}
export enum PdfEditorEditMode {
Update = 'update',
Create = 'create',
}
@Component({
selector: 'pngx-pdf-editor',
templateUrl: './pdf-editor.component.html',
styleUrl: './pdf-editor.component.scss',
imports: [
DragDropModule,
FormsModule,
PdfViewerModule,
NgxBootstrapIconsModule,
],
})
export class PDFEditorComponent extends ConfirmDialogComponent {
public PdfEditorEditMode = PdfEditorEditMode
private documentService = inject(DocumentService)
activeModal: NgbActiveModal = inject(NgbActiveModal)
documentID: number
pages: PageOperation[] = []
totalPages = 0
editMode: PdfEditorEditMode = PdfEditorEditMode.Create
deleteOriginal: boolean = false
includeMetadata: boolean = true
get pdfSrc(): string {
return this.documentService.getPreviewUrl(this.documentID)
}
pdfLoaded(pdf: PDFDocumentProxy) {
this.totalPages = pdf.numPages
this.pages = Array.from({ length: this.totalPages }, (_, i) => ({
page: i + 1,
rotate: 0,
splitAfter: false,
selected: false,
loaded: false,
}))
}
toggleSelection(i: number) {
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
}
rotateSelected(dir: number) {
for (let p of this.pages) {
if (p.selected) {
p.rotate = (p.rotate + dir + 360) % 360
}
}
}
remove(i: number) {
this.pages.splice(i, 1)
}
toggleSplit(i: number) {
this.pages[i].splitAfter = !this.pages[i].splitAfter
if (this.pages[i].splitAfter) {
// force create mode
this.editMode = PdfEditorEditMode.Create
}
}
selectAll() {
this.pages.forEach((p) => (p.selected = true))
}
deselectAll() {
this.pages.forEach((p) => (p.selected = false))
}
deleteSelected() {
this.pages = this.pages.filter((p) => !p.selected)
}
hasSelection(): boolean {
return this.pages.some((p) => p.selected)
}
hasSplit(): boolean {
return this.pages.some((p) => p.splitAfter)
}
drop(event: CdkDragDrop<PageOperation[]>) {
moveItemInArray(this.pages, event.previousIndex, event.currentIndex)
}
getOperations() {
return this.pages.map((p, idx) => ({
page: p.page,
rotate: p.rotate,
doc: this.computeDocIndex(idx),
}))
}
private computeDocIndex(index: number): number {
let docIndex = 0
for (let i = 0; i <= index; i++) {
if (this.pages[i].splitAfter && i < index) docIndex++
}
return docIndex
}
}

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

@@ -106,7 +106,6 @@ describe('DashboardComponent', () => {
}),
dashboardViews: saved_views.filter((v) => v.show_on_dashboard),
allViews: saved_views,
setDocumentCount: jest.fn(),
},
},
provideHttpClient(withInterceptorsFromDi()),

View File

@@ -52,7 +52,6 @@ import {
} from 'src/app/services/permissions.service'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service'
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
@@ -95,7 +94,6 @@ export class SavedViewWidgetComponent
permissionsService = inject(PermissionsService)
private settingsService = inject(SettingsService)
private customFieldService = inject(CustomFieldsService)
private savedViewService = inject(SavedViewService)
public DisplayMode = DisplayMode
public DisplayField = DisplayField
@@ -183,7 +181,6 @@ export class SavedViewWidgetComponent
this.show = true
this.documents = result.results
this.count = result.count
this.savedViewService.setDocumentCount(this.savedView, result.count)
}),
delay(500)
)

View File

@@ -54,16 +54,20 @@
<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>
<button ngbDropdownItem (click)="editPdf()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF">
<i-bs name="pencil"></i-bs>&nbsp;<ng-container i18n>PDF Editor</ng-container>
<button ngbDropdownItem (click)="splitDocument()" [disabled]="!userCanAdd || originalContentRenderType !== ContentRenderType.PDF || previewNumPages === 1">
<i-bs width="1em" height="1em" name="scissors"></i-bs>&nbsp;<span i18n>Split</span>
</button>
<button ngbDropdownItem (click)="rotateDocument()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF">
<i-bs name="arrow-clockwise"></i-bs>&nbsp;<ng-container i18n>Rotate</ng-container>
</button>
<button ngbDropdownItem (click)="deletePages()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF || previewNumPages === 1">
<i-bs name="file-earmark-minus"></i-bs>&nbsp;<ng-container i18n>Delete page(s)</ng-container>
</button>
</div>
</div>
@@ -216,14 +220,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

@@ -452,18 +452,6 @@ describe('DocumentDetailComponent', () => {
expect(navigateSpy).toHaveBeenCalledWith(['404'], { replaceUrl: true })
})
it('should navigate to 404 if error on load', () => {
jest
.spyOn(activatedRoute, 'paramMap', 'get')
.mockReturnValue(of(convertToParamMap({ id: 3, section: 'details' })))
const navigateSpy = jest.spyOn(router, 'navigate')
jest
.spyOn(documentService, 'get')
.mockReturnValue(throwError(() => new Error('not found')))
fixture.detectChanges()
expect(navigateSpy).toHaveBeenCalledWith(['404'], { replaceUrl: true })
})
it('should support save, close and show success toast', () => {
initNormally()
component.title = 'Foo Bar'
@@ -1170,43 +1158,81 @@ describe('DocumentDetailComponent', () => {
).not.toBeUndefined()
})
it('should support pdf editor, handle error', () => {
it('should support split', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0]))
const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument')
const errorSpy = jest.spyOn(toastService, 'showError')
initNormally()
component.editPdf()
component.splitDocument()
expect(modal).not.toBeUndefined()
modal.componentInstance.documentID = doc.id
modal.componentInstance.pages = [{ page: 1, rotate: 0, splitAfter: false }]
modal.componentInstance.totalPages = 5
modal.componentInstance.page = 2
modal.componentInstance.addSplit()
modal.componentInstance.confirm()
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
expect(req.request.body).toEqual({
documents: [doc.id],
method: 'edit_pdf',
parameters: {
operations: [{ page: 1, rotate: 0, doc: 0 }],
delete_original: false,
update_document: false,
include_metadata: true,
},
method: 'split',
parameters: { pages: '1-2,3-5', delete_originals: false },
})
req.error(new ErrorEvent('failed'))
expect(errorSpy).toHaveBeenCalled()
component.editPdf()
modal.componentInstance.documentID = doc.id
modal.componentInstance.pages = [{ page: 1, rotate: 0, splitAfter: true }]
modal.componentInstance.deleteOriginal = true
req.error(new ProgressEvent('failed'))
modal.componentInstance.confirm()
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
})
it('should support rotate', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0]))
initNormally()
component.rotateDocument()
expect(modal).not.toBeUndefined()
modal.componentInstance.documentID = doc.id
modal.componentInstance.rotate()
modal.componentInstance.confirm()
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
expect(req.request.body).toEqual({
documents: [doc.id],
method: 'rotate',
parameters: { degrees: 90 },
})
req.error(new ProgressEvent('failed'))
modal.componentInstance.confirm()
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
})
it('should support delete pages', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0]))
initNormally()
component.deletePages()
expect(modal).not.toBeUndefined()
modal.componentInstance.documentID = doc.id
modal.componentInstance.pages = [1, 2]
modal.componentInstance.confirm()
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
expect(req.request.body).toEqual({
documents: [doc.id],
method: 'delete_pages',
parameters: { pages: [1, 2] },
})
req.error(new ProgressEvent('failed'))
modal.componentInstance.confirm()
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
expect(closeSpy).toHaveBeenCalled()
})
it('should support keyboard shortcuts', () => {
@@ -1400,166 +1426,4 @@ describe('DocumentDetailComponent', () => {
component.openEmailDocument()
expect(modalSpy).toHaveBeenCalled()
})
it('should set previewText', () => {
initNormally()
const previewText = 'Hello world, this is a test'
httpTestingController.expectOne(component.previewUrl).flush(previewText)
expect(component.previewText).toEqual(previewText)
})
it('should set previewText to error message if preview fails', () => {
initNormally()
httpTestingController
.expectOne(component.previewUrl)
.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

@@ -21,9 +21,8 @@ import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms'
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { DeviceDetectorService } from 'ngx-device-detector'
import { BehaviorSubject, Observable, of, Subject } from 'rxjs'
import { BehaviorSubject, Observable, Subject } from 'rxjs'
import {
catchError,
debounceTime,
distinctUntilChanged,
filter,
@@ -83,6 +82,9 @@ import { getFilenameFromContentDisposition } from 'src/app/utils/http'
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
import * as UTIF from 'utif'
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
import { DeletePagesConfirmDialogComponent } from '../common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component'
import { RotateConfirmDialogComponent } from '../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
import { SplitConfirmDialogComponent } from '../common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component'
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
@@ -98,13 +100,8 @@ 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 {
PDFEditorComponent,
PdfEditorEditMode,
} from '../common/pdf-editor/pdf-editor.component'
import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-links-dialog.component'
import { DocumentHistoryComponent } from '../document-history/document-history.component'
import { DocumentNotesComponent } from '../document-notes/document-notes.component'
@@ -174,7 +171,6 @@ export enum ZoomSetting {
NgbDropdownModule,
NgxBootstrapIconsModule,
PdfViewerModule,
TextAreaComponent,
],
})
export class DocumentDetailComponent
@@ -293,10 +289,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')
@@ -334,164 +326,19 @@ export class DocumentDetailComponent
}
}
private mapDocToForm(doc: Document): any {
return {
...doc,
permissions_form: { owner: doc.owner, set_permissions: doc.permissions },
}
}
private mapFormToDoc(value: any): any {
const docValues = { ...value }
docValues['owner'] = value['permissions_form']?.owner
docValues['set_permissions'] = value['permissions_form']?.set_permissions
delete docValues['permissions_form']
return docValues
}
private prepareForm(doc: Document): void {
this.documentForm.reset(this.mapDocToForm(doc), { emitEvent: false })
if (!this.userCanEditDoc(doc)) {
this.documentForm.disable({ emitEvent: false })
} else {
this.documentForm.enable({ emitEvent: false })
}
if (doc.__changedFields) {
doc.__changedFields.forEach((field) => {
if (field === 'owner' || field === 'set_permissions') {
this.documentForm.get('permissions_form')?.markAsDirty()
} else {
this.documentForm.get(field)?.markAsDirty()
}
})
}
}
private setupDirtyTracking(
currentDocument: Document,
originalDocument: Document
): void {
this.store = new BehaviorSubject({
title: originalDocument.title,
content: originalDocument.content,
created: originalDocument.created,
correspondent: originalDocument.correspondent,
document_type: originalDocument.document_type,
storage_path: originalDocument.storage_path,
archive_serial_number: originalDocument.archive_serial_number,
tags: [...originalDocument.tags],
permissions_form: {
owner: originalDocument.owner,
set_permissions: originalDocument.permissions,
},
custom_fields: [...originalDocument.custom_fields],
})
this.isDirty$ = dirtyCheck(this.documentForm, this.store.asObservable())
this.isDirty$
.pipe(
takeUntil(this.unsubscribeNotifier),
takeUntil(this.docChangeNotifier)
)
.subscribe((dirty) =>
this.openDocumentService.setDirty(
currentDocument,
dirty,
this.getChangedFields()
)
)
}
private loadDocument(documentId: number): void {
this.previewUrl = this.documentsService.getPreviewUrl(documentId)
this.http
.get(this.previewUrl, { responseType: 'text' })
.pipe(
first(),
takeUntil(this.unsubscribeNotifier),
takeUntil(this.docChangeNotifier)
)
.subscribe({
next: (res) => (this.previewText = res.toString()),
error: (err) =>
(this.previewText = $localize`An error occurred loading content: ${
err.message ?? err.toString()
}`),
})
this.thumbUrl = this.documentsService.getThumbUrl(documentId)
this.documentsService
.get(documentId)
.pipe(
catchError(() => {
// 404 is handled in the subscribe below
return of(null)
}),
first(),
takeUntil(this.unsubscribeNotifier),
takeUntil(this.docChangeNotifier)
)
.subscribe({
next: (doc) => {
if (!doc) {
this.router.navigate(['404'], { replaceUrl: true })
return
}
this.documentId = doc.id
this.suggestions = null
const openDocument = this.openDocumentService.getOpenDocument(
this.documentId
)
const useDoc = openDocument || doc
if (openDocument) {
if (
new Date(doc.modified) > new Date(openDocument.modified) &&
!this.modalService.hasOpenModals()
) {
const modal = this.modalService.open(ConfirmDialogComponent)
modal.componentInstance.title = $localize`Document changes detected`
modal.componentInstance.messageBold = $localize`The version of this document in your browser session appears older than the existing version.`
modal.componentInstance.message = $localize`Saving the document here may overwrite other changes that were made. To restore the existing version, discard your changes or close the document.`
modal.componentInstance.cancelBtnClass = 'visually-hidden'
modal.componentInstance.btnCaption = $localize`Ok`
modal.componentInstance.confirmClicked.subscribe(() =>
modal.close()
)
}
} else {
this.openDocumentService
.openDocument(doc)
.pipe(
first(),
takeUntil(this.unsubscribeNotifier),
takeUntil(this.docChangeNotifier)
)
.subscribe()
}
this.updateComponent(useDoc)
this.titleSubject
.pipe(
debounceTime(1000),
distinctUntilChanged(),
takeUntil(this.docChangeNotifier),
takeUntil(this.unsubscribeNotifier)
)
.subscribe((titleValue) => {
if (titleValue !== this.titleInput.value) return
this.title = titleValue
this.documentForm.patchValue({ title: titleValue })
this.documentForm.get('title').markAsDirty()
})
this.setupDirtyTracking(useDoc, doc)
},
})
}
ngOnInit(): void {
this.setZoom(this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING))
this.documentForm.valueChanges
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((values) => {
.subscribe(() => {
this.error = null
Object.assign(this.document, this.mapFormToDoc(values))
const docValues = Object.assign({}, this.documentForm.value)
docValues['owner'] =
this.documentForm.get('permissions_form').value['owner']
docValues['set_permissions'] =
this.documentForm.get('permissions_form').value['set_permissions']
delete docValues['permissions_form']
Object.assign(this.document, docValues)
})
if (
@@ -543,37 +390,164 @@ export class DocumentDetailComponent
this.route.paramMap
.pipe(
filter(
(paramMap) =>
filter((paramMap) => {
// only init when changing docs & section is set
return (
+paramMap.get('id') !== this.documentId &&
paramMap.get('section')?.length > 0
),
takeUntil(this.unsubscribeNotifier)
)
.subscribe((paramMap) => {
const documentId = +paramMap.get('id')
this.docChangeNotifier.next(documentId)
this.loadDocument(documentId)
})
this.route.paramMap
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((paramMap) => {
const section = paramMap.get('section')
if (section) {
const navIDKey: string = Object.keys(DocumentDetailNavIDs).find(
(navID) => navID.toLowerCase() == section
)
if (navIDKey) {
this.activeNavID = DocumentDetailNavIDs[navIDKey]
}),
takeUntil(this.unsubscribeNotifier),
switchMap((paramMap) => {
const documentId = +paramMap.get('id')
this.docChangeNotifier.next(documentId)
// Dont wait to get the preview
this.previewUrl = this.documentsService.getPreviewUrl(documentId)
this.http.get(this.previewUrl, { responseType: 'text' }).subscribe({
next: (res) => {
this.previewText = res.toString()
},
error: (err) => {
this.previewText = $localize`An error occurred loading content: ${
err.message ?? err.toString()
}`
},
})
this.thumbUrl = this.documentsService.getThumbUrl(documentId)
return this.documentsService.get(documentId)
})
)
.pipe(
switchMap((doc) => {
this.documentId = doc.id
this.suggestions = null
const openDocument = this.openDocumentService.getOpenDocument(
this.documentId
)
if (openDocument) {
if (
new Date(doc.modified) > new Date(openDocument.modified) &&
!this.modalService.hasOpenModals()
) {
let modal = this.modalService.open(ConfirmDialogComponent)
modal.componentInstance.title = $localize`Document changes detected`
modal.componentInstance.messageBold = $localize`The version of this document in your browser session appears older than the existing version.`
modal.componentInstance.message = $localize`Saving the document here may overwrite other changes that were made. To restore the existing version, discard your changes or close the document.`
modal.componentInstance.cancelBtnClass = 'visually-hidden'
modal.componentInstance.btnCaption = $localize`Ok`
modal.componentInstance.confirmClicked.subscribe(() =>
modal.close()
)
}
if (this.documentForm.dirty) {
Object.assign(openDocument, this.documentForm.value)
openDocument['owner'] =
this.documentForm.get('permissions_form').value['owner']
openDocument['permissions'] =
this.documentForm.get('permissions_form').value[
'set_permissions'
]
delete openDocument['permissions_form']
}
if (openDocument.__changedFields) {
openDocument.__changedFields.forEach((field) => {
if (field === 'owner' || field === 'set_permissions') {
this.documentForm.get('permissions_form').markAsDirty()
} else {
this.documentForm.get(field)?.markAsDirty()
}
})
}
this.updateComponent(openDocument)
} else {
this.openDocumentService.openDocument(doc)
this.updateComponent(doc)
}
} else if (paramMap.get('id')) {
this.router.navigate(['documents', +paramMap.get('id'), 'details'], {
this.titleSubject
.pipe(
debounceTime(1000),
distinctUntilChanged(),
takeUntil(this.docChangeNotifier),
takeUntil(this.unsubscribeNotifier)
)
.subscribe({
next: (titleValue) => {
// In the rare case when the field changed just after debounced event was fired.
// We dont want to overwrite what's actually in the text field, so just return
if (titleValue !== this.titleInput.value) return
this.title = titleValue
this.documentForm.patchValue({ title: titleValue })
},
complete: () => {
// doc changed so we manually check dirty in case title was changed
if (
this.store.getValue().title !==
this.documentForm.get('title').value
) {
this.openDocumentService.setDirty(doc, true)
}
},
})
// Initialize dirtyCheck
this.store = new BehaviorSubject({
title: doc.title,
content: doc.content,
created: doc.created,
correspondent: doc.correspondent,
document_type: doc.document_type,
storage_path: doc.storage_path,
archive_serial_number: doc.archive_serial_number,
tags: [...doc.tags],
permissions_form: {
owner: doc.owner,
set_permissions: doc.permissions,
},
custom_fields: [...doc.custom_fields],
})
this.isDirty$ = dirtyCheck(
this.documentForm,
this.store.asObservable()
)
return this.isDirty$.pipe(
takeUntil(this.unsubscribeNotifier),
map((dirty) => ({ doc, dirty }))
)
})
)
.subscribe({
next: ({ doc, dirty }) => {
this.openDocumentService.setDirty(doc, dirty, this.getChangedFields())
},
error: (error) => {
this.router.navigate(['404'], {
replaceUrl: true,
})
}
},
})
this.route.paramMap.subscribe((paramMap) => {
const section = paramMap.get('section')
if (section) {
const navIDKey: string = Object.keys(DocumentDetailNavIDs).find(
(navID) => navID.toLowerCase() == section
)
if (navIDKey) {
this.activeNavID = DocumentDetailNavIDs[navIDKey]
}
} else if (paramMap.get('id')) {
this.router.navigate(['documents', +paramMap.get('id'), 'details'], {
replaceUrl: true,
})
}
})
this.hotKeyService
.addShortcut({
keys: 'control.arrowright',
@@ -699,7 +673,14 @@ export class DocumentDetailComponent
})
}
this.title = this.documentTitlePipe.transform(doc.title)
this.prepareForm(doc)
const docFormValues = Object.assign({}, doc)
docFormValues['permissions_form'] = {
owner: doc.owner,
set_permissions: doc.permissions,
}
this.documentForm.patchValue(docFormValues, { emitEvent: false })
if (!this.userCanEdit) this.documentForm.disable()
}
get customFieldFormFields(): FormArray {
@@ -802,11 +783,7 @@ export class DocumentDetailComponent
discard() {
this.documentsService
.get(this.documentId)
.pipe(
first(),
takeUntil(this.unsubscribeNotifier),
takeUntil(this.docChangeNotifier)
)
.pipe(first())
.subscribe({
next: (doc) => {
Object.assign(this.document, doc)
@@ -909,11 +886,9 @@ 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)
)
return this.documentListViewService
.getNext(this.documentId)
.pipe(map((nextDocId) => ({ nextDocId, updateResult })))
})
)
.pipe(
@@ -923,10 +898,7 @@ export class DocumentDetailComponent
return this.openDocumentService
.closeDocument(this.document)
.pipe(
map(
(closeResult) => ({ updateResult, nextDocId, closeResult }),
takeUntil(this.unsubscribeNotifier)
)
map((closeResult) => ({ updateResult, nextDocId, closeResult }))
)
}
})
@@ -1252,19 +1224,16 @@ export class DocumentDetailComponent
) {
doc.owner = this.store.value.permissions_form.owner
}
return !this.document || this.userCanEditDoc(doc)
}
private userCanEditDoc(doc: Document): boolean {
return (
this.permissionsService.currentUserCan(
!this.document ||
(this.permissionsService.currentUserCan(
PermissionAction.Change,
PermissionType.Document
) &&
this.permissionsService.currentUserHasObjectPermissions(
PermissionAction.Change,
doc
)
this.permissionsService.currentUserHasObjectPermissions(
PermissionAction.Change,
doc
))
)
}
@@ -1380,13 +1349,13 @@ export class DocumentDetailComponent
this.documentForm.updateValueAndValidity()
}
editPdf() {
let modal = this.modalService.open(PDFEditorComponent, {
splitDocument() {
let modal = this.modalService.open(SplitConfirmDialogComponent, {
backdrop: 'static',
size: 'xl',
scrollable: true,
size: 'lg',
})
modal.componentInstance.title = $localize`PDF Editor`
modal.componentInstance.title = $localize`Split confirm`
modal.componentInstance.messageBold = $localize`This operation will split the selected document(s) into new documents.`
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.documentID = this.document.id
modal.componentInstance.confirmClicked
@@ -1394,30 +1363,24 @@ export class DocumentDetailComponent
.subscribe(() => {
modal.componentInstance.buttonsEnabled = false
this.documentsService
.bulkEdit([this.document.id], 'edit_pdf', {
operations: modal.componentInstance.getOperations(),
delete_original: modal.componentInstance.deleteOriginal,
update_document:
modal.componentInstance.editMode == PdfEditorEditMode.Update,
include_metadata: modal.componentInstance.includeMetadata,
.bulkEdit([this.document.id], 'split', {
pages: modal.componentInstance.pagesString,
delete_originals: modal.componentInstance.deleteOriginal,
})
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({
next: () => {
this.toastService.showInfo(
$localize`PDF edit operation for "${this.document.title}" will begin in the background.`
$localize`Split operation for "${this.document.title}" will begin in the background.`
)
modal.close()
if (modal.componentInstance.deleteOriginal) {
this.openDocumentService.closeDocument(this.document)
}
},
error: (error) => {
if (modal) {
modal.componentInstance.buttonsEnabled = true
}
this.toastService.showError(
$localize`Error executing PDF edit operation`,
$localize`Error executing split operation`,
error
)
},
@@ -1425,41 +1388,82 @@ export class DocumentDetailComponent
})
}
printDocument() {
const printUrl = this.documentsService.getDownloadUrl(
this.document.id,
false
)
this.http
.get(printUrl, { responseType: 'blob' })
rotateDocument() {
let modal = this.modalService.open(RotateConfirmDialogComponent, {
backdrop: 'static',
size: 'lg',
})
modal.componentInstance.title = $localize`Rotate confirm`
modal.componentInstance.messageBold = $localize`This operation will permanently rotate the original version of the current document.`
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.documentID = this.document.id
modal.componentInstance.showPDFNote = false
modal.componentInstance.confirmClicked
.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)
.subscribe(() => {
modal.componentInstance.buttonsEnabled = false
this.documentsService
.bulkEdit([this.document.id], 'rotate', {
degrees: modal.componentInstance.degrees,
})
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({
next: () => {
this.toastService.show({
content: $localize`Rotation of "${this.document.title}" will begin in the background. Close and re-open the document after the operation has completed to see the changes.`,
delay: 8000,
action: this.close.bind(this),
actionName: $localize`Close`,
})
modal.close()
},
error: (error) => {
if (modal) {
modal.componentInstance.buttonsEnabled = true
}
} 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.`
)
},
this.toastService.showError(
$localize`Error executing rotate operation`,
error
)
},
})
})
}
deletePages() {
let modal = this.modalService.open(DeletePagesConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Delete pages confirm`
modal.componentInstance.messageBold = $localize`This operation will permanently delete the selected pages from the original document.`
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.documentID = this.document.id
modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
modal.componentInstance.buttonsEnabled = false
this.documentsService
.bulkEdit([this.document.id], 'delete_pages', {
pages: modal.componentInstance.pages,
})
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({
next: () => {
this.toastService.showInfo(
$localize`Delete pages operation for "${this.document.title}" will begin in the background. Close and re-open or reload this document after the operation has completed to see the changes.`
)
modal.close()
},
error: (error) => {
if (modal) {
modal.componentInstance.buttonsEnabled = true
}
this.toastService.showError(
$localize`Error executing delete pages operation`,
error
)
},
})
})
}
@@ -1484,50 +1488,43 @@ export class DocumentDetailComponent
}
private tryRenderTiff() {
this.http
.get(this.previewUrl, { responseType: 'arraybuffer' })
.pipe(
first(),
takeUntil(this.unsubscribeNotifier),
takeUntil(this.docChangeNotifier)
)
.subscribe({
next: (res) => {
/* istanbul ignore next */
try {
// See UTIF.js > _imgLoaded
const tiffIfds: any[] = UTIF.decode(res)
var vsns = tiffIfds,
ma = 0,
page = vsns[0]
if (tiffIfds[0].subIFD) vsns = vsns.concat(tiffIfds[0].subIFD)
for (var i = 0; i < vsns.length; i++) {
var img = vsns[i]
if (img['t258'] == null || img['t258'].length < 3) continue
var ar = img['t256'] * img['t257']
if (ar > ma) {
ma = ar
page = img
}
this.http.get(this.previewUrl, { responseType: 'arraybuffer' }).subscribe({
next: (res) => {
/* istanbul ignore next */
try {
// See UTIF.js > _imgLoaded
const tiffIfds: any[] = UTIF.decode(res)
var vsns = tiffIfds,
ma = 0,
page = vsns[0]
if (tiffIfds[0].subIFD) vsns = vsns.concat(tiffIfds[0].subIFD)
for (var i = 0; i < vsns.length; i++) {
var img = vsns[i]
if (img['t258'] == null || img['t258'].length < 3) continue
var ar = img['t256'] * img['t257']
if (ar > ma) {
ma = ar
page = img
}
UTIF.decodeImage(res, page, tiffIfds)
const rgba = UTIF.toRGBA8(page)
const { width: w, height: h } = page
var cnv = document.createElement('canvas')
cnv.width = w
cnv.height = h
var ctx = cnv.getContext('2d'),
imgd = ctx.createImageData(w, h)
for (var i = 0; i < rgba.length; i++) imgd.data[i] = rgba[i]
ctx.putImageData(imgd, 0, 0)
this.tiffURL = cnv.toDataURL()
} catch (err) {
this.tiffError = $localize`An error occurred loading tiff: ${err.toString()}`
}
},
error: (err) => {
UTIF.decodeImage(res, page, tiffIfds)
const rgba = UTIF.toRGBA8(page)
const { width: w, height: h } = page
var cnv = document.createElement('canvas')
cnv.width = w
cnv.height = h
var ctx = cnv.getContext('2d'),
imgd = ctx.createImageData(w, h)
for (var i = 0; i < rgba.length; i++) imgd.data[i] = rgba[i]
ctx.putImageData(imgd, 0, 0)
this.tiffURL = cnv.toDataURL()
} catch (err) {
this.tiffError = $localize`An error occurred loading tiff: ${err.toString()}`
},
})
}
},
error: (err) => {
this.tiffError = $localize`An error occurred loading tiff: ${err.toString()}`
},
})
}
}

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

@@ -199,14 +199,6 @@ describe('DocumentListComponent', () => {
}
const queryParams = { id: view.id.toString() }
const getSavedViewSpy = jest.spyOn(savedViewService, 'getCached')
const setCountSpy = jest.spyOn(savedViewService, 'setDocumentCount')
jest.spyOn(documentService, 'listFiltered').mockReturnValue(
of({
results: docs,
count: 3,
all: docs.map((d) => d.id),
})
)
getSavedViewSpy.mockReturnValue(of(view))
const activateSavedViewSpy = jest.spyOn(
documentListService,
@@ -223,7 +215,6 @@ describe('DocumentListComponent', () => {
view,
convertToParamMap(queryParams)
)
expect(setCountSpy).toHaveBeenCalledWith(view, 3)
})
it('should 404 on load saved view from URL if no view', () => {
@@ -257,34 +248,6 @@ describe('DocumentListComponent', () => {
expect(getSavedViewSpy).toHaveBeenCalledWith(view.id)
})
it('should update saved view document count on load saved view from query params', () => {
jest.spyOn(savedViewService, 'getCached').mockReturnValue(
of({
id: 10,
sort_field: 'added',
sort_reverse: true,
filter_rules: [],
})
)
jest.spyOn(documentService, 'listFiltered').mockReturnValue(
of({
results: docs,
count: 3,
all: docs.map((d) => d.id),
})
)
const setCountSpy = jest.spyOn(savedViewService, 'setDocumentCount')
jest.spyOn(documentService, 'listFiltered').mockReturnValue(
of({
results: docs,
count: 3,
all: docs.map((d) => d.id),
})
)
component.loadViewConfig(10)
expect(setCountSpy).toHaveBeenCalledWith(expect.any(Object), 3)
})
it('should support 3 different display modes', () => {
jest.spyOn(documentListService, 'documents', 'get').mockReturnValue(docs)
fixture.detectChanges()

View File

@@ -264,9 +264,7 @@ export class DocumentListComponent
view,
convertToParamMap(this.route.snapshot.queryParams)
)
this.list.reload(() => {
this.savedViewService.setDocumentCount(view, this.list.collectionSize)
})
this.list.reload()
this.updateDisplayCustomFields()
this.unmodifiedFilterRules = view.filter_rules
})
@@ -401,9 +399,7 @@ export class DocumentListComponent
.subscribe((view) => {
this.unmodifiedSavedView = view
this.list.activateSavedView(view)
this.list.reload(() => {
this.savedViewService.setDocumentCount(view, this.list.collectionSize)
})
this.list.reload()
})
}

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

@@ -54,7 +54,59 @@
</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 {
{{ 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 +127,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

@@ -53,8 +53,6 @@ export interface ManagementListColumn {
rendersHtml?: boolean
hideOnMobile?: boolean
monospace?: boolean
}
@Directive()
@@ -79,7 +77,6 @@ export abstract class ManagementListComponent<T extends MatchingModel>
@ViewChildren(SortableDirective) headers: QueryList<SortableDirective>
public data: T[] = []
private unfilteredData: T[] = []
public page = 1
@@ -133,18 +130,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 +146,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

@@ -80,7 +80,7 @@ describe('StoragePathListComponent', () => {
path: 'a'.repeat(100),
}
expect(component.extraColumns[0].valueFn(path)).toEqual(
`${'a'.repeat(49)}...`
`<code>${'a'.repeat(49)}...</code>`
)
})
})

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