mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-27 14:55:19 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b18c14188 | ||
|
|
d721a88a2f | ||
|
|
f7b4d38e39 | ||
|
|
46cf6b4583 | ||
|
|
2d701c5c1b | ||
|
|
1123d845ec | ||
|
|
dfa6308ca4 | ||
|
|
b5a17a8d11 | ||
|
|
cfac74319f | ||
|
|
f9f069b092 | ||
|
|
b2703b4605 | ||
|
|
852eb0ef36 | ||
|
|
0870d42eae | ||
|
|
e2cf95f8af | ||
|
|
a79c8dc51c | ||
|
|
4b95c2f0e5 | ||
|
|
e1c8cd779b | ||
|
|
cc7c7f31ba | ||
|
|
1d30ce2afa | ||
|
|
5aa86f8755 | ||
|
|
de2ddad5ee | ||
|
|
d2064a2535 | ||
|
|
cc621cf729 | ||
|
|
fc4134e15c | ||
|
|
ac1b420966 | ||
|
|
80595899c1 | ||
|
|
9463a8fd26 | ||
|
|
58ab137282 | ||
|
|
05c216b2a8 | ||
|
|
d6db2d3fce | ||
|
|
a6e41b4145 | ||
|
|
cb927c5b22 | ||
|
|
107374af71 | ||
|
|
a77141e133 | ||
|
|
117dfb83fe | ||
|
|
fdef774a16 | ||
|
|
08887cb8e3 | ||
|
|
7b679e11bc | ||
|
|
dbbebaeb89 | ||
|
|
d9459ac37f | ||
|
|
4e0f5dff95 | ||
|
|
10ccccc987 | ||
|
|
27d72ebb18 | ||
|
|
909ccebb34 | ||
|
|
4275e18c10 | ||
|
|
0088333360 | ||
|
|
ed1d488d6e | ||
|
|
b25b15ba32 | ||
|
|
f2fabc81d4 | ||
|
|
f94c3eeea8 | ||
|
|
bf468ac64f | ||
|
|
22064ed004 | ||
|
|
23daa0b974 | ||
|
|
7b63f5a98c | ||
|
|
7c76377477 | ||
|
|
56c70bf177 | ||
|
|
daf47f377b | ||
|
|
64f31cac0c | ||
|
|
dcc503c35f | ||
|
|
a583cff21c | ||
|
|
bfd468103b | ||
|
|
be0c1fd1ed | ||
|
|
82370963da | ||
|
|
0fdfa42a83 | ||
|
|
0f0ba92e15 | ||
|
|
5f0281e427 | ||
|
|
a0c7785881 | ||
|
|
349fbce579 | ||
|
|
217b004884 | ||
|
|
29c36542fa | ||
|
|
d5b87aeffb | ||
|
|
9225a38458 | ||
|
|
3fa89b85d7 | ||
|
|
5e6b49971f | ||
|
|
be63c79db1 | ||
|
|
26c70b69c4 | ||
|
|
e0b0dd8548 | ||
|
|
1bbac9948a | ||
|
|
ca9b5d9586 | ||
|
|
521fd1c957 | ||
|
|
f00a565130 | ||
|
|
d878bc153a | ||
|
|
f5e6951910 | ||
|
|
91102d0335 | ||
|
|
82ec1be622 | ||
|
|
01a8cf6f36 | ||
|
|
6bdb365f87 | ||
|
|
696e591a3b | ||
|
|
3c2782c3a9 | ||
|
|
a68800d53c |
No files matched your search
@@ -10,10 +10,8 @@ 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,7 +3,7 @@
|
||||
"dockerComposeFile": "docker-compose.devcontainer.sqlite-tika.yml",
|
||||
"service": "paperless-development",
|
||||
"workspaceFolder": "/usr/src/paperless/paperless-ngx",
|
||||
"postCreateCommand": "/bin/bash -c 'uv sync --group dev && uv run pre-commit install'",
|
||||
"postCreateCommand": "/bin/bash -c 'rm -rf .venv/.* && uv sync --group dev && uv run pre-commit install'",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
{
|
||||
"python.testing.pytestArgs": [
|
||||
"src"
|
||||
],
|
||||
"python.testing.pytestArgs": [],
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.pytestEnabled": true,
|
||||
"files.watcherExclude": {
|
||||
"**/.venv/**": true,
|
||||
"**/pytest_cache/**": true
|
||||
}
|
||||
},
|
||||
"python.testing.cwd": "${workspaceFolder}/src"
|
||||
}
|
||||
+14
-14
@@ -26,7 +26,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Install python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
- pre-commit
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- 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@v4
|
||||
uses: actions/checkout@v5
|
||||
- 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@v4
|
||||
- uses: actions/checkout@v5
|
||||
- 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@v4
|
||||
- uses: actions/checkout@v5
|
||||
- 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@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
@@ -288,7 +288,7 @@ jobs:
|
||||
- tests-frontend
|
||||
- tests-frontend-e2e
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- 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'))
|
||||
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_'))
|
||||
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@v4
|
||||
uses: actions/checkout@v5
|
||||
# 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@v4
|
||||
uses: actions/checkout@v5
|
||||
- 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@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: frontend-compiled
|
||||
path: src/documents/static/frontend/
|
||||
- name: Download documentation artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
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@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: release
|
||||
path: ./
|
||||
@@ -579,7 +579,7 @@ jobs:
|
||||
if: needs.publish-release.outputs.prerelease == 'false'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: main
|
||||
- name: Set up Python
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
steps:
|
||||
- name: Clean temporary images
|
||||
if: "${{ env.TOKEN != '' }}"
|
||||
uses: stumpylog/image-cleaner-action/ephemeral@v0.10.0
|
||||
uses: stumpylog/image-cleaner-action/ephemeral@v0.11.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.10.0
|
||||
uses: stumpylog/image-cleaner-action/untagged@v0.11.0
|
||||
with:
|
||||
token: "${{ env.TOKEN }}"
|
||||
owner: "${{ github.repository_owner }}"
|
||||
|
||||
@@ -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@v4
|
||||
uses: actions/checkout@v5
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
token: ${{ secrets.PNGX_BOT_PAT }}
|
||||
- name: crowdin action
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
labels.push('bug');
|
||||
} else if (/^feature/i.test(title)) {
|
||||
labels.push('enhancement');
|
||||
} else if (!/^(dependabot)/i.test(title)) {
|
||||
} else if (!/^(dependabot)/i.test(title) && !/^(chore)/i.test(title)) {
|
||||
labels.push('enhancement'); // Default fallback
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
token: ${{ secrets.PNGX_BOT_PAT }}
|
||||
ref: ${{ github.head_ref }}
|
||||
|
||||
@@ -18,7 +18,7 @@ repos:
|
||||
exclude_types:
|
||||
- svg
|
||||
- pofile
|
||||
exclude: "(^LICENSE$)"
|
||||
exclude: "(^LICENSE$|^src/documents/static/bootstrap.min.css$)"
|
||||
- id: mixed-line-ending
|
||||
args:
|
||||
- "--fix=lf"
|
||||
@@ -31,6 +31,7 @@ repos:
|
||||
rev: v2.4.1
|
||||
hooks:
|
||||
- id: codespell
|
||||
additional_dependencies: [tomli]
|
||||
exclude_types:
|
||||
- pofile
|
||||
- json
|
||||
@@ -50,7 +51,7 @@ repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.12.2
|
||||
hooks:
|
||||
- id: ruff
|
||||
- id: ruff-check
|
||||
- id: ruff-format
|
||||
- repo: https://github.com/tox-dev/pyproject-fmt
|
||||
rev: "v2.6.0"
|
||||
|
||||
+5
-19
@@ -37,6 +37,8 @@ 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).
|
||||
@@ -109,28 +111,12 @@ Paperless-ngx is a community project. We do our best to delegate permission and
|
||||
|
||||
## Structure
|
||||
|
||||
As of writing, there are 21 members in paperless-ngx. 4 of these people have complete administrative privileges to the repo:
|
||||
There are currently 2 members in paperless-ngx with complete administrative privileges to the repo:
|
||||
|
||||
- [@shamoon](https://github.com/shamoon)
|
||||
- [@bauerj](https://github.com/bauerj)
|
||||
- [@qcasey](https://github.com/qcasey)
|
||||
- [@FrankStrieter](https://github.com/FrankStrieter)
|
||||
- [@stumpylog](https://github.com/stumpylog)
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Joining
|
||||
|
||||
|
||||
+1
-1
@@ -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.8-python3.12-bookworm-slim AS s6-overlay-base
|
||||
FROM ghcr.io/astral-sh/uv:0.8.13-python3.12-bookworm-slim AS s6-overlay-base
|
||||
|
||||
WORKDIR /usr/src/s6
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# correct networking for the tests
|
||||
services:
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.20
|
||||
image: docker.io/gotenberg/gotenberg:8.22
|
||||
hostname: gotenberg
|
||||
container_name: gotenberg
|
||||
network_mode: host
|
||||
|
||||
@@ -35,7 +35,7 @@ services:
|
||||
volumes:
|
||||
- redisdata:/data
|
||||
db:
|
||||
image: docker.io/library/mariadb:11
|
||||
image: docker.io/library/mariadb:12
|
||||
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.20
|
||||
image: docker.io/gotenberg/gotenberg:8.22
|
||||
restart: unless-stopped
|
||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||
# want to allow external content like tracking pixels or even javascript.
|
||||
|
||||
@@ -31,7 +31,7 @@ services:
|
||||
volumes:
|
||||
- redisdata:/data
|
||||
db:
|
||||
image: docker.io/library/mariadb:11
|
||||
image: docker.io/library/mariadb:12
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- dbdata:/var/lib/mysql
|
||||
|
||||
@@ -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.20
|
||||
image: docker.io/gotenberg/gotenberg:8.22
|
||||
restart: unless-stopped
|
||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||
# want to allow external content like tracking pixels or even javascript.
|
||||
|
||||
@@ -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.20
|
||||
image: docker.io/gotenberg/gotenberg:8.22
|
||||
restart: unless-stopped
|
||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||
# want to allow external content like tracking pixels or even javascript.
|
||||
|
||||
@@ -185,7 +185,7 @@ the instructions from your database's documentation for how to upgrade between m
|
||||
|
||||
!!! note
|
||||
|
||||
As of Paperless-ngx v2.18, the minimum supported version of PostgreSQL is 13.
|
||||
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).
|
||||
|
||||
@@ -471,7 +471,7 @@ Failing to invalidate the cache after such modifications can lead to stale data
|
||||
Use the following management command to clear the cache:
|
||||
|
||||
```
|
||||
invalidate_cachalot
|
||||
python3 manage.py invalidate_cachalot
|
||||
```
|
||||
|
||||
!!! info
|
||||
|
||||
+13
-16
@@ -469,12 +469,12 @@ The `get_cf_value` filter retrieves a value from custom field data with optional
|
||||
|
||||
##### Datetime Formatting
|
||||
|
||||
The `format_datetime`filter formats a datetime string or datetime object using Python's strftime formatting.
|
||||
The `datetime` filter formats a datetime string or datetime object using Python's strftime formatting.
|
||||
|
||||
###### Syntax
|
||||
|
||||
```jinja2
|
||||
{{ datetime_value | format_datetime('%Y-%m-%d %H:%M:%S') }}
|
||||
{{ datetime_value | datetime('%Y-%m-%d %H:%M:%S') }}
|
||||
```
|
||||
|
||||
###### Parameters
|
||||
@@ -490,15 +490,11 @@ The `format_datetime`filter formats a datetime string or datetime object using P
|
||||
|
||||
```jinja2
|
||||
<!-- Format datetime object -->
|
||||
{{ created_at | format_datetime('%B %d, %Y at %I:%M %p') }}
|
||||
{{ created | datetime('%B %d, %Y at %I:%M %p') }}
|
||||
<!-- Output: "January 15, 2024 at 02:30 PM" -->
|
||||
|
||||
<!-- Format datetime string -->
|
||||
{{ "2024-01-15T14:30:00" | format_datetime('%m/%d/%Y') }}
|
||||
<!-- Output: "01/15/2024" -->
|
||||
|
||||
<!-- Custom formatting -->
|
||||
{{ timestamp | format_datetime('%A, %B %d, %Y') }}
|
||||
{{ custom_fields | get_cf_value('Date Field') | datetime('%A, %B %d, %Y') }}
|
||||
<!-- Output: "Monday, January 15, 2024" -->
|
||||
```
|
||||
|
||||
@@ -508,7 +504,8 @@ 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.
|
||||
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`.
|
||||
|
||||
###### Syntax
|
||||
|
||||
@@ -531,27 +528,27 @@ This takes into account the provided locale for translation.
|
||||
|
||||
```jinja2
|
||||
<!-- Preset formats -->
|
||||
{{ created_date | localize_date('short', 'en_US') }}
|
||||
{{ document.created | localize_date('short', 'en_US') }}
|
||||
<!-- Output: "1/15/24" -->
|
||||
|
||||
{{ created_date | localize_date('medium', 'en_US') }}
|
||||
{{ document.created | localize_date('medium', 'en_US') }}
|
||||
<!-- Output: "Jan 15, 2024" -->
|
||||
|
||||
{{ created_date | localize_date('long', 'en_US') }}
|
||||
{{ document.created | localize_date('long', 'en_US') }}
|
||||
<!-- Output: "January 15, 2024" -->
|
||||
|
||||
{{ created_date | localize_date('full', 'en_US') }}
|
||||
{{ document.created | localize_date('full', 'en_US') }}
|
||||
<!-- Output: "Monday, January 15, 2024" -->
|
||||
|
||||
<!-- Different locales -->
|
||||
{{ created_date | localize_date('medium', 'fr_FR') }}
|
||||
{{ document.created | localize_date('medium', 'fr_FR') }}
|
||||
<!-- Output: "15 janv. 2024" -->
|
||||
|
||||
{{ created_date | localize_date('medium', 'de_DE') }}
|
||||
{{ document.created | localize_date('medium', 'de_DE') }}
|
||||
<!-- Output: "15.01.2024" -->
|
||||
|
||||
<!-- Custom patterns -->
|
||||
{{ created_date | localize_date('dd/MM/yyyy', 'en_GB') }}
|
||||
{{ document.created | localize_date('dd/MM/yyyy', 'en_GB') }}
|
||||
<!-- Output: "15/01/2024" -->
|
||||
```
|
||||
|
||||
|
||||
@@ -1,5 +1,235 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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)
|
||||
marching your document's languages.
|
||||
matching 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
|
||||
|
||||
+10
-17
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "paperless-ngx"
|
||||
version = "2.18.0"
|
||||
description = "A community-supported supercharged version of paperless: scan, index and archive all your physical documents"
|
||||
version = "2.18.4"
|
||||
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
@@ -41,9 +41,9 @@ dependencies = [
|
||||
"drf-spectacular~=0.28",
|
||||
"drf-spectacular-sidecar~=2025.8.1",
|
||||
"drf-writable-nested~=0.7.1",
|
||||
"filelock~=3.18.0",
|
||||
"filelock~=3.19.1",
|
||||
"flower~=2.0.1",
|
||||
"gotenberg-client~=0.10.0",
|
||||
"gotenberg-client~=0.11.0",
|
||||
"httpx-oauth~=0.16",
|
||||
"imap-tools~=1.11.0",
|
||||
"inotifyrecursive~=0.3",
|
||||
@@ -60,7 +60,7 @@ dependencies = [
|
||||
"python-ipware~=3.0.0",
|
||||
"python-magic~=0.4.27",
|
||||
"pyzbar~=0.1.9",
|
||||
"rapidfuzz~=3.13.0",
|
||||
"rapidfuzz~=3.14.0",
|
||||
"redis[hiredis]~=5.2.1",
|
||||
"scikit-learn~=1.7.0",
|
||||
"setproctitle~=1.3.4",
|
||||
@@ -82,7 +82,7 @@ optional-dependencies.postgres = [
|
||||
"psycopg-pool==3.2.6",
|
||||
]
|
||||
optional-dependencies.webserver = [
|
||||
"granian[uvloop]~=2.4.1",
|
||||
"granian[uvloop]~=2.5.1",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
@@ -114,7 +114,7 @@ testing = [
|
||||
]
|
||||
|
||||
lint = [
|
||||
"pre-commit~=4.2.0",
|
||||
"pre-commit~=4.3.0",
|
||||
"pre-commit-uv~=4.1.3",
|
||||
"ruff~=0.12.2",
|
||||
]
|
||||
@@ -124,6 +124,7 @@ typing = [
|
||||
"django-filter-stubs",
|
||||
"django-stubs[compatible-mypy]",
|
||||
"djangorestframework-stubs[compatible-mypy]",
|
||||
"lxml-stubs",
|
||||
"mypy",
|
||||
"types-bleach",
|
||||
"types-colorama",
|
||||
@@ -131,6 +132,7 @@ typing = [
|
||||
"types-markdown",
|
||||
"types-pygments",
|
||||
"types-python-dateutil",
|
||||
"types-pytz",
|
||||
"types-redis",
|
||||
"types-setuptools",
|
||||
"types-tqdm",
|
||||
@@ -205,18 +207,9 @@ 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",
|
||||
]
|
||||
@@ -279,10 +272,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
|
||||
|
||||
+155
-137
File diff suppressed because it is too large.
Load diff
+33
-33
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paperless-ngx-ui",
|
||||
"version": "2.18.0",
|
||||
"version": "2.18.4",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"ng": "ng",
|
||||
@@ -11,27 +11,27 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@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",
|
||||
"@ng-bootstrap/ng-bootstrap": "^19.0.1",
|
||||
"@ng-select/ng-select": "^20.0.1",
|
||||
"@ng-select/ng-select": "^20.1.3",
|
||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"bootstrap": "^5.3.7",
|
||||
"bootstrap": "^5.3.8",
|
||||
"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.0.1",
|
||||
"ngx-device-detector": "^10.0.2",
|
||||
"ngx-cookie-service": "^20.1.0",
|
||||
"ngx-device-detector": "^10.1.0",
|
||||
"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.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",
|
||||
"@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",
|
||||
"@codecov/webpack-plugin": "^1.9.1",
|
||||
"@playwright/test": "^1.54.2",
|
||||
"@playwright/test": "^1.55.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@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",
|
||||
"@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",
|
||||
"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.0"
|
||||
"webpack": "^5.101.3"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
||||
Generated
+2112
-1523
File diff suppressed because it is too large.
Load diff
@@ -61,6 +61,40 @@ 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>
|
||||
@@ -290,40 +324,6 @@ 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,6 +340,8 @@ 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, {
|
||||
|
||||
@@ -185,7 +185,8 @@ export class SettingsComponent
|
||||
this.systemStatus.tasks.classifier_status ===
|
||||
SystemStatusItemStatus.ERROR ||
|
||||
this.systemStatus.tasks.sanity_check_status ===
|
||||
SystemStatusItemStatus.ERROR
|
||||
SystemStatusItemStatus.ERROR ||
|
||||
this.systemStatus.websocket_connected === SystemStatusItemStatus.ERROR
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -108,15 +108,16 @@
|
||||
<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" [class.text-truncate]="!slimSidebarEnabled" routerLink="view/{{view.id}}"
|
||||
<a class="nav-link" 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> {{view.name}}
|
||||
@if (showSidebarCounts && !slimSidebarEnabled) {
|
||||
<span><span class="badge bg-info text-dark ms-2 d-inline">{{ savedViewService.getDocumentCount(view) }}</span></span>
|
||||
}
|
||||
</span>
|
||||
<i-bs class="me-1" name="funnel"></i-bs>
|
||||
<span> <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>
|
||||
@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>
|
||||
}
|
||||
@@ -146,7 +147,7 @@
|
||||
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
||||
popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="file-text"></i-bs><span> {{d.title | documentTitle}}</span>
|
||||
<span class="close" (click)="closeDocument(d); $event.preventDefault()">
|
||||
<span class="close flex-column justify-content-center" (click)="closeDocument(d); $event.preventDefault()">
|
||||
<i-bs name="x"></i-bs>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
height: 0.8em;
|
||||
}
|
||||
|
||||
.view-name {
|
||||
max-width: calc(100% - 50px)
|
||||
}
|
||||
|
||||
.nav-group:not(:has(.app-link)) .sidebar-heading {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -187,7 +191,7 @@ main {
|
||||
list-style-type: none;
|
||||
|
||||
&:hover .close {
|
||||
display: block;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.close {
|
||||
|
||||
@@ -145,7 +145,7 @@ export class AppFrameComponent
|
||||
}
|
||||
|
||||
get versionString(): string {
|
||||
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.production ? '' : ` #${environment.tag}`}`
|
||||
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
|
||||
}
|
||||
|
||||
get customAppTitle(): string {
|
||||
@@ -287,6 +287,9 @@ export class AppFrameComponent
|
||||
}
|
||||
|
||||
get showSidebarCounts(): boolean {
|
||||
return this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT)
|
||||
return (
|
||||
this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT) &&
|
||||
!this.settingsService.organizingSidebarSavedViews
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"></i-bs>
|
||||
<i-bs width="1em" height="1em" name="check" class="variant-unfocused text-dark"></i-bs>
|
||||
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
|
||||
</a>
|
||||
}
|
||||
|
||||
+10
@@ -28,6 +28,16 @@
|
||||
</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>
|
||||
}
|
||||
|
||||
+38
@@ -125,4 +125,42 @@ 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
+71
-13
@@ -14,6 +14,7 @@ import {
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { takeUntil } from 'rxjs'
|
||||
import {
|
||||
@@ -28,6 +29,8 @@ 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',
|
||||
@@ -37,6 +40,7 @@ import { EditDialogComponent, EditDialogMode } from '../edit-dialog.component'
|
||||
TextComponent,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgbPaginationModule,
|
||||
NgxBootstrapIconsModule,
|
||||
],
|
||||
})
|
||||
@@ -45,6 +49,21 @@ 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>
|
||||
@@ -67,17 +86,10 @@ export class CustomFieldEditDialogComponent
|
||||
this.objectForm.get('data_type').disable()
|
||||
}
|
||||
if (this.object?.data_type === CustomFieldDataType.Select) {
|
||||
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),
|
||||
})
|
||||
)
|
||||
)
|
||||
this._allSelectOptions = [
|
||||
...(this.object.extra_data.select_options ?? []),
|
||||
]
|
||||
this.selectOptionsPage = 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +99,19 @@ 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() {
|
||||
@@ -108,6 +133,17 @@ 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
|
||||
}
|
||||
@@ -116,13 +152,35 @@ 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.selectOptions.push(
|
||||
new FormGroup({ label: new FormControl(null), id: new FormControl(null) })
|
||||
this._allSelectOptions.push({ label: null, id: null })
|
||||
this.selectOptionsPage = Math.ceil(
|
||||
this.allSelectOptions.length / SELECT_OPTION_PAGE_SIZE
|
||||
)
|
||||
}
|
||||
|
||||
public removeSelectOption(index: number) {
|
||||
this.selectOptions.removeAt(index)
|
||||
this._allSelectOptions.splice(
|
||||
index + (this.selectOptionsPage - 1) * SELECT_OPTION_PAGE_SIZE,
|
||||
1
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -147,9 +147,13 @@ export abstract class EditDialogComponent<
|
||||
)
|
||||
}
|
||||
|
||||
protected getFormValues(): any {
|
||||
return Object.assign({}, this.objectForm.value)
|
||||
}
|
||||
|
||||
save() {
|
||||
this.error = null
|
||||
const formValues = Object.assign({}, this.objectForm.value)
|
||||
const formValues = this.getFormValues()
|
||||
const permissionsObject: PermissionsFormObject =
|
||||
this.objectForm.get('permissions_form')?.value
|
||||
if (permissionsObject) {
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div cdkDropList (cdkDropListDropped)="drop($event)" cdkDropListOrientation="mixed" class="d-flex flex-wrap row-cols-5">
|
||||
<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">
|
||||
@@ -73,8 +73,8 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer flex-column">
|
||||
<div class="d-flex w-100 justify-content-between align-items-center">
|
||||
<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">
|
||||
@@ -88,16 +88,20 @@
|
||||
</label>
|
||||
</div>
|
||||
@if (editMode === PdfEditorEditMode.Create) {
|
||||
<div class="form-check ms-3">
|
||||
<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 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>
|
||||
}
|
||||
<button type="button" class="btn ms-auto 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 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>
|
||||
+12
@@ -254,6 +254,18 @@
|
||||
<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>
|
||||
|
||||
+25
-1
@@ -24,7 +24,7 @@ import {
|
||||
} from '@angular/core/testing'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { Subject, of, throwError } from 'rxjs'
|
||||
import { PaperlessTaskName } from 'src/app/data/paperless-task'
|
||||
import {
|
||||
InstallType,
|
||||
@@ -34,6 +34,7 @@ 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 = {
|
||||
@@ -77,6 +78,8 @@ 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({
|
||||
@@ -98,6 +101,12 @@ 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()
|
||||
})
|
||||
|
||||
@@ -168,4 +177,19 @@ 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
|
||||
)
|
||||
})
|
||||
})
|
||||
+23
-3
@@ -1,5 +1,5 @@
|
||||
import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard'
|
||||
import { Component, OnInit, inject } from '@angular/core'
|
||||
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
|
||||
import {
|
||||
NgbActiveModal,
|
||||
NgbModalModule,
|
||||
@@ -7,6 +7,7 @@ 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,
|
||||
@@ -18,6 +19,7 @@ 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({
|
||||
@@ -34,13 +36,14 @@ import { environment } from 'src/environments/environment'
|
||||
NgxBootstrapIconsModule,
|
||||
],
|
||||
})
|
||||
export class SystemStatusDialogComponent implements OnInit {
|
||||
export class SystemStatusDialogComponent implements OnInit, OnDestroy {
|
||||
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
|
||||
@@ -51,6 +54,7 @@ export class SystemStatusDialogComponent implements OnInit {
|
||||
public copied: boolean = false
|
||||
|
||||
private runningTasks: Set<PaperlessTaskName> = new Set()
|
||||
private unsubscribeNotifier: Subject<any> = new Subject()
|
||||
|
||||
get currentUserIsSuperUser(): boolean {
|
||||
return this.permissionsService.isSuperUser()
|
||||
@@ -65,6 +69,17 @@ export class SystemStatusDialogComponent implements OnInit {
|
||||
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() {
|
||||
@@ -97,7 +112,7 @@ export class SystemStatusDialogComponent implements OnInit {
|
||||
this.runningTasks.delete(taskName)
|
||||
this.systemStatusService.get().subscribe({
|
||||
next: (status) => {
|
||||
this.status = status
|
||||
Object.assign(this.status, status)
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -110,4 +125,9 @@ export class SystemStatusDialogComponent implements OnInit {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.unsubscribeNotifier.next(this)
|
||||
this.unsubscribeNotifier.complete()
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,7 @@ describe('DashboardComponent', () => {
|
||||
}),
|
||||
dashboardViews: saved_views.filter((v) => v.show_on_dashboard),
|
||||
allViews: saved_views,
|
||||
setDocumentCount: jest.fn(),
|
||||
},
|
||||
},
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
|
||||
+3
@@ -52,6 +52,7 @@ 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'
|
||||
@@ -94,6 +95,7 @@ export class SavedViewWidgetComponent
|
||||
permissionsService = inject(PermissionsService)
|
||||
private settingsService = inject(SettingsService)
|
||||
private customFieldService = inject(CustomFieldsService)
|
||||
private savedViewService = inject(SavedViewService)
|
||||
|
||||
public DisplayMode = DisplayMode
|
||||
public DisplayField = DisplayField
|
||||
@@ -181,6 +183,7 @@ export class SavedViewWidgetComponent
|
||||
this.show = true
|
||||
this.documents = result.results
|
||||
this.count = result.count
|
||||
this.savedViewService.setDocumentCount(this.savedView, result.count)
|
||||
}),
|
||||
delay(500)
|
||||
)
|
||||
|
||||
@@ -452,6 +452,18 @@ 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'
|
||||
@@ -1388,4 +1400,19 @@ 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')
|
||||
})
|
||||
})
|
||||
@@ -21,8 +21,9 @@ 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, Subject } from 'rxjs'
|
||||
import { BehaviorSubject, Observable, of, Subject } from 'rxjs'
|
||||
import {
|
||||
catchError,
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
@@ -327,19 +328,164 @@ 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(() => {
|
||||
.subscribe((values) => {
|
||||
this.error = null
|
||||
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)
|
||||
Object.assign(this.document, this.mapFormToDoc(values))
|
||||
})
|
||||
|
||||
if (
|
||||
@@ -391,163 +537,36 @@ export class DocumentDetailComponent
|
||||
|
||||
this.route.paramMap
|
||||
.pipe(
|
||||
filter((paramMap) => {
|
||||
// only init when changing docs & section is set
|
||||
return (
|
||||
filter(
|
||||
(paramMap) =>
|
||||
+paramMap.get('id') !== this.documentId &&
|
||||
paramMap.get('section')?.length > 0
|
||||
)
|
||||
}),
|
||||
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)
|
||||
})
|
||||
),
|
||||
takeUntil(this.unsubscribeNotifier)
|
||||
)
|
||||
.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)
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
},
|
||||
.subscribe((paramMap) => {
|
||||
const documentId = +paramMap.get('id')
|
||||
this.docChangeNotifier.next(documentId)
|
||||
this.loadDocument(documentId)
|
||||
})
|
||||
|
||||
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]
|
||||
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]
|
||||
}
|
||||
} else if (paramMap.get('id')) {
|
||||
this.router.navigate(['documents', +paramMap.get('id'), 'details'], {
|
||||
replaceUrl: true,
|
||||
})
|
||||
}
|
||||
} else if (paramMap.get('id')) {
|
||||
this.router.navigate(['documents', +paramMap.get('id'), 'details'], {
|
||||
replaceUrl: true,
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
this.hotKeyService
|
||||
.addShortcut({
|
||||
@@ -674,14 +693,7 @@ export class DocumentDetailComponent
|
||||
})
|
||||
}
|
||||
this.title = this.documentTitlePipe.transform(doc.title)
|
||||
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()
|
||||
this.prepareForm(doc)
|
||||
}
|
||||
|
||||
get customFieldFormFields(): FormArray {
|
||||
@@ -784,7 +796,11 @@ export class DocumentDetailComponent
|
||||
discard() {
|
||||
this.documentsService
|
||||
.get(this.documentId)
|
||||
.pipe(first())
|
||||
.pipe(
|
||||
first(),
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
takeUntil(this.docChangeNotifier)
|
||||
)
|
||||
.subscribe({
|
||||
next: (doc) => {
|
||||
Object.assign(this.document, doc)
|
||||
@@ -887,9 +903,11 @@ export class DocumentDetailComponent
|
||||
.patch(this.getChangedFields())
|
||||
.pipe(
|
||||
switchMap((updateResult) => {
|
||||
return this.documentListViewService
|
||||
.getNext(this.documentId)
|
||||
.pipe(map((nextDocId) => ({ nextDocId, updateResult })))
|
||||
this.savedViewService.maybeRefreshDocumentCounts()
|
||||
return this.documentListViewService.getNext(this.documentId).pipe(
|
||||
map((nextDocId) => ({ nextDocId, updateResult })),
|
||||
takeUntil(this.unsubscribeNotifier)
|
||||
)
|
||||
})
|
||||
)
|
||||
.pipe(
|
||||
@@ -899,7 +917,10 @@ export class DocumentDetailComponent
|
||||
return this.openDocumentService
|
||||
.closeDocument(this.document)
|
||||
.pipe(
|
||||
map((closeResult) => ({ updateResult, nextDocId, closeResult }))
|
||||
map(
|
||||
(closeResult) => ({ updateResult, nextDocId, closeResult }),
|
||||
takeUntil(this.unsubscribeNotifier)
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -1225,16 +1246,19 @@ export class DocumentDetailComponent
|
||||
) {
|
||||
doc.owner = this.store.value.permissions_form.owner
|
||||
}
|
||||
return !this.document || this.userCanEditDoc(doc)
|
||||
}
|
||||
|
||||
private userCanEditDoc(doc: Document): boolean {
|
||||
return (
|
||||
!this.document ||
|
||||
(this.permissionsService.currentUserCan(
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.Change,
|
||||
PermissionType.Document
|
||||
) &&
|
||||
this.permissionsService.currentUserHasObjectPermissions(
|
||||
PermissionAction.Change,
|
||||
doc
|
||||
))
|
||||
this.permissionsService.currentUserHasObjectPermissions(
|
||||
PermissionAction.Change,
|
||||
doc
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1416,43 +1440,50 @@ export class DocumentDetailComponent
|
||||
}
|
||||
|
||||
private tryRenderTiff() {
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
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()}`
|
||||
}
|
||||
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) {
|
||||
},
|
||||
error: (err) => {
|
||||
this.tiffError = $localize`An error occurred loading tiff: ${err.toString()}`
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.tiffError = $localize`An error occurred loading tiff: ${err.toString()}`
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -199,6 +199,14 @@ 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,
|
||||
@@ -215,6 +223,7 @@ describe('DocumentListComponent', () => {
|
||||
view,
|
||||
convertToParamMap(queryParams)
|
||||
)
|
||||
expect(setCountSpy).toHaveBeenCalledWith(view, 3)
|
||||
})
|
||||
|
||||
it('should 404 on load saved view from URL if no view', () => {
|
||||
@@ -248,6 +257,34 @@ 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()
|
||||
|
||||
@@ -264,7 +264,9 @@ export class DocumentListComponent
|
||||
view,
|
||||
convertToParamMap(this.route.snapshot.queryParams)
|
||||
)
|
||||
this.list.reload()
|
||||
this.list.reload(() => {
|
||||
this.savedViewService.setDocumentCount(view, this.list.collectionSize)
|
||||
})
|
||||
this.updateDisplayCustomFields()
|
||||
this.unmodifiedFilterRules = view.filter_rules
|
||||
})
|
||||
@@ -399,7 +401,9 @@ export class DocumentListComponent
|
||||
.subscribe((view) => {
|
||||
this.unmodifiedSavedView = view
|
||||
this.list.activateSavedView(view)
|
||||
this.list.reload()
|
||||
this.list.reload(() => {
|
||||
this.savedViewService.setDocumentCount(view, this.list.collectionSize)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -44,4 +44,5 @@ export interface SystemStatus {
|
||||
sanity_check_last_run: string // ISO date string
|
||||
sanity_check_error: string
|
||||
}
|
||||
websocket_connected?: SystemStatusItemStatus // added client-side
|
||||
}
|
||||
@@ -140,11 +140,15 @@ export class SavedViewService extends AbstractPaperlessService<SavedView> {
|
||||
)
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((results: Results<Document>) => {
|
||||
this.savedViewDocumentCounts.set(view.id, results.count)
|
||||
this.setDocumentCount(view, results.count)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
public setDocumentCount(view: SavedView, count: number) {
|
||||
this.savedViewDocumentCounts.set(view.id, count)
|
||||
}
|
||||
|
||||
public getDocumentCount(view: SavedView): number {
|
||||
return this.savedViewDocumentCounts.get(view.id)
|
||||
}
|
||||
|
||||
@@ -103,6 +103,7 @@ export class WebsocketStatusService {
|
||||
private documentConsumptionFinishedSubject = new Subject<FileStatus>()
|
||||
private documentConsumptionFailedSubject = new Subject<FileStatus>()
|
||||
private documentDeletedSubject = new Subject<boolean>()
|
||||
private connectionStatusSubject = new Subject<boolean>()
|
||||
|
||||
private get(taskId: string, filename?: string) {
|
||||
let status =
|
||||
@@ -153,6 +154,15 @@ export class WebsocketStatusService {
|
||||
this.statusWebSocket = new WebSocket(
|
||||
`${environment.webSocketProtocol}//${environment.webSocketHost}${environment.webSocketBaseUrl}status/`
|
||||
)
|
||||
this.statusWebSocket.onopen = () => {
|
||||
this.connectionStatusSubject.next(true)
|
||||
}
|
||||
this.statusWebSocket.onclose = () => {
|
||||
this.connectionStatusSubject.next(false)
|
||||
}
|
||||
this.statusWebSocket.onerror = () => {
|
||||
this.connectionStatusSubject.next(false)
|
||||
}
|
||||
this.statusWebSocket.onmessage = (ev: MessageEvent) => {
|
||||
const {
|
||||
type,
|
||||
@@ -286,4 +296,12 @@ export class WebsocketStatusService {
|
||||
onDocumentDeleted() {
|
||||
return this.documentDeletedSubject
|
||||
}
|
||||
|
||||
onConnectionStatus() {
|
||||
return this.connectionStatusSubject.asObservable()
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.statusWebSocket?.readyState === WebSocket.OPEN
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export const environment = {
|
||||
apiVersion: '9', // match src/paperless/settings.py
|
||||
appTitle: 'Paperless-ngx',
|
||||
tag: 'prod',
|
||||
version: '2.18.0',
|
||||
version: '2.18.4',
|
||||
webSocketHost: window.location.host,
|
||||
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
||||
webSocketBaseUrl: base_url.pathname + 'ws/',
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -32,7 +32,7 @@ except ImportError: # pragma: no cover
|
||||
logger = logging.getLogger("paperless.management.consumer")
|
||||
|
||||
|
||||
def _tags_from_path(filepath) -> list[int]:
|
||||
def _tags_from_path(filepath: Path) -> list[int]:
|
||||
"""
|
||||
Walk up the directory tree from filepath to CONSUMPTION_DIR
|
||||
and get or create Tag IDs for every directory.
|
||||
@@ -41,7 +41,7 @@ def _tags_from_path(filepath) -> list[int]:
|
||||
"""
|
||||
db.close_old_connections()
|
||||
tag_ids = set()
|
||||
path_parts = Path(filepath).relative_to(settings.CONSUMPTION_DIR).parent.parts
|
||||
path_parts = filepath.relative_to(settings.CONSUMPTION_DIR).parent.parts
|
||||
for part in path_parts:
|
||||
tag_ids.add(
|
||||
Tag.objects.get_or_create(name__iexact=part, defaults={"name": part})[0].pk,
|
||||
@@ -50,17 +50,13 @@ def _tags_from_path(filepath) -> list[int]:
|
||||
return list(tag_ids)
|
||||
|
||||
|
||||
def _is_ignored(filepath: str) -> bool:
|
||||
def _is_ignored(filepath: Path) -> bool:
|
||||
"""
|
||||
Checks if the given file should be ignored, based on configured
|
||||
patterns.
|
||||
|
||||
Returns True if the file is ignored, False otherwise
|
||||
"""
|
||||
filepath = os.path.abspath(
|
||||
os.path.normpath(filepath),
|
||||
)
|
||||
|
||||
# Trim out the consume directory, leaving only filename and it's
|
||||
# path relative to the consume directory
|
||||
filepath_relative = PurePath(filepath).relative_to(settings.CONSUMPTION_DIR)
|
||||
@@ -85,15 +81,15 @@ def _is_ignored(filepath: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _consume(filepath: str) -> None:
|
||||
if os.path.isdir(filepath) or _is_ignored(filepath):
|
||||
def _consume(filepath: Path) -> None:
|
||||
if filepath.is_dir() or _is_ignored(filepath):
|
||||
return
|
||||
|
||||
if not os.path.isfile(filepath):
|
||||
if not filepath.is_file():
|
||||
logger.debug(f"Not consuming file {filepath}: File has moved.")
|
||||
return
|
||||
|
||||
if not is_file_ext_supported(os.path.splitext(filepath)[1]):
|
||||
if not is_file_ext_supported(filepath.suffix):
|
||||
logger.warning(f"Not consuming file {filepath}: Unknown file extension.")
|
||||
return
|
||||
|
||||
@@ -107,7 +103,7 @@ def _consume(filepath: str) -> None:
|
||||
|
||||
while (read_try_count < os_error_retry_count) and not file_open_ok:
|
||||
try:
|
||||
with open(filepath, "rb"):
|
||||
with filepath.open("rb"):
|
||||
file_open_ok = True
|
||||
except OSError as e:
|
||||
read_try_count += 1
|
||||
@@ -141,7 +137,7 @@ def _consume(filepath: str) -> None:
|
||||
logger.exception("Error while consuming document")
|
||||
|
||||
|
||||
def _consume_wait_unmodified(file: str) -> None:
|
||||
def _consume_wait_unmodified(file: Path) -> None:
|
||||
"""
|
||||
Waits for the given file to appear unmodified based on file size
|
||||
and modification time. Will wait a configured number of seconds
|
||||
@@ -157,7 +153,7 @@ def _consume_wait_unmodified(file: str) -> None:
|
||||
current_try = 0
|
||||
while current_try < settings.CONSUMER_POLLING_RETRY_COUNT:
|
||||
try:
|
||||
stat_data = os.stat(file)
|
||||
stat_data = file.stat()
|
||||
new_mtime = stat_data.st_mtime
|
||||
new_size = stat_data.st_size
|
||||
except FileNotFoundError:
|
||||
@@ -182,10 +178,10 @@ class Handler(FileSystemEventHandler):
|
||||
self._pool = pool
|
||||
|
||||
def on_created(self, event):
|
||||
self._pool.submit(_consume_wait_unmodified, event.src_path)
|
||||
self._pool.submit(_consume_wait_unmodified, Path(event.src_path))
|
||||
|
||||
def on_moved(self, event):
|
||||
self._pool.submit(_consume_wait_unmodified, event.dest_path)
|
||||
self._pool.submit(_consume_wait_unmodified, Path(event.dest_path))
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -227,9 +223,9 @@ class Command(BaseCommand):
|
||||
if not directory:
|
||||
raise CommandError("CONSUMPTION_DIR does not appear to be set.")
|
||||
|
||||
directory = os.path.abspath(directory)
|
||||
directory = Path(directory).resolve()
|
||||
|
||||
if not os.path.isdir(directory):
|
||||
if not directory.is_dir():
|
||||
raise CommandError(f"Consumption directory {directory} does not exist")
|
||||
|
||||
# Consumer will need this
|
||||
@@ -238,11 +234,11 @@ class Command(BaseCommand):
|
||||
if recursive:
|
||||
for dirpath, _, filenames in os.walk(directory):
|
||||
for filename in filenames:
|
||||
filepath = os.path.join(dirpath, filename)
|
||||
filepath = Path(dirpath) / filename
|
||||
_consume(filepath)
|
||||
else:
|
||||
for entry in os.scandir(directory):
|
||||
_consume(entry.path)
|
||||
for filepath in directory.iterdir():
|
||||
_consume(filepath)
|
||||
|
||||
if options["oneshot"]:
|
||||
return
|
||||
@@ -310,7 +306,7 @@ class Command(BaseCommand):
|
||||
try:
|
||||
for event in inotify.read(timeout=timeout_ms):
|
||||
path = inotify.get_path(event.wd) if recursive else directory
|
||||
filepath = os.path.join(path, event.name)
|
||||
filepath = Path(path) / event.name
|
||||
if flags.MODIFY in flags.from_mask(event.mask):
|
||||
notified_files.pop(filepath, None)
|
||||
else:
|
||||
@@ -327,9 +323,7 @@ class Command(BaseCommand):
|
||||
|
||||
# Also make sure the file exists still, some scanners might write a
|
||||
# temporary file first
|
||||
file_still_exists = os.path.exists(filepath) and os.path.isfile(
|
||||
filepath,
|
||||
)
|
||||
file_still_exists = filepath.exists() and filepath.is_file()
|
||||
|
||||
if waited_long_enough and file_still_exists:
|
||||
_consume(filepath)
|
||||
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
import os
|
||||
import shutil
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from time import sleep
|
||||
|
||||
import pathvalidate
|
||||
@@ -50,38 +51,38 @@ def many_to_dictionary(field): # pragma: no cover
|
||||
return mydictionary
|
||||
|
||||
|
||||
def archive_name_from_filename(filename):
|
||||
return os.path.splitext(filename)[0] + ".pdf"
|
||||
def archive_name_from_filename(filename: Path) -> Path:
|
||||
return Path(filename.stem + ".pdf")
|
||||
|
||||
|
||||
def archive_path_old(doc):
|
||||
def archive_path_old(doc) -> Path:
|
||||
if doc.filename:
|
||||
fname = archive_name_from_filename(doc.filename)
|
||||
fname = archive_name_from_filename(Path(doc.filename))
|
||||
else:
|
||||
fname = f"{doc.pk:07}.pdf"
|
||||
fname = Path(f"{doc.pk:07}.pdf")
|
||||
|
||||
return os.path.join(settings.ARCHIVE_DIR, fname)
|
||||
return settings.ARCHIVE_DIR / fname
|
||||
|
||||
|
||||
STORAGE_TYPE_GPG = "gpg"
|
||||
|
||||
|
||||
def archive_path_new(doc):
|
||||
def archive_path_new(doc) -> Path | None:
|
||||
if doc.archive_filename is not None:
|
||||
return os.path.join(settings.ARCHIVE_DIR, str(doc.archive_filename))
|
||||
return settings.ARCHIVE_DIR / doc.archive_filename
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def source_path(doc):
|
||||
def source_path(doc) -> Path:
|
||||
if doc.filename:
|
||||
fname = str(doc.filename)
|
||||
fname = doc.filename
|
||||
else:
|
||||
fname = f"{doc.pk:07}{doc.file_type}"
|
||||
if doc.storage_type == STORAGE_TYPE_GPG:
|
||||
fname += ".gpg" # pragma: no cover
|
||||
fname = Path(str(fname) + ".gpg") # pragma: no cover
|
||||
|
||||
return os.path.join(settings.ORIGINALS_DIR, fname)
|
||||
return settings.ORIGINALS_DIR / fname
|
||||
|
||||
|
||||
def generate_unique_filename(doc, *, archive_filename=False):
|
||||
@@ -104,7 +105,7 @@ def generate_unique_filename(doc, *, archive_filename=False):
|
||||
# still the same as before.
|
||||
return new_filename
|
||||
|
||||
if os.path.exists(os.path.join(root, new_filename)):
|
||||
if (root / new_filename).exists():
|
||||
counter += 1
|
||||
else:
|
||||
return new_filename
|
||||
@@ -202,18 +203,18 @@ def create_archive_version(doc, retry_count=3):
|
||||
parser,
|
||||
source_path(doc),
|
||||
doc.mime_type,
|
||||
os.path.basename(doc.filename),
|
||||
Path(doc.filename).name,
|
||||
)
|
||||
doc.content = parser.get_text()
|
||||
|
||||
if parser.get_archive_path() and os.path.isfile(parser.get_archive_path()):
|
||||
if parser.get_archive_path() and Path(parser.get_archive_path()).is_file():
|
||||
doc.archive_filename = generate_unique_filename(
|
||||
doc,
|
||||
archive_filename=True,
|
||||
)
|
||||
with open(parser.get_archive_path(), "rb") as f:
|
||||
with Path(parser.get_archive_path()).open("rb") as f:
|
||||
doc.archive_checksum = hashlib.md5(f.read()).hexdigest()
|
||||
os.makedirs(os.path.dirname(archive_path_new(doc)), exist_ok=True)
|
||||
archive_path_new(doc).parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(parser.get_archive_path(), archive_path_new(doc))
|
||||
else:
|
||||
doc.archive_checksum = None
|
||||
@@ -264,7 +265,7 @@ def move_old_to_new_locations(apps, schema_editor):
|
||||
# check that archive files of all unaffected documents are in place
|
||||
for doc in Document.objects.filter(archive_checksum__isnull=False):
|
||||
old_path = archive_path_old(doc)
|
||||
if doc.id not in affected_document_ids and not os.path.isfile(old_path):
|
||||
if doc.id not in affected_document_ids and not old_path.is_file():
|
||||
raise ValueError(
|
||||
f"Archived document ID:{doc.id} does not exist at: {old_path}",
|
||||
)
|
||||
@@ -285,12 +286,12 @@ def move_old_to_new_locations(apps, schema_editor):
|
||||
if doc.id in affected_document_ids:
|
||||
old_path = archive_path_old(doc)
|
||||
# remove affected archive versions
|
||||
if os.path.isfile(old_path):
|
||||
if old_path.is_file():
|
||||
logger.debug(f"Removing {old_path}")
|
||||
os.unlink(old_path)
|
||||
old_path.unlink()
|
||||
else:
|
||||
# Set archive path for unaffected files
|
||||
doc.archive_filename = archive_name_from_filename(doc.filename)
|
||||
doc.archive_filename = archive_name_from_filename(Path(doc.filename))
|
||||
Document.objects.filter(id=doc.id).update(
|
||||
archive_filename=doc.archive_filename,
|
||||
)
|
||||
@@ -316,7 +317,7 @@ def move_new_to_old_locations(apps, schema_editor):
|
||||
f"filename.",
|
||||
)
|
||||
old_archive_paths.add(old_archive_path)
|
||||
if new_archive_path != old_archive_path and os.path.isfile(old_archive_path):
|
||||
if new_archive_path != old_archive_path and old_archive_path.is_file():
|
||||
raise ValueError(
|
||||
f"Cannot migrate: Cannot move {new_archive_path} to "
|
||||
f"{old_archive_path}: file already exists.",
|
||||
|
||||
@@ -19,6 +19,8 @@ from documents.loggers import LoggingMixin
|
||||
from documents.signals import document_consumer_declaration
|
||||
from documents.utils import copy_file_with_basic_stats
|
||||
from documents.utils import run_subprocess
|
||||
from paperless.config import OcrConfig
|
||||
from paperless.utils import ocr_to_dateparser_languages
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import datetime
|
||||
@@ -167,7 +169,7 @@ def run_convert(
|
||||
args += ["-depth", str(depth)] if depth else []
|
||||
args += ["-auto-orient"] if auto_orient else []
|
||||
args += ["-define", "pdf:use-cropbox=true"] if use_cropbox else []
|
||||
args += [input_file, output_file]
|
||||
args += [str(input_file), str(output_file)]
|
||||
|
||||
logger.debug("Execute: " + " ".join(args), extra={"group": logging_group})
|
||||
|
||||
@@ -186,8 +188,8 @@ def get_default_thumbnail() -> Path:
|
||||
return (Path(__file__).parent / "resources" / "document.webp").resolve()
|
||||
|
||||
|
||||
def make_thumbnail_from_pdf_gs_fallback(in_path, temp_dir, logging_group=None) -> str:
|
||||
out_path = os.path.join(temp_dir, "convert_gs.webp")
|
||||
def make_thumbnail_from_pdf_gs_fallback(in_path, temp_dir, logging_group=None) -> Path:
|
||||
out_path: Path = Path(temp_dir) / "convert_gs.webp"
|
||||
|
||||
# if convert fails, fall back to extracting
|
||||
# the first PDF page as a PNG using Ghostscript
|
||||
@@ -197,7 +199,7 @@ def make_thumbnail_from_pdf_gs_fallback(in_path, temp_dir, logging_group=None) -
|
||||
extra={"group": logging_group},
|
||||
)
|
||||
# Ghostscript doesn't handle WebP outputs
|
||||
gs_out_path = os.path.join(temp_dir, "gs_out.png")
|
||||
gs_out_path: Path = Path(temp_dir) / "gs_out.png"
|
||||
cmd = [settings.GS_BINARY, "-q", "-sDEVICE=pngalpha", "-o", gs_out_path, in_path]
|
||||
|
||||
try:
|
||||
@@ -225,16 +227,16 @@ def make_thumbnail_from_pdf_gs_fallback(in_path, temp_dir, logging_group=None) -
|
||||
# The caller might expect a generated thumbnail that can be moved,
|
||||
# so we need to copy it before it gets moved.
|
||||
# https://github.com/paperless-ngx/paperless-ngx/issues/3631
|
||||
default_thumbnail_path = os.path.join(temp_dir, "document.webp")
|
||||
default_thumbnail_path: Path = Path(temp_dir) / "document.webp"
|
||||
copy_file_with_basic_stats(get_default_thumbnail(), default_thumbnail_path)
|
||||
return default_thumbnail_path
|
||||
|
||||
|
||||
def make_thumbnail_from_pdf(in_path, temp_dir, logging_group=None) -> Path:
|
||||
def make_thumbnail_from_pdf(in_path: Path, temp_dir: Path, logging_group=None) -> Path:
|
||||
"""
|
||||
The thumbnail of a PDF is just a 500px wide image of the first page.
|
||||
"""
|
||||
out_path = temp_dir / "convert.webp"
|
||||
out_path: Path = temp_dir / "convert.webp"
|
||||
|
||||
# Run convert to get a decent thumbnail
|
||||
try:
|
||||
@@ -272,6 +274,11 @@ def parse_date_generator(filename, text) -> Iterator[datetime.datetime]:
|
||||
"""
|
||||
import dateparser
|
||||
|
||||
ocr_config = OcrConfig()
|
||||
languages = settings.DATE_PARSER_LANGUAGES or ocr_to_dateparser_languages(
|
||||
ocr_config.language,
|
||||
)
|
||||
|
||||
return dateparser.parse(
|
||||
ds,
|
||||
settings={
|
||||
@@ -280,7 +287,7 @@ def parse_date_generator(filename, text) -> Iterator[datetime.datetime]:
|
||||
"RETURN_AS_TIMEZONE_AWARE": True,
|
||||
"TIMEZONE": settings.TIME_ZONE,
|
||||
},
|
||||
locales=settings.DATE_PARSER_LANGUAGES,
|
||||
locales=languages,
|
||||
)
|
||||
|
||||
def __filter(date: datetime.datetime) -> datetime.datetime | None:
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
@charset "UTF-8";/*!
|
||||
* Bootstrap v5.3.1 (https://getbootstrap.com/)
|
||||
* Copyright 2011-2023 The Bootstrap Authors
|
||||
* Bootstrap v5.3.8 (https://getbootstrap.com/)
|
||||
* Copyright 2011-2025 The Bootstrap Authors
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||
*/:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:#052c65;--bs-secondary-text-emphasis:#2b2f32;--bs-success-text-emphasis:#0a3622;--bs-info-text-emphasis:#055160;--bs-warning-text-emphasis:#664d03;--bs-danger-text-emphasis:#58151c;--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:#cfe2ff;--bs-secondary-bg-subtle:#e2e3e5;--bs-success-bg-subtle:#d1e7dd;--bs-info-bg-subtle:#cff4fc;--bs-warning-bg-subtle:#fff3cd;--bs-danger-bg-subtle:#f8d7da;--bs-light-bg-subtle:#fcfcfd;--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:#9ec5fe;--bs-secondary-border-subtle:#c4c8cb;--bs-success-border-subtle:#a3cfbb;--bs-info-border-subtle:#9eeaf9;--bs-warning-border-subtle:#ffe69c;--bs-danger-border-subtle:#f1aeb5;--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:#0a58ca;--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-bg:#fff3cd;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#dee2e6;--bs-body-color-rgb:222,226,230;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb:222,226,230;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(222, 226, 230, 0.5);--bs-tertiary-color-rgb:222,226,230;--bs-tertiary-bg:#2b3035;--bs-tertiary-bg-rgb:43,48,53;--bs-primary-text-emphasis:#6ea8fe;--bs-secondary-text-emphasis:#a7acb1;--bs-success-text-emphasis:#75b798;--bs-info-text-emphasis:#6edff6;--bs-warning-text-emphasis:#ffda6a;--bs-danger-text-emphasis:#ea868f;--bs-light-text-emphasis:#f8f9fa;--bs-dark-text-emphasis:#dee2e6;--bs-primary-bg-subtle:#031633;--bs-secondary-bg-subtle:#161719;--bs-success-bg-subtle:#051b11;--bs-info-bg-subtle:#032830;--bs-warning-bg-subtle:#332701;--bs-danger-bg-subtle:#2c0b0e;--bs-light-bg-subtle:#343a40;--bs-dark-bg-subtle:#1a1d20;--bs-primary-border-subtle:#084298;--bs-secondary-border-subtle:#41464b;--bs-success-border-subtle:#0f5132;--bs-info-border-subtle:#087990;--bs-warning-border-subtle:#997404;--bs-danger-border-subtle:#842029;--bs-light-border-subtle:#495057;--bs-dark-border-subtle:#343a40;--bs-heading-color:inherit;--bs-link-color:#6ea8fe;--Line truncated
|
||||
/*# sourceMappingURL=bootstrap.min.css.map */
|
||||
*/:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:#052c65;--bs-secondary-text-emphasis:#2b2f32;--bs-success-text-emphasis:#0a3622;--bs-info-text-emphasis:#055160;--bs-warning-text-emphasis:#664d03;--bs-danger-text-emphasis:#58151c;--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:#cfe2ff;--bs-secondary-bg-subtle:#e2e3e5;--bs-success-bg-subtle:#d1e7dd;--bs-info-bg-subtle:#cff4fc;--bs-warning-bg-subtle:#fff3cd;--bs-danger-bg-subtle:#f8d7da;--bs-light-bg-subtle:#fcfcfd;--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:#9ec5fe;--bs-secondary-border-subtle:#c4c8cb;--bs-success-border-subtle:#a3cfbb;--bs-info-border-subtle:#9eeaf9;--bs-warning-border-subtle:#ffe69c;--bs-danger-border-subtle:#f1aeb5;--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:#0a58ca;--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-color:#212529;--bs-highlight-bg:#fff3cd;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#dee2e6;--bs-body-color-rgb:222,226,230;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb:222,226,230;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(222, 226, 230, 0.5);--bs-tertiary-color-rgb:222,226,230;--bs-tertiary-bg:#2b3035;--bs-tertiary-bg-rgb:43,48,53;--bs-primary-text-emphasis:#6ea8fe;--bs-secondary-text-emphasis:#a7acb1;--bs-success-text-emphasis:#75b798;--bs-info-text-emphasis:#6edff6;--bs-warning-text-emphasis:#ffda6a;--bs-danger-text-emphasis:#ea868f;--bs-light-text-emphasis:#f8f9fa;--bs-dark-text-emphasis:#dee2e6;--bs-primary-bg-subtle:#031633;--bs-secondary-bg-subtle:#161719;--bs-success-bg-subtle:#051b11;--bs-info-bg-subtle:#032830;--bs-warning-bg-subtle:#332701;--bs-danger-bg-subtle:#2c0b0e;--bs-light-bg-subtle:#343a40;--bs-dark-bg-subtle:#1a1d20;--bs-primary-border-subtle:#084298;--bs-secondary-border-subtle:#41464b;--bs-success-border-subtle:#0f5132;--bs-info-border-subtle:#087990;--bs-warning-border-subtle:#997404;--bs-danger-border-subtle:#842029;--bs-light-border-subtle:#495057;--bs-dark-border-subtle:#343a40;--bs-heading-color:inherLine truncated
|
||||
/*# sourceMappingURL=bootstrap.min.css.map */
|
||||
@@ -654,7 +654,7 @@ class TestClassifier(DirectoriesMixin, TestCase):
|
||||
},
|
||||
)
|
||||
@override_settings(
|
||||
MODEL_FILE=(Path(__file__).parent / "data" / "model.pickle").as_posix(),
|
||||
MODEL_FILE=str(Path(__file__).parent / "data" / "model.pickle"),
|
||||
)
|
||||
@pytest.mark.skip(
|
||||
reason="Disabled caching due to high memory usage - need to investigate.",
|
||||
|
||||
@@ -254,7 +254,7 @@ class TestConsumer(
|
||||
# https://github.com/jonaswinkler/paperless-ng/discussions/1037
|
||||
|
||||
filename = self.get_test_file()
|
||||
shadow_file = Path(self.dirs.scratch_dir / "._sample.pdf")
|
||||
shadow_file = Path(self.dirs.scratch_dir) / "._sample.pdf"
|
||||
|
||||
shutil.copy(filename, shadow_file)
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
from pytest_django.fixtures import SettingsWrapper
|
||||
|
||||
from documents.parsers import parse_date
|
||||
from documents.parsers import parse_date_generator
|
||||
|
||||
|
||||
@pytest.mark.django_db()
|
||||
class TestDate:
|
||||
def test_date_format_1(self):
|
||||
text = "lorem ipsum 130218 lorem ipsum"
|
||||
@@ -49,7 +51,7 @@ class TestDate:
|
||||
settings: SettingsWrapper,
|
||||
settings_timezone: ZoneInfo,
|
||||
):
|
||||
settings.DATE_PARSER_LANGUAGES = []
|
||||
settings.DATE_PARSER_LANGUAGES = ["de"]
|
||||
text = "lorem ipsum\nMärz 2019\nlorem ipsum"
|
||||
date = parse_date("", text)
|
||||
assert date == datetime.datetime(2019, 3, 1, 0, 0, tzinfo=settings_timezone)
|
||||
|
||||
@@ -258,66 +258,66 @@ class TestConsumer(DirectoriesMixin, ConsumerThreadMixin, TransactionTestCase):
|
||||
def test_is_ignored(self):
|
||||
test_paths = [
|
||||
{
|
||||
"path": (Path(self.dirs.consumption_dir) / "foo.pdf").as_posix(),
|
||||
"path": str(Path(self.dirs.consumption_dir) / "foo.pdf"),
|
||||
"ignore": False,
|
||||
},
|
||||
{
|
||||
"path": (
|
||||
Path(self.dirs.consumption_dir) / "foo" / "bar.pdf"
|
||||
).as_posix(),
|
||||
"path": str(
|
||||
Path(self.dirs.consumption_dir) / "foo" / "bar.pdf",
|
||||
),
|
||||
"ignore": False,
|
||||
},
|
||||
{
|
||||
"path": (Path(self.dirs.consumption_dir) / ".DS_STORE").as_posix(),
|
||||
"path": str(Path(self.dirs.consumption_dir) / ".DS_STORE"),
|
||||
"ignore": True,
|
||||
},
|
||||
{
|
||||
"path": (Path(self.dirs.consumption_dir) / ".DS_Store").as_posix(),
|
||||
"path": str(Path(self.dirs.consumption_dir) / ".DS_Store"),
|
||||
"ignore": True,
|
||||
},
|
||||
{
|
||||
"path": (
|
||||
Path(self.dirs.consumption_dir) / ".stfolder" / "foo.pdf"
|
||||
).as_posix(),
|
||||
"path": str(
|
||||
Path(self.dirs.consumption_dir) / ".stfolder" / "foo.pdf",
|
||||
),
|
||||
"ignore": True,
|
||||
},
|
||||
{
|
||||
"path": (Path(self.dirs.consumption_dir) / ".stfolder.pdf").as_posix(),
|
||||
"path": str(Path(self.dirs.consumption_dir) / ".stfolder.pdf"),
|
||||
"ignore": False,
|
||||
},
|
||||
{
|
||||
"path": (
|
||||
Path(self.dirs.consumption_dir) / ".stversions" / "foo.pdf"
|
||||
).as_posix(),
|
||||
"path": str(
|
||||
Path(self.dirs.consumption_dir) / ".stversions" / "foo.pdf",
|
||||
),
|
||||
"ignore": True,
|
||||
},
|
||||
{
|
||||
"path": (
|
||||
Path(self.dirs.consumption_dir) / ".stversions.pdf"
|
||||
).as_posix(),
|
||||
"path": str(
|
||||
Path(self.dirs.consumption_dir) / ".stversions.pdf",
|
||||
),
|
||||
"ignore": False,
|
||||
},
|
||||
{
|
||||
"path": (Path(self.dirs.consumption_dir) / "._foo.pdf").as_posix(),
|
||||
"path": str(Path(self.dirs.consumption_dir) / "._foo.pdf"),
|
||||
"ignore": True,
|
||||
},
|
||||
{
|
||||
"path": (Path(self.dirs.consumption_dir) / "my_foo.pdf").as_posix(),
|
||||
"path": str(Path(self.dirs.consumption_dir) / "my_foo.pdf"),
|
||||
"ignore": False,
|
||||
},
|
||||
{
|
||||
"path": (
|
||||
Path(self.dirs.consumption_dir) / "._foo" / "bar.pdf"
|
||||
).as_posix(),
|
||||
"path": str(
|
||||
Path(self.dirs.consumption_dir) / "._foo" / "bar.pdf",
|
||||
),
|
||||
"ignore": True,
|
||||
},
|
||||
{
|
||||
"path": (
|
||||
"path": str(
|
||||
Path(self.dirs.consumption_dir)
|
||||
/ "@eaDir"
|
||||
/ "SYNO@.fileindexdb"
|
||||
/ "_1jk.fnm"
|
||||
).as_posix(),
|
||||
/ "_1jk.fnm",
|
||||
),
|
||||
"ignore": True,
|
||||
},
|
||||
]
|
||||
@@ -330,7 +330,7 @@ class TestConsumer(DirectoriesMixin, ConsumerThreadMixin, TransactionTestCase):
|
||||
f'_is_ignored("{filepath}") != {expected_ignored_result}',
|
||||
)
|
||||
|
||||
@mock.patch("documents.management.commands.document_consumer.open")
|
||||
@mock.patch("documents.management.commands.document_consumer.Path.open")
|
||||
def test_consume_file_busy(self, open_mock):
|
||||
# Calling this mock always raises this
|
||||
open_mock.side_effect = OSError
|
||||
|
||||
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user