mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-09-06 21:13:43 -05:00
Compare commits
203 Commits
4feedf2add
...
feature-ai
Author | SHA1 | Date | |
---|---|---|---|
![]() |
3825023337 | ||
![]() |
b2703b4605 | ||
![]() |
852eb0ef36 | ||
![]() |
0870d42eae | ||
![]() |
e2cf95f8af | ||
![]() |
a79c8dc51c | ||
![]() |
4b95c2f0e5 | ||
![]() |
e1c8cd779b | ||
![]() |
cc7c7f31ba | ||
![]() |
1d30ce2afa | ||
![]() |
5aa86f8755 | ||
![]() |
de2ddad5ee | ||
![]() |
d2064a2535 | ||
![]() |
cc621cf729 | ||
![]() |
fc4134e15c | ||
![]() |
ac1b420966 | ||
![]() |
80595899c1 | ||
![]() |
b9e34bd793 | ||
![]() |
fcbc438ffd | ||
![]() |
9463a8fd26 | ||
![]() |
58ab137282 | ||
![]() |
05c216b2a8 | ||
![]() |
d6db2d3fce | ||
![]() |
a6e41b4145 | ||
![]() |
cb927c5b22 | ||
![]() |
107374af71 | ||
![]() |
a77141e133 | ||
![]() |
117dfb83fe | ||
![]() |
fdef774a16 | ||
![]() |
08887cb8e3 | ||
![]() |
7b679e11bc | ||
![]() |
dbbebaeb89 | ||
![]() |
d9459ac37f | ||
![]() |
4e0f5dff95 | ||
![]() |
10ccccc987 | ||
![]() |
27d72ebb18 | ||
![]() |
909ccebb34 | ||
![]() |
4275e18c10 | ||
![]() |
4076a35559 | ||
![]() |
0088333360 | ||
![]() |
ed1d488d6e | ||
![]() |
b25b15ba32 | ||
![]() |
f2fabc81d4 | ||
![]() |
f94c3eeea8 | ||
![]() |
bf468ac64f | ||
![]() |
22064ed004 | ||
![]() |
23daa0b974 | ||
![]() |
7b63f5a98c | ||
![]() |
7c76377477 | ||
![]() |
56c70bf177 | ||
![]() |
daf47f377b | ||
![]() |
64f31cac0c | ||
![]() |
dcc503c35f | ||
![]() |
a583cff21c | ||
![]() |
3bb03062b1 | ||
![]() |
bfd468103b | ||
![]() |
be0c1fd1ed | ||
![]() |
82370963da | ||
![]() |
0fdfa42a83 | ||
![]() |
0f0ba92e15 | ||
![]() |
5f0281e427 | ||
![]() |
a0c7785881 | ||
![]() |
349fbce579 | ||
![]() |
217b004884 | ||
![]() |
29c36542fa | ||
![]() |
af1928f734 | ||
![]() |
d5b87aeffb | ||
![]() |
9225a38458 | ||
![]() |
3fa89b85d7 | ||
![]() |
5e6b49971f | ||
![]() |
be63c79db1 | ||
![]() |
26c70b69c4 | ||
![]() |
e0b0dd8548 | ||
![]() |
1bbac9948a | ||
![]() |
ca9b5d9586 | ||
![]() |
7cc089599c | ||
![]() |
4c719948d9 | ||
![]() |
521fd1c957 | ||
![]() |
f00a565130 | ||
![]() |
d878bc153a | ||
![]() |
f5e6951910 | ||
![]() |
91102d0335 | ||
![]() |
82ec1be622 | ||
![]() |
01a8cf6f36 | ||
![]() |
6bdb365f87 | ||
![]() |
696e591a3b | ||
![]() |
3c2782c3a9 | ||
![]() |
a68800d53c | ||
![]() |
52a937cdcc | ||
![]() |
00e629d957 | ||
![]() |
243b3bc812 | ||
![]() |
0ccc2da9bb | ||
![]() |
b6dbbec019 | ||
![]() |
b1c406680f | ||
![]() |
42bdbc1b2d | ||
![]() |
2f529a9500 | ||
![]() |
ee6b700243 | ||
![]() |
b1a84c65ed | ||
![]() |
edb8c06e2a | ||
![]() |
867c7d9e62 | ||
![]() |
6eb0b21a44 | ||
![]() |
1b6ec65f6e | ||
![]() |
6d72ee795f | ||
![]() |
6730896894 | ||
![]() |
95ed997717 | ||
![]() |
7bd9b385aa | ||
![]() |
541108688a | ||
![]() |
74c9fedd4c | ||
![]() |
6b99c21710 | ||
![]() |
5d6ea70434 | ||
![]() |
fac1ee4283 | ||
![]() |
64ff422fef | ||
![]() |
540539643c | ||
![]() |
b52412d776 | ||
![]() |
da2ac19193 | ||
![]() |
3583470856 | ||
![]() |
5bfbe856a6 | ||
![]() |
20bae4bd41 | ||
![]() |
b94912a392 | ||
![]() |
50e6a4bd61 | ||
![]() |
87e5d82c46 | ||
![]() |
476844f32a | ||
![]() |
01285c96d4 | ||
![]() |
3e6ba34c5e | ||
![]() |
d9cbd3652a | ||
![]() |
90bd878cf2 | ||
![]() |
62e04ab2fe | ||
![]() |
dbdc67da7a | ||
![]() |
11a4e0d5ba | ||
![]() |
c4b431f5a6 | ||
![]() |
d31f4669a2 | ||
![]() |
483f1e9438 | ||
![]() |
d7a358d39d | ||
![]() |
b94a60d607 | ||
![]() |
e6d8cd6547 | ||
![]() |
e2fc7f596d | ||
![]() |
20e7f01cec | ||
![]() |
96daa5eb18 | ||
![]() |
84e17535fc | ||
![]() |
77db0c399c | ||
![]() |
e51c7a27bb | ||
![]() |
a3455c8373 | ||
![]() |
cce9dfd5b8 | ||
![]() |
3a9257f10a | ||
![]() |
3b921da6c3 | ||
![]() |
ad8519482c | ||
![]() |
fe205b31c2 | ||
![]() |
13ab148c7e | ||
![]() |
559caf72c2 | ||
![]() |
2481a66544 | ||
![]() |
f6a3882199 | ||
![]() |
8d48d398eb | ||
![]() |
b3b9a8fb5b | ||
![]() |
4cdc629e3d | ||
![]() |
5195a97e4c | ||
![]() |
96fa522394 | ||
![]() |
dd1da9f072 | ||
![]() |
d99f2d6160 | ||
![]() |
ebd46f08e5 | ||
![]() |
6f0c6f39b1 | ||
![]() |
0690fd36c5 | ||
![]() |
0052f21cea | ||
![]() |
c809a65571 | ||
![]() |
bb3336f7bc | ||
![]() |
a9ed46de11 | ||
![]() |
1ccaf66869 | ||
![]() |
e864a51497 | ||
![]() |
4a28be233e | ||
![]() |
9183bfc0a4 | ||
![]() |
5f26139a5f | ||
![]() |
ccfc7d98b1 | ||
![]() |
d1bd2af49c | ||
![]() |
e2eec6dc71 | ||
![]() |
42e3684211 | ||
![]() |
df8f07555f | ||
![]() |
3660336bcf | ||
![]() |
aeceaf60a2 | ||
![]() |
959ebdbb85 | ||
![]() |
eb1c49090b | ||
![]() |
9f8b8a9f20 | ||
![]() |
f5fc04cfe2 | ||
![]() |
3186550fd7 | ||
![]() |
74aaf18630 | ||
![]() |
e6a147079d | ||
![]() |
105b823fd9 | ||
![]() |
be20c48588 | ||
![]() |
377dcc39f5 | ||
![]() |
767118fa8a | ||
![]() |
339612f4ec | ||
![]() |
e7592c6269 | ||
![]() |
ffc0b936f3 | ||
![]() |
1a6540e8ed | ||
![]() |
abbf9060d0 | ||
![]() |
11a3dfe890 | ||
![]() |
faa5d3e5b9 | ||
![]() |
8d1a8c2c42 | ||
![]() |
01dc3cc17c | ||
![]() |
cfbd5af820 | ||
![]() |
e8090fd030 | ||
![]() |
05896d5b70 | ||
![]() |
65b8a74166 | ||
![]() |
56b1c7adeb | ||
![]() |
55cb9cedc7 |
@@ -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"
|
||||
|
@@ -1,3 +0,0 @@
|
||||
[codespell]
|
||||
write-changes = True
|
||||
ignore-words-list = criterias,afterall,valeu,ureue,equest,ure,assertIn
|
@@ -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"
|
||||
}
|
||||
|
28
.github/workflows/ci.yml
vendored
28
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
4
.github/workflows/cleanup-tags.yml
vendored
4
.github/workflows/cleanup-tags.yml
vendored
@@ -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 }}"
|
||||
|
2
.github/workflows/codeql-analysis.yml
vendored
2
.github/workflows/codeql-analysis.yml
vendored
@@ -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
|
||||
|
2
.github/workflows/crowdin.yml
vendored
2
.github/workflows/crowdin.yml
vendored
@@ -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
|
||||
|
2
.github/workflows/pr-bot.yml
vendored
2
.github/workflows/pr-bot.yml
vendored
@@ -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
|
||||
}
|
||||
|
||||
|
2
.github/workflows/translate-strings.yml
vendored
2
.github/workflows/translate-strings.yml
vendored
@@ -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,7 +31,7 @@ repos:
|
||||
rev: v2.4.1
|
||||
hooks:
|
||||
- id: codespell
|
||||
exclude: "(^src-ui/src/locale/)|(^src-ui/pnpm-lock.yaml)|(^src-ui/e2e/)|(^src/paperless_mail/tests/samples/)|(^src/documents/tests/samples/)"
|
||||
additional_dependencies: [tomli]
|
||||
exclude_types:
|
||||
- pofile
|
||||
- json
|
||||
@@ -51,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"
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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.4-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.
|
||||
|
@@ -11,6 +11,7 @@ for command in decrypt_documents \
|
||||
mail_fetcher \
|
||||
document_create_classifier \
|
||||
document_index \
|
||||
document_llmindex \
|
||||
document_renamer \
|
||||
document_retagger \
|
||||
document_thumbnails \
|
||||
|
14
docker/rootfs/usr/local/bin/document_llmindex
Executable file
14
docker/rootfs/usr/local/bin/document_llmindex
Executable file
@@ -0,0 +1,14 @@
|
||||
#!/command/with-contenv /usr/bin/bash
|
||||
# shellcheck shell=bash
|
||||
|
||||
set -e
|
||||
|
||||
cd "${PAPERLESS_SRC_DIR}"
|
||||
|
||||
if [[ $(id -u) == 0 ]]; then
|
||||
s6-setuidgid paperless python3 manage.py document_llmindex "$@"
|
||||
elif [[ $(id -un) == "paperless" ]]; then
|
||||
python3 manage.py document_llmindex "$@"
|
||||
else
|
||||
echo "Unknown user."
|
||||
fi
|
@@ -179,10 +179,14 @@ following:
|
||||
|
||||
### Database Upgrades
|
||||
|
||||
In general, paperless does not require a specific version of PostgreSQL or MariaDB and it is
|
||||
Paperless-ngx is compatible with Django-supported versions of PostgreSQL and MariaDB and it is generally
|
||||
safe to update them to newer versions. However, you should always take a backup and follow
|
||||
the instructions from your database's documentation for how to upgrade between major versions.
|
||||
|
||||
!!! note
|
||||
|
||||
As of Paperless-ngx v2.18, the minimum supported version of PostgreSQL is 14.
|
||||
|
||||
For PostgreSQL, refer to [Upgrading a PostgreSQL Cluster](https://www.postgresql.org/docs/current/upgrading.html).
|
||||
|
||||
For MariaDB, refer to [Upgrading MariaDB](https://mariadb.com/kb/en/upgrading/)
|
||||
@@ -467,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
|
||||
|
@@ -434,6 +434,133 @@ provided. The template is provided as a string, potentially multiline, and rende
|
||||
In addition, the entire Document instance is available to be utilized in a more advanced way, as well as some variables which only make sense to be accessed
|
||||
with more complex logic.
|
||||
|
||||
#### Custom Jinja2 Filters
|
||||
|
||||
##### Custom Field Access
|
||||
|
||||
The `get_cf_value` filter retrieves a value from custom field data with optional default fallback.
|
||||
|
||||
###### Syntax
|
||||
|
||||
```jinja2
|
||||
{{ custom_fields | get_cf_value('field_name') }}
|
||||
{{ custom_fields | get_cf_value('field_name', 'default_value') }}
|
||||
```
|
||||
|
||||
###### Parameters
|
||||
|
||||
- `custom_fields`: This _must_ be the provided custom field data
|
||||
- `name` (str): Name of the custom field to retrieve
|
||||
- `default` (str, optional): Default value to return if field is not found or has no value
|
||||
|
||||
###### Returns
|
||||
|
||||
- `str | None`: The field value, default value, or `None` if neither exists
|
||||
|
||||
###### Examples
|
||||
|
||||
```jinja2
|
||||
<!-- Basic usage -->
|
||||
{{ custom_fields | get_cf_value('department') }}
|
||||
|
||||
<!-- With default value -->
|
||||
{{ custom_fields | get_cf_value('phone', 'Not provided') }}
|
||||
```
|
||||
|
||||
##### Datetime Formatting
|
||||
|
||||
The `datetime` filter formats a datetime string or datetime object using Python's strftime formatting.
|
||||
|
||||
###### Syntax
|
||||
|
||||
```jinja2
|
||||
{{ datetime_value | datetime('%Y-%m-%d %H:%M:%S') }}
|
||||
```
|
||||
|
||||
###### Parameters
|
||||
|
||||
- `value` (str | datetime): Date/time value to format (strings will be parsed automatically)
|
||||
- `format` (str): Python strftime format string
|
||||
|
||||
###### Returns
|
||||
|
||||
- `str`: Formatted datetime string
|
||||
|
||||
###### Examples
|
||||
|
||||
```jinja2
|
||||
<!-- Format datetime object -->
|
||||
{{ created | datetime('%B %d, %Y at %I:%M %p') }}
|
||||
<!-- Output: "January 15, 2024 at 02:30 PM" -->
|
||||
|
||||
<!-- Custom formatting -->
|
||||
{{ custom_fields | get_cf_value('Date Field') | datetime('%A, %B %d, %Y') }}
|
||||
<!-- Output: "Monday, January 15, 2024" -->
|
||||
```
|
||||
|
||||
See the [strftime format code documentation](https://docs.python.org/3.13/library/datetime.html#strftime-and-strptime-format-codes)
|
||||
for the possible codes and their meanings.
|
||||
|
||||
##### Date Localization
|
||||
|
||||
The `localize_date` filter formats a date or datetime object into a localized string using Babel internationalization.
|
||||
This takes into account the provided locale for translation. Since this must be used on a date or datetime object,
|
||||
you must access the field directly, i.e. `document.created`.
|
||||
|
||||
###### Syntax
|
||||
|
||||
```jinja2
|
||||
{{ date_value | localize_date('medium', 'en_US') }}
|
||||
{{ datetime_value | localize_date('short', 'fr_FR') }}
|
||||
```
|
||||
|
||||
###### Parameters
|
||||
|
||||
- `value` (date | datetime): Date or datetime object to format (datetime should be timezone-aware)
|
||||
- `format` (str): Format type - either a Babel preset ('short', 'medium', 'long', 'full') or custom pattern
|
||||
- `locale` (str): Locale code for localization (e.g., 'en_US', 'fr_FR', 'de_DE')
|
||||
|
||||
###### Returns
|
||||
|
||||
- `str`: Localized, formatted date string
|
||||
|
||||
###### Examples
|
||||
|
||||
```jinja2
|
||||
<!-- Preset formats -->
|
||||
{{ document.created | localize_date('short', 'en_US') }}
|
||||
<!-- Output: "1/15/24" -->
|
||||
|
||||
{{ document.created | localize_date('medium', 'en_US') }}
|
||||
<!-- Output: "Jan 15, 2024" -->
|
||||
|
||||
{{ document.created | localize_date('long', 'en_US') }}
|
||||
<!-- Output: "January 15, 2024" -->
|
||||
|
||||
{{ document.created | localize_date('full', 'en_US') }}
|
||||
<!-- Output: "Monday, January 15, 2024" -->
|
||||
|
||||
<!-- Different locales -->
|
||||
{{ document.created | localize_date('medium', 'fr_FR') }}
|
||||
<!-- Output: "15 janv. 2024" -->
|
||||
|
||||
{{ document.created | localize_date('medium', 'de_DE') }}
|
||||
<!-- Output: "15.01.2024" -->
|
||||
|
||||
<!-- Custom patterns -->
|
||||
{{ document.created | localize_date('dd/MM/yyyy', 'en_GB') }}
|
||||
<!-- Output: "15/01/2024" -->
|
||||
```
|
||||
|
||||
See the [supported format codes](https://unicode.org/reports/tr35/tr35-dates.html#Date_Format_Patterns) for more options.
|
||||
|
||||
### Format Presets
|
||||
|
||||
- **short**: Abbreviated format (e.g., "1/15/24")
|
||||
- **medium**: Medium-length format (e.g., "Jan 15, 2024")
|
||||
- **long**: Long format with full month name (e.g., "January 15, 2024")
|
||||
- **full**: Full format including day of week (e.g., "Monday, January 15, 2024")
|
||||
|
||||
#### Additional Variables
|
||||
|
||||
- `{{ tag_name_list }}`: A list of tag names applied to the document, ordered by the tag name. Note this is a list, not a single string
|
||||
|
12
docs/api.md
12
docs/api.md
@@ -282,6 +282,18 @@ The following methods are supported:
|
||||
- `"merge": true or false` (defaults to false)
|
||||
- The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including
|
||||
removing them) or be merged with existing permissions.
|
||||
- `edit_pdf`
|
||||
- Requires `parameters`:
|
||||
- `"doc_ids": [DOCUMENT_ID]` A list of a single document ID to edit.
|
||||
- `"operations": [OPERATION, ...]` A list of operations to perform on the documents. Each operation is a dictionary
|
||||
with the following keys:
|
||||
- `"page": PAGE_NUMBER` The page number to edit (1-based).
|
||||
- `"rotate": DEGREES` Optional rotation in degrees (90, 180, 270).
|
||||
- `"doc": OUTPUT_DOCUMENT_INDEX` Optional index of the output document for split operations.
|
||||
- Optional `parameters`:
|
||||
- `"delete_original": true` to delete the original documents after editing.
|
||||
- `"update_document": true` to update the existing document with the edited PDF.
|
||||
- `"include_metadata": true` to copy metadata from the original document to the edited document.
|
||||
- `merge`
|
||||
- No additional `parameters` required.
|
||||
- The ordering of the merged document is determined by the list of IDs.
|
||||
|
@@ -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
|
||||
@@ -5423,9 +5653,6 @@ This release contains new database migrations.
|
||||
Paperless will continue to work with WSGI, but you will not get any
|
||||
status notifications.
|
||||
|
||||
Apache `mod_wsgi` users, see
|
||||
[this note](faq.md#how-do-i-get-websocket-support-with-apache-mod_wsgi).
|
||||
|
||||
- Paperless now offers suggestions for tags, correspondents and types
|
||||
on the document detail page.
|
||||
|
||||
@@ -6227,11 +6454,12 @@ primarily.
|
||||
who are doing active development on Paperless using the Docker
|
||||
environment:
|
||||
[#376](https://github.com/the-paperless-project/paperless/pull/376).
|
||||
- You now also have the ability to customise the interface to your
|
||||
- ~~You now also have the ability to customise the interface to your
|
||||
heart's content by creating a file called `overrides.css` and/or
|
||||
`overrides.js` in the root of your media directory. Thanks to [Mark
|
||||
McFate](https://github.com/SummittDweller) for this idea:
|
||||
[#371](https://github.com/the-paperless-project/paperless/issues/371)
|
||||
[#371](https://github.com/the-paperless-project/paperless/issues/371)~~
|
||||
(Not supported by Paperless-ngx)
|
||||
|
||||
### 2.0.0
|
||||
|
||||
|
@@ -1282,6 +1282,30 @@ within your documents.
|
||||
|
||||
Defaults to false.
|
||||
|
||||
## Workflow webhooks
|
||||
|
||||
#### [`PAPERLESS_WEBHOOKS_ALLOWED_SCHEMES=<str>`](#PAPERLESS_WEBHOOKS_ALLOWED_SCHEMES) {#PAPERLESS_WEBHOOKS_ALLOWED_SCHEMES}
|
||||
|
||||
: A comma-separated list of allowed schemes for webhooks. This setting
|
||||
controls which URL schemes are permitted for webhook URLs.
|
||||
|
||||
Defaults to `http,https`.
|
||||
|
||||
#### [`PAPERLESS_WEBHOOKS_ALLOWED_PORTS=<str>`](#PAPERLESS_WEBHOOKS_ALLOWED_PORTS) {#PAPERLESS_WEBHOOKS_ALLOWED_PORTS}
|
||||
|
||||
: A comma-separated list of allowed ports for webhooks. This setting
|
||||
controls which ports are permitted for webhook URLs. For example, if you
|
||||
set this to `80,443`, webhooks will only be sent to URLs that use these
|
||||
ports.
|
||||
|
||||
Defaults to empty list, which allows all ports.
|
||||
|
||||
#### [`PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS=<bool>`](#PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS) {#PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS}
|
||||
|
||||
: If set to false, webhooks cannot be sent to internal URLs (e.g., localhost).
|
||||
|
||||
Defaults to true, which allows internal requests.
|
||||
|
||||
### Polling {#polling}
|
||||
|
||||
#### [`PAPERLESS_CONSUMER_POLLING=<num>`](#PAPERLESS_CONSUMER_POLLING) {#PAPERLESS_CONSUMER_POLLING}
|
||||
@@ -1776,3 +1800,67 @@ password. All of these options come from their similarly-named [Django settings]
|
||||
#### [`PAPERLESS_EMAIL_USE_SSL=<bool>`](#PAPERLESS_EMAIL_USE_SSL) {#PAPERLESS_EMAIL_USE_SSL}
|
||||
|
||||
: Defaults to false.
|
||||
|
||||
## AI {#ai}
|
||||
|
||||
#### [`PAPERLESS_AI_ENABLED=<bool>`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED}
|
||||
|
||||
: Enables the AI features in Paperless. This includes the AI-based
|
||||
suggestions. This setting is required to be set to true in order to use the AI features.
|
||||
|
||||
Defaults to false.
|
||||
|
||||
#### [`PAPERLESS_AI_LLM_EMBEDDING_BACKEND=<str>`](#PAPERLESS_AI_LLM_EMBEDDING_BACKEND) {#PAPERLESS_AI_LLM_EMBEDDING_BACKEND}
|
||||
|
||||
: The embedding backend to use for RAG. This can be either "openai" or "huggingface".
|
||||
|
||||
Defaults to None.
|
||||
|
||||
#### [`PAPERLESS_AI_LLM_EMBEDDING_MODEL=<str>`](#PAPERLESS_AI_LLM_EMBEDDING_MODEL) {#PAPERLESS_AI_LLM_EMBEDDING_MODEL}
|
||||
|
||||
: The model to use for the embedding backend for RAG. This can be set to any of the embedding models supported by the current embedding backend. If not supplied, defaults to "text-embedding-3-small" for OpenAI and "sentence-transformers/all-MiniLM-L6-v2" for Huggingface.
|
||||
|
||||
Defaults to None.
|
||||
|
||||
#### [`PAPERLESS_AI_BACKEND=<str>`](#PAPERLESS_AI_BACKEND) {#PAPERLESS_AI_BACKEND}
|
||||
|
||||
: The AI backend to use. This can be either "openai" or "ollama". If set to "ollama", the AI
|
||||
features will be run locally on your machine. If set to "openai", the AI features will be run
|
||||
using the OpenAI API. This setting is required to be set to use the AI features.
|
||||
|
||||
Defaults to None.
|
||||
|
||||
!!! note
|
||||
|
||||
The OpenAI API is a paid service. You will need to set up an OpenAI account and
|
||||
will be charged for usage incurred by Paperless-ngx features and your document data
|
||||
will (of course) be sent to the OpenAI API. Paperless-ngx does not endorse the use of the
|
||||
OpenAI API in any way.
|
||||
|
||||
Refer to the OpenAI terms of service, and use at your own risk.
|
||||
|
||||
#### [`PAPERLESS_AI_LLM_MODEL=<str>`](#PAPERLESS_AI_LLM_MODEL) {#PAPERLESS_AI_LLM_MODEL}
|
||||
|
||||
: The model to use for the AI backend, i.e. "gpt-3.5-turbo", "gpt-4" or any of the models supported by the
|
||||
current backend. If not supplied, defaults to "gpt-3.5-turbo" for OpenAI and "llama3" for Ollama.
|
||||
|
||||
Defaults to None.
|
||||
|
||||
#### [`PAPERLESS_AI_LLM_API_KEY=<str>`](#PAPERLESS_AI_LLM_API_KEY) {#PAPERLESS_AI_LLM_API_KEY}
|
||||
|
||||
: The API key to use for the AI backend. This is required for the OpenAI backend only.
|
||||
|
||||
Defaults to None.
|
||||
|
||||
#### [`PAPERLESS_AI_LLM_ENDPOINT=<str>`](#PAPERLESS_AI_LLM_ENDPOINT) {#PAPERLESS_AI_LLM_ENDPOINT}
|
||||
|
||||
: The endpoint / url to use for the AI backend. This is required for the Ollama backend only.
|
||||
|
||||
Defaults to None.
|
||||
|
||||
#### [`PAPERLESS_AI_LLM_INDEX_TASK_CRON=<cron expression>`](#PAPERLESS_AI_LLM_INDEX_TASK_CRON) {#PAPERLESS_AI_LLM_INDEX_TASK_CRON}
|
||||
|
||||
: Configures the schedule to update the AI embeddings of text content and metadata for all documents. Only performed if
|
||||
AI is enabled and the LLM embedding backend is set.
|
||||
|
||||
Defaults to `10 2 * * *`, once per day.
|
||||
|
@@ -25,11 +25,12 @@ physical documents into a searchable online archive so you can keep, well, _less
|
||||
## Features
|
||||
|
||||
- **Organize and index** your scanned documents with tags, correspondents, types, and more.
|
||||
- _Your_ data is stored locally on _your_ server and is never transmitted or shared in any way.
|
||||
- _Your_ data is stored locally on _your_ server and is never transmitted or shared in any way, unless you explicitly choose to do so.
|
||||
- Performs **OCR** on your documents, adding searchable and selectable text, even to documents scanned with only images.
|
||||
- Utilizes the open-source Tesseract engine to recognize more than 100 languages.
|
||||
- Documents are saved as PDF/A format which is designed for long term storage, alongside the unaltered originals.
|
||||
- Uses machine-learning to automatically add tags, correspondents and document types to your documents.
|
||||
- **New**: Paperless-ngx can now leverage AI (Large Language Models or LLMs) for document suggestions. This is an optional feature that can be enabled (and is disabled by default).
|
||||
- Supports PDF documents, images, plain text files, Office documents (Word, Excel, PowerPoint, and LibreOffice equivalents)[^1] and more.
|
||||
- Paperless stores your documents plain on disk. Filenames and folders are managed by paperless and their format can be configured freely with different configurations assigned to different documents.
|
||||
- **Beautiful, modern web application** that features:
|
||||
|
@@ -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
|
||||
|
@@ -264,6 +264,28 @@ Once setup, navigating to the email settings page in Paperless-ngx will allow yo
|
||||
You can also submit a document using the REST API, see [POSTing documents](api.md#file-uploads)
|
||||
for details.
|
||||
|
||||
## Document Suggestions
|
||||
|
||||
Paperless-ngx can suggest tags, correspondents, document types and storage paths for documents based on the content of the document. This is done using a (non-LLM) machine learning model that is trained on the documents in your database. The suggestions are shown in the document detail page and can be accepted or rejected by the user.
|
||||
|
||||
## AI Features
|
||||
|
||||
Paperless-ngx includes several features that use AI to enhance the document management experience. These features are optional and can be enabled or disabled in the settings. If you are using the AI features, you may want to also enable the "LLM index" feature, which supports Retrieval-Augmented Generation (RAG) designed to improve the quality of AI responses. The LLM index feature is not enabled by default and requires additional configuration.
|
||||
|
||||
!!! warning
|
||||
|
||||
Remember that Paperless-ngx will send document content to the AI provider you have configured, so consider the privacy implications of using these features, especially if using a remote model (e.g. OpenAI), instead of the default local model.
|
||||
|
||||
The AI features work by creating an embedding of the text content and metadata of documents, which is then used for various tasks such as similarity search and question answering. This uses the FAISS vector store.
|
||||
|
||||
### AI-Enhanced Suggestions
|
||||
|
||||
If enabled, Paperless-ngx can use an AI LLM model to suggest document titles, dates, tags, correspondents and document types for documents. This feature will always be "opt-in" and does not disable the existing classifier-based suggestion system. Currently, both remote (via the OpenAI API) and local (via Ollama) models are supported, see [configuration](configuration.md#ai) for details.
|
||||
|
||||
### Document Chat
|
||||
|
||||
Paperless-ngx can use an AI LLM model to answer questions about a document or across multiple documents. Again, this feature works best when RAG is enabled. The chat feature is available in the upper app toolbar and will switch between chatting across multiple documents or a single document based on the current view.
|
||||
|
||||
## Sharing documents from Paperless-ngx
|
||||
|
||||
Paperless-ngx supports sharing documents with other users by assigning them [permissions](#object-permissions)
|
||||
@@ -499,6 +521,10 @@ The following workflow action types are available:
|
||||
- Encoding for the request body, either JSON or form data
|
||||
- The request headers as key-value pairs
|
||||
|
||||
For security reasons, webhooks can be limited to specific ports and disallowed from connecting to local URLs. See the relevant
|
||||
[configuration settings](configuration.md#workflow-webhooks) to change this behavior. If you are allowing non-admins to create workflows,
|
||||
you may want to adjust these settings to prevent abuse.
|
||||
|
||||
#### Workflow placeholders
|
||||
|
||||
Some workflow text can include placeholders but the available options differ depending on the type of
|
||||
@@ -576,12 +602,14 @@ The following custom field types are supported:
|
||||
|
||||
## PDF Actions
|
||||
|
||||
Paperless-ngx supports four basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files):
|
||||
Paperless-ngx supports basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files). When viewing an individual document you can
|
||||
open the 'PDF Editor' to use a simple UI for re-arranging, rotating, deleting pages and splitting documents.
|
||||
|
||||
- Merging documents: available when selecting multiple documents for 'bulk editing'.
|
||||
- Rotating documents: available when selecting multiple documents for 'bulk editing' and from an individual document's details page.
|
||||
- Splitting documents: available from an individual document's details page.
|
||||
- Deleting pages: available from an individual document's details page.
|
||||
- Rotating documents: available when selecting multiple documents for 'bulk editing' and via the pdf editor on an individual document's details page.
|
||||
- Splitting documents: via the pdf editor on an individual document's details page.
|
||||
- Deleting pages: via the pdf editor on an individual document's details page.
|
||||
- Re-arranging pages: via the pdf editor on an individual document's details page.
|
||||
|
||||
!!! important
|
||||
|
||||
|
@@ -47,6 +47,7 @@ markdown_extensions:
|
||||
- pymdownx.superfences
|
||||
- pymdownx.inlinehilite
|
||||
- pymdownx.snippets
|
||||
- pymdownx.tilde
|
||||
- footnotes
|
||||
- pymdownx.superfences:
|
||||
custom_fences:
|
||||
|
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "paperless-ngx"
|
||||
version = "2.17.1"
|
||||
description = "A community-supported supercharged version of paperless: scan, index and archive all your physical documents"
|
||||
version = "2.18.3"
|
||||
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
@@ -15,6 +15,7 @@ classifiers = [
|
||||
# This will allow testing to not install a webserver, mysql, etc
|
||||
|
||||
dependencies = [
|
||||
"babel>=2.17",
|
||||
"bleach~=6.2.0",
|
||||
"celery[redis]~=5.5.1",
|
||||
"channels~=4.2",
|
||||
@@ -23,33 +24,41 @@ dependencies = [
|
||||
"dateparser~=1.2",
|
||||
# WARNING: django does not use semver.
|
||||
# Only patch versions are guaranteed to not introduce breaking changes.
|
||||
"django~=5.1.7",
|
||||
"django~=5.2.5",
|
||||
"django-allauth[socialaccount,mfa]~=65.4.0",
|
||||
"django-auditlog~=3.1.2",
|
||||
"django-auditlog~=3.2.1",
|
||||
"django-cachalot~=2.8.0",
|
||||
"django-celery-results~=2.6.0",
|
||||
"django-compression-middleware~=0.5.0",
|
||||
"django-cors-headers~=4.7.0",
|
||||
"django-extensions~=4.1",
|
||||
"django-filter~=25.1",
|
||||
"django-guardian~=2.4.0",
|
||||
"django-multiselectfield~=0.1.13",
|
||||
"django-guardian~=3.0.3",
|
||||
"django-multiselectfield~=1.0.1",
|
||||
"django-soft-delete~=1.0.18",
|
||||
"djangorestframework~=3.15",
|
||||
"djangorestframework-guardian~=0.3.0",
|
||||
"djangorestframework~=3.16",
|
||||
"djangorestframework-guardian~=0.4.0",
|
||||
"drf-spectacular~=0.28",
|
||||
"drf-spectacular-sidecar~=2025.4.1",
|
||||
"drf-spectacular-sidecar~=2025.8.1",
|
||||
"drf-writable-nested~=0.7.1",
|
||||
"filelock~=3.18.0",
|
||||
"faiss-cpu>=1.10",
|
||||
"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",
|
||||
"jinja2~=3.1.5",
|
||||
"langdetect~=1.0.9",
|
||||
"llama-index-core>=0.12.33.post1",
|
||||
"llama-index-embeddings-huggingface>=0.5.3",
|
||||
"llama-index-embeddings-openai>=0.3.1",
|
||||
"llama-index-llms-ollama>=0.5.4",
|
||||
"llama-index-llms-openai>=0.3.38",
|
||||
"llama-index-vector-stores-faiss>=0.3",
|
||||
"nltk~=3.9.1",
|
||||
"ocrmypdf~=16.10.0",
|
||||
"openai>=1.76",
|
||||
"pathvalidate~=3.3.1",
|
||||
"pdf2image~=1.17.0",
|
||||
"psycopg-pool",
|
||||
@@ -59,9 +68,10 @@ 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",
|
||||
"sentence-transformers>=4.1",
|
||||
"setproctitle~=1.3.4",
|
||||
"tika-client~=0.10.0",
|
||||
"tqdm~=4.67.1",
|
||||
@@ -81,7 +91,7 @@ optional-dependencies.postgres = [
|
||||
"psycopg-pool==3.2.6",
|
||||
]
|
||||
optional-dependencies.webserver = [
|
||||
"granian[uvloop]~=2.4.1",
|
||||
"granian[uvloop]~=2.5.1",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
@@ -103,7 +113,7 @@ testing = [
|
||||
"imagehash",
|
||||
"pytest~=8.4.1",
|
||||
"pytest-cov~=6.2.1",
|
||||
"pytest-django~=4.10.0",
|
||||
"pytest-django~=4.11.1",
|
||||
"pytest-env",
|
||||
"pytest-httpx",
|
||||
"pytest-mock",
|
||||
@@ -113,7 +123,7 @@ testing = [
|
||||
]
|
||||
|
||||
lint = [
|
||||
"pre-commit~=4.2.0",
|
||||
"pre-commit~=4.3.0",
|
||||
"pre-commit-uv~=4.1.3",
|
||||
"ruff~=0.12.2",
|
||||
]
|
||||
@@ -204,23 +214,19 @@ lint.per-file-ignores."docker/wait-for-redis.py" = [
|
||||
"INP001",
|
||||
"T201",
|
||||
]
|
||||
lint.per-file-ignores."src/documents/management/commands/document_consumer.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/documents/migrations/1012_fix_archive_files.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/documents/models.py" = [
|
||||
"SIM115",
|
||||
]
|
||||
lint.per-file-ignores."src/documents/parsers.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/paperless_tesseract/tests/test_parser.py" = [
|
||||
"RUF001",
|
||||
]
|
||||
lint.isort.force-single-line = true
|
||||
|
||||
[tool.codespell]
|
||||
write-changes = true
|
||||
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober"
|
||||
skip = "src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/documents/tests/samples/*,*.po,*.json"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
minversion = "8.0"
|
||||
pythonpath = [
|
||||
@@ -233,6 +239,7 @@ testpaths = [
|
||||
"src/paperless_tesseract/tests/",
|
||||
"src/paperless_tika/tests",
|
||||
"src/paperless_text/tests/",
|
||||
"src/paperless_ai/tests",
|
||||
]
|
||||
addopts = [
|
||||
"--pythonwarnings=all",
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paperless-ngx-ui",
|
||||
"version": "2.17.1",
|
||||
"version": "2.18.3",
|
||||
"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": [
|
||||
|
3635
src-ui/pnpm-lock.yaml
generated
3635
src-ui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -121,6 +121,26 @@ if (!URL.revokeObjectURL) {
|
||||
}
|
||||
Object.defineProperty(window, 'ResizeObserver', { value: mock() })
|
||||
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
class MockIntersectionObserver {
|
||||
constructor(
|
||||
public callback: IntersectionObserverCallback,
|
||||
public options?: IntersectionObserverInit
|
||||
) {}
|
||||
|
||||
observe = jest.fn()
|
||||
unobserve = jest.fn()
|
||||
disconnect = jest.fn()
|
||||
takeRecords = jest.fn()
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'IntersectionObserver', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: MockIntersectionObserver,
|
||||
})
|
||||
}
|
||||
|
||||
HTMLCanvasElement.prototype.getContext = <
|
||||
typeof HTMLCanvasElement.prototype.getContext
|
||||
>jest.fn()
|
||||
|
@@ -35,8 +35,12 @@
|
||||
@case (ConfigOptionType.String) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
||||
@case (ConfigOptionType.JSON) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
||||
@case (ConfigOptionType.File) { <pngx-input-file [formControlName]="option.key" (upload)="uploadFile($event, option.key)" [error]="errors[option.key]"></pngx-input-file> }
|
||||
@case (ConfigOptionType.Password) { <pngx-input-password [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-password> }
|
||||
}
|
||||
</div>
|
||||
@if (option.note) {
|
||||
<div class="form-text fst-italic">{{option.note}}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -29,6 +29,7 @@ import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { FileComponent } from '../../common/input/file/file.component'
|
||||
import { NumberComponent } from '../../common/input/number/number.component'
|
||||
import { PasswordComponent } from '../../common/input/password/password.component'
|
||||
import { SelectComponent } from '../../common/input/select/select.component'
|
||||
import { SwitchComponent } from '../../common/input/switch/switch.component'
|
||||
import { TextComponent } from '../../common/input/text/text.component'
|
||||
@@ -46,6 +47,7 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
|
||||
TextComponent,
|
||||
NumberComponent,
|
||||
FileComponent,
|
||||
PasswordComponent,
|
||||
AsyncPipe,
|
||||
NgbNavModule,
|
||||
FormsModule,
|
||||
|
@@ -322,6 +322,9 @@ describe('SettingsComponent', () => {
|
||||
sanity_check_status: SystemStatusItemStatus.ERROR,
|
||||
sanity_check_last_run: new Date().toISOString(),
|
||||
sanity_check_error: 'Error running sanity check.',
|
||||
llmindex_status: SystemStatusItemStatus.DISABLED,
|
||||
llmindex_last_modified: new Date().toISOString(),
|
||||
llmindex_error: null,
|
||||
},
|
||||
}
|
||||
jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status))
|
||||
|
@@ -30,6 +30,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<ul ngbNav class="order-sm-3">
|
||||
@if (aiEnabled) {
|
||||
<pngx-chat></pngx-chat>
|
||||
}
|
||||
<pngx-toasts-dropdown></pngx-toasts-dropdown>
|
||||
<li ngbDropdown class="nav-item dropdown">
|
||||
<button class="btn ps-1 border-0" id="userDropdown" ngbDropdownToggle>
|
||||
@@ -108,15 +111,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 +150,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 {
|
||||
|
@@ -44,6 +44,7 @@ import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { TasksService } from 'src/app/services/tasks.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { ChatComponent } from '../chat/chat/chat.component'
|
||||
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
||||
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
||||
@@ -59,6 +60,7 @@ import { ToastsDropdownComponent } from './toasts-dropdown/toasts-dropdown.compo
|
||||
DocumentTitlePipe,
|
||||
IfPermissionsDirective,
|
||||
ToastsDropdownComponent,
|
||||
ChatComponent,
|
||||
RouterModule,
|
||||
NgClass,
|
||||
NgbDropdownModule,
|
||||
@@ -145,7 +147,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 {
|
||||
@@ -171,6 +173,10 @@ export class AppFrameComponent
|
||||
})
|
||||
}
|
||||
|
||||
get aiEnabled(): boolean {
|
||||
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
|
||||
}
|
||||
|
||||
closeMenu() {
|
||||
this.isMenuCollapsed = true
|
||||
}
|
||||
@@ -287,6 +293,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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,5 @@
|
||||
|
||||
<li ngbDropdown class="nav-item" (openChange)="onOpenChange($event)">
|
||||
<li ngbDropdown class="nav-item mx-1" (openChange)="onOpenChange($event)">
|
||||
@if (toasts.length) {
|
||||
<span class="badge rounded-pill z-3 pe-none bg-secondary me-2 position-absolute top-0 left-0">{{ toasts.length }}</span>
|
||||
}
|
||||
|
35
src-ui/src/app/components/chat/chat/chat.component.html
Normal file
35
src-ui/src/app/components/chat/chat/chat.component.html
Normal file
@@ -0,0 +1,35 @@
|
||||
|
||||
<li ngbDropdown class="nav-item me-n2" (openChange)="onOpenChange($event)">
|
||||
<button class="btn border-0" id="chatDropdown" ngbDropdownToggle>
|
||||
<i-bs width="1.3em" height="1.3em" name="chatSquareDots"></i-bs>
|
||||
</button>
|
||||
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
|
||||
<div class="chat-container bg-light p-2">
|
||||
<div class="chat-messages font-monospace small">
|
||||
@for (message of messages; track message) {
|
||||
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'">
|
||||
<span class="p-2 m-2" [class.bg-dark]="message.role === 'user'">
|
||||
{{ message.content }}
|
||||
@if (message.isStreaming) { <span class="blinking-cursor">|</span> }
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
<div #scrollAnchor></div>
|
||||
</div>
|
||||
|
||||
<form class="chat-input">
|
||||
<div class="input-group">
|
||||
<input
|
||||
#chatInput
|
||||
class="form-control form-control-sm" name="chatInput" type="text"
|
||||
[placeholder]="placeholder"
|
||||
[disabled]="loading"
|
||||
[(ngModel)]="input"
|
||||
(keydown)="searchInputKeyDown($event)"
|
||||
/>
|
||||
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading">Send</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
37
src-ui/src/app/components/chat/chat/chat.component.scss
Normal file
37
src-ui/src/app/components/chat/chat/chat.component.scss
Normal file
@@ -0,0 +1,37 @@
|
||||
.dropdown-menu {
|
||||
width: var(--pngx-toast-max-width);
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
max-height: 350px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dropdown-toggle::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
white-space: initial;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 400px) {
|
||||
:host ::ng-deep .dropdown-menu-end {
|
||||
right: -3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.blinking-cursor {
|
||||
font-weight: bold;
|
||||
font-size: 1.2em;
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
from, to {
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
132
src-ui/src/app/components/chat/chat/chat.component.spec.ts
Normal file
132
src-ui/src/app/components/chat/chat/chat.component.spec.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { ElementRef } from '@angular/core'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { NavigationEnd, Router } from '@angular/router'
|
||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { Subject } from 'rxjs'
|
||||
import { ChatService } from 'src/app/services/chat.service'
|
||||
import { ChatComponent } from './chat.component'
|
||||
|
||||
describe('ChatComponent', () => {
|
||||
let component: ChatComponent
|
||||
let fixture: ComponentFixture<ChatComponent>
|
||||
let chatService: ChatService
|
||||
let router: Router
|
||||
let routerEvents$: Subject<NavigationEnd>
|
||||
let mockStream$: Subject<string>
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [NgxBootstrapIconsModule.pick(allIcons), ChatComponent],
|
||||
providers: [
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(ChatComponent)
|
||||
router = TestBed.inject(Router)
|
||||
routerEvents$ = new Subject<any>()
|
||||
jest
|
||||
.spyOn(router, 'events', 'get')
|
||||
.mockReturnValue(routerEvents$.asObservable())
|
||||
chatService = TestBed.inject(ChatService)
|
||||
mockStream$ = new Subject<string>()
|
||||
jest
|
||||
.spyOn(chatService, 'streamChat')
|
||||
.mockReturnValue(mockStream$.asObservable())
|
||||
component = fixture.componentInstance
|
||||
|
||||
jest.useFakeTimers()
|
||||
|
||||
fixture.detectChanges()
|
||||
|
||||
component.scrollAnchor.nativeElement.scrollIntoView = jest.fn()
|
||||
})
|
||||
|
||||
it('should update documentId on initialization', () => {
|
||||
jest.spyOn(router, 'url', 'get').mockReturnValue('/documents/123')
|
||||
component.ngOnInit()
|
||||
expect(component.documentId).toBe(123)
|
||||
})
|
||||
|
||||
it('should update documentId on navigation', () => {
|
||||
component.ngOnInit()
|
||||
routerEvents$.next(new NavigationEnd(1, '/documents/456', '/documents/456'))
|
||||
expect(component.documentId).toBe(456)
|
||||
})
|
||||
|
||||
it('should return correct placeholder based on documentId', () => {
|
||||
component.documentId = 123
|
||||
expect(component.placeholder).toBe('Ask a question about this document...')
|
||||
component.documentId = undefined
|
||||
expect(component.placeholder).toBe('Ask a question about a document...')
|
||||
})
|
||||
|
||||
it('should send a message and handle streaming response', () => {
|
||||
component.input = 'Hello'
|
||||
component.sendMessage()
|
||||
|
||||
expect(component.messages.length).toBe(2)
|
||||
expect(component.messages[0].content).toBe('Hello')
|
||||
expect(component.loading).toBe(true)
|
||||
|
||||
mockStream$.next('Hi')
|
||||
expect(component.messages[1].content).toBe('H')
|
||||
mockStream$.next('Hi there')
|
||||
// advance time to process the typewriter effect
|
||||
jest.advanceTimersByTime(1000)
|
||||
expect(component.messages[1].content).toBe('Hi there')
|
||||
|
||||
mockStream$.complete()
|
||||
expect(component.loading).toBe(false)
|
||||
expect(component.messages[1].isStreaming).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle errors during streaming', () => {
|
||||
component.input = 'Hello'
|
||||
component.sendMessage()
|
||||
|
||||
mockStream$.error('Error')
|
||||
expect(component.messages[1].content).toContain(
|
||||
'⚠️ Error receiving response.'
|
||||
)
|
||||
expect(component.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('should enqueue typewriter chunks correctly', () => {
|
||||
const message = { content: '', role: 'assistant', isStreaming: true }
|
||||
component.enqueueTypewriter(null, message as any) // coverage for null
|
||||
component.enqueueTypewriter('Hello', message as any)
|
||||
expect(component['typewriterBuffer'].length).toBe(4)
|
||||
})
|
||||
|
||||
it('should scroll to bottom after sending a message', () => {
|
||||
const scrollSpy = jest.spyOn(
|
||||
ChatComponent.prototype as any,
|
||||
'scrollToBottom'
|
||||
)
|
||||
component.input = 'Test'
|
||||
component.sendMessage()
|
||||
expect(scrollSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should focus chat input when dropdown is opened', () => {
|
||||
const focus = jest.fn()
|
||||
component.chatInput = {
|
||||
nativeElement: { focus: focus },
|
||||
} as unknown as ElementRef<HTMLInputElement>
|
||||
|
||||
component.onOpenChange(true)
|
||||
jest.advanceTimersByTime(15)
|
||||
expect(focus).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should send message on Enter key press', () => {
|
||||
jest.spyOn(component, 'sendMessage')
|
||||
const event = new KeyboardEvent('keydown', { key: 'Enter' })
|
||||
component.searchInputKeyDown(event)
|
||||
expect(component.sendMessage).toHaveBeenCalled()
|
||||
})
|
||||
})
|
140
src-ui/src/app/components/chat/chat/chat.component.ts
Normal file
140
src-ui/src/app/components/chat/chat/chat.component.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { Component, ElementRef, inject, OnInit, ViewChild } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NavigationEnd, Router } from '@angular/router'
|
||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { filter, map } from 'rxjs'
|
||||
import { ChatMessage, ChatService } from 'src/app/services/chat.service'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-chat',
|
||||
imports: [
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgxBootstrapIconsModule,
|
||||
NgbDropdownModule,
|
||||
],
|
||||
templateUrl: './chat.component.html',
|
||||
styleUrl: './chat.component.scss',
|
||||
})
|
||||
export class ChatComponent implements OnInit {
|
||||
public messages: ChatMessage[] = []
|
||||
public loading = false
|
||||
public input: string = ''
|
||||
public documentId!: number
|
||||
|
||||
private chatService: ChatService = inject(ChatService)
|
||||
private router: Router = inject(Router)
|
||||
|
||||
@ViewChild('scrollAnchor') scrollAnchor!: ElementRef<HTMLDivElement>
|
||||
@ViewChild('chatInput') chatInput!: ElementRef<HTMLInputElement>
|
||||
|
||||
private typewriterBuffer: string[] = []
|
||||
private typewriterActive = false
|
||||
|
||||
public get placeholder(): string {
|
||||
return this.documentId
|
||||
? $localize`Ask a question about this document...`
|
||||
: $localize`Ask a question about a document...`
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.updateDocumentId(this.router.url)
|
||||
this.router.events
|
||||
.pipe(
|
||||
filter((event) => event instanceof NavigationEnd),
|
||||
map((event) => (event as NavigationEnd).url)
|
||||
)
|
||||
.subscribe((url) => {
|
||||
this.updateDocumentId(url)
|
||||
})
|
||||
}
|
||||
|
||||
private updateDocumentId(url: string): void {
|
||||
const docIdRe = url.match(/^\/documents\/(\d+)/)
|
||||
this.documentId = docIdRe ? +docIdRe[1] : undefined
|
||||
}
|
||||
|
||||
sendMessage(): void {
|
||||
if (!this.input.trim()) return
|
||||
|
||||
const userMessage: ChatMessage = { role: 'user', content: this.input }
|
||||
this.messages.push(userMessage)
|
||||
this.scrollToBottom()
|
||||
|
||||
const assistantMessage: ChatMessage = {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
isStreaming: true,
|
||||
}
|
||||
this.messages.push(assistantMessage)
|
||||
this.loading = true
|
||||
|
||||
let lastPartialLength = 0
|
||||
|
||||
this.chatService.streamChat(this.documentId, this.input).subscribe({
|
||||
next: (chunk) => {
|
||||
const delta = chunk.substring(lastPartialLength)
|
||||
lastPartialLength = chunk.length
|
||||
this.enqueueTypewriter(delta, assistantMessage)
|
||||
},
|
||||
error: () => {
|
||||
assistantMessage.content += '\n\n⚠️ Error receiving response.'
|
||||
assistantMessage.isStreaming = false
|
||||
this.loading = false
|
||||
},
|
||||
complete: () => {
|
||||
assistantMessage.isStreaming = false
|
||||
this.loading = false
|
||||
this.scrollToBottom()
|
||||
},
|
||||
})
|
||||
|
||||
this.input = ''
|
||||
}
|
||||
|
||||
enqueueTypewriter(chunk: string, message: ChatMessage): void {
|
||||
if (!chunk) return
|
||||
|
||||
this.typewriterBuffer.push(...chunk.split(''))
|
||||
|
||||
if (!this.typewriterActive) {
|
||||
this.typewriterActive = true
|
||||
this.playTypewriter(message)
|
||||
}
|
||||
}
|
||||
|
||||
playTypewriter(message: ChatMessage): void {
|
||||
if (this.typewriterBuffer.length === 0) {
|
||||
this.typewriterActive = false
|
||||
return
|
||||
}
|
||||
|
||||
const nextChar = this.typewriterBuffer.shift()!
|
||||
message.content += nextChar
|
||||
this.scrollToBottom()
|
||||
|
||||
setTimeout(() => this.playTypewriter(message), 10) // 10ms per character
|
||||
}
|
||||
|
||||
private scrollToBottom(): void {
|
||||
setTimeout(() => {
|
||||
this.scrollAnchor?.nativeElement?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, 50)
|
||||
}
|
||||
|
||||
public onOpenChange(open: boolean): void {
|
||||
if (open) {
|
||||
setTimeout(() => {
|
||||
this.chatInput.nativeElement.focus()
|
||||
}, 10)
|
||||
}
|
||||
}
|
||||
|
||||
public searchInputKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
this.sendMessage()
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,54 +0,0 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="btn-toolbar flex-nowrap">
|
||||
<div class="input-group input-group-sm">
|
||||
<div class="input-group-text" i18n>Page</div>
|
||||
<input class="form-control mw-60" type="number" min="1" [(ngModel)]="currentPage" />
|
||||
<div class="input-group-text" i18n>of {{totalPages}}</div>
|
||||
</div>
|
||||
<div class="input-group input-group-sm ms-auto">
|
||||
<span class="input-group-text" i18n>Pages to remove</span>
|
||||
<input [ngModel]="pagesString" class="form-control" disabled />
|
||||
</div>
|
||||
</div>
|
||||
<div class="pdf-viewer-container w-100 mt-3">
|
||||
<pdf-viewer #pdfViewer [src]="pdfSrc" [(page)]="currentPage"
|
||||
[original-size]="false"
|
||||
[zoom]="1"
|
||||
zoom-scale="page-fit"
|
||||
[render-text]="false"
|
||||
(pagerendered)="pageRendered($event)"
|
||||
(after-load-complete)="pdfPreviewLoaded($event)">
|
||||
</pdf-viewer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer flex-nowrap">
|
||||
<div>
|
||||
@if (message) {
|
||||
<p [innerHTML]="message | safeHtml"></p>
|
||||
}
|
||||
@if (messageBold) {
|
||||
<p class="mb-0 small"><b [innerHTML]="messageBold | safeHtml"></b></p>
|
||||
}
|
||||
</div>
|
||||
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
|
||||
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
||||
</button>
|
||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
|
||||
{{btnCaption}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ng-template #pageCheckOverlay let-page="page" let-pages="pages">
|
||||
<div class="position-absolute top-0 start-0 w-100 h-100 p-2" (click)="pageCheckChanged(page)">
|
||||
<input type="checkbox" class="form-check-input" />
|
||||
</div>
|
||||
</ng-template>
|
@@ -1,28 +0,0 @@
|
||||
.pdf-viewer-container {
|
||||
background-color: gray;
|
||||
height: 550px;
|
||||
|
||||
pdf-viewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.mw-60 {
|
||||
max-width: 60px;
|
||||
}
|
||||
|
||||
div.position-absolute:has(.form-check-input:checked) {
|
||||
background-color: rgba(var(--bs-dark-rgb), 0.4);
|
||||
}
|
||||
|
||||
.form-check-input {
|
||||
&:checked {
|
||||
background-color: var(--bs-danger);
|
||||
border-color: var(--bs-danger);
|
||||
}
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 0.25rem rgba(var(--bs-danger-rgb), var(--pngx-focus-alpha));
|
||||
border-color: var(--bs-danger);
|
||||
}
|
||||
}
|
@@ -1,60 +0,0 @@
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
|
||||
import { DeletePagesConfirmDialogComponent } from './delete-pages-confirm-dialog.component'
|
||||
|
||||
describe('DeletePagesConfirmDialogComponent', () => {
|
||||
let component: DeletePagesConfirmDialogComponent
|
||||
let fixture: ComponentFixture<DeletePagesConfirmDialogComponent>
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [],
|
||||
imports: [
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
DeletePagesConfirmDialogComponent,
|
||||
],
|
||||
providers: [
|
||||
NgbActiveModal,
|
||||
SafeHtmlPipe,
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
}).compileComponents()
|
||||
fixture = TestBed.createComponent(DeletePagesConfirmDialogComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should return a string with comma-separated pages', () => {
|
||||
component.pages = [1, 2, 3, 4]
|
||||
expect(component.pagesString).toEqual('1, 2, 3, 4')
|
||||
})
|
||||
|
||||
it('should update totalPages when pdf is loaded', () => {
|
||||
component.pdfPreviewLoaded({ numPages: 5 } as any)
|
||||
expect(component.totalPages).toEqual(5)
|
||||
})
|
||||
|
||||
it('should update checks when page is rendered', () => {
|
||||
const event = {
|
||||
target: document.createElement('div'),
|
||||
detail: { pageNumber: 1 },
|
||||
} as any
|
||||
component.pageRendered(event)
|
||||
expect(component['checks'].length).toEqual(1)
|
||||
})
|
||||
|
||||
it('should update pages when page check is changed', () => {
|
||||
component.pageCheckChanged(1)
|
||||
expect(component.pages).toEqual([1])
|
||||
component.pageCheckChanged(1)
|
||||
expect(component.pages).toEqual([])
|
||||
})
|
||||
})
|
@@ -1,69 +0,0 @@
|
||||
import { Component, TemplateRef, ViewChild, inject } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import {
|
||||
PDFDocumentProxy,
|
||||
PdfViewerComponent,
|
||||
PdfViewerModule,
|
||||
} from 'ng2-pdf-viewer'
|
||||
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-delete-pages-confirm-dialog',
|
||||
templateUrl: './delete-pages-confirm-dialog.component.html',
|
||||
styleUrl: './delete-pages-confirm-dialog.component.scss',
|
||||
imports: [PdfViewerModule, FormsModule, ReactiveFormsModule, SafeHtmlPipe],
|
||||
})
|
||||
export class DeletePagesConfirmDialogComponent extends ConfirmDialogComponent {
|
||||
private documentService = inject(DocumentService)
|
||||
|
||||
public documentID: number
|
||||
public pages: number[] = []
|
||||
public currentPage: number = 1
|
||||
public totalPages: number
|
||||
|
||||
@ViewChild('pdfViewer') pdfViewer: PdfViewerComponent
|
||||
@ViewChild('pageCheckOverlay') pageCheckOverlay!: TemplateRef<any>
|
||||
private checks: HTMLElement[] = []
|
||||
|
||||
public get pagesString(): string {
|
||||
return this.pages.join(', ')
|
||||
}
|
||||
|
||||
public get pdfSrc(): string {
|
||||
return this.documentService.getPreviewUrl(this.documentID)
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
public pdfPreviewLoaded(pdf: PDFDocumentProxy) {
|
||||
this.totalPages = pdf.numPages
|
||||
}
|
||||
|
||||
pageRendered(event: CustomEvent) {
|
||||
const pageDiv = event.target as HTMLDivElement
|
||||
const check = this.pageCheckOverlay.createEmbeddedView({
|
||||
page: event.detail.pageNumber,
|
||||
})
|
||||
this.checks[event.detail.pageNumber - 1] = check.rootNodes[0]
|
||||
pageDiv?.insertBefore(check.rootNodes[0], pageDiv.firstChild)
|
||||
this.updateChecks()
|
||||
}
|
||||
|
||||
pageCheckChanged(pageNumber: number) {
|
||||
if (!this.pages.includes(pageNumber)) this.pages.push(pageNumber)
|
||||
else if (this.pages.includes(pageNumber))
|
||||
this.pages.splice(this.pages.indexOf(pageNumber), 1)
|
||||
this.updateChecks()
|
||||
}
|
||||
|
||||
private updateChecks() {
|
||||
this.checks.forEach((check, i) => {
|
||||
const input = check.getElementsByTagName('input')[0]
|
||||
input.checked = this.pages.includes(i + 1)
|
||||
})
|
||||
}
|
||||
}
|
@@ -1,59 +0,0 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>{{message}}</p>
|
||||
<div class="row mb-2">
|
||||
<div class="col-7">
|
||||
<div class="input-group input-group-sm">
|
||||
<div class="input-group-text" i18n>Page</div>
|
||||
<input class="form-control" type="number" min="1" [(ngModel)]="page" />
|
||||
<div class="input-group-text" i18n>of {{totalPages}}</div>
|
||||
</div>
|
||||
<div class="pdf-viewer-container w-100 mt-3">
|
||||
<pdf-viewer [src]="pdfSrc" [(page)]="page"
|
||||
[original-size]="false"
|
||||
[zoom]="1"
|
||||
zoom-scale="page-fit"
|
||||
(after-load-complete)="pdfPreviewLoaded($event)">
|
||||
</pdf-viewer>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-5">
|
||||
<div class="d-grid">
|
||||
<button class="btn btn-sm btn-primary" (click)="addSplit()" [disabled]="!canSplit">
|
||||
<i-bs name="plus-circle"></i-bs>
|
||||
<span i18n>Add Split</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul class="list-group mt-3">
|
||||
@for (pageStr of pagesString.split(','); track pageStr; let i = $index) {
|
||||
<li class="list-group-item d-flex align-items-center">
|
||||
{{pageStr}}
|
||||
@if (pagesString.split(',').length > 1) {
|
||||
|
||||
<button class="btn btn-sm btn-danger ms-auto" (click)="removeSplit(i)">
|
||||
<i-bs name="trash"></i-bs>
|
||||
</button>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="form-check form-switch me-auto">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="deleteOriginalSwitch" [(ngModel)]="deleteOriginal" [disabled]="!userOwnsDocument">
|
||||
<label class="form-check-label" for="deleteOriginalSwitch" i18n>Delete original document after successful split</label>
|
||||
</div>
|
||||
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
|
||||
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
||||
</button>
|
||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
|
||||
{{btnCaption}}
|
||||
</button>
|
||||
</div>
|
@@ -1,9 +0,0 @@
|
||||
.pdf-viewer-container {
|
||||
background-color: gray;
|
||||
height: 500px;
|
||||
|
||||
pdf-viewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
@@ -1,107 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { PdfViewerModule } from 'ng2-pdf-viewer'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { of } from 'rxjs'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { SplitConfirmDialogComponent } from './split-confirm-dialog.component'
|
||||
|
||||
describe('SplitConfirmDialogComponent', () => {
|
||||
let component: SplitConfirmDialogComponent
|
||||
let fixture: ComponentFixture<SplitConfirmDialogComponent>
|
||||
let documentService: DocumentService
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
ReactiveFormsModule,
|
||||
FormsModule,
|
||||
PdfViewerModule,
|
||||
SplitConfirmDialogComponent,
|
||||
],
|
||||
providers: [
|
||||
NgbActiveModal,
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(SplitConfirmDialogComponent)
|
||||
documentService = TestBed.inject(DocumentService)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should load document on init', () => {
|
||||
const getSpy = jest.spyOn(documentService, 'get')
|
||||
component.documentID = 1
|
||||
getSpy.mockReturnValue(of({ id: 1 } as any))
|
||||
component.ngOnInit()
|
||||
expect(documentService.get).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('should update pagesString when pages are added', () => {
|
||||
component.totalPages = 5
|
||||
component.page = 2
|
||||
component.addSplit()
|
||||
expect(component.pagesString).toEqual('1-2,3-5')
|
||||
component.page = 4
|
||||
component.addSplit()
|
||||
expect(component.pagesString).toEqual('1-2,3-4,5')
|
||||
})
|
||||
|
||||
it('should update pagesString when pages are removed', () => {
|
||||
component.totalPages = 5
|
||||
component.page = 2
|
||||
component.addSplit()
|
||||
component.page = 4
|
||||
component.addSplit()
|
||||
expect(component.pagesString).toEqual('1-2,3-4,5')
|
||||
component.removeSplit(0)
|
||||
expect(component.pagesString).toEqual('1-4,5')
|
||||
})
|
||||
|
||||
it('should enable confirm button when pages are added', () => {
|
||||
component.totalPages = 5
|
||||
component.page = 2
|
||||
component.addSplit()
|
||||
expect(component.confirmButtonEnabled).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should disable confirm button when all pages are removed', () => {
|
||||
component.totalPages = 5
|
||||
component.page = 2
|
||||
component.addSplit()
|
||||
component.removeSplit(0)
|
||||
expect(component.confirmButtonEnabled).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should not add split if page is the last page', () => {
|
||||
component.totalPages = 5
|
||||
component.page = 5
|
||||
component.addSplit()
|
||||
expect(component.pagesString).toEqual('1-5')
|
||||
})
|
||||
|
||||
it('should update totalPages when pdf is loaded', () => {
|
||||
component.pdfPreviewLoaded({ numPages: 5 } as any)
|
||||
expect(component.totalPages).toEqual(5)
|
||||
})
|
||||
|
||||
it('should correctly disable split button', () => {
|
||||
component.totalPages = 5
|
||||
component.page = 1
|
||||
expect(component.canSplit).toBeTruthy()
|
||||
component.page = 5
|
||||
expect(component.canSplit).toBeFalsy()
|
||||
component.page = 4
|
||||
expect(component.canSplit).toBeTruthy()
|
||||
component['pages'] = new Set([1, 2, 3, 4])
|
||||
expect(component.canSplit).toBeFalsy()
|
||||
})
|
||||
})
|
@@ -1,98 +0,0 @@
|
||||
import { Component, OnInit, inject } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { Document } from 'src/app/data/document'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-split-confirm-dialog',
|
||||
templateUrl: './split-confirm-dialog.component.html',
|
||||
styleUrl: './split-confirm-dialog.component.scss',
|
||||
imports: [
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgxBootstrapIconsModule,
|
||||
PdfViewerModule,
|
||||
],
|
||||
})
|
||||
export class SplitConfirmDialogComponent
|
||||
extends ConfirmDialogComponent
|
||||
implements OnInit
|
||||
{
|
||||
private documentService = inject(DocumentService)
|
||||
private permissionService = inject(PermissionsService)
|
||||
|
||||
public get pagesString(): string {
|
||||
let pagesStr = ''
|
||||
|
||||
let lastPage = 1
|
||||
for (let i = 1; i <= this.totalPages; i++) {
|
||||
if (this.pages.has(i) || i === this.totalPages) {
|
||||
if (lastPage === i) {
|
||||
pagesStr += `${i},`
|
||||
lastPage = Math.min(i + 1, this.totalPages)
|
||||
} else {
|
||||
pagesStr += `${lastPage}-${i},`
|
||||
lastPage = Math.min(i + 1, this.totalPages)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pagesStr.replace(/,$/, '')
|
||||
}
|
||||
|
||||
private pages: Set<number> = new Set()
|
||||
|
||||
public documentID: number
|
||||
private document: Document
|
||||
public page: number = 1
|
||||
public totalPages: number
|
||||
public deleteOriginal: boolean = false
|
||||
|
||||
public get canSplit(): boolean {
|
||||
return (
|
||||
this.page < this.totalPages &&
|
||||
this.pages.size < this.totalPages - 1 &&
|
||||
!this.pages.has(this.page)
|
||||
)
|
||||
}
|
||||
|
||||
public get pdfSrc(): string {
|
||||
return this.documentService.getPreviewUrl(this.documentID)
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.confirmButtonEnabled = this.pages.size > 0
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.documentService.get(this.documentID).subscribe((r) => {
|
||||
this.document = r
|
||||
})
|
||||
}
|
||||
|
||||
pdfPreviewLoaded(pdf: PDFDocumentProxy) {
|
||||
this.totalPages = pdf.numPages
|
||||
}
|
||||
|
||||
addSplit() {
|
||||
if (this.page === this.totalPages) return
|
||||
this.pages.add(this.page)
|
||||
this.pages = new Set(Array.from(this.pages).sort((a, b) => a - b))
|
||||
this.confirmButtonEnabled = this.pages.size > 0
|
||||
}
|
||||
|
||||
removeSplit(i: number) {
|
||||
let page = Array.from(this.pages)[Math.min(i, this.pages.size - 1)]
|
||||
this.pages.delete(page)
|
||||
this.confirmButtonEnabled = this.pages.size > 0
|
||||
}
|
||||
|
||||
get userOwnsDocument(): boolean {
|
||||
return this.permissionService.currentUserOwnsObject(this.document)
|
||||
}
|
||||
}
|
@@ -1,7 +1,7 @@
|
||||
<div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose($event)" [popperOptions]="popperOptions" placement="bottom-end">
|
||||
<button class="btn btn-sm btn-outline-primary" id="customFieldsDropdown" [disabled]="disabled" ngbDropdownToggle>
|
||||
<div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose($event)" [popperOptions]="popperOptions">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="customFieldsDropdown" [disabled]="disabled" ngbDropdownToggle>
|
||||
<i-bs name="ui-radios"></i-bs>
|
||||
<div class="d-none d-sm-inline"> <ng-container i18n>Custom Fields</ng-container></div>
|
||||
<div class="d-none d-lg-inline"> <ng-container i18n>Custom Fields</ng-container></div>
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="customFieldsDropdown" class="shadow custom-fields-dropdown">
|
||||
<div class="list-group list-group-flush" (keydown)="listKeyDown($event)">
|
||||
|
@@ -51,6 +51,7 @@
|
||||
<ng-select #fieldSelects
|
||||
class="paperless-input-select rounded-end"
|
||||
[items]="getSelectOptionsForField(atom.field)"
|
||||
[virtualScroll]="getSelectOptionsForField(atom.field)?.length > 100"
|
||||
bindLabel="label"
|
||||
bindValue="id"
|
||||
[(ngModel)]="atom.value"
|
||||
|
@@ -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>
|
||||
}
|
||||
|
@@ -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>
|
||||
}
|
||||
|
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@@ -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) {
|
||||
|
@@ -1,17 +1,24 @@
|
||||
<div class="mb-3">
|
||||
<label class="form-label" [for]="inputId">{{title}}</label>
|
||||
<div class="input-group" [class.is-invalid]="error">
|
||||
<input #inputField [type]="showReveal && textVisible ? 'text' : 'password'" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (focus)="onFocus()" (focusout)="onFocusOut()" (change)="onChange(value)" [disabled]="disabled" [autocomplete]="autocomplete">
|
||||
@if (showReveal) {
|
||||
<button type="button" class="btn btn-outline-secondary" (click)="toggleVisibility()" i18n-title title="Show password" [disabled]="disabled || disableRevealToggle">
|
||||
<i-bs name="eye"></i-bs>
|
||||
</button>
|
||||
<div class="mb-3" [class.pb-3]="error">
|
||||
<div class="row">
|
||||
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
|
||||
@if (title) {
|
||||
<label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
|
||||
}
|
||||
</div>
|
||||
<div class="position-relative" [class.col-md-9]="horizontal">
|
||||
<div class="input-group" [class.is-invalid]="error">
|
||||
<input #inputField [type]="showReveal && textVisible ? 'text' : 'password'" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (focus)="onFocus()" (focusout)="onFocusOut()" (change)="onChange(value)" [disabled]="disabled" [autocomplete]="autocomplete">
|
||||
@if (showReveal) {
|
||||
<button type="button" class="btn btn-outline-secondary" (click)="toggleVisibility()" i18n-title title="Show password" [disabled]="disabled || disableRevealToggle">
|
||||
<i-bs name="eye"></i-bs>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="invalid-feedback">
|
||||
{{error}}
|
||||
</div>
|
||||
@if (hint) {
|
||||
<small class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
|
||||
}
|
||||
</div>
|
||||
<div class="invalid-feedback">
|
||||
{{error}}
|
||||
</div>
|
||||
@if (hint) {
|
||||
<small class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
|
||||
}
|
||||
</div>
|
||||
|
@@ -19,6 +19,7 @@
|
||||
[class.private]="isPrivate"
|
||||
[clearable]="allowNull"
|
||||
[items]="items"
|
||||
[virtualScroll]="items?.length > 100"
|
||||
[addTag]="allowCreateNew && addItemRef"
|
||||
addTagText="Add item"
|
||||
i18n-addTagText="Used for both types, correspondents, storage paths"
|
||||
|
@@ -15,6 +15,12 @@
|
||||
@if (hint) {
|
||||
<small class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
|
||||
}
|
||||
@if (getSuggestion()?.length > 0) {
|
||||
<small>
|
||||
<span i18n>Suggestion:</span>
|
||||
<a (click)="applySuggestion(s)" [routerLink]="[]">{{getSuggestion()}}</a>
|
||||
</small>
|
||||
}
|
||||
<div class="invalid-feedback position-absolute top-100">
|
||||
{{error}}
|
||||
</div>
|
||||
|
@@ -26,10 +26,20 @@ describe('TextComponent', () => {
|
||||
|
||||
it('should support use of input field', () => {
|
||||
expect(component.value).toBeUndefined()
|
||||
// TODO: why doesn't this work?
|
||||
// input.value = 'foo'
|
||||
// input.dispatchEvent(new Event('change'))
|
||||
// fixture.detectChanges()
|
||||
// expect(component.value).toEqual('foo')
|
||||
input.value = 'foo'
|
||||
input.dispatchEvent(new Event('input'))
|
||||
fixture.detectChanges()
|
||||
expect(component.value).toBe('foo')
|
||||
})
|
||||
|
||||
it('should support suggestion', () => {
|
||||
component.value = 'foo'
|
||||
component.suggestion = 'foo'
|
||||
expect(component.getSuggestion()).toBe('')
|
||||
component.value = 'bar'
|
||||
expect(component.getSuggestion()).toBe('foo')
|
||||
component.applySuggestion()
|
||||
fixture.detectChanges()
|
||||
expect(component.value).toBe('foo')
|
||||
})
|
||||
})
|
||||
|
@@ -4,6 +4,7 @@ import {
|
||||
NG_VALUE_ACCESSOR,
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
|
||||
import { AbstractInputComponent } from '../abstract-input'
|
||||
@@ -24,6 +25,7 @@ import { AbstractInputComponent } from '../abstract-input'
|
||||
ReactiveFormsModule,
|
||||
SafeHtmlPipe,
|
||||
NgxBootstrapIconsModule,
|
||||
RouterLink,
|
||||
],
|
||||
})
|
||||
export class TextComponent extends AbstractInputComponent<string> {
|
||||
@@ -33,7 +35,19 @@ export class TextComponent extends AbstractInputComponent<string> {
|
||||
@Input()
|
||||
placeholder: string = ''
|
||||
|
||||
@Input()
|
||||
suggestion: string = ''
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
getSuggestion() {
|
||||
return this.value !== this.suggestion ? this.suggestion : ''
|
||||
}
|
||||
|
||||
applySuggestion() {
|
||||
this.value = this.suggestion
|
||||
this.onChange(this.value)
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,107 @@
|
||||
<pdf-viewer [src]="pdfSrc" [render-text]="false" zoom="0.4" (after-load-complete)="pdfLoaded($event)"></pdf-viewer>
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">{{ title }}</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="btn-toolbar mb-2">
|
||||
<div class="btn-group me-3">
|
||||
<button class="btn btn-sm btn-secondary" (click)="selectAll()" title="Select all pages" i18n-title>
|
||||
<i-bs name="check-all"></i-bs>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary" (click)="deselectAll()" [disabled]="!hasSelection()" title="Deselect all pages" i18n-title>
|
||||
<i-bs name="x"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm btn-secondary" (click)="rotateSelected(-90)" [disabled]="!hasSelection()" title="Rotate selected pages counter-clockwise" i18n-title>
|
||||
<i-bs name="arrow-counterclockwise"></i-bs>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary" (click)="rotateSelected(90)" [disabled]="!hasSelection()" title="Rotate selected pages clockwise" i18n-title>
|
||||
<i-bs name="arrow-clockwise"></i-bs>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" (click)="deleteSelected()" [disabled]="!hasSelection()" title="Delete selected pages" i18n-title>
|
||||
<i-bs name="trash"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div cdkDropList (cdkDropListDropped)="drop($event)" cdkDropListOrientation="mixed" class="d-flex flex-wrap row-cols-2 row-cols-md-5">
|
||||
@for (p of pages; track p.page; let i = $index) {
|
||||
<div class="page-item rounded p-2" cdkDrag (click)="toggleSelection(i)" [class.selected]="p.selected">
|
||||
<div class="btn-toolbar hover-actions z-10">
|
||||
<div class="btn-group me-2">
|
||||
<button class="btn btn-sm btn-dark" (click)="rotate(i); $event.stopPropagation()" title="Rotate page counter-clockwise" i18n-title>
|
||||
<i-bs name="arrow-counterclockwise"></i-bs>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-dark" (click)="rotate(i); $event.stopPropagation()" title="Rotate page clockwise" i18n-title>
|
||||
<i-bs name="arrow-clockwise"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm btn-dark text-danger" (click)="remove(i); $event.stopPropagation()" title="Delete page" i18n-title>
|
||||
<i-bs name="trash"></i-bs>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-dark" (click)="toggleSplit(i); $event.stopPropagation()" title="Add / remove document split here" i18n-title>
|
||||
<i-bs name="scissors"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-end border-bottom bg-light py-1 px-2 document-check z-10">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="page{{i}}" [checked]="p.selected" (click)="toggleSelection(i); $event.stopPropagation()">
|
||||
<label class="form-check-label" for="page{{i}}"></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pdf-viewer-container w-100" [class.selected]="p.selected">
|
||||
@defer (on viewport) {
|
||||
@if (!p.loaded) {
|
||||
<div class="placeholder-glow w-100 h-100 z-10">
|
||||
<span class="placeholder w-100 h-100"></span>
|
||||
</div>
|
||||
}
|
||||
<pdf-viewer class="fade" [class.show]="p.loaded" [src]="pdfSrc" [page]="p.page" [rotation]="p.rotate" [original-size]="false" [show-all]="false" [render-text]="false" (page-rendered)="p.loaded = true"></pdf-viewer>
|
||||
} @placeholder {
|
||||
<div class="placeholder-glow w-100 h-100 z-10">
|
||||
<span class="placeholder w-100 h-100"></span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (p.splitAfter) {
|
||||
<div class="split-after rounded position-absolute top-0 end-0 bg-dark text-uppercase text-center h-100 px-1 small fw-bold">— <span i18n>Split here</span> —</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="d-flex flex-column flex-md-row w-100 gap-3 align-items-center">
|
||||
<div class="btn-group" role="group">
|
||||
<input type="radio" class="btn-check" [(ngModel)]="editMode" [value]="PdfEditorEditMode.Create" id="editModeCreate" name="editmode">
|
||||
<label for="editModeCreate" class="btn btn-outline-primary btn-sm">
|
||||
<i-bs name="plus"></i-bs>
|
||||
<span class="form-check-label ms-1" i18n>Create new document(s)</span>
|
||||
</label>
|
||||
<input type="radio" class="btn-check" [(ngModel)]="editMode" [value]="PdfEditorEditMode.Update" id="editModeUpdate" name="editmode" [disabled]="hasSplit()">
|
||||
<label for="editModeUpdate" class="btn btn-outline-primary btn-sm">
|
||||
<i-bs name="pencil"></i-bs>
|
||||
<span class="form-check-label ms-2" i18n>Update existing document</span>
|
||||
</label>
|
||||
</div>
|
||||
@if (editMode === PdfEditorEditMode.Create) {
|
||||
<div class="form-group d-flex">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="copyMeta" [(ngModel)]="includeMetadata">
|
||||
<label class="form-check-label" for="copyMeta" i18n>Copy metadata</label>
|
||||
</div>
|
||||
<div class="form-check ms-3">
|
||||
<input class="form-check-input" type="checkbox" id="deleteOriginal" [(ngModel)]="deleteOriginal">
|
||||
<label class="form-check-label" for="deleteOriginal" i18n>Delete original</label>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="form-group ms-md-auto">
|
||||
<button type="button" class="btn me-2" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">{{ cancelBtnCaption }}</button>
|
||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="pages.length === 0">{{ btnCaption }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,70 @@
|
||||
|
||||
|
||||
.page-item {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
background-origin: border-box;
|
||||
|
||||
&.selected {
|
||||
background-color: var(--pngx-primary-darken-5);
|
||||
}
|
||||
}
|
||||
|
||||
.pdf-viewer-container {
|
||||
background-color: gray;
|
||||
height: 240px;
|
||||
|
||||
pdf-viewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .ng2-pdf-viewer-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hover-actions {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page-item:hover .hover-actions {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.document-check {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: 0.5rem;
|
||||
border-top-left-radius: 0.25rem;
|
||||
border-bottom-right-radius: 0.25rem;
|
||||
pointer-events: none;
|
||||
|
||||
.form-check {
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
margin-bottom: 0;
|
||||
|
||||
.form-check-input {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.page-item:hover .document-check, .selected .document-check {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.z-10 {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.split-after {
|
||||
writing-mode: vertical-rl;
|
||||
}
|
@@ -0,0 +1,142 @@
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { PDFEditorComponent } from './pdf-editor.component'
|
||||
|
||||
describe('PDFEditorComponent', () => {
|
||||
let component: PDFEditorComponent
|
||||
let fixture: ComponentFixture<PDFEditorComponent>
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PDFEditorComponent, NgxBootstrapIconsModule.pick(allIcons)],
|
||||
providers: [
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: NgbActiveModal, useValue: {} },
|
||||
],
|
||||
}).compileComponents()
|
||||
fixture = TestBed.createComponent(PDFEditorComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should return correct operations with no changes', () => {
|
||||
component.pages = [
|
||||
{ page: 1, rotate: 0, splitAfter: false },
|
||||
{ page: 2, rotate: 0, splitAfter: false },
|
||||
{ page: 3, rotate: 0, splitAfter: false },
|
||||
]
|
||||
const ops = component.getOperations()
|
||||
expect(ops).toEqual([
|
||||
{ page: 1, rotate: 0, doc: 0 },
|
||||
{ page: 2, rotate: 0, doc: 0 },
|
||||
{ page: 3, rotate: 0, doc: 0 },
|
||||
])
|
||||
})
|
||||
|
||||
it('should rotate, delete and reorder pages', () => {
|
||||
component.pages = [
|
||||
{ page: 1, rotate: 0, splitAfter: false, selected: false },
|
||||
{ page: 2, rotate: 0, splitAfter: false, selected: false },
|
||||
]
|
||||
component.toggleSelection(0)
|
||||
component.rotateSelected(90)
|
||||
expect(component.pages[0].rotate).toBe(90)
|
||||
component.toggleSelection(0) // deselect
|
||||
component.toggleSelection(1)
|
||||
component.deleteSelected()
|
||||
expect(component.pages.length).toBe(1)
|
||||
component.pages.push({ page: 2, rotate: 0, splitAfter: false })
|
||||
component.drop({ previousIndex: 0, currentIndex: 1 } as any)
|
||||
expect(component.pages[0].page).toBe(2)
|
||||
component.rotate(0)
|
||||
expect(component.pages[0].rotate).toBe(90)
|
||||
})
|
||||
|
||||
it('should handle empty pages array', () => {
|
||||
component.pages = []
|
||||
expect(component.getOperations()).toEqual([])
|
||||
})
|
||||
|
||||
it('should increment doc index after splitAfter', () => {
|
||||
component.pages = [
|
||||
{ page: 1, rotate: 0, splitAfter: true },
|
||||
{ page: 2, rotate: 0, splitAfter: false },
|
||||
{ page: 3, rotate: 0, splitAfter: true },
|
||||
{ page: 4, rotate: 0, splitAfter: false },
|
||||
]
|
||||
const ops = component.getOperations()
|
||||
expect(ops).toEqual([
|
||||
{ page: 1, rotate: 0, doc: 0 },
|
||||
{ page: 2, rotate: 0, doc: 1 },
|
||||
{ page: 3, rotate: 0, doc: 1 },
|
||||
{ page: 4, rotate: 0, doc: 2 },
|
||||
])
|
||||
})
|
||||
|
||||
it('should include rotations in operations', () => {
|
||||
component.pages = [
|
||||
{ page: 1, rotate: 90, splitAfter: false },
|
||||
{ page: 2, rotate: 180, splitAfter: true },
|
||||
{ page: 3, rotate: 270, splitAfter: false },
|
||||
]
|
||||
const ops = component.getOperations()
|
||||
expect(ops).toEqual([
|
||||
{ page: 1, rotate: 90, doc: 0 },
|
||||
{ page: 2, rotate: 180, doc: 0 },
|
||||
{ page: 3, rotate: 270, doc: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
it('should handle remove operation', () => {
|
||||
component.pages = [
|
||||
{ page: 1, rotate: 0, splitAfter: false, selected: false },
|
||||
{ page: 2, rotate: 0, splitAfter: false, selected: true },
|
||||
{ page: 3, rotate: 0, splitAfter: false, selected: false },
|
||||
]
|
||||
component.remove(1) // remove page 2
|
||||
expect(component.pages.length).toBe(2)
|
||||
expect(component.pages[0].page).toBe(1)
|
||||
expect(component.pages[1].page).toBe(3)
|
||||
})
|
||||
|
||||
it('should toggle splitAfter correctly', () => {
|
||||
component.pages = [
|
||||
{ page: 1, rotate: 0, splitAfter: false },
|
||||
{ page: 2, rotate: 0, splitAfter: false },
|
||||
]
|
||||
component.toggleSplit(0)
|
||||
expect(component.pages[0].splitAfter).toBeTruthy()
|
||||
component.toggleSplit(1)
|
||||
expect(component.pages[1].splitAfter).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should select and deselect all pages', () => {
|
||||
component.pages = [
|
||||
{ page: 1, rotate: 0, splitAfter: false, selected: false },
|
||||
{ page: 2, rotate: 0, splitAfter: false, selected: false },
|
||||
]
|
||||
component.selectAll()
|
||||
expect(component.pages.every((p) => p.selected)).toBeTruthy()
|
||||
expect(component.hasSelection()).toBeTruthy()
|
||||
component.deselectAll()
|
||||
expect(component.pages.every((p) => !p.selected)).toBeTruthy()
|
||||
expect(component.hasSelection()).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should handle pdf loading and page generation', () => {
|
||||
const mockPdf = {
|
||||
numPages: 3,
|
||||
getPage: (pageNum: number) => Promise.resolve({ pageNumber: pageNum }),
|
||||
}
|
||||
component.pdfLoaded(mockPdf as any)
|
||||
expect(component.totalPages).toBe(3)
|
||||
expect(component.pages.length).toBe(3)
|
||||
expect(component.pages[0].page).toBe(1)
|
||||
expect(component.pages[1].page).toBe(2)
|
||||
expect(component.pages[2].page).toBe(3)
|
||||
})
|
||||
})
|
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
CdkDragDrop,
|
||||
DragDropModule,
|
||||
moveItemInArray,
|
||||
} from '@angular/cdk/drag-drop'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component'
|
||||
|
||||
interface PageOperation {
|
||||
page: number
|
||||
rotate: number
|
||||
splitAfter: boolean
|
||||
selected?: boolean
|
||||
loaded?: boolean
|
||||
}
|
||||
|
||||
export enum PdfEditorEditMode {
|
||||
Update = 'update',
|
||||
Create = 'create',
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-pdf-editor',
|
||||
templateUrl: './pdf-editor.component.html',
|
||||
styleUrl: './pdf-editor.component.scss',
|
||||
imports: [
|
||||
DragDropModule,
|
||||
FormsModule,
|
||||
PdfViewerModule,
|
||||
NgxBootstrapIconsModule,
|
||||
],
|
||||
})
|
||||
export class PDFEditorComponent extends ConfirmDialogComponent {
|
||||
public PdfEditorEditMode = PdfEditorEditMode
|
||||
|
||||
private documentService = inject(DocumentService)
|
||||
activeModal: NgbActiveModal = inject(NgbActiveModal)
|
||||
|
||||
documentID: number
|
||||
pages: PageOperation[] = []
|
||||
totalPages = 0
|
||||
editMode: PdfEditorEditMode = PdfEditorEditMode.Create
|
||||
deleteOriginal: boolean = false
|
||||
includeMetadata: boolean = true
|
||||
|
||||
get pdfSrc(): string {
|
||||
return this.documentService.getPreviewUrl(this.documentID)
|
||||
}
|
||||
|
||||
pdfLoaded(pdf: PDFDocumentProxy) {
|
||||
this.totalPages = pdf.numPages
|
||||
this.pages = Array.from({ length: this.totalPages }, (_, i) => ({
|
||||
page: i + 1,
|
||||
rotate: 0,
|
||||
splitAfter: false,
|
||||
selected: false,
|
||||
loaded: false,
|
||||
}))
|
||||
}
|
||||
|
||||
toggleSelection(i: number) {
|
||||
this.pages[i].selected = !this.pages[i].selected
|
||||
}
|
||||
|
||||
rotate(i: number) {
|
||||
this.pages[i].rotate = (this.pages[i].rotate + 90) % 360
|
||||
}
|
||||
|
||||
rotateSelected(dir: number) {
|
||||
for (let p of this.pages) {
|
||||
if (p.selected) {
|
||||
p.rotate = (p.rotate + dir + 360) % 360
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
remove(i: number) {
|
||||
this.pages.splice(i, 1)
|
||||
}
|
||||
|
||||
toggleSplit(i: number) {
|
||||
this.pages[i].splitAfter = !this.pages[i].splitAfter
|
||||
if (this.pages[i].splitAfter) {
|
||||
// force create mode
|
||||
this.editMode = PdfEditorEditMode.Create
|
||||
}
|
||||
}
|
||||
|
||||
selectAll() {
|
||||
this.pages.forEach((p) => (p.selected = true))
|
||||
}
|
||||
|
||||
deselectAll() {
|
||||
this.pages.forEach((p) => (p.selected = false))
|
||||
}
|
||||
|
||||
deleteSelected() {
|
||||
this.pages = this.pages.filter((p) => !p.selected)
|
||||
}
|
||||
|
||||
hasSelection(): boolean {
|
||||
return this.pages.some((p) => p.selected)
|
||||
}
|
||||
|
||||
hasSplit(): boolean {
|
||||
return this.pages.some((p) => p.splitAfter)
|
||||
}
|
||||
|
||||
drop(event: CdkDragDrop<PageOperation[]>) {
|
||||
moveItemInArray(this.pages, event.previousIndex, event.currentIndex)
|
||||
}
|
||||
|
||||
getOperations() {
|
||||
return this.pages.map((p, idx) => ({
|
||||
page: p.page,
|
||||
rotate: p.rotate,
|
||||
doc: this.computeDocIndex(idx),
|
||||
}))
|
||||
}
|
||||
|
||||
private computeDocIndex(index: number): number {
|
||||
let docIndex = 0
|
||||
for (let i = 0; i <= index; i++) {
|
||||
if (this.pages[i].splitAfter && i < index) docIndex++
|
||||
}
|
||||
return docIndex
|
||||
}
|
||||
}
|
@@ -0,0 +1,49 @@
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="loading || (suggestions && !aiEnabled)">
|
||||
@if (loading) {
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
} @else {
|
||||
<i-bs width="1.2em" height="1.2em" name="stars"></i-bs>
|
||||
}
|
||||
<span class="d-none d-lg-inline ps-1" i18n>Suggest</span>
|
||||
@if (totalSuggestions > 0) {
|
||||
<span class="badge bg-primary ms-2">{{ totalSuggestions }}</span>
|
||||
}
|
||||
</button>
|
||||
|
||||
@if (aiEnabled) {
|
||||
<div class="btn-group" ngbDropdown #dropdown="ngbDropdown" [popperOptions]="popperOptions">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" ngbDropdownToggle [disabled]="loading || !suggestions" aria-expanded="false" aria-controls="suggestionsDropdown" aria-label="Suggestions dropdown">
|
||||
<span class="visually-hidden" i18n>Show suggestions</span>
|
||||
</button>
|
||||
|
||||
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
|
||||
<div class="list-group list-group-flush small pb-0">
|
||||
@if (!suggestions?.suggested_tags && !suggestions?.suggested_document_types && !suggestions?.suggested_correspondents) {
|
||||
<div class="list-group-item text-muted fst-italic">
|
||||
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
|
||||
</div>
|
||||
}
|
||||
@if (suggestions?.suggested_tags.length > 0) {
|
||||
<small class="list-group-item text-uppercase text-muted small">Tags</small>
|
||||
@for (tag of suggestions.suggested_tags; track tag) {
|
||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)" i18n>{{ tag }}</button>
|
||||
}
|
||||
}
|
||||
@if (suggestions?.suggested_document_types.length > 0) {
|
||||
<div class="list-group-item text-uppercase text-muted small">Document Types</div>
|
||||
@for (type of suggestions.suggested_document_types; track type) {
|
||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)" i18n>{{ type }}</button>
|
||||
}
|
||||
}
|
||||
@if (suggestions?.suggested_correspondents.length > 0) {
|
||||
<div class="list-group-item text-uppercase text-muted small">Correspondents</div>
|
||||
@for (correspondent of suggestions.suggested_correspondents; track correspondent) {
|
||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)" i18n>{{ correspondent }}</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
@@ -0,0 +1,3 @@
|
||||
.suggestions-dropdown {
|
||||
min-width: 250px;
|
||||
}
|
@@ -0,0 +1,51 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { SuggestionsDropdownComponent } from './suggestions-dropdown.component'
|
||||
|
||||
describe('SuggestionsDropdownComponent', () => {
|
||||
let component: SuggestionsDropdownComponent
|
||||
let fixture: ComponentFixture<SuggestionsDropdownComponent>
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
NgbDropdownModule,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
SuggestionsDropdownComponent,
|
||||
],
|
||||
providers: [],
|
||||
})
|
||||
fixture = TestBed.createComponent(SuggestionsDropdownComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should calculate totalSuggestions', () => {
|
||||
component.suggestions = {
|
||||
suggested_correspondents: ['John Doe'],
|
||||
suggested_tags: ['Tag1', 'Tag2'],
|
||||
suggested_document_types: ['Type1'],
|
||||
}
|
||||
expect(component.totalSuggestions).toBe(4)
|
||||
})
|
||||
|
||||
it('should emit getSuggestions when clickSuggest is called and suggestions are null', () => {
|
||||
jest.spyOn(component.getSuggestions, 'emit')
|
||||
component.suggestions = null
|
||||
component.clickSuggest()
|
||||
expect(component.getSuggestions.emit).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should toggle dropdown when clickSuggest is called and suggestions are not null', () => {
|
||||
component.aiEnabled = true
|
||||
fixture.detectChanges()
|
||||
component.suggestions = {
|
||||
suggested_correspondents: [],
|
||||
suggested_tags: [],
|
||||
suggested_document_types: [],
|
||||
}
|
||||
component.clickSuggest()
|
||||
expect(component.dropdown.open).toBeTruthy()
|
||||
})
|
||||
})
|
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
Output,
|
||||
ViewChild,
|
||||
} from '@angular/core'
|
||||
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
|
||||
import { pngxPopperOptions } from 'src/app/utils/popper-options'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-suggestions-dropdown',
|
||||
imports: [NgbDropdownModule, NgxBootstrapIconsModule],
|
||||
templateUrl: './suggestions-dropdown.component.html',
|
||||
styleUrl: './suggestions-dropdown.component.scss',
|
||||
})
|
||||
export class SuggestionsDropdownComponent {
|
||||
public popperOptions = pngxPopperOptions
|
||||
|
||||
@ViewChild('dropdown') dropdown: NgbDropdown
|
||||
|
||||
@Input()
|
||||
suggestions: DocumentSuggestions = null
|
||||
|
||||
@Input()
|
||||
aiEnabled: boolean = false
|
||||
|
||||
@Input()
|
||||
loading: boolean = false
|
||||
|
||||
@Input()
|
||||
disabled: boolean = false
|
||||
|
||||
@Output()
|
||||
getSuggestions: EventEmitter<SuggestionsDropdownComponent> =
|
||||
new EventEmitter()
|
||||
|
||||
@Output()
|
||||
addTag: EventEmitter<string> = new EventEmitter()
|
||||
|
||||
@Output()
|
||||
addDocumentType: EventEmitter<string> = new EventEmitter()
|
||||
|
||||
@Output()
|
||||
addCorrespondent: EventEmitter<string> = new EventEmitter()
|
||||
|
||||
public clickSuggest(): void {
|
||||
if (!this.suggestions) {
|
||||
this.getSuggestions.emit(this)
|
||||
} else {
|
||||
this.dropdown?.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
get totalSuggestions(): number {
|
||||
return (
|
||||
this.suggestions?.suggested_correspondents?.length +
|
||||
this.suggestions?.suggested_tags?.length +
|
||||
this.suggestions?.suggested_document_types?.length || 0
|
||||
)
|
||||
}
|
||||
}
|
@@ -254,6 +254,43 @@
|
||||
<h6><ng-container i18n>Error</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.sanity_check_error}}</span>
|
||||
}
|
||||
</ng-template>
|
||||
@if (aiEnabled) {
|
||||
<dt i18n>AI Index</dt>
|
||||
<dd class="d-flex align-items-center">
|
||||
<button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="llmIndexStatus" triggers="click mouseenter:mouseleave">
|
||||
{{status.tasks.llmindex_status}}
|
||||
@if (status.tasks.llmindex_status === 'OK') {
|
||||
@if (isStale(status.tasks.llmindex_last_modified)) {
|
||||
<i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1"></i-bs>
|
||||
} @else {
|
||||
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
|
||||
}
|
||||
} @else {
|
||||
<i-bs name="exclamation-triangle-fill" class="ms-2 lh-1"
|
||||
[class.text-danger]="status.tasks.llmindex_status === SystemStatusItemStatus.ERROR"
|
||||
[class.text-warning]="status.tasks.llmindex_status === SystemStatusItemStatus.WARNING"
|
||||
[class.text-muted]="status.tasks.llmindex_status === SystemStatusItemStatus.DISABLED"></i-bs>
|
||||
}
|
||||
</button>
|
||||
@if (currentUserIsSuperUser) {
|
||||
@if (isRunning(PaperlessTaskName.LLMIndexUpdate)) {
|
||||
<div class="spinner-border spinner-border-sm ms-2" role="status"></div>
|
||||
} @else {
|
||||
<button class="btn btn-sm d-flex align-items-center btn-dark small ms-2" (click)="runTask(PaperlessTaskName.LLMIndexUpdate)">
|
||||
<i-bs name="play-fill"></i-bs>
|
||||
<ng-container i18n>Run Task</ng-container>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</dd>
|
||||
<ng-template #llmIndexStatus>
|
||||
@if (status.tasks.llmindex_status === 'OK') {
|
||||
<h6><ng-container i18n>Last Run</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.llmindex_last_modified | customDate:'medium'}}</span>
|
||||
} @else {
|
||||
<h6><ng-container i18n>Error</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.llmindex_error}}</span>
|
||||
}
|
||||
</ng-template>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -67,6 +67,9 @@ const status: SystemStatus = {
|
||||
sanity_check_status: SystemStatusItemStatus.OK,
|
||||
sanity_check_last_run: new Date().toISOString(),
|
||||
sanity_check_error: null,
|
||||
llmindex_status: SystemStatusItemStatus.OK,
|
||||
llmindex_last_modified: new Date().toISOString(),
|
||||
llmindex_error: null,
|
||||
},
|
||||
}
|
||||
|
||||
|
@@ -12,9 +12,11 @@ import {
|
||||
SystemStatus,
|
||||
SystemStatusItemStatus,
|
||||
} from 'src/app/data/system-status'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { FileSizePipe } from 'src/app/pipes/file-size.pipe'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import { SettingsService } from 'src/app/services/settings.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'
|
||||
@@ -41,6 +43,7 @@ export class SystemStatusDialogComponent implements OnInit {
|
||||
private tasksService = inject(TasksService)
|
||||
private toastService = inject(ToastService)
|
||||
private permissionsService = inject(PermissionsService)
|
||||
private settingsService = inject(SettingsService)
|
||||
|
||||
public SystemStatusItemStatus = SystemStatusItemStatus
|
||||
public PaperlessTaskName = PaperlessTaskName
|
||||
@@ -56,6 +59,10 @@ export class SystemStatusDialogComponent implements OnInit {
|
||||
return this.permissionsService.isSuperUser()
|
||||
}
|
||||
|
||||
get aiEnabled(): boolean {
|
||||
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
|
||||
}
|
||||
|
||||
public ngOnInit() {
|
||||
this.versionMismatch =
|
||||
environment.production &&
|
||||
|
@@ -106,6 +106,7 @@ describe('DashboardComponent', () => {
|
||||
}),
|
||||
dashboardViews: saved_views.filter((v) => v.show_on_dashboard),
|
||||
allViews: saved_views,
|
||||
setDocumentCount: jest.fn(),
|
||||
},
|
||||
},
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
|
@@ -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)
|
||||
)
|
||||
|
@@ -58,30 +58,12 @@
|
||||
<i-bs width="1em" height="1em" name="diagram-3"></i-bs> <span i18n>More like this</span>
|
||||
</button>
|
||||
|
||||
<button ngbDropdownItem (click)="splitDocument()" [disabled]="!userCanAdd || originalContentRenderType !== ContentRenderType.PDF || previewNumPages === 1">
|
||||
<i-bs width="1em" height="1em" name="scissors"></i-bs> <span i18n>Split</span>
|
||||
</button>
|
||||
|
||||
<button ngbDropdownItem (click)="rotateDocument()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF">
|
||||
<i-bs name="arrow-clockwise"></i-bs> <ng-container i18n>Rotate</ng-container>
|
||||
</button>
|
||||
|
||||
<button ngbDropdownItem (click)="deletePages()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF || previewNumPages === 1">
|
||||
<i-bs name="file-earmark-minus"></i-bs> <ng-container i18n>Delete page(s)</ng-container>
|
||||
<button ngbDropdownItem (click)="editPdf()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF">
|
||||
<i-bs name="pencil"></i-bs> <ng-container i18n>PDF Editor</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pngx-custom-fields-dropdown
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }"
|
||||
[documentId]="documentId"
|
||||
[disabled]="!userCanEdit"
|
||||
[existingFields]="document?.custom_fields"
|
||||
(created)="refreshCustomFields()"
|
||||
(added)="addField($event)">
|
||||
</pngx-custom-fields-dropdown>
|
||||
|
||||
|
||||
<div class="ms-auto" ngbDropdown>
|
||||
<button class="btn btn-sm btn-outline-primary" id="sendDropdown" ngbDropdownToggle>
|
||||
<i-bs name="send"></i-bs>
|
||||
@@ -102,7 +84,7 @@
|
||||
</pngx-page-header>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-xl-4 mb-4">
|
||||
<div class="col-md-6 col-xl-5 mb-4">
|
||||
|
||||
<form [formGroup]='documentForm' (ngSubmit)="save()">
|
||||
|
||||
@@ -119,6 +101,32 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
||||
<div class="btn-group pb-3 ms-auto">
|
||||
<pngx-suggestions-dropdown *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"
|
||||
[disabled]="!userCanEdit || suggestionsLoading"
|
||||
[loading]="suggestionsLoading"
|
||||
[suggestions]="suggestions"
|
||||
[aiEnabled]="aiEnabled"
|
||||
(getSuggestions)="getSuggestions()"
|
||||
(addTag)="createTag($event)"
|
||||
(addDocumentType)="createDocumentType($event)"
|
||||
(addCorrespondent)="createCorrespondent($event)">
|
||||
</pngx-suggestions-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="btn-group pb-3 ms-2">
|
||||
<pngx-custom-fields-dropdown
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }"
|
||||
[documentId]="documentId"
|
||||
[disabled]="!userCanEdit"
|
||||
[existingFields]="document?.custom_fields"
|
||||
(created)="refreshCustomFields()"
|
||||
(added)="addField($event)">
|
||||
</pngx-custom-fields-dropdown>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngTemplateOutlet="saveButtons"></ng-container>
|
||||
</div>
|
||||
|
||||
@@ -127,7 +135,7 @@
|
||||
<a ngbNavLink i18n>Details</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div>
|
||||
<pngx-input-text #inputTitle i18n-title title="Title" formControlName="title" [horizontal]="true" (keyup)="titleKeyUp($event)" [error]="error?.title"></pngx-input-text>
|
||||
<pngx-input-text #inputTitle i18n-title title="Title" formControlName="title" [horizontal]="true" [suggestion]="suggestions?.title" (keyup)="titleKeyUp($event)" [error]="error?.title"></pngx-input-text>
|
||||
<pngx-input-number i18n-title title="Archive serial number" [error]="error?.archive_serial_number" [horizontal]="true" formControlName='archive_serial_number'></pngx-input-number>
|
||||
<pngx-input-date i18n-title title="Date created" formControlName="created" [suggestions]="suggestions?.dates" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
|
||||
[error]="error?.created"></pngx-input-date>
|
||||
@@ -137,7 +145,7 @@
|
||||
(createNew)="createDocumentType($event)" [hideAddButton]="createDisabled(DataType.DocumentType)" [suggestions]="suggestions?.document_types" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }"></pngx-input-select>
|
||||
<pngx-input-select [items]="storagePaths" i18n-title title="Storage path" formControlName="storage_path" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event, DataType.StoragePath)"
|
||||
(createNew)="createStoragePath($event)" [hideAddButton]="createDisabled(DataType.StoragePath)" [suggestions]="suggestions?.storage_paths" i18n-placeholder placeholder="Default" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }"></pngx-input-select>
|
||||
<pngx-input-tags formControlName="tags" [suggestions]="suggestions?.tags" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event, DataType.Tag)" [hideAddButton]="createDisabled(DataType.Tag)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"></pngx-input-tags>
|
||||
<pngx-input-tags #tagsInput formControlName="tags" [suggestions]="suggestions?.tags" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event, DataType.Tag)" [hideAddButton]="createDisabled(DataType.Tag)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"></pngx-input-tags>
|
||||
@for (fieldInstance of document?.custom_fields; track fieldInstance.field; let i = $index) {
|
||||
<div [formGroup]="customFieldFormFields.controls[i]">
|
||||
@switch (getCustomFieldFromInstance(fieldInstance)?.data_type) {
|
||||
@@ -351,14 +359,14 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-xl-8 mb-3 d-none d-md-block position-relative" #pdfPreview>
|
||||
<div class="col-md-6 col-xl-7 mb-3 d-none d-md-block position-relative" #pdfPreview>
|
||||
<ng-container *ngTemplateOutlet="previewContent"></ng-container>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<ng-template #saveButtons>
|
||||
<div class="btn-group pb-3 ms-auto">
|
||||
<div class="btn-group pb-3 ms-4">
|
||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
||||
<button type="submit" class="order-3 btn btn-sm btn-primary" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save</button>
|
||||
@if (hasNext()) {
|
||||
|
@@ -156,6 +156,16 @@ describe('DocumentDetailComponent', () => {
|
||||
{
|
||||
provide: TagService,
|
||||
useValue: {
|
||||
getCachedMany: (ids: number[]) =>
|
||||
of(
|
||||
ids.map((id) => ({
|
||||
id,
|
||||
name: `Tag${id}`,
|
||||
is_inbox_tag: true,
|
||||
color: '#ff0000',
|
||||
text_color: '#000000',
|
||||
}))
|
||||
),
|
||||
listAll: () =>
|
||||
of({
|
||||
count: 3,
|
||||
@@ -382,8 +392,32 @@ describe('DocumentDetailComponent', () => {
|
||||
currentUserCan = true
|
||||
})
|
||||
|
||||
it('should support creating document type', () => {
|
||||
it('should support creating tag, remove from suggestions', () => {
|
||||
initNormally()
|
||||
component.suggestions = {
|
||||
suggested_tags: ['Tag1', 'NewTag12'],
|
||||
}
|
||||
let openModal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
||||
const modalSpy = jest.spyOn(modalService, 'open')
|
||||
component.createTag('NewTag12')
|
||||
expect(modalSpy).toHaveBeenCalled()
|
||||
openModal.componentInstance.succeeded.next({
|
||||
id: 12,
|
||||
name: 'NewTag12',
|
||||
is_inbox_tag: true,
|
||||
color: '#ff0000',
|
||||
text_color: '#000000',
|
||||
})
|
||||
expect(component.documentForm.get('tags').value).toContain(12)
|
||||
expect(component.suggestions.suggested_tags).not.toContain('NewTag12')
|
||||
})
|
||||
|
||||
it('should support creating document type, remove from suggestions', () => {
|
||||
initNormally()
|
||||
component.suggestions = {
|
||||
suggested_document_types: ['DocumentType1', 'NewDocType2'],
|
||||
}
|
||||
let openModal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
||||
const modalSpy = jest.spyOn(modalService, 'open')
|
||||
@@ -391,10 +425,16 @@ describe('DocumentDetailComponent', () => {
|
||||
expect(modalSpy).toHaveBeenCalled()
|
||||
openModal.componentInstance.succeeded.next({ id: 12, name: 'NewDocType12' })
|
||||
expect(component.documentForm.get('document_type').value).toEqual(12)
|
||||
expect(component.suggestions.suggested_document_types).not.toContain(
|
||||
'NewDocType2'
|
||||
)
|
||||
})
|
||||
|
||||
it('should support creating correspondent', () => {
|
||||
it('should support creating correspondent, remove from suggestions', () => {
|
||||
initNormally()
|
||||
component.suggestions = {
|
||||
suggested_correspondents: ['Correspondent1', 'NewCorrrespondent12'],
|
||||
}
|
||||
let openModal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
||||
const modalSpy = jest.spyOn(modalService, 'open')
|
||||
@@ -405,6 +445,9 @@ describe('DocumentDetailComponent', () => {
|
||||
name: 'NewCorrrespondent12',
|
||||
})
|
||||
expect(component.documentForm.get('correspondent').value).toEqual(12)
|
||||
expect(component.suggestions.suggested_correspondents).not.toContain(
|
||||
'NewCorrrespondent12'
|
||||
)
|
||||
})
|
||||
|
||||
it('should support creating storage path', () => {
|
||||
@@ -452,6 +495,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'
|
||||
@@ -983,7 +1038,7 @@ describe('DocumentDetailComponent', () => {
|
||||
expect(component.document.custom_fields).toHaveLength(initialLength - 1)
|
||||
expect(component.customFieldFormFields).toHaveLength(initialLength - 1)
|
||||
expect(
|
||||
fixture.debugElement.query(By.css('form')).nativeElement.textContent
|
||||
fixture.debugElement.query(By.css('form ul')).nativeElement.textContent
|
||||
).not.toContain('Field 1')
|
||||
const patchSpy = jest.spyOn(documentService, 'patch')
|
||||
component.save(true)
|
||||
@@ -1074,10 +1129,22 @@ describe('DocumentDetailComponent', () => {
|
||||
|
||||
it('should get suggestions', () => {
|
||||
const suggestionsSpy = jest.spyOn(documentService, 'getSuggestions')
|
||||
suggestionsSpy.mockReturnValue(of({ tags: [42, 43] }))
|
||||
suggestionsSpy.mockReturnValue(
|
||||
of({
|
||||
tags: [42, 43],
|
||||
suggested_tags: [],
|
||||
suggested_document_types: [],
|
||||
suggested_correspondents: [],
|
||||
})
|
||||
)
|
||||
initNormally()
|
||||
expect(suggestionsSpy).toHaveBeenCalled()
|
||||
expect(component.suggestions).toEqual({ tags: [42, 43] })
|
||||
expect(component.suggestions).toEqual({
|
||||
tags: [42, 43],
|
||||
suggested_tags: [],
|
||||
suggested_document_types: [],
|
||||
suggested_correspondents: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('should show error if needed for get suggestions', () => {
|
||||
@@ -1158,81 +1225,43 @@ describe('DocumentDetailComponent', () => {
|
||||
).not.toBeUndefined()
|
||||
})
|
||||
|
||||
it('should support split', () => {
|
||||
it('should support pdf editor, handle error', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||
const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument')
|
||||
const errorSpy = jest.spyOn(toastService, 'showError')
|
||||
initNormally()
|
||||
component.splitDocument()
|
||||
component.editPdf()
|
||||
expect(modal).not.toBeUndefined()
|
||||
modal.componentInstance.documentID = doc.id
|
||||
modal.componentInstance.totalPages = 5
|
||||
modal.componentInstance.page = 2
|
||||
modal.componentInstance.addSplit()
|
||||
modal.componentInstance.pages = [{ page: 1, rotate: 0, splitAfter: false }]
|
||||
modal.componentInstance.confirm()
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
)
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [doc.id],
|
||||
method: 'split',
|
||||
parameters: { pages: '1-2,3-5', delete_originals: false },
|
||||
method: 'edit_pdf',
|
||||
parameters: {
|
||||
operations: [{ page: 1, rotate: 0, doc: 0 }],
|
||||
delete_original: false,
|
||||
update_document: false,
|
||||
include_metadata: true,
|
||||
},
|
||||
})
|
||||
req.error(new ProgressEvent('failed'))
|
||||
modal.componentInstance.confirm()
|
||||
req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
)
|
||||
req.flush(true)
|
||||
})
|
||||
req.error(new ErrorEvent('failed'))
|
||||
expect(errorSpy).toHaveBeenCalled()
|
||||
|
||||
it('should support rotate', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||
initNormally()
|
||||
component.rotateDocument()
|
||||
expect(modal).not.toBeUndefined()
|
||||
component.editPdf()
|
||||
modal.componentInstance.documentID = doc.id
|
||||
modal.componentInstance.rotate()
|
||||
modal.componentInstance.confirm()
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
)
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [doc.id],
|
||||
method: 'rotate',
|
||||
parameters: { degrees: 90 },
|
||||
})
|
||||
req.error(new ProgressEvent('failed'))
|
||||
modal.componentInstance.confirm()
|
||||
req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
)
|
||||
req.flush(true)
|
||||
})
|
||||
|
||||
it('should support delete pages', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||
initNormally()
|
||||
component.deletePages()
|
||||
expect(modal).not.toBeUndefined()
|
||||
modal.componentInstance.documentID = doc.id
|
||||
modal.componentInstance.pages = [1, 2]
|
||||
modal.componentInstance.confirm()
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
)
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [doc.id],
|
||||
method: 'delete_pages',
|
||||
parameters: { pages: [1, 2] },
|
||||
})
|
||||
req.error(new ProgressEvent('failed'))
|
||||
modal.componentInstance.pages = [{ page: 1, rotate: 0, splitAfter: true }]
|
||||
modal.componentInstance.deleteOriginal = true
|
||||
modal.componentInstance.confirm()
|
||||
req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
)
|
||||
req.flush(true)
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support keyboard shortcuts', () => {
|
||||
@@ -1426,4 +1455,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,
|
||||
@@ -75,6 +76,7 @@ import { DocumentTypeService } from 'src/app/services/rest/document-type.service
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
@@ -82,14 +84,12 @@ import { getFilenameFromContentDisposition } from 'src/app/utils/http'
|
||||
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
|
||||
import * as UTIF from 'utif'
|
||||
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
||||
import { DeletePagesConfirmDialogComponent } from '../common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component'
|
||||
import { RotateConfirmDialogComponent } from '../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
||||
import { SplitConfirmDialogComponent } from '../common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component'
|
||||
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
|
||||
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||
import { EditDialogMode } from '../common/edit-dialog/edit-dialog.component'
|
||||
import { StoragePathEditDialogComponent } from '../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
|
||||
import { TagEditDialogComponent } from '../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
|
||||
import { EmailDocumentDialogComponent } from '../common/email-document-dialog/email-document-dialog.component'
|
||||
import { CheckComponent } from '../common/input/check/check.component'
|
||||
import { DateComponent } from '../common/input/date/date.component'
|
||||
@@ -102,7 +102,12 @@ import { TagsComponent } from '../common/input/tags/tags.component'
|
||||
import { TextComponent } from '../common/input/text/text.component'
|
||||
import { UrlComponent } from '../common/input/url/url.component'
|
||||
import { PageHeaderComponent } from '../common/page-header/page-header.component'
|
||||
import {
|
||||
PDFEditorComponent,
|
||||
PdfEditorEditMode,
|
||||
} from '../common/pdf-editor/pdf-editor.component'
|
||||
import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-links-dialog.component'
|
||||
import { SuggestionsDropdownComponent } from '../common/suggestions-dropdown/suggestions-dropdown.component'
|
||||
import { DocumentHistoryComponent } from '../document-history/document-history.component'
|
||||
import { DocumentNotesComponent } from '../document-notes/document-notes.component'
|
||||
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
||||
@@ -159,6 +164,7 @@ export enum ZoomSetting {
|
||||
NumberComponent,
|
||||
MonetaryComponent,
|
||||
UrlComponent,
|
||||
SuggestionsDropdownComponent,
|
||||
CustomDatePipe,
|
||||
FileSizePipe,
|
||||
IfPermissionsDirective,
|
||||
@@ -179,6 +185,7 @@ export class DocumentDetailComponent
|
||||
{
|
||||
private documentsService = inject(DocumentService)
|
||||
private route = inject(ActivatedRoute)
|
||||
private tagService = inject(TagService)
|
||||
private correspondentService = inject(CorrespondentService)
|
||||
private documentTypeService = inject(DocumentTypeService)
|
||||
private router = inject(Router)
|
||||
@@ -201,6 +208,8 @@ export class DocumentDetailComponent
|
||||
@ViewChild('inputTitle')
|
||||
titleInput: TextComponent
|
||||
|
||||
@ViewChild('tagsInput') tagsInput: TagsComponent
|
||||
|
||||
expandOriginalMetadata = false
|
||||
expandArchivedMetadata = false
|
||||
|
||||
@@ -212,6 +221,7 @@ export class DocumentDetailComponent
|
||||
document: Document
|
||||
metadata: DocumentMetadata
|
||||
suggestions: DocumentSuggestions
|
||||
suggestionsLoading: boolean = false
|
||||
users: User[]
|
||||
|
||||
title: string
|
||||
@@ -289,6 +299,10 @@ export class DocumentDetailComponent
|
||||
return this.settings.get(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER)
|
||||
}
|
||||
|
||||
get aiEnabled(): boolean {
|
||||
return this.settings.get(SETTINGS_KEYS.AI_ENABLED)
|
||||
}
|
||||
|
||||
get archiveContentRenderType(): ContentRenderType {
|
||||
return this.document?.archived_file_name
|
||||
? this.getRenderType('application/pdf')
|
||||
@@ -326,19 +340,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 (
|
||||
@@ -390,163 +549,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({
|
||||
@@ -652,41 +684,71 @@ export class DocumentDetailComponent
|
||||
PermissionType.Document
|
||||
)
|
||||
) {
|
||||
this.documentsService
|
||||
.getSuggestions(doc.id)
|
||||
.pipe(
|
||||
first(),
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
takeUntil(this.docChangeNotifier)
|
||||
)
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
this.suggestions = result
|
||||
},
|
||||
error: (error) => {
|
||||
this.suggestions = null
|
||||
this.toastService.showError(
|
||||
$localize`Error retrieving suggestions.`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
this.tagService.getCachedMany(doc.tags).subscribe((tags) => {
|
||||
// only show suggestions if document has inbox tags
|
||||
if (tags.some((tag) => tag.is_inbox_tag)) {
|
||||
this.getSuggestions()
|
||||
}
|
||||
})
|
||||
}
|
||||
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 {
|
||||
return this.documentForm.get('custom_fields') as FormArray
|
||||
}
|
||||
|
||||
getSuggestions() {
|
||||
this.suggestionsLoading = true
|
||||
this.documentsService
|
||||
.getSuggestions(this.documentId)
|
||||
.pipe(
|
||||
first(),
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
takeUntil(this.docChangeNotifier)
|
||||
)
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
this.suggestions = result
|
||||
this.suggestionsLoading = false
|
||||
},
|
||||
error: (error) => {
|
||||
this.suggestions = null
|
||||
this.suggestionsLoading = false
|
||||
this.toastService.showError(
|
||||
$localize`Error retrieving suggestions.`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
createTag(newName: string) {
|
||||
var modal = this.modalService.open(TagEditDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.dialogMode = EditDialogMode.CREATE
|
||||
if (newName) modal.componentInstance.object = { name: newName }
|
||||
modal.componentInstance.succeeded
|
||||
.pipe(
|
||||
switchMap((newTag) => {
|
||||
return this.tagService
|
||||
.listAll()
|
||||
.pipe(map((tags) => ({ newTag, tags })))
|
||||
})
|
||||
)
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(({ newTag, tags }) => {
|
||||
this.tagsInput.tags = tags.results
|
||||
this.tagsInput.addTag(newTag.id)
|
||||
if (this.suggestions) {
|
||||
this.suggestions.suggested_tags =
|
||||
this.suggestions.suggested_tags.filter((tag) => tag !== newName)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
createDocumentType(newName: string) {
|
||||
var modal = this.modalService.open(DocumentTypeEditDialogComponent, {
|
||||
backdrop: 'static',
|
||||
@@ -706,6 +768,12 @@ export class DocumentDetailComponent
|
||||
this.documentTypes = documentTypes.results
|
||||
this.documentForm.get('document_type').setValue(newDocumentType.id)
|
||||
this.documentForm.get('document_type').markAsDirty()
|
||||
if (this.suggestions) {
|
||||
this.suggestions.suggested_document_types =
|
||||
this.suggestions.suggested_document_types.filter(
|
||||
(dt) => dt !== newName
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -730,6 +798,12 @@ export class DocumentDetailComponent
|
||||
this.correspondents = correspondents.results
|
||||
this.documentForm.get('correspondent').setValue(newCorrespondent.id)
|
||||
this.documentForm.get('correspondent').markAsDirty()
|
||||
if (this.suggestions) {
|
||||
this.suggestions.suggested_correspondents =
|
||||
this.suggestions.suggested_correspondents.filter(
|
||||
(c) => c !== newName
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -783,7 +857,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)
|
||||
@@ -886,9 +964,10 @@ export class DocumentDetailComponent
|
||||
.patch(this.getChangedFields())
|
||||
.pipe(
|
||||
switchMap((updateResult) => {
|
||||
return this.documentListViewService
|
||||
.getNext(this.documentId)
|
||||
.pipe(map((nextDocId) => ({ nextDocId, updateResult })))
|
||||
return this.documentListViewService.getNext(this.documentId).pipe(
|
||||
map((nextDocId) => ({ nextDocId, updateResult })),
|
||||
takeUntil(this.unsubscribeNotifier)
|
||||
)
|
||||
})
|
||||
)
|
||||
.pipe(
|
||||
@@ -898,7 +977,10 @@ export class DocumentDetailComponent
|
||||
return this.openDocumentService
|
||||
.closeDocument(this.document)
|
||||
.pipe(
|
||||
map((closeResult) => ({ updateResult, nextDocId, closeResult }))
|
||||
map(
|
||||
(closeResult) => ({ updateResult, nextDocId, closeResult }),
|
||||
takeUntil(this.unsubscribeNotifier)
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -1224,16 +1306,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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1349,13 +1434,13 @@ export class DocumentDetailComponent
|
||||
this.documentForm.updateValueAndValidity()
|
||||
}
|
||||
|
||||
splitDocument() {
|
||||
let modal = this.modalService.open(SplitConfirmDialogComponent, {
|
||||
editPdf() {
|
||||
let modal = this.modalService.open(PDFEditorComponent, {
|
||||
backdrop: 'static',
|
||||
size: 'lg',
|
||||
size: 'xl',
|
||||
scrollable: true,
|
||||
})
|
||||
modal.componentInstance.title = $localize`Split confirm`
|
||||
modal.componentInstance.messageBold = $localize`This operation will split the selected document(s) into new documents.`
|
||||
modal.componentInstance.title = $localize`PDF Editor`
|
||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||
modal.componentInstance.documentID = this.document.id
|
||||
modal.componentInstance.confirmClicked
|
||||
@@ -1363,103 +1448,30 @@ export class DocumentDetailComponent
|
||||
.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
this.documentsService
|
||||
.bulkEdit([this.document.id], 'split', {
|
||||
pages: modal.componentInstance.pagesString,
|
||||
delete_originals: modal.componentInstance.deleteOriginal,
|
||||
.bulkEdit([this.document.id], 'edit_pdf', {
|
||||
operations: modal.componentInstance.getOperations(),
|
||||
delete_original: modal.componentInstance.deleteOriginal,
|
||||
update_document:
|
||||
modal.componentInstance.editMode == PdfEditorEditMode.Update,
|
||||
include_metadata: modal.componentInstance.includeMetadata,
|
||||
})
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastService.showInfo(
|
||||
$localize`Split operation for "${this.document.title}" will begin in the background.`
|
||||
$localize`PDF edit operation for "${this.document.title}" will begin in the background.`
|
||||
)
|
||||
modal.close()
|
||||
if (modal.componentInstance.deleteOriginal) {
|
||||
this.openDocumentService.closeDocument(this.document)
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
if (modal) {
|
||||
modal.componentInstance.buttonsEnabled = true
|
||||
}
|
||||
this.toastService.showError(
|
||||
$localize`Error executing split operation`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
rotateDocument() {
|
||||
let modal = this.modalService.open(RotateConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
size: 'lg',
|
||||
})
|
||||
modal.componentInstance.title = $localize`Rotate confirm`
|
||||
modal.componentInstance.messageBold = $localize`This operation will permanently rotate the original version of the current document.`
|
||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||
modal.componentInstance.documentID = this.document.id
|
||||
modal.componentInstance.showPDFNote = false
|
||||
modal.componentInstance.confirmClicked
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
this.documentsService
|
||||
.bulkEdit([this.document.id], 'rotate', {
|
||||
degrees: modal.componentInstance.degrees,
|
||||
})
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastService.show({
|
||||
content: $localize`Rotation of "${this.document.title}" will begin in the background. Close and re-open the document after the operation has completed to see the changes.`,
|
||||
delay: 8000,
|
||||
action: this.close.bind(this),
|
||||
actionName: $localize`Close`,
|
||||
})
|
||||
modal.close()
|
||||
},
|
||||
error: (error) => {
|
||||
if (modal) {
|
||||
modal.componentInstance.buttonsEnabled = true
|
||||
}
|
||||
this.toastService.showError(
|
||||
$localize`Error executing rotate operation`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
deletePages() {
|
||||
let modal = this.modalService.open(DeletePagesConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.title = $localize`Delete pages confirm`
|
||||
modal.componentInstance.messageBold = $localize`This operation will permanently delete the selected pages from the original document.`
|
||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||
modal.componentInstance.documentID = this.document.id
|
||||
modal.componentInstance.confirmClicked
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
this.documentsService
|
||||
.bulkEdit([this.document.id], 'delete_pages', {
|
||||
pages: modal.componentInstance.pages,
|
||||
})
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastService.showInfo(
|
||||
$localize`Delete pages operation for "${this.document.title}" will begin in the background. Close and re-open or reload this document after the operation has completed to see the changes.`
|
||||
)
|
||||
modal.close()
|
||||
},
|
||||
error: (error) => {
|
||||
if (modal) {
|
||||
modal.componentInstance.buttonsEnabled = true
|
||||
}
|
||||
this.toastService.showError(
|
||||
$localize`Error executing delete pages operation`,
|
||||
$localize`Error executing PDF edit operation`,
|
||||
error
|
||||
)
|
||||
},
|
||||
@@ -1488,43 +1500,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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
@@ -68,6 +68,8 @@
|
||||
<td scope="row" [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }">
|
||||
@if (column.rendersHtml) {
|
||||
<div [innerHtml]="column.valueFn.call(null, object) | safeHtml"></div>
|
||||
} @else if (column.monospace) {
|
||||
<span class="font-monospace">{{ column.valueFn.call(null, object) }}</span>
|
||||
} @else {
|
||||
{{ column.valueFn.call(null, object) }}
|
||||
}
|
||||
|
@@ -53,6 +53,8 @@ export interface ManagementListColumn {
|
||||
rendersHtml?: boolean
|
||||
|
||||
hideOnMobile?: boolean
|
||||
|
||||
monospace?: boolean
|
||||
}
|
||||
|
||||
@Directive()
|
||||
|
@@ -80,7 +80,7 @@ describe('StoragePathListComponent', () => {
|
||||
path: 'a'.repeat(100),
|
||||
}
|
||||
expect(component.extraColumns[0].valueFn(path)).toEqual(
|
||||
`<code>${'a'.repeat(49)}...</code>`
|
||||
`${'a'.repeat(49)}...`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
@@ -48,10 +48,10 @@ export class StoragePathListComponent extends ManagementListComponent<StoragePat
|
||||
{
|
||||
key: 'path',
|
||||
name: $localize`Path`,
|
||||
rendersHtml: true,
|
||||
hideOnMobile: true,
|
||||
monospace: true,
|
||||
valueFn: (c: StoragePath) => {
|
||||
return `<code>${c.path?.slice(0, 49)}${c.path?.length > 50 ? '...' : ''}</code>`
|
||||
return `${c.path?.slice(0, 49)}${c.path?.length > 50 ? '...' : ''}`
|
||||
},
|
||||
},
|
||||
]
|
||||
|
@@ -1,11 +1,17 @@
|
||||
export interface DocumentSuggestions {
|
||||
title?: string
|
||||
|
||||
tags?: number[]
|
||||
suggested_tags?: string[]
|
||||
|
||||
correspondents?: number[]
|
||||
suggested_correspondents?: string[]
|
||||
|
||||
document_types?: number[]
|
||||
suggested_document_types?: string[]
|
||||
|
||||
storage_paths?: number[]
|
||||
suggested_storage_paths?: string[]
|
||||
|
||||
dates?: string[] // ISO-formatted date string e.g. 2022-11-03
|
||||
}
|
||||
|
@@ -44,12 +44,24 @@ export enum ConfigOptionType {
|
||||
Boolean = 'boolean',
|
||||
JSON = 'json',
|
||||
File = 'file',
|
||||
Password = 'password',
|
||||
}
|
||||
|
||||
export const ConfigCategory = {
|
||||
General: $localize`General Settings`,
|
||||
OCR: $localize`OCR Settings`,
|
||||
Barcode: $localize`Barcode Settings`,
|
||||
AI: $localize`AI Settings`,
|
||||
}
|
||||
|
||||
export const LLMEmbeddingBackendConfig = {
|
||||
OPENAI: 'openai',
|
||||
HUGGINGFACE: 'huggingface',
|
||||
}
|
||||
|
||||
export const LLMBackendConfig = {
|
||||
OPENAI: 'openai',
|
||||
OLLAMA: 'ollama',
|
||||
}
|
||||
|
||||
export interface ConfigOption {
|
||||
@@ -59,6 +71,7 @@ export interface ConfigOption {
|
||||
choices?: Array<{ id: string; name: string }>
|
||||
config_key?: string
|
||||
category: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
function mapToItems(enumObj: Object): Array<{ id: string; name: string }> {
|
||||
@@ -258,6 +271,58 @@ export const PaperlessConfigOptions: ConfigOption[] = [
|
||||
config_key: 'PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING',
|
||||
category: ConfigCategory.Barcode,
|
||||
},
|
||||
{
|
||||
key: 'ai_enabled',
|
||||
title: $localize`AI Enabled`,
|
||||
type: ConfigOptionType.Boolean,
|
||||
config_key: 'PAPERLESS_AI_ENABLED',
|
||||
category: ConfigCategory.AI,
|
||||
note: $localize`Consider privacy implications when enabling AI features, especially if using a remote model.`,
|
||||
},
|
||||
{
|
||||
key: 'llm_embedding_backend',
|
||||
title: $localize`LLM Embedding Backend`,
|
||||
type: ConfigOptionType.Select,
|
||||
choices: mapToItems(LLMEmbeddingBackendConfig),
|
||||
config_key: 'PAPERLESS_AI_LLM_EMBEDDING_BACKEND',
|
||||
category: ConfigCategory.AI,
|
||||
},
|
||||
{
|
||||
key: 'llm_embedding_model',
|
||||
title: $localize`LLM Embedding Model`,
|
||||
type: ConfigOptionType.String,
|
||||
config_key: 'PAPERLESS_AI_LLM_EMBEDDING_MODEL',
|
||||
category: ConfigCategory.AI,
|
||||
},
|
||||
{
|
||||
key: 'llm_backend',
|
||||
title: $localize`LLM Backend`,
|
||||
type: ConfigOptionType.Select,
|
||||
choices: mapToItems(LLMBackendConfig),
|
||||
config_key: 'PAPERLESS_AI_LLM_BACKEND',
|
||||
category: ConfigCategory.AI,
|
||||
},
|
||||
{
|
||||
key: 'llm_model',
|
||||
title: $localize`LLM Model`,
|
||||
type: ConfigOptionType.String,
|
||||
config_key: 'PAPERLESS_AI_LLM_MODEL',
|
||||
category: ConfigCategory.AI,
|
||||
},
|
||||
{
|
||||
key: 'llm_api_key',
|
||||
title: $localize`LLM API Key`,
|
||||
type: ConfigOptionType.Password,
|
||||
config_key: 'PAPERLESS_AI_LLM_API_KEY',
|
||||
category: ConfigCategory.AI,
|
||||
},
|
||||
{
|
||||
key: 'llm_endpoint',
|
||||
title: $localize`LLM Endpoint`,
|
||||
type: ConfigOptionType.String,
|
||||
config_key: 'PAPERLESS_AI_LLM_ENDPOINT',
|
||||
category: ConfigCategory.AI,
|
||||
},
|
||||
]
|
||||
|
||||
export interface PaperlessConfig extends ObjectWithId {
|
||||
@@ -287,4 +352,11 @@ export interface PaperlessConfig extends ObjectWithId {
|
||||
barcode_max_pages: number
|
||||
barcode_enable_tag: boolean
|
||||
barcode_tag_mapping: object
|
||||
ai_enabled: boolean
|
||||
llm_embedding_backend: string
|
||||
llm_embedding_model: string
|
||||
llm_backend: string
|
||||
llm_model: string
|
||||
llm_api_key: string
|
||||
llm_endpoint: string
|
||||
}
|
||||
|
@@ -11,6 +11,7 @@ export enum PaperlessTaskName {
|
||||
TrainClassifier = 'train_classifier',
|
||||
SanityCheck = 'check_sanity',
|
||||
IndexOptimize = 'index_optimize',
|
||||
LLMIndexUpdate = 'llmindex_update',
|
||||
}
|
||||
|
||||
export enum PaperlessTaskStatus {
|
||||
|
@@ -7,6 +7,7 @@ export enum SystemStatusItemStatus {
|
||||
OK = 'OK',
|
||||
ERROR = 'ERROR',
|
||||
WARNING = 'WARNING',
|
||||
DISABLED = 'DISABLED',
|
||||
}
|
||||
|
||||
export interface SystemStatus {
|
||||
@@ -43,5 +44,8 @@ export interface SystemStatus {
|
||||
sanity_check_status: SystemStatusItemStatus
|
||||
sanity_check_last_run: string // ISO date string
|
||||
sanity_check_error: string
|
||||
llmindex_status: SystemStatusItemStatus
|
||||
llmindex_last_modified: string // ISO date string
|
||||
llmindex_error: string
|
||||
}
|
||||
}
|
||||
|
@@ -76,6 +76,7 @@ export const SETTINGS_KEYS = {
|
||||
GMAIL_OAUTH_URL: 'gmail_oauth_url',
|
||||
OUTLOOK_OAUTH_URL: 'outlook_oauth_url',
|
||||
EMAIL_ENABLED: 'email_enabled',
|
||||
AI_ENABLED: 'ai_enabled',
|
||||
}
|
||||
|
||||
export const SETTINGS: UiSetting[] = [
|
||||
@@ -289,4 +290,9 @@ export const SETTINGS: UiSetting[] = [
|
||||
type: 'string',
|
||||
default: 'page-width', // ZoomSetting from 'document-detail.component'
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.AI_ENABLED,
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
]
|
||||
|
@@ -4,15 +4,15 @@ import {
|
||||
HttpInterceptor,
|
||||
HttpRequest,
|
||||
} from '@angular/common/http'
|
||||
import { Injectable, inject } from '@angular/core'
|
||||
import { inject, Injectable } from '@angular/core'
|
||||
import { Meta } from '@angular/platform-browser'
|
||||
import { CookieService } from 'ngx-cookie-service'
|
||||
import { Observable } from 'rxjs'
|
||||
|
||||
@Injectable()
|
||||
export class CsrfInterceptor implements HttpInterceptor {
|
||||
private cookieService = inject(CookieService)
|
||||
private meta = inject(Meta)
|
||||
private cookieService: CookieService = inject(CookieService)
|
||||
private meta: Meta = inject(Meta)
|
||||
|
||||
intercept(
|
||||
request: HttpRequest<unknown>,
|
||||
|
58
src-ui/src/app/services/chat.service.spec.ts
Normal file
58
src-ui/src/app/services/chat.service.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
HttpEventType,
|
||||
provideHttpClient,
|
||||
withInterceptorsFromDi,
|
||||
} from '@angular/common/http'
|
||||
import {
|
||||
HttpTestingController,
|
||||
provideHttpClientTesting,
|
||||
} from '@angular/common/http/testing'
|
||||
import { TestBed } from '@angular/core/testing'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { ChatService } from './chat.service'
|
||||
|
||||
describe('ChatService', () => {
|
||||
let service: ChatService
|
||||
let httpMock: HttpTestingController
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [],
|
||||
providers: [
|
||||
ChatService,
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
})
|
||||
service = TestBed.inject(ChatService)
|
||||
httpMock = TestBed.inject(HttpTestingController)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify()
|
||||
})
|
||||
|
||||
it('should stream chat messages', (done) => {
|
||||
const documentId = 1
|
||||
const prompt = 'Hello, world!'
|
||||
const mockResponse = 'Partial response text'
|
||||
const apiUrl = `${environment.apiBaseUrl}documents/chat/`
|
||||
|
||||
service.streamChat(documentId, prompt).subscribe((chunk) => {
|
||||
expect(chunk).toBe(mockResponse)
|
||||
done()
|
||||
})
|
||||
|
||||
const req = httpMock.expectOne(apiUrl)
|
||||
expect(req.request.method).toBe('POST')
|
||||
expect(req.request.body).toEqual({
|
||||
document_id: documentId,
|
||||
q: prompt,
|
||||
})
|
||||
|
||||
req.event({
|
||||
type: HttpEventType.DownloadProgress,
|
||||
partialText: mockResponse,
|
||||
} as any)
|
||||
})
|
||||
})
|
46
src-ui/src/app/services/chat.service.ts
Normal file
46
src-ui/src/app/services/chat.service.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
HttpClient,
|
||||
HttpDownloadProgressEvent,
|
||||
HttpEventType,
|
||||
} from '@angular/common/http'
|
||||
import { inject, Injectable } from '@angular/core'
|
||||
import { filter, map, Observable } from 'rxjs'
|
||||
import { environment } from 'src/environments/environment'
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
isStreaming?: boolean
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ChatService {
|
||||
private http: HttpClient = inject(HttpClient)
|
||||
|
||||
streamChat(documentId: number, prompt: string): Observable<string> {
|
||||
return this.http
|
||||
.post(
|
||||
`${environment.apiBaseUrl}documents/chat/`,
|
||||
{
|
||||
document_id: documentId,
|
||||
q: prompt,
|
||||
},
|
||||
{
|
||||
observe: 'events',
|
||||
reportProgress: true,
|
||||
responseType: 'text',
|
||||
withCredentials: true,
|
||||
}
|
||||
)
|
||||
.pipe(
|
||||
map((event) => {
|
||||
if (event.type === HttpEventType.DownloadProgress) {
|
||||
return (event as HttpDownloadProgressEvent).partialText!
|
||||
}
|
||||
}),
|
||||
filter((chunk) => !!chunk)
|
||||
)
|
||||
}
|
||||
}
|
@@ -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)
|
||||
}
|
||||
|
@@ -6,7 +6,7 @@ export const environment = {
|
||||
apiVersion: '9', // match src/paperless/settings.py
|
||||
appTitle: 'Paperless-ngx',
|
||||
tag: 'prod',
|
||||
version: '2.17.1',
|
||||
version: '2.18.3',
|
||||
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user