mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-26 20:53:20 +00:00
Compare commits
162
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b4f44096d | ||
|
|
2e7cd14964 | ||
|
|
5090ffe39e | ||
|
|
35d40b51cb | ||
|
|
78025df405 | ||
|
|
e24db7023a | ||
|
|
f5ddc14588 | ||
|
|
492424f7f2 | ||
|
|
cd525819d7 | ||
|
|
2609327e9c | ||
|
|
b90ccf910f | ||
|
|
c93c996edf | ||
|
|
7f1609332a | ||
|
|
a1f20c9fe7 | ||
|
|
4fd1c60731 | ||
|
|
ba83e5b39a | ||
|
|
1497bd33a1 | ||
|
|
01c40aa0bd | ||
|
|
5f70b6eee7 | ||
|
|
e3e4b26944 | ||
|
|
a0908f6b4a | ||
|
|
bab9129ff8 | ||
|
|
1e13f86174 | ||
|
|
22c57fb392 | ||
|
|
08a7f6ccc0 | ||
|
|
3f4d327f64 | ||
|
|
bd326540fa | ||
|
|
33f9adb05a | ||
|
|
d24606a03b | ||
|
|
294328f174 | ||
|
|
0458bad5f2 | ||
|
|
a510d03c77 | ||
|
|
d7b3612a41 | ||
|
|
7e4a644714 | ||
|
|
bbcd6af2fe | ||
|
|
0431939f18 | ||
|
|
bed95ea301 | ||
|
|
42034c3c77 | ||
|
|
705220fb5a | ||
|
|
a424dace43 | ||
|
|
751299895e | ||
|
|
5f9bc5de88 | ||
|
|
f1c8a72f26 | ||
|
|
fd3c525f03 | ||
|
|
e389298aab | ||
|
|
c5c5cc0b1d | ||
|
|
b17a512539 | ||
|
|
4cf027de40 | ||
|
|
41953c7846 | ||
|
|
e5abe5cf32 | ||
|
|
643d0205bc | ||
|
|
2865a095b0 | ||
|
|
f0eb77c405 | ||
|
|
e5cc7c6c40 | ||
|
|
f2806179a2 | ||
|
|
36c736a9f9 | ||
|
|
be35079d54 | ||
|
|
a75465b734 | ||
|
|
0af1e41753 | ||
|
|
d88b281eb2 | ||
|
|
8446036777 | ||
|
|
8ad5e8cca3 | ||
|
|
1606a46b53 | ||
|
|
f647f304da | ||
|
|
31746371f4 | ||
|
|
0e5fbc973a | ||
|
|
3322c92837 | ||
|
|
db15c82804 | ||
|
|
fe5d09a123 | ||
|
|
a0feb827c9 | ||
|
|
b599b13f72 | ||
|
|
01c12d9ea4 | ||
|
|
f5c0d118f7 | ||
|
|
ff13847d0a | ||
|
|
634f803872 | ||
|
|
639d566a7c | ||
|
|
0a94f8f0d4 | ||
|
|
2d084983c8 | ||
|
|
2a8579f610 | ||
|
|
4789fe9a52 | ||
|
|
e150c8c7c0 | ||
|
|
879cd4a30a | ||
|
|
6a02b87dde | ||
|
|
59a2651804 | ||
|
|
a99f63e059 | ||
|
|
939cb52f6e | ||
|
|
855669ddf9 | ||
|
|
62089df2d8 | ||
|
|
5e5f6a88a3 | ||
|
|
02e6c49c62 | ||
|
|
e2c284f64e | ||
|
|
3be64da4cb | ||
|
|
c28c532bef | ||
|
|
1d61f7fc62 | ||
|
|
aa67fd3aef | ||
|
|
17dc482872 | ||
|
|
b0e0e8a353 | ||
|
|
fc242bb570 | ||
|
|
b192a419fd | ||
|
|
3986150f95 | ||
|
|
ee5588ade3 | ||
|
|
2635a12281 | ||
|
|
1f396e51f3 | ||
|
|
3eb784b34d | ||
|
|
cb03a0b33e | ||
|
|
a96d0b15b8 | ||
|
|
376b61938f | ||
|
|
d1f5eb0335 | ||
|
|
d283205f57 | ||
|
|
42908ad2b9 | ||
|
|
db6842e710 | ||
|
|
d5f8cd59fb | ||
|
|
91d6741b4f | ||
|
|
972758fc72 | ||
|
|
15d9829b6d | ||
|
|
5ad34dfe03 | ||
|
|
52a0484f74 | ||
|
|
71e2f86f70 | ||
|
|
1824e5fafd | ||
|
|
7b9e56ef22 | ||
|
|
2b9bed749d | ||
|
|
ef49414162 | ||
|
|
a488ba6f90 | ||
|
|
47727de116 | ||
|
|
e86adb6c36 | ||
|
|
74aa10f31f | ||
|
|
2ea4a792fe | ||
|
|
3f5c770af4 | ||
|
|
765313926f | ||
|
|
1f1725ba56 | ||
|
|
c6cecd3c4e | ||
|
|
5e54259db9 | ||
|
|
3a484a3ff2 | ||
|
|
92a1b24583 | ||
|
|
8709dd807b | ||
|
|
5a5a0ffd21 | ||
|
|
d7aa0cb862 | ||
|
|
eafb494066 | ||
|
|
4ca1863dee | ||
|
|
d5d22c2c0f | ||
|
|
991d99bb7c | ||
|
|
30f11814ff | ||
|
|
0ca5c66ca7 | ||
|
|
dd99c2289d | ||
|
|
23c11f49d0 | ||
|
|
cfa1d3b058 | ||
|
|
68bd8f8f63 | ||
|
|
41d5d86637 | ||
|
|
2a50425902 | ||
|
|
b67ac5a7d7 | ||
|
|
b48c434266 | ||
|
|
92baa89638 | ||
|
|
4c7b3dd307 | ||
|
|
fe48752a6f | ||
|
|
4692de5595 | ||
|
|
141d74464b | ||
|
|
c05d069e97 | ||
|
|
f5e47a86c3 | ||
|
|
fe196eaf0e | ||
|
|
7fda0e877b | ||
|
|
5b8f4db1ca | ||
|
|
08ab98f3fc |
@@ -68,7 +68,7 @@ services:
|
||||
- "--chromium-disable-javascript=true"
|
||||
- "--chromium-allow-list=file:///tmp/.*"
|
||||
tika:
|
||||
image: docker.io/apache/tika:latest
|
||||
image: docker.io/apache/tika:3.3.1.0
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
data:
|
||||
|
||||
+37
-35
@@ -1,49 +1,51 @@
|
||||
categories:
|
||||
- type: 'pre-include'
|
||||
when:
|
||||
- label: 'enhancement'
|
||||
- label: 'bug'
|
||||
- label: 'chore'
|
||||
- label: 'deployment'
|
||||
- label: 'translation'
|
||||
- label: 'dependencies'
|
||||
- label: 'documentation'
|
||||
- label: 'frontend'
|
||||
- label: 'backend'
|
||||
- label: 'ci-cd'
|
||||
- label: 'breaking-change'
|
||||
- label: 'notable'
|
||||
- type: 'pre-exclude'
|
||||
when:
|
||||
- label: 'skip-changelog'
|
||||
- title: 'Breaking Changes'
|
||||
labels:
|
||||
- 'breaking-change'
|
||||
when:
|
||||
- label: 'breaking-change'
|
||||
- title: 'Notable Changes'
|
||||
labels:
|
||||
- 'notable'
|
||||
when:
|
||||
- label: 'notable'
|
||||
- title: 'Features / Enhancements'
|
||||
labels:
|
||||
- 'enhancement'
|
||||
when:
|
||||
- label: 'enhancement'
|
||||
- title: 'Bug Fixes'
|
||||
labels:
|
||||
- 'bug'
|
||||
when:
|
||||
- label: 'bug'
|
||||
- title: 'Documentation'
|
||||
labels:
|
||||
- 'documentation'
|
||||
when:
|
||||
- label: 'documentation'
|
||||
- title: 'Maintenance'
|
||||
labels:
|
||||
- 'chore'
|
||||
- 'deployment'
|
||||
- 'translation'
|
||||
- 'ci-cd'
|
||||
when:
|
||||
- label: 'chore'
|
||||
- label: 'deployment'
|
||||
- label: 'translation'
|
||||
- label: 'ci-cd'
|
||||
- title: 'Dependencies'
|
||||
collapse-after: 3
|
||||
labels:
|
||||
- 'dependencies'
|
||||
when:
|
||||
- label: 'dependencies'
|
||||
- title: 'All App Changes'
|
||||
labels:
|
||||
- 'frontend'
|
||||
- 'backend'
|
||||
collapse-after: 1
|
||||
include-labels:
|
||||
- 'enhancement'
|
||||
- 'bug'
|
||||
- 'chore'
|
||||
- 'deployment'
|
||||
- 'translation'
|
||||
- 'dependencies'
|
||||
- 'documentation'
|
||||
- 'frontend'
|
||||
- 'backend'
|
||||
- 'ci-cd'
|
||||
- 'breaking-change'
|
||||
- 'notable'
|
||||
exclude-labels:
|
||||
- 'skip-changelog'
|
||||
when:
|
||||
- label: 'frontend'
|
||||
- label: 'backend'
|
||||
filter-by-commitish: true
|
||||
category-template: '### $TITLE'
|
||||
change-template: '- $TITLE @$AUTHOR ([#$NUMBER]($URL))'
|
||||
|
||||
@@ -11,7 +11,7 @@ concurrency:
|
||||
group: backend-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
env:
|
||||
DEFAULT_UV_VERSION: "0.11.x"
|
||||
DEFAULT_UV_VERSION: "0.12.x"
|
||||
NLTK_DATA: "/usr/share/nltk_data"
|
||||
permissions: {}
|
||||
jobs:
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
backend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.backend == 'true' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
- name: Detect changes
|
||||
id: filter
|
||||
if: steps.force.outputs.run_all != 'true'
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
with:
|
||||
base: ${{ steps.range.outputs.base }}
|
||||
ref: ${{ steps.range.outputs.ref }}
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
fail-fast: false
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Start containers
|
||||
@@ -96,11 +96,11 @@ jobs:
|
||||
docker compose --file docker/compose/docker-compose.ci-test.yml up --detach
|
||||
- name: Set up Python
|
||||
id: setup-python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "${{ matrix.python-version }}"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: true
|
||||
@@ -169,16 +169,16 @@ jobs:
|
||||
PAPERLESS_SECRET_KEY: "ci-typing-not-a-real-secret"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
id: setup-python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "${{ env.DEFAULT_PYTHON }}"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: true
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
ref-name: ${{ steps.ref.outputs.name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Determine ref name
|
||||
@@ -106,9 +106,9 @@ jobs:
|
||||
echo "repository=${repo_name}"
|
||||
echo "name=${repo_name}" >> $GITHUB_OUTPUT
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -121,7 +121,7 @@ jobs:
|
||||
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
|
||||
- name: Docker metadata
|
||||
id: docker-meta
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}
|
||||
@@ -132,7 +132,7 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
@@ -182,29 +182,29 @@ jobs:
|
||||
echo "Downloaded digests:"
|
||||
ls -la /tmp/digests/
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Login to Docker Hub
|
||||
if: needs.build-arch.outputs.push-external == 'true'
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Login to Quay.io
|
||||
if: needs.build-arch.outputs.push-external == 'true'
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
registry: quay.io
|
||||
username: ${{ secrets.QUAY_USERNAME }}
|
||||
password: ${{ secrets.QUAY_ROBOT_TOKEN }}
|
||||
- name: Docker metadata
|
||||
id: docker-meta
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ needs.build-arch.outputs.repository }}
|
||||
|
||||
@@ -11,7 +11,7 @@ concurrency:
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
DEFAULT_UV_VERSION: "0.11.x"
|
||||
DEFAULT_UV_VERSION: "0.12.x"
|
||||
DEFAULT_PYTHON_VERSION: "3.12"
|
||||
jobs:
|
||||
changes:
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
docs_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.docs == 'true' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
- name: Detect changes
|
||||
id: filter
|
||||
if: steps.force.outputs.run_all != 'true'
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
with:
|
||||
base: ${{ steps.range.outputs.base }}
|
||||
ref: ${{ steps.range.outputs.ref }}
|
||||
@@ -69,16 +69,16 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
id: setup-python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: true
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
frontend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.frontend == 'true' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
- name: Detect changes
|
||||
id: filter
|
||||
if: steps.force.outputs.run_all != 'true'
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
with:
|
||||
base: ${{ steps.range.outputs.base }}
|
||||
ref: ${{ steps.range.outputs.ref }}
|
||||
@@ -77,15 +77,15 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
with:
|
||||
version: 10
|
||||
package_json_file: src-ui/package.json
|
||||
- name: Use Node.js 24
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
cache: 'pnpm'
|
||||
@@ -109,15 +109,15 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
with:
|
||||
version: 10
|
||||
package_json_file: src-ui/package.json
|
||||
- name: Use Node.js 24
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
cache: 'pnpm'
|
||||
@@ -129,8 +129,8 @@ jobs:
|
||||
~/.pnpm-store
|
||||
~/.cache
|
||||
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||
- name: Re-link Angular CLI
|
||||
run: cd src-ui && pnpm link @angular/cli
|
||||
- name: Install dependencies
|
||||
run: cd src-ui && pnpm install --frozen-lockfile
|
||||
- name: Run lint
|
||||
run: cd src-ui && pnpm run lint
|
||||
unit-tests:
|
||||
@@ -148,15 +148,15 @@ jobs:
|
||||
shard-count: [4]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
with:
|
||||
version: 10
|
||||
package_json_file: src-ui/package.json
|
||||
- name: Use Node.js 24
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
cache: 'pnpm'
|
||||
@@ -168,8 +168,8 @@ jobs:
|
||||
~/.pnpm-store
|
||||
~/.cache
|
||||
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||
- name: Re-link Angular CLI
|
||||
run: cd src-ui && pnpm link @angular/cli
|
||||
- name: Install dependencies
|
||||
run: cd src-ui && pnpm install --frozen-lockfile
|
||||
- name: Run Jest unit tests
|
||||
run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
||||
- name: Upload test results to Codecov
|
||||
@@ -191,7 +191,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
container: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
container: mcr.microsoft.com/playwright:v1.62.0-noble
|
||||
env:
|
||||
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
|
||||
@@ -203,15 +203,15 @@ jobs:
|
||||
shard-count: [2]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
with:
|
||||
version: 10
|
||||
package_json_file: src-ui/package.json
|
||||
- name: Use Node.js 24
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
cache: 'pnpm'
|
||||
@@ -223,32 +223,29 @@ jobs:
|
||||
~/.pnpm-store
|
||||
~/.cache
|
||||
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||
- name: Re-link Angular CLI
|
||||
run: cd src-ui && pnpm link @angular/cli
|
||||
- name: Install dependencies
|
||||
run: cd src-ui && pnpm install --no-frozen-lockfile
|
||||
run: cd src-ui && pnpm install --frozen-lockfile
|
||||
- name: Run Playwright E2E tests
|
||||
run: cd src-ui && pnpm exec playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
||||
bundle-analysis:
|
||||
name: Bundle Analysis
|
||||
frontend-build:
|
||||
name: Frontend Build
|
||||
needs: [changes, unit-tests, e2e-tests]
|
||||
if: needs.changes.outputs.frontend_changed == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
environment: bundle-analysis
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 2
|
||||
persist-credentials: false
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
with:
|
||||
version: 10
|
||||
package_json_file: src-ui/package.json
|
||||
- name: Use Node.js 24
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
cache: 'pnpm'
|
||||
@@ -260,21 +257,19 @@ jobs:
|
||||
~/.pnpm-store
|
||||
~/.cache
|
||||
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||
- name: Re-link Angular CLI
|
||||
run: cd src-ui && pnpm link @angular/cli
|
||||
- name: Build and analyze
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
- name: Install dependencies
|
||||
run: cd src-ui && pnpm install --frozen-lockfile
|
||||
- name: Build
|
||||
run: cd src-ui && pnpm run build --configuration=production
|
||||
gate:
|
||||
name: Frontend CI Gate
|
||||
needs: [changes, install-dependencies, lint, unit-tests, e2e-tests, bundle-analysis]
|
||||
needs: [changes, install-dependencies, lint, unit-tests, e2e-tests, frontend-build]
|
||||
if: always()
|
||||
runs-on: ubuntu-slim
|
||||
steps:
|
||||
- name: Check gate
|
||||
env:
|
||||
BUNDLE_ANALYSIS_RESULT: ${{ needs['bundle-analysis'].result }}
|
||||
BUILD_RESULT: ${{ needs['frontend-build'].result }}
|
||||
E2E_RESULT: ${{ needs['e2e-tests'].result }}
|
||||
FRONTEND_CHANGED: ${{ needs.changes.outputs.frontend_changed }}
|
||||
INSTALL_RESULT: ${{ needs['install-dependencies'].result }}
|
||||
@@ -306,8 +301,8 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${BUNDLE_ANALYSIS_RESULT}" != "success" ]]; then
|
||||
echo "::error::Frontend bundle-analysis job result: ${BUNDLE_ANALYSIS_RESULT}"
|
||||
if [[ "${BUILD_RESULT}" != "success" ]]; then
|
||||
echo "::error::Frontend build job result: ${BUILD_RESULT}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -17,12 +17,12 @@ jobs:
|
||||
runs-on: ubuntu-slim
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
- name: Run prek
|
||||
uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4
|
||||
uses: j178/prek-action@5337cb91e0fa35a7ff31b9ca345126d8bbbcdf16 # v2.0.6
|
||||
|
||||
@@ -8,7 +8,7 @@ concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
DEFAULT_UV_VERSION: "0.11.x"
|
||||
DEFAULT_UV_VERSION: "0.12.x"
|
||||
DEFAULT_PYTHON_VERSION: "3.12"
|
||||
permissions: {}
|
||||
jobs:
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
statuses: read
|
||||
steps:
|
||||
- name: Wait for Docker build
|
||||
uses: lewagon/wait-on-check-action@96d9100b431964d10e0136aff8b9ccb92470505e # v1.8.0
|
||||
uses: lewagon/wait-on-check-action@2271c86c146b96545b4e871b855e10ffa6f50773 # v1.9.0
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
check-name: 'Merge and Push Manifest'
|
||||
@@ -35,16 +35,16 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
# ---- Frontend Build ----
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
with:
|
||||
version: 10
|
||||
package_json_file: src-ui/package.json
|
||||
- name: Use Node.js 24
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
package-manager-cache: false
|
||||
@@ -55,11 +55,11 @@ jobs:
|
||||
# ---- Backend Setup ----
|
||||
- name: Set up Python
|
||||
id: setup-python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: false
|
||||
@@ -171,7 +171,7 @@ jobs:
|
||||
fi
|
||||
- name: Create release and changelog
|
||||
id: create-release
|
||||
uses: release-drafter/release-drafter@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1
|
||||
uses: release-drafter/release-drafter@eada3c96a64734dd381cfbda23511034e328ddb0 # v7.6.0
|
||||
with:
|
||||
name: Paperless-ngx ${{ steps.get-version.outputs.version }}
|
||||
tag: ${{ steps.get-version.outputs.version }}
|
||||
@@ -202,17 +202,17 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: true # for pushing changelog branch
|
||||
- name: Set up Python
|
||||
id: setup-python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: false
|
||||
|
||||
@@ -22,11 +22,11 @@ jobs:
|
||||
security-events: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run zizmor
|
||||
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
|
||||
uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1
|
||||
semgrep:
|
||||
name: Semgrep CE
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -38,13 +38,13 @@ jobs:
|
||||
security-events: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run Semgrep
|
||||
run: semgrep scan --config auto --sarif-output results.sarif
|
||||
- name: Upload results to GitHub code scanning
|
||||
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -34,12 +34,12 @@ jobs:
|
||||
# Learn more about CodeQL language support at https://git.io/codeql-language-support
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -47,4 +47,4 @@ jobs:
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
|
||||
@@ -17,12 +17,12 @@ jobs:
|
||||
environment: translation-sync
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
token: ${{ secrets.PNGX_BOT_PAT }}
|
||||
persist-credentials: false
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
|
||||
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
|
||||
with:
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
steps:
|
||||
- name: Label PR by file path or branch name
|
||||
# see .github/labeler.yml for the labeler config
|
||||
uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
|
||||
uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Label by size
|
||||
|
||||
@@ -19,6 +19,6 @@ jobs:
|
||||
if: github.event_name == 'pull_request_target' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login != 'dependabot'
|
||||
steps:
|
||||
- name: Label PR with release-drafter
|
||||
uses: release-drafter/release-drafter@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1
|
||||
uses: release-drafter/release-drafter@eada3c96a64734dd381cfbda23511034e328ddb0 # v7.6.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
||||
with:
|
||||
days-before-stale: 7
|
||||
days-before-close: 14
|
||||
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
branches:
|
||||
- dev
|
||||
env:
|
||||
DEFAULT_UV_VERSION: "0.11.x"
|
||||
DEFAULT_UV_VERSION: "0.12.x"
|
||||
jobs:
|
||||
generate-translate-strings:
|
||||
name: Generate Translation Strings
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
env:
|
||||
GH_REF: ${{ github.ref }} # sonar rule:githubactions:S7630 - avoid injection
|
||||
with:
|
||||
@@ -23,13 +23,13 @@ jobs:
|
||||
persist-credentials: true # for pushing translation branch
|
||||
- name: Set up Python
|
||||
id: setup-python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -qq --no-install-recommends gettext
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: true
|
||||
@@ -45,9 +45,9 @@ jobs:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
with:
|
||||
version: 10
|
||||
package_json_file: src-ui/package.json
|
||||
- name: Use Node.js 24
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
cache: 'pnpm'
|
||||
@@ -61,16 +61,13 @@ jobs:
|
||||
~/.cache
|
||||
key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||
- name: Install frontend dependencies
|
||||
if: steps.cache-frontend-deps.outputs.cache-hit != 'true'
|
||||
run: cd src-ui && pnpm install
|
||||
- name: Re-link Angular cli
|
||||
run: cd src-ui && pnpm link @angular/cli
|
||||
run: cd src-ui && pnpm install --frozen-lockfile
|
||||
- name: Generate frontend translation strings
|
||||
run: |
|
||||
cd src-ui
|
||||
pnpm run ng extract-i18n
|
||||
- name: Commit changes
|
||||
uses: stefanzweifel/git-auto-commit-action@04702edda442b2e678b25b537cec683a1493fcb9 # v7.1.0
|
||||
uses: stefanzweifel/git-auto-commit-action@4a55954c782fc1ea30b9056cd3e7a2b40ca8887d # v7.2.0
|
||||
with:
|
||||
file_pattern: 'src-ui/messages.xlf src/locale/en_US/LC_MESSAGES/django.po'
|
||||
commit_message: "Auto translate strings"
|
||||
|
||||
@@ -29,7 +29,7 @@ repos:
|
||||
- id: check-case-conflict
|
||||
- id: detect-private-key
|
||||
- repo: https://github.com/codespell-project/codespell
|
||||
rev: v2.4.2
|
||||
rev: v2.4.3
|
||||
hooks:
|
||||
- id: codespell
|
||||
additional_dependencies: [tomli]
|
||||
@@ -38,7 +38,7 @@ repos:
|
||||
- json
|
||||
# See https://github.com/prettier/prettier/issues/15742 for the fork reason
|
||||
- repo: https://github.com/rbubley/mirrors-prettier
|
||||
rev: 'v3.9.4'
|
||||
rev: 'v3.9.6'
|
||||
hooks:
|
||||
- id: prettier
|
||||
types_or:
|
||||
@@ -46,16 +46,16 @@ repos:
|
||||
- ts
|
||||
- markdown
|
||||
additional_dependencies:
|
||||
- prettier@3.9.4
|
||||
- prettier@3.9.6
|
||||
- 'prettier-plugin-organize-imports@4.3.0'
|
||||
# Python hooks
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.20
|
||||
rev: v0.16.1
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
- id: ruff-format
|
||||
- repo: https://github.com/tox-dev/pyproject-fmt
|
||||
rev: "v2.25.1"
|
||||
rev: "v2.26.0"
|
||||
hooks:
|
||||
- id: pyproject-fmt
|
||||
additional_dependencies: [tomli]
|
||||
|
||||
+1
-1
@@ -30,7 +30,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.11.28-python3.12-trixie-slim AS s6-overlay-base
|
||||
FROM ghcr.io/astral-sh/uv:0.12.5-python3.14-trixie-slim AS s6-overlay-base
|
||||
|
||||
WORKDIR /usr/src/s6
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ services:
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
greenmail:
|
||||
image: docker.io/greenmail/standalone:2.1.9
|
||||
image: docker.io/greenmail/standalone:2.1.11
|
||||
hostname: greenmail
|
||||
container_name: greenmail
|
||||
environment:
|
||||
@@ -35,7 +35,7 @@ services:
|
||||
- "3143:3143" # IMAP
|
||||
restart: unless-stopped
|
||||
nginx:
|
||||
image: docker.io/nginx:1.31.2-alpine
|
||||
image: docker.io/nginx:1.31.3-alpine
|
||||
hostname: nginx
|
||||
container_name: nginx
|
||||
ports:
|
||||
|
||||
@@ -81,7 +81,7 @@ services:
|
||||
- "--chromium-disable-javascript=true"
|
||||
- "--chromium-allow-list=file:///tmp/.*"
|
||||
tika:
|
||||
image: docker.io/apache/tika:latest
|
||||
image: docker.io/apache/tika:3.3.1.0
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
data:
|
||||
|
||||
@@ -76,7 +76,7 @@ services:
|
||||
- "--chromium-disable-javascript=true"
|
||||
- "--chromium-allow-list=file:///tmp/.*"
|
||||
tika:
|
||||
image: docker.io/apache/tika:latest
|
||||
image: docker.io/apache/tika:3.3.1.0
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
data:
|
||||
|
||||
@@ -65,7 +65,7 @@ services:
|
||||
- "--chromium-disable-javascript=true"
|
||||
- "--chromium-allow-list=file:///tmp/.*"
|
||||
tika:
|
||||
image: docker.io/apache/tika:latest
|
||||
image: docker.io/apache/tika:3.3.1.0
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
data:
|
||||
|
||||
@@ -68,7 +68,14 @@
|
||||
<!-- <policy domain="resource" name="thread" value="4"/> -->
|
||||
<!-- <policy domain="resource" name="throttle" value="0"/> -->
|
||||
<!-- <policy domain="resource" name="time" value="3600"/> -->
|
||||
<!-- <policy domain="coder" rights="none" pattern="MVG" /> -->
|
||||
<!-- Paperless does not process SVG or ImageMagick scripting formats. -->
|
||||
<policy domain="coder" rights="none" pattern="SVG" />
|
||||
<policy domain="coder" rights="none" pattern="SVGZ" />
|
||||
<policy domain="coder" rights="none" pattern="MSVG" />
|
||||
<policy domain="coder" rights="none" pattern="RSVG" />
|
||||
<policy domain="coder" rights="none" pattern="MSL" />
|
||||
<policy domain="coder" rights="none" pattern="MVG" />
|
||||
<policy domain="coder" rights="none" pattern="EPHEMERAL" />
|
||||
<!-- <policy domain="module" rights="none" pattern="{PS,PDF,XPS}" /> -->
|
||||
<!-- <policy domain="delegate" rights="none" pattern="HTTPS" /> -->
|
||||
<!-- <policy domain="path" rights="none" pattern="@*" /> -->
|
||||
@@ -78,8 +85,6 @@
|
||||
<!-- <policy domain="system" name="pixel-cache-memory" value="anonymous"/> -->
|
||||
<!-- <policy domain="system" name="shred" value="2"/> -->
|
||||
<!-- <policy domain="system" name="precision" value="6"/> -->
|
||||
<!-- not needed due to the need to use explicitly by mvg: -->
|
||||
<!-- <policy domain="delegate" rights="none" pattern="MVG" /> -->
|
||||
<!-- use curl -->
|
||||
<policy domain="delegate" rights="none" pattern="URL" />
|
||||
<policy domain="delegate" rights="none" pattern="HTTPS" />
|
||||
|
||||
@@ -299,6 +299,8 @@ optional arguments:
|
||||
-sm, --split-manifest
|
||||
-z, --zip
|
||||
-zn, --zip-name
|
||||
--zip-compression
|
||||
--zip-compression-level
|
||||
--data-only
|
||||
--no-progress-bar
|
||||
--passphrase
|
||||
@@ -361,6 +363,19 @@ If `-z` or `--zip` is provided, the export will be a zip file
|
||||
in the target directory, named according to the current local date or the
|
||||
value set in `-zn` or `--zip-name`.
|
||||
|
||||
The compression method for the zip can be set with `--zip-compression`
|
||||
(`stored`, `deflated` (default), `bzip2`, `lzma`, or `zstd`) and tuned with
|
||||
`--zip-compression-level` (deflated: 0–9, bzip2: 1–9, zstd: -22–22; ignored
|
||||
for `stored` and `lzma`). Both options require `--zip`.
|
||||
|
||||
!!! warning
|
||||
|
||||
`zstd` compression requires Python 3.14 or newer on **both** the machine
|
||||
creating the export and any machine importing it. An archive compressed with
|
||||
`zstd` (or `lzma`/`bzip2` where those modules are unavailable) cannot be
|
||||
imported on a runtime that lacks the codec; the importer will refuse it with
|
||||
a clear error. The default `deflated` is universally readable.
|
||||
|
||||
If `--data-only` is provided, only the database will be exported. This option is intended
|
||||
to facilitate database upgrades without needing to clean documents and thumbnails from the media directory.
|
||||
|
||||
@@ -699,6 +714,7 @@ document_fuzzy_match [--ratio] [--processes N]
|
||||
| --ratio | No | 85.0 | a number between 0 and 100, setting how similar a document must be for it to be reported. Higher numbers mean more similarity. |
|
||||
| --processes | No | 1/4 of system cores | Number of processes to use for matching. Setting 1 disables multiple processes |
|
||||
| --delete | No | False | If provided, one document of a matched pair above the ratio will be deleted. |
|
||||
| --url | No | blank | If an instance URL is provided, the output table will show URLs to each documents instead of the document ID and name. |
|
||||
|
||||
!!! warning
|
||||
|
||||
|
||||
@@ -129,6 +129,10 @@ At a minimum you need to enable AI and choose an LLM backend:
|
||||
and/or [`PAPERLESS_AI_LLM_ENDPOINT`](configuration.md#PAPERLESS_AI_LLM_ENDPOINT). Ollama
|
||||
requires `PAPERLESS_AI_LLM_ENDPOINT` pointing at your Ollama server.
|
||||
|
||||
See the community-maintained wiki page on
|
||||
[choosing AI models](https://github.com/paperless-ngx/paperless-ngx/wiki/AI-Model-Recommendations)
|
||||
for suggested generation and embedding models.
|
||||
|
||||
### AI-assisted suggestions
|
||||
|
||||
With AI enabled, Paperless-ngx can suggest a title, tags, correspondent, document type,
|
||||
@@ -808,7 +812,8 @@ Third-party parser plugins extend Paperless-ngx to support additional file
|
||||
formats. A plugin is a Python package that advertises itself under the
|
||||
`paperless_ngx.parsers` entry point group. Refer to the
|
||||
[developer documentation](development.md#making-custom-parsers) for how to
|
||||
create one.
|
||||
create one, or see the wiki for a community-maintained list of
|
||||
[parser plugins](https://github.com/paperless-ngx/paperless-ngx/wiki/Related-Projects#parser-plugins).
|
||||
|
||||
!!! warning "Third-party plugins are not officially supported"
|
||||
|
||||
|
||||
+3
-1
@@ -227,6 +227,7 @@ Version-aware endpoints:
|
||||
- `PATCH /api/documents/{id}/`: content updates target the selected version (`?version={version_id}`) or latest version by default; non-content metadata updates target the root document.
|
||||
- `GET /api/documents/{id}/download/`, `GET /api/documents/{id}/preview/`, `GET /api/documents/{id}/thumb/`, `GET /api/documents/{id}/metadata/`: accept `?version={version_id}`.
|
||||
- `POST /api/documents/{id}/update_version/`: uploads a new version using multipart form field `document` and optional `version_label`.
|
||||
- `POST /api/documents/merge_as_versions/`: merges existing top-level documents as versions of a selected root. The JSON body must contain `documents` (at least two document IDs) and `root_document_id` (one of those IDs). When merging one source document, an optional `version_label` may be provided.
|
||||
- `PATCH /api/documents/{id}/versions/{version_id}/`: updates the `version_label` of a specific version.
|
||||
- `DELETE /api/documents/{root_id}/versions/{version_id}/`: deletes a non-root version.
|
||||
|
||||
@@ -301,7 +302,8 @@ The following methods are supported:
|
||||
- `delete`
|
||||
- No `parameters` required
|
||||
- `reprocess`
|
||||
- No `parameters` required
|
||||
- Optional `parameters`: `{ "remote_ocr": true }` to send the documents to the
|
||||
remote OCR engine, see [Remote OCR](usage.md#remote-ocr). Defaults to false.
|
||||
- `set_permissions`
|
||||
- Requires `parameters`:
|
||||
- `"set_permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or
|
||||
|
||||
@@ -1,5 +1,65 @@
|
||||
# Changelog
|
||||
|
||||
## paperless-ngx 3.0.5
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: accept Whoosh-era abbreviated relative-date units (yrs, mos, wks, etc) in search queries [@stumpylog](https://github.com/stumpylog) ([#13486](https://github.com/paperless-ngx/paperless-ngx/pull/13486))
|
||||
- Fix: fix edit dialog error change detection [@shamoon](https://github.com/shamoon) ([#13483](https://github.com/paperless-ngx/paperless-ngx/pull/13483))
|
||||
- Fix: key the AI suggestion cache by model and endpoint [@lunetics](https://github.com/lunetics) ([#13449](https://github.com/paperless-ngx/paperless-ngx/pull/13449))
|
||||
- Fix: validate custom field values in bulk operations [@shamoon](https://github.com/shamoon) ([#13457](https://github.com/paperless-ngx/paperless-ngx/pull/13457))
|
||||
- Fixhancement: better handle empty fields from AI suggestions [@shamoon](https://github.com/shamoon) ([#13454](https://github.com/paperless-ngx/paperless-ngx/pull/13454))
|
||||
- Fix: normalize monetary decimal symbol by locale [@shamoon](https://github.com/shamoon) ([#13427](https://github.com/paperless-ngx/paperless-ngx/pull/13427))
|
||||
- Fix: fold overflowing path text in sanity checker [@stumpylog](https://github.com/stumpylog) ([#13426](https://github.com/paperless-ngx/paperless-ngx/pull/13426))
|
||||
- Fix: correct delayed add field button for custom fields [@shamoon](https://github.com/shamoon) ([#13424](https://github.com/paperless-ngx/paperless-ngx/pull/13424))
|
||||
- Fix: hide attributes collapse / show button without UI settings [@shamoon](https://github.com/shamoon) ([#13425](https://github.com/paperless-ngx/paperless-ngx/pull/13425))
|
||||
- Fix: consolidate born-digital PDF detection between archive decision and OCR [@stumpylog](https://github.com/stumpylog) ([#13409](https://github.com/paperless-ngx/paperless-ngx/pull/13409))
|
||||
- Fix: prevent scale vs page loop in pngx PDF viewer [@shamoon](https://github.com/shamoon) ([#13406](https://github.com/paperless-ngx/paperless-ngx/pull/13406))
|
||||
- Fix: handle hidden line breaks in email subjects when sending email [@shamoon](https://github.com/shamoon) ([#13402](https://github.com/paperless-ngx/paperless-ngx/pull/13402))
|
||||
- Fix: avoid NotSupportedError from document\_importer on MariaDB [@stumpylog](https://github.com/stumpylog) ([#13400](https://github.com/paperless-ngx/paperless-ngx/pull/13400))
|
||||
- Fix: exclude next-period start from relative date-range filters [@stumpylog](https://github.com/stumpylog) ([#13381](https://github.com/paperless-ngx/paperless-ngx/pull/13381))
|
||||
- Fix: close non-atomic db connections in before\_task\_publish [@shamoon](https://github.com/shamoon) ([#13366](https://github.com/paperless-ngx/paperless-ngx/pull/13366))
|
||||
- Chore/fix: refactor frontend task service [@shamoon](https://github.com/shamoon) ([#13365](https://github.com/paperless-ngx/paperless-ngx/pull/13365))
|
||||
- Fix: fix Enter selection in search autocomplete [@shamoon](https://github.com/shamoon) ([#13361](https://github.com/paperless-ngx/paperless-ngx/pull/13361))
|
||||
|
||||
### Documentation
|
||||
|
||||
- Documentation: PAPERLESS\_CONSUMER\_IGNORE\_PATTERNS clarifications [@shamoon](https://github.com/shamoon) ([#13489](https://github.com/paperless-ngx/paperless-ngx/pull/13489))
|
||||
- Documentation: Add Password Removal workflow action documentation [@stumpylog](https://github.com/stumpylog) ([#13377](https://github.com/paperless-ngx/paperless-ngx/pull/13377))
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Chore(deps): Bump pymdown-extensions from 10.21.3 to 11.0 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13378](https://github.com/paperless-ngx/paperless-ngx/pull/13378))
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>21 changes</summary>
|
||||
|
||||
- Fix: accept Whoosh-era abbreviated relative-date units (yrs, mos, wks, etc) in search queries [@stumpylog](https://github.com/stumpylog) ([#13486](https://github.com/paperless-ngx/paperless-ngx/pull/13486))
|
||||
- Fix: fix edit dialog error change detection [@shamoon](https://github.com/shamoon) ([#13483](https://github.com/paperless-ngx/paperless-ngx/pull/13483))
|
||||
- Performance: sqlite-vec point-delete for document chunks [@stumpylog](https://github.com/stumpylog) ([#13438](https://github.com/paperless-ngx/paperless-ngx/pull/13438))
|
||||
- Fix: key the AI suggestion cache by model and endpoint [@lunetics](https://github.com/lunetics) ([#13449](https://github.com/paperless-ngx/paperless-ngx/pull/13449))
|
||||
- Fix: validate custom field values in bulk operations [@shamoon](https://github.com/shamoon) ([#13457](https://github.com/paperless-ngx/paperless-ngx/pull/13457))
|
||||
- Fixhancement: better handle empty fields from AI suggestions [@shamoon](https://github.com/shamoon) ([#13454](https://github.com/paperless-ngx/paperless-ngx/pull/13454))
|
||||
- Performance: Use server side iterators during LLM index updating [@stumpylog](https://github.com/stumpylog) ([#13430](https://github.com/paperless-ngx/paperless-ngx/pull/13430))
|
||||
- Fix: normalize monetary decimal symbol by locale [@shamoon](https://github.com/shamoon) ([#13427](https://github.com/paperless-ngx/paperless-ngx/pull/13427))
|
||||
- Fix: fold overflowing path text in sanity checker [@stumpylog](https://github.com/stumpylog) ([#13426](https://github.com/paperless-ngx/paperless-ngx/pull/13426))
|
||||
- Fix: correct delayed add field button for custom fields [@shamoon](https://github.com/shamoon) ([#13424](https://github.com/paperless-ngx/paperless-ngx/pull/13424))
|
||||
- Fix: hide attributes collapse / show button without UI settings [@shamoon](https://github.com/shamoon) ([#13425](https://github.com/paperless-ngx/paperless-ngx/pull/13425))
|
||||
- Fix: consolidate born-digital PDF detection between archive decision and OCR [@stumpylog](https://github.com/stumpylog) ([#13409](https://github.com/paperless-ngx/paperless-ngx/pull/13409))
|
||||
- Fix: prevent scale vs page loop in pngx PDF viewer [@shamoon](https://github.com/shamoon) ([#13406](https://github.com/paperless-ngx/paperless-ngx/pull/13406))
|
||||
- Fix: handle hidden line breaks in email subjects when sending email [@shamoon](https://github.com/shamoon) ([#13402](https://github.com/paperless-ngx/paperless-ngx/pull/13402))
|
||||
- Fix: avoid NotSupportedError from document\_importer on MariaDB [@stumpylog](https://github.com/stumpylog) ([#13400](https://github.com/paperless-ngx/paperless-ngx/pull/13400))
|
||||
- Tweak: adjust doc details button toolbar flow [@shamoon](https://github.com/shamoon) ([#13382](https://github.com/paperless-ngx/paperless-ngx/pull/13382))
|
||||
- Fix: exclude next-period start from relative date-range filters [@stumpylog](https://github.com/stumpylog) ([#13381](https://github.com/paperless-ngx/paperless-ngx/pull/13381))
|
||||
- Chore(deps): Bump pymdown-extensions from 10.21.3 to 11.0 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13378](https://github.com/paperless-ngx/paperless-ngx/pull/13378))
|
||||
- Fix: close non-atomic db connections in before\_task\_publish [@shamoon](https://github.com/shamoon) ([#13366](https://github.com/paperless-ngx/paperless-ngx/pull/13366))
|
||||
- Chore/fix: refactor frontend task service [@shamoon](https://github.com/shamoon) ([#13365](https://github.com/paperless-ngx/paperless-ngx/pull/13365))
|
||||
- Fix: fix Enter selection in search autocomplete [@shamoon](https://github.com/shamoon) ([#13361](https://github.com/paperless-ngx/paperless-ngx/pull/13361))
|
||||
|
||||
</details>
|
||||
|
||||
## paperless-ngx 3.0.4
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
+40
-5
@@ -776,6 +776,24 @@ system. See the corresponding
|
||||
|
||||
Defaults to "groups"
|
||||
|
||||
#### [`PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP=<str>`](#PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP) {#PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP}
|
||||
|
||||
: Allows you to define a group name that, if present in the third-party authentication system's groups claim, will grant the user superuser (admin) and staff status in Paperless-ngx. If the group is not present in the claim, superuser status will be revoked upon next login.
|
||||
|
||||
!!! warning
|
||||
This is a direct reflection of the claim on every login, including the connecting user, with no exemption for the last remaining admin. If the group is missing or misconfigured on the identity provider side, the logged-in user will immediately lose their own superuser access. Fix the group membership or claim mapping on the identity provider to restore it. If the identity provider itself is unreachable or misconfigured and you are locked out, you can recover admin access locally with `manage.py createsuperuser`.
|
||||
|
||||
Defaults to None
|
||||
|
||||
#### [`PAPERLESS_SOCIAL_ACCOUNT_SYNC_STAFF_GROUP=<str>`](#PAPERLESS_SOCIAL_ACCOUNT_SYNC_STAFF_GROUP) {#PAPERLESS_SOCIAL_ACCOUNT_SYNC_STAFF_GROUP}
|
||||
|
||||
: Allows you to define a group name that, if present in the third-party authentication system's groups claim, will grant the user staff status in Paperless-ngx. If the group is not present in the claim and the user is not a superuser, staff status will be revoked upon next login.
|
||||
|
||||
!!! warning
|
||||
As with [`PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP`](#PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP), this is applied on every login unconditionally, including for the connecting user themselves.
|
||||
|
||||
Defaults to None
|
||||
|
||||
#### [`PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS=<comma-separated-list>`](#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS) {#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS}
|
||||
|
||||
: A list of group names that users who signup via social accounts will be added to upon signup. Groups listed here must already exist.
|
||||
@@ -948,10 +966,11 @@ for display in the web interface.
|
||||
|
||||
!!! note
|
||||
|
||||
The **remote OCR parser** (Azure AI) always produces a searchable
|
||||
PDF and stores it as the archive copy, regardless of this setting.
|
||||
`ARCHIVE_FILE_GENERATION=never` has no effect when the remote
|
||||
parser handles a document.
|
||||
The **remote OCR parser** (Azure AI) also honors this setting: when
|
||||
no archive is requested (`never`, or `auto` with a born-digital PDF),
|
||||
the remote engine is skipped entirely and locally-extracted text is
|
||||
used instead, avoiding an unnecessary API call and a duplicate text
|
||||
layer.
|
||||
|
||||
#### [`PAPERLESS_OCR_CLEAN=<mode>`](#PAPERLESS_OCR_CLEAN) {#PAPERLESS_OCR_CLEAN}
|
||||
|
||||
@@ -1196,7 +1215,7 @@ should be a valid crontab(5) expression describing when to run.
|
||||
|
||||
: If set to the string "disable", no emails will be fetched automatically.
|
||||
|
||||
Defaults to `*/10 * * * *` or every ten minutes.
|
||||
Defaults to every ten minutes, with an installation-specific minute offset.
|
||||
|
||||
#### [`PAPERLESS_TRAIN_TASK_CRON=<cron expression>`](#PAPERLESS_TRAIN_TASK_CRON) {#PAPERLESS_TRAIN_TASK_CRON}
|
||||
|
||||
@@ -2047,6 +2066,18 @@ password. All of these options come from their similarly-named [Django settings]
|
||||
|
||||
Defaults to None.
|
||||
|
||||
#### [`PAPERLESS_REMOTE_OCR_MODE=<str>`](#PAPERLESS_REMOTE_OCR_MODE) {#PAPERLESS_REMOTE_OCR_MODE}
|
||||
|
||||
: Which documents are sent to the remote OCR engine.
|
||||
|
||||
- `always`: every document of a supported file type is sent to the remote
|
||||
engine, bypassing the local OCR engine.
|
||||
- `workflow_only`: documents are processed locally unless a workflow
|
||||
explicitly enables remote OCR for them, letting you use the remote engine
|
||||
selectively.
|
||||
|
||||
Defaults to "always".
|
||||
|
||||
## AI {#ai}
|
||||
|
||||
#### [`PAPERLESS_AI_ENABLED=<bool>`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED}
|
||||
@@ -2069,6 +2100,8 @@ suggestions. This setting is required to be set to true in order to use the AI f
|
||||
models supported by the current embedding backend. If not supplied, defaults to
|
||||
"text-embedding-3-small" for the OpenAI-compatible backend,
|
||||
"sentence-transformers/all-MiniLM-L6-v2" for Huggingface, and "embeddinggemma" for Ollama.
|
||||
See [choosing AI models](https://github.com/paperless-ngx/paperless-ngx/wiki/AI-Model-Recommendations)
|
||||
for language and resource considerations.
|
||||
|
||||
Defaults to None.
|
||||
|
||||
@@ -2125,6 +2158,8 @@ setting is required to be set to use the AI features.
|
||||
: 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 the OpenAI-compatible
|
||||
backend and "llama3.1" for Ollama.
|
||||
See [choosing AI models](https://github.com/paperless-ngx/paperless-ngx/wiki/AI-Model-Recommendations)
|
||||
for local versus remote and model-size considerations.
|
||||
|
||||
Defaults to None.
|
||||
|
||||
|
||||
@@ -456,6 +456,20 @@ def score(
|
||||
return 10
|
||||
```
|
||||
|
||||
**Remote services**
|
||||
|
||||
If your parser sends document content to a remote service, declare it:
|
||||
|
||||
```python
|
||||
class MyCustomParser:
|
||||
uses_remote_service = True
|
||||
```
|
||||
|
||||
Paperless-ngx excludes such parsers when the document being consumed has not
|
||||
been marked for remote processing, so users can keep remote OCR off by default
|
||||
and enable it selectively with a workflow. Parsers that do not declare the
|
||||
attribute are treated as fully local and are always considered.
|
||||
|
||||
**Archive and rendition flags**
|
||||
|
||||
```python
|
||||
|
||||
@@ -156,7 +156,7 @@ The new settings are independent:
|
||||
|
||||
### Database configuration
|
||||
|
||||
If you changed OCR settings via the admin UI (ApplicationConfiguration), the database values are **migrated automatically** during the upgrade. `mode` values (`skip` / `skip_noarchive`) are mapped to their new equivalents and `skip_archive_file` values are converted to the new `archive_file_generation` field. After upgrading, review the OCR settings in the admin UI to confirm the migrated values match your intent.
|
||||
If you changed OCR settings via the admin UI (ApplicationConfiguration), the database values are **migrated automatically** during the upgrade. `mode` values (`skip` / `skip_noarchive`) are mapped to their new equivalents and explicit `skip_archive_file` values are converted to the new `archive_file_generation` field. Users who relied on the old defaults must set `archive_file_generation` to `always` to preserve the v2 behaviour of always creating an archive. After upgrading, review the OCR settings in the admin UI to confirm the migrated values match your intent.
|
||||
|
||||
### Action Required
|
||||
|
||||
@@ -165,8 +165,9 @@ Remove any `PAPERLESS_OCR_SKIP_ARCHIVE_FILE` variable from your environment. If
|
||||
```bash
|
||||
# v2: skip OCR when text present, always archive
|
||||
PAPERLESS_OCR_MODE=skip
|
||||
# v3: equivalent (auto is the new default)
|
||||
# No change needed - auto is the default
|
||||
# v3: equivalent
|
||||
PAPERLESS_OCR_MODE=auto
|
||||
PAPERLESS_ARCHIVE_FILE_GENERATION=always
|
||||
|
||||
# v2: skip OCR when text present, skip archive too
|
||||
PAPERLESS_OCR_MODE=skip_noarchive
|
||||
@@ -187,10 +188,11 @@ PAPERLESS_ARCHIVE_FILE_GENERATION=auto
|
||||
|
||||
### Remote OCR parser
|
||||
|
||||
If you use the **remote OCR parser** (Azure AI), note that it always produces a
|
||||
searchable PDF and stores it as the archive copy. `ARCHIVE_FILE_GENERATION=never`
|
||||
has no effect for documents handled by the remote parser - the archive is produced
|
||||
unconditionally by the remote engine.
|
||||
If you use the **remote OCR parser** (Azure AI), `ARCHIVE_FILE_GENERATION` is
|
||||
honored the same way as for the local engine: when no archive is requested
|
||||
(`never`, or `auto` with a born-digital PDF), the remote engine is skipped
|
||||
entirely and locally-extracted text is used instead, avoiding an unnecessary
|
||||
API call and a duplicate text layer.
|
||||
|
||||
## Search Index (Whoosh -> Tantivy)
|
||||
|
||||
|
||||
+8
-15
@@ -416,22 +416,15 @@ to a positive number to enable polling and disable native filesystem notificatio
|
||||
You may need to change the path in the files. Example:
|
||||
`ExecStart=/opt/paperless/.local/bin/celery --app paperless worker --loglevel INFO`
|
||||
|
||||
12. Configure ImageMagick to allow processing of PDF documents. Most
|
||||
distributions have this disabled by default, since PDF documents can
|
||||
contain malware. If you don't do this, Paperless-ngx will fall back to
|
||||
Ghostscript for certain steps such as thumbnail generation.
|
||||
12. Configure ImageMagick to allow processing of PDF documents and disable
|
||||
formats that Paperless-ngx does not use. Most distributions disable PDF
|
||||
processing by default, since PDF documents can contain malware. If you
|
||||
don't enable it, Paperless-ngx will fall back to Ghostscript for certain
|
||||
steps such as thumbnail generation.
|
||||
|
||||
Edit `/etc/ImageMagick-6/policy.xml` and adjust
|
||||
|
||||
```
|
||||
<policy domain="coder" rights="none" pattern="PDF" />
|
||||
```
|
||||
|
||||
to
|
||||
|
||||
```
|
||||
<policy domain="coder" rights="read|write" pattern="PDF" />
|
||||
```
|
||||
Configure the active ImageMagick policy file (commonly
|
||||
`/etc/ImageMagick-6/policy.xml` or `/etc/ImageMagick-7/policy.xml`) and
|
||||
adjust similar to [the docker policy file](https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/refs/heads/main/docker/rootfs/etc/ImageMagick-6/paperless-policy.xml). You should also include restrictions as noted there.
|
||||
|
||||
**Optional: Install the [jbig2enc](https://ocrmypdf.readthedocs.io/en/latest/jbig2.html) encoder.**
|
||||
This will reduce the size of generated PDF documents. You'll most likely need to compile this yourself, because this
|
||||
|
||||
+29
-2
@@ -99,6 +99,10 @@ Think of versions as **file history** for a document.
|
||||
- By default, search and document content use the latest version.
|
||||
- In document detail, selecting a version switches the preview, file metadata and content (and download etc buttons) to that version.
|
||||
- Deleting a non-root version keeps metadata and falls back to the latest remaining version.
|
||||
- From the document list, select two or more documents and choose **Merge as versions** to combine them under one entry. Select the root document whose metadata and permissions should be retained; the other selected documents become file versions. The root may already have versions, but documents being added as versions must not have version histories of their own.
|
||||
- From a document's **Versions** menu, choose **Existing** to search for another document and add it as a version of the current document.
|
||||
- Documents merged as versions give up their archive serial number. If the root has no ASN of its own it takes the first one, otherwise the ASNs are released and the removal is logged.
|
||||
- Merging as versions cannot be undone from the UI, and deleting the root document moves its versions to the trash as well.
|
||||
|
||||
### Management Lists
|
||||
|
||||
@@ -576,7 +580,9 @@ The following workflow action types are available:
|
||||
- Tags, correspondent, document type and storage path
|
||||
- Document owner
|
||||
- View and / or edit permissions to users or groups
|
||||
- Custom fields. Note that no value for the field will be set
|
||||
- Custom fields, optionally with a value. If no value is set, the field is only added to the
|
||||
document and any value it may already have is left untouched. If a value is set, it will
|
||||
overwrite an existing value of that field on the document.
|
||||
|
||||
##### Removal {#workflow-action-removal}
|
||||
|
||||
@@ -648,6 +654,19 @@ happened while it was still encrypted, that original version will likewise be mi
|
||||
**Current limitation**: Passwords are stored as a simple list without descriptions. To handle
|
||||
multiple PDF types with different passwords, create separate workflows for each use case.
|
||||
|
||||
##### Remote OCR {#workflow-action-remote-ocr}
|
||||
|
||||
"Remote OCR" actions send the document to the configured remote OCR engine instead of processing it
|
||||
locally. To use remote OCR selectively, set the [remote OCR mode](configuration.md#PAPERLESS_REMOTE_OCR_MODE)
|
||||
to `workflow_only` then add this action to a workflow that matches only the documents you
|
||||
want sent to the remote engine. See [Remote OCR](#remote-ocr) for the engine setup. The action only works with
|
||||
a **Consumption Started** trigger.
|
||||
|
||||
The action takes no options, its presence is what enables remote OCR for a matching document.
|
||||
|
||||
If the remote engine is not configured, or does not support the document's file type, the document is
|
||||
processed locally instead and a warning is written to the log.
|
||||
|
||||
#### Workflow placeholders
|
||||
|
||||
Titles and webhook payloads can be generated by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
|
||||
@@ -1084,11 +1103,19 @@ Paperless-ngx supports performing OCR on documents using remote services. At the
|
||||
[Microsoft's Azure "Document Intelligence" service](https://azure.microsoft.com/en-us/products/ai-services/ai-document-intelligence).
|
||||
This is of course a paid service (with a free tier) which requires an Azure account and subscription. Azure AI is not affiliated with
|
||||
Paperless-ngx in any way. When enabled, Paperless-ngx will automatically send appropriate documents to Azure for OCR processing, bypassing
|
||||
the local OCR engine. See the [configuration](configuration.md#PAPERLESS_REMOTE_OCR_ENGINE) options for more details.
|
||||
the local OCR engine. See the [configuration](configuration.md#PAPERLESS_REMOTE_OCR_ENGINE) options for more details. These
|
||||
settings can be supplied as environment variables or via **Application Configuration**.
|
||||
|
||||
Additionally, when using a commercial service with this feature, consider both potential costs as well as any associated file size
|
||||
or page limitations (e.g. with a free tier).
|
||||
|
||||
By default, every document of a supported file type is sent to the remote engine. To use it more selectively, set the
|
||||
[remote OCR mode](configuration.md#PAPERLESS_REMOTE_OCR_MODE) to `workflow_only`. Documents are then processed locally
|
||||
unless a [remote OCR workflow action](#workflow-action-remote-ocr) enables it for them, so you can limit the remote
|
||||
engine to particular documents.
|
||||
|
||||
Setting the mode to `workflow_only` also allows the **Reprocess** actions to selectively use remote OCR for individual documents.
|
||||
|
||||
## Architecture
|
||||
|
||||
Paperless-ngx consists of the following components:
|
||||
|
||||
@@ -34,6 +34,8 @@ PAPERLESS_SECRET_KEY=change-me
|
||||
#PAPERLESS_AUTO_LOGIN_USERNAME=
|
||||
#PAPERLESS_COOKIE_PREFIX=
|
||||
#PAPERLESS_ENABLE_HTTP_REMOTE_USER=false
|
||||
#PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP=
|
||||
#PAPERLESS_SOCIAL_ACCOUNT_SYNC_STAFF_GROUP=
|
||||
|
||||
# OCR settings
|
||||
|
||||
|
||||
+30
-28
@@ -21,7 +21,7 @@ dependencies = [
|
||||
"channels~=4.2",
|
||||
"channels-redis~=4.2",
|
||||
"concurrent-log-handler~=0.9.25",
|
||||
"dateparser~=1.2",
|
||||
"dateparser~=1.4",
|
||||
# WARNING: django does not use semver.
|
||||
# Only patch versions are guaranteed to not introduce breaking changes.
|
||||
"django~=5.2.13",
|
||||
@@ -32,33 +32,32 @@ dependencies = [
|
||||
"django-cors-headers~=4.9.0",
|
||||
"django-extensions~=4.1",
|
||||
"django-filter~=25.1",
|
||||
"django-guardian~=3.3.0",
|
||||
"django-guardian~=3.3.3",
|
||||
"django-multiselectfield~=1.0.1",
|
||||
"django-rich~=2.2.0",
|
||||
"django-soft-delete~=1.0.18",
|
||||
"django-treenode>=0.24",
|
||||
"djangorestframework~=3.16",
|
||||
"djangorestframework-guardian~=0.4.0",
|
||||
"drf-spectacular~=0.28",
|
||||
"drf-spectacular-sidecar~=2026.5.1",
|
||||
"drf-spectacular~=0.30",
|
||||
"drf-spectacular-sidecar~=2026.7.1",
|
||||
"drf-writable-nested~=0.7.1",
|
||||
"filelock~=3.29.0",
|
||||
"filelock~=3.32.0",
|
||||
"flower~=2.0.1",
|
||||
"gotenberg-client~=0.14.0",
|
||||
"httpx-oauth~=0.16",
|
||||
"ijson>=3.2",
|
||||
"imap-tools~=1.13.0",
|
||||
"httpx-oauth~=0.17",
|
||||
"ijson>=3.5.1",
|
||||
"imap-tools~=1.14.0",
|
||||
"jinja2~=3.1.5",
|
||||
"langdetect~=1.0.9",
|
||||
"llama-index-core>=0.14.22",
|
||||
"llama-index-core>=0.14.23",
|
||||
"llama-index-embeddings-huggingface>=0.6.1",
|
||||
"llama-index-embeddings-ollama>=0.9",
|
||||
"llama-index-embeddings-openai-like>=0.2.2",
|
||||
"llama-index-llms-ollama>=0.9.1",
|
||||
"llama-index-llms-openai-like>=0.7.1",
|
||||
"nltk~=3.10.0",
|
||||
"ocrmypdf~=17.4.2",
|
||||
"openai>=2.32",
|
||||
"ocrmypdf~=17.7.0",
|
||||
"openai>=2.48",
|
||||
"pathvalidate~=3.3.1",
|
||||
"pdf2image~=1.17.0",
|
||||
"python-dateutil~=2.9.0",
|
||||
@@ -68,26 +67,26 @@ dependencies = [
|
||||
"python-magic~=0.4.27",
|
||||
"rapidfuzz~=3.14.5",
|
||||
"redis[hiredis]~=5.2.1",
|
||||
"regex>=2026.4.4",
|
||||
"scikit-learn~=1.8.0",
|
||||
"sentence-transformers>=5.4.1",
|
||||
"regex>=2026.7.19",
|
||||
"scikit-learn~=1.9.0",
|
||||
"sentence-transformers>=5.6.1",
|
||||
"setproctitle~=1.3.4",
|
||||
"sqlite-vec==0.1.9",
|
||||
"tantivy~=0.26.0",
|
||||
"tika-client~=0.11.0",
|
||||
"torch~=2.13.0",
|
||||
"watchfiles>=1.1.1",
|
||||
"watchfiles>=1.2",
|
||||
"whitenoise~=6.11",
|
||||
"zxing-cpp~=3.0.0",
|
||||
"zxing-cpp~=3.1.0",
|
||||
]
|
||||
[project.optional-dependencies]
|
||||
mariadb = [
|
||||
"mysqlclient~=2.2.7",
|
||||
]
|
||||
postgres = [
|
||||
"psycopg[c,pool]==3.3",
|
||||
"psycopg[c,pool]==3.3.4",
|
||||
# Direct dependency for proper resolution of the pre-built wheels
|
||||
"psycopg-c==3.3",
|
||||
"psycopg-c==3.3.4",
|
||||
"psycopg-pool==3.3.1",
|
||||
]
|
||||
webserver = [
|
||||
@@ -101,25 +100,25 @@ dev = [
|
||||
{ include-group = "testing" },
|
||||
]
|
||||
docs = [
|
||||
"zensical>=0.0.47",
|
||||
"zensical>=0.0.51",
|
||||
]
|
||||
lint = [
|
||||
"prek~=0.3.10",
|
||||
"ruff~=0.15.20",
|
||||
"prek~=0.4.11",
|
||||
"ruff~=0.16.1",
|
||||
]
|
||||
testing = [
|
||||
"daphne",
|
||||
"factory-boy~=3.3.1",
|
||||
"faker~=40.15.0",
|
||||
"faker~=40.36.0",
|
||||
"imagehash",
|
||||
"pytest~=9.0.3",
|
||||
"pytest~=9.1.1",
|
||||
"pytest-cov~=7.1.0",
|
||||
"pytest-django~=4.12.0",
|
||||
"pytest-env~=1.6.0",
|
||||
"pytest-env~=1.7.0",
|
||||
"pytest-httpx",
|
||||
"pytest-mock~=3.15.1",
|
||||
# "pytest-randomly~=4.0.1",
|
||||
"pytest-rerunfailures~=16.1",
|
||||
"pytest-rerunfailures~=16.4",
|
||||
"pytest-sugar",
|
||||
"pytest-xdist~=3.8.0",
|
||||
"time-machine>=2.13",
|
||||
@@ -161,8 +160,10 @@ explicit = true
|
||||
[tool.uv.sources]
|
||||
# Markers are chosen to select these almost exclusively when building the Docker image
|
||||
psycopg-c = [
|
||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.0/psycopg_c-3.3.0-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
|
||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.0/psycopg_c-3.3.0-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
|
||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.4/psycopg_c-3.3.4-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
|
||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.4/psycopg_c-3.3.4-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
|
||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.4/psycopg_c-3.3.4-cp314-cp314-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.14'" },
|
||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.4/psycopg_c-3.3.4-cp314-cp314-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.14'" },
|
||||
]
|
||||
torch = [
|
||||
{ index = "pytorch-cpu" },
|
||||
@@ -183,6 +184,7 @@ output-format = "grouped"
|
||||
line-ending = "lf"
|
||||
[tool.ruff.lint]
|
||||
# https://docs.astral.sh/ruff/rules/
|
||||
select = [ "E4", "E7", "E9", "F" ]
|
||||
extend-select = [
|
||||
"COM", # https://docs.astral.sh/ruff/rules/#flake8-commas-com
|
||||
"DJ", # https://docs.astral.sh/ruff/rules/#flake8-django-dj
|
||||
|
||||
+12
-9
@@ -56,13 +56,13 @@
|
||||
},
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-builders/custom-webpack:browser",
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"customWebpackConfig": {
|
||||
"path": "./extra-webpack.config.ts"
|
||||
"outputPath": {
|
||||
"base": "dist/paperless-ui",
|
||||
"browser": ""
|
||||
},
|
||||
"outputPath": "dist/paperless-ui",
|
||||
"main": "src/main.ts",
|
||||
"browser": "src/main.ts",
|
||||
"outputHashing": "none",
|
||||
"index": "src/index.html",
|
||||
"polyfills": [
|
||||
@@ -97,6 +97,7 @@
|
||||
"scripts": [],
|
||||
"allowedCommonJsDependencies": [
|
||||
"file-saver",
|
||||
"mime-names",
|
||||
"utif"
|
||||
],
|
||||
"extractLicenses": false,
|
||||
@@ -117,11 +118,13 @@
|
||||
"with": "src/environments/environment.prod.ts"
|
||||
}
|
||||
],
|
||||
"outputPath": "../src/documents/static/frontend/",
|
||||
"outputPath": {
|
||||
"base": "../src/documents/static/frontend/",
|
||||
"browser": ""
|
||||
},
|
||||
"optimization": true,
|
||||
"outputHashing": "none",
|
||||
"sourceMap": false,
|
||||
"namedChunks": false,
|
||||
"extractLicenses": true,
|
||||
"budgets": [
|
||||
{
|
||||
@@ -145,7 +148,7 @@
|
||||
"defaultConfiguration": ""
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-builders/custom-webpack:dev-server",
|
||||
"builder": "@angular/build:dev-server",
|
||||
"options": {
|
||||
"buildTarget": "paperless-ui:build:en-US"
|
||||
},
|
||||
@@ -156,7 +159,7 @@
|
||||
}
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-builders/custom-webpack:extract-i18n",
|
||||
"builder": "@angular/build:extract-i18n",
|
||||
"options": {
|
||||
"buildTarget": "paperless-ui:build"
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ test('should warn on unsaved changes', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Close', exact: true }).click()
|
||||
await expect(page.getByRole('dialog')).toHaveText(/unsaved changes/)
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
await page.getByRole('link', { name: 'Close all' }).click()
|
||||
await page.getByRole('button', { name: 'Close all' }).click()
|
||||
await expect(page.getByRole('dialog')).toHaveText(/unsaved changes/)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import {
|
||||
CustomWebpackBrowserSchema,
|
||||
TargetOptions,
|
||||
} from '@angular-builders/custom-webpack'
|
||||
import * as webpack from 'webpack'
|
||||
const { codecovWebpackPlugin } = require('@codecov/webpack-plugin')
|
||||
|
||||
export default (
|
||||
config: webpack.Configuration,
|
||||
options: CustomWebpackBrowserSchema,
|
||||
targetOptions: TargetOptions
|
||||
) => {
|
||||
if (config.plugins) {
|
||||
config.plugins.push(
|
||||
codecovWebpackPlugin({
|
||||
enableBundleAnalysis: process.env.CODECOV_TOKEN !== undefined,
|
||||
bundleName: 'paperless-ngx',
|
||||
uploadToken: process.env.CODECOV_TOKEN,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
+2047
-1087
File diff suppressed because it is too large
Load Diff
+31
-32
@@ -11,20 +11,21 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^22.0.3",
|
||||
"@angular/common": "~22.0.5",
|
||||
"@angular/compiler": "~22.0.5",
|
||||
"@angular/core": "~22.0.5",
|
||||
"@angular/forms": "~22.0.5",
|
||||
"@angular/localize": "~22.0.5",
|
||||
"@angular/platform-browser": "~22.0.5",
|
||||
"@angular/router": "~22.0.5",
|
||||
"@angular/cdk": "^22.1.1",
|
||||
"@angular/common": "~22.1.1",
|
||||
"@angular/compiler": "~22.1.1",
|
||||
"@angular/core": "~22.1.1",
|
||||
"@angular/forms": "~22.1.1",
|
||||
"@angular/localize": "~22.1.1",
|
||||
"@angular/platform-browser": "~22.1.1",
|
||||
"@angular/router": "~22.1.1",
|
||||
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
||||
"@ng-select/ng-select": "^23.2.0",
|
||||
"@ng-select/ng-select": "~23.6.0",
|
||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"bootstrap": "^5.3.8",
|
||||
"file-saver": "^2.0.5",
|
||||
"lodash-es": "^4.18.1",
|
||||
"mime-names": "^1.0.0",
|
||||
"ngx-bootstrap-icons": "^1.9.3",
|
||||
"ngx-color": "^10.1.0",
|
||||
@@ -32,42 +33,40 @@
|
||||
"ngx-device-detector": "^12.0.0",
|
||||
"ngx-ui-tour-ng-bootstrap": "^19.0.0",
|
||||
"normalize-diacritics": "^5.0.0",
|
||||
"pdfjs-dist": "^6.0.227",
|
||||
"pdfjs-dist": "^6.2.108",
|
||||
"rxjs": "^7.8.2",
|
||||
"tslib": "^2.8.1",
|
||||
"utif": "^3.1.0",
|
||||
"uuid": "^14.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-builders/custom-webpack": "^22.0.1",
|
||||
"@angular-builders/jest": "^22.0.1",
|
||||
"@angular-devkit/core": "^22.0.5",
|
||||
"@angular-devkit/schematics": "^22.0.5",
|
||||
"@angular-eslint/builder": "22.0.0",
|
||||
"@angular-eslint/eslint-plugin": "22.0.0",
|
||||
"@angular-eslint/eslint-plugin-template": "22.0.0",
|
||||
"@angular-eslint/schematics": "22.0.0",
|
||||
"@angular-eslint/template-parser": "22.0.0",
|
||||
"@angular/build": "^22.0.5",
|
||||
"@angular/cli": "~22.0.5",
|
||||
"@angular/compiler-cli": "~22.0.5",
|
||||
"@codecov/webpack-plugin": "^2.0.1",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@angular-devkit/core": "^22.1.3",
|
||||
"@angular-devkit/schematics": "^22.1.3",
|
||||
"@angular-eslint/builder": "22.1.0",
|
||||
"@angular-eslint/eslint-plugin": "22.1.0",
|
||||
"@angular-eslint/eslint-plugin-template": "22.1.0",
|
||||
"@angular-eslint/schematics": "22.1.0",
|
||||
"@angular-eslint/template-parser": "22.1.0",
|
||||
"@angular/build": "22.1.3",
|
||||
"@angular/cli": "22.1.3",
|
||||
"@angular/compiler-cli": "~22.1.1",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^26.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.62.0",
|
||||
"@typescript-eslint/parser": "^8.62.0",
|
||||
"@typescript-eslint/utils": "^8.62.0",
|
||||
"eslint": "^10.5.0",
|
||||
"@types/node": "^26.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.67.0",
|
||||
"@typescript-eslint/parser": "^8.67.0",
|
||||
"@typescript-eslint/utils": "^8.67.0",
|
||||
"eslint": "^10.8.1",
|
||||
"jest": "30.4.2",
|
||||
"jest-environment-jsdom": "^30.4.1",
|
||||
"jest-junit": "^17.0.0",
|
||||
"jest-preset-angular": "^17.0.0",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"prettier": "^3.9.6",
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"ts-node": "~10.9.1",
|
||||
"typescript": "^6.0.3",
|
||||
"webpack": "^5.107.2"
|
||||
"ts-node": "~10.9.2",
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"packageManager": "pnpm@10.26.0"
|
||||
"packageManager": "pnpm@11.15.1"
|
||||
}
|
||||
|
||||
Generated
+2004
-6666
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,8 @@ trustPolicy: no-downgrade
|
||||
trustPolicyExclude:
|
||||
- "chokidar@4.0.3"
|
||||
- "semver@6.3.1 || 5.7.2"
|
||||
blockExoticSubdeps: true
|
||||
autoInstallPeers: false
|
||||
allowBuilds:
|
||||
"@parcel/watcher": true
|
||||
canvas: true
|
||||
|
||||
@@ -14,43 +14,48 @@
|
||||
<a ngbNavLink>{{category}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="p-3">
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
|
||||
@for (option of getCategoryOptions(category); track option.key) {
|
||||
<div class="col">
|
||||
<div class="card bg-light">
|
||||
<div class="card-body">
|
||||
<div class="card-title d-flex align-items-center">
|
||||
<h6 class="mb-0">
|
||||
{{option.title}}
|
||||
</h6>
|
||||
<a class="btn btn-sm btn-link" title="Read the documentation about this setting" i18n-title [href]="getDocsUrl(option.config_key)" target="_blank" referrerpolicy="no-referrer">
|
||||
<i-bs name="info-circle"></i-bs>
|
||||
</a>
|
||||
@if (isSet(option.key)) {
|
||||
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)">
|
||||
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
|
||||
</button>
|
||||
@for (section of getCategorySections(category); track section) {
|
||||
@if (section) {
|
||||
<h5 class="mt-4 mb-3">{{section}}</h5>
|
||||
}
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
|
||||
@for (option of getCategoryOptions(category, section); track option.key) {
|
||||
<div class="col">
|
||||
<div class="card bg-light">
|
||||
<div class="card-body">
|
||||
<div class="card-title d-flex align-items-center">
|
||||
<h6 class="mb-0">
|
||||
{{option.title}}
|
||||
</h6>
|
||||
<a class="btn btn-sm btn-link" title="Read the documentation about this setting" i18n-title [href]="getDocsUrl(option.config_key)" target="_blank" referrerpolicy="no-referrer">
|
||||
<i-bs name="info-circle"></i-bs>
|
||||
</a>
|
||||
@if (isSet(option.key)) {
|
||||
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)">
|
||||
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-n3">
|
||||
@switch (option.type) {
|
||||
@case (ConfigOptionType.Select) { <pngx-input-select [formControlName]="option.key" [error]="errors[option.key]" [items]="option.choices" [allowNull]="true"></pngx-input-select> }
|
||||
@case (ConfigOptionType.Number) { <pngx-input-number [formControlName]="option.key" [error]="errors[option.key]" [showAdd]="false"></pngx-input-number> }
|
||||
@case (ConfigOptionType.Boolean) { <pngx-input-switch [formControlName]="option.key" [error]="errors[option.key]" [showUnsetNote]="true" [horizontal]="true" title="Enable" i18n-title></pngx-input-switch> }
|
||||
@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 class="mb-n3">
|
||||
@switch (option.type) {
|
||||
@case (ConfigOptionType.Select) { <pngx-input-select [formControlName]="option.key" [error]="errors[option.key]" [items]="option.choices" [allowNull]="true"></pngx-input-select> }
|
||||
@case (ConfigOptionType.Number) { <pngx-input-number [formControlName]="option.key" [error]="errors[option.key]" [showAdd]="false"></pngx-input-number> }
|
||||
@case (ConfigOptionType.Boolean) { <pngx-input-switch [formControlName]="option.key" [error]="errors[option.key]" [showUnsetNote]="true" [horizontal]="true" title="Enable" i18n-title></pngx-input-switch> }
|
||||
@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>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
@@ -8,7 +8,11 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { OutputTypeConfig } from 'src/app/data/paperless-config'
|
||||
import {
|
||||
ConfigCategory,
|
||||
ConfigSection,
|
||||
OutputTypeConfig,
|
||||
} from 'src/app/data/paperless-config'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
@@ -158,4 +162,24 @@ describe('ConfigComponent', () => {
|
||||
component.resetOption('barcodes_enabled')
|
||||
expect(component.configForm.get('barcodes_enabled').value).toBeNull()
|
||||
})
|
||||
|
||||
it('should group options into sections within a category, or not', () => {
|
||||
const sections = component.getCategorySections(ConfigCategory.OCR)
|
||||
expect(sections).toEqual([null, ConfigSection.RemoteOCR])
|
||||
expect(
|
||||
component
|
||||
.getCategoryOptions(ConfigCategory.OCR)
|
||||
.map((option) => option.key)
|
||||
).toContain('output_type')
|
||||
expect(
|
||||
component
|
||||
.getCategoryOptions(ConfigCategory.OCR, ConfigSection.RemoteOCR)
|
||||
.map((option) => option.key)
|
||||
).toEqual([
|
||||
'remote_ocr_engine',
|
||||
'remote_ocr_api_key',
|
||||
'remote_ocr_endpoint',
|
||||
'remote_ocr_mode',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -74,8 +74,20 @@ export class ConfigComponent
|
||||
return Object.values(ConfigCategory)
|
||||
}
|
||||
|
||||
getCategoryOptions(category: string): ConfigOption[] {
|
||||
return PaperlessConfigOptions.filter((o) => o.category === category)
|
||||
getCategorySections(category: string): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
PaperlessConfigOptions.filter((o) => o.category === category).map(
|
||||
(o) => o.section ?? null // null means no section
|
||||
)
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
getCategoryOptions(category: string, section: string = null): ConfigOption[] {
|
||||
return PaperlessConfigOptions.filter(
|
||||
(o) => o.category === category && (o.section ?? null) === section
|
||||
)
|
||||
}
|
||||
|
||||
initialConfig: PaperlessConfig
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
</div>
|
||||
</div>
|
||||
@if (filterText?.length) {
|
||||
<button class="btn btn-link btn-sm px-2 position-absolute top-0 end-0 z-10" (click)="resetFilter()">
|
||||
<button class="btn btn-link btn-sm px-2 position-absolute top-0 end-0 z-10" (click)="resetFilter()" aria-label="Clear search" i18n-aria-label>
|
||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
||||
</button>
|
||||
}
|
||||
@@ -208,7 +208,7 @@
|
||||
</td>
|
||||
}
|
||||
<td class="d-lg-none">
|
||||
<button class="btn btn-link" (click)="expandTask(task); $event.stopPropagation();">
|
||||
<button class="btn btn-link" (click)="expandTask(task); $event.stopPropagation();" aria-label="View task details" i18n-aria-label>
|
||||
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
|
||||
</button>
|
||||
</td>
|
||||
|
||||
@@ -576,7 +576,7 @@ describe('TasksComponent', () => {
|
||||
|
||||
expect(dismissSpy).toHaveBeenCalledWith(new Set([tasks[0].id, tasks[1].id]))
|
||||
expect(toastSpy).toHaveBeenCalledWith('Error dismissing tasks', error)
|
||||
expect(modal.componentInstance.buttonsEnabled).toBe(true)
|
||||
expect(modal.componentInstance.buttonsEnabled()).toBe(true)
|
||||
expect(component.selectedTasks.size).toBe(0)
|
||||
})
|
||||
|
||||
@@ -642,7 +642,7 @@ describe('TasksComponent', () => {
|
||||
|
||||
expect(dismissSpy).toHaveBeenCalled()
|
||||
expect(toastSpy).toHaveBeenCalledWith('Error dismissing tasks', error)
|
||||
expect(modal.componentInstance.buttonsEnabled).toBe(true)
|
||||
expect(modal.componentInstance.buttonsEnabled()).toBe(true)
|
||||
})
|
||||
|
||||
it('should dismiss the currently visible scoped and filtered tasks', () => {
|
||||
|
||||
@@ -316,7 +316,7 @@ export class TasksComponent
|
||||
modal.componentInstance.btnClass = 'btn-warning'
|
||||
modal.componentInstance.btnCaption = $localize`Dismiss`
|
||||
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
modal.close()
|
||||
this.tasksService.dismissTasks(tasks).subscribe({
|
||||
next: () => {
|
||||
@@ -324,7 +324,7 @@ export class TasksComponent
|
||||
},
|
||||
error: (e) => {
|
||||
this.toastService.showError($localize`Error dismissing tasks`, e)
|
||||
modal.componentInstance.buttonsEnabled = true
|
||||
modal.componentInstance.buttonsEnabled.set(true)
|
||||
},
|
||||
})
|
||||
this.clearSelection()
|
||||
@@ -350,7 +350,7 @@ export class TasksComponent
|
||||
modal.componentInstance.btnClass = 'btn-warning'
|
||||
modal.componentInstance.btnCaption = $localize`Dismiss`
|
||||
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
modal.close()
|
||||
this.tasksService.dismissAllTasks().subscribe({
|
||||
next: () => {
|
||||
@@ -358,7 +358,7 @@ export class TasksComponent
|
||||
},
|
||||
error: (e) => {
|
||||
this.toastService.showError($localize`Error dismissing tasks`, e)
|
||||
modal.componentInstance.buttonsEnabled = true
|
||||
modal.componentInstance.buttonsEnabled.set(true)
|
||||
},
|
||||
})
|
||||
this.clearSelection()
|
||||
|
||||
@@ -18,9 +18,11 @@
|
||||
</button>
|
||||
</pngx-page-header>
|
||||
|
||||
<div class="row mb-3">
|
||||
<ngb-pagination class="col-auto" [pageSize]="25" [collectionSize]="totalDocuments()" [page]="page()" [maxSize]="5" (pageChange)="page.set($event); reload()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||
</div>
|
||||
@if (totalDocuments() > 25) {
|
||||
<div class="row mb-3">
|
||||
<ngb-pagination class="col-auto" [pageSize]="25" [collectionSize]="totalDocuments()" [page]="page()" [maxSize]="5" (pageChange)="page.set($event); reload()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="card border table-responsive mb-3">
|
||||
<table class="table table-striped align-middle shadow-sm mb-0">
|
||||
@@ -64,7 +66,7 @@
|
||||
<td scope="row">
|
||||
<div class="btn-group d-block d-sm-none">
|
||||
<div ngbDropdown container="body" class="d-inline-block">
|
||||
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
|
||||
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle aria-label="Actions" i18n-aria-label>
|
||||
<i-bs name="three-dots-vertical"></i-bs>
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
|
||||
|
||||
@@ -2,3 +2,8 @@
|
||||
.d-block.d-sm-none .dropdown-toggle::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
tbody tr:last-child td,
|
||||
table:not(:has(tbody tr)) thead th {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ export class TrashComponent
|
||||
modal.componentInstance.confirmClicked
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
this.trashService.emptyTrash([document.id]).subscribe({
|
||||
next: () => {
|
||||
this.toastService.showInfo(
|
||||
|
||||
@@ -146,7 +146,7 @@ export class UsersAndGroupsComponent
|
||||
modal.componentInstance.btnClass = 'btn-danger'
|
||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
this.usersService.delete(user).subscribe({
|
||||
next: () => {
|
||||
modal.close()
|
||||
@@ -199,7 +199,7 @@ export class UsersAndGroupsComponent
|
||||
modal.componentInstance.btnClass = 'btn-danger'
|
||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
this.groupsService.delete(group).subscribe({
|
||||
next: () => {
|
||||
modal.close()
|
||||
|
||||
@@ -2,29 +2,32 @@
|
||||
<button class="navbar-toggler d-md-none collapsed border-0" type="button" data-toggle="collapse"
|
||||
data-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation"
|
||||
(click)="closeMobileSearch(); toggleMenuCollapsed()">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
<i-bs width="1.5em" height="1.5em" name="list"></i-bs>
|
||||
</button>
|
||||
<a class="navbar-brand d-flex align-items-center me-0 px-3 py-3 order-sm-0"
|
||||
[ngClass]="{ 'slim': slimSidebarEnabled, 'col-auto col-md-3 col-lg-2 col-xxxl-1' : !slimSidebarEnabled, 'py-3' : !customAppTitle?.length || slimSidebarEnabled, 'py-2': customAppTitle?.length }"
|
||||
<a class="navbar-brand d-flex align-items-center me-0 ps-md-3 py-0 order-sm-0"
|
||||
[ngClass]="{ 'slim': slimSidebarEnabled, '' : !slimSidebarEnabled }"
|
||||
routerLink="/dashboard"
|
||||
tourAnchor="tour.intro">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1.5em" height="1.5em" fill="currentColor">
|
||||
<path d="M341,949.1c-6.9-20.3-20.7-61.2-21.9-61-199.6-88.9-182.5-229.8-134.3-347.5,30,137.2,268.8,148.9,146.2,336-.9,2.2,10,27.8,19.5,51.3,22.7-51.9,58.6-115.5,55.8-120.8C178,398.7,724.9,299,807.1,18.5c83,251.5,53.1,659.8-377.4,814.9-2,1.4-63.5,148.6-66.9,150.2-.2-2.1-33.2,2.9-30.1-8.7,1.6-7,4.8-16.2,8.2-25.6h0v-.2h.1ZM323.1,846.2c48.3-71.9-12.7-120.8-56.9-152.2,81.2,107.4,66.4,120.8,56.9,152.2h0Z"/>
|
||||
</svg>
|
||||
<div class="ms-2 ms-md-3 d-inline-block" [class.d-md-none]="slimSidebarEnabled">
|
||||
@if (customAppTitle?.length) {
|
||||
<div class="d-flex flex-column align-items-start custom-title">
|
||||
<span class="title">{{customAppTitle}}</span>
|
||||
<span class="byline text-uppercase font-monospace" i18n>by Paperless-ngx</span>
|
||||
</div>
|
||||
@if (!hasCustomBranding) {
|
||||
<pngx-logo extra_classes="navbar-official-logo px-1" height="2.4rem"></pngx-logo>
|
||||
<pngx-brand-mark class="brand-mark brand-mark-slim d-none"></pngx-brand-mark>
|
||||
} @else {
|
||||
@if (customAppLogo) {
|
||||
<img class="brand-logo" [src]="customAppLogo" alt="" />
|
||||
} @else {
|
||||
Paperless-ngx
|
||||
<pngx-brand-mark class="brand-mark"></pngx-brand-mark>
|
||||
}
|
||||
</div>
|
||||
<div class="brand-copy ms-2 text-truncate" [class.d-md-none]="slimSidebarEnabled">
|
||||
<span class="brand-title text-truncate">{{ appTitle }}</span>
|
||||
@if (customAppTitle) {
|
||||
<span class="byline text-uppercase font-monospace" i18n>by Paperless-ngx</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</a>
|
||||
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1"
|
||||
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 me-sm-auto order-3 order-sm-1"
|
||||
[class.mobile-hidden]="mobileSearchHidden()">
|
||||
<div class="col-12 col-md-7">
|
||||
<div class="col-12 header-search mx-auto">
|
||||
<pngx-global-search></pngx-global-search>
|
||||
</div>
|
||||
</div>
|
||||
@@ -34,7 +37,7 @@
|
||||
}
|
||||
<pngx-toasts-dropdown></pngx-toasts-dropdown>
|
||||
<li ngbDropdown class="nav-item dropdown">
|
||||
<button class="btn ps-1 border-0" id="userDropdown" ngbDropdownToggle>
|
||||
<button class="btn navbar-action border-0" id="userDropdown" ngbDropdownToggle aria-label="User menu" i18n-aria-label>
|
||||
<i-bs width="1.3em" height="1.3em" name="person-circle"></i-bs>
|
||||
<span class="small ms-2 d-none d-sm-inline">
|
||||
{{this.settingsService.displayName}}
|
||||
@@ -71,7 +74,7 @@
|
||||
[ngClass]="slimSidebarEnabled ? 'slim' : 'col-md-3 col-lg-2 col-xxxl-1'" [class.animating]="slimSidebarAnimating()"
|
||||
[ngbCollapse]="isMenuCollapsed()">
|
||||
@if (canSaveSettings) {
|
||||
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()">
|
||||
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()" [aria-label]="slimSidebarEnabled ? 'Expand sidebar' : 'Collapse sidebar'" i18n-aria-label>
|
||||
@if (slimSidebarEnabled) {
|
||||
<i-bs width="0.9em" height="0.9em" name="chevron-double-right"></i-bs>
|
||||
} @else {
|
||||
@@ -79,18 +82,20 @@
|
||||
}
|
||||
</button>
|
||||
}
|
||||
<div class="sidebar-sticky pt-3 d-flex flex-column justify-space-around">
|
||||
<div class="sidebar-sticky pt-3 pb-1 d-flex flex-column justify-space-around">
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item app-link">
|
||||
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="house"></i-bs><span><ng-container i18n>Dashboard</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||
<a class="nav-link" routerLink="documents" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
<a class="nav-link" routerLink="documents" routerLinkActive="active"
|
||||
[routerLinkActiveOptions]="{ paths: 'exact', queryParams: 'ignored', matrixParams: 'ignored', fragment: 'ignored' }"
|
||||
(click)="closeMenu()"
|
||||
ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="files"></i-bs><span><ng-container i18n>Documents</ng-container></span>
|
||||
</a>
|
||||
@@ -104,14 +109,14 @@
|
||||
</h6>
|
||||
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
|
||||
@for (view of savedViewService.sidebarViews; track view.id) {
|
||||
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews()"
|
||||
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews() || !canSaveSettings"
|
||||
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
|
||||
(cdkDragEnded)="onDragEnd($event)">
|
||||
<a class="nav-link" routerLink="view/{{view.id}}"
|
||||
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name"
|
||||
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
||||
[disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
||||
popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="funnel"></i-bs><span><div class="d-inline-flex view-name"><span class="overflow-hidden" [class.text-wrap]="!slimSidebarEnabled">{{view.name}}</span></div>
|
||||
<i-bs class="me-2" [name]="view.icon || '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>
|
||||
}
|
||||
@@ -120,7 +125,7 @@
|
||||
<span class="badge bg-info text-dark position-absolute top-0 end-0 d-none d-md-block">{{ savedViewService.getDocumentCount(view) }}</span>
|
||||
}
|
||||
</a>
|
||||
@if (settingsService.organizingSidebarSavedViews()) {
|
||||
@if (settingsService.organizingSidebarSavedViews() && canSaveSettings) {
|
||||
<div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>
|
||||
<i-bs name="grip-vertical"></i-bs>
|
||||
</div>
|
||||
@@ -147,7 +152,7 @@
|
||||
<li class="nav-item w-100 app-link">
|
||||
<a class="nav-link app-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="documents/{{d.id}}"
|
||||
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="d.title | documentTitle"
|
||||
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
||||
[disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
||||
popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="file-text"></i-bs><span>{{d.title | documentTitle}}</span>
|
||||
<span class="close flex-column justify-content-center"
|
||||
@@ -159,11 +164,12 @@
|
||||
}
|
||||
@if (openDocuments.length >= 1) {
|
||||
<li class="nav-item w-100 app-link">
|
||||
<a class="nav-link app-link" [class.text-truncate]="!slimSidebarEnabled" [routerLink]="[]" (click)="closeAll()"
|
||||
ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
<button type="button" class="nav-link nav-link-action app-link w-100 text-start"
|
||||
[class.text-truncate]="!slimSidebarEnabled" (click)="closeAll()"
|
||||
ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="x"></i-bs><span><ng-container i18n>Close all</ng-container></span>
|
||||
</a>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@@ -177,10 +183,11 @@
|
||||
@if (canManageAttributes) {
|
||||
<li class="nav-item app-link" tourAnchor="tour.tags">
|
||||
<div class="d-flex align-items-center attributes-row">
|
||||
<a class="nav-link flex-fill" routerLink="attributes" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Attributes" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
<a class="nav-link flex-fill" routerLink="attributes" routerLinkActive="active"
|
||||
[routerLinkActiveOptions]="{ exact: !(slimSidebarEnabled || attributesSectionsCollapsed) }" (click)="closeMenu()"
|
||||
ngbPopover="Attributes" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="stack"></i-bs><span><ng-container i18n>Attributes</ng-container></span>
|
||||
<i-bs name="stack"></i-bs><span class="ms-2"><ng-container i18n>Attributes</ng-container></span>
|
||||
</a>
|
||||
@if (!slimSidebarEnabled && canSaveSettings) {
|
||||
<button
|
||||
@@ -195,32 +202,32 @@
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
class="attributes-submenu ms-2"
|
||||
class="attributes-submenu ms-3"
|
||||
[ngbCollapse]="slimSidebarEnabled || attributesSectionsCollapsed"
|
||||
>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }">
|
||||
<a class="nav-link py-1" routerLink="attributes/tags" routerLinkActive="active" (click)="closeMenu()">
|
||||
<a class="nav-link" routerLink="attributes/tags" routerLinkActive="active" (click)="closeMenu()">
|
||||
<i-bs class="me-2" name="tags"></i-bs><span><ng-container i18n>Tags</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
||||
<a class="nav-link py-1" routerLink="attributes/correspondents" routerLinkActive="active" (click)="closeMenu()">
|
||||
<a class="nav-link" routerLink="attributes/correspondents" routerLinkActive="active" (click)="closeMenu()">
|
||||
<i-bs class="me-2" name="person"></i-bs><span><ng-container i18n>Correspondents</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
|
||||
<a class="nav-link py-1" routerLink="attributes/documenttypes" routerLinkActive="active" (click)="closeMenu()">
|
||||
<a class="nav-link" routerLink="attributes/documenttypes" routerLinkActive="active" (click)="closeMenu()">
|
||||
<i-bs class="me-2" name="hash"></i-bs><span><ng-container i18n>Document types</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
|
||||
<a class="nav-link py-1" routerLink="attributes/storagepaths" routerLinkActive="active" (click)="closeMenu()">
|
||||
<a class="nav-link" routerLink="attributes/storagepaths" routerLinkActive="active" (click)="closeMenu()">
|
||||
<i-bs class="me-2" name="folder"></i-bs><span><ng-container i18n>Storage paths</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
|
||||
<a class="nav-link py-1" routerLink="attributes/customfields" routerLinkActive="active" (click)="closeMenu()">
|
||||
<a class="nav-link" routerLink="attributes/customfields" routerLinkActive="active" (click)="closeMenu()">
|
||||
<i-bs class="me-2" name="ui-radios"></i-bs><span><ng-container i18n>Custom fields</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
@@ -230,7 +237,7 @@
|
||||
}
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
||||
<a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="window-stack"></i-bs><span><ng-container i18n>Saved Views</ng-container></span>
|
||||
</a>
|
||||
@@ -239,7 +246,7 @@
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
|
||||
tourAnchor="tour.workflows">
|
||||
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="boxes"></i-bs><span><ng-container i18n>Workflows</ng-container></span>
|
||||
</a>
|
||||
@@ -247,14 +254,14 @@
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
||||
tourAnchor="tour.mail">
|
||||
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
|
||||
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="envelope"></i-bs><span><ng-container i18n>Mail</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
|
||||
<a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash"
|
||||
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="trash"></i-bs><span><ng-container i18n>Trash</ng-container></span>
|
||||
</a>
|
||||
@@ -270,21 +277,21 @@
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }"
|
||||
tourAnchor="tour.settings">
|
||||
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="gear"></i-bs><span><ng-container i18n>Settings</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }">
|
||||
<a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="sliders2-vertical"></i-bs><span><ng-container i18n>Configuration</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
|
||||
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="people"></i-bs><span><ng-container i18n>Users & Groups</ng-container></span>
|
||||
</a>
|
||||
@@ -293,7 +300,7 @@
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }"
|
||||
tourAnchor="tour.file-tasks">
|
||||
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
ngbPopover="Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="list-task"></i-bs><span><ng-container i18n>Tasks</ng-container>@if (tasksService.needsAttentionTasks.length > 0) {
|
||||
<span><span class="badge bg-danger ms-2 d-inline">{{tasksService.needsAttentionTasks.length}}</span></span>
|
||||
@@ -306,26 +313,26 @@
|
||||
@if (permissionsService.isAdmin()) {
|
||||
<li class="nav-item app-link">
|
||||
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
|
||||
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="text-left"></i-bs><span><ng-container i18n>Logs</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
<li class="nav-item mt-2" tourAnchor="tour.outro">
|
||||
<a class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap text-decoration-none"
|
||||
<a class="text-muted small d-flex align-items-center flex-wrap text-decoration-none nav-anchor"
|
||||
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
|
||||
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="d-flex me-2" name="question-circle"></i-bs><span><ng-container i18n>Documentation</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
||||
<div class="px-3 py-0 text-muted small d-flex align-items-center flex-wrap">
|
||||
<div class="text-muted small d-flex align-items-center flex-wrap nav-label">
|
||||
<div class="me-3">
|
||||
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer"
|
||||
href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover
|
||||
[disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||
[disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
{{ versionString }}
|
||||
</a>
|
||||
@@ -363,7 +370,7 @@
|
||||
</a>
|
||||
}
|
||||
} @else {
|
||||
<a *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }" class="small text-decoration-none" routerLink="/settings" fragment="update-checking"
|
||||
<a *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }" class="small text-decoration-none" routerLink="/settings" fragment="update-checking" aria-label="Configure update checking" i18n-aria-label
|
||||
[ngbPopover]="updateCheckingNotEnabledPopContent" popoverClass="shadow" triggers="mouseenter"
|
||||
container="body">
|
||||
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 995; /* Behind the navbar */
|
||||
padding: 50px 0 0; /* Height of navbar */
|
||||
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
|
||||
padding: 64px 0 0; /* Height of navbar */
|
||||
border-right: 1px solid color-mix(in srgb, var(--bs-border-color) 65%, transparent);
|
||||
overflow-y: auto;
|
||||
--pngx-sidebar-width: 100%;
|
||||
max-width: var(--pngx-sidebar-width);
|
||||
@@ -19,8 +19,13 @@
|
||||
height: 0.8em;
|
||||
}
|
||||
|
||||
.sidebar-heading,
|
||||
.text-uppercase {
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.view-name {
|
||||
max-width: calc(100% - 50px)
|
||||
max-width: calc(100% - 55px)
|
||||
}
|
||||
|
||||
.nav-group:not(:has(.app-link)) .sidebar-heading {
|
||||
@@ -42,16 +47,17 @@
|
||||
}
|
||||
@media (max-width: 767.98px) {
|
||||
.sidebar {
|
||||
top: 3.5rem;
|
||||
top: 4rem;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
max-height: 4.5rem;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
transition: max-height .2s ease, opacity .2s ease, padding-top .2s ease, padding-bottom .2s ease;
|
||||
|
||||
&.mobile-hidden {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
@@ -61,16 +67,35 @@
|
||||
main.mobile-search-hidden {
|
||||
padding-top: 56px;
|
||||
}
|
||||
|
||||
.navbar-toggler {
|
||||
padding-left: calc(12px - (1.5em * 2.5 / 16));
|
||||
}
|
||||
}
|
||||
|
||||
.search-container {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
// Below sm the search gets its own full-width row, so line it up with main's content edge
|
||||
@media (max-width: 575.98px) {
|
||||
.search-container {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
transition: all .2s ease;
|
||||
padding-top: 110px;
|
||||
padding-top: 118px;
|
||||
background: var(--pngx-bg-darker);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
main {
|
||||
padding-top: 56px;
|
||||
padding-top: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,16 +105,16 @@ main {
|
||||
|
||||
.sidebar li.nav-item span,
|
||||
.sidebar .sidebar-heading span {
|
||||
transition: all .1s ease;
|
||||
transition: opacity .1s ease;
|
||||
}
|
||||
|
||||
@media(min-width: 768px) {
|
||||
.sidebar.slim {
|
||||
max-width: 50px;
|
||||
max-width: 55px;
|
||||
|
||||
li.nav-item span.badge {
|
||||
display: inline-block;
|
||||
margin-right: 2px;
|
||||
margin-right: -4px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,26 +158,19 @@ main {
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar.slim {
|
||||
li.nav-item span.badge {
|
||||
display: inline-block;
|
||||
margin-right: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-slim-toggler {
|
||||
display: block;
|
||||
position: fixed;
|
||||
left: calc(var(--pngx-sidebar-width) - 12px);
|
||||
top: 60px;
|
||||
bottom: 16px;
|
||||
z-index: 996;
|
||||
--bs-btn-padding-x: 0.35rem;
|
||||
--bs-btn-padding-y: 0.125rem;
|
||||
transition: all .2s ease;
|
||||
transition: left .2s ease;
|
||||
}
|
||||
|
||||
.sidebar.slim .sidebar-slim-toggler {
|
||||
--pngx-sidebar-width: 50px !important;
|
||||
--pngx-sidebar-width: 56px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +183,7 @@ main {
|
||||
position: relative;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
padding-top: 0.5rem;
|
||||
padding: .75rem .5rem 1rem;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */
|
||||
min-height: min-content;
|
||||
@@ -180,9 +198,14 @@ main {
|
||||
.sidebar .nav-link {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
border-radius: .55rem;
|
||||
margin: 1px 0;
|
||||
padding: .55rem .7rem;
|
||||
transition: color .15s ease-in-out, background-color .15s ease-in-out;
|
||||
|
||||
&:hover, &.active, &:focus {
|
||||
&:hover, &:focus {
|
||||
color: var(--bs-primary);
|
||||
background-color: color-mix(in srgb, var(--bs-primary) 8%, transparent);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@@ -191,7 +214,21 @@ main {
|
||||
}
|
||||
|
||||
&.active {
|
||||
font-weight: bold;
|
||||
font-weight: 600;
|
||||
color: var(--bs-primary);
|
||||
background-color: color-mix(in srgb, var(--bs-primary) 13%, transparent);
|
||||
}
|
||||
|
||||
&.nav-link-action {
|
||||
color: var(--bs-secondary-color);
|
||||
font-weight: 400;
|
||||
background-color: transparent;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: var(--bs-primary);
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
i-bs {
|
||||
@@ -200,9 +237,49 @@ main {
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar .nav-anchor, .sidebar .nav-label {
|
||||
padding: .25rem .7rem;
|
||||
}
|
||||
|
||||
.attributes-row {
|
||||
border-radius: .55rem;
|
||||
margin: .1rem 0;
|
||||
transition: color .15s ease-in-out, background-color .15s ease-in-out;
|
||||
|
||||
> .nav-link {
|
||||
margin: 0;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&.active {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:has(> .nav-link:focus-visible) {
|
||||
background-color: color-mix(in srgb, var(--bs-primary) 8%, transparent);
|
||||
}
|
||||
|
||||
&:has(> .nav-link.active) {
|
||||
background-color: color-mix(in srgb, var(--bs-primary) 13%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.attributes-row .attributes-expand-btn {
|
||||
opacity: 0.2;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
margin-right: .35rem !important;
|
||||
border-radius: 50%;
|
||||
box-shadow: none !important;
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--bs-primary) 55%, transparent);
|
||||
outline-offset: 1px;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.attributes-row:hover .attributes-expand-btn {
|
||||
@@ -210,8 +287,10 @@ main {
|
||||
}
|
||||
|
||||
.sidebar-heading {
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
opacity: .75;
|
||||
}
|
||||
|
||||
.nav {
|
||||
@@ -268,16 +347,118 @@ main {
|
||||
*/
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 1rem;
|
||||
--pngx-navbar-brand-shadow-rgb: 0, 0, 0;
|
||||
font-size: 1.0625rem;
|
||||
min-height: 64px;
|
||||
letter-spacing: -0.015em;
|
||||
|
||||
.flex-column {
|
||||
padding: 0.15rem 0;
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
::ng-deep .navbar-official-logo,
|
||||
.brand-mark,
|
||||
.brand-logo {
|
||||
filter: drop-shadow(0 2px 3px rgba(var(--pngx-navbar-brand-shadow-rgb), .5));
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .navbar-official-logo {
|
||||
filter: drop-shadow(0 1px 2px rgba(var(--pngx-navbar-brand-shadow-rgb), .3));
|
||||
transition: filter .15s ease-in-out;
|
||||
|
||||
@media screen and (max-width: 575.98px) {
|
||||
max-height: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 1.65rem;
|
||||
height: 1.65rem;
|
||||
flex: 0 0 auto;
|
||||
transition: filter .15s ease-in-out;
|
||||
}
|
||||
|
||||
.brand-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
line-height: 1.1;
|
||||
transition: transform .15s ease-in-out;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-weight: 600;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.byline {
|
||||
font-size: 0.5rem;
|
||||
letter-spacing: 0.1rem;
|
||||
margin-top: .15rem;
|
||||
font-size: .5rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: .1rem;
|
||||
opacity: .8;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: auto;
|
||||
height: 2.75rem;
|
||||
max-width: 5rem;
|
||||
flex: 0 0 auto;
|
||||
object-fit: contain;
|
||||
transition: filter .15s ease-in-out, transform .15s ease-in-out;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
:host-context(.primary-light) .navbar-brand {
|
||||
--pngx-navbar-brand-shadow-rgb: 255, 255, 255; // Light app color, use white shadow for dark text
|
||||
}
|
||||
|
||||
:host ::ng-deep .navbar-official-logo {
|
||||
.leaf {
|
||||
fill: color-mix(in srgb, var(--pngx-primary-text-contrast) 85%, var(--bs-primary)) !important;
|
||||
}
|
||||
|
||||
.text {
|
||||
fill: var(--pngx-primary-text-contrast) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar {
|
||||
min-height: 64px;
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, .12), 0 4px 18px rgba(0, 0, 0, .08) !important;
|
||||
}
|
||||
|
||||
.navbar > ul {
|
||||
align-items: center;
|
||||
gap: .125rem;
|
||||
padding-right: .5rem;
|
||||
}
|
||||
|
||||
:host ::ng-deep .navbar-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2.5rem;
|
||||
min-height: 2.5rem;
|
||||
padding: .45rem .55rem;
|
||||
border-radius: .6rem;
|
||||
transition: background-color .15s ease-in-out, opacity .15s ease-in-out;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: rgba(0, 0, 0, .14);
|
||||
}
|
||||
}
|
||||
|
||||
#userDropdown {
|
||||
padding-left: .75rem;
|
||||
}
|
||||
|
||||
.header-search {
|
||||
width: 100%;
|
||||
max-width: 55rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 575.98px) {
|
||||
@@ -291,6 +472,7 @@ main {
|
||||
|
||||
.navbar-toggler {
|
||||
grid-area: toggler;
|
||||
color: var(--pngx-primary-text-contrast);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@@ -326,18 +508,44 @@ main {
|
||||
grid-area: actions;
|
||||
justify-self: end;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0;
|
||||
padding-right: .25rem;
|
||||
}
|
||||
|
||||
:host ::ng-deep .navbar-action {
|
||||
min-width: 2.25rem;
|
||||
padding-right: .4rem;
|
||||
padding-left: .4rem;
|
||||
}
|
||||
|
||||
#userDropdown {
|
||||
padding-right: .35rem;
|
||||
padding-left: .45rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.navbar-brand.slim {
|
||||
max-width: 50px;
|
||||
max-width: 55px;
|
||||
|
||||
.brand-logo {
|
||||
width: 1.65rem;
|
||||
max-width: 1.65rem;
|
||||
}
|
||||
|
||||
.brand-mark-slim {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
|
||||
:host ::ng-deep .navbar-brand.slim .navbar-official-logo {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
:host ::ng-deep .dropdown.show .dropdown-toggle,
|
||||
:host ::ng-deep .dropdown-toggle:hover {
|
||||
opacity: 0.7;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dropdown-toggle::after {
|
||||
|
||||
@@ -45,6 +45,8 @@ 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 { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
|
||||
import { LogoComponent } from '../common/logo/logo.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 +61,8 @@ const SCROLL_THRESHOLD = 16
|
||||
styleUrls: ['./app-frame.component.scss'],
|
||||
imports: [
|
||||
GlobalSearchComponent,
|
||||
LogoComponent,
|
||||
BrandMarkComponent,
|
||||
DocumentTitlePipe,
|
||||
IfPermissionsDirective,
|
||||
ToastsDropdownComponent,
|
||||
@@ -190,11 +194,34 @@ export class AppFrameComponent
|
||||
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
|
||||
}
|
||||
|
||||
get appTitle(): string {
|
||||
this.settingsService.trackChanges()
|
||||
return (
|
||||
this.settingsService.get(SETTINGS_KEYS.APP_TITLE) || environment.appTitle
|
||||
)
|
||||
}
|
||||
|
||||
get customAppTitle(): string {
|
||||
this.settingsService.trackChanges()
|
||||
return this.settingsService.get(SETTINGS_KEYS.APP_TITLE)
|
||||
}
|
||||
|
||||
get hasCustomBranding(): boolean {
|
||||
this.settingsService.trackChanges()
|
||||
return !!(
|
||||
this.settingsService.get(SETTINGS_KEYS.APP_TITLE)?.length ||
|
||||
this.settingsService.get(SETTINGS_KEYS.APP_LOGO)?.length
|
||||
)
|
||||
}
|
||||
|
||||
get customAppLogo(): string {
|
||||
this.settingsService.trackChanges()
|
||||
const logo = this.settingsService.get(SETTINGS_KEYS.APP_LOGO)
|
||||
return logo?.length
|
||||
? environment.apiBaseUrl.replace(/\/api\/$/, logo)
|
||||
: null
|
||||
}
|
||||
|
||||
get canSaveSettings(): boolean {
|
||||
return (
|
||||
this.permissionsService.currentUserCan(
|
||||
@@ -253,6 +280,10 @@ export class AppFrameComponent
|
||||
})
|
||||
}
|
||||
|
||||
get slimSidebarPopoversEnabled(): boolean {
|
||||
return this.slimSidebarEnabled && !this.isMobileViewport()
|
||||
}
|
||||
|
||||
get attributesSectionsCollapsed(): boolean {
|
||||
this.settingsService.trackChanges()
|
||||
return this.settingsService
|
||||
|
||||
@@ -4,12 +4,12 @@ form {
|
||||
> i-bs[name="search"] {
|
||||
position: absolute;
|
||||
left: 0.6rem;
|
||||
top: .35rem;
|
||||
top: .25rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
// adjust for smaller font size on non-mobile
|
||||
top: 0.25rem;
|
||||
top: .15rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,8 +37,9 @@ form {
|
||||
}
|
||||
|
||||
.form-control {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
background-color: rgba(0, 0, 0, 0.15);
|
||||
min-height: 2.25rem;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
background-color: rgba(0, 0, 0, 0.16);
|
||||
padding-left: 1.8rem;
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
transition: all .3s ease, padding-left 0s ease, background-color 0s ease; // Safari requires all
|
||||
@@ -52,7 +53,7 @@ form {
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
background-color: rgba(0, 0, 0, 0.26);
|
||||
color: var(--bs-light);
|
||||
flex-grow: 1;
|
||||
padding-left: 0.5rem;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
|
||||
<li ngbDropdown class="nav-item mx-1" (openChange)="onOpenChange($event)">
|
||||
<li ngbDropdown class="nav-item position-relative" (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>
|
||||
<span class="notification-count badge rounded-pill z-3 pe-none bg-secondary position-absolute">{{ toasts().length }}</span>
|
||||
}
|
||||
<button class="btn border-0" id="notificationsDropdown" ngbDropdownToggle>
|
||||
<button class="btn navbar-action border-0" id="notificationsDropdown" ngbDropdownToggle aria-label="Notifications" i18n-aria-label>
|
||||
<i-bs width="1.3em" height="1.3em" name="bell"></i-bs>
|
||||
</button>
|
||||
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="notificationsDropdown">
|
||||
|
||||
@@ -11,6 +11,16 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.notification-count {
|
||||
top: -.2rem;
|
||||
right: -.2rem;
|
||||
min-width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
padding: .18rem .32rem;
|
||||
font-size: .68rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
white-space: initial;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
<li ngbDropdown class="nav-item me-n2" (openChange)="onOpenChange($event)">
|
||||
<button class="btn border-0" id="chatDropdown" ngbDropdownToggle>
|
||||
<li ngbDropdown class="nav-item" (openChange)="onOpenChange($event)">
|
||||
<button class="btn navbar-action border-0" id="chatDropdown" ngbDropdownToggle aria-label="Chat" i18n-aria-label>
|
||||
<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">
|
||||
|
||||
@@ -12,10 +12,10 @@
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
|
||||
<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">
|
||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled()">
|
||||
<span>
|
||||
{{btnCaption}}
|
||||
<span class="visually-hidden">{{ seconds | number: '1.0-0' }} seconds</span>
|
||||
@@ -25,7 +25,7 @@
|
||||
}
|
||||
</button>
|
||||
@if (alternativeBtnCaption) {
|
||||
<button type="button" class="btn" [class]="alternativeBtnClass" (click)="alternative()" [disabled]="!alternativeButtonEnabled || !buttonsEnabled">
|
||||
<button type="button" class="btn" [class]="alternativeBtnClass" (click)="alternative()" [disabled]="!alternativeButtonEnabled || !buttonsEnabled()">
|
||||
{{alternativeBtnCaption}}
|
||||
</button>
|
||||
}
|
||||
|
||||
@@ -64,6 +64,22 @@ describe('ConfirmDialogComponent', () => {
|
||||
expect(confirmSubjectResult).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should re-render the buttons when they are toggled from outside', async () => {
|
||||
const confirmButton: HTMLButtonElement =
|
||||
fixture.nativeElement.querySelectorAll('.modal-footer button')[1]
|
||||
expect(confirmButton.disabled).toBeFalsy()
|
||||
|
||||
// Deliberately no detectChanges: a request callback toggling this is all
|
||||
// that happens, and nothing else schedules a render for the modal
|
||||
component.buttonsEnabled.set(false)
|
||||
await fixture.whenStable()
|
||||
expect(confirmButton.disabled).toBeTruthy()
|
||||
|
||||
component.buttonsEnabled.set(true)
|
||||
await fixture.whenStable()
|
||||
expect(confirmButton.disabled).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should support cancel & close modal', () => {
|
||||
let confirmSubjectResult
|
||||
const closeModalSpy = jest.spyOn(modal, 'close')
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { DecimalPipe } from '@angular/common'
|
||||
import { Component, EventEmitter, Input, Output, inject } from '@angular/core'
|
||||
import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
Output,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { Subject } from 'rxjs'
|
||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||
@@ -46,8 +53,7 @@ export class ConfirmDialogComponent extends LoadingComponentWithPermissions {
|
||||
@Input()
|
||||
cancelBtnCaption = $localize`Cancel`
|
||||
|
||||
@Input()
|
||||
buttonsEnabled = true
|
||||
readonly buttonsEnabled = signal(true)
|
||||
|
||||
confirmButtonEnabled = true
|
||||
alternativeButtonEnabled = true
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
<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="form-group">
|
||||
<label class="form-label" for="rootDocumentID" i18n>Root document:</label>
|
||||
<select id="rootDocumentID" class="form-select" [ngModel]="rootDocumentID()" (ngModelChange)="rootDocumentID.set($event)">
|
||||
@for (document of documents(); track document.id) {
|
||||
<option [ngValue]="document.id">{{document.title}}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group mt-4">
|
||||
<span class="form-label d-inline-block" i18n>Versions (oldest first):</span>
|
||||
<ul class="list-group"
|
||||
cdkDropList
|
||||
[cdkDropListData]="versionDocumentIDs()"
|
||||
(cdkDropListDropped)="onDrop($event)">
|
||||
@for (documentID of versionDocumentIDs(); track documentID) {
|
||||
@let document = getDocument(documentID);
|
||||
@if (document) {
|
||||
<li class="list-group-item d-flex align-items-center" cdkDrag>
|
||||
<i-bs name="grip-vertical" class="me-2"></i-bs>
|
||||
<div class="d-flex flex-column">
|
||||
<div>
|
||||
@if (document.correspondent) {
|
||||
<b>{{document.correspondent | correspondentName | async}}: </b>
|
||||
}{{document.title}}
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
{{document.created | customDate:'mediumDate'}}
|
||||
@if (document.page_count) {
|
||||
| {document.page_count, plural, =1 {One page} other {{{document.page_count}} pages}}
|
||||
}
|
||||
</small>
|
||||
</div>
|
||||
@if ($last) {
|
||||
<span class="badge bg-primary ms-auto" i18n>Current version</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
@if (versionDocumentIDs().length > 1) {
|
||||
<div class="form-text" i18n>Drag to reorder.</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<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>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
.list-group-item {
|
||||
cursor: move;
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
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 { of } from 'rxjs'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { MergeAsVersionsConfirmDialogComponent } from './merge-as-versions-confirm-dialog.component'
|
||||
|
||||
describe('MergeAsVersionsConfirmDialogComponent', () => {
|
||||
let component: MergeAsVersionsConfirmDialogComponent
|
||||
let fixture: ComponentFixture<MergeAsVersionsConfirmDialogComponent>
|
||||
let documentService: DocumentService
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
MergeAsVersionsConfirmDialogComponent,
|
||||
],
|
||||
providers: [
|
||||
NgbActiveModal,
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(MergeAsVersionsConfirmDialogComponent)
|
||||
documentService = TestBed.inject(DocumentService)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should fetch selected documents', () => {
|
||||
const documents = [
|
||||
{ id: 1, title: 'Document 1' },
|
||||
{ id: 2, title: 'Document 2' },
|
||||
]
|
||||
jest.spyOn(documentService, 'getFew').mockReturnValue(
|
||||
of({
|
||||
all: [1, 2],
|
||||
count: 2,
|
||||
results: documents,
|
||||
})
|
||||
)
|
||||
component.documentIDs.set([1, 2])
|
||||
|
||||
component.ngOnInit()
|
||||
|
||||
expect(component.documents()).toEqual(documents)
|
||||
expect(documentService.getFew).toHaveBeenCalledWith([1, 2])
|
||||
})
|
||||
|
||||
it('should exclude the root from the draggable documents', () => {
|
||||
component.documentIDs.set([1, 2, 3])
|
||||
component.rootDocumentID.set(2)
|
||||
|
||||
expect(component.versionDocumentIDs()).toEqual([1, 3])
|
||||
})
|
||||
|
||||
it('should move draggable documents while keeping the root fixed', () => {
|
||||
component.documentIDs.set([1, 2, 3])
|
||||
component.rootDocumentID.set(1)
|
||||
|
||||
component.onDrop({ previousIndex: 1, currentIndex: 0 } as any)
|
||||
|
||||
expect(component.documentIDs()).toEqual([1, 3, 2])
|
||||
expect(component.versionDocumentIDs()).toEqual([3, 2])
|
||||
})
|
||||
})
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
CdkDragDrop,
|
||||
DragDropModule,
|
||||
moveItemInArray,
|
||||
} from '@angular/cdk/drag-drop'
|
||||
import { AsyncPipe } from '@angular/common'
|
||||
import { Component, OnInit, computed, inject, signal } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { takeUntil } from 'rxjs'
|
||||
import { Document } from 'src/app/data/document'
|
||||
import { CorrespondentNamePipe } from 'src/app/pipes/correspondent-name.pipe'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-merge-as-versions-confirm-dialog',
|
||||
templateUrl: './merge-as-versions-confirm-dialog.component.html',
|
||||
styleUrl: './merge-as-versions-confirm-dialog.component.scss',
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
CorrespondentNamePipe,
|
||||
CustomDatePipe,
|
||||
DragDropModule,
|
||||
FormsModule,
|
||||
NgxBootstrapIconsModule,
|
||||
],
|
||||
})
|
||||
export class MergeAsVersionsConfirmDialogComponent
|
||||
extends ConfirmDialogComponent
|
||||
implements OnInit
|
||||
{
|
||||
private readonly documentService = inject(DocumentService)
|
||||
|
||||
readonly documentIDs = signal<number[]>([])
|
||||
readonly documents = signal<Document[]>([])
|
||||
readonly rootDocumentID = signal(-1)
|
||||
readonly versionDocumentIDs = computed(() =>
|
||||
this.documentIDs().filter(
|
||||
(documentID) => documentID !== this.rootDocumentID()
|
||||
)
|
||||
)
|
||||
|
||||
ngOnInit() {
|
||||
this.documentService
|
||||
.getFew(this.documentIDs())
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((response) => this.documents.set(response.results))
|
||||
}
|
||||
|
||||
onDrop(event: CdkDragDrop<number[]>) {
|
||||
const versionDocumentIDs = this.versionDocumentIDs().concat()
|
||||
moveItemInArray(versionDocumentIDs, event.previousIndex, event.currentIndex)
|
||||
|
||||
// The root keeps its place in the list, only the versions move around it
|
||||
let versionIndex = 0
|
||||
this.documentIDs.update((documentIDs) =>
|
||||
documentIDs.map((documentID) =>
|
||||
documentID === this.rootDocumentID()
|
||||
? documentID
|
||||
: versionDocumentIDs[versionIndex++]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
getDocument(documentID: number): Document | undefined {
|
||||
return this.documents().find((document) => document.id === documentID)
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
<div class="form-group mt-4">
|
||||
<label class="form-label" for="metadataDocumentID" i18n>Use metadata from:</label>
|
||||
<select class="form-select" [ngModel]="metadataDocumentID()" (ngModelChange)="metadataDocumentID.set($event)">
|
||||
<select id="metadataDocumentID" class="form-select" [ngModel]="metadataDocumentID()" (ngModelChange)="metadataDocumentID.set($event)">
|
||||
<option [ngValue]="-1" i18n>Regenerate all metadata</option>
|
||||
@for (document of documents(); track document.id) {
|
||||
<option [ngValue]="document.id">{{document.title}}</option>
|
||||
@@ -56,10 +56,10 @@
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
|
||||
<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">
|
||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled()">
|
||||
{{btnCaption}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -57,7 +57,7 @@
|
||||
class="btn"
|
||||
[class]="cancelBtnClass"
|
||||
(click)="cancel()"
|
||||
[disabled]="!buttonsEnabled"
|
||||
[disabled]="!buttonsEnabled()"
|
||||
>
|
||||
<span class="d-inline-block" style="padding-bottom: 1px;">
|
||||
{{cancelBtnCaption}}
|
||||
@@ -68,7 +68,7 @@
|
||||
class="btn"
|
||||
[class]="btnClass"
|
||||
(click)="confirm()"
|
||||
[disabled]="!confirmButtonEnabled || !buttonsEnabled"
|
||||
[disabled]="!confirmButtonEnabled || !buttonsEnabled()"
|
||||
>
|
||||
{{btnCaption}}
|
||||
</button>
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<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">
|
||||
@if (messageBold) {
|
||||
<p class="text-break"><b>{{messageBold}}</b></p>
|
||||
}
|
||||
@if (message) {
|
||||
<p class="mb-0 text-break" [innerHTML]="message"></p>
|
||||
}
|
||||
@if (showRemoteOcr) {
|
||||
<div class="form-check mt-3">
|
||||
<input class="form-check-input" type="checkbox" id="reprocessRemoteOcr" [(ngModel)]="remoteOcr" />
|
||||
<label class="form-check-label" for="reprocessRemoteOcr" i18n>Use remote OCR</label>
|
||||
<div class="form-text" i18n>Sends the document to the configured remote OCR service, which may incur costs.</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<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>
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
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 { RemoteOCRModeConfig } from 'src/app/data/paperless-config'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ReprocessConfirmDialogComponent } from './reprocess-confirm-dialog.component'
|
||||
|
||||
describe('ReprocessConfirmDialogComponent', () => {
|
||||
let component: ReprocessConfirmDialogComponent
|
||||
let fixture: ComponentFixture<ReprocessConfirmDialogComponent>
|
||||
let settingsService: SettingsService
|
||||
|
||||
const createComponent = (configured: boolean, mode: string) => {
|
||||
settingsService.set(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED, configured)
|
||||
settingsService.set(SETTINGS_KEYS.REMOTE_OCR_MODE, mode)
|
||||
|
||||
fixture = TestBed.createComponent(ReprocessConfirmDialogComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
NgbActiveModal,
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
imports: [ReprocessConfirmDialogComponent],
|
||||
}).compileComponents()
|
||||
|
||||
settingsService = TestBed.inject(SettingsService)
|
||||
})
|
||||
|
||||
it('should not request remote OCR by default', () => {
|
||||
createComponent(true, RemoteOCRModeConfig.WORKFLOW_ONLY)
|
||||
|
||||
expect(component.remoteOcr).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should not offer remote OCR when no engine is configured', () => {
|
||||
createComponent(false, RemoteOCRModeConfig.WORKFLOW_ONLY)
|
||||
|
||||
expect(component.showRemoteOcr).toBeFalsy()
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('#reprocessRemoteOcr')
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('should not offer remote OCR when it already handles every document', () => {
|
||||
createComponent(true, RemoteOCRModeConfig.ALWAYS)
|
||||
|
||||
expect(component.showRemoteOcr).toBeFalsy()
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('#reprocessRemoteOcr')
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('should offer remote OCR when configured and selective', () => {
|
||||
createComponent(true, RemoteOCRModeConfig.WORKFLOW_ONLY)
|
||||
|
||||
expect(component.showRemoteOcr).toBeTruthy()
|
||||
const checkbox = fixture.nativeElement.querySelector('#reprocessRemoteOcr')
|
||||
expect(checkbox).not.toBeNull()
|
||||
|
||||
checkbox.click()
|
||||
fixture.detectChanges()
|
||||
expect(component.remoteOcr).toBeTruthy()
|
||||
})
|
||||
})
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-reprocess-confirm-dialog',
|
||||
templateUrl: './reprocess-confirm-dialog.component.html',
|
||||
imports: [FormsModule],
|
||||
})
|
||||
export class ReprocessConfirmDialogComponent extends ConfirmDialogComponent {
|
||||
private settings = inject(SettingsService)
|
||||
|
||||
remoteOcr: boolean = false
|
||||
|
||||
public get showRemoteOcr(): boolean {
|
||||
// Hidden when it is not configured, or when it already handles every document anyway.
|
||||
return this.settings.remoteOCRIsSelectable
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -6,7 +6,7 @@
|
||||
<div class="modal-body">
|
||||
<div class="row">
|
||||
<div class="col-2 d-flex justify-content-end">
|
||||
<button class="btn btn-secondary mt-auto" (click)="rotate(false)">
|
||||
<button class="btn btn-secondary mt-auto" (click)="rotate(false)" aria-label="Rotate counterclockwise" i18n-aria-label>
|
||||
<i-bs name="arrow-counterclockwise"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
@@ -16,7 +16,7 @@
|
||||
}
|
||||
</div>
|
||||
<div class="col-2 d-flex">
|
||||
<button class="btn btn-secondary mt-auto" (click)="rotate()">
|
||||
<button class="btn btn-secondary mt-auto" (click)="rotate()" aria-label="Rotate clockwise" i18n-aria-label>
|
||||
<i-bs name="arrow-clockwise"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
@@ -34,10 +34,10 @@
|
||||
<p class="mb-0 small"><b>{{messageBold}}</b></p>
|
||||
}
|
||||
</div>
|
||||
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
|
||||
<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 || degrees === 0">
|
||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled() || degrees === 0">
|
||||
{{btnCaption}}
|
||||
@if (!confirmButtonEnabled) {
|
||||
<ngb-progressbar style="height: 1px;" type="dark" [max]="secondsTotal" [value]="seconds"></ngb-progressbar>
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose($event)" [popperOptions]="popperOptions">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="customFieldsDropdown" [disabled]="disabled" ngbDropdownToggle>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="customFieldsDropdown" [disabled]="disabled" ngbDropdownToggle aria-label="Custom Fields" i18n-aria-label>
|
||||
<i-bs name="ui-radios"></i-bs><div class="d-none d-lg-inline ms-1"><ng-container i18n>Custom Fields</ng-container></div>
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="customFieldsDropdown" class="shadow custom-fields-dropdown">
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
@if (useDropdown) {
|
||||
<div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions">
|
||||
<button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled">
|
||||
<button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled" [aria-label]="title">
|
||||
<i-bs name="{{icon}}"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
|
||||
@if (isActive) {
|
||||
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge>
|
||||
@@ -38,7 +38,7 @@
|
||||
ngbDatepicker
|
||||
#d="ngbDatepicker"
|
||||
[footerTemplate]="datePickerFooterTemplate" />
|
||||
<button class="btn btn-sm btn-outline-secondary rounded-end" (click)="d.toggle()" type="button">
|
||||
<button class="btn btn-sm btn-outline-secondary rounded-end" (click)="d.toggle()" type="button" aria-label="Open date picker" i18n-aria-label>
|
||||
<i-bs name="calendar-event"></i-bs>
|
||||
</button>
|
||||
<ng-template #datePickerFooterTemplate>
|
||||
@@ -143,7 +143,7 @@
|
||||
<input class="w-25 form-control rounded-end" type="text" [(ngModel)]="atom.value" [disabled]="disabled">
|
||||
}
|
||||
}
|
||||
<button class="btn btn-link btn-sm text-danger pe-0" type="button" (click)="removeElement(atom)" [disabled]="disabled">
|
||||
<button class="btn btn-link btn-sm text-danger pe-0" type="button" (click)="removeElement(atom)" [disabled]="disabled" aria-label="Remove query" i18n-aria-label>
|
||||
<i-bs name="x-circle"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
@@ -185,7 +185,7 @@
|
||||
<i-bs name="braces"></i-bs>
|
||||
</button>
|
||||
@if (expression.depth > 0) {
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary text-danger" (click)="removeElement(expression)" [disabled]="disabled">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary text-danger" (click)="removeElement(expression)" [disabled]="disabled" aria-label="Remove expression" i18n-aria-label>
|
||||
<i-bs name="x-circle"></i-bs>
|
||||
</button>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="btn-group w-100" ngbDropdown role="group" [popperOptions]="popperOptions" [placement]="placement">
|
||||
<button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="createdDateTo || createdDateFrom ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
|
||||
<button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="createdDateTo || createdDateFrom ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled" [aria-label]="title">
|
||||
<i-bs width="1em" height="1em" name="calendar-event-fill"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
|
||||
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
|
||||
</button>
|
||||
@@ -9,7 +9,7 @@
|
||||
<div class="list-group-item d-flex p-2 select-item" role="menuitem">
|
||||
<div class="selected-icon">
|
||||
@if (createdRelativeDate) {
|
||||
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedRelativeDate()">
|
||||
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedRelativeDate()" aria-label="Clear created relative date" i18n-aria-label>
|
||||
<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>
|
||||
@@ -33,7 +33,7 @@
|
||||
<div class="list-group-item d-flex p-2" role="menuitem">
|
||||
<div class="selected-icon">
|
||||
@if (createdDateFrom) {
|
||||
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedFrom()">
|
||||
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedFrom()" aria-label="Clear created from date" i18n-aria-label>
|
||||
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
|
||||
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
|
||||
</a>
|
||||
@@ -43,7 +43,7 @@
|
||||
<span class="input-group-text w-25 small text-muted" i18n>From</span>
|
||||
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
|
||||
maxlength="10" [(ngModel)]="createdDateFrom" ngbDatepicker #createdDateFromPicker="ngbDatepicker" [footerTemplate]="createdFromFooterTemplate">
|
||||
<button class="btn btn-outline-secondary" (click)="createdDateFromPicker.toggle()" type="button">
|
||||
<button class="btn btn-outline-secondary" (click)="createdDateFromPicker.toggle()" type="button" aria-label="Open created from date picker" i18n-aria-label>
|
||||
<i-bs width="1em" height="1em" name="calendar"></i-bs>
|
||||
</button>
|
||||
<ng-template #createdFromFooterTemplate>
|
||||
@@ -57,7 +57,7 @@
|
||||
<div class="list-group-item d-flex p-2" role="menuitem">
|
||||
<div class="selected-icon">
|
||||
@if (createdDateTo) {
|
||||
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedTo()">
|
||||
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedTo()" aria-label="Clear created to date" i18n-aria-label>
|
||||
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
|
||||
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
|
||||
</a>
|
||||
@@ -67,7 +67,7 @@
|
||||
<span class="input-group-text w-25 small text-muted" i18n>To</span>
|
||||
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
|
||||
maxlength="10" [(ngModel)]="createdDateTo" ngbDatepicker #createdDateToPicker="ngbDatepicker" [footerTemplate]="createdToFooterTemplate">
|
||||
<button class="btn btn-outline-secondary" (click)="createdDateToPicker.toggle()" type="button">
|
||||
<button class="btn btn-outline-secondary" (click)="createdDateToPicker.toggle()" type="button" aria-label="Open created to date picker" i18n-aria-label>
|
||||
<i-bs width="1em" height="1em" name="calendar"></i-bs>
|
||||
</button>
|
||||
<ng-template #createdToFooterTemplate>
|
||||
@@ -85,7 +85,7 @@
|
||||
<div class="list-group-item d-flex p-2 select-item" role="menuitem">
|
||||
<div class="selected-icon">
|
||||
@if (addedRelativeDate) {
|
||||
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedRelativeDate()">
|
||||
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedRelativeDate()" aria-label="Clear added relative date" i18n-aria-label>
|
||||
<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>
|
||||
@@ -109,7 +109,7 @@
|
||||
<div class="list-group-item d-flex p-2" role="menuitem">
|
||||
<div class="selected-icon">
|
||||
@if (addedDateFrom) {
|
||||
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedFrom()">
|
||||
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedFrom()" aria-label="Clear added from date" i18n-aria-label>
|
||||
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
|
||||
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
|
||||
</a>
|
||||
@@ -119,7 +119,7 @@
|
||||
<span class="input-group-text w-25 small text-muted" i18n>From</span>
|
||||
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
|
||||
maxlength="10" [(ngModel)]="addedDateFrom" ngbDatepicker #addedDateFromPicker="ngbDatepicker" [footerTemplate]="addedFromFooterTemplate">
|
||||
<button class="btn btn-outline-secondary" (click)="addedDateFromPicker.toggle()" type="button">
|
||||
<button class="btn btn-outline-secondary" (click)="addedDateFromPicker.toggle()" type="button" aria-label="Open added from date picker" i18n-aria-label>
|
||||
<i-bs width="1em" height="1em" name="calendar"></i-bs>
|
||||
</button>
|
||||
<ng-template #addedFromFooterTemplate>
|
||||
@@ -133,7 +133,7 @@
|
||||
<div class="list-group-item d-flex p-2" role="menuitem">
|
||||
<div class="selected-icon">
|
||||
@if (addedDateTo) {
|
||||
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedTo()">
|
||||
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedTo()" aria-label="Clear added to date" i18n-aria-label>
|
||||
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
|
||||
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
|
||||
</a>
|
||||
@@ -143,7 +143,7 @@
|
||||
<span class="input-group-text w-25 small text-muted" i18n>To</span>
|
||||
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
|
||||
maxlength="10" [(ngModel)]="addedDateTo" ngbDatepicker #addedDateToPicker="ngbDatepicker" [footerTemplate]="addedToFooterTemplate">
|
||||
<button class="btn btn-outline-secondary" (click)="addedDateToPicker.toggle()" type="button">
|
||||
<button class="btn btn-outline-secondary" (click)="addedDateToPicker.toggle()" type="button" aria-label="Open added to date picker" i18n-aria-label>
|
||||
<i-bs width="1em" height="1em" name="calendar"></i-bs>
|
||||
</button>
|
||||
<ng-template #addedToFooterTemplate>
|
||||
|
||||
+7
@@ -455,6 +455,13 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@case (WorkflowActionType.RemoteOcr) {
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<p class="text-muted small" i18n>The document will be sent to the configured remote OCR service. May incur costs.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
+129
-2
@@ -29,6 +29,7 @@ import {
|
||||
DocumentSource,
|
||||
WorkflowTriggerType,
|
||||
} from 'src/app/data/workflow-trigger'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||
@@ -224,7 +225,12 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
).toEqual('Document Added')
|
||||
expect(component.getTriggerTypeOptionName(null)).toEqual('')
|
||||
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS)
|
||||
expect(component.actionTypeOptions).toEqual(WORKFLOW_ACTION_OPTIONS)
|
||||
// Remote OCR is absent until the workflow has a consumption trigger
|
||||
expect(component.actionTypeOptions).toEqual(
|
||||
WORKFLOW_ACTION_OPTIONS.filter(
|
||||
(a) => a.id !== WorkflowActionType.RemoteOcr
|
||||
)
|
||||
)
|
||||
expect(
|
||||
component.getActionTypeOptionName(WorkflowActionType.Assignment)
|
||||
).toEqual('Assignment')
|
||||
@@ -237,7 +243,104 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue(false)
|
||||
component.ngOnInit()
|
||||
expect(component.actionTypeOptions).toEqual(
|
||||
WORKFLOW_ACTION_OPTIONS.filter((a) => a.id !== WorkflowActionType.Email)
|
||||
WORKFLOW_ACTION_OPTIONS.filter(
|
||||
(a) =>
|
||||
a.id !== WorkflowActionType.Email &&
|
||||
a.id !== WorkflowActionType.RemoteOcr
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('should offer remote OCR only for consumption workflows', () => {
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||
|
||||
// A consumption trigger makes the action reachable
|
||||
component.object = {
|
||||
name: 'Workflow 1',
|
||||
order: 0,
|
||||
enabled: true,
|
||||
triggers: [{ type: WorkflowTriggerType.Consumption }],
|
||||
actions: [],
|
||||
} as Workflow
|
||||
component.ngOnInit()
|
||||
expect(component.actionTypeOptions.map((a) => a.id)).toContain(
|
||||
WorkflowActionType.RemoteOcr
|
||||
)
|
||||
|
||||
// Any other trigger type runs after the document has been parsed
|
||||
component.object = {
|
||||
name: 'Workflow 2',
|
||||
order: 0,
|
||||
enabled: true,
|
||||
triggers: [{ type: WorkflowTriggerType.DocumentAdded }],
|
||||
actions: [],
|
||||
} as Workflow
|
||||
component.ngOnInit()
|
||||
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
|
||||
WorkflowActionType.RemoteOcr
|
||||
)
|
||||
})
|
||||
|
||||
it('should offer remote OCR on a trigger added to a new workflow', () => {
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||
component.ngOnInit()
|
||||
|
||||
// Nothing for the action to apply to yet
|
||||
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
|
||||
WorkflowActionType.RemoteOcr
|
||||
)
|
||||
|
||||
// addTrigger creates the form field with emitEvent false, so the options
|
||||
// have to be computed on read rather than cached from valueChanges
|
||||
component.addTrigger()
|
||||
expect(component.actionTypeOptions.map((a) => a.id)).toContain(
|
||||
WorkflowActionType.RemoteOcr
|
||||
)
|
||||
|
||||
// Switching that trigger to a type that runs after parsing removes it
|
||||
component.triggerFields
|
||||
.at(0)
|
||||
.get('type')
|
||||
.setValue(WorkflowTriggerType.DocumentAdded)
|
||||
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
|
||||
WorkflowActionType.RemoteOcr
|
||||
)
|
||||
})
|
||||
|
||||
it('should keep remote OCR listed when an action already uses it', () => {
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||
|
||||
// Otherwise changing the trigger would silently blank the selection
|
||||
component.object = {
|
||||
name: 'Workflow 1',
|
||||
order: 0,
|
||||
enabled: true,
|
||||
triggers: [{ type: WorkflowTriggerType.DocumentAdded }],
|
||||
actions: [{ type: WorkflowActionType.RemoteOcr }],
|
||||
} as Workflow
|
||||
component.ngOnInit()
|
||||
|
||||
expect(component.actionTypeOptions.map((a) => a.id)).toContain(
|
||||
WorkflowActionType.RemoteOcr
|
||||
)
|
||||
})
|
||||
|
||||
it('should not offer remote OCR when no engine is configured', () => {
|
||||
jest
|
||||
.spyOn(settingsService, 'get')
|
||||
.mockImplementation((key) => key !== SETTINGS_KEYS.REMOTE_OCR_CONFIGURED)
|
||||
|
||||
component.object = {
|
||||
name: 'Workflow 1',
|
||||
order: 0,
|
||||
enabled: true,
|
||||
triggers: [{ type: WorkflowTriggerType.Consumption }],
|
||||
actions: [],
|
||||
} as Workflow
|
||||
component.ngOnInit()
|
||||
|
||||
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
|
||||
WorkflowActionType.RemoteOcr
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1019,4 +1122,28 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
'pass3',
|
||||
])
|
||||
})
|
||||
|
||||
it('should parse passwords again when retrying a failed save', () => {
|
||||
component.object = {
|
||||
name: 'Workflow with blank Passwords',
|
||||
id: 1,
|
||||
order: null,
|
||||
enabled: true,
|
||||
triggers: [],
|
||||
actions: [
|
||||
{
|
||||
id: 1,
|
||||
type: WorkflowActionType.PasswordRemoval,
|
||||
passwords: [],
|
||||
},
|
||||
],
|
||||
}
|
||||
component.ngOnInit()
|
||||
|
||||
component.save()
|
||||
component.objectForm.get('order').setValue(1)
|
||||
|
||||
expect(() => component.save()).not.toThrow()
|
||||
expect(component.objectForm.get('actions').value[0].passwords).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
+42
-13
@@ -148,6 +148,10 @@ export const WORKFLOW_ACTION_OPTIONS = [
|
||||
id: WorkflowActionType.MoveToTrash,
|
||||
name: $localize`Move to trash`,
|
||||
},
|
||||
{
|
||||
id: WorkflowActionType.RemoteOcr,
|
||||
name: $localize`Remote OCR`,
|
||||
},
|
||||
]
|
||||
|
||||
export enum TriggerFilterType {
|
||||
@@ -504,8 +508,6 @@ export class WorkflowEditDialogComponent
|
||||
|
||||
expandedItem: number = null
|
||||
|
||||
readonly allowedActionTypes = signal([])
|
||||
|
||||
private readonly triggerFilterOptionsMap = new WeakMap<
|
||||
FormArray,
|
||||
TriggerFilterOption[]
|
||||
@@ -548,13 +550,40 @@ export class WorkflowEditDialogComponent
|
||||
this.checkRemovalActionFields.bind(this)
|
||||
)
|
||||
this.checkRemovalActionFields(this.objectForm.value)
|
||||
this.allowedActionTypes.set(
|
||||
this.settingsService.get(SETTINGS_KEYS.EMAIL_ENABLED)
|
||||
? WORKFLOW_ACTION_OPTIONS
|
||||
: WORKFLOW_ACTION_OPTIONS.filter(
|
||||
(a) => a.id !== WorkflowActionType.Email
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private allowedActionTypes: typeof WORKFLOW_ACTION_OPTIONS = null
|
||||
|
||||
private getAllowedActionTypes() {
|
||||
let allowed = WORKFLOW_ACTION_OPTIONS
|
||||
|
||||
if (!this.settingsService.get(SETTINGS_KEYS.EMAIL_ENABLED)) {
|
||||
allowed = allowed.filter((a) => a.id !== WorkflowActionType.Email)
|
||||
}
|
||||
|
||||
// Remote OCR is decided before the document is parsed, so it is only
|
||||
// offered for workflows that run at consumption.
|
||||
const formWorkflow: Workflow = this.objectForm?.value
|
||||
const remoteOcrUsable =
|
||||
this.settingsService.get(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED) &&
|
||||
(formWorkflow?.triggers?.some(
|
||||
(trigger) => trigger.type === WorkflowTriggerType.Consumption
|
||||
) ||
|
||||
formWorkflow?.actions?.some(
|
||||
(action) => action.type === WorkflowActionType.RemoteOcr
|
||||
))
|
||||
if (!remoteOcrUsable) {
|
||||
allowed = allowed.filter((a) => a.id !== WorkflowActionType.RemoteOcr)
|
||||
}
|
||||
|
||||
if (
|
||||
this.allowedActionTypes?.length === allowed.length &&
|
||||
this.allowedActionTypes.every((a, i) => a.id === allowed[i].id)
|
||||
) {
|
||||
return this.allowedActionTypes
|
||||
}
|
||||
this.allowedActionTypes = allowed
|
||||
return allowed
|
||||
}
|
||||
|
||||
private checkRemovalActionFields(formWorkflow: Workflow) {
|
||||
@@ -1207,9 +1236,8 @@ export class WorkflowEditDialogComponent
|
||||
return passwords.join('\n')
|
||||
}
|
||||
|
||||
private parsePasswords(value: string = ''): string[] {
|
||||
return value
|
||||
.split(/[\n,]+/)
|
||||
private parsePasswords(value: string | string[] = ''): string[] {
|
||||
return (Array.isArray(value) ? value : value.split(/[\n,]+/))
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0)
|
||||
}
|
||||
@@ -1280,7 +1308,8 @@ export class WorkflowEditDialogComponent
|
||||
|
||||
get actionTypeOptions() {
|
||||
this.settingsService.trackChanges()
|
||||
return this.allowedActionTypes()
|
||||
// Computed on read rather than cached
|
||||
return this.getAllowedActionTypes()
|
||||
}
|
||||
|
||||
getActionTypeOptionName(type: WorkflowActionType): string {
|
||||
|
||||
+4
-4
@@ -1,5 +1,5 @@
|
||||
<div class="btn-group w-100" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown" (keydown)="listKeyDown($event)" [popperOptions]="popperOptions">
|
||||
<button class="btn btn-sm" id="dropdown_{{name}}" ngbDropdownToggle [ngClass]="!editing && selectionModel.selectionSize() > 0 ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
|
||||
<div class="btn-group w-100" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown" (keydown)="listKeyDown($event)" [popperOptions]="popperOptions" [autoClose]="!creating()">
|
||||
<button class="btn btn-sm" id="dropdown_{{name}}" ngbDropdownToggle [ngClass]="!editing && selectionModel.selectionSize() > 0 ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled" [aria-label]="title">
|
||||
<i-bs name="{{icon}}"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
|
||||
@if (!editing && selectionModel.totalCount > 0) {
|
||||
<pngx-clearable-badge [number]="selectionModel.totalCount" [selected]="selectionModel.selectionSize() > 0" (cleared)="reset()"></pngx-clearable-badge>
|
||||
@@ -49,7 +49,7 @@
|
||||
</cdk-virtual-scroll-viewport>
|
||||
}
|
||||
@if (editing) {
|
||||
@if (filteredItems.length === 0 && createRef !== undefined) {
|
||||
@if (filteredItems.length === 0 && createRef !== undefined && filterText?.length > 0) {
|
||||
<button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled">
|
||||
<small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small>
|
||||
<i-bs width="1.5em" height="1em" name="plus"></i-bs>
|
||||
@@ -62,7 +62,7 @@
|
||||
</button>
|
||||
}
|
||||
}
|
||||
@if (extraButtonTitle) {
|
||||
@if (extraButtonTitle && (showExtraButtonIfEmpty || filteredItems?.length > 0)) {
|
||||
<button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="extraButtonClicked($event)" [disabled]="disabled">
|
||||
<small class="ms-2 fw-bold">{{extraButtonTitle}}</small>
|
||||
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
|
||||
|
||||
+63
-4
@@ -3,6 +3,7 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { NEVER, Subject } from 'rxjs'
|
||||
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
|
||||
import {
|
||||
DEFAULT_MATCHING_ALGORITHM,
|
||||
@@ -48,6 +49,7 @@ const negativeNullItem = {
|
||||
|
||||
let selectionModel: FilterableDropdownSelectionModel
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
const createModalRef = () => ({ closed: NEVER, dismissed: NEVER }) as any
|
||||
|
||||
describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => {
|
||||
let component: FilterableDropdownComponent
|
||||
@@ -868,7 +870,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0)
|
||||
})
|
||||
|
||||
it('should set support create, keep open model and call createRef method', async () => {
|
||||
it('should keep the dropdown open while the create modal is active', async () => {
|
||||
component.selectionModel.items = items
|
||||
component.icon = 'tag-fill'
|
||||
component.selectionModel = selectionModel
|
||||
@@ -882,20 +884,44 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
fixture.detectChanges()
|
||||
|
||||
component.filterText = 'Test Filter Text'
|
||||
component.createRef = jest.fn()
|
||||
const modalClosed = new Subject<void>()
|
||||
component.createRef = jest.fn(
|
||||
() =>
|
||||
({
|
||||
closed: modalClosed,
|
||||
dismissed: NEVER,
|
||||
}) as any
|
||||
)
|
||||
component.createClicked()
|
||||
expect(component.creating).toBeTruthy()
|
||||
expect(component.creating()).toBeTruthy()
|
||||
expect(component.createRef).toHaveBeenCalledWith('Test Filter Text')
|
||||
fixture.detectChanges()
|
||||
expect(component.dropdown.autoClose).toBeFalsy()
|
||||
|
||||
document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
||||
document.body.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
|
||||
await wait(10)
|
||||
expect(component.dropdown.isOpen()).toBeTruthy()
|
||||
|
||||
// Also cover a close that was already scheduled before autoClose changed.
|
||||
const openSpy = jest.spyOn(component.dropdown, 'open')
|
||||
component.dropdownOpenChange(false)
|
||||
expect(openSpy).toHaveBeenCalled() // should keep open
|
||||
component.dropdownOpenChange(false)
|
||||
expect(openSpy).toHaveBeenCalledTimes(2) // modal interactions keep it open
|
||||
|
||||
modalClosed.next()
|
||||
fixture.detectChanges()
|
||||
expect(component.creating()).toBeFalsy()
|
||||
expect(component.dropdown.autoClose).toBeTruthy()
|
||||
expect(component.dropdown.isOpen()).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should call create on enter inside filter field if 0 items remain while editing', async () => {
|
||||
component.selectionModel.items = items
|
||||
component.icon = 'tag-fill'
|
||||
component.editing = true
|
||||
component.createRef = jest.fn()
|
||||
component.createRef = jest.fn(createModalRef)
|
||||
const createSpy = jest.spyOn(component, 'createClicked')
|
||||
expect(component.selectionModel.getSelectedItems()).toEqual([])
|
||||
fixture.nativeElement
|
||||
@@ -911,6 +937,25 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
expect(createSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should only show create when a non-empty filter has no matches', () => {
|
||||
component.selectionModel.items = []
|
||||
component.icon = 'tag-fill'
|
||||
component.editing = true
|
||||
component.createRef = jest.fn(createModalRef)
|
||||
|
||||
fixture.detectChanges()
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Create')
|
||||
component.listFilterEnter()
|
||||
expect(component.createRef).not.toHaveBeenCalled()
|
||||
|
||||
const filterInput: HTMLInputElement =
|
||||
fixture.nativeElement.querySelector('input[type="text"]')
|
||||
filterInput.value = 'FooBar'
|
||||
filterInput.dispatchEvent(new Event('input'))
|
||||
fixture.detectChanges()
|
||||
expect(fixture.nativeElement.textContent).toContain('Create "FooBar"')
|
||||
})
|
||||
|
||||
it('should exclude item and trigger change event', () => {
|
||||
const id = 1
|
||||
const state = ToggleableItemState.Selected
|
||||
@@ -970,4 +1015,18 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
expect(extraButtonClicked).toBeTruthy()
|
||||
expect(applied).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should only show the extra button for an empty result when enabled', () => {
|
||||
component.selectionModel.items = items
|
||||
component.icon = 'tag-fill'
|
||||
component.extraButtonTitle = 'Extra'
|
||||
component.filterText = 'FooBar'
|
||||
|
||||
fixture.detectChanges()
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Extra')
|
||||
|
||||
fixture.componentRef.setInput('showExtraButtonIfEmpty', true)
|
||||
fixture.detectChanges()
|
||||
expect(fixture.nativeElement.textContent).toContain('Extra')
|
||||
})
|
||||
})
|
||||
|
||||
+25
-9
@@ -15,9 +15,13 @@ import {
|
||||
signal,
|
||||
} from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import {
|
||||
NgbDropdown,
|
||||
NgbDropdownModule,
|
||||
NgbModalRef,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { Subject, filter, takeUntil } from 'rxjs'
|
||||
import { Subject, filter, first, merge, takeUntil } from 'rxjs'
|
||||
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
|
||||
import { MatchingModel } from 'src/app/data/matching-model'
|
||||
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
||||
@@ -759,7 +763,7 @@ export class FilterableDropdownComponent
|
||||
disabled = false
|
||||
|
||||
@Input()
|
||||
createRef: (name) => void
|
||||
createRef: (name: string) => NgbModalRef
|
||||
|
||||
@Input()
|
||||
set documentCounts(counts: SelectionDataItem[]) {
|
||||
@@ -774,7 +778,10 @@ export class FilterableDropdownComponent
|
||||
@Input()
|
||||
extraButtonTitle: string
|
||||
|
||||
creating: boolean = false
|
||||
@Input()
|
||||
showExtraButtonIfEmpty: boolean = false
|
||||
|
||||
readonly creating = signal(false)
|
||||
|
||||
@Output()
|
||||
apply = new EventEmitter<ChangedItems>()
|
||||
@@ -851,12 +858,18 @@ export class FilterableDropdownComponent
|
||||
}
|
||||
|
||||
createClicked() {
|
||||
this.creating = true
|
||||
this.createRef(this.filterText)
|
||||
this.creating.set(true)
|
||||
const modal = this.createRef(this.filterText)
|
||||
merge(modal.closed, modal.dismissed)
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => this.creating.set(false))
|
||||
}
|
||||
|
||||
dropdownOpenChange(open: boolean): void {
|
||||
if (open) {
|
||||
// Dont let a create modal close this
|
||||
if (this.creating()) return
|
||||
|
||||
setTimeout(() => {
|
||||
this.listFilterTextInput?.nativeElement.focus()
|
||||
this.buttonsViewport?.checkViewportSize()
|
||||
@@ -869,9 +882,8 @@ export class FilterableDropdownComponent
|
||||
this.editing && !this.selectionModel.manyToOne
|
||||
this.opened.next(this)
|
||||
} else {
|
||||
if (this.creating) {
|
||||
if (this.creating()) {
|
||||
this.dropdown?.open()
|
||||
this.creating = false
|
||||
} else {
|
||||
this.filterText = ''
|
||||
if (this.applyOnClose && this.selectionModel.isDirty()) {
|
||||
@@ -892,7 +904,11 @@ export class FilterableDropdownComponent
|
||||
this.dropdown.close()
|
||||
}
|
||||
}, 200)
|
||||
} else if (filtered.length == 0 && this.createRef) {
|
||||
} else if (
|
||||
filtered.length == 0 &&
|
||||
this.createRef &&
|
||||
this.filterText?.length > 0
|
||||
) {
|
||||
this.createClicked()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
}
|
||||
|
||||
<div class="input-group" [class.is-invalid]="error">
|
||||
<button type="button" class="input-group-text" [style.background-color]="value" (click)="colorPicker.toggle()"> </button>
|
||||
<button type="button" class="input-group-text" [style.background-color]="value" (click)="colorPicker.toggle()" aria-label="Open color picker" i18n-aria-label> </button>
|
||||
|
||||
<ng-template #popContent>
|
||||
<div style="min-width: 200px;" class="pb-3">
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<input #inputField class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [autoClose]="'outside'" [ngbPopover]="popContent" #colorPicker="ngbPopover" placement="bottom" popoverClass="shadow">
|
||||
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="randomize()">
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="randomize()" aria-label="Choose a random color" i18n-aria-label>
|
||||
<i-bs name="dice5"></i-bs>
|
||||
</button>
|
||||
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@
|
||||
class="flex-grow-1"></pngx-input-textarea>
|
||||
}
|
||||
}
|
||||
<button type="button" class="btn btn-link text-danger" (click)="removeSelectedField.next(fieldId)">
|
||||
<button type="button" class="btn btn-link text-danger" (click)="removeSelectedField.next(fieldId)" aria-label="Remove custom field" i18n-aria-label>
|
||||
<i-bs name="trash"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
+4
-4
@@ -52,10 +52,10 @@ describe('CustomFieldsValuesComponent', () => {
|
||||
})
|
||||
|
||||
it('should set selectedFields and map values correctly', () => {
|
||||
component.value = { 1: 'value1' }
|
||||
component.selectedFields = [1, 2]
|
||||
expect(component.selectedFields).toEqual([1, 2])
|
||||
expect(component.value).toEqual({ 1: 'value1', 2: null })
|
||||
component.value = { 1: 'value1', 3: 0, 4: false }
|
||||
component.selectedFields = [1, 2, 3, 4]
|
||||
expect(component.selectedFields).toEqual([1, 2, 3, 4])
|
||||
expect(component.value).toEqual({ 1: 'value1', 2: null, 3: 0, 4: false })
|
||||
})
|
||||
|
||||
it('should return the correct custom field by id', () => {
|
||||
|
||||
+1
-1
@@ -77,7 +77,7 @@ export class CustomFieldsValuesComponent extends AbstractInputComponent<Object>
|
||||
this._selectedFields = newFields
|
||||
// map the selected fields to an object with field_id as key and value as value
|
||||
this.value = newFields.reduce((acc, fieldId) => {
|
||||
acc[fieldId] = this.value?.[fieldId] || null
|
||||
acc[fieldId] = this.value?.[fieldId] ?? null
|
||||
return acc
|
||||
}, {})
|
||||
this.onChange(this.value)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<input #inputField class="form-control" [class.is-invalid]="error" [placeholder]="placeholder" [id]="inputId" maxlength="10"
|
||||
(dateSelect)="onChange(value)" (change)="onChange(value)" (keypress)="onKeyPress($event)" (paste)="onPaste($event)"
|
||||
name="dp" [(ngModel)]="value" ngbDatepicker #datePicker="ngbDatepicker" #datePickerContent="ngModel" [disabled]="disabled" [footerTemplate]="datePickerFooterTemplate">
|
||||
<button class="btn btn-outline-secondary calendar" (click)="datePicker.toggle()" type="button" [disabled]="disabled">
|
||||
<button class="btn btn-outline-secondary calendar" (click)="datePicker.toggle()" type="button" [disabled]="disabled" aria-label="Open date picker" i18n-aria-label>
|
||||
<i-bs width="1.2em" height="1.2em" name="calendar"></i-bs>
|
||||
</button>
|
||||
<ng-template #datePickerFooterTemplate>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<div class="input-group mb-3">
|
||||
<input type="text" class="form-control" [(ngModel)]="entry[0]" (change)="inputChange()" [disabled]="disabled" autocomplete="off">
|
||||
<input type="text" class="form-control" [(ngModel)]="entry[1]" (change)="inputChange()" [disabled]="disabled" autocomplete="off">
|
||||
<button type="button" class="btn btn-outline-secondary" (click)="removeEntry(i)">
|
||||
<button type="button" class="btn btn-outline-secondary" (click)="removeEntry(i)" aria-label="Remove entry" i18n-aria-label>
|
||||
<i-bs class="text-danger" name="trash"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -36,12 +36,21 @@
|
||||
(focus)="clearLastSearchTerm()"
|
||||
(clear)="clearLastSearchTerm()"
|
||||
(blur)="onBlur()">
|
||||
<ng-template ng-label-tmp let-item="item">
|
||||
@if (iconField && item[iconField]) {
|
||||
<i-bs class="me-2" [name]="item[iconField]"></i-bs>
|
||||
}
|
||||
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
|
||||
</ng-template>
|
||||
<ng-template ng-option-tmp let-item="item">
|
||||
@if (iconField && item[iconField]) {
|
||||
<i-bs class="me-2" [name]="item[iconField]"></i-bs>
|
||||
}
|
||||
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
|
||||
</ng-template>
|
||||
</ng-select>
|
||||
@if (allowCreateNew && !hideAddButton) {
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="addItem()" [disabled]="disabled">
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="addItem()" [disabled]="disabled" aria-label="Create new item" i18n-aria-label>
|
||||
<i-bs width="1.2em" height="1.2em" name="plus"></i-bs>
|
||||
</button>
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ import { AbstractInputComponent } from '../abstract-input'
|
||||
NgxBootstrapIconsModule,
|
||||
],
|
||||
})
|
||||
export class SelectComponent extends AbstractInputComponent<number> {
|
||||
export class SelectComponent extends AbstractInputComponent<number | string> {
|
||||
constructor() {
|
||||
super()
|
||||
this.addItemRef = this.addItem.bind(this)
|
||||
@@ -100,6 +100,9 @@ export class SelectComponent extends AbstractInputComponent<number> {
|
||||
@Input()
|
||||
bindLabel: string = 'name'
|
||||
|
||||
@Input()
|
||||
iconField: string
|
||||
|
||||
public searchFn = (term: string, item: any): boolean =>
|
||||
matchesSearchText(item?.[this.bindLabel], term)
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
</ng-template>
|
||||
</ng-select>
|
||||
@if (allowCreate && !hideAddButton) {
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="createTag(null, true)" [disabled]="disabled">
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="createTag(null, true)" [disabled]="disabled" aria-label="Create new tag" i18n-aria-label>
|
||||
<i-bs width="1.2em" height="1.2em" name="plus"></i-bs>
|
||||
</button>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg class="w-100 h-100 d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" fill="currentColor" aria-hidden="true">
|
||||
<path d="M341,949.1c-6.9-20.3-20.7-61.2-21.9-61-199.6-88.9-182.5-229.8-134.3-347.5,30,137.2,268.8,148.9,146.2,336-.9,2.2,10,27.8,19.5,51.3,22.7-51.9,58.6-115.5,55.8-120.8C178,398.7,724.9,299,807.1,18.5c83,251.5,53.1,659.8-377.4,814.9-2,1.4-63.5,148.6-66.9,150.2-.2-2.1-33.2,2.9-30.1-8.7,1.6-7,4.8-16.2,8.2-25.6h0v-.2h.1ZM323.1,846.2c48.3-71.9-12.7-120.8-56.9-152.2,81.2,107.4,66.4,120.8,56.9,152.2h0Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 545 B |
@@ -0,0 +1,14 @@
|
||||
import { Component, input } from '@angular/core'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-brand-mark',
|
||||
templateUrl: './brand-mark.component.html',
|
||||
host: {
|
||||
'[style.width]': 'width()',
|
||||
'[style.height]': 'height()',
|
||||
},
|
||||
})
|
||||
export class BrandMarkComponent {
|
||||
readonly width = input<string>(null)
|
||||
readonly height = input<string>(null)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="row pt-3 pb-3 pb-md-2 align-items-center">
|
||||
<div class="row pt-3 pb-2 align-items-center">
|
||||
<div class="col-md text-truncate">
|
||||
<h3 class="d-flex align-items-center mb-1" style="line-height: 1.4">
|
||||
<h3 class="d-flex align-items-center mb-2 mb-md-1" style="line-height: 1.4">
|
||||
<span class="text-truncate">{{title()}}</span>
|
||||
@if (id()) {
|
||||
<span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyID()">
|
||||
@@ -12,10 +12,10 @@
|
||||
</span>
|
||||
}
|
||||
@if (subTitle()) {
|
||||
<span class="h6 mb-0 mt-1 d-block d-md-inline fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle()}}</span>
|
||||
<span class="page-subtitle h6 mb-0 mt-1 fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle()}}</span>
|
||||
}
|
||||
@if (info()) {
|
||||
<button class="btn btn-sm btn-link text-muted p-0 p-md-2" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true">
|
||||
<button class="btn btn-sm btn-link text-muted p-0 ms-2 p-md-2 ms-md-0" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true">
|
||||
<i-bs name="question-circle"></i-bs>
|
||||
</button>
|
||||
<ng-template #infoPopover>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
h3 {
|
||||
min-height: calc(1.325rem + 0.9vw);
|
||||
flex-wrap: wrap;
|
||||
|
||||
.badge {
|
||||
font-size: 0.65rem;
|
||||
@@ -7,6 +8,25 @@ h3 {
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the subtitle onto its own and shrink it
|
||||
@media (max-width: 767.98px) {
|
||||
h3 > .page-subtitle {
|
||||
flex: 0 0 100%;
|
||||
margin-top: 0 !important;
|
||||
font-size: .7rem;
|
||||
line-height: 1.3 !important;
|
||||
}
|
||||
}
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
margin-bottom: .35rem;
|
||||
}
|
||||
|
||||
h3 > .h6 {
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
h3 {
|
||||
min-height: 2.8rem;
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
</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 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>
|
||||
|
||||
@@ -151,6 +151,13 @@
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
|
||||
& section {
|
||||
position: absolute;
|
||||
text-align: initial;
|
||||
box-sizing: border-box;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
& .annotationTextContent {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ describe('PngxPdfViewerComponent', () => {
|
||||
}
|
||||
expect(viewer).toBeInstanceOf(PDFSinglePageViewer)
|
||||
expect(viewer.options.textLayerMode).toBe(0)
|
||||
expect(viewer.options.enableSelectionRendering).toBe(false)
|
||||
})
|
||||
|
||||
it('applies zoom, rotation, and page changes', async () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ViewChild,
|
||||
} from '@angular/core'
|
||||
import {
|
||||
AnnotationMode,
|
||||
getDocument,
|
||||
GlobalWorkerOptions,
|
||||
PDFDocumentLoadingTask,
|
||||
@@ -221,6 +222,8 @@ export class PngxPdfViewerComponent
|
||||
linkService: this.linkService,
|
||||
findController: this.findController,
|
||||
textLayerMode,
|
||||
annotationMode: AnnotationMode.ENABLE,
|
||||
enableSelectionRendering: false,
|
||||
removePageBorders: true,
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -1,5 +1,5 @@
|
||||
<div class="btn-group w-100" ngbDropdown role="group">
|
||||
<button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="isActive ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
|
||||
<button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="isActive ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled" [aria-label]="title">
|
||||
<i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
|
||||
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
|
||||
</button>
|
||||
@@ -22,7 +22,7 @@
|
||||
}
|
||||
</div>
|
||||
<div class="me-1">
|
||||
<small i18n>My documents</small>
|
||||
<small>{{ownerFilterLabel}}</small>
|
||||
</div>
|
||||
</button>
|
||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NOT_SELF)" [disabled]="disabled">
|
||||
@@ -32,7 +32,7 @@
|
||||
}
|
||||
</div>
|
||||
<div class="me-1">
|
||||
<small i18n>Shared with me</small>
|
||||
<small>{{ownerExclusionFilterLabel}}</small>
|
||||
</div>
|
||||
</button>
|
||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SHARED_BY_ME)" [disabled]="disabled">
|
||||
@@ -42,7 +42,7 @@
|
||||
}
|
||||
</div>
|
||||
<div class="me-1">
|
||||
<small i18n>Shared by me</small>
|
||||
<small>{{sharedByFilterLabel}}</small>
|
||||
</div>
|
||||
</button>
|
||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.UNOWNED)" [disabled]="disabled">
|
||||
|
||||
+52
@@ -94,6 +94,58 @@ describe('PermissionsFilterDropdownComponent', () => {
|
||||
expect(component.isActive).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should describe concrete user filters honestly', () => {
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.SELF
|
||||
component.selectionModel.userID = 1
|
||||
expect(component.ownerFilterLabel).toEqual('Owned by user1')
|
||||
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
|
||||
component.selectionModel.excludeUsers = [1]
|
||||
expect(component.ownerExclusionFilterLabel).toEqual('Not owned by user1')
|
||||
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME
|
||||
component.selectionModel.userID = 1
|
||||
expect(component.sharedByFilterLabel).toEqual('Shared by user1')
|
||||
})
|
||||
|
||||
it('should describe concrete filters when usernames are unavailable', () => {
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.SELF
|
||||
component.selectionModel.userID = 99
|
||||
expect(component.ownerFilterLabel).toEqual('Owned by another user')
|
||||
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
|
||||
component.selectionModel.excludeUsers = [99]
|
||||
expect(component.ownerExclusionFilterLabel).toEqual(
|
||||
'Not owned by another user'
|
||||
)
|
||||
|
||||
component.selectionModel.excludeUsers = [98, 99]
|
||||
expect(component.ownerExclusionFilterLabel).toEqual(
|
||||
'Not owned by selected users'
|
||||
)
|
||||
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME
|
||||
component.selectionModel.userID = 99
|
||||
expect(component.sharedByFilterLabel).toEqual('Shared by another user')
|
||||
})
|
||||
|
||||
it('should retain relative labels for filters bound to the current user', () => {
|
||||
component.selectionModel.userID = currentUserID
|
||||
expect(component.ownerFilterLabel).toEqual('My documents')
|
||||
expect(component.sharedByFilterLabel).toEqual('Shared by me')
|
||||
|
||||
component.selectionModel.excludeUsers = [currentUserID]
|
||||
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
|
||||
})
|
||||
|
||||
it('should retain relative labels for inactive filter choices', () => {
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.NONE
|
||||
|
||||
expect(component.ownerFilterLabel).toEqual('My documents')
|
||||
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
|
||||
expect(component.sharedByFilterLabel).toEqual('Shared by me')
|
||||
})
|
||||
|
||||
it('should support reset', () => {
|
||||
component.setFilter(OwnerFilterType.OTHERS)
|
||||
expect(component.selectionModel.ownerFilter).not.toEqual(
|
||||
|
||||
+53
@@ -93,6 +93,55 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
||||
)
|
||||
}
|
||||
|
||||
get ownerFilterLabel(): string {
|
||||
if (
|
||||
this.selectionModel?.ownerFilter !== OwnerFilterType.SELF ||
|
||||
this.selectionModel?.userID === this.settingsService.currentUser()?.id
|
||||
) {
|
||||
return $localize`My documents`
|
||||
}
|
||||
|
||||
const username = this.getUsername(this.selectionModel?.userID)
|
||||
return username
|
||||
? $localize`Owned by ${username}`
|
||||
: $localize`Owned by another user`
|
||||
}
|
||||
|
||||
get ownerExclusionFilterLabel(): string {
|
||||
const excludedUsers = this.selectionModel?.excludeUsers ?? []
|
||||
if (
|
||||
this.selectionModel?.ownerFilter !== OwnerFilterType.NOT_SELF ||
|
||||
(excludedUsers.length === 1 &&
|
||||
excludedUsers[0] === this.settingsService.currentUser()?.id)
|
||||
) {
|
||||
return $localize`Shared with me`
|
||||
}
|
||||
|
||||
const usernames = excludedUsers
|
||||
.map((id) => this.getUsername(id))
|
||||
.filter(Boolean)
|
||||
if (usernames.length === excludedUsers.length && usernames.length > 0) {
|
||||
return $localize`Not owned by ${usernames.join(', ')}`
|
||||
}
|
||||
return excludedUsers.length === 1
|
||||
? $localize`Not owned by another user`
|
||||
: $localize`Not owned by selected users`
|
||||
}
|
||||
|
||||
get sharedByFilterLabel(): string {
|
||||
if (
|
||||
this.selectionModel?.ownerFilter !== OwnerFilterType.SHARED_BY_ME ||
|
||||
this.selectionModel?.userID === this.settingsService.currentUser()?.id
|
||||
) {
|
||||
return $localize`Shared by me`
|
||||
}
|
||||
|
||||
const username = this.getUsername(this.selectionModel?.userID)
|
||||
return username
|
||||
? $localize`Shared by ${username}`
|
||||
: $localize`Shared by another user`
|
||||
}
|
||||
|
||||
constructor() {
|
||||
const userService = inject(UserService)
|
||||
|
||||
@@ -164,4 +213,8 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
||||
}
|
||||
this.onChange()
|
||||
}
|
||||
|
||||
private getUsername(userID: number): string {
|
||||
return this.users().find((user) => user.id === userID)?.username
|
||||
}
|
||||
}
|
||||
|
||||
+28
@@ -17,6 +17,10 @@ const permissions = [
|
||||
'view_document',
|
||||
'change_document',
|
||||
'delete_document',
|
||||
'add_sharelinkbundle',
|
||||
'view_sharelinkbundle',
|
||||
'change_sharelinkbundle',
|
||||
'delete_sharelinkbundle',
|
||||
'change_tag',
|
||||
'view_documenttype',
|
||||
]
|
||||
@@ -75,6 +79,7 @@ describe('PermissionsSelectComponent', () => {
|
||||
component.ngOnInit()
|
||||
component.writeValue(permissions)
|
||||
expect(component.typesWithAllActions).toContain('Document')
|
||||
expect(component.typesWithAllActions).toContain('ShareLinkBundle')
|
||||
})
|
||||
|
||||
it('should update checkboxes on permissions set', () => {
|
||||
@@ -85,6 +90,10 @@ describe('PermissionsSelectComponent', () => {
|
||||
expect(input1.nativeElement.checked).toBeTruthy()
|
||||
const input2 = fixture.debugElement.query(By.css('input#Tag_Change'))
|
||||
expect(input2.nativeElement.checked).toBeTruthy()
|
||||
const bundleInput = fixture.debugElement.query(
|
||||
By.css('input#ShareLinkBundle_Add')
|
||||
)
|
||||
expect(bundleInput.nativeElement.checked).toBeTruthy()
|
||||
})
|
||||
|
||||
it('disable checkboxes when permissions are inherited', () => {
|
||||
@@ -98,6 +107,25 @@ describe('PermissionsSelectComponent', () => {
|
||||
expect(component.form.get('Tag').get('Change').disabled).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should update checkboxes when inherited permissions change', () => {
|
||||
component.ngOnInit()
|
||||
component.inheritedPermissions = ['documents.change_document']
|
||||
component.writeValue(['delete_document'])
|
||||
expect(component.form.get('Document').get('Change').value).toBeTruthy()
|
||||
expect(component.form.get('Document').get('Change').disabled).toBeTruthy()
|
||||
|
||||
// swap for a group with a different permission, but the same number of them
|
||||
component.inheritedPermissions = ['documents.view_document']
|
||||
|
||||
// the no-longer-inherited permission is unchecked, the explicit one is kept
|
||||
expect(component.permissions).toEqual(['delete_document'])
|
||||
expect(component.form.get('Document').get('Change').value).toBeFalsy()
|
||||
expect(component.form.get('Document').get('Change').disabled).toBeFalsy()
|
||||
expect(component.form.get('Document').get('Delete').value).toBeTruthy()
|
||||
expect(component.form.get('Document').get('View').value).toBeTruthy()
|
||||
expect(component.form.get('Document').get('View').disabled).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should exclude history permissions if disabled', () => {
|
||||
settingsService.set(SETTINGS_KEYS.AUDITLOG_ENABLED, false)
|
||||
fixture = TestBed.createComponent(PermissionsSelectComponent)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user