Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dde9b6d25 | ||
|
|
5c5b1ee6b5 | ||
|
|
08f2f4bfe2 | ||
|
|
f993462973 | ||
|
|
ae70b8d60f | ||
|
|
38db6b51db | ||
|
|
31e9f4272c | ||
|
|
b8659c1af3 | ||
|
|
741115b36b | ||
|
|
1211db5cbb | ||
|
|
ca98dffbd2 | ||
|
|
4db1451e41 | ||
|
|
624b7911e5 | ||
|
|
5a1a5333ad | ||
|
|
bfe8213b78 | ||
|
|
85e192f935 | ||
|
|
4fcd4961bb | ||
|
|
5d9401ac4a | ||
|
|
6935defe7c | ||
|
|
440049978b | ||
|
|
06e9c1c02b | ||
|
|
40d09ef309 | ||
|
|
a4499dc9c1 | ||
|
|
ba017ce5b8 | ||
|
|
fd543f2bff | ||
|
|
e0e060f089 | ||
|
|
a7ff3a8272 | ||
|
|
b20d707f2a | ||
|
|
fa6e481224 | ||
|
|
aba32fbd57 | ||
|
|
d371085699 | ||
|
|
14b6a41b88 | ||
|
|
8af3e69084 | ||
|
|
1d23a9550c | ||
|
|
b89fb0f978 | ||
|
|
535975e2fd | ||
|
|
9c475e0b27 | ||
|
|
7ddc1c9801 | ||
|
|
6f3945f11f | ||
|
|
a784a642ee | ||
|
|
8ded82ea23 | ||
|
|
3a5312ba6f | ||
|
|
00d9bf474a | ||
|
|
8079690f33 | ||
|
|
c6252a0234 | ||
|
|
4a69c47bdd | ||
|
|
d30ee1d620 | ||
|
|
e1aa9997d7 | ||
|
|
05917a04aa | ||
|
|
02b2f55248 | ||
|
|
a2ea3b9e76 | ||
|
|
a298568962 | ||
|
|
f6f37898e8 | ||
|
|
0cfe33720a | ||
|
|
14b17ddf8d | ||
|
|
9009fbb4e9 | ||
|
|
94651d817d | ||
|
|
1630e78aac | ||
|
|
4e79553489 | ||
|
|
f24d58a383 | ||
|
|
d486dd1205 | ||
|
|
406bc1a233 | ||
|
|
808746c28c | ||
|
|
ff6d73ce17 | ||
|
|
b4068d38bc | ||
|
|
139739c2a2 | ||
|
|
cafed919a1 | ||
|
|
549afde1fd | ||
|
|
a8576d827e | ||
|
|
7330c4d9cb | ||
|
|
aad75b4981 | ||
|
|
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 | ||
|
|
2d084983c8 | ||
|
|
e2c284f64e |
@@ -68,7 +68,7 @@ services:
|
|||||||
- "--chromium-disable-javascript=true"
|
- "--chromium-disable-javascript=true"
|
||||||
- "--chromium-allow-list=file:///tmp/.*"
|
- "--chromium-allow-list=file:///tmp/.*"
|
||||||
tika:
|
tika:
|
||||||
image: docker.io/apache/tika:latest
|
image: docker.io/apache/tika:3.3.1.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
data:
|
data:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ concurrency:
|
|||||||
group: backend-${{ github.event.pull_request.number || github.ref }}
|
group: backend-${{ github.event.pull_request.number || github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
env:
|
env:
|
||||||
DEFAULT_UV_VERSION: "0.11.x"
|
DEFAULT_UV_VERSION: "0.12.x"
|
||||||
NLTK_DATA: "/usr/share/nltk_data"
|
NLTK_DATA: "/usr/share/nltk_data"
|
||||||
permissions: {}
|
permissions: {}
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ concurrency:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
env:
|
env:
|
||||||
DEFAULT_UV_VERSION: "0.11.x"
|
DEFAULT_UV_VERSION: "0.12.x"
|
||||||
DEFAULT_PYTHON_VERSION: "3.12"
|
DEFAULT_PYTHON_VERSION: "3.12"
|
||||||
jobs:
|
jobs:
|
||||||
changes:
|
changes:
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ jobs:
|
|||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
with:
|
with:
|
||||||
version: 10
|
package_json_file: src-ui/package.json
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
@@ -115,7 +115,7 @@ jobs:
|
|||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
with:
|
with:
|
||||||
version: 10
|
package_json_file: src-ui/package.json
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
@@ -154,7 +154,7 @@ jobs:
|
|||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
with:
|
with:
|
||||||
version: 10
|
package_json_file: src-ui/package.json
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
@@ -185,22 +185,16 @@ jobs:
|
|||||||
flags: frontend-node-${{ matrix.node-version }}
|
flags: frontend-node-${{ matrix.node-version }}
|
||||||
directory: src-ui/coverage/
|
directory: src-ui/coverage/
|
||||||
e2e-tests:
|
e2e-tests:
|
||||||
name: "E2E Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})"
|
name: E2E Tests
|
||||||
needs: [changes, install-dependencies]
|
needs: [changes, install-dependencies]
|
||||||
if: needs.changes.outputs.frontend_changed == 'true'
|
if: needs.changes.outputs.frontend_changed == 'true'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
container: mcr.microsoft.com/playwright:v1.62.0-noble
|
container: mcr.microsoft.com/playwright:v1.62.1-noble
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
|
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
|
||||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
|
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
node-version: [24.x]
|
|
||||||
shard-index: [1, 2]
|
|
||||||
shard-count: [2]
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
@@ -209,13 +203,24 @@ jobs:
|
|||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
with:
|
with:
|
||||||
version: 10
|
package_json_file: src-ui/package.json
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
|
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
|
||||||
|
- name: Set up Python
|
||||||
|
id: setup-python
|
||||||
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||||
|
with:
|
||||||
|
version: '0.12.x'
|
||||||
|
enable-cache: false
|
||||||
|
python-version: ${{ steps.setup-python.outputs.python-version }}
|
||||||
- name: Cache frontend dependencies
|
- name: Cache frontend dependencies
|
||||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
with:
|
with:
|
||||||
@@ -225,8 +230,17 @@ jobs:
|
|||||||
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: cd src-ui && pnpm install --frozen-lockfile
|
run: cd src-ui && pnpm install --frozen-lockfile
|
||||||
|
- name: Install backend system dependencies
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install --yes --quiet --no-install-recommends libmagic1
|
||||||
|
- name: Install backend dependencies
|
||||||
|
env:
|
||||||
|
PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }}
|
||||||
|
# The frozen repository lockfile and its build hooks are trusted.
|
||||||
|
run: uv sync --python "${PYTHON_VERSION}" --no-dev --frozen # NOSONAR
|
||||||
- name: Run Playwright E2E tests
|
- name: Run Playwright E2E tests
|
||||||
run: cd src-ui && pnpm exec playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
run: cd src-ui && pnpm exec playwright test
|
||||||
frontend-build:
|
frontend-build:
|
||||||
name: Frontend Build
|
name: Frontend Build
|
||||||
needs: [changes, unit-tests, e2e-tests]
|
needs: [changes, unit-tests, e2e-tests]
|
||||||
@@ -243,7 +257,7 @@ jobs:
|
|||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
with:
|
with:
|
||||||
version: 10
|
package_json_file: src-ui/package.json
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ concurrency:
|
|||||||
group: release-${{ github.ref }}
|
group: release-${{ github.ref }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
env:
|
env:
|
||||||
DEFAULT_UV_VERSION: "0.11.x"
|
DEFAULT_UV_VERSION: "0.12.x"
|
||||||
DEFAULT_PYTHON_VERSION: "3.12"
|
DEFAULT_PYTHON_VERSION: "3.12"
|
||||||
permissions: {}
|
permissions: {}
|
||||||
jobs:
|
jobs:
|
||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
with:
|
with:
|
||||||
version: 10
|
package_json_file: src-ui/package.json
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- dev
|
- dev
|
||||||
env:
|
env:
|
||||||
DEFAULT_UV_VERSION: "0.11.x"
|
DEFAULT_UV_VERSION: "0.12.x"
|
||||||
jobs:
|
jobs:
|
||||||
generate-translate-strings:
|
generate-translate-strings:
|
||||||
name: Generate Translation Strings
|
name: Generate Translation Strings
|
||||||
@@ -45,7 +45,7 @@ jobs:
|
|||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
with:
|
with:
|
||||||
version: 10
|
package_json_file: src-ui/package.json
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ RUN set -eux \
|
|||||||
# Purpose: Installs s6-overlay and rootfs
|
# Purpose: Installs s6-overlay and rootfs
|
||||||
# Comments:
|
# Comments:
|
||||||
# - Don't leave anything extra in here either
|
# - Don't leave anything extra in here either
|
||||||
FROM ghcr.io/astral-sh/uv:0.11.32-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
|
WORKDIR /usr/src/s6
|
||||||
|
|
||||||
|
|||||||
@@ -59,10 +59,13 @@ The following are not generally considered vulnerabilities unless accompanied by
|
|||||||
- large uploads or resource usage that do not bypass documented limits or privileges
|
- large uploads or resource usage that do not bypass documented limits or privileges
|
||||||
- IDOR / access control claims regarding the ability to attach an un-viewable object to a document. This is expected behavior.
|
- IDOR / access control claims regarding the ability to attach an un-viewable object to a document. This is expected behavior.
|
||||||
- claims based solely on the presence of a library, framework feature or code pattern without a working exploit
|
- claims based solely on the presence of a library, framework feature or code pattern without a working exploit
|
||||||
- reports that rely on admin-level access, workflow-editing privileges, shell access, or other high-trust roles unless they demonstrate an unintended privilege boundary bypass
|
- pickle deserialization of internal data from trusted components such as the Redis-compatible broker or Paperless-ngx data directory
|
||||||
|
- users with permission to edit users granting themselves additional privileges; this is expected behavior for that trusted permission
|
||||||
|
- reports that rely on admin-level access, application-configuration access, workflow-editing privileges, shell access, or other high-trust roles unless they demonstrate an unintended privilege boundary bypass
|
||||||
- optional webhook, mail, AI, OCR, or integration behavior described without a product-level vulnerability
|
- optional webhook, mail, AI, OCR, or integration behavior described without a product-level vulnerability
|
||||||
- missing limits or hardening settings presented without concrete impact
|
- missing limits or hardening settings presented without concrete impact
|
||||||
- generic AI or static-analysis output that is not confirmed against the current codebase and a real deployment scenario
|
- generic AI or static-analysis output that is not confirmed against the current codebase and a real deployment scenario
|
||||||
|
- metadata names visible in a document's custom storage path, even when the user cannot access the underlying metadata object; this is expected behavior
|
||||||
- the ability to attach objects that a user cannot access to a document by ID is an intentional design choice, and not considered a vulnerability
|
- the ability to attach objects that a user cannot access to a document by ID is an intentional design choice, and not considered a vulnerability
|
||||||
|
|
||||||
## Transparency
|
## Transparency
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ services:
|
|||||||
- "--chromium-disable-javascript=true"
|
- "--chromium-disable-javascript=true"
|
||||||
- "--chromium-allow-list=file:///tmp/.*"
|
- "--chromium-allow-list=file:///tmp/.*"
|
||||||
tika:
|
tika:
|
||||||
image: docker.io/apache/tika:latest
|
image: docker.io/apache/tika:3.3.1.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
data:
|
data:
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ services:
|
|||||||
- "--chromium-disable-javascript=true"
|
- "--chromium-disable-javascript=true"
|
||||||
- "--chromium-allow-list=file:///tmp/.*"
|
- "--chromium-allow-list=file:///tmp/.*"
|
||||||
tika:
|
tika:
|
||||||
image: docker.io/apache/tika:latest
|
image: docker.io/apache/tika:3.3.1.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
data:
|
data:
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ services:
|
|||||||
- "--chromium-disable-javascript=true"
|
- "--chromium-disable-javascript=true"
|
||||||
- "--chromium-allow-list=file:///tmp/.*"
|
- "--chromium-allow-list=file:///tmp/.*"
|
||||||
tika:
|
tika:
|
||||||
image: docker.io/apache/tika:latest
|
image: docker.io/apache/tika:3.3.1.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
data:
|
data:
|
||||||
|
|||||||
@@ -68,7 +68,14 @@
|
|||||||
<!-- <policy domain="resource" name="thread" value="4"/> -->
|
<!-- <policy domain="resource" name="thread" value="4"/> -->
|
||||||
<!-- <policy domain="resource" name="throttle" value="0"/> -->
|
<!-- <policy domain="resource" name="throttle" value="0"/> -->
|
||||||
<!-- <policy domain="resource" name="time" value="3600"/> -->
|
<!-- <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="module" rights="none" pattern="{PS,PDF,XPS}" /> -->
|
||||||
<!-- <policy domain="delegate" rights="none" pattern="HTTPS" /> -->
|
<!-- <policy domain="delegate" rights="none" pattern="HTTPS" /> -->
|
||||||
<!-- <policy domain="path" rights="none" pattern="@*" /> -->
|
<!-- <policy domain="path" rights="none" pattern="@*" /> -->
|
||||||
@@ -78,8 +85,6 @@
|
|||||||
<!-- <policy domain="system" name="pixel-cache-memory" value="anonymous"/> -->
|
<!-- <policy domain="system" name="pixel-cache-memory" value="anonymous"/> -->
|
||||||
<!-- <policy domain="system" name="shred" value="2"/> -->
|
<!-- <policy domain="system" name="shred" value="2"/> -->
|
||||||
<!-- <policy domain="system" name="precision" value="6"/> -->
|
<!-- <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 -->
|
<!-- use curl -->
|
||||||
<policy domain="delegate" rights="none" pattern="URL" />
|
<policy domain="delegate" rights="none" pattern="URL" />
|
||||||
<policy domain="delegate" rights="none" pattern="HTTPS" />
|
<policy domain="delegate" rights="none" pattern="HTTPS" />
|
||||||
|
|||||||
@@ -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
|
and/or [`PAPERLESS_AI_LLM_ENDPOINT`](configuration.md#PAPERLESS_AI_LLM_ENDPOINT). Ollama
|
||||||
requires `PAPERLESS_AI_LLM_ENDPOINT` pointing at your Ollama server.
|
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
|
### AI-assisted suggestions
|
||||||
|
|
||||||
With AI enabled, Paperless-ngx can suggest a title, tags, correspondent, document type,
|
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
|
formats. A plugin is a Python package that advertises itself under the
|
||||||
`paperless_ngx.parsers` entry point group. Refer to the
|
`paperless_ngx.parsers` entry point group. Refer to the
|
||||||
[developer documentation](development.md#making-custom-parsers) for how to
|
[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"
|
!!! warning "Third-party plugins are not officially supported"
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- `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}`.
|
- `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/{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.
|
- `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.
|
- `DELETE /api/documents/{root_id}/versions/{version_id}/`: deletes a non-root version.
|
||||||
|
|
||||||
@@ -301,7 +302,8 @@ The following methods are supported:
|
|||||||
- `delete`
|
- `delete`
|
||||||
- No `parameters` required
|
- No `parameters` required
|
||||||
- `reprocess`
|
- `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`
|
- `set_permissions`
|
||||||
- Requires `parameters`:
|
- Requires `parameters`:
|
||||||
- `"set_permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or
|
- `"set_permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 501 KiB After Width: | Height: | Size: 487 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 558 KiB |
|
Before Width: | Height: | Size: 644 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 667 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1003 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 925 KiB After Width: | Height: | Size: 972 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 558 KiB |
|
Before Width: | Height: | Size: 726 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 294 KiB |
|
Before Width: | Height: | Size: 432 KiB After Width: | Height: | Size: 298 KiB |
|
Before Width: | Height: | Size: 280 KiB After Width: | Height: | Size: 322 KiB |
|
Before Width: | Height: | Size: 246 KiB After Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 559 KiB After Width: | Height: | Size: 516 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 333 KiB |
|
Before Width: | Height: | Size: 792 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 291 KiB |
@@ -1,5 +1,277 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## paperless-ngx 3.1.2
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: fix dark mode select disabled color, ensure disabled cursor on display mode dropdown [@shamoon](https://github.com/shamoon) ([#13881](https://github.com/paperless-ngx/paperless-ngx/pull/13881))
|
||||||
|
- Fix: add disable to the drag-drop list component [@shamoon](https://github.com/shamoon) ([#13880](https://github.com/paperless-ngx/paperless-ngx/pull/13880))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Chore: update screenshots for v3+ [@shamoon](https://github.com/shamoon) ([#13883](https://github.com/paperless-ngx/paperless-ngx/pull/13883))
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>2 changes</summary>
|
||||||
|
|
||||||
|
- Fix: fix dark mode select disabled color, ensure disabled cursor on display mode dropdown [@shamoon](https://github.com/shamoon) ([#13881](https://github.com/paperless-ngx/paperless-ngx/pull/13881))
|
||||||
|
- Fix: add disable to the drag-drop list component [@shamoon](https://github.com/shamoon) ([#13880](https://github.com/paperless-ngx/paperless-ngx/pull/13880))
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## paperless-ngx 3.1.1
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: 3.1.0 llm suggestions remove existing metadata from prompt, dont drop name suggestions [@shamoon](https://github.com/shamoon) ([#13866](https://github.com/paperless-ngx/paperless-ngx/pull/13866))
|
||||||
|
- Fix: set global search earlier to avoid awaiting debounce [@shamoon](https://github.com/shamoon) ([#13865](https://github.com/paperless-ngx/paperless-ngx/pull/13865))
|
||||||
|
- Fix: responsive sidebar, centralize and make sizes saner [@shamoon](https://github.com/shamoon) ([#13863](https://github.com/paperless-ngx/paperless-ngx/pull/13863))
|
||||||
|
- Tweak/fix: show existing count for ai suggestions [@shamoon](https://github.com/shamoon) ([#13861](https://github.com/paperless-ngx/paperless-ngx/pull/13861))
|
||||||
|
- Fix: 3.1.0 llm suggestions simplify schema, fix docstrings [@shamoon](https://github.com/shamoon) ([#13850](https://github.com/paperless-ngx/paperless-ngx/pull/13850))
|
||||||
|
- Fix: ensure ui reset of suggestionsLoading when changing docs [@shamoon](https://github.com/shamoon) ([#13840](https://github.com/paperless-ngx/paperless-ngx/pull/13840))
|
||||||
|
- Fix: always pass a non-empty api key for OpenAI-like servers [@shamoon](https://github.com/shamoon) ([#13838](https://github.com/paperless-ngx/paperless-ngx/pull/13838))
|
||||||
|
- Fix: hide slim sidebar scrollbar in browsers with stupid scrollbars [@shamoon](https://github.com/shamoon) ([#13837](https://github.com/paperless-ngx/paperless-ngx/pull/13837))
|
||||||
|
- Fix: correct sharelink bundle + document link permissions display bugs [@shamoon](https://github.com/shamoon) ([#13827](https://github.com/paperless-ngx/paperless-ngx/pull/13827))
|
||||||
|
- Fix: immediately re-add doc to index after trash restore [@shamoon](https://github.com/shamoon) ([#13818](https://github.com/paperless-ngx/paperless-ngx/pull/13818))
|
||||||
|
- Fix: navbar brand anchor size + Safari position jitter [@shamoon](https://github.com/shamoon) ([#13810](https://github.com/paperless-ngx/paperless-ngx/pull/13810))
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>12 changes</summary>
|
||||||
|
|
||||||
|
- Fix: 3.1.0 llm suggestions remove existing metadata from prompt, dont drop name suggestions [@shamoon](https://github.com/shamoon) ([#13866](https://github.com/paperless-ngx/paperless-ngx/pull/13866))
|
||||||
|
- Fix: set global search earlier to avoid awaiting debounce [@shamoon](https://github.com/shamoon) ([#13865](https://github.com/paperless-ngx/paperless-ngx/pull/13865))
|
||||||
|
- Fix: responsive sidebar, centralize and make sizes saner [@shamoon](https://github.com/shamoon) ([#13863](https://github.com/paperless-ngx/paperless-ngx/pull/13863))
|
||||||
|
- Tweak/fix: show existing count for ai suggestions [@shamoon](https://github.com/shamoon) ([#13861](https://github.com/paperless-ngx/paperless-ngx/pull/13861))
|
||||||
|
- Fix: 3.1.0 llm suggestions simplify schema, fix docstrings [@shamoon](https://github.com/shamoon) ([#13850](https://github.com/paperless-ngx/paperless-ngx/pull/13850))
|
||||||
|
- Fixhancement: make imap port required, better error display [@shamoon](https://github.com/shamoon) ([#13845](https://github.com/paperless-ngx/paperless-ngx/pull/13845))
|
||||||
|
- Fix: ensure ui reset of suggestionsLoading when changing docs [@shamoon](https://github.com/shamoon) ([#13840](https://github.com/paperless-ngx/paperless-ngx/pull/13840))
|
||||||
|
- Fix: always pass a non-empty api key for OpenAI-like servers [@shamoon](https://github.com/shamoon) ([#13838](https://github.com/paperless-ngx/paperless-ngx/pull/13838))
|
||||||
|
- Fix: hide slim sidebar scrollbar in browsers with stupid scrollbars [@shamoon](https://github.com/shamoon) ([#13837](https://github.com/paperless-ngx/paperless-ngx/pull/13837))
|
||||||
|
- Fix: correct sharelink bundle + document link permissions display bugs [@shamoon](https://github.com/shamoon) ([#13827](https://github.com/paperless-ngx/paperless-ngx/pull/13827))
|
||||||
|
- Fix: immediately re-add doc to index after trash restore [@shamoon](https://github.com/shamoon) ([#13818](https://github.com/paperless-ngx/paperless-ngx/pull/13818))
|
||||||
|
- Fix: navbar brand anchor size + Safari position jitter [@shamoon](https://github.com/shamoon) ([#13810](https://github.com/paperless-ngx/paperless-ngx/pull/13810))
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## paperless-ngx 3.1.0
|
||||||
|
|
||||||
|
### Features / Enhancements
|
||||||
|
|
||||||
|
- Enhancement: Apply AI suggestions workflow action [@shamoon](https://github.com/shamoon) ([#13639](https://github.com/paperless-ngx/paperless-ngx/pull/13639))
|
||||||
|
- Enhancement: welcome widget visual tweaks [@shamoon](https://github.com/shamoon) ([#13794](https://github.com/paperless-ngx/paperless-ngx/pull/13794))
|
||||||
|
- Enhancement: support using remote OCR engines selectively [@shamoon](https://github.com/shamoon) ([#13633](https://github.com/paperless-ngx/paperless-ngx/pull/13633))
|
||||||
|
- Enhancement: websocket heartbeat [@oktupol](https://github.com/oktupol) ([#13739](https://github.com/paperless-ngx/paperless-ngx/pull/13739))
|
||||||
|
- Enhancement: more v3 ui tweaks [@shamoon](https://github.com/shamoon) ([#13774](https://github.com/paperless-ngx/paperless-ngx/pull/13774))
|
||||||
|
- Tweak: better support long list of views in documents list [@shamoon](https://github.com/shamoon) ([#13769](https://github.com/paperless-ngx/paperless-ngx/pull/13769))
|
||||||
|
- QoL: add count badge to versions dropdown [@shamoon](https://github.com/shamoon) ([#13753](https://github.com/paperless-ngx/paperless-ngx/pull/13753))
|
||||||
|
- Tweakhancement: add jitter to IMAP polling schedule [@shamoon](https://github.com/shamoon) ([#13734](https://github.com/paperless-ngx/paperless-ngx/pull/13734))
|
||||||
|
- Enhancement: sync OIDC groups to superuser and staff roles [@BeSovereign](https://github.com/BeSovereign) ([#13060](https://github.com/paperless-ngx/paperless-ngx/pull/13060))
|
||||||
|
- Enhancement: merge documents as versions [@shamoon](https://github.com/shamoon) ([#13515](https://github.com/paperless-ngx/paperless-ngx/pull/13515))
|
||||||
|
- Tweak: adjust modal proportions for small screens [@shamoon](https://github.com/shamoon) ([#13728](https://github.com/paperless-ngx/paperless-ngx/pull/13728))
|
||||||
|
- Refactor: render paperless\_ai prompts via Jinja2 templates instead of f-strings [@stumpylog](https://github.com/stumpylog) ([#13698](https://github.com/paperless-ngx/paperless-ngx/pull/13698))
|
||||||
|
- Tweak: small visual tweaks / improvements \& fixes [@shamoon](https://github.com/shamoon) ([#13700](https://github.com/paperless-ngx/paperless-ngx/pull/13700))
|
||||||
|
- Enhancement: prefer existing tags, types, correspondents, and storage paths in AI suggestions [@stumpylog](https://github.com/stumpylog) ([#13676](https://github.com/paperless-ngx/paperless-ngx/pull/13676))
|
||||||
|
- Tweak: tweak permissions menu labels for shared user-dependent views [@shamoon](https://github.com/shamoon) ([#13685](https://github.com/paperless-ngx/paperless-ngx/pull/13685))
|
||||||
|
- Feature: Allow selection of compression type and and level during export [@stumpylog](https://github.com/stumpylog) ([#13661](https://github.com/paperless-ngx/paperless-ngx/pull/13661))
|
||||||
|
- Enhancement: customizable icons for saved views [@shamoon](https://github.com/shamoon) ([#13388](https://github.com/paperless-ngx/paperless-ngx/pull/13388))
|
||||||
|
- Enhancement: Add --url argument to document\_fuzzy\_match to improve output [@lukyjay](https://github.com/lukyjay) ([#13123](https://github.com/paperless-ngx/paperless-ngx/pull/13123))
|
||||||
|
- Feature: Updates remote OCR parser to respect the OCR mode setting [@stumpylog](https://github.com/stumpylog) ([#13408](https://github.com/paperless-ngx/paperless-ngx/pull/13408))
|
||||||
|
- Tweak: improve no ML suggestions UX [@shamoon](https://github.com/shamoon) ([#13621](https://github.com/paperless-ngx/paperless-ngx/pull/13621))
|
||||||
|
- Performance: reduce memory and I/O overhead of the document exporter during zip exports [@stumpylog](https://github.com/stumpylog) ([#13490](https://github.com/paperless-ngx/paperless-ngx/pull/13490))
|
||||||
|
- QoL: make name button text on attribute pages selectable [@shamoon](https://github.com/shamoon) ([#13592](https://github.com/paperless-ngx/paperless-ngx/pull/13592))
|
||||||
|
- Performance: More efficient mail fetching [@stumpylog](https://github.com/stumpylog) ([#13432](https://github.com/paperless-ngx/paperless-ngx/pull/13432))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: prevent config autocomplete craziness [@shamoon](https://github.com/shamoon) ([#13808](https://github.com/paperless-ngx/paperless-ngx/pull/13808))
|
||||||
|
- Fix/performance: prevent token reuse in dropdown filtering, also a perf thing [@shamoon](https://github.com/shamoon) ([#13804](https://github.com/paperless-ngx/paperless-ngx/pull/13804))
|
||||||
|
- Fix: exclude version documents from bulk edit "all" [@shamoon](https://github.com/shamoon) ([#13791](https://github.com/paperless-ngx/paperless-ngx/pull/13791))
|
||||||
|
- Fix: fix bottom mobile nav buttons on Android [@shamoon](https://github.com/shamoon) ([#13780](https://github.com/paperless-ngx/paperless-ngx/pull/13780))
|
||||||
|
- Fix: lazy import guardian modules to fix search language setting [@shamoon](https://github.com/shamoon) ([#13768](https://github.com/paperless-ngx/paperless-ngx/pull/13768))
|
||||||
|
- Fix: version indexing fixes [@shamoon](https://github.com/shamoon) ([#13737](https://github.com/paperless-ngx/paperless-ngx/pull/13737))
|
||||||
|
- Fix: append charset to file response for text files [@shamoon](https://github.com/shamoon) ([#13759](https://github.com/paperless-ngx/paperless-ngx/pull/13759))
|
||||||
|
- Chore: pin Apache Tika images to 3.3.1 [@shamoon](https://github.com/shamoon) ([#13758](https://github.com/paperless-ngx/paperless-ngx/pull/13758))
|
||||||
|
- Fix: align bulk edit object perms with document model [@shamoon](https://github.com/shamoon) ([#13757](https://github.com/paperless-ngx/paperless-ngx/pull/13757))
|
||||||
|
- Fix: use selected version for doc detail emailing [@shamoon](https://github.com/shamoon) ([#13738](https://github.com/paperless-ngx/paperless-ngx/pull/13738))
|
||||||
|
- Fix: hide version delete button without global perms [@shamoon](https://github.com/shamoon) ([#13735](https://github.com/paperless-ngx/paperless-ngx/pull/13735))
|
||||||
|
- Fix: dont re-render path template when checking collisions [@shamoon](https://github.com/shamoon) ([#13718](https://github.com/paperless-ngx/paperless-ngx/pull/13718))
|
||||||
|
- Fix: DocumentClassifierSchema bounds [@shamoon](https://github.com/shamoon) ([#13707](https://github.com/paperless-ngx/paperless-ngx/pull/13707))
|
||||||
|
- Fix: remove shadow around attribute pages [@shamoon](https://github.com/shamoon) ([#13696](https://github.com/paperless-ngx/paperless-ngx/pull/13696))
|
||||||
|
- Zen: correct dropdown corner radius visual defect [@shamoon](https://github.com/shamoon) ([#13695](https://github.com/paperless-ngx/paperless-ngx/pull/13695))
|
||||||
|
- Fix: handle Android keyboard popper overlay [@shamoon](https://github.com/shamoon) ([#13694](https://github.com/paperless-ngx/paperless-ngx/pull/13694))
|
||||||
|
- Fix: only show create when there is text, hide set values if no fields in cf bulk edit dropdown [@shamoon](https://github.com/shamoon) ([#13688](https://github.com/paperless-ngx/paperless-ngx/pull/13688))
|
||||||
|
- Fix: reopen a fresh Tantivy index per write to prevent orphaned segment files [@stumpylog](https://github.com/stumpylog) ([#13682](https://github.com/paperless-ngx/paperless-ngx/pull/13682))
|
||||||
|
- Fix: fix validation of workflow title assignment [@maxtruxa](https://github.com/maxtruxa) ([#13659](https://github.com/paperless-ngx/paperless-ngx/pull/13659))
|
||||||
|
- Fix: dont clip search dropdown on mobile [@shamoon](https://github.com/shamoon) ([#13675](https://github.com/paperless-ngx/paperless-ngx/pull/13675))
|
||||||
|
- Fix: include sharelink bundle perms in WebUI [@shamoon](https://github.com/shamoon) ([#13664](https://github.com/paperless-ngx/paperless-ngx/pull/13664))
|
||||||
|
- Fix: add pagination to saved views management page [@shamoon](https://github.com/shamoon) ([#13646](https://github.com/paperless-ngx/paperless-ngx/pull/13646))
|
||||||
|
- Fix: fixes for workflow assign custom field values [@shamoon](https://github.com/shamoon) ([#13630](https://github.com/paperless-ngx/paperless-ngx/pull/13630))
|
||||||
|
- Fix: deny deactivated users in permission filtering and auto-login [@stumpylog](https://github.com/stumpylog) ([#13623](https://github.com/paperless-ngx/paperless-ngx/pull/13623))
|
||||||
|
- Fix: check bulk mail delete permissions for the whole batch up front [@stumpylog](https://github.com/stumpylog) ([#13620](https://github.com/paperless-ngx/paperless-ngx/pull/13620))
|
||||||
|
- Fix: Allow DRF to validate the maximum API key length [@stumpylog](https://github.com/stumpylog) ([#13614](https://github.com/paperless-ngx/paperless-ngx/pull/13614))
|
||||||
|
- Fix: render PDF form values in annotation layer [@shamoon](https://github.com/shamoon) ([#13607](https://github.com/paperless-ngx/paperless-ngx/pull/13607))
|
||||||
|
- QoL: disable name button without perms [@shamoon](https://github.com/shamoon) ([#13606](https://github.com/paperless-ngx/paperless-ngx/pull/13606))
|
||||||
|
- Fix: prevent debounce overwrites in advanced search field, also improve Esc behavior [@shamoon](https://github.com/shamoon) ([#13602](https://github.com/paperless-ngx/paperless-ngx/pull/13602))
|
||||||
|
- Fix: raise ParseError on remote OCR failure instead of silently continuing [@stumpylog](https://github.com/stumpylog) ([#13574](https://github.com/paperless-ngx/paperless-ngx/pull/13574))
|
||||||
|
- Fix: use selected version when creating share links [@shamoon](https://github.com/shamoon) ([#13571](https://github.com/paperless-ngx/paperless-ngx/pull/13571))
|
||||||
|
- Fix: reject bulk edit permissions requests without the correct key [@shamoon](https://github.com/shamoon) ([#13563](https://github.com/paperless-ngx/paperless-ngx/pull/13563))
|
||||||
|
- Fix: correctly serve app logo specified in env [@shamoon](https://github.com/shamoon) ([#13561](https://github.com/paperless-ngx/paperless-ngx/pull/13561))
|
||||||
|
- Fix: prevent workflow passwords field type error [@shamoon](https://github.com/shamoon) ([#13552](https://github.com/paperless-ngx/paperless-ngx/pull/13552))
|
||||||
|
- Fix: correct Firefox print regression [@shamoon](https://github.com/shamoon) ([#13543](https://github.com/paperless-ngx/paperless-ngx/pull/13543))
|
||||||
|
- Fix: hide some saved view operations on management page without permissions [@shamoon](https://github.com/shamoon) ([#13542](https://github.com/paperless-ngx/paperless-ngx/pull/13542))
|
||||||
|
- Fix: disable pdfjs selection rendering [@shamoon](https://github.com/shamoon) ([#13538](https://github.com/paperless-ngx/paperless-ngx/pull/13538))
|
||||||
|
- Fix: hide sidebar drag grips with insufficient permissions [@shamoon](https://github.com/shamoon) ([#13536](https://github.com/paperless-ngx/paperless-ngx/pull/13536))
|
||||||
|
- Fix: don't re-queue a consume-folder file that is already queued and awaiting consumption [@stumpylog](https://github.com/stumpylog) ([#13526](https://github.com/paperless-ngx/paperless-ngx/pull/13526))
|
||||||
|
- Fix: correct multi-search non-adjacent queries [@shamoon](https://github.com/shamoon) ([#13504](https://github.com/paperless-ngx/paperless-ngx/pull/13504))
|
||||||
|
- Fix: Content-Disposition filename normalization [@shamoon](https://github.com/shamoon) ([#13514](https://github.com/paperless-ngx/paperless-ngx/pull/13514))
|
||||||
|
- Fix: prevent duplicated text query with multiple date queries [@shamoon](https://github.com/shamoon) ([#13522](https://github.com/paperless-ngx/paperless-ngx/pull/13522))
|
||||||
|
- Fix: crash filtering document link custom fields with an unset or unrelated field present [@ggouzi](https://github.com/ggouzi) ([#13518](https://github.com/paperless-ngx/paperless-ngx/pull/13518))
|
||||||
|
- Fix: parse unpadded yyyy-mm-dd date input regardless of locale [@Se1foo](https://github.com/Se1foo) ([#13501](https://github.com/paperless-ngx/paperless-ngx/pull/13501))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Documentation: clarify default OCR mode changes in v3 [@shamoon](https://github.com/shamoon) ([#13666](https://github.com/paperless-ngx/paperless-ngx/pull/13666))
|
||||||
|
- Fix: fixes for workflow assign custom field values [@shamoon](https://github.com/shamoon) ([#13630](https://github.com/paperless-ngx/paperless-ngx/pull/13630))
|
||||||
|
- Documentation: add wiki links for AI stuff and parser plugins [@shamoon](https://github.com/shamoon) ([#13626](https://github.com/paperless-ngx/paperless-ngx/pull/13626))
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
- Chore(deps): Bump the actions group across 1 directory with 20 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13481](https://github.com/paperless-ngx/paperless-ngx/pull/13481))
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>23 changes</summary>
|
||||||
|
|
||||||
|
- Chore: Upgrade Docker image to Python 3.14 [@stumpylog](https://github.com/stumpylog) ([#13721](https://github.com/paperless-ngx/paperless-ngx/pull/13721))
|
||||||
|
- Chore(deps): Bump the uv group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13709](https://github.com/paperless-ngx/paperless-ngx/pull/13709))
|
||||||
|
- Chore: update, reorg some npm deps [@shamoon](https://github.com/shamoon) ([#13716](https://github.com/paperless-ngx/paperless-ngx/pull/13716))
|
||||||
|
- Chore: update fpdf2 to 2.8.8 [@shamoon](https://github.com/shamoon) ([#13629](https://github.com/paperless-ngx/paperless-ngx/pull/13629))
|
||||||
|
- Chore: update pnpm, add blockExoticSubdeps [@shamoon](https://github.com/shamoon) ([#13628](https://github.com/paperless-ngx/paperless-ngx/pull/13628))
|
||||||
|
- Chore(deps): Bump h2 from 4.3.0 to 4.4.1 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13593](https://github.com/paperless-ngx/paperless-ngx/pull/13593))
|
||||||
|
- Chore(deps): Bump pdfjs-dist from 6.1.200 to 6.2.108 in /src-ui in the npm\_and\_yarn group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13594](https://github.com/paperless-ngx/paperless-ngx/pull/13594))
|
||||||
|
- Chore(deps): Bump cryptography from 48.0.1 to 50.0.0 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13588](https://github.com/paperless-ngx/paperless-ngx/pull/13588))
|
||||||
|
- Chore(deps): Bump the utilities-patch group across 1 directory with 6 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13539](https://github.com/paperless-ngx/paperless-ngx/pull/13539))
|
||||||
|
- Chore(deps): Bump the utilities-minor group across 1 directory with 20 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13535](https://github.com/paperless-ngx/paperless-ngx/pull/13535))
|
||||||
|
- Chore(deps): Bump aiohttp from 3.14.1 to 3.14.3 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13537](https://github.com/paperless-ngx/paperless-ngx/pull/13537))
|
||||||
|
- Chore(deps-dev): Bump zensical from 0.0.47 to 0.0.51 in the development group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13533](https://github.com/paperless-ngx/paperless-ngx/pull/13533))
|
||||||
|
- Chore(deps-dev): Bump postcss from 8.5.22 to 8.5.25 in /src/paperless\_mail/templates in the npm\_and\_yarn group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13534](https://github.com/paperless-ngx/paperless-ngx/pull/13534))
|
||||||
|
- Chore(deps): Bump the pre-commit-dependencies group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13532](https://github.com/paperless-ngx/paperless-ngx/pull/13532))
|
||||||
|
- Chore: ruff 0.16 upgrade [@stumpylog](https://github.com/stumpylog) ([#13531](https://github.com/paperless-ngx/paperless-ngx/pull/13531))
|
||||||
|
- Chore(deps): Bump the actions group across 1 directory with 20 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13481](https://github.com/paperless-ngx/paperless-ngx/pull/13481))
|
||||||
|
- docker(deps): bump astral-sh/uv from 0.11.28-python3.12-trixie-slim to 0.11.32-python3.12-trixie-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#13472](https://github.com/paperless-ngx/paperless-ngx/pull/13472))
|
||||||
|
- docker-compose(deps): bump nginx from 1.31.2-alpine to 1.31.3-alpine in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#13470](https://github.com/paperless-ngx/paperless-ngx/pull/13470))
|
||||||
|
- docker-compose(deps): Bump greenmail/standalone from 2.1.9 to 2.1.11 in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#13469](https://github.com/paperless-ngx/paperless-ngx/pull/13469))
|
||||||
|
- Chore(deps): Bump the frontend-angular-dependencies group across 1 directory with 18 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13476](https://github.com/paperless-ngx/paperless-ngx/pull/13476))
|
||||||
|
- Chore(deps-dev): Bump @playwright/test from 1.61.1 to 1.62.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#13480](https://github.com/paperless-ngx/paperless-ngx/pull/13480))
|
||||||
|
- Chore(deps-dev): Bump the frontend-eslint-dependencies group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13477](https://github.com/paperless-ngx/paperless-ngx/pull/13477))
|
||||||
|
- Chore(deps-dev): Bump @types/node from 26.1.0 to 26.1.1 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#13479](https://github.com/paperless-ngx/paperless-ngx/pull/13479))
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>91 changes</summary>
|
||||||
|
|
||||||
|
- Fix: prevent config autocomplete craziness [@shamoon](https://github.com/shamoon) ([#13808](https://github.com/paperless-ngx/paperless-ngx/pull/13808))
|
||||||
|
- Fix/performance: prevent token reuse in dropdown filtering, also a perf thing [@shamoon](https://github.com/shamoon) ([#13804](https://github.com/paperless-ngx/paperless-ngx/pull/13804))
|
||||||
|
- Enhancement: Apply AI suggestions workflow action [@shamoon](https://github.com/shamoon) ([#13639](https://github.com/paperless-ngx/paperless-ngx/pull/13639))
|
||||||
|
- Fix: exclude version documents from bulk edit "all" [@shamoon](https://github.com/shamoon) ([#13791](https://github.com/paperless-ngx/paperless-ngx/pull/13791))
|
||||||
|
- Enhancement: welcome widget visual tweaks [@shamoon](https://github.com/shamoon) ([#13794](https://github.com/paperless-ngx/paperless-ngx/pull/13794))
|
||||||
|
- Performance: fetch note authors with prefetch instead of one query each [@shamoon](https://github.com/shamoon) ([#13790](https://github.com/paperless-ngx/paperless-ngx/pull/13790))
|
||||||
|
- Tweak: more misc UI tweaks [@shamoon](https://github.com/shamoon) ([#13783](https://github.com/paperless-ngx/paperless-ngx/pull/13783))
|
||||||
|
- Enhancement: support using remote OCR engines selectively [@shamoon](https://github.com/shamoon) ([#13633](https://github.com/paperless-ngx/paperless-ngx/pull/13633))
|
||||||
|
- Fix: fix bottom mobile nav buttons on Android [@shamoon](https://github.com/shamoon) ([#13780](https://github.com/paperless-ngx/paperless-ngx/pull/13780))
|
||||||
|
- Enhancement: websocket heartbeat [@oktupol](https://github.com/oktupol) ([#13739](https://github.com/paperless-ngx/paperless-ngx/pull/13739))
|
||||||
|
- Fix: lazy import guardian modules to fix search language setting [@shamoon](https://github.com/shamoon) ([#13768](https://github.com/paperless-ngx/paperless-ngx/pull/13768))
|
||||||
|
- Enhancement: more v3 ui tweaks [@shamoon](https://github.com/shamoon) ([#13774](https://github.com/paperless-ngx/paperless-ngx/pull/13774))
|
||||||
|
- Chore: add some missing UI accessibility labels [@shamoon](https://github.com/shamoon) ([#13772](https://github.com/paperless-ngx/paperless-ngx/pull/13772))
|
||||||
|
- Chore: refactor permission checkbox live changes [@shamoon](https://github.com/shamoon) ([#13771](https://github.com/paperless-ngx/paperless-ngx/pull/13771))
|
||||||
|
- Tweak: better support long list of views in documents list [@shamoon](https://github.com/shamoon) ([#13769](https://github.com/paperless-ngx/paperless-ngx/pull/13769))
|
||||||
|
- Fix: version indexing fixes [@shamoon](https://github.com/shamoon) ([#13737](https://github.com/paperless-ngx/paperless-ngx/pull/13737))
|
||||||
|
- Fix: append charset to file response for text files [@shamoon](https://github.com/shamoon) ([#13759](https://github.com/paperless-ngx/paperless-ngx/pull/13759))
|
||||||
|
- Fix: align bulk edit object perms with document model [@shamoon](https://github.com/shamoon) ([#13757](https://github.com/paperless-ngx/paperless-ngx/pull/13757))
|
||||||
|
- QoL: add count badge to versions dropdown [@shamoon](https://github.com/shamoon) ([#13753](https://github.com/paperless-ngx/paperless-ngx/pull/13753))
|
||||||
|
- Tweakhancement: add jitter to IMAP polling schedule [@shamoon](https://github.com/shamoon) ([#13734](https://github.com/paperless-ngx/paperless-ngx/pull/13734))
|
||||||
|
- Fix: use selected version for doc detail emailing [@shamoon](https://github.com/shamoon) ([#13738](https://github.com/paperless-ngx/paperless-ngx/pull/13738))
|
||||||
|
- Fix: hide version delete button without global perms [@shamoon](https://github.com/shamoon) ([#13735](https://github.com/paperless-ngx/paperless-ngx/pull/13735))
|
||||||
|
- Enhancement: sync OIDC groups to superuser and staff roles [@BeSovereign](https://github.com/BeSovereign) ([#13060](https://github.com/paperless-ngx/paperless-ngx/pull/13060))
|
||||||
|
- Enhancement: merge documents as versions [@shamoon](https://github.com/shamoon) ([#13515](https://github.com/paperless-ngx/paperless-ngx/pull/13515))
|
||||||
|
- Tweak: adjust modal proportions for small screens [@shamoon](https://github.com/shamoon) ([#13728](https://github.com/paperless-ngx/paperless-ngx/pull/13728))
|
||||||
|
- Refactor: render paperless\_ai prompts via Jinja2 templates instead of f-strings [@stumpylog](https://github.com/stumpylog) ([#13698](https://github.com/paperless-ngx/paperless-ngx/pull/13698))
|
||||||
|
- Chore(deps): Bump the uv group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13709](https://github.com/paperless-ngx/paperless-ngx/pull/13709))
|
||||||
|
- Fix: dont re-render path template when checking collisions [@shamoon](https://github.com/shamoon) ([#13718](https://github.com/paperless-ngx/paperless-ngx/pull/13718))
|
||||||
|
- Chore: update, reorg some npm deps [@shamoon](https://github.com/shamoon) ([#13716](https://github.com/paperless-ngx/paperless-ngx/pull/13716))
|
||||||
|
- Fix: DocumentClassifierSchema bounds [@shamoon](https://github.com/shamoon) ([#13707](https://github.com/paperless-ngx/paperless-ngx/pull/13707))
|
||||||
|
- Tweak: small visual tweaks / improvements \& fixes [@shamoon](https://github.com/shamoon) ([#13700](https://github.com/paperless-ngx/paperless-ngx/pull/13700))
|
||||||
|
- Fix: remove shadow around attribute pages [@shamoon](https://github.com/shamoon) ([#13696](https://github.com/paperless-ngx/paperless-ngx/pull/13696))
|
||||||
|
- Zen: correct dropdown corner radius visual defect [@shamoon](https://github.com/shamoon) ([#13695](https://github.com/paperless-ngx/paperless-ngx/pull/13695))
|
||||||
|
- Fix: handle Android keyboard popper overlay [@shamoon](https://github.com/shamoon) ([#13694](https://github.com/paperless-ngx/paperless-ngx/pull/13694))
|
||||||
|
- Enhancement: prefer existing tags, types, correspondents, and storage paths in AI suggestions [@stumpylog](https://github.com/stumpylog) ([#13676](https://github.com/paperless-ngx/paperless-ngx/pull/13676))
|
||||||
|
- Fix: only show create when there is text, hide set values if no fields in cf bulk edit dropdown [@shamoon](https://github.com/shamoon) ([#13688](https://github.com/paperless-ngx/paperless-ngx/pull/13688))
|
||||||
|
- Fix: reopen a fresh Tantivy index per write to prevent orphaned segment files [@stumpylog](https://github.com/stumpylog) ([#13682](https://github.com/paperless-ngx/paperless-ngx/pull/13682))
|
||||||
|
- Tweak: tweak permissions menu labels for shared user-dependent views [@shamoon](https://github.com/shamoon) ([#13685](https://github.com/paperless-ngx/paperless-ngx/pull/13685))
|
||||||
|
- Fix: fix validation of workflow title assignment [@maxtruxa](https://github.com/maxtruxa) ([#13659](https://github.com/paperless-ngx/paperless-ngx/pull/13659))
|
||||||
|
- Feature: Allow selection of compression type and and level during export [@stumpylog](https://github.com/stumpylog) ([#13661](https://github.com/paperless-ngx/paperless-ngx/pull/13661))
|
||||||
|
- Fix: dont clip search dropdown on mobile [@shamoon](https://github.com/shamoon) ([#13675](https://github.com/paperless-ngx/paperless-ngx/pull/13675))
|
||||||
|
- Fix: include sharelink bundle perms in WebUI [@shamoon](https://github.com/shamoon) ([#13664](https://github.com/paperless-ngx/paperless-ngx/pull/13664))
|
||||||
|
- Enhancement: customizable icons for saved views [@shamoon](https://github.com/shamoon) ([#13388](https://github.com/paperless-ngx/paperless-ngx/pull/13388))
|
||||||
|
- Enhancement: Add --url argument to document\_fuzzy\_match to improve output [@lukyjay](https://github.com/lukyjay) ([#13123](https://github.com/paperless-ngx/paperless-ngx/pull/13123))
|
||||||
|
- Feature: Updates remote OCR parser to respect the OCR mode setting [@stumpylog](https://github.com/stumpylog) ([#13408](https://github.com/paperless-ngx/paperless-ngx/pull/13408))
|
||||||
|
- Performance: pass document chat queries as a QuerySet instead of a materialized.list [@stumpylog](https://github.com/stumpylog) ([#13638](https://github.com/paperless-ngx/paperless-ngx/pull/13638))
|
||||||
|
- Fix: add pagination to saved views management page [@shamoon](https://github.com/shamoon) ([#13646](https://github.com/paperless-ngx/paperless-ngx/pull/13646))
|
||||||
|
- Fix: fixes for workflow assign custom field values [@shamoon](https://github.com/shamoon) ([#13630](https://github.com/paperless-ngx/paperless-ngx/pull/13630))
|
||||||
|
- Fix: deny deactivated users in permission filtering and auto-login [@stumpylog](https://github.com/stumpylog) ([#13623](https://github.com/paperless-ngx/paperless-ngx/pull/13623))
|
||||||
|
- Chore: update fpdf2 to 2.8.8 [@shamoon](https://github.com/shamoon) ([#13629](https://github.com/paperless-ngx/paperless-ngx/pull/13629))
|
||||||
|
- Chore: update pnpm, add blockExoticSubdeps [@shamoon](https://github.com/shamoon) ([#13628](https://github.com/paperless-ngx/paperless-ngx/pull/13628))
|
||||||
|
- Fix: check bulk mail delete permissions for the whole batch up front [@stumpylog](https://github.com/stumpylog) ([#13620](https://github.com/paperless-ngx/paperless-ngx/pull/13620))
|
||||||
|
- Tweak: improve no ML suggestions UX [@shamoon](https://github.com/shamoon) ([#13621](https://github.com/paperless-ngx/paperless-ngx/pull/13621))
|
||||||
|
- Fix: Allow DRF to validate the maximum API key length [@stumpylog](https://github.com/stumpylog) ([#13614](https://github.com/paperless-ngx/paperless-ngx/pull/13614))
|
||||||
|
- Performance: unify permission-filtering backends, fixes Correspondent/Tag list slowness [@stumpylog](https://github.com/stumpylog) ([#13601](https://github.com/paperless-ngx/paperless-ngx/pull/13601))
|
||||||
|
- Performance: generalize permitted\_document\_ids into permitted\_object\_ids for any model [@stumpylog](https://github.com/stumpylog) ([#13578](https://github.com/paperless-ngx/paperless-ngx/pull/13578))
|
||||||
|
- Fix: render PDF form values in annotation layer [@shamoon](https://github.com/shamoon) ([#13607](https://github.com/paperless-ngx/paperless-ngx/pull/13607))
|
||||||
|
- QoL: disable name button without perms [@shamoon](https://github.com/shamoon) ([#13606](https://github.com/paperless-ngx/paperless-ngx/pull/13606))
|
||||||
|
- Fix: prevent debounce overwrites in advanced search field, also improve Esc behavior [@shamoon](https://github.com/shamoon) ([#13602](https://github.com/paperless-ngx/paperless-ngx/pull/13602))
|
||||||
|
- Performance: reduce memory and I/O overhead of the document exporter during zip exports [@stumpylog](https://github.com/stumpylog) ([#13490](https://github.com/paperless-ngx/paperless-ngx/pull/13490))
|
||||||
|
- Chore(deps): Bump h2 from 4.3.0 to 4.4.1 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13593](https://github.com/paperless-ngx/paperless-ngx/pull/13593))
|
||||||
|
- Chore(deps): Bump pdfjs-dist from 6.1.200 to 6.2.108 in /src-ui in the npm\_and\_yarn group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13594](https://github.com/paperless-ngx/paperless-ngx/pull/13594))
|
||||||
|
- QoL: make name button text on attribute pages selectable [@shamoon](https://github.com/shamoon) ([#13592](https://github.com/paperless-ngx/paperless-ngx/pull/13592))
|
||||||
|
- Fix: raise ParseError on remote OCR failure instead of silently continuing [@stumpylog](https://github.com/stumpylog) ([#13574](https://github.com/paperless-ngx/paperless-ngx/pull/13574))
|
||||||
|
- Fix: use selected version when creating share links [@shamoon](https://github.com/shamoon) ([#13571](https://github.com/paperless-ngx/paperless-ngx/pull/13571))
|
||||||
|
- Chore: specify AI chat refine template [@shamoon](https://github.com/shamoon) ([#13564](https://github.com/paperless-ngx/paperless-ngx/pull/13564))
|
||||||
|
- Fix: reject bulk edit permissions requests without the correct key [@shamoon](https://github.com/shamoon) ([#13563](https://github.com/paperless-ngx/paperless-ngx/pull/13563))
|
||||||
|
- Fix: correctly serve app logo specified in env [@shamoon](https://github.com/shamoon) ([#13561](https://github.com/paperless-ngx/paperless-ngx/pull/13561))
|
||||||
|
- Fix: prevent workflow passwords field type error [@shamoon](https://github.com/shamoon) ([#13552](https://github.com/paperless-ngx/paperless-ngx/pull/13552))
|
||||||
|
- Chore(deps): Bump the utilities-patch group across 1 directory with 6 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13539](https://github.com/paperless-ngx/paperless-ngx/pull/13539))
|
||||||
|
- Chore(deps): Bump the utilities-minor group across 1 directory with 20 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13535](https://github.com/paperless-ngx/paperless-ngx/pull/13535))
|
||||||
|
- Performance: More efficient mail fetching [@stumpylog](https://github.com/stumpylog) ([#13432](https://github.com/paperless-ngx/paperless-ngx/pull/13432))
|
||||||
|
- Performance: eliminate per-document guardian permission-check causing high CPU on document lists [@stumpylog](https://github.com/stumpylog) ([#13505](https://github.com/paperless-ngx/paperless-ngx/pull/13505))
|
||||||
|
- Fix: correct Firefox print regression [@shamoon](https://github.com/shamoon) ([#13543](https://github.com/paperless-ngx/paperless-ngx/pull/13543))
|
||||||
|
- Fix: hide some saved view operations on management page without permissions [@shamoon](https://github.com/shamoon) ([#13542](https://github.com/paperless-ngx/paperless-ngx/pull/13542))
|
||||||
|
- Chore(deps): Bump aiohttp from 3.14.1 to 3.14.3 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13537](https://github.com/paperless-ngx/paperless-ngx/pull/13537))
|
||||||
|
- Chore(deps-dev): Bump zensical from 0.0.47 to 0.0.51 in the development group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13533](https://github.com/paperless-ngx/paperless-ngx/pull/13533))
|
||||||
|
- Fix: disable pdfjs selection rendering [@shamoon](https://github.com/shamoon) ([#13538](https://github.com/paperless-ngx/paperless-ngx/pull/13538))
|
||||||
|
- Fix: hide sidebar drag grips with insufficient permissions [@shamoon](https://github.com/shamoon) ([#13536](https://github.com/paperless-ngx/paperless-ngx/pull/13536))
|
||||||
|
- Chore(deps-dev): Bump postcss from 8.5.22 to 8.5.25 in /src/paperless\_mail/templates in the npm\_and\_yarn group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13534](https://github.com/paperless-ngx/paperless-ngx/pull/13534))
|
||||||
|
- Chore: ruff 0.16 upgrade [@stumpylog](https://github.com/stumpylog) ([#13531](https://github.com/paperless-ngx/paperless-ngx/pull/13531))
|
||||||
|
- Fix: don't re-queue a consume-folder file that is already queued and awaiting consumption [@stumpylog](https://github.com/stumpylog) ([#13526](https://github.com/paperless-ngx/paperless-ngx/pull/13526))
|
||||||
|
- Fix: correct multi-search non-adjacent queries [@shamoon](https://github.com/shamoon) ([#13504](https://github.com/paperless-ngx/paperless-ngx/pull/13504))
|
||||||
|
- Fix: Content-Disposition filename normalization [@shamoon](https://github.com/shamoon) ([#13514](https://github.com/paperless-ngx/paperless-ngx/pull/13514))
|
||||||
|
- Fix: prevent duplicated text query with multiple date queries [@shamoon](https://github.com/shamoon) ([#13522](https://github.com/paperless-ngx/paperless-ngx/pull/13522))
|
||||||
|
- Fix: crash filtering document link custom fields with an unset or unrelated field present [@ggouzi](https://github.com/ggouzi) ([#13518](https://github.com/paperless-ngx/paperless-ngx/pull/13518))
|
||||||
|
- Fix: parse unpadded yyyy-mm-dd date input regardless of locale [@Se1foo](https://github.com/Se1foo) ([#13501](https://github.com/paperless-ngx/paperless-ngx/pull/13501))
|
||||||
|
- Chore(deps): Bump the frontend-angular-dependencies group across 1 directory with 18 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13476](https://github.com/paperless-ngx/paperless-ngx/pull/13476))
|
||||||
|
- Chore(deps-dev): Bump @playwright/test from 1.61.1 to 1.62.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#13480](https://github.com/paperless-ngx/paperless-ngx/pull/13480))
|
||||||
|
- Chore(deps-dev): Bump the frontend-eslint-dependencies group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13477](https://github.com/paperless-ngx/paperless-ngx/pull/13477))
|
||||||
|
- Chore(deps-dev): Bump @types/node from 26.1.0 to 26.1.1 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#13479](https://github.com/paperless-ngx/paperless-ngx/pull/13479))
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## paperless-ngx 3.0.5
|
## paperless-ngx 3.0.5
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ common [OCR](#ocr) related settings and some frontend settings. If set, these wi
|
|||||||
preference over the settings via environment variables. If not set, the environment setting
|
preference over the settings via environment variables. If not set, the environment setting
|
||||||
or applicable default will be utilized instead.
|
or applicable default will be utilized instead.
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
|
||||||
|
Changing configuration from the UI requires the `AppConfig` permission, which applies
|
||||||
|
instance-wide and should be treated as an admin-level permission. See
|
||||||
|
[global permissions](usage.md#global-permissions).
|
||||||
|
|
||||||
- If you run paperless on docker, `paperless.conf` is not used.
|
- If you run paperless on docker, `paperless.conf` is not used.
|
||||||
Rather, configure paperless by copying necessary options to
|
Rather, configure paperless by copying necessary options to
|
||||||
`docker-compose.env`.
|
`docker-compose.env`.
|
||||||
@@ -776,6 +782,24 @@ system. See the corresponding
|
|||||||
|
|
||||||
Defaults to "groups"
|
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}
|
#### [`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.
|
: A list of group names that users who signup via social accounts will be added to upon signup. Groups listed here must already exist.
|
||||||
@@ -1107,6 +1131,10 @@ they use underscores instead of dashes.
|
|||||||
so specifying invalid options may prevent paperless from consuming
|
so specifying invalid options may prevent paperless from consuming
|
||||||
any documents. Use with caution!
|
any documents. Use with caution!
|
||||||
|
|
||||||
|
These arguments are passed directly to OCRmyPDF, so this setting should only
|
||||||
|
be changed by trusted users. This applies to the `AppConfig` permission as well,
|
||||||
|
which allows setting these arguments from the UI.
|
||||||
|
|
||||||
Specify arguments as a JSON dictionary. Keep note of lower case
|
Specify arguments as a JSON dictionary. Keep note of lower case
|
||||||
booleans and double quoted parameter names and strings. Examples:
|
booleans and double quoted parameter names and strings. Examples:
|
||||||
|
|
||||||
@@ -1197,7 +1225,7 @@ should be a valid crontab(5) expression describing when to run.
|
|||||||
|
|
||||||
: If set to the string "disable", no emails will be fetched automatically.
|
: 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}
|
#### [`PAPERLESS_TRAIN_TASK_CRON=<cron expression>`](#PAPERLESS_TRAIN_TASK_CRON) {#PAPERLESS_TRAIN_TASK_CRON}
|
||||||
|
|
||||||
@@ -2048,6 +2076,18 @@ password. All of these options come from their similarly-named [Django settings]
|
|||||||
|
|
||||||
Defaults to None.
|
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}
|
## AI {#ai}
|
||||||
|
|
||||||
#### [`PAPERLESS_AI_ENABLED=<bool>`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED}
|
#### [`PAPERLESS_AI_ENABLED=<bool>`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED}
|
||||||
@@ -2070,6 +2110,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
|
models supported by the current embedding backend. If not supplied, defaults to
|
||||||
"text-embedding-3-small" for the OpenAI-compatible backend,
|
"text-embedding-3-small" for the OpenAI-compatible backend,
|
||||||
"sentence-transformers/all-MiniLM-L6-v2" for Huggingface, and "embeddinggemma" for Ollama.
|
"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.
|
Defaults to None.
|
||||||
|
|
||||||
@@ -2126,6 +2168,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
|
: 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
|
by the current backend. If not supplied, defaults to "gpt-3.5-turbo" for the OpenAI-compatible
|
||||||
backend and "llama3.1" for Ollama.
|
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.
|
Defaults to None.
|
||||||
|
|
||||||
|
|||||||
@@ -224,13 +224,19 @@ respectively, can be run non-interactively with:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm ng test
|
pnpm ng test
|
||||||
pnpm playwright test
|
pnpm e2e
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The Playwright suite starts both the Angular development server and a disposable
|
||||||
|
Paperless instance on port 8001. The instance uses SQLite, temporary data and
|
||||||
|
media directories, and deterministic sample documents; it is removed when the
|
||||||
|
test run finishes. This requires the back-end Python dependencies from the
|
||||||
|
regular development setup to be installed with `uv sync`.
|
||||||
|
|
||||||
Playwright also includes a UI which can be run with:
|
Playwright also includes a UI which can be run with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm playwright test --ui
|
pnpm e2e:ui
|
||||||
```
|
```
|
||||||
|
|
||||||
### Building the frontend
|
### Building the frontend
|
||||||
@@ -456,6 +462,20 @@ def score(
|
|||||||
return 10
|
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**
|
**Archive and rendition flags**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ The new settings are independent:
|
|||||||
|
|
||||||
### Database configuration
|
### 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
|
### Action Required
|
||||||
|
|
||||||
@@ -165,8 +165,9 @@ Remove any `PAPERLESS_OCR_SKIP_ARCHIVE_FILE` variable from your environment. If
|
|||||||
```bash
|
```bash
|
||||||
# v2: skip OCR when text present, always archive
|
# v2: skip OCR when text present, always archive
|
||||||
PAPERLESS_OCR_MODE=skip
|
PAPERLESS_OCR_MODE=skip
|
||||||
# v3: equivalent (auto is the new default)
|
# v3: equivalent
|
||||||
# No change needed - auto is the default
|
PAPERLESS_OCR_MODE=auto
|
||||||
|
PAPERLESS_ARCHIVE_FILE_GENERATION=always
|
||||||
|
|
||||||
# v2: skip OCR when text present, skip archive too
|
# v2: skip OCR when text present, skip archive too
|
||||||
PAPERLESS_OCR_MODE=skip_noarchive
|
PAPERLESS_OCR_MODE=skip_noarchive
|
||||||
|
|||||||
@@ -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:
|
You may need to change the path in the files. Example:
|
||||||
`ExecStart=/opt/paperless/.local/bin/celery --app paperless worker --loglevel INFO`
|
`ExecStart=/opt/paperless/.local/bin/celery --app paperless worker --loglevel INFO`
|
||||||
|
|
||||||
12. Configure ImageMagick to allow processing of PDF documents. Most
|
12. Configure ImageMagick to allow processing of PDF documents and disable
|
||||||
distributions have this disabled by default, since PDF documents can
|
formats that Paperless-ngx does not use. Most distributions disable PDF
|
||||||
contain malware. If you don't do this, Paperless-ngx will fall back to
|
processing by default, since PDF documents can contain malware. If you
|
||||||
Ghostscript for certain steps such as thumbnail generation.
|
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
|
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.
|
||||||
<policy domain="coder" rights="none" pattern="PDF" />
|
|
||||||
```
|
|
||||||
|
|
||||||
to
|
|
||||||
|
|
||||||
```
|
|
||||||
<policy domain="coder" rights="read|write" pattern="PDF" />
|
|
||||||
```
|
|
||||||
|
|
||||||
**Optional: Install the [jbig2enc](https://ocrmypdf.readthedocs.io/en/latest/jbig2.html) encoder.**
|
**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
|
This will reduce the size of generated PDF documents. You'll most likely need to compile this yourself, because this
|
||||||
|
|||||||
@@ -1,428 +0,0 @@
|
|||||||
# Split views.py and serialisers.py Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Split `src/documents/views.py` (5,395 lines) and `src/documents/serialisers.py` (3,532 lines) into domain-based module packages, with zero behavior change.
|
|
||||||
|
|
||||||
**Architecture:** Both files become packages (`documents/views/`, `documents/serialisers/`), one module per domain area. Serialisers split first (views depend on serialisers, never the reverse), then views, then the three external call sites (`paperless/urls.py`, `paperless_mail/views.py`, `paperless_mail/serialisers.py`) are pointed at the new submodules. No `__init__.py` re-exports in either package — every internal and external consumer imports the exact submodule.
|
|
||||||
|
|
||||||
**Tech Stack:** Django REST Framework (viewsets/serializers), ruff (lint/format), pytest via the project's VM test runner.
|
|
||||||
|
|
||||||
**Spec:** `docs/superpowers/specs/2026-08-13-views-serialisers-split-design.md`
|
|
||||||
|
|
||||||
## Global Constraints
|
|
||||||
|
|
||||||
- No behavior change: class/function bodies, names, and public API responses are unchanged — pure move/reorganize. (spec: Non-goals)
|
|
||||||
- Domain module names are identical across both packages (`bulk_edit.py` exists in both, etc.). (spec: Import direction)
|
|
||||||
- Import direction is one-way: `documents/views/*` may import from `documents/serialisers/*`; `documents/serialisers/*` must never import from `documents/views/*`. (spec: Import direction)
|
|
||||||
- Neither package's `__init__.py` re-exports submodule contents — every consumer, internal or external, imports the specific submodule (e.g. `from documents.views.workflows import WorkflowViewSet`). (spec: Architecture)
|
|
||||||
- `src/documents/tests/test_views.py` and `src/documents/tests/test_api_documents.py` are not modified — they must pass unchanged, proving the move didn't alter behavior. (spec: Non-goals, Testing)
|
|
||||||
- This branch targets `dev` and is separate from `feature-ai-taxonomy-hints-v2`. (spec: Non-goals)
|
|
||||||
- Backend tests run on the Linux VM via the helper script, never locally: `bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "<pytest targets>"`. `ruff check` / `ruff format` run locally (global binary, not `uv run ruff`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Reference: symbol-to-module maps
|
|
||||||
|
|
||||||
These tables (from the spec) are the authoritative source for which class/function goes to which new file. Copy them exactly — do not improvise groupings.
|
|
||||||
|
|
||||||
### `documents/serialisers/` map
|
|
||||||
|
|
||||||
| Module | Symbols |
|
|
||||||
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `base.py` | `DynamicFieldsModelSerializer`, `DocumentUpdateFieldsModelSerializer`, `MatchingModelSerializer`, `SetPermissionsMixin`, `SerializerWithPerms`, `SetPermissionsSerializer`, `OwnedObjectSerializer`, `OwnedObjectListSerializer`, `ReadWriteSerializerMethodField`, `DocumentListSerializer`, `DocumentSelectionSerializer`, `SourceModeValidationMixin`, `BasicUserSerializer`, `NotesSerializer` |
|
|
||||||
| `metadata.py` | `CorrespondentSerializer`, `DocumentTypeSerializer`, `DeprecatedColors`, `ColorField`, `TagSerializer`, `CorrespondentField`, `TagsField`, `DocumentTypeField`, `StoragePathField`, `StoragePathSerializer`, `StoragePathTestSerializer`, `CustomFieldSerializer`, `CustomFieldInstanceSerializer`, `validate_documentlink_targets` |
|
|
||||||
| `documents.py` | `DocumentSerializer`, `SearchResultListSerializer`, `SearchResultSerializer`, `DuplicateDocumentSummarySerializer`, `_DocumentVersionInfo`, `DocumentVersionInfoSerializer`, `DocumentVersionSerializer`, `DocumentVersionLabelSerializer`, `_get_viewable_duplicates` |
|
|
||||||
| `upload.py` | `PostDocumentSerializer` |
|
|
||||||
| `saved_views.py` | `SavedViewFilterRuleSerializer`, `SavedViewSerializer` |
|
|
||||||
| `bulk_edit.py` | `RotateDocumentsSerializer`, `MergeDocumentsSerializer`, `EditPdfDocumentsSerializer`, `RemovePasswordDocumentsSerializer`, `DeleteDocumentsSerializer`, `ReprocessDocumentsSerializer`, `BulkEditSerializer`, `BulkDownloadSerializer`, `BulkEditObjectsSerializer` |
|
|
||||||
| `sharing.py` | `EmailSerializer`, `ShareLinkSerializer`, `ShareLinkBundleSerializer` |
|
|
||||||
| `tasks.py` | `TaskSerializerV10`, `TaskSerializerV9`, `TaskSummarySerializer`, `RunTaskSerializer`, `AcknowledgeTasksViewSerializer` |
|
|
||||||
| `workflows.py` | `WorkflowTriggerSerializer`, `WorkflowActionEmailSerializer`, `WorkflowActionWebhookSerializer`, `WorkflowActionSerializer`, `WorkflowSerializer` |
|
|
||||||
| `system.py` | `UiSettingsViewSerializer`, `TrashSerializer` |
|
|
||||||
|
|
||||||
Extraction order matters (later modules reference earlier ones): `base` → `metadata` → `documents` → `upload` → `saved_views` → `bulk_edit` → `sharing` → `tasks` → `workflows` → `system`.
|
|
||||||
|
|
||||||
### `documents/views/` map
|
|
||||||
|
|
||||||
| Module | Symbols |
|
|
||||||
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `base.py` | `PassUserMixin`, `BulkPermissionMixin`, `PermissionsAwareDocumentCountMixin`, `DocumentSelectionMixin`, `DocumentOperationPermissionMixin`, `SearchParams`, `SearchResultPage`, `ResolvedRequestDocs`, `_get_tantivy_query_and_mode`, `_get_more_like_id`, `serve_file` |
|
|
||||||
| `index.py` | `IndexView`, `serve_logo` |
|
|
||||||
| `metadata.py` | `CorrespondentViewSet`, `TagViewSet`, `DocumentTypeViewSet`, `StoragePathViewSet`, `CustomFieldViewSet`, `_get_llm_output_language` |
|
|
||||||
| `documents.py` | `EmailDocumentDetailSchema`, `DocumentViewSet`, `UnifiedSearchViewSet` |
|
|
||||||
| `upload.py` | `PostDocumentView` |
|
|
||||||
| `chat.py` | `ChatStreamingSerializer`, `ChatStreamingView` |
|
|
||||||
| `search.py` | `SearchAutoCompleteView`, `GlobalSearchView`, `SelectionDataView`, `StatisticsView` |
|
|
||||||
| `bulk_edit.py` | `BulkEditView`, `RotateDocumentsView`, `MergeDocumentsView`, `DeleteDocumentsView`, `ReprocessDocumentsView`, `EditPdfDocumentsView`, `RemovePasswordDocumentsView`, `BulkEditObjectsView`, `BulkDownloadView` |
|
|
||||||
| `sharing.py` | `ShareLinkViewSet`, `ShareLinkBundleViewSet`, `SharedLinkView` |
|
|
||||||
| `saved_views.py` | `SavedViewViewSet` |
|
|
||||||
| `tasks.py` | `_TasksViewSetSchema`, `TasksViewSet` |
|
|
||||||
| `workflows.py` | `WorkflowTriggerViewSet`, `WorkflowActionViewSet`, `WorkflowViewSet` |
|
|
||||||
| `system.py` | `UiSettingsView`, `RemoteVersionView`, `SystemStatusView`, `TrashView` |
|
|
||||||
| `logs.py` | `LogViewSet` |
|
|
||||||
|
|
||||||
Extraction order: `base` → `index` → `metadata` → `documents` → `upload` → `chat` → `search` → `bulk_edit` → `sharing` → `saved_views` → `tasks` → `workflows` → `system` → `logs`. Note `ChatStreamingSerializer` is defined in `views.py` today, directly above `ChatStreamingView` — it moves with it into `views/chat.py`, not into the serialisers package.
|
|
||||||
|
|
||||||
### Mechanical extraction recipe (applies to every task below)
|
|
||||||
|
|
||||||
For each module being created:
|
|
||||||
|
|
||||||
1. `grep -n "^class |^def " src/documents/<serialisers|views>.py` to get current line numbers for every symbol still in the monolith (numbers shift as earlier modules are extracted, so re-run this each time, don't reuse stale numbers).
|
|
||||||
2. Create the new file. Start it by copying the **entire top-of-file import block** from the monolith verbatim, plus a relative `from .base import ...` line if the module isn't `base.py` itself.
|
|
||||||
3. Cut each listed symbol (including any decorators/comments immediately above it) from the monolith and paste it into the new file, preserving original order.
|
|
||||||
4. Remove the cut symbols from the monolith.
|
|
||||||
5. Run `ruff check --select F401,F811,F821 <new file> <monolith file>` and fix everything reported:
|
|
||||||
- `F401` (unused import) → delete the import line.
|
|
||||||
- `F821` (undefined name) → the symbol lives in a sibling module already extracted; add `from .<sibling> import <Symbol>`. If it hasn't been extracted yet, that's an ordering bug — stop and re-check the extraction order table.
|
|
||||||
- `F811` (redefinition) → duplicate import, delete one.
|
|
||||||
6. Run `ruff format <new file> <monolith file>`.
|
|
||||||
|
|
||||||
## Task 1: Scaffold `documents/serialisers/` and extract `base.py`
|
|
||||||
|
|
||||||
**Agent:** django-expert — **Model:** sonnet (mechanical extraction, but sets the foundation every later serialiser module imports from — get the base set right or every later task inherits the mistake)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Create: `src/documents/serialisers/__init__.py` (empty — no re-exports, per Global Constraints)
|
|
||||||
- Create: `src/documents/serialisers/base.py`
|
|
||||||
- Modify: `src/documents/serialisers.py` (shrinks; stays in place as the monolith for the remaining tasks in this phase — it is only deleted in Task 2 once empty)
|
|
||||||
- Test: `src/documents/tests/` (full app suite), `src/paperless_mail/tests/`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
|
|
||||||
- Produces: `documents.serialisers.base` exporting `DynamicFieldsModelSerializer`, `DocumentUpdateFieldsModelSerializer`, `MatchingModelSerializer`, `SetPermissionsMixin`, `SerializerWithPerms`, `SetPermissionsSerializer`, `OwnedObjectSerializer`, `OwnedObjectListSerializer`, `ReadWriteSerializerMethodField`, `DocumentListSerializer`, `DocumentSelectionSerializer`, `SourceModeValidationMixin`, `BasicUserSerializer`, `NotesSerializer` — every later serialiser/view module that needs one of these imports `from documents.serialisers.base import <Symbol>`.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create the package directory and empty `__init__.py`**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p src/documents/serialisers
|
|
||||||
touch src/documents/serialisers/__init__.py
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Extract `base.py` per the mechanical extraction recipe above**
|
|
||||||
|
|
||||||
Move exactly these 14 symbols (in their current relative order) out of `src/documents/serialisers.py` into `src/documents/serialisers/base.py`: `DynamicFieldsModelSerializer`, `DocumentUpdateFieldsModelSerializer`, `MatchingModelSerializer`, `SetPermissionsMixin`, `SerializerWithPerms`, `SetPermissionsSerializer`, `OwnedObjectSerializer`, `OwnedObjectListSerializer`, `ReadWriteSerializerMethodField`, `DocumentListSerializer`, `DocumentSelectionSerializer`, `SourceModeValidationMixin`, `BasicUserSerializer`, `NotesSerializer`.
|
|
||||||
|
|
||||||
Run the ruff fix-up (`ruff check --select F401,F811,F821 src/documents/serialisers/base.py src/documents/serialisers.py` then `ruff format` both files) as described in the recipe.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Verify `documents.serialisers` (the monolith module, still at `src/documents/serialisers.py`) still imports cleanly and the app still boots**
|
|
||||||
|
|
||||||
Note: at this point Python resolves `documents.serialisers` to the package `src/documents/serialisers/__init__.py` (empty), **not** to `src/documents/serialisers.py` — having both a `serialisers.py` file and a `serialisers/` directory in the same parent package is invalid and Python will pick the package. So before running anything, rename the monolith out of the way so it's importable as a submodule of the new package for the rest of Phase A:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git mv src/documents/serialisers.py src/documents/serialisers/_monolith.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Everywhere else in this phase, "the monolith file" now means `src/documents/serialisers/_monolith.py`. Because nothing outside this package imports the monolith directly by its old dotted path (`documents.serialisers` resolved to the file before; now it's the package), you must update every consumer of `documents.serialisers` symbols still owned by the monolith to import from `documents.serialisers._monolith` for the remainder of this phase. Concretely, in `src/documents/views.py`, change every `from documents.serialisers import <Symbol>` line for a symbol _not yet extracted_ (i.e., not one of the 14 `base.py` symbols) to `from documents.serialisers._monolith import <Symbol>`, and change the 14 now-extracted symbols' import lines to `from documents.serialisers.base import <Symbol>`. Do the same in `src/paperless_mail/serialisers.py` for `OwnedObjectSerializer` (→ `documents.serialisers.base`); its other three imports (`CorrespondentField`, `DocumentTypeField`, `TagsField`) stay pointed at `documents.serialisers._monolith` until Task 2 moves them into `metadata.py`.
|
|
||||||
|
|
||||||
This `_monolith` re-pointing is scaffolding only — Task 2 finishes emptying and deletes `_monolith.py`, and every import that currently says `._monolith` gets its final home then.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run the full test suite for this app boundary**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests src/paperless_mail/tests -v"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: PASS, no collection errors (a collection error here almost always means a missed import update in `views.py` or `paperless_mail/serialisers.py`).
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/documents/serialisers src/documents/views.py src/paperless_mail/serialisers.py
|
|
||||||
git commit -m "refactor: extract documents/serialisers/base.py from the serialisers monolith"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Task 2: Extract the remaining 8 serialiser domain modules and delete the monolith
|
|
||||||
|
|
||||||
**Agent:** django-expert — **Model:** sonnet (repetitive but each of the 8 modules needs its own cross-reference check against `base.py` and previously-extracted siblings; DocumentSerializer in particular is large and central)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Create: `src/documents/serialisers/metadata.py`, `src/documents/serialisers/documents.py`, `src/documents/serialisers/upload.py`, `src/documents/serialisers/saved_views.py`, `src/documents/serialisers/bulk_edit.py`, `src/documents/serialisers/sharing.py`, `src/documents/serialisers/tasks.py`, `src/documents/serialisers/workflows.py`, `src/documents/serialisers/system.py`
|
|
||||||
- Delete: `src/documents/serialisers/_monolith.py` (once empty)
|
|
||||||
- Modify: `src/documents/views.py` (finish re-pointing every `from documents.serialisers._monolith import X` line at the correct new submodule), `src/paperless_mail/serialisers.py` (re-point `CorrespondentField`, `DocumentTypeField`, `TagsField` at `documents.serialisers.metadata`)
|
|
||||||
- Test: `src/documents/tests/` (full app suite), `src/paperless_mail/tests/`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
|
|
||||||
- Consumes: `documents.serialisers.base` from Task 1 (relative import `.base` within the package).
|
|
||||||
- Produces: the full `documents/serialisers/` package as specified in the Reference map above — this is what Task 3/4 (views split) and Task 5 (external call sites) import from.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Extract the 8 remaining domain modules in order**
|
|
||||||
|
|
||||||
Following the mechanical extraction recipe, and in this exact order (each may depend on symbols extracted earlier in this same order, plus anything in `base.py`):
|
|
||||||
|
|
||||||
1. `metadata.py` — `CorrespondentSerializer`, `DocumentTypeSerializer`, `DeprecatedColors`, `ColorField`, `TagSerializer`, `CorrespondentField`, `TagsField`, `DocumentTypeField`, `StoragePathField`, `StoragePathSerializer`, `StoragePathTestSerializer`, `CustomFieldSerializer`, `CustomFieldInstanceSerializer`, `validate_documentlink_targets`
|
|
||||||
2. `documents.py` — `DocumentSerializer`, `SearchResultListSerializer`, `SearchResultSerializer`, `DuplicateDocumentSummarySerializer`, `_DocumentVersionInfo`, `DocumentVersionInfoSerializer`, `DocumentVersionSerializer`, `DocumentVersionLabelSerializer`, `_get_viewable_duplicates`
|
|
||||||
3. `upload.py` — `PostDocumentSerializer`
|
|
||||||
4. `saved_views.py` — `SavedViewFilterRuleSerializer`, `SavedViewSerializer`
|
|
||||||
5. `bulk_edit.py` — `RotateDocumentsSerializer`, `MergeDocumentsSerializer`, `EditPdfDocumentsSerializer`, `RemovePasswordDocumentsSerializer`, `DeleteDocumentsSerializer`, `ReprocessDocumentsSerializer`, `BulkEditSerializer`, `BulkDownloadSerializer`, `BulkEditObjectsSerializer`
|
|
||||||
6. `sharing.py` — `EmailSerializer`, `ShareLinkSerializer`, `ShareLinkBundleSerializer`
|
|
||||||
7. `tasks.py` — `TaskSerializerV10`, `TaskSerializerV9`, `TaskSummarySerializer`, `RunTaskSerializer`, `AcknowledgeTasksViewSerializer`
|
|
||||||
8. `workflows.py` — `WorkflowTriggerSerializer`, `WorkflowActionEmailSerializer`, `WorkflowActionWebhookSerializer`, `WorkflowActionSerializer`, `WorkflowSerializer`
|
|
||||||
9. `system.py` — `UiSettingsViewSerializer`, `TrashSerializer`
|
|
||||||
|
|
||||||
After each individual module extraction, run the ruff fix-up from the recipe against that new file and `_monolith.py` before moving to the next module (don't batch all 8 and fix imports once at the end — F821 errors compound and get harder to attribute to the right module).
|
|
||||||
|
|
||||||
- [ ] **Step 2: Confirm the monolith is empty and delete it**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -n "^class |^def " src/documents/serialisers/_monolith.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no output. If anything remains, it wasn't in the Reference map — stop and reconcile with the spec rather than deleting a symbol.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git rm src/documents/serialisers/_monolith.py
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Re-point every remaining `._monolith` import**
|
|
||||||
|
|
||||||
Search for any import left pointing at the now-deleted module:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -rn "serialisers\._monolith\|serialisers/_monolith" src/
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no output. Fix any that remain by pointing them at the correct submodule per the Reference map (e.g. `from documents.serialisers._monolith import DocumentSerializer` → `from documents.serialisers.documents import DocumentSerializer`).
|
|
||||||
|
|
||||||
- [ ] **Step 4: Update `paperless_mail/serialisers.py`'s remaining imports**
|
|
||||||
|
|
||||||
```python
|
|
||||||
# was: from documents.serialisers import CorrespondentField, DocumentTypeField, OwnedObjectSerializer, TagsField
|
|
||||||
from documents.serialisers.base import OwnedObjectSerializer
|
|
||||||
from documents.serialisers.metadata import CorrespondentField, DocumentTypeField, TagsField
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Ruff and full test suite**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ruff check src/documents/serialisers src/documents/views.py src/paperless_mail/serialisers.py
|
|
||||||
ruff format src/documents/serialisers src/documents/views.py src/paperless_mail/serialisers.py
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests src/paperless_mail/tests -v"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/documents/serialisers src/documents/views.py src/paperless_mail/serialisers.py
|
|
||||||
git commit -m "refactor: finish splitting serialisers.py into documents/serialisers/"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Task 3: Scaffold `documents/views/` and extract `base.py`
|
|
||||||
|
|
||||||
**Agent:** django-expert — **Model:** sonnet (same shape as Task 1, one level up — views/base.py is imported by every other view module)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Create: `src/documents/views/__init__.py` (empty), `src/documents/views/base.py`
|
|
||||||
- Modify: `src/documents/views.py` → `src/documents/views/_monolith.py` (renamed, same reasoning as Task 1 Step 3)
|
|
||||||
- Modify: `src/paperless/urls.py`, `src/paperless_mail/views.py` (re-point the 1 symbol each currently pulls from `documents.views` that now lives in `base.py`, if any — see step 3)
|
|
||||||
- Test: `src/documents/tests/` (full app suite, includes URL-resolution-dependent tests), `src/paperless_mail/tests/`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
|
|
||||||
- Consumes: `documents.serialisers.*` submodules from Tasks 1–2 (already at final locations — import these directly, e.g. `from documents.serialisers.documents import DocumentSerializer`, never through a monolith or shim).
|
|
||||||
- Produces: `documents.views.base` exporting `PassUserMixin`, `BulkPermissionMixin`, `PermissionsAwareDocumentCountMixin`, `DocumentSelectionMixin`, `DocumentOperationPermissionMixin`, `SearchParams`, `SearchResultPage`, `ResolvedRequestDocs`, `_get_tantivy_query_and_mode`, `_get_more_like_id`, `serve_file`.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create the package directory, empty `__init__.py`, and rename the monolith**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p src/documents/views
|
|
||||||
touch src/documents/views/__init__.py
|
|
||||||
git mv src/documents/views.py src/documents/views/_monolith.py
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Extract `base.py` per the mechanical extraction recipe**
|
|
||||||
|
|
||||||
Move exactly these 11 symbols out of `_monolith.py` into `views/base.py`: `PassUserMixin`, `BulkPermissionMixin`, `PermissionsAwareDocumentCountMixin`, `DocumentSelectionMixin`, `DocumentOperationPermissionMixin`, `SearchParams`, `SearchResultPage`, `ResolvedRequestDocs`, `_get_tantivy_query_and_mode`, `_get_more_like_id`, `serve_file`.
|
|
||||||
|
|
||||||
Within `_monolith.py`, every reference to these 11 symbols needs `from .base import <Symbol>` added (they're used throughout the rest of the file by the not-yet-extracted viewsets).
|
|
||||||
|
|
||||||
- [ ] **Step 3: Re-point external consumers of the now-moved symbol**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -n "from documents.views import PassUserMixin" src/paperless_mail/views.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Update it to `from documents.views.base import PassUserMixin`.
|
|
||||||
|
|
||||||
`paperless/urls.py` doesn't import any of the 11 `base.py` symbols directly (it only imports viewsets/views, which are all still in `_monolith.py` at this point) — confirm with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -nE "from documents\.views import (PassUserMixin|BulkPermissionMixin|PermissionsAwareDocumentCountMixin|DocumentSelectionMixin|DocumentOperationPermissionMixin|serve_file)" src/paperless/urls.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no output. If something does match, re-point it at `documents.views.base` the same way.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Ruff and test**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ruff check src/documents/views src/paperless_mail/views.py
|
|
||||||
ruff format src/documents/views src/paperless_mail/views.py
|
|
||||||
bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests src/paperless_mail/tests -v"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/documents/views src/paperless_mail/views.py
|
|
||||||
git commit -m "refactor: extract documents/views/base.py from the views monolith"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Task 4: Extract the remaining 13 view domain modules and delete the monolith
|
|
||||||
|
|
||||||
**Agent:** django-expert — **Model:** opus (highest blast radius in the plan — `DocumentViewSet` alone is ~1,300 lines and central to the whole API; this task also rewires `paperless/urls.py`'s ~34 import lines that drive URL routing for the entire backend, where a mistake breaks the app at startup, not just in one test)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Create: `src/documents/views/index.py`, `src/documents/views/metadata.py`, `src/documents/views/documents.py`, `src/documents/views/upload.py`, `src/documents/views/chat.py`, `src/documents/views/search.py`, `src/documents/views/bulk_edit.py`, `src/documents/views/sharing.py`, `src/documents/views/saved_views.py`, `src/documents/views/tasks.py`, `src/documents/views/workflows.py`, `src/documents/views/system.py`, `src/documents/views/logs.py`
|
|
||||||
- Delete: `src/documents/views/_monolith.py` (once empty)
|
|
||||||
- Modify: `src/paperless/urls.py` (all ~34 `from documents.views import X` lines)
|
|
||||||
- Test: `src/documents/tests/` (full app suite — includes `test_views.py`, `test_api_documents.py`), `src/paperless_mail/tests/`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
|
|
||||||
- Consumes: `documents.serialisers.*` (Tasks 1–2) and `documents.views.base` (Task 3).
|
|
||||||
- Produces: the full `documents/views/` package as specified in the Reference map above.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Extract the 13 remaining domain modules in order**
|
|
||||||
|
|
||||||
Following the mechanical extraction recipe, in this exact order:
|
|
||||||
|
|
||||||
1. `index.py` — `IndexView`, `serve_logo`
|
|
||||||
2. `metadata.py` — `CorrespondentViewSet`, `TagViewSet`, `DocumentTypeViewSet`, `StoragePathViewSet`, `CustomFieldViewSet`, `_get_llm_output_language`
|
|
||||||
3. `documents.py` — `EmailDocumentDetailSchema`, `DocumentViewSet`, `UnifiedSearchViewSet`
|
|
||||||
4. `upload.py` — `PostDocumentView`
|
|
||||||
5. `chat.py` — `ChatStreamingSerializer`, `ChatStreamingView`
|
|
||||||
6. `search.py` — `SearchAutoCompleteView`, `GlobalSearchView`, `SelectionDataView`, `StatisticsView`
|
|
||||||
7. `bulk_edit.py` — `BulkEditView`, `RotateDocumentsView`, `MergeDocumentsView`, `DeleteDocumentsView`, `ReprocessDocumentsView`, `EditPdfDocumentsView`, `RemovePasswordDocumentsView`, `BulkEditObjectsView`, `BulkDownloadView`
|
|
||||||
8. `sharing.py` — `ShareLinkViewSet`, `ShareLinkBundleViewSet`, `SharedLinkView`
|
|
||||||
9. `saved_views.py` — `SavedViewViewSet`
|
|
||||||
10. `tasks.py` — `_TasksViewSetSchema`, `TasksViewSet`
|
|
||||||
11. `workflows.py` — `WorkflowTriggerViewSet`, `WorkflowActionViewSet`, `WorkflowViewSet`
|
|
||||||
12. `system.py` — `UiSettingsView`, `RemoteVersionView`, `SystemStatusView`, `TrashView`
|
|
||||||
13. `logs.py` — `LogViewSet`
|
|
||||||
|
|
||||||
After each module, run the ruff fix-up from the recipe before continuing to the next (same rationale as Task 2 Step 1 — attribute F821s to the right module while context is fresh). `documents.py` is the biggest single extraction in this whole plan (`DocumentViewSet` is ~1,300 lines) — expect the most F821 fix-ups here, mostly resolved by adding `from documents.serialisers.documents import ...`, `from documents.serialisers.metadata import ...`, and `from .base import ...` as needed.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Confirm the monolith is empty and delete it**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -n "^class |^def " src/documents/views/_monolith.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no output.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git rm src/documents/views/_monolith.py
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Re-point every remaining `._monolith` import**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -rn "views\._monolith\|views/_monolith" src/
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no output. Fix any stragglers per the Reference map.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Update `paperless/urls.py`**
|
|
||||||
|
|
||||||
Replace each of the ~34 `from documents.views import X` lines with `from documents.views.<domain> import X` per the Reference map. For example:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# was:
|
|
||||||
from documents.views import CorrespondentViewSet
|
|
||||||
from documents.views import WorkflowViewSet
|
|
||||||
from documents.views import serve_logo
|
|
||||||
# becomes:
|
|
||||||
from documents.views.metadata import CorrespondentViewSet
|
|
||||||
from documents.views.workflows import WorkflowViewSet
|
|
||||||
from documents.views.index import serve_logo
|
|
||||||
```
|
|
||||||
|
|
||||||
Do this for every import in that block — check off against the full symbol list in the Reference map above so none are missed.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Ruff and test**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ruff check src/documents/views src/paperless/urls.py
|
|
||||||
ruff format src/documents/views src/paperless/urls.py
|
|
||||||
bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests src/paperless_mail/tests -v"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: PASS, including `test_views.py` and `test_api_documents.py` — these exercise URL routing end-to-end, so a broken `urls.py` import shows up here as a collection error.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/documents/views src/paperless/urls.py
|
|
||||||
git commit -m "refactor: finish splitting views.py into documents/views/"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Task 5: Repo-wide verification sweep
|
|
||||||
|
|
||||||
**Agent:** general-purpose — **Model:** sonnet (an audit/verification pass: run targeted checks, read the output, fix anything found — moderate judgment, not novel design work)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: any file a grep in this task turns up beyond the ones already handled in Tasks 1–4 (expected: none, per the spec's stated blast radius of exactly `paperless/urls.py`, `paperless_mail/views.py`, `paperless_mail/serialisers.py` — this task exists to confirm that, not to find new work)
|
|
||||||
- Test: full backend suite (all apps, not just `documents`/`paperless_mail`)
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
|
|
||||||
- Consumes: the finished `documents/views/` and `documents/serialisers/` packages from Tasks 1–4.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Grep the whole repo for any remaining bare-module reference**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -rn "from documents\.views import\|from documents\.serialisers import\|documents\.views\.\_monolith\|documents\.serialisers\.\_monolith\|import documents\.views$\|import documents\.serialisers$" src/
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no output. `documents/views/__init__.py` and `documents/serialisers/__init__.py` should still be empty (`0` bytes or a single blank line) — confirm with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
wc -l src/documents/views/__init__.py src/documents/serialisers/__init__.py
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Confirm import direction was never violated**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -rln "from documents\.views" src/documents/serialisers/
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no output (no file in `serialisers/` imports from `views/`).
|
|
||||||
|
|
||||||
- [ ] **Step 3: Full ruff pass**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ruff check src/documents/views src/documents/serialisers src/paperless/urls.py src/paperless_mail/views.py src/paperless_mail/serialisers.py
|
|
||||||
ruff format --check src/documents/views src/documents/serialisers src/paperless/urls.py src/paperless_mail/views.py src/paperless_mail/serialisers.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: clean.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Full backend test suite**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "-v"
|
|
||||||
```
|
|
||||||
|
|
||||||
(No path filter — this runs the whole backend suite, confirming nothing outside `documents`/`paperless_mail` was quietly relying on the old module shape, e.g. a management command or a script under `scripts/`.)
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 5: If Steps 1–4 found nothing to fix, commit is a no-op — skip it. If they found strays, fix and commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add -A
|
|
||||||
git commit -m "refactor: fix stray documents.views/serialisers references found in repo sweep"
|
|
||||||
```
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
# Split `documents/views.py` and `documents/serialisers.py` into modules
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
`src/documents/views.py` (5,395 lines) and `src/documents/serialisers.py`
|
|
||||||
(3,532 lines) have grown into monolithic files covering every REST resource
|
|
||||||
in the `documents` app: correspondents, tags, document types, storage paths,
|
|
||||||
custom fields, the core document viewset and search, chat, bulk-edit
|
|
||||||
operations, sharing, saved views, tasks, workflows, and system/UI settings.
|
|
||||||
Their size makes them hard to navigate, hard to review incrementally, and
|
|
||||||
increases the chance of unrelated changes colliding in the same file.
|
|
||||||
|
|
||||||
This document specifies splitting both files into packages, one module per
|
|
||||||
domain area, with no behavior change.
|
|
||||||
|
|
||||||
## Non-goals
|
|
||||||
|
|
||||||
- No behavior change. Class names, method bodies, and public API responses
|
|
||||||
are unchanged — this is a pure move/reorganize.
|
|
||||||
- No change to `test_views.py` or `test_api_documents.py`. They exercise the
|
|
||||||
moved classes via imports or via the live API; class names and behavior
|
|
||||||
don't change, so they need no edits. Splitting those test files is a
|
|
||||||
separate, later task if desired.
|
|
||||||
- No change to the frontend, migrations, or any other app beyond the three
|
|
||||||
files that import from `documents.views` / `documents.serialisers`
|
|
||||||
(`paperless/urls.py`, `paperless_mail/views.py`,
|
|
||||||
`paperless_mail/serialisers.py`).
|
|
||||||
- This work happens as its own branch/PR against `dev`, after the in-flight
|
|
||||||
`feature-ai-taxonomy-hints-v2` work merges — not layered on top of it.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
`documents/views.py` becomes the package `documents/views/`, and
|
|
||||||
`documents/serialisers.py` becomes `documents/serialisers/`. Each gets one
|
|
||||||
module per domain area (table below). Neither package's `__init__.py`
|
|
||||||
re-exports its submodules' contents — it stays empty (or a short docstring
|
|
||||||
only). The three external call sites that currently do
|
|
||||||
`from documents.views import X` / `from documents.serialisers import X` are
|
|
||||||
updated to import from the specific submodule instead
|
|
||||||
(`from documents.views.workflows import WorkflowViewSet`, etc.). This avoids
|
|
||||||
adding an indirection layer that could quietly regrow into a second dumping
|
|
||||||
ground, at the cost of touching those three files.
|
|
||||||
|
|
||||||
### Import direction
|
|
||||||
|
|
||||||
`views/*` modules may import from `serialisers/*` modules; `serialisers/*`
|
|
||||||
modules never import from `views/*`. This keeps the dependency graph acyclic
|
|
||||||
by construction — there is no case in the current code where a serializer
|
|
||||||
needs a view.
|
|
||||||
|
|
||||||
Domain module names are the same across both packages (e.g. `bulk_edit.py`
|
|
||||||
exists in both), which makes the natural import `from documents.serialisers.bulk_edit import BulkEditSerializer`
|
|
||||||
inside `documents/views/bulk_edit.py` easy to find, but a view is free to
|
|
||||||
import a serializer from a different domain module when needed (e.g. a
|
|
||||||
`documents.py` view using a `metadata.py` field serializer) — that's a plain
|
|
||||||
cross-module import, not a cycle risk, since the reverse direction never
|
|
||||||
happens.
|
|
||||||
|
|
||||||
## Module breakdown — `documents/views/`
|
|
||||||
|
|
||||||
| Module | Contents |
|
|
||||||
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `base.py` | Shared mixins/helpers: `PassUserMixin`, `BulkPermissionMixin`, `PermissionsAwareDocumentCountMixin`, `DocumentSelectionMixin`, `DocumentOperationPermissionMixin`, `SearchParams`/`SearchResultPage`/`ResolvedRequestDocs`, `_get_tantivy_query_and_mode`, `_get_more_like_id`, `serve_file` |
|
|
||||||
| `index.py` | `IndexView`, `serve_logo` |
|
|
||||||
| `metadata.py` | `CorrespondentViewSet`, `TagViewSet`, `DocumentTypeViewSet`, `StoragePathViewSet`, `CustomFieldViewSet`, `_get_llm_output_language` |
|
|
||||||
| `documents.py` | `EmailDocumentDetailSchema`, `DocumentViewSet`, `UnifiedSearchViewSet` |
|
|
||||||
| `upload.py` | `PostDocumentView` |
|
|
||||||
| `chat.py` | `ChatStreamingSerializer`, `ChatStreamingView` |
|
|
||||||
| `search.py` | `SearchAutoCompleteView`, `GlobalSearchView`, `SelectionDataView`, `StatisticsView` |
|
|
||||||
| `bulk_edit.py` | `BulkEditView`, `RotateDocumentsView`, `MergeDocumentsView`, `DeleteDocumentsView`, `ReprocessDocumentsView`, `EditPdfDocumentsView`, `RemovePasswordDocumentsView`, `BulkEditObjectsView`, `BulkDownloadView` |
|
|
||||||
| `sharing.py` | `ShareLinkViewSet`, `ShareLinkBundleViewSet`, `SharedLinkView` |
|
|
||||||
| `saved_views.py` | `SavedViewViewSet` |
|
|
||||||
| `tasks.py` | `_TasksViewSetSchema`, `TasksViewSet` |
|
|
||||||
| `workflows.py` | `WorkflowTriggerViewSet`, `WorkflowActionViewSet`, `WorkflowViewSet` |
|
|
||||||
| `system.py` | `UiSettingsView`, `RemoteVersionView`, `SystemStatusView`, `TrashView` |
|
|
||||||
| `logs.py` | `LogViewSet` |
|
|
||||||
|
|
||||||
`documents.py` remains the largest module at roughly 1,600 lines
|
|
||||||
(`DocumentViewSet` alone is ~1,300 lines in the current file); every other
|
|
||||||
module is well under 500 lines.
|
|
||||||
|
|
||||||
## Module breakdown — `documents/serialisers/`
|
|
||||||
|
|
||||||
| Module | Contents |
|
|
||||||
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `base.py` | `DynamicFieldsModelSerializer`, `DocumentUpdateFieldsModelSerializer`, `MatchingModelSerializer`, `SetPermissionsMixin`, `SerializerWithPerms`, `SetPermissionsSerializer`, `OwnedObjectSerializer`, `OwnedObjectListSerializer`, `ReadWriteSerializerMethodField`, `DocumentListSerializer`, `DocumentSelectionSerializer`, `SourceModeValidationMixin`, `BasicUserSerializer`, `NotesSerializer` |
|
|
||||||
| `metadata.py` | `CorrespondentSerializer`, `DocumentTypeSerializer`, `DeprecatedColors`, `ColorField`, `TagSerializer`, `CorrespondentField`, `TagsField`, `DocumentTypeField`, `StoragePathField`, `StoragePathSerializer`, `StoragePathTestSerializer`, `CustomFieldSerializer`, `CustomFieldInstanceSerializer`, `validate_documentlink_targets` |
|
|
||||||
| `documents.py` | `DocumentSerializer`, `SearchResultListSerializer`, `SearchResultSerializer`, `DuplicateDocumentSummarySerializer`, `_DocumentVersionInfo`, `DocumentVersionInfoSerializer`, `DocumentVersionSerializer`, `DocumentVersionLabelSerializer`, `_get_viewable_duplicates` |
|
|
||||||
| `upload.py` | `PostDocumentSerializer` |
|
|
||||||
| `saved_views.py` | `SavedViewFilterRuleSerializer`, `SavedViewSerializer` |
|
|
||||||
| `bulk_edit.py` | `RotateDocumentsSerializer`, `MergeDocumentsSerializer`, `EditPdfDocumentsSerializer`, `RemovePasswordDocumentsSerializer`, `DeleteDocumentsSerializer`, `ReprocessDocumentsSerializer`, `BulkEditSerializer`, `BulkDownloadSerializer`, `BulkEditObjectsSerializer` |
|
|
||||||
| `sharing.py` | `EmailSerializer`, `ShareLinkSerializer`, `ShareLinkBundleSerializer` |
|
|
||||||
| `tasks.py` | `TaskSerializerV10`, `TaskSerializerV9`, `TaskSummarySerializer`, `RunTaskSerializer`, `AcknowledgeTasksViewSerializer` |
|
|
||||||
| `workflows.py` | `WorkflowTriggerSerializer`, `WorkflowActionEmailSerializer`, `WorkflowActionWebhookSerializer`, `WorkflowActionSerializer`, `WorkflowSerializer` |
|
|
||||||
| `system.py` | `UiSettingsViewSerializer`, `TrashSerializer` |
|
|
||||||
|
|
||||||
Note: `ChatStreamingSerializer` is defined in `views.py` today (not
|
|
||||||
`serialisers.py`), directly above `ChatStreamingView`. It moves with
|
|
||||||
`ChatStreamingView` into `documents/views/chat.py` rather than into the
|
|
||||||
serialisers package, preserving its current co-location.
|
|
||||||
|
|
||||||
## External call sites to update
|
|
||||||
|
|
||||||
Only three files import from these two modules today, and all move to
|
|
||||||
importing from the specific new submodule:
|
|
||||||
|
|
||||||
- `src/paperless/urls.py` — ~34 `from documents.views import X` lines, one
|
|
||||||
per viewset/view used in URL routing. Each becomes
|
|
||||||
`from documents.views.<domain> import X`.
|
|
||||||
- `src/paperless_mail/views.py` — `from documents.views import PassUserMixin`
|
|
||||||
becomes `from documents.views.base import PassUserMixin`.
|
|
||||||
- `src/paperless_mail/serialisers.py` — `CorrespondentField`,
|
|
||||||
`DocumentTypeField`, `OwnedObjectSerializer`, `TagsField` move to
|
|
||||||
`from documents.serialisers.metadata import CorrespondentField, DocumentTypeField, TagsField`
|
|
||||||
and `from documents.serialisers.base import OwnedObjectSerializer`.
|
|
||||||
|
|
||||||
## Migration order
|
|
||||||
|
|
||||||
1. Split `serialisers.py` into `documents/serialisers/` first — serializers
|
|
||||||
have no dependency on views, so this half can be verified in isolation.
|
|
||||||
Run the full backend test suite after this step.
|
|
||||||
2. Split `views.py` into `documents/views/`, importing from the new
|
|
||||||
`documents/serialisers/*` modules per the table above. Run the full
|
|
||||||
backend test suite.
|
|
||||||
3. Update the three external call sites (`paperless/urls.py`,
|
|
||||||
`paperless_mail/views.py`, `paperless_mail/serialisers.py`).
|
|
||||||
4. Run `ruff check` / `ruff format` and the full backend test suite once
|
|
||||||
more end to end.
|
|
||||||
|
|
||||||
Splitting serialisers before views (rather than in parallel) means step 2
|
|
||||||
can immediately import finished, correctly-located serializer modules
|
|
||||||
instead of guessing at not-yet-final paths.
|
|
||||||
|
|
||||||
## Risks / error handling
|
|
||||||
|
|
||||||
- **Circular imports**: prevented by construction (serialisers never import
|
|
||||||
from views — see Import direction above). If a genuine cross-domain need
|
|
||||||
is discovered during implementation that seems to require a
|
|
||||||
views→views import cycle (e.g. `UnifiedSearchViewSet` extending
|
|
||||||
`DocumentViewSet` from a different module — both already live in
|
|
||||||
`documents.py` so this doesn't arise), resolve it by moving the shared
|
|
||||||
piece to `base.py` rather than introducing a cycle.
|
|
||||||
- **Missed re-export consumers**: verified via a full-repo grep for
|
|
||||||
`from documents.views import` / `from documents.serialisers import` /
|
|
||||||
`documents.views.` / `documents.serialisers.` before considering the split
|
|
||||||
complete, in case something beyond the three known call sites appears
|
|
||||||
(e.g. in a management command or a rarely-run script).
|
|
||||||
- **Silent behavior drift during move**: since this is a pure reorganization,
|
|
||||||
the full test suite passing after each step (rather than only at the end)
|
|
||||||
is the primary safety net; no new tests are required for this refactor
|
|
||||||
itself.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
No new tests. Existing coverage (`test_views.py`, `test_api_documents.py`,
|
|
||||||
and the rest of the `documents` test suite) is run after each migration step
|
|
||||||
per the ordering above, and must pass unchanged — a failure indicates the
|
|
||||||
move altered behavior, not that new coverage is needed.
|
|
||||||
@@ -99,6 +99,10 @@ Think of versions as **file history** for a document.
|
|||||||
- By default, search and document content use the latest version.
|
- 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.
|
- 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.
|
- 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
|
### Management Lists
|
||||||
|
|
||||||
@@ -424,8 +428,8 @@ determine if a user can create, edit, delete or view _any_ documents, but indivi
|
|||||||
still have "object-level" permissions.
|
still have "object-level" permissions.
|
||||||
|
|
||||||
| Type | Details |
|
| Type | Details |
|
||||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| AppConfig | _Change_ or higher permissions grants access to the "Application Configuration" area. |
|
| AppConfig | _Change_ or higher permissions grants access to the "Application Configuration" area.<br/>:warning: **This is a trusted, admin-level permission.** Application configuration applies instance-wide and some settings, such as OCR arguments, are passed directly to the underlying processing tools. |
|
||||||
| Correspondent | Add, edit, delete or view Correspondents. |
|
| Correspondent | Add, edit, delete or view Correspondents. |
|
||||||
| CustomField | Add, edit, delete or view Custom Fields. |
|
| CustomField | Add, edit, delete or view Custom Fields. |
|
||||||
| Document | Add, edit, delete or view Documents. |
|
| Document | Add, edit, delete or view Documents. |
|
||||||
@@ -650,6 +654,48 @@ 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
|
**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.
|
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.
|
||||||
|
|
||||||
|
##### Apply AI Suggestions {#workflow-action-apply-ai-suggestions}
|
||||||
|
|
||||||
|
"Apply AI Suggestions" actions ask the configured AI service for title and metadata suggestions,
|
||||||
|
the same as the AI suggestions shown on the document detail page, except applied automatically and in bulk.
|
||||||
|
It requires [AI features](configuration.md#ai) to be enabled. You can specify:
|
||||||
|
|
||||||
|
- Which suggestions to apply: title, tags, correspondent, document type, storage path and / or created
|
||||||
|
date. Suggestions for fields you did not select are discarded.
|
||||||
|
- Whether to create missing items. By default only tags, correspondents and document types that
|
||||||
|
already exist are assigned and any other suggestion is dropped. With this enabled, suggested items
|
||||||
|
that do not exist are created. Storage paths are never created.
|
||||||
|
- Whether to overwrite existing values. By default a field is only filled in if it is currently empty.
|
||||||
|
Note that documents almost always already have a title and created date, so if you select those you
|
||||||
|
will usually want to enable this too. Tags are an exception: suggested tags are always added and
|
||||||
|
never replace the document's existing tags.
|
||||||
|
|
||||||
|
The action works with every trigger **except Consumption Started**, because suggestions are made from
|
||||||
|
the document's text, which does not exist until after the document has been processed.
|
||||||
|
|
||||||
|
Because the query to the AI service is slow, the action is queued and runs in the background rather
|
||||||
|
than as part of the workflow run itself. The document is updated once the suggestions come back.
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
|
||||||
|
Every matching document results in a query to the AI service, which may incur costs and have privacy
|
||||||
|
implications. Queries can be slow, so a workflow matching a large number of documents can occupy the
|
||||||
|
task queue, and delay consumption of new documents, etc. Consider narrowing the trigger filters,
|
||||||
|
running in small batches and / or increasing workers.
|
||||||
|
|
||||||
#### Workflow placeholders
|
#### Workflow placeholders
|
||||||
|
|
||||||
Titles and webhook payloads can be generated by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
|
Titles and webhook payloads can be generated by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
|
||||||
@@ -1086,11 +1132,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).
|
[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
|
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
|
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
|
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).
|
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
|
## Architecture
|
||||||
|
|
||||||
Paperless-ngx consists of the following components:
|
Paperless-ngx consists of the following components:
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ PAPERLESS_SECRET_KEY=change-me
|
|||||||
#PAPERLESS_AUTO_LOGIN_USERNAME=
|
#PAPERLESS_AUTO_LOGIN_USERNAME=
|
||||||
#PAPERLESS_COOKIE_PREFIX=
|
#PAPERLESS_COOKIE_PREFIX=
|
||||||
#PAPERLESS_ENABLE_HTTP_REMOTE_USER=false
|
#PAPERLESS_ENABLE_HTTP_REMOTE_USER=false
|
||||||
|
#PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP=
|
||||||
|
#PAPERLESS_SOCIAL_ACCOUNT_SYNC_STAFF_GROUP=
|
||||||
|
|
||||||
# OCR settings
|
# OCR settings
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "paperless-ngx"
|
name = "paperless-ngx"
|
||||||
version = "3.0.5"
|
version = "3.1.2"
|
||||||
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
@@ -43,11 +43,11 @@ dependencies = [
|
|||||||
"drf-writable-nested~=0.7.1",
|
"drf-writable-nested~=0.7.1",
|
||||||
"filelock~=3.32.0",
|
"filelock~=3.32.0",
|
||||||
"flower~=2.0.1",
|
"flower~=2.0.1",
|
||||||
"gotenberg-client~=0.14.0",
|
"gotenberg-client>=0.14,<1.1",
|
||||||
"httpx-oauth~=0.17",
|
"httpx-oauth~=0.17",
|
||||||
"ijson>=3.5.1",
|
"ijson>=3.5.1",
|
||||||
"imap-tools~=1.14.0",
|
"imap-tools~=1.14.0",
|
||||||
"jinja2~=3.1.5",
|
"jinja2~=3.1.6",
|
||||||
"langdetect~=1.0.9",
|
"langdetect~=1.0.9",
|
||||||
"llama-index-core>=0.14.23",
|
"llama-index-core>=0.14.23",
|
||||||
"llama-index-embeddings-huggingface>=0.6.1",
|
"llama-index-embeddings-huggingface>=0.6.1",
|
||||||
@@ -56,7 +56,7 @@ dependencies = [
|
|||||||
"llama-index-llms-ollama>=0.9.1",
|
"llama-index-llms-ollama>=0.9.1",
|
||||||
"llama-index-llms-openai-like>=0.7.1",
|
"llama-index-llms-openai-like>=0.7.1",
|
||||||
"nltk~=3.10.0",
|
"nltk~=3.10.0",
|
||||||
"ocrmypdf~=17.7.0",
|
"ocrmypdf>=17.7,<17.11",
|
||||||
"openai>=2.48",
|
"openai>=2.48",
|
||||||
"pathvalidate~=3.3.1",
|
"pathvalidate~=3.3.1",
|
||||||
"pdf2image~=1.17.0",
|
"pdf2image~=1.17.0",
|
||||||
@@ -73,7 +73,7 @@ dependencies = [
|
|||||||
"setproctitle~=1.3.4",
|
"setproctitle~=1.3.4",
|
||||||
"sqlite-vec==0.1.9",
|
"sqlite-vec==0.1.9",
|
||||||
"tantivy~=0.26.0",
|
"tantivy~=0.26.0",
|
||||||
"tika-client~=0.11.0",
|
"tika-client>=0.11,<1.1",
|
||||||
"torch~=2.13.0",
|
"torch~=2.13.0",
|
||||||
"watchfiles>=1.2",
|
"watchfiles>=1.2",
|
||||||
"whitenoise~=6.11",
|
"whitenoise~=6.11",
|
||||||
@@ -84,9 +84,9 @@ mariadb = [
|
|||||||
"mysqlclient~=2.2.7",
|
"mysqlclient~=2.2.7",
|
||||||
]
|
]
|
||||||
postgres = [
|
postgres = [
|
||||||
"psycopg[c,pool]==3.3",
|
"psycopg[c,pool]==3.3.4",
|
||||||
# Direct dependency for proper resolution of the pre-built wheels
|
# Direct dependency for proper resolution of the pre-built wheels
|
||||||
"psycopg-c==3.3",
|
"psycopg-c==3.3.4",
|
||||||
"psycopg-pool==3.3.1",
|
"psycopg-pool==3.3.1",
|
||||||
]
|
]
|
||||||
webserver = [
|
webserver = [
|
||||||
@@ -160,8 +160,10 @@ explicit = true
|
|||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
# Markers are chosen to select these almost exclusively when building the Docker image
|
# Markers are chosen to select these almost exclusively when building the Docker image
|
||||||
psycopg-c = [
|
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.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.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_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 = [
|
torch = [
|
||||||
{ index = "pytorch-cpu" },
|
{ index = "pytorch-cpu" },
|
||||||
|
|||||||
@@ -139,6 +139,17 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"e2e": {
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.e2e.ts"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"localize": [
|
||||||
|
"en-US"
|
||||||
|
]
|
||||||
|
},
|
||||||
"en-US": {
|
"en-US": {
|
||||||
"localize": [
|
"localize": [
|
||||||
"en-US"
|
"en-US"
|
||||||
@@ -155,6 +166,9 @@
|
|||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
"buildTarget": "paperless-ui:build:production"
|
"buildTarget": "paperless-ui:build:production"
|
||||||
|
},
|
||||||
|
"e2e": {
|
||||||
|
"buildTarget": "paperless-ui:build:e2e"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,194 +0,0 @@
|
|||||||
{
|
|
||||||
"log": {
|
|
||||||
"version": "1.2",
|
|
||||||
"creator": {
|
|
||||||
"name": "Playwright",
|
|
||||||
"version": "1.33.0"
|
|
||||||
},
|
|
||||||
"browser": {
|
|
||||||
"name": "chromium",
|
|
||||||
"version": "113.0.5672.53"
|
|
||||||
},
|
|
||||||
"entries": [
|
|
||||||
{
|
|
||||||
"startedDateTime": "2023-05-14T07:18:59.856Z",
|
|
||||||
"time": 6.025,
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"url": "http://localhost:8000/api/ui_settings/",
|
|
||||||
"httpVersion": "HTTP/1.1",
|
|
||||||
"cookies": [],
|
|
||||||
"headers": [
|
|
||||||
{ "name": "Accept", "value": "application/json; version=3" },
|
|
||||||
{ "name": "Accept-Encoding", "value": "gzip, deflate, br" },
|
|
||||||
{ "name": "Accept-Language", "value": "en-US" },
|
|
||||||
{ "name": "Connection", "value": "keep-alive" },
|
|
||||||
{ "name": "Host", "value": "localhost:8000" },
|
|
||||||
{ "name": "Origin", "value": "http://localhost:4200" },
|
|
||||||
{ "name": "Referer", "value": "http://localhost:4200/" },
|
|
||||||
{ "name": "Sec-Fetch-Dest", "value": "empty" },
|
|
||||||
{ "name": "Sec-Fetch-Mode", "value": "cors" },
|
|
||||||
{ "name": "Sec-Fetch-Site", "value": "same-site" },
|
|
||||||
{ "name": "User-Agent", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.53 Safari/537.36" }
|
|
||||||
],
|
|
||||||
"queryString": [],
|
|
||||||
"headersSize": -1,
|
|
||||||
"bodySize": -1
|
|
||||||
},
|
|
||||||
"response": {
|
|
||||||
"status": 200,
|
|
||||||
"statusText": "OK",
|
|
||||||
"httpVersion": "HTTP/1.1",
|
|
||||||
"cookies": [],
|
|
||||||
"headers": [
|
|
||||||
{ "name": "Access-Control-Allow-Origin", "value": "http://localhost:4200" },
|
|
||||||
{ "name": "Allow", "value": "GET, POST, HEAD, OPTIONS" },
|
|
||||||
{ "name": "Content-Encoding", "value": "br" },
|
|
||||||
{ "name": "Content-Language", "value": "en-us" },
|
|
||||||
{ "name": "Content-Length", "value": "953" },
|
|
||||||
{ "name": "Content-Type", "value": "application/json" },
|
|
||||||
{ "name": "Cross-Origin-Opener-Policy", "value": "same-origin" },
|
|
||||||
{ "name": "Referrer-Policy", "value": "same-origin" },
|
|
||||||
{ "name": "Vary", "value": "Accept, Accept-Language, Origin, Cookie, Accept-Encoding" },
|
|
||||||
{ "name": "X-Api-Version", "value": "3" },
|
|
||||||
{ "name": "X-Content-Type-Options", "value": "nosniff" },
|
|
||||||
{ "name": "X-Frame-Options", "value": "ANY" },
|
|
||||||
{ "name": "X-Version", "value": "1.14.4" }
|
|
||||||
],
|
|
||||||
"content": {
|
|
||||||
"size": -1,
|
|
||||||
"mimeType": "application/json",
|
|
||||||
"text": "{\"user\":{\"id\":2,\"username\":\"testuser\",\"is_superuser\":false,\"groups\":[]},\"settings\":{\"language\":\"\",\"bulk_edit\":{\"confirmation_dialogs\":true,\"apply_on_close\":false},\"documentListSize\":50,\"dark_mode\":{\"use_system\":true,\"enabled\":\"false\",\"thumb_inverted\":\"true\"},\"theme\":{\"color\":\"#9fbf2f\"},\"document_details\":{\"native_pdf_viewer\":false},\"date_display\":{\"date_locale\":\"\",\"date_format\":\"mediumDate\"},\"notifications\":{\"consumer_new_documents\":true,\"consumer_success\":true,\"consumer_failed\":true,\"consumer_suppress_on_dashboard\":true},\"comments_enabled\":true,\"slim_sidebar\":false,\"update_checking\":{\"enabled\":false,\"backend_setting\":\"default\"},\"saved_views\":{\"warn_on_unsaved_change\":true},\"notes_enabled\":true,\"tour_complete\":true},\"permissions\":[\"change_savedview\",\"change_schedule\",\"change_failure\",\"delete_token\",\"add_mailrule\",\"view_failure\",\"view_groupresult\",\"add_note\",\"change_taskresult\",\"view_tag\",\"view_user\",\"add_tag\",\"change_processedmail\",\"change_session\",\"view_taskattributes\",\"delete_groupresult\",\"delete_correspondent\",\"delete_schedule\",\"delete_contenttype\",\"view_chordcounter\",\"view_success\",\"delete_documenttype\",\"add_tokenproxy\",\"delete_paperlesstask\",\"add_log\",\"view_mailaccount\",\"add_uisettings\",\"view_savedview\",\"view_uisettings\",\"delete_storagepath\",\"delete_frontendsettings\",\"change_paperlesstask\",\"view_taskresult\",\"delete_processedmail\",\"view_processedmail\",\"view_session\",\"delete_chordcounter\",\"view_note\",\"delete_session\",\"view_document\",\"change_mailaccount\",\"delete_taskattributes\",\"add_groupobjectpermission\",\"view_mailrule\",\"change_savedviewfilterrule\",\"change_log\",\"change_comment\",\"add_mailaccount\",\"add_frontendsettings\",\"add_userobjectpermission\",\"delete_note\",\"view_token\",\"add_failure\",\"delete_user\",\"add_success\",\"view_ormq\",\"view_tokenproxy\",\"delete_uisettings\",\"change_groupobjectpermission\",\"add_logentry\",\"add_ormq\",\"view_frontendsettings\",\"view_schedule\",\"change_taskattributes\",\"view_documenttype\",\"view_logentry\",\"change_correspondent\",\"add_groupresult\",\"delete_groupobjectpermission\",\"change_mailrule\",\"change_permission\",\"delete_log\",\"view_userobjectpermission\",\"view_correspondent\",\"delete_document\",\"change_uisettings\",\"change_storagepath\",\"change_document\",\"delete_tokenproxy\",\"change_note\",\"delete_permission\",\"change_contenttype\",\"add_token\",\"change_success\",\"delete_logentry\",\"view_savedviewfilterrule\",\"delete_task\",\"add_savedview\",\"add_paperlesstask\",\"add_task\",\"change_documenttype\",\"add_documenttype\",\"change_token\",\"view_task\",\"view_permission\",\"change_task\",\"delete_userobjectpermission\",\"change_group\",\"add_group\",\"change_tag\",\"change_chordcounter\",\"add_storagepath\",\"delete_group\",\"add_taskattributes\",\"delete_mailaccount\",\"delete_tag\",\"add_schedule\",\"delete_failure\",\"delete_mailrule\",\"add_savedviewfilterrule\",\"change_ormq\",\"change_logentry\",\"add_taskresult\",\"view_group\",\"delete_comment\",\"add_contenttype\",\"add_document\",\"change_tokenproxy\",\"delete_success\",\"add_comment\",\"delete_ormq\",\"add_processedmail\",\"view_paperlesstask\",\"delete_savedview\",\"change_user\",\"add_session\",\"view_groupobjectpermission\",\"add_user\",\"add_correspondent\",\"delete_taskresult\",\"view_contenttype\",\"view_storagepath\",\"add_permission\",\"change_userobjectpermission\",\"delete_savedviewfilterrule\",\"change_groupresult\",\"add_chordcounter\",\"view_log\",\"view_comment\",\"change_frontendsettings\"]}"
|
|
||||||
},
|
|
||||||
"headersSize": -1,
|
|
||||||
"bodySize": -1,
|
|
||||||
"redirectURL": ""
|
|
||||||
},
|
|
||||||
"cache": {},
|
|
||||||
"timings": { "send": -1, "wait": -1, "receive": 6.025 }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"startedDateTime": "2023-05-14T07:18:59.990Z",
|
|
||||||
"time": 1.082,
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"url": "http://localhost:8000/api/saved_views/?page=1&page_size=100000",
|
|
||||||
"httpVersion": "HTTP/1.1",
|
|
||||||
"cookies": [],
|
|
||||||
"headers": [
|
|
||||||
{ "name": "Accept", "value": "application/json; version=3" },
|
|
||||||
{ "name": "Accept-Encoding", "value": "gzip, deflate, br" },
|
|
||||||
{ "name": "Accept-Language", "value": "en-US" },
|
|
||||||
{ "name": "Connection", "value": "keep-alive" },
|
|
||||||
{ "name": "Host", "value": "localhost:8000" },
|
|
||||||
{ "name": "Origin", "value": "http://localhost:4200" },
|
|
||||||
{ "name": "Referer", "value": "http://localhost:4200/" },
|
|
||||||
{ "name": "Sec-Fetch-Dest", "value": "empty" },
|
|
||||||
{ "name": "Sec-Fetch-Mode", "value": "cors" },
|
|
||||||
{ "name": "Sec-Fetch-Site", "value": "same-site" },
|
|
||||||
{ "name": "User-Agent", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.53 Safari/537.36" }
|
|
||||||
],
|
|
||||||
"queryString": [
|
|
||||||
{
|
|
||||||
"name": "page",
|
|
||||||
"value": "1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "page_size",
|
|
||||||
"value": "100000"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"headersSize": -1,
|
|
||||||
"bodySize": -1
|
|
||||||
},
|
|
||||||
"response": {
|
|
||||||
"status": 200,
|
|
||||||
"statusText": "OK",
|
|
||||||
"httpVersion": "HTTP/1.1",
|
|
||||||
"cookies": [],
|
|
||||||
"headers": [
|
|
||||||
{ "name": "Access-Control-Allow-Origin", "value": "http://localhost:4200" },
|
|
||||||
{ "name": "Allow", "value": "GET, POST, HEAD, OPTIONS" },
|
|
||||||
{ "name": "Content-Encoding", "value": "br" },
|
|
||||||
{ "name": "Content-Language", "value": "en-us" },
|
|
||||||
{ "name": "Content-Length", "value": "851" },
|
|
||||||
{ "name": "Content-Type", "value": "application/json" },
|
|
||||||
{ "name": "Cross-Origin-Opener-Policy", "value": "same-origin" },
|
|
||||||
{ "name": "Referrer-Policy", "value": "same-origin" },
|
|
||||||
{ "name": "Vary", "value": "Accept, Accept-Language, Origin, Cookie, Accept-Encoding" },
|
|
||||||
{ "name": "X-Api-Version", "value": "3" },
|
|
||||||
{ "name": "X-Content-Type-Options", "value": "nosniff" },
|
|
||||||
{ "name": "X-Frame-Options", "value": "ANY" },
|
|
||||||
{ "name": "X-Version", "value": "1.14.4" }
|
|
||||||
],
|
|
||||||
"content": {
|
|
||||||
"size": -1,
|
|
||||||
"mimeType": "application/json",
|
|
||||||
"text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true}]}"
|
|
||||||
},
|
|
||||||
"headersSize": -1,
|
|
||||||
"bodySize": -1,
|
|
||||||
"redirectURL": ""
|
|
||||||
},
|
|
||||||
"cache": {},
|
|
||||||
"timings": { "send": -1, "wait": -1, "receive": 1.082 }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"startedDateTime": "2023-05-14T07:18:59.990Z",
|
|
||||||
"time": 0.647,
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"url": "http://localhost:8000/api/tasks/",
|
|
||||||
"httpVersion": "HTTP/1.1",
|
|
||||||
"cookies": [],
|
|
||||||
"headers": [
|
|
||||||
{ "name": "Accept", "value": "application/json; version=3" },
|
|
||||||
{ "name": "Accept-Encoding", "value": "gzip, deflate, br" },
|
|
||||||
{ "name": "Accept-Language", "value": "en-US" },
|
|
||||||
{ "name": "Connection", "value": "keep-alive" },
|
|
||||||
{ "name": "Host", "value": "localhost:8000" },
|
|
||||||
{ "name": "Origin", "value": "http://localhost:4200" },
|
|
||||||
{ "name": "Referer", "value": "http://localhost:4200/" },
|
|
||||||
{ "name": "Sec-Fetch-Dest", "value": "empty" },
|
|
||||||
{ "name": "Sec-Fetch-Mode", "value": "cors" },
|
|
||||||
{ "name": "Sec-Fetch-Site", "value": "same-site" },
|
|
||||||
{ "name": "User-Agent", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.53 Safari/537.36" }
|
|
||||||
],
|
|
||||||
"queryString": [],
|
|
||||||
"headersSize": -1,
|
|
||||||
"bodySize": -1
|
|
||||||
},
|
|
||||||
"response": {
|
|
||||||
"status": 200,
|
|
||||||
"statusText": "OK",
|
|
||||||
"httpVersion": "HTTP/1.1",
|
|
||||||
"cookies": [],
|
|
||||||
"headers": [
|
|
||||||
{ "name": "Access-Control-Allow-Origin", "value": "http://localhost:4200" },
|
|
||||||
{ "name": "Allow", "value": "GET, HEAD, OPTIONS" },
|
|
||||||
{ "name": "Content-Language", "value": "en-us" },
|
|
||||||
{ "name": "Content-Length", "value": "2" },
|
|
||||||
{ "name": "Content-Type", "value": "application/json" },
|
|
||||||
{ "name": "Cross-Origin-Opener-Policy", "value": "same-origin" },
|
|
||||||
{ "name": "Referrer-Policy", "value": "same-origin" },
|
|
||||||
{ "name": "Vary", "value": "Accept, Accept-Language, Origin, Cookie" },
|
|
||||||
{ "name": "X-Api-Version", "value": "3" },
|
|
||||||
{ "name": "X-Content-Type-Options", "value": "nosniff" },
|
|
||||||
{ "name": "X-Frame-Options", "value": "ANY" },
|
|
||||||
{ "name": "X-Version", "value": "1.14.4" }
|
|
||||||
],
|
|
||||||
"content": {
|
|
||||||
"size": -1,
|
|
||||||
"mimeType": "application/json",
|
|
||||||
"text": "[]"
|
|
||||||
},
|
|
||||||
"headersSize": -1,
|
|
||||||
"bodySize": -1,
|
|
||||||
"redirectURL": ""
|
|
||||||
},
|
|
||||||
"cache": {},
|
|
||||||
"timings": { "send": -1, "wait": -1, "receive": 0.647 }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,25 @@
|
|||||||
import { expect, test } from '@playwright/test'
|
import { expect, test } from '@playwright/test'
|
||||||
import path from 'node:path'
|
|
||||||
|
|
||||||
const REQUESTS_HAR = path.join(__dirname, 'requests/api-settings.har')
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.route('**/api/status/', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: {
|
||||||
|
database: { status: 'OK' },
|
||||||
|
tasks: {
|
||||||
|
redis_status: 'DISABLED',
|
||||||
|
celery_status: 'DISABLED',
|
||||||
|
index_status: 'OK',
|
||||||
|
classifier_status: 'OK',
|
||||||
|
sanity_check_status: 'OK',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
test('should activate / deactivate save button when settings change', async ({
|
test('should activate / deactivate save button when settings change', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
|
||||||
await page.goto('/settings')
|
await page.goto('/settings')
|
||||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||||
await page.getByLabel('Use system setting').click()
|
await page.getByLabel('Use system setting').click()
|
||||||
@@ -15,7 +28,6 @@ test('should activate / deactivate save button when settings change', async ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('should warn on unsaved changes', async ({ page }) => {
|
test('should warn on unsaved changes', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
|
||||||
await page.goto('/settings')
|
await page.goto('/settings')
|
||||||
await page.getByLabel('Use system setting').click()
|
await page.getByLabel('Use system setting').click()
|
||||||
await page.getByRole('link', { name: 'Dashboard' }).click()
|
await page.getByRole('link', { name: 'Dashboard' }).click()
|
||||||
@@ -27,7 +39,6 @@ test('should warn on unsaved changes', async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('should apply appearance changes when set', async ({ page }) => {
|
test('should apply appearance changes when set', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
|
||||||
await page.goto('/settings')
|
await page.goto('/settings')
|
||||||
await expect(page.locator('html')).toHaveAttribute('data-bs-theme', /auto/)
|
await expect(page.locator('html')).toHaveAttribute('data-bs-theme', /auto/)
|
||||||
await page.getByLabel('Use system setting').click()
|
await page.getByLabel('Use system setting').click()
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
"""Start a disposable Paperless instance for the Playwright test suite."""
|
||||||
|
|
||||||
|
# ruff: noqa: INP001, T201
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
import logging.config
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
SOURCE_ROOT = REPOSITORY_ROOT / "src"
|
||||||
|
|
||||||
|
|
||||||
|
def configure_environment(instance_root: Path) -> None:
|
||||||
|
paths = {
|
||||||
|
"PAPERLESS_CONSUMPTION_DIR": instance_root / "consume",
|
||||||
|
"PAPERLESS_DATA_DIR": instance_root / "data",
|
||||||
|
"PAPERLESS_MEDIA_ROOT": instance_root / "media",
|
||||||
|
"PAPERLESS_SCRATCH_DIR": instance_root / "scratch",
|
||||||
|
}
|
||||||
|
for name, path in paths.items():
|
||||||
|
path.mkdir(parents=True)
|
||||||
|
os.environ[name] = str(path)
|
||||||
|
(paths["PAPERLESS_DATA_DIR"] / "index").mkdir()
|
||||||
|
|
||||||
|
os.environ.update(
|
||||||
|
{
|
||||||
|
"DJANGO_SETTINGS_MODULE": "paperless.settings",
|
||||||
|
"PAPERLESS_AI_ENABLED": "false",
|
||||||
|
"PAPERLESS_CHANNELS_BACKEND": "channels.layers.InMemoryChannelLayer",
|
||||||
|
"PAPERLESS_DEBUG": "true",
|
||||||
|
"PAPERLESS_SECRET_KEY": "playwright-only-not-a-real-secret",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
sys.path.insert(0, str(SOURCE_ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
def seed_database() -> None:
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.core.management import call_command
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from documents.models import Correspondent
|
||||||
|
from documents.models import Document
|
||||||
|
from documents.models import DocumentType
|
||||||
|
from documents.models import Note
|
||||||
|
from documents.models import SavedView
|
||||||
|
from documents.models import SavedViewFilterRule
|
||||||
|
from documents.models import StoragePath
|
||||||
|
from documents.models import Tag
|
||||||
|
from documents.models import UiSettings
|
||||||
|
|
||||||
|
# Fixed credentials are safe within this disposable, localhost-only instance.
|
||||||
|
admin = User.objects.create_superuser(
|
||||||
|
username="playwright",
|
||||||
|
password="playwright", # NOSONAR
|
||||||
|
)
|
||||||
|
User.objects.create_user(
|
||||||
|
username="viewer",
|
||||||
|
password="viewer", # NOSONAR
|
||||||
|
)
|
||||||
|
|
||||||
|
inbox = Tag.objects.create(name="Inbox", is_inbox_tag=True, owner=admin)
|
||||||
|
quick_filter = Tag.objects.create(name="Another Sample Tag", owner=admin)
|
||||||
|
Tag.objects.create(name="TagWithPartial", owner=admin)
|
||||||
|
invoice = DocumentType.objects.create(name="Invoice Test", owner=admin)
|
||||||
|
correspondent_1 = Correspondent.objects.create(
|
||||||
|
name="Test Correspondent 1",
|
||||||
|
owner=admin,
|
||||||
|
)
|
||||||
|
correspondent_2 = Correspondent.objects.create(name="Correspondent 9", owner=admin)
|
||||||
|
storage_path = StoragePath.objects.create(
|
||||||
|
name="Testing 12",
|
||||||
|
path="e2e/{created_year}/{title}",
|
||||||
|
owner=admin,
|
||||||
|
)
|
||||||
|
|
||||||
|
today = timezone.localdate()
|
||||||
|
documents = []
|
||||||
|
for number in range(1, 62):
|
||||||
|
title = f"test document {number}" if number <= 9 else f"document {number}"
|
||||||
|
content = (
|
||||||
|
f"Playwright test content for document {number}"
|
||||||
|
if number <= 32
|
||||||
|
else f"Seeded content for document {number}"
|
||||||
|
)
|
||||||
|
created = today if number == 1 else datetime.date(2021, 1, 1)
|
||||||
|
if number in (2, 3):
|
||||||
|
created = datetime.date(2022, 12, 11)
|
||||||
|
|
||||||
|
documents.append(
|
||||||
|
Document(
|
||||||
|
title=title,
|
||||||
|
content=content,
|
||||||
|
checksum=f"{number:064x}",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
filename=f"{number:07}.pdf",
|
||||||
|
original_filename=f"document-{number}.pdf",
|
||||||
|
archive_serial_number=1122 + number if number <= 6 else None,
|
||||||
|
created=created,
|
||||||
|
owner=admin,
|
||||||
|
document_type=invoice if number <= 3 else None,
|
||||||
|
correspondent=(
|
||||||
|
correspondent_1
|
||||||
|
if number <= 4
|
||||||
|
else correspondent_2
|
||||||
|
if number <= 7
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
storage_path=storage_path if number <= 8 else None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
Document.objects.bulk_create(documents)
|
||||||
|
originals = Path(os.environ["PAPERLESS_MEDIA_ROOT"]) / "documents" / "originals"
|
||||||
|
originals.mkdir(parents=True)
|
||||||
|
thumbnails = Path(os.environ["PAPERLESS_MEDIA_ROOT"]) / "documents" / "thumbnails"
|
||||||
|
thumbnails.mkdir(parents=True)
|
||||||
|
sample_pdf = SOURCE_ROOT / "documents" / "tests" / "samples" / "simple.pdf"
|
||||||
|
for document in documents:
|
||||||
|
shutil.copyfile(sample_pdf, originals / document.filename)
|
||||||
|
shutil.copyfile(
|
||||||
|
SOURCE_ROOT / "documents" / "resources" / "document.webp",
|
||||||
|
thumbnails / f"{document.pk:07}.webp",
|
||||||
|
)
|
||||||
|
|
||||||
|
for document in documents[:8]:
|
||||||
|
document.tags.add(inbox)
|
||||||
|
documents[0].tags.add(quick_filter)
|
||||||
|
|
||||||
|
for number in range(1, 5):
|
||||||
|
Note.objects.create(
|
||||||
|
note=f"Playwright note {number}",
|
||||||
|
document=documents[0],
|
||||||
|
user=admin,
|
||||||
|
)
|
||||||
|
|
||||||
|
inbox_view = SavedView.objects.create(
|
||||||
|
name="Inbox",
|
||||||
|
owner=admin,
|
||||||
|
sort_field="created",
|
||||||
|
sort_reverse=True,
|
||||||
|
page_size=10,
|
||||||
|
display_mode=SavedView.DisplayMode.TABLE,
|
||||||
|
display_fields=["created", "title", "tag", "documenttype"],
|
||||||
|
)
|
||||||
|
SavedViewFilterRule.objects.create(
|
||||||
|
saved_view=inbox_view,
|
||||||
|
rule_type=6,
|
||||||
|
value=str(inbox.pk),
|
||||||
|
)
|
||||||
|
|
||||||
|
UiSettings.objects.create(
|
||||||
|
user=admin,
|
||||||
|
settings={
|
||||||
|
"language": "",
|
||||||
|
"bulk_edit": {"confirmation_dialogs": True, "apply_on_close": False},
|
||||||
|
"documentListSize": 50,
|
||||||
|
"dark_mode": {
|
||||||
|
"use_system": True,
|
||||||
|
"enabled": False,
|
||||||
|
"thumb_inverted": True,
|
||||||
|
},
|
||||||
|
"theme": {"color": "#9fbf2f"},
|
||||||
|
"document_details": {"native_pdf_viewer": False},
|
||||||
|
"date_display": {"date_locale": "", "date_format": "mediumDate"},
|
||||||
|
"comments_enabled": True,
|
||||||
|
"slim_sidebar": False,
|
||||||
|
"update_checking": {"enabled": False},
|
||||||
|
"saved_views": {
|
||||||
|
"warn_on_unsaved_change": True,
|
||||||
|
"dashboard_views_visible_ids": [inbox_view.pk],
|
||||||
|
"sidebar_views_visible_ids": [inbox_view.pk],
|
||||||
|
},
|
||||||
|
"notes_enabled": True,
|
||||||
|
"tour_complete": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
call_command(
|
||||||
|
"document_index",
|
||||||
|
"reindex",
|
||||||
|
recreate=True,
|
||||||
|
heap_size_mb=16,
|
||||||
|
verbosity=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
started = time.monotonic()
|
||||||
|
with tempfile.TemporaryDirectory(prefix="paperless-playwright-") as instance:
|
||||||
|
configure_environment(Path(instance))
|
||||||
|
|
||||||
|
os.chdir(SOURCE_ROOT)
|
||||||
|
print("Loading the Paperless backend...", flush=True)
|
||||||
|
import django
|
||||||
|
|
||||||
|
django.setup()
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.management import call_command
|
||||||
|
|
||||||
|
settings.CELERY_TASK_ALWAYS_EAGER = True
|
||||||
|
settings.CELERY_TASK_EAGER_PROPAGATES = True
|
||||||
|
|
||||||
|
print("Migrating the disposable database...", flush=True)
|
||||||
|
call_command("migrate", interactive=False, verbosity=0)
|
||||||
|
print("Seeding Playwright data...", flush=True)
|
||||||
|
seed_database()
|
||||||
|
elapsed = time.monotonic() - started
|
||||||
|
print(f"Playwright backend ready in {elapsed:.1f}s", flush=True)
|
||||||
|
settings.LOGGING["handlers"]["console"]["level"] = "WARNING"
|
||||||
|
settings.LOGGING["handlers"]["playwright_null"] = {
|
||||||
|
"class": "logging.NullHandler",
|
||||||
|
}
|
||||||
|
settings.LOGGING["loggers"]["django.server"] = {
|
||||||
|
"handlers": ["playwright_null"],
|
||||||
|
"propagate": False,
|
||||||
|
}
|
||||||
|
logging.config.dictConfig(settings.LOGGING)
|
||||||
|
call_command(
|
||||||
|
"runserver",
|
||||||
|
"localhost:8001",
|
||||||
|
use_reloader=False,
|
||||||
|
verbosity=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,21 +1,13 @@
|
|||||||
import { expect, test } from '@playwright/test'
|
import { expect, test } from '@playwright/test'
|
||||||
import path from 'node:path'
|
|
||||||
|
|
||||||
const REQUESTS_HAR1 = path.join(__dirname, 'requests/api-dashboard1.har')
|
|
||||||
const REQUESTS_HAR2 = path.join(__dirname, 'requests/api-dashboard2.har')
|
|
||||||
const REQUESTS_HAR3 = path.join(__dirname, 'requests/api-dashboard3.har')
|
|
||||||
const REQUESTS_HAR4 = path.join(__dirname, 'requests/api-dashboard4.har')
|
|
||||||
|
|
||||||
test('dashboard inbox link', async ({ page }) => {
|
test('dashboard inbox link', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR1, { notFound: 'fallback' })
|
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await page.getByRole('link', { name: 'Documents in inbox' }).click()
|
await page.getByRole('link', { name: 'Documents in inbox' }).click()
|
||||||
await expect(page).toHaveURL(/tags__id__in=9/)
|
await expect(page).toHaveURL(/tags__id__in=1/)
|
||||||
await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
|
await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('dashboard total documents link', async ({ page }) => {
|
test('dashboard total documents link', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR2, { notFound: 'fallback' })
|
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await page.getByRole('link').filter({ hasText: 'Total documents' }).click()
|
await page.getByRole('link').filter({ hasText: 'Total documents' }).click()
|
||||||
await expect(page).toHaveURL(/documents/)
|
await expect(page).toHaveURL(/documents/)
|
||||||
@@ -24,7 +16,6 @@ test('dashboard total documents link', async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('dashboard saved view show all', async ({ page }) => {
|
test('dashboard saved view show all', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' })
|
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await page
|
await page
|
||||||
.locator('pngx-widget-frame')
|
.locator('pngx-widget-frame')
|
||||||
@@ -32,12 +23,11 @@ test('dashboard saved view show all', async ({ page }) => {
|
|||||||
.getByRole('link', { name: 'Show all' })
|
.getByRole('link', { name: 'Show all' })
|
||||||
.first()
|
.first()
|
||||||
.click()
|
.click()
|
||||||
await expect(page).toHaveURL(/view\/7/)
|
await expect(page).toHaveURL(/view\/1/)
|
||||||
await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
|
await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('dashboard saved view document links', async ({ page }) => {
|
test('dashboard saved view document links', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR4, { notFound: 'fallback' })
|
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await page
|
await page
|
||||||
.locator('pngx-widget-frame')
|
.locator('pngx-widget-frame')
|
||||||
@@ -46,11 +36,10 @@ test('dashboard saved view document links', async ({ page }) => {
|
|||||||
.getByRole('link', { name: /test/ })
|
.getByRole('link', { name: /test/ })
|
||||||
.first()
|
.first()
|
||||||
.click({ position: { x: 0, y: 0 } })
|
.click({ position: { x: 0, y: 0 } })
|
||||||
await expect(page).toHaveURL(/documents\/310\/details/)
|
await expect(page).toHaveURL(/documents\/1\/details/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('test slim sidebar', async ({ page }) => {
|
test('test slim sidebar', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR1, { notFound: 'fallback' })
|
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await page.locator('.sidebar-slim-toggler').click()
|
await page.locator('.sidebar-slim-toggler').click()
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
import { expect, test, type WebSocketRoute } from '@playwright/test'
|
import { expect, test } from '@playwright/test'
|
||||||
import path from 'node:path'
|
|
||||||
|
|
||||||
const REQUESTS_HAR = path.join(__dirname, 'requests/api-document-detail.har')
|
|
||||||
const REQUESTS_HAR2 = path.join(__dirname, 'requests/api-document-detail2.har')
|
|
||||||
|
|
||||||
test('should activate / deactivate save button when changes are saved', async ({
|
test('should activate / deactivate save button when changes are saved', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
await page.goto('/documents/1/')
|
||||||
await page.goto('/documents/175/')
|
|
||||||
await page.waitForSelector('pngx-document-detail pngx-input-text:first-child')
|
await page.waitForSelector('pngx-document-detail pngx-input-text:first-child')
|
||||||
await expect(page.getByTitle('Storage path', { exact: true })).toHaveText(
|
await expect(page.getByTitle('Storage path', { exact: true })).toHaveText(
|
||||||
/\w+/
|
/\w+/
|
||||||
@@ -19,8 +14,7 @@ test('should activate / deactivate save button when changes are saved', async ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('should warn on unsaved changes', async ({ page }) => {
|
test('should warn on unsaved changes', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
await page.goto('/documents/1/')
|
||||||
await page.goto('/documents/175/')
|
|
||||||
await expect(page.getByTitle('Correspondent', { exact: true })).toHaveText(
|
await expect(page.getByTitle('Correspondent', { exact: true })).toHaveText(
|
||||||
/\w+/
|
/\w+/
|
||||||
)
|
)
|
||||||
@@ -33,33 +27,32 @@ test('should warn on unsaved changes', async ({ page }) => {
|
|||||||
await page.getByRole('button', { name: 'Close', exact: true }).click()
|
await page.getByRole('button', { name: 'Close', exact: true }).click()
|
||||||
await expect(page.getByRole('dialog')).toHaveText(/unsaved changes/)
|
await expect(page.getByRole('dialog')).toHaveText(/unsaved changes/)
|
||||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
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/)
|
await expect(page.getByRole('dialog')).toHaveText(/unsaved changes/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should support tab direct navigation', async ({ page }) => {
|
test('should support tab direct navigation', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
await page.goto('/documents/1/details')
|
||||||
await page.goto('/documents/175/details')
|
|
||||||
await expect(page.getByRole('tab', { name: 'Details' })).toHaveAttribute(
|
await expect(page.getByRole('tab', { name: 'Details' })).toHaveAttribute(
|
||||||
'aria-selected',
|
'aria-selected',
|
||||||
'true'
|
'true'
|
||||||
)
|
)
|
||||||
await page.goto('/documents/175/content')
|
await page.goto('/documents/1/content')
|
||||||
await expect(page.getByRole('tab', { name: 'Content' })).toHaveAttribute(
|
await expect(page.getByRole('tab', { name: 'Content' })).toHaveAttribute(
|
||||||
'aria-selected',
|
'aria-selected',
|
||||||
'true'
|
'true'
|
||||||
)
|
)
|
||||||
await page.goto('/documents/175/metadata')
|
await page.goto('/documents/1/metadata')
|
||||||
await expect(page.getByRole('tab', { name: 'Metadata' })).toHaveAttribute(
|
await expect(page.getByRole('tab', { name: 'Metadata' })).toHaveAttribute(
|
||||||
'aria-selected',
|
'aria-selected',
|
||||||
'true'
|
'true'
|
||||||
)
|
)
|
||||||
await page.goto('/documents/175/notes')
|
await page.goto('/documents/1/notes')
|
||||||
await expect(page.getByRole('tab', { name: 'Notes' })).toHaveAttribute(
|
await expect(page.getByRole('tab', { name: 'Notes' })).toHaveAttribute(
|
||||||
'aria-selected',
|
'aria-selected',
|
||||||
'true'
|
'true'
|
||||||
)
|
)
|
||||||
await page.goto('/documents/175/permissions')
|
await page.goto('/documents/1/permissions')
|
||||||
await expect(page.getByRole('tab', { name: 'Permissions' })).toHaveAttribute(
|
await expect(page.getByRole('tab', { name: 'Permissions' })).toHaveAttribute(
|
||||||
'aria-selected',
|
'aria-selected',
|
||||||
'true'
|
'true'
|
||||||
@@ -67,8 +60,7 @@ test('should support tab direct navigation', async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('should show a mobile preview', async ({ page }) => {
|
test('should show a mobile preview', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
await page.goto('/documents/1/')
|
||||||
await page.goto('/documents/175/')
|
|
||||||
await page.setViewportSize({ width: 400, height: 1000 })
|
await page.setViewportSize({ width: 400, height: 1000 })
|
||||||
await expect(page.getByRole('tab', { name: 'Preview' })).toBeVisible()
|
await expect(page.getByRole('tab', { name: 'Preview' })).toBeVisible()
|
||||||
await page.getByRole('tab', { name: 'Preview' }).click()
|
await page.getByRole('tab', { name: 'Preview' }).click()
|
||||||
@@ -76,8 +68,7 @@ test('should show a mobile preview', async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('should show a list of notes', async ({ page }) => {
|
test('should show a list of notes', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
await page.goto('/documents/1/notes')
|
||||||
await page.goto('/documents/175/notes')
|
|
||||||
await expect(page.locator('pngx-document-notes')).toBeVisible()
|
await expect(page.locator('pngx-document-notes')).toBeVisible()
|
||||||
await expect(
|
await expect(
|
||||||
await page.getByRole('button', {
|
await page.getByRole('button', {
|
||||||
@@ -88,32 +79,25 @@ test('should show a list of notes', async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('should support quick filters', async ({ page }) => {
|
test('should support quick filters', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR2, { notFound: 'fallback' })
|
await page.goto('/documents/1/details')
|
||||||
await page.goto('/documents/175/details')
|
|
||||||
await page
|
await page
|
||||||
.getByRole('button', { name: 'Filter documents with these Tags' })
|
.getByRole('button', { name: 'Filter documents with these Tags' })
|
||||||
.click()
|
.click()
|
||||||
await expect(page).toHaveURL(/tags__id__all=4&sort=created&reverse=1&page=1/)
|
await expect(page).toHaveURL(
|
||||||
|
/tags__id__all=2,1&sort=created&reverse=1&page=1/
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should finish reloading the preview after a remote document update', async ({
|
test('should finish reloading the preview after a remote document update', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
let resolveStatusSocket: (socket: WebSocketRoute) => void
|
|
||||||
const statusSocketReady = new Promise<WebSocketRoute>((resolve) => {
|
|
||||||
resolveStatusSocket = resolve
|
|
||||||
})
|
|
||||||
await page.routeWebSocket(/\/ws\/status\/$/, (socket) => {
|
|
||||||
resolveStatusSocket(socket)
|
|
||||||
})
|
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
|
||||||
let previewRequestCount = 0
|
let previewRequestCount = 0
|
||||||
page.on('request', (request) => {
|
page.on('request', (request) => {
|
||||||
if (request.url().includes('/api/documents/175/preview/')) {
|
if (request.url().includes('/api/documents/1/preview/')) {
|
||||||
previewRequestCount++
|
previewRequestCount++
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
await page.goto('/documents/175/details')
|
await page.goto('/documents/1/details')
|
||||||
|
|
||||||
await page.locator('pngx-document-detail').waitFor()
|
await page.locator('pngx-document-detail').waitFor()
|
||||||
await expect(page.getByTitle('Storage path', { exact: true })).toHaveText(
|
await expect(page.getByTitle('Storage path', { exact: true })).toHaveText(
|
||||||
@@ -128,27 +112,37 @@ test('should finish reloading the preview after a remote document update', async
|
|||||||
expect(previewWasLoaded).toBe(true)
|
expect(previewWasLoaded).toBe(true)
|
||||||
const previewRequestsBeforeReload = previewRequestCount
|
const previewRequestsBeforeReload = previewRequestCount
|
||||||
|
|
||||||
const statusSocket = await statusSocketReady
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const detail = document.querySelector('pngx-document-detail')
|
||||||
|
return (window as any).ng.getComponent(detail).networkActive()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.toBe(false)
|
||||||
const documentReloaded = page.waitForResponse(
|
const documentReloaded = page.waitForResponse(
|
||||||
(response) =>
|
(response) =>
|
||||||
response.url().includes('/api/documents/175/?full_perms=true') &&
|
response.url().includes('/api/documents/1/?full_perms=true') &&
|
||||||
response.request().method() === 'GET'
|
response.request().method() === 'GET'
|
||||||
)
|
)
|
||||||
statusSocket.send(
|
await page.evaluate(() => {
|
||||||
JSON.stringify({
|
const detail = document.querySelector('pngx-document-detail')
|
||||||
type: 'document_updated',
|
const component = (window as any).ng.getComponent(detail)
|
||||||
data: {
|
component.handleIncomingDocumentUpdated({
|
||||||
document_id: 175,
|
document_id: 1,
|
||||||
modified: '2026-07-26T20:00:00Z',
|
modified: '2099-07-26T20:00:00Z',
|
||||||
},
|
})
|
||||||
})
|
})
|
||||||
)
|
|
||||||
await documentReloaded
|
await documentReloaded
|
||||||
|
|
||||||
await expect(
|
|
||||||
page.getByText('Document reloaded with latest changes.').first()
|
|
||||||
).toBeVisible()
|
|
||||||
await expect
|
await expect
|
||||||
.poll(() => previewRequestCount)
|
.poll(() => previewRequestCount)
|
||||||
.toBeGreaterThan(previewRequestsBeforeReload + 1)
|
.toBeGreaterThan(previewRequestsBeforeReload)
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const detail = document.querySelector('pngx-document-detail')
|
||||||
|
return (window as any).ng.getComponent(detail).previewLoaded()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,19 +1,10 @@
|
|||||||
import { expect, test } from '@playwright/test'
|
import { expect, test } from '@playwright/test'
|
||||||
import path from 'node:path'
|
|
||||||
|
|
||||||
const REQUESTS_HAR1 = path.join(__dirname, 'requests/api-document-list1.har')
|
|
||||||
const REQUESTS_HAR2 = path.join(__dirname, 'requests/api-document-list2.har')
|
|
||||||
const REQUESTS_HAR3 = path.join(__dirname, 'requests/api-document-list3.har')
|
|
||||||
const REQUESTS_HAR4 = path.join(__dirname, 'requests/api-document-list4.har')
|
|
||||||
const REQUESTS_HAR5 = path.join(__dirname, 'requests/api-document-list5.har')
|
|
||||||
const REQUESTS_HAR6 = path.join(__dirname, 'requests/api-document-list6.har')
|
|
||||||
|
|
||||||
test('basic filtering', async ({ page }) => {
|
test('basic filtering', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR1, { notFound: 'fallback' })
|
|
||||||
await page.goto('/documents')
|
await page.goto('/documents')
|
||||||
await page.getByRole('button', { name: 'Tags' }).click()
|
await page.getByRole('button', { name: 'Tags' }).click()
|
||||||
await page.getByRole('menuitem', { name: 'Inbox' }).click()
|
await page.getByRole('menuitem', { name: 'Inbox' }).click()
|
||||||
await expect(page).toHaveURL(/tags__id__all=9/)
|
await expect(page).toHaveURL(/tags__id__all=1/)
|
||||||
await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
|
await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
|
||||||
await page.getByRole('button', { name: 'Document type' }).click()
|
await page.getByRole('button', { name: 'Document type' }).click()
|
||||||
await page.getByRole('menuitem', { name: /^Invoice Test/ }).click()
|
await page.getByRole('menuitem', { name: /^Invoice Test/ }).click()
|
||||||
@@ -23,28 +14,27 @@ test('basic filtering', async ({ page }) => {
|
|||||||
await page.getByRole('button', { name: 'Correspondent' }).click()
|
await page.getByRole('button', { name: 'Correspondent' }).click()
|
||||||
await page.getByRole('menuitem', { name: 'Test Correspondent 1' }).click()
|
await page.getByRole('menuitem', { name: 'Test Correspondent 1' }).click()
|
||||||
await page.getByRole('menuitem', { name: 'Correspondent 9' }).click()
|
await page.getByRole('menuitem', { name: 'Correspondent 9' }).click()
|
||||||
await expect(page).toHaveURL(/correspondent__id__in=12,1/)
|
await expect(page).toHaveURL(/correspondent__id__in=(?:1,2|2,1)/)
|
||||||
await expect(page.locator('pngx-document-list')).toHaveText(/7 documents/)
|
await expect(page.locator('pngx-document-list')).toHaveText(/7 documents/)
|
||||||
await page
|
await page
|
||||||
.locator('pngx-filter-editor')
|
.locator('pngx-filter-editor')
|
||||||
.getByTitle('Correspondent')
|
.getByTitle('Correspondent')
|
||||||
.getByText('Exclude')
|
.getByText('Exclude')
|
||||||
.click()
|
.click()
|
||||||
await expect(page).toHaveURL(/correspondent__id__none=12,1/)
|
await expect(page).toHaveURL(/correspondent__id__none=(?:1,2|2,1)/)
|
||||||
await expect(page.locator('pngx-document-list')).toHaveText(/54 documents/)
|
await expect(page.locator('pngx-document-list')).toHaveText(/54 documents/)
|
||||||
// clear button
|
// clear button
|
||||||
await page.getByRole('button', { name: '2 selected', exact: true }).click()
|
await page.getByRole('button', { name: '2 selected', exact: true }).click()
|
||||||
await expect(page.locator('pngx-document-list')).toHaveText(/61 documents/)
|
await expect(page.locator('pngx-document-list')).toHaveText(/61 documents/)
|
||||||
await page.getByRole('button', { name: 'Storage path' }).click()
|
await page.getByRole('button', { name: 'Storage path' }).click()
|
||||||
await page.getByRole('menuitem', { name: 'Testing 12' }).click()
|
await page.getByRole('menuitem', { name: 'Testing 12' }).click()
|
||||||
await expect(page).toHaveURL(/storage_path__id__in=5/)
|
await expect(page).toHaveURL(/storage_path__id__in=1/)
|
||||||
await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
|
await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
|
||||||
await page.getByRole('button', { name: 'Reset filters' }).first().click()
|
await page.getByRole('button', { name: 'Reset filters' }).first().click()
|
||||||
await expect(page.locator('pngx-document-list')).toHaveText(/61 documents/)
|
await expect(page.locator('pngx-document-list')).toHaveText(/61 documents/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('text filtering', async ({ page }) => {
|
test('text filtering', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR2, { notFound: 'fallback' })
|
|
||||||
await page.goto('/documents')
|
await page.goto('/documents')
|
||||||
await page.getByRole('main').getByRole('combobox').click()
|
await page.getByRole('main').getByRole('combobox').click()
|
||||||
await page.getByRole('main').getByRole('combobox').fill('test')
|
await page.getByRole('main').getByRole('combobox').fill('test')
|
||||||
@@ -57,7 +47,7 @@ test('text filtering', async ({ page }) => {
|
|||||||
await page.getByRole('button', { name: 'Title', exact: true }).click()
|
await page.getByRole('button', { name: 'Title', exact: true }).click()
|
||||||
await page.getByRole('button', { name: 'Advanced search' }).click()
|
await page.getByRole('button', { name: 'Advanced search' }).click()
|
||||||
await expect(page).toHaveURL(/query=test/)
|
await expect(page).toHaveURL(/query=test/)
|
||||||
await expect(page.locator('pngx-document-list')).toHaveText(/26 documents/)
|
await expect(page.locator('pngx-document-list')).toHaveText(/32 documents/)
|
||||||
await page.getByRole('button', { name: 'Advanced search' }).click()
|
await page.getByRole('button', { name: 'Advanced search' }).click()
|
||||||
await page.getByRole('button', { name: 'ASN' }).click()
|
await page.getByRole('button', { name: 'ASN' }).click()
|
||||||
await page.getByRole('main').getByRole('combobox').nth(1).fill('1123')
|
await page.getByRole('main').getByRole('combobox').nth(1).fill('1123')
|
||||||
@@ -80,7 +70,6 @@ test('text filtering', async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('date filtering', async ({ page }) => {
|
test('date filtering', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' })
|
|
||||||
await page.goto('/documents')
|
await page.goto('/documents')
|
||||||
await page.getByRole('button', { name: 'Dates' }).click()
|
await page.getByRole('button', { name: 'Dates' }).click()
|
||||||
await page.locator('.ng-arrow-wrapper').first().click()
|
await page.locator('.ng-arrow-wrapper').first().click()
|
||||||
@@ -94,15 +83,14 @@ test('date filtering', async ({ page }) => {
|
|||||||
await page.getByRole('option', { name: 'Within 3 months' }).click()
|
await page.getByRole('option', { name: 'Within 3 months' }).click()
|
||||||
await page.getByLabel('Dates selected').locator('button').first().click()
|
await page.getByLabel('Dates selected').locator('button').first().click()
|
||||||
await page.getByLabel('Dates selected').locator('button').first().click()
|
await page.getByLabel('Dates selected').locator('button').first().click()
|
||||||
await page.getByRole('combobox', { name: 'Select month' }).selectOption('12')
|
const createdFrom = page.getByRole('textbox', { name: 'mm/dd/yyyy' }).first()
|
||||||
await page.getByRole('combobox', { name: 'Select year' }).selectOption('2022')
|
await createdFrom.fill('12/11/2022')
|
||||||
await page.getByText('11', { exact: true }).click()
|
await createdFrom.press('Enter')
|
||||||
await page.getByRole('button', { name: 'Title & content' }).click()
|
await page.getByRole('button', { name: 'Title & content' }).click()
|
||||||
await expect(page.locator('pngx-document-list')).toHaveText(/2 documents/)
|
await expect(page.locator('pngx-document-list')).toHaveText(/3 documents/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('sorting', async ({ page }) => {
|
test('sorting', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR4, { notFound: 'fallback' })
|
|
||||||
await page.goto('/documents')
|
await page.goto('/documents')
|
||||||
await page.getByRole('button', { name: 'Sort' }).click()
|
await page.getByRole('button', { name: 'Sort' }).click()
|
||||||
await page.getByRole('button', { name: 'ASN' }).click()
|
await page.getByRole('button', { name: 'ASN' }).click()
|
||||||
@@ -140,7 +128,6 @@ test('sorting', async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('change views', async ({ page }) => {
|
test('change views', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR5, { notFound: 'fallback' })
|
|
||||||
await page.goto('/documents')
|
await page.goto('/documents')
|
||||||
await page.locator('.btn-group > label').first().click()
|
await page.locator('.btn-group > label').first().click()
|
||||||
await expect(page.locator('pngx-document-list table')).toBeVisible()
|
await expect(page.locator('pngx-document-list table')).toBeVisible()
|
||||||
@@ -151,12 +138,18 @@ test('change views', async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('bulk edit', async ({ page }) => {
|
test('bulk edit', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR6, { notFound: 'fallback' })
|
const uiSettingsLoaded = page.waitForResponse(
|
||||||
|
(response) => response.url().includes('/api/ui_settings/') && response.ok()
|
||||||
|
)
|
||||||
await page.goto('/documents')
|
await page.goto('/documents')
|
||||||
|
await uiSettingsLoaded
|
||||||
|
|
||||||
await page.locator('pngx-document-card-small').nth(0).click()
|
|
||||||
await page
|
await page
|
||||||
.locator('pngx-document-card-small')
|
.locator('pngx-document-card-small .doc-img-container')
|
||||||
|
.nth(0)
|
||||||
|
.click()
|
||||||
|
await page
|
||||||
|
.locator('pngx-document-card-small .doc-img-container')
|
||||||
.nth(3)
|
.nth(3)
|
||||||
.click({
|
.click({
|
||||||
modifiers: ['Shift'],
|
modifiers: ['Shift'],
|
||||||
@@ -176,8 +169,14 @@ test('bulk edit', async ({ page }) => {
|
|||||||
)
|
)
|
||||||
await page.getByRole('button', { name: 'None' }).click()
|
await page.getByRole('button', { name: 'None' }).click()
|
||||||
|
|
||||||
await page.locator('pngx-document-card-small').nth(1).click()
|
await page
|
||||||
await page.locator('pngx-document-card-small').nth(2).click()
|
.locator('pngx-document-card-small .doc-img-container')
|
||||||
|
.nth(1)
|
||||||
|
.click()
|
||||||
|
await page
|
||||||
|
.locator('pngx-document-card-small .doc-img-container')
|
||||||
|
.nth(2)
|
||||||
|
.click()
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Tags' }).click()
|
await page.getByRole('button', { name: 'Tags' }).click()
|
||||||
await page
|
await page
|
||||||
@@ -185,15 +184,26 @@ test('bulk edit', async ({ page }) => {
|
|||||||
.fill('TagWithPartial')
|
.fill('TagWithPartial')
|
||||||
await page.getByRole('menuitem', { name: 'TagWithPartial' }).click()
|
await page.getByRole('menuitem', { name: 'TagWithPartial' }).click()
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Apply' }).click()
|
|
||||||
|
|
||||||
const bulkEditPromise = page.waitForRequest((request) => {
|
const bulkEditPromise = page.waitForRequest((request) => {
|
||||||
|
if (
|
||||||
|
!request.url().includes('/api/documents/bulk_edit/') ||
|
||||||
|
request.method() !== 'POST'
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
const postData = request.postDataJSON()
|
const postData = request.postDataJSON()
|
||||||
let isValid = postData['method'] == 'modify_tags'
|
let isValid = postData['method'] == 'modify_tags'
|
||||||
isValid = isValid && postData['parameters']['add_tags'].includes(5)
|
isValid =
|
||||||
return request.url().toString().includes('bulk_edit') && isValid
|
isValid &&
|
||||||
|
[
|
||||||
|
...postData['parameters']['add_tags'],
|
||||||
|
...postData['parameters']['remove_tags'],
|
||||||
|
].includes(3)
|
||||||
|
return isValid
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Apply' }).click()
|
||||||
|
await expect(page.getByRole('button', { name: 'Confirm' })).toBeVisible()
|
||||||
await page.getByRole('button', { name: 'Confirm' }).click()
|
await page.getByRole('button', { name: 'Confirm' }).click()
|
||||||
await bulkEditPromise
|
await bulkEditPromise
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { expect, test } from '@playwright/test'
|
import { expect, test } from '@playwright/test'
|
||||||
import path from 'node:path'
|
|
||||||
|
|
||||||
const REQUESTS_HAR = path.join(__dirname, 'requests/api-global-permissions.har')
|
test.use({
|
||||||
|
extraHTTPHeaders: {
|
||||||
|
Authorization: `Basic ${Buffer.from('viewer:viewer').toString('base64')}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
test('should not allow user to edit settings', async ({ page }) => {
|
test('should not allow user to edit settings', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await expect(page.getByRole('link', { name: 'Settings' })).not.toBeAttached()
|
await expect(page.getByRole('link', { name: 'Settings' })).not.toBeAttached()
|
||||||
await page.goto('/settings')
|
await page.goto('/settings')
|
||||||
@@ -14,7 +16,6 @@ test('should not allow user to edit settings', async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('should not allow user to view documents', async ({ page }) => {
|
test('should not allow user to view documents', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await expect(
|
await expect(
|
||||||
page.locator('nav').getByRole('link', { name: 'Documents' })
|
page.locator('nav').getByRole('link', { name: 'Documents' })
|
||||||
@@ -30,7 +31,6 @@ test('should not allow user to view documents', async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('should not allow user to view correspondents', async ({ page }) => {
|
test('should not allow user to view correspondents', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('link', { name: 'Attributes' })
|
page.getByRole('link', { name: 'Attributes' })
|
||||||
@@ -42,7 +42,6 @@ test('should not allow user to view correspondents', async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('should not allow user to view tags', async ({ page }) => {
|
test('should not allow user to view tags', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('link', { name: 'Attributes' })
|
page.getByRole('link', { name: 'Attributes' })
|
||||||
@@ -54,7 +53,6 @@ test('should not allow user to view tags', async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('should not allow user to view document types', async ({ page }) => {
|
test('should not allow user to view document types', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('link', { name: 'Attributes' })
|
page.getByRole('link', { name: 'Attributes' })
|
||||||
@@ -66,7 +64,6 @@ test('should not allow user to view document types', async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('should not allow user to view storage paths', async ({ page }) => {
|
test('should not allow user to view storage paths', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('link', { name: 'Attributes' })
|
page.getByRole('link', { name: 'Attributes' })
|
||||||
@@ -78,7 +75,6 @@ test('should not allow user to view storage paths', async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('should not allow user to view logs', async ({ page }) => {
|
test('should not allow user to view logs', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await expect(page.getByRole('link', { name: 'Logs' })).not.toBeAttached()
|
await expect(page.getByRole('link', { name: 'Logs' })).not.toBeAttached()
|
||||||
await page.goto('/logs')
|
await page.goto('/logs')
|
||||||
@@ -88,7 +84,6 @@ test('should not allow user to view logs', async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('should not allow user to view tasks', async ({ page }) => {
|
test('should not allow user to view tasks', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await expect(page.getByRole('link', { name: 'Tasks' })).not.toBeAttached()
|
await expect(page.getByRole('link', { name: 'Tasks' })).not.toBeAttached()
|
||||||
await page.goto('/tasks')
|
await page.goto('/tasks')
|
||||||
|
|||||||
@@ -1,353 +0,0 @@
|
|||||||
{
|
|
||||||
"log": {
|
|
||||||
"version": "1.2",
|
|
||||||
"creator": {
|
|
||||||
"name": "Playwright",
|
|
||||||
"version": "1.33.0"
|
|
||||||
},
|
|
||||||
"browser": {
|
|
||||||
"name": "chromium",
|
|
||||||
"version": "113.0.5672.53"
|
|
||||||
},
|
|
||||||
"entries": [
|
|
||||||
{
|
|
||||||
"startedDateTime": "2023-05-14T07:16:51.455Z",
|
|
||||||
"time": 5.787,
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"url": "http://localhost:8000/api/ui_settings/",
|
|
||||||
"httpVersion": "HTTP/1.1",
|
|
||||||
"cookies": [],
|
|
||||||
"headers": [
|
|
||||||
{ "name": "Accept", "value": "application/json; version=3" },
|
|
||||||
{ "name": "Accept-Encoding", "value": "gzip, deflate, br" },
|
|
||||||
{ "name": "Accept-Language", "value": "en-US" },
|
|
||||||
{ "name": "Connection", "value": "keep-alive" },
|
|
||||||
{ "name": "Host", "value": "localhost:8000" },
|
|
||||||
{ "name": "Origin", "value": "http://localhost:4200" },
|
|
||||||
{ "name": "Referer", "value": "http://localhost:4200/" },
|
|
||||||
{ "name": "Sec-Fetch-Dest", "value": "empty" },
|
|
||||||
{ "name": "Sec-Fetch-Mode", "value": "cors" },
|
|
||||||
{ "name": "Sec-Fetch-Site", "value": "same-site" },
|
|
||||||
{ "name": "User-Agent", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.53 Safari/537.36" }
|
|
||||||
],
|
|
||||||
"queryString": [],
|
|
||||||
"headersSize": -1,
|
|
||||||
"bodySize": -1
|
|
||||||
},
|
|
||||||
"response": {
|
|
||||||
"status": 200,
|
|
||||||
"statusText": "OK",
|
|
||||||
"httpVersion": "HTTP/1.1",
|
|
||||||
"cookies": [],
|
|
||||||
"headers": [
|
|
||||||
{ "name": "Access-Control-Allow-Origin", "value": "http://localhost:4200" },
|
|
||||||
{ "name": "Allow", "value": "GET, POST, HEAD, OPTIONS" },
|
|
||||||
{ "name": "Content-Encoding", "value": "br" },
|
|
||||||
{ "name": "Content-Language", "value": "en-us" },
|
|
||||||
{ "name": "Content-Length", "value": "385" },
|
|
||||||
{ "name": "Content-Type", "value": "application/json" },
|
|
||||||
{ "name": "Cross-Origin-Opener-Policy", "value": "same-origin" },
|
|
||||||
{ "name": "Referrer-Policy", "value": "same-origin" },
|
|
||||||
{ "name": "Vary", "value": "Accept, Accept-Language, Origin, Cookie, Accept-Encoding" },
|
|
||||||
{ "name": "X-Api-Version", "value": "3" },
|
|
||||||
{ "name": "X-Content-Type-Options", "value": "nosniff" },
|
|
||||||
{ "name": "X-Frame-Options", "value": "ANY" },
|
|
||||||
{ "name": "X-Version", "value": "1.14.4" }
|
|
||||||
],
|
|
||||||
"content": {
|
|
||||||
"size": -1,
|
|
||||||
"mimeType": "application/json",
|
|
||||||
"text": "{\"user\":{\"id\":2,\"username\":\"testuser\",\"is_superuser\":false,\"groups\":[]},\"settings\":{\"language\":\"\",\"bulk_edit\":{\"confirmation_dialogs\":true,\"apply_on_close\":false},\"documentListSize\":50,\"dark_mode\":{\"use_system\":false,\"enabled\":\"false\",\"thumb_inverted\":\"true\"},\"theme\":{\"color\":\"#9fbf2f\"},\"document_details\":{\"native_pdf_viewer\":false},\"date_display\":{\"date_locale\":\"\",\"date_format\":\"mediumDate\"},\"notifications\":{\"consumer_new_documents\":true,\"consumer_success\":true,\"consumer_failed\":true,\"consumer_suppress_on_dashboard\":true},\"comments_enabled\":true,\"slim_sidebar\":false,\"update_checking\":{\"enabled\":false,\"backend_setting\":\"default\"},\"saved_views\":{\"warn_on_unsaved_change\":true},\"notes_enabled\":true,\"tour_complete\":true},\"permissions\":[]}"
|
|
||||||
},
|
|
||||||
"headersSize": -1,
|
|
||||||
"bodySize": -1,
|
|
||||||
"redirectURL": ""
|
|
||||||
},
|
|
||||||
"cache": {},
|
|
||||||
"timings": { "send": -1, "wait": -1, "receive": 5.787 }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"startedDateTime": "2023-05-14T07:16:51.578Z",
|
|
||||||
"time": 0.566,
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"url": "http://localhost:8000/api/tasks/",
|
|
||||||
"httpVersion": "HTTP/1.1",
|
|
||||||
"cookies": [],
|
|
||||||
"headers": [
|
|
||||||
{ "name": "Accept", "value": "application/json; version=3" },
|
|
||||||
{ "name": "Accept-Encoding", "value": "gzip, deflate, br" },
|
|
||||||
{ "name": "Accept-Language", "value": "en-US" },
|
|
||||||
{ "name": "Connection", "value": "keep-alive" },
|
|
||||||
{ "name": "Host", "value": "localhost:8000" },
|
|
||||||
{ "name": "Origin", "value": "http://localhost:4200" },
|
|
||||||
{ "name": "Referer", "value": "http://localhost:4200/" },
|
|
||||||
{ "name": "Sec-Fetch-Dest", "value": "empty" },
|
|
||||||
{ "name": "Sec-Fetch-Mode", "value": "cors" },
|
|
||||||
{ "name": "Sec-Fetch-Site", "value": "same-site" },
|
|
||||||
{ "name": "User-Agent", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.53 Safari/537.36" }
|
|
||||||
],
|
|
||||||
"queryString": [],
|
|
||||||
"headersSize": -1,
|
|
||||||
"bodySize": -1
|
|
||||||
},
|
|
||||||
"response": {
|
|
||||||
"status": 200,
|
|
||||||
"statusText": "OK",
|
|
||||||
"httpVersion": "HTTP/1.1",
|
|
||||||
"cookies": [],
|
|
||||||
"headers": [
|
|
||||||
{ "name": "Access-Control-Allow-Origin", "value": "http://localhost:4200" },
|
|
||||||
{ "name": "Allow", "value": "GET, HEAD, OPTIONS" },
|
|
||||||
{ "name": "Content-Language", "value": "en-us" },
|
|
||||||
{ "name": "Content-Length", "value": "2" },
|
|
||||||
{ "name": "Content-Type", "value": "application/json" },
|
|
||||||
{ "name": "Cross-Origin-Opener-Policy", "value": "same-origin" },
|
|
||||||
{ "name": "Referrer-Policy", "value": "same-origin" },
|
|
||||||
{ "name": "Vary", "value": "Accept, Accept-Language, Origin, Cookie" },
|
|
||||||
{ "name": "X-Api-Version", "value": "3" },
|
|
||||||
{ "name": "X-Content-Type-Options", "value": "nosniff" },
|
|
||||||
{ "name": "X-Frame-Options", "value": "ANY" },
|
|
||||||
{ "name": "X-Version", "value": "1.14.4" }
|
|
||||||
],
|
|
||||||
"content": {
|
|
||||||
"size": -1,
|
|
||||||
"mimeType": "application/json",
|
|
||||||
"text": "[]"
|
|
||||||
},
|
|
||||||
"headersSize": -1,
|
|
||||||
"bodySize": -1,
|
|
||||||
"redirectURL": ""
|
|
||||||
},
|
|
||||||
"cache": {},
|
|
||||||
"timings": { "send": -1, "wait": -1, "receive": 0.566 }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"startedDateTime": "2023-05-14T07:16:51.578Z",
|
|
||||||
"time": 0.452,
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"url": "http://localhost:8000/api/statistics/",
|
|
||||||
"httpVersion": "HTTP/1.1",
|
|
||||||
"cookies": [],
|
|
||||||
"headers": [
|
|
||||||
{ "name": "Accept", "value": "application/json; version=3" },
|
|
||||||
{ "name": "Accept-Encoding", "value": "gzip, deflate, br" },
|
|
||||||
{ "name": "Accept-Language", "value": "en-US" },
|
|
||||||
{ "name": "Connection", "value": "keep-alive" },
|
|
||||||
{ "name": "Host", "value": "localhost:8000" },
|
|
||||||
{ "name": "Origin", "value": "http://localhost:4200" },
|
|
||||||
{ "name": "Referer", "value": "http://localhost:4200/" },
|
|
||||||
{ "name": "Sec-Fetch-Dest", "value": "empty" },
|
|
||||||
{ "name": "Sec-Fetch-Mode", "value": "cors" },
|
|
||||||
{ "name": "Sec-Fetch-Site", "value": "same-site" },
|
|
||||||
{ "name": "User-Agent", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.53 Safari/537.36" }
|
|
||||||
],
|
|
||||||
"queryString": [],
|
|
||||||
"headersSize": -1,
|
|
||||||
"bodySize": -1
|
|
||||||
},
|
|
||||||
"response": {
|
|
||||||
"status": 200,
|
|
||||||
"statusText": "OK",
|
|
||||||
"httpVersion": "HTTP/1.1",
|
|
||||||
"cookies": [],
|
|
||||||
"headers": [
|
|
||||||
{ "name": "Access-Control-Allow-Origin", "value": "http://localhost:4200" },
|
|
||||||
{ "name": "Allow", "value": "GET, HEAD, OPTIONS" },
|
|
||||||
{ "name": "Content-Language", "value": "en-us" },
|
|
||||||
{ "name": "Content-Length", "value": "257" },
|
|
||||||
{ "name": "Content-Type", "value": "application/json" },
|
|
||||||
{ "name": "Cross-Origin-Opener-Policy", "value": "same-origin" },
|
|
||||||
{ "name": "Referrer-Policy", "value": "same-origin" },
|
|
||||||
{ "name": "Vary", "value": "Accept, Accept-Language, Origin, Cookie" },
|
|
||||||
{ "name": "X-Api-Version", "value": "3" },
|
|
||||||
{ "name": "X-Content-Type-Options", "value": "nosniff" },
|
|
||||||
{ "name": "X-Frame-Options", "value": "ANY" },
|
|
||||||
{ "name": "X-Version", "value": "1.14.4" }
|
|
||||||
],
|
|
||||||
"content": {
|
|
||||||
"size": -1,
|
|
||||||
"mimeType": "application/json",
|
|
||||||
"text": "{\"documents_total\":61,\"documents_inbox\":8,\"inbox_tag\":9,\"document_file_type_counts\":[{\"mime_type\":\"application/pdf\",\"mime_type_count\":57},{\"mime_type\":\"text/plain\",\"mime_type_count\":3},{\"mime_type\":\"text/csv\",\"mime_type_count\":1}],\"character_count\":2407053}"
|
|
||||||
},
|
|
||||||
"headersSize": -1,
|
|
||||||
"bodySize": -1,
|
|
||||||
"redirectURL": ""
|
|
||||||
},
|
|
||||||
"cache": {},
|
|
||||||
"timings": { "send": -1, "wait": -1, "receive": 0.452 }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"startedDateTime": "2023-05-14T07:16:51.691Z",
|
|
||||||
"time": 0.891,
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"url": "http://localhost:8000/api/ui_settings/",
|
|
||||||
"httpVersion": "HTTP/1.1",
|
|
||||||
"cookies": [],
|
|
||||||
"headers": [
|
|
||||||
{ "name": "Accept", "value": "application/json; version=3" },
|
|
||||||
{ "name": "Accept-Encoding", "value": "gzip, deflate, br" },
|
|
||||||
{ "name": "Accept-Language", "value": "en-US" },
|
|
||||||
{ "name": "Connection", "value": "keep-alive" },
|
|
||||||
{ "name": "Host", "value": "localhost:8000" },
|
|
||||||
{ "name": "Origin", "value": "http://localhost:4200" },
|
|
||||||
{ "name": "Referer", "value": "http://localhost:4200/" },
|
|
||||||
{ "name": "Sec-Fetch-Dest", "value": "empty" },
|
|
||||||
{ "name": "Sec-Fetch-Mode", "value": "cors" },
|
|
||||||
{ "name": "Sec-Fetch-Site", "value": "same-site" },
|
|
||||||
{ "name": "User-Agent", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.53 Safari/537.36" }
|
|
||||||
],
|
|
||||||
"queryString": [],
|
|
||||||
"headersSize": -1,
|
|
||||||
"bodySize": -1
|
|
||||||
},
|
|
||||||
"response": {
|
|
||||||
"status": 200,
|
|
||||||
"statusText": "OK",
|
|
||||||
"httpVersion": "HTTP/1.1",
|
|
||||||
"cookies": [],
|
|
||||||
"headers": [
|
|
||||||
{ "name": "Access-Control-Allow-Origin", "value": "http://localhost:4200" },
|
|
||||||
{ "name": "Allow", "value": "GET, POST, HEAD, OPTIONS" },
|
|
||||||
{ "name": "Content-Encoding", "value": "br" },
|
|
||||||
{ "name": "Content-Language", "value": "en-us" },
|
|
||||||
{ "name": "Content-Length", "value": "385" },
|
|
||||||
{ "name": "Content-Type", "value": "application/json" },
|
|
||||||
{ "name": "Cross-Origin-Opener-Policy", "value": "same-origin" },
|
|
||||||
{ "name": "Referrer-Policy", "value": "same-origin" },
|
|
||||||
{ "name": "Vary", "value": "Accept, Accept-Language, Origin, Cookie, Accept-Encoding" },
|
|
||||||
{ "name": "X-Api-Version", "value": "3" },
|
|
||||||
{ "name": "X-Content-Type-Options", "value": "nosniff" },
|
|
||||||
{ "name": "X-Frame-Options", "value": "ANY" },
|
|
||||||
{ "name": "X-Version", "value": "1.14.4" }
|
|
||||||
],
|
|
||||||
"content": {
|
|
||||||
"size": -1,
|
|
||||||
"mimeType": "application/json",
|
|
||||||
"text": "{\"user\":{\"id\":2,\"username\":\"testuser\",\"is_superuser\":false,\"groups\":[]},\"settings\":{\"language\":\"\",\"bulk_edit\":{\"confirmation_dialogs\":true,\"apply_on_close\":false},\"documentListSize\":50,\"dark_mode\":{\"use_system\":false,\"enabled\":\"false\",\"thumb_inverted\":\"true\"},\"theme\":{\"color\":\"#9fbf2f\"},\"document_details\":{\"native_pdf_viewer\":false},\"date_display\":{\"date_locale\":\"\",\"date_format\":\"mediumDate\"},\"notifications\":{\"consumer_new_documents\":true,\"consumer_success\":true,\"consumer_failed\":true,\"consumer_suppress_on_dashboard\":true},\"comments_enabled\":true,\"slim_sidebar\":false,\"update_checking\":{\"enabled\":false,\"backend_setting\":\"default\"},\"saved_views\":{\"warn_on_unsaved_change\":true},\"notes_enabled\":true,\"tour_complete\":true},\"permissions\":[]}"
|
|
||||||
},
|
|
||||||
"headersSize": -1,
|
|
||||||
"bodySize": -1,
|
|
||||||
"redirectURL": ""
|
|
||||||
},
|
|
||||||
"cache": {},
|
|
||||||
"timings": { "send": -1, "wait": -1, "receive": 0.891 }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"startedDateTime": "2023-05-14T07:16:51.739Z",
|
|
||||||
"time": 0.405,
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"url": "http://localhost:8000/api/tasks/",
|
|
||||||
"httpVersion": "HTTP/1.1",
|
|
||||||
"cookies": [],
|
|
||||||
"headers": [
|
|
||||||
{ "name": "Accept", "value": "application/json; version=3" },
|
|
||||||
{ "name": "Accept-Encoding", "value": "gzip, deflate, br" },
|
|
||||||
{ "name": "Accept-Language", "value": "en-US" },
|
|
||||||
{ "name": "Connection", "value": "keep-alive" },
|
|
||||||
{ "name": "Host", "value": "localhost:8000" },
|
|
||||||
{ "name": "Origin", "value": "http://localhost:4200" },
|
|
||||||
{ "name": "Referer", "value": "http://localhost:4200/" },
|
|
||||||
{ "name": "Sec-Fetch-Dest", "value": "empty" },
|
|
||||||
{ "name": "Sec-Fetch-Mode", "value": "cors" },
|
|
||||||
{ "name": "Sec-Fetch-Site", "value": "same-site" },
|
|
||||||
{ "name": "User-Agent", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.53 Safari/537.36" }
|
|
||||||
],
|
|
||||||
"queryString": [],
|
|
||||||
"headersSize": -1,
|
|
||||||
"bodySize": -1
|
|
||||||
},
|
|
||||||
"response": {
|
|
||||||
"status": 200,
|
|
||||||
"statusText": "OK",
|
|
||||||
"httpVersion": "HTTP/1.1",
|
|
||||||
"cookies": [],
|
|
||||||
"headers": [
|
|
||||||
{ "name": "Access-Control-Allow-Origin", "value": "http://localhost:4200" },
|
|
||||||
{ "name": "Allow", "value": "GET, HEAD, OPTIONS" },
|
|
||||||
{ "name": "Content-Language", "value": "en-us" },
|
|
||||||
{ "name": "Content-Length", "value": "2" },
|
|
||||||
{ "name": "Content-Type", "value": "application/json" },
|
|
||||||
{ "name": "Cross-Origin-Opener-Policy", "value": "same-origin" },
|
|
||||||
{ "name": "Referrer-Policy", "value": "same-origin" },
|
|
||||||
{ "name": "Vary", "value": "Accept, Accept-Language, Origin, Cookie" },
|
|
||||||
{ "name": "X-Api-Version", "value": "3" },
|
|
||||||
{ "name": "X-Content-Type-Options", "value": "nosniff" },
|
|
||||||
{ "name": "X-Frame-Options", "value": "ANY" },
|
|
||||||
{ "name": "X-Version", "value": "1.14.4" }
|
|
||||||
],
|
|
||||||
"content": {
|
|
||||||
"size": -1,
|
|
||||||
"mimeType": "application/json",
|
|
||||||
"text": "[]"
|
|
||||||
},
|
|
||||||
"headersSize": -1,
|
|
||||||
"bodySize": -1,
|
|
||||||
"redirectURL": ""
|
|
||||||
},
|
|
||||||
"cache": {},
|
|
||||||
"timings": { "send": -1, "wait": -1, "receive": 0.405 }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"startedDateTime": "2023-05-14T07:16:51.739Z",
|
|
||||||
"time": 0.665,
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"url": "http://localhost:8000/api/statistics/",
|
|
||||||
"httpVersion": "HTTP/1.1",
|
|
||||||
"cookies": [],
|
|
||||||
"headers": [
|
|
||||||
{ "name": "Accept", "value": "application/json; version=3" },
|
|
||||||
{ "name": "Accept-Encoding", "value": "gzip, deflate, br" },
|
|
||||||
{ "name": "Accept-Language", "value": "en-US" },
|
|
||||||
{ "name": "Connection", "value": "keep-alive" },
|
|
||||||
{ "name": "Host", "value": "localhost:8000" },
|
|
||||||
{ "name": "Origin", "value": "http://localhost:4200" },
|
|
||||||
{ "name": "Referer", "value": "http://localhost:4200/" },
|
|
||||||
{ "name": "Sec-Fetch-Dest", "value": "empty" },
|
|
||||||
{ "name": "Sec-Fetch-Mode", "value": "cors" },
|
|
||||||
{ "name": "Sec-Fetch-Site", "value": "same-site" },
|
|
||||||
{ "name": "User-Agent", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.53 Safari/537.36" }
|
|
||||||
],
|
|
||||||
"queryString": [],
|
|
||||||
"headersSize": -1,
|
|
||||||
"bodySize": -1
|
|
||||||
},
|
|
||||||
"response": {
|
|
||||||
"status": 200,
|
|
||||||
"statusText": "OK",
|
|
||||||
"httpVersion": "HTTP/1.1",
|
|
||||||
"cookies": [],
|
|
||||||
"headers": [
|
|
||||||
{ "name": "Access-Control-Allow-Origin", "value": "http://localhost:4200" },
|
|
||||||
{ "name": "Allow", "value": "GET, HEAD, OPTIONS" },
|
|
||||||
{ "name": "Content-Language", "value": "en-us" },
|
|
||||||
{ "name": "Content-Length", "value": "257" },
|
|
||||||
{ "name": "Content-Type", "value": "application/json" },
|
|
||||||
{ "name": "Cross-Origin-Opener-Policy", "value": "same-origin" },
|
|
||||||
{ "name": "Referrer-Policy", "value": "same-origin" },
|
|
||||||
{ "name": "Vary", "value": "Accept, Accept-Language, Origin, Cookie" },
|
|
||||||
{ "name": "X-Api-Version", "value": "3" },
|
|
||||||
{ "name": "X-Content-Type-Options", "value": "nosniff" },
|
|
||||||
{ "name": "X-Frame-Options", "value": "ANY" },
|
|
||||||
{ "name": "X-Version", "value": "1.14.4" }
|
|
||||||
],
|
|
||||||
"content": {
|
|
||||||
"size": -1,
|
|
||||||
"mimeType": "application/json",
|
|
||||||
"text": "{\"documents_total\":61,\"documents_inbox\":8,\"inbox_tag\":9,\"document_file_type_counts\":[{\"mime_type\":\"application/pdf\",\"mime_type_count\":57},{\"mime_type\":\"text/plain\",\"mime_type_count\":3},{\"mime_type\":\"text/csv\",\"mime_type_count\":1}],\"character_count\":2407053}"
|
|
||||||
},
|
|
||||||
"headersSize": -1,
|
|
||||||
"bodySize": -1,
|
|
||||||
"redirectURL": ""
|
|
||||||
},
|
|
||||||
"cache": {},
|
|
||||||
"timings": { "send": -1, "wait": -1, "receive": 0.665 }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +1,35 @@
|
|||||||
{
|
{
|
||||||
"name": "paperless-ngx-ui",
|
"name": "paperless-ngx-ui",
|
||||||
"version": "3.0.5",
|
"version": "3.1.2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve",
|
"start": "ng serve",
|
||||||
"build": "ng build",
|
"build": "ng build",
|
||||||
|
"e2e": "playwright test",
|
||||||
|
"e2e:backend": "uv run --project .. --no-sync python e2e/backend.py",
|
||||||
|
"e2e:ui": "playwright test --ui",
|
||||||
|
"start:e2e": "ng serve --configuration=e2e",
|
||||||
"test": "ng test",
|
"test": "ng test",
|
||||||
"lint": "ng lint"
|
"lint": "ng lint"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^22.0.6",
|
"@angular/cdk": "^22.1.1",
|
||||||
"@angular/common": "~22.1.0",
|
"@angular/common": "~22.1.1",
|
||||||
"@angular/compiler": "~22.1.0",
|
"@angular/compiler": "~22.1.1",
|
||||||
"@angular/core": "~22.1.0",
|
"@angular/core": "~22.1.1",
|
||||||
"@angular/forms": "~22.1.0",
|
"@angular/forms": "~22.1.1",
|
||||||
"@angular/localize": "~22.1.0",
|
"@angular/localize": "~22.1.1",
|
||||||
"@angular/platform-browser": "~22.1.0",
|
"@angular/platform-browser": "~22.1.1",
|
||||||
"@angular/router": "~22.1.0",
|
"@angular/router": "~22.1.1",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
||||||
"@ng-select/ng-select": "^23.5.0",
|
"@ng-select/ng-select": "~23.6.0",
|
||||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
|
"lodash-es": "^4.18.1",
|
||||||
"mime-names": "^1.0.0",
|
"mime-names": "^1.0.0",
|
||||||
"ngx-bootstrap-icons": "^1.9.3",
|
"ngx-bootstrap-icons": "^1.9.3",
|
||||||
"ngx-color": "^10.1.0",
|
"ngx-color": "^10.1.0",
|
||||||
@@ -40,30 +45,31 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-builders/jest": "^22.0.1",
|
"@angular-builders/jest": "^22.0.1",
|
||||||
"@angular-devkit/core": "^22.1.2",
|
"@angular-devkit/core": "^22.1.3",
|
||||||
"@angular-devkit/schematics": "^22.1.2",
|
"@angular-devkit/schematics": "^22.1.3",
|
||||||
"@angular-eslint/builder": "22.1.0",
|
"@angular-eslint/builder": "22.1.0",
|
||||||
"@angular-eslint/eslint-plugin": "22.1.0",
|
"@angular-eslint/eslint-plugin": "22.1.0",
|
||||||
"@angular-eslint/eslint-plugin-template": "22.1.0",
|
"@angular-eslint/eslint-plugin-template": "22.1.0",
|
||||||
"@angular-eslint/schematics": "22.1.0",
|
"@angular-eslint/schematics": "22.1.0",
|
||||||
"@angular-eslint/template-parser": "22.1.0",
|
"@angular-eslint/template-parser": "22.1.0",
|
||||||
"@angular/build": "22.1.2",
|
"@angular/build": "22.1.3",
|
||||||
"@angular/cli": "22.1.2",
|
"@angular/cli": "22.1.3",
|
||||||
"@angular/compiler-cli": "~22.1.0",
|
"@angular/compiler-cli": "~22.1.1",
|
||||||
"@playwright/test": "^1.62.0",
|
"@playwright/test": "^1.62.1",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^26.1.1",
|
"@types/node": "^26.2.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.65.0",
|
"@typescript-eslint/eslint-plugin": "^8.67.0",
|
||||||
"@typescript-eslint/parser": "^8.65.0",
|
"@typescript-eslint/parser": "^8.67.0",
|
||||||
"@typescript-eslint/utils": "^8.65.0",
|
"@typescript-eslint/utils": "^8.67.0",
|
||||||
"eslint": "^10.8.0",
|
"eslint": "^10.8.1",
|
||||||
"jest": "30.4.2",
|
"jest": "30.4.2",
|
||||||
"jest-environment-jsdom": "^30.4.1",
|
"jest-environment-jsdom": "^30.4.1",
|
||||||
"jest-junit": "^17.0.0",
|
"jest-junit": "^17.0.0",
|
||||||
"jest-preset-angular": "^17.0.0",
|
"jest-preset-angular": "^17.0.0",
|
||||||
"jest-websocket-mock": "^2.5.0",
|
"jest-websocket-mock": "^2.5.0",
|
||||||
|
"prettier": "^3.9.6",
|
||||||
"prettier-plugin-organize-imports": "^4.3.0",
|
"prettier-plugin-organize-imports": "^4.3.0",
|
||||||
"ts-node": "~10.9.1",
|
"ts-node": "~10.9.2",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.15.1"
|
"packageManager": "pnpm@11.15.1"
|
||||||
|
|||||||
@@ -14,17 +14,25 @@ export default defineConfig({
|
|||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
/* Retry on CI only */
|
/* Retry on CI only */
|
||||||
retries: process.env.CI ? 3 : 0,
|
retries: process.env.CI ? 3 : 0,
|
||||||
/* Opt out of parallel tests on CI. */
|
/* Keep parallelism modest for the shared SQLite backend. */
|
||||||
workers: process.env.CI ? 1 : undefined,
|
workers: 2,
|
||||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
reporter: 'html',
|
reporter: 'html',
|
||||||
/* Run your local dev server before starting the tests */
|
/* Run the disposable backend and local UI before starting the tests. */
|
||||||
webServer: {
|
webServer: [
|
||||||
|
{
|
||||||
|
url: 'http://localhost:8001/accounts/login/',
|
||||||
|
command: 'npm run e2e:backend',
|
||||||
|
reuseExistingServer: false,
|
||||||
|
timeout: 2 * 60 * 1000,
|
||||||
|
},
|
||||||
|
{
|
||||||
port,
|
port,
|
||||||
command: 'pnpm run start',
|
command: 'npm run start:e2e',
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
timeout: 2 * 60 * 1000,
|
timeout: 2 * 60 * 1000,
|
||||||
},
|
},
|
||||||
|
],
|
||||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
use: {
|
use: {
|
||||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ trustPolicyExclude:
|
|||||||
- "chokidar@4.0.3"
|
- "chokidar@4.0.3"
|
||||||
- "semver@6.3.1 || 5.7.2"
|
- "semver@6.3.1 || 5.7.2"
|
||||||
blockExoticSubdeps: true
|
blockExoticSubdeps: true
|
||||||
|
autoInstallPeers: false
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
"@parcel/watcher": true
|
"@parcel/watcher": true
|
||||||
canvas: true
|
canvas: true
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
infoLink="configuration">
|
infoLink="configuration">
|
||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
|
|
||||||
<form [formGroup]="configForm" (ngSubmit)="saveConfig()" class="pb-4">
|
<form [formGroup]="configForm" (ngSubmit)="saveConfig()" class="pb-4" autocomplete="off">
|
||||||
|
|
||||||
<ul ngbNav #nav="ngbNav" class="nav-tabs">
|
<ul ngbNav #nav="ngbNav" class="nav-tabs">
|
||||||
@for (category of optionCategories; track category) {
|
@for (category of optionCategories; track category) {
|
||||||
@@ -14,8 +14,12 @@
|
|||||||
<a ngbNavLink>{{category}}</a>
|
<a ngbNavLink>{{category}}</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
|
@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">
|
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
|
||||||
@for (option of getCategoryOptions(category); track option.key) {
|
@for (option of getCategoryOptions(category, section); track option.key) {
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="card bg-light">
|
<div class="card bg-light">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@@ -40,7 +44,7 @@
|
|||||||
@case (ConfigOptionType.String) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
@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.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.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> }
|
@case (ConfigOptionType.Password) { <pngx-input-password [formControlName]="option.key" [error]="errors[option.key]" autocomplete="new-password"></pngx-input-password> }
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (option.note) {
|
@if (option.note) {
|
||||||
@@ -51,6 +55,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
|||||||
import { NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { of, throwError } from 'rxjs'
|
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 { ConfigService } from 'src/app/services/config.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
@@ -158,4 +162,24 @@ describe('ConfigComponent', () => {
|
|||||||
component.resetOption('barcodes_enabled')
|
component.resetOption('barcodes_enabled')
|
||||||
expect(component.configForm.get('barcodes_enabled').value).toBeNull()
|
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)
|
return Object.values(ConfigCategory)
|
||||||
}
|
}
|
||||||
|
|
||||||
getCategoryOptions(category: string): ConfigOption[] {
|
getCategorySections(category: string): string[] {
|
||||||
return PaperlessConfigOptions.filter((o) => o.category === category)
|
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
|
initialConfig: PaperlessConfig
|
||||||
|
|||||||
@@ -89,7 +89,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (filterText?.length) {
|
@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>
|
<i-bs width="1em" height="1em" name="x"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -208,7 +208,7 @@
|
|||||||
</td>
|
</td>
|
||||||
}
|
}
|
||||||
<td class="d-lg-none">
|
<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>
|
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -18,9 +18,11 @@
|
|||||||
</button>
|
</button>
|
||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
|
|
||||||
|
@if (totalDocuments() > 25) {
|
||||||
<div class="row mb-3">
|
<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>
|
<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>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="card border table-responsive mb-3">
|
<div class="card border table-responsive mb-3">
|
||||||
<table class="table table-striped align-middle shadow-sm mb-0">
|
<table class="table table-striped align-middle shadow-sm mb-0">
|
||||||
@@ -64,7 +66,7 @@
|
|||||||
<td scope="row">
|
<td scope="row">
|
||||||
<div class="btn-group d-block d-sm-none">
|
<div class="btn-group d-block d-sm-none">
|
||||||
<div ngbDropdown container="body" class="d-inline-block">
|
<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>
|
<i-bs name="three-dots-vertical"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
|
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
|
||||||
|
|||||||
@@ -2,3 +2,8 @@
|
|||||||
.d-block.d-sm-none .dropdown-toggle::after {
|
.d-block.d-sm-none .dropdown-toggle::after {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tbody tr:last-child td,
|
||||||
|
table:not(:has(tbody tr)) thead th {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,29 +2,34 @@
|
|||||||
<button class="navbar-toggler d-md-none collapsed border-0" type="button" data-toggle="collapse"
|
<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"
|
data-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation"
|
||||||
(click)="closeMobileSearch(); toggleMenuCollapsed()">
|
(click)="closeMobileSearch(); toggleMenuCollapsed()">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<i-bs width="1.5em" height="1.5em" name="list"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
<a class="navbar-brand d-flex align-items-center me-0 px-3 py-3 order-sm-0"
|
<div class="navbar-brand-container 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"
|
||||||
|
[ngClass]="{ 'slim': slimSidebarEnabled, '' : !slimSidebarEnabled }"
|
||||||
routerLink="/dashboard"
|
routerLink="/dashboard"
|
||||||
tourAnchor="tour.intro">
|
tourAnchor="tour.intro">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1.5em" height="1.5em" fill="currentColor">
|
@if (!hasCustomBranding) {
|
||||||
<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"/>
|
<pngx-logo extra_classes="navbar-official-logo px-1" height="2.4rem"></pngx-logo>
|
||||||
</svg>
|
<pngx-brand-mark class="brand-mark brand-mark-slim d-none"></pngx-brand-mark>
|
||||||
<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>
|
|
||||||
} @else {
|
} @else {
|
||||||
Paperless-ngx
|
@if (customAppLogo) {
|
||||||
|
<img class="brand-logo" [src]="customAppLogo" alt="" />
|
||||||
|
} @else {
|
||||||
|
<pngx-brand-mark class="brand-mark"></pngx-brand-mark>
|
||||||
|
}
|
||||||
|
<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>
|
</div>
|
||||||
|
}
|
||||||
</a>
|
</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>
|
||||||
|
<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()">
|
[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>
|
<pngx-global-search></pngx-global-search>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -34,7 +39,7 @@
|
|||||||
}
|
}
|
||||||
<pngx-toasts-dropdown></pngx-toasts-dropdown>
|
<pngx-toasts-dropdown></pngx-toasts-dropdown>
|
||||||
<li ngbDropdown class="nav-item dropdown">
|
<li ngbDropdown class="nav-item dropdown">
|
||||||
<button class="btn ps-1 border-0" id="userDropdown" ngbDropdownToggle>
|
<button class="btn navbar-action border-0 me-1 me-md-2" id="userDropdown" ngbDropdownToggle aria-label="User menu" i18n-aria-label>
|
||||||
<i-bs width="1.3em" height="1.3em" name="person-circle"></i-bs>
|
<i-bs width="1.3em" height="1.3em" name="person-circle"></i-bs>
|
||||||
<span class="small ms-2 d-none d-sm-inline">
|
<span class="small ms-2 d-none d-sm-inline">
|
||||||
{{this.settingsService.displayName}}
|
{{this.settingsService.displayName}}
|
||||||
@@ -68,10 +73,10 @@
|
|||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
|
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
|
||||||
[ngClass]="slimSidebarEnabled ? 'slim' : 'col-md-3 col-lg-2 col-xxxl-1'" [class.animating]="slimSidebarAnimating()"
|
[ngClass]="slimSidebarEnabled ? 'slim' : 'expanded'" [class.animating]="slimSidebarAnimating()"
|
||||||
[ngbCollapse]="isMenuCollapsed()">
|
[ngbCollapse]="isMenuCollapsed()">
|
||||||
@if (canSaveSettings) {
|
@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) {
|
@if (slimSidebarEnabled) {
|
||||||
<i-bs width="0.9em" height="0.9em" name="chevron-double-right"></i-bs>
|
<i-bs width="0.9em" height="0.9em" name="chevron-double-right"></i-bs>
|
||||||
} @else {
|
} @else {
|
||||||
@@ -79,20 +84,22 @@
|
|||||||
}
|
}
|
||||||
</button>
|
</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">
|
<ul class="nav flex-column">
|
||||||
<li class="nav-item app-link">
|
<li class="nav-item app-link">
|
||||||
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
<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">
|
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>
|
<i-bs class="me-2" name="house"></i-bs><span class="nav-link-label"><ng-container i18n>Dashboard</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||||
<a class="nav-link" routerLink="documents" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="documents" routerLinkActive="active"
|
||||||
ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
[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">
|
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>
|
<i-bs class="me-2" name="files"></i-bs><span class="nav-link-label"><ng-container i18n>Documents</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -104,12 +111,12 @@
|
|||||||
</h6>
|
</h6>
|
||||||
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
|
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
|
||||||
@for (view of savedViewService.sidebarViews; track view.id) {
|
@for (view of savedViewService.sidebarViews; track view.id) {
|
||||||
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews() || !canSaveSettings"
|
<li class="nav-item app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews() || !canSaveSettings"
|
||||||
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
|
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
|
||||||
(cdkDragEnded)="onDragEnd($event)">
|
(cdkDragEnded)="onDragEnd($event)">
|
||||||
<a class="nav-link" routerLink="view/{{view.id}}"
|
<a class="nav-link" routerLink="view/{{view.id}}"
|
||||||
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name"
|
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">
|
popoverClass="popover-slim">
|
||||||
<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>
|
<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) {
|
@if (showSidebarCounts && !slimSidebarEnabled) {
|
||||||
@@ -121,7 +128,7 @@
|
|||||||
}
|
}
|
||||||
</a>
|
</a>
|
||||||
@if (settingsService.organizingSidebarSavedViews() && canSaveSettings) {
|
@if (settingsService.organizingSidebarSavedViews() && canSaveSettings) {
|
||||||
<div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>
|
<div class="position-absolute end-0 top-0 px-1 py-2" [class.me-n2]="slimSidebarEnabled" cdkDragHandle>
|
||||||
<i-bs name="grip-vertical"></i-bs>
|
<i-bs name="grip-vertical"></i-bs>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -147,7 +154,7 @@
|
|||||||
<li class="nav-item w-100 app-link">
|
<li class="nav-item w-100 app-link">
|
||||||
<a class="nav-link app-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="documents/{{d.id}}"
|
<a class="nav-link app-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="documents/{{d.id}}"
|
||||||
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="d.title | documentTitle"
|
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">
|
popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="file-text"></i-bs><span>{{d.title | documentTitle}}</span>
|
<i-bs class="me-2" name="file-text"></i-bs><span>{{d.title | documentTitle}}</span>
|
||||||
<span class="close flex-column justify-content-center"
|
<span class="close flex-column justify-content-center"
|
||||||
@@ -159,11 +166,12 @@
|
|||||||
}
|
}
|
||||||
@if (openDocuments.length >= 1) {
|
@if (openDocuments.length >= 1) {
|
||||||
<li class="nav-item w-100 app-link">
|
<li class="nav-item w-100 app-link">
|
||||||
<a class="nav-link app-link" [class.text-truncate]="!slimSidebarEnabled" [routerLink]="[]" (click)="closeAll()"
|
<button type="button" class="nav-link nav-link-action app-link w-100 text-start"
|
||||||
ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
[class.text-truncate]="!slimSidebarEnabled" (click)="closeAll()"
|
||||||
|
ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
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>
|
<i-bs class="me-2" name="x"></i-bs><span class="nav-link-label"><ng-container i18n>Close all</ng-container></span>
|
||||||
</a>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -177,10 +185,11 @@
|
|||||||
@if (canManageAttributes) {
|
@if (canManageAttributes) {
|
||||||
<li class="nav-item app-link" tourAnchor="tour.tags">
|
<li class="nav-item app-link" tourAnchor="tour.tags">
|
||||||
<div class="d-flex align-items-center attributes-row">
|
<div class="d-flex align-items-center attributes-row">
|
||||||
<a class="nav-link flex-fill" routerLink="attributes" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link flex-fill" routerLink="attributes" routerLinkActive="active"
|
||||||
ngbPopover="Attributes" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
[routerLinkActiveOptions]="{ exact: !(slimSidebarEnabled || attributesSectionsCollapsed) }" (click)="closeMenu()"
|
||||||
|
ngbPopover="Attributes" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
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="nav-link-label ms-2"><ng-container i18n>Attributes</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@if (!slimSidebarEnabled && canSaveSettings) {
|
@if (!slimSidebarEnabled && canSaveSettings) {
|
||||||
<button
|
<button
|
||||||
@@ -195,33 +204,33 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="attributes-submenu ms-2"
|
class="attributes-submenu ms-3"
|
||||||
[ngbCollapse]="slimSidebarEnabled || attributesSectionsCollapsed"
|
[ngbCollapse]="slimSidebarEnabled || attributesSectionsCollapsed"
|
||||||
>
|
>
|
||||||
<ul class="nav flex-column">
|
<ul class="nav flex-column">
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }">
|
<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>
|
<i-bs class="me-2" name="tags"></i-bs><span class="nav-link-label"><ng-container i18n>Tags</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
<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>
|
<i-bs class="me-2" name="person"></i-bs><span class="nav-link-label"><ng-container i18n>Correspondents</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
|
<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>
|
<i-bs class="me-2" name="hash"></i-bs><span class="nav-link-label"><ng-container i18n>Document types</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
|
<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>
|
<i-bs class="me-2" name="folder"></i-bs><span class="nav-link-label"><ng-container i18n>Storage paths</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
|
<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>
|
<i-bs class="me-2" name="ui-radios"></i-bs><span class="nav-link-label"><ng-container i18n>Custom fields</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -230,33 +239,33 @@
|
|||||||
}
|
}
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
||||||
<a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
|
<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">
|
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>
|
<i-bs class="me-2" name="window-stack"></i-bs><span class="nav-link-label"><ng-container i18n>Saved Views</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link"
|
<li class="nav-item app-link"
|
||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
|
||||||
tourAnchor="tour.workflows">
|
tourAnchor="tour.workflows">
|
||||||
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
<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">
|
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>
|
<i-bs class="me-2" name="boxes"></i-bs><span class="nav-link-label"><ng-container i18n>Workflows</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
||||||
tourAnchor="tour.mail">
|
tourAnchor="tour.mail">
|
||||||
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="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">
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="envelope"></i-bs><span><ng-container i18n>Mail</ng-container></span>
|
<i-bs class="me-2" name="envelope"></i-bs><span class="nav-link-label"><ng-container i18n>Mail</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
|
<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"
|
<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">
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="trash"></i-bs><span><ng-container i18n>Trash</ng-container></span>
|
<i-bs class="me-2" name="trash"></i-bs><span class="nav-link-label"><ng-container i18n>Trash</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -270,34 +279,35 @@
|
|||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }"
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }"
|
||||||
tourAnchor="tour.settings">
|
tourAnchor="tour.settings">
|
||||||
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
|
<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">
|
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>
|
<i-bs class="me-2" name="gear"></i-bs><span class="nav-link-label"><ng-container i18n>Settings</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }">
|
||||||
<a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()"
|
<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">
|
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>
|
<i-bs class="me-2" name="sliders2-vertical"></i-bs><span class="nav-link-label"><ng-container i18n>Configuration</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
|
||||||
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()"
|
<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">
|
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>
|
<i-bs class="me-2" name="people"></i-bs><span class="nav-link-label"><ng-container i18n>Users & Groups</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link"
|
<li class="nav-item app-link"
|
||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }"
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }"
|
||||||
tourAnchor="tour.file-tasks">
|
tourAnchor="tour.file-tasks">
|
||||||
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
|
<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">
|
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) {
|
<i-bs class="me-2" name="list-task"></i-bs><span class="nav-link-label"><ng-container i18n>Tasks</ng-container></span>
|
||||||
<span><span class="badge bg-danger ms-2 d-inline">{{tasksService.needsAttentionTasks.length}}</span></span>
|
@if (tasksService.needsAttentionTasks.length > 0 && !slimSidebarEnabled) {
|
||||||
}</span>
|
<span class="badge bg-danger ms-2 d-inline flex-shrink-0">{{tasksService.needsAttentionTasks.length}}</span>
|
||||||
|
}
|
||||||
@if (tasksService.needsAttentionTasks.length > 0 && slimSidebarEnabled) {
|
@if (tasksService.needsAttentionTasks.length > 0 && slimSidebarEnabled) {
|
||||||
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.needsAttentionTasks.length}}</span>
|
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.needsAttentionTasks.length}}</span>
|
||||||
}
|
}
|
||||||
@@ -306,32 +316,32 @@
|
|||||||
@if (permissionsService.isAdmin()) {
|
@if (permissionsService.isAdmin()) {
|
||||||
<li class="nav-item app-link">
|
<li class="nav-item app-link">
|
||||||
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
|
<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">
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="text-left"></i-bs><span><ng-container i18n>Logs</ng-container></span>
|
<i-bs class="me-2" name="text-left"></i-bs><span class="nav-link-label"><ng-container i18n>Logs</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
<li class="nav-item mt-2" tourAnchor="tour.outro">
|
<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"
|
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">
|
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>
|
<i-bs class="d-flex me-2" name="question-circle"></i-bs><span><ng-container i18n>Documentation</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
<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">
|
<div class="me-2">
|
||||||
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer"
|
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer"
|
||||||
href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover
|
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">
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
{{ versionString }}
|
{{ versionString }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@if (!settingsService.updateCheckingIsSet || appRemoteVersion()) {
|
@if (!settingsService.updateCheckingIsSet || appRemoteVersion()) {
|
||||||
<div class="version-check">
|
<div class="version-check d-flex align-items-center">
|
||||||
<ng-template #updateAvailablePopContent>
|
<ng-template #updateAvailablePopContent>
|
||||||
<span class="small">Paperless-ngx {{ appRemoteVersion().version }} <ng-container i18n>is
|
<span class="small">Paperless-ngx {{ appRemoteVersion().version }} <ng-container i18n>is
|
||||||
available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span>
|
available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span>
|
||||||
@@ -363,7 +373,7 @@
|
|||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
} @else {
|
} @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"
|
[ngbPopover]="updateCheckingNotEnabledPopContent" popoverClass="shadow" triggers="mouseenter"
|
||||||
container="body">
|
container="body">
|
||||||
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
|
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
|
||||||
@@ -379,7 +389,7 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main role="main" class="ms-sm-auto px-md-4" [class.mobile-search-hidden]="mobileSearchHidden()"
|
<main role="main" class="ms-sm-auto px-md-4" [class.mobile-search-hidden]="mobileSearchHidden()"
|
||||||
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-md-9 col-lg-10 col-xxxl-11'">
|
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-sidebar-expanded'">
|
||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,10 +7,11 @@
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 995; /* Behind the navbar */
|
z-index: 995; /* Behind the navbar */
|
||||||
padding: 50px 0 0; /* Height of navbar */
|
padding: 64px 0 0; /* Height of navbar */
|
||||||
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
|
border-right: 1px solid color-mix(in srgb, var(--bs-border-color) 65%, transparent);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
--pngx-sidebar-width: 100%;
|
--pngx-sidebar-width: 100%;
|
||||||
|
width: var(--pngx-sidebar-width);
|
||||||
max-width: var(--pngx-sidebar-width);
|
max-width: var(--pngx-sidebar-width);
|
||||||
transition: all .2s ease;
|
transition: all .2s ease;
|
||||||
|
|
||||||
@@ -19,30 +20,28 @@
|
|||||||
height: 0.8em;
|
height: 0.8em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-heading,
|
||||||
|
.text-uppercase {
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
.view-name {
|
.view-name {
|
||||||
max-width: calc(100% - 50px)
|
max-width: calc(100% - 55px)
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-group:not(:has(.app-link)) .sidebar-heading {
|
.nav-group:not(:has(.app-link)) .sidebar-heading {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// These come from the col-* classes for non-slim sidebar, needed for animation
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
--pngx-sidebar-width: 25%;
|
&.expanded {
|
||||||
|
--pngx-sidebar-width: var(--pngx-sidebar-expanded-width);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 992px) {
|
|
||||||
--pngx-sidebar-width: 16.66666667%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 2400px) {
|
|
||||||
--pngx-sidebar-width: 8.33333333%;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 767.98px) {
|
@media (max-width: 767.98px) {
|
||||||
.sidebar {
|
.sidebar {
|
||||||
top: 3.5rem;
|
top: 4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-container {
|
.search-container {
|
||||||
@@ -62,16 +61,35 @@
|
|||||||
main.mobile-search-hidden {
|
main.mobile-search-hidden {
|
||||||
padding-top: 56px;
|
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 {
|
main {
|
||||||
transition: all .2s ease;
|
transition: all .2s ease;
|
||||||
padding-top: 110px;
|
padding-top: 118px;
|
||||||
|
background: var(--pngx-bg-darker);
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
main {
|
main {
|
||||||
padding-top: 56px;
|
padding-top: 64px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,16 +99,26 @@ main {
|
|||||||
|
|
||||||
.sidebar li.nav-item span,
|
.sidebar li.nav-item span,
|
||||||
.sidebar .sidebar-heading span {
|
.sidebar .sidebar-heading span {
|
||||||
transition: all .1s ease;
|
transition: opacity .1s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media(min-width: 768px) {
|
@media(min-width: 768px) {
|
||||||
|
// hide scrollbars on browsers that take up layout width
|
||||||
|
// :host-context since <html> is outside the component
|
||||||
|
:host-context(.pngx-classic-scrollbars) .sidebar.slim {
|
||||||
|
scrollbar-width: none;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar.slim {
|
.sidebar.slim {
|
||||||
max-width: 50px;
|
max-width: 55px;
|
||||||
|
|
||||||
li.nav-item span.badge {
|
li.nav-item span.badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-right: 2px;
|
margin-right: -4px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +129,19 @@ main {
|
|||||||
.sidebar-heading span {
|
.sidebar-heading span {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-link,
|
||||||
|
.nav-anchor {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
|
||||||
|
i-bs {
|
||||||
|
margin-right: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.slim:not(.animating) ~ main.col-slim {
|
.sidebar.slim:not(.animating) ~ main.col-slim {
|
||||||
@@ -134,26 +175,19 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.slim {
|
|
||||||
li.nav-item span.badge {
|
|
||||||
display: inline-block;
|
|
||||||
margin-right: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-slim-toggler {
|
.sidebar-slim-toggler {
|
||||||
display: block;
|
display: block;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: calc(var(--pngx-sidebar-width) - 12px);
|
left: calc(var(--pngx-sidebar-width) - 12px);
|
||||||
top: 60px;
|
bottom: 16px;
|
||||||
z-index: 996;
|
z-index: 996;
|
||||||
--bs-btn-padding-x: 0.35rem;
|
--bs-btn-padding-x: 0.35rem;
|
||||||
--bs-btn-padding-y: 0.125rem;
|
--bs-btn-padding-y: 0.125rem;
|
||||||
transition: all .2s ease;
|
transition: left .2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.slim .sidebar-slim-toggler {
|
.sidebar.slim .sidebar-slim-toggler {
|
||||||
--pngx-sidebar-width: 50px !important;
|
--pngx-sidebar-width: 56px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +200,7 @@ main {
|
|||||||
position: relative;
|
position: relative;
|
||||||
top: 0;
|
top: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding-top: 0.5rem;
|
padding: .75rem .5rem 1rem;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */
|
overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */
|
||||||
min-height: min-content;
|
min-height: min-content;
|
||||||
@@ -181,9 +215,14 @@ main {
|
|||||||
.sidebar .nav-link {
|
.sidebar .nav-link {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
white-space: nowrap;
|
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);
|
color: var(--bs-primary);
|
||||||
|
background-color: color-mix(in srgb, var(--bs-primary) 8%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
@@ -192,7 +231,21 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.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 {
|
i-bs {
|
||||||
@@ -201,9 +254,69 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar .nav-link:has(> .nav-link-label) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
> i-bs {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .nav-link-label {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar:not(.slim):not(.animating) .nav-link > .nav-link-label {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
.attributes-row .attributes-expand-btn {
|
||||||
opacity: 0.2;
|
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;
|
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 {
|
.attributes-row:hover .attributes-expand-btn {
|
||||||
@@ -211,8 +324,10 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-heading {
|
.sidebar-heading {
|
||||||
font-size: 0.75rem;
|
font-size: 0.68rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
font-weight: 700;
|
||||||
|
opacity: .75;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
@@ -269,16 +384,136 @@ main {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
.navbar-brand {
|
.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 {
|
&:hover,
|
||||||
padding: 0.15rem 0;
|
&: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 {
|
||||||
|
will-change: filter; // prevent resizing the filter region on hover and nudging the logo a pixel
|
||||||
|
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;
|
||||||
|
will-change: filter; // prevent resizing the filter region on hover and nudging the logo a pixel
|
||||||
|
filter: drop-shadow(0 2px 3px rgba(var(--pngx-navbar-brand-shadow-rgb), 0));
|
||||||
|
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 {
|
.byline {
|
||||||
font-size: 0.5rem;
|
margin-top: .15rem;
|
||||||
letter-spacing: 0.1rem;
|
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;
|
||||||
|
will-change: filter; // prevent resizing the filter region on hover and nudging the logo a pixel
|
||||||
|
filter: drop-shadow(0 2px 3px rgba(var(--pngx-navbar-brand-shadow-rgb), 0));
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// true center the search with equal flex widths
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.navbar-brand-container,
|
||||||
|
.navbar > ul {
|
||||||
|
display: flex; // so the brand link stays as wide as its contents
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar > ul {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
: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) {
|
@media screen and (max-width: 575.98px) {
|
||||||
@@ -292,9 +527,11 @@ main {
|
|||||||
|
|
||||||
.navbar-toggler {
|
.navbar-toggler {
|
||||||
grid-area: toggler;
|
grid-area: toggler;
|
||||||
|
color: var(--pngx-primary-text-contrast);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.navbar-brand-container,
|
||||||
.navbar-brand {
|
.navbar-brand {
|
||||||
grid-area: brand;
|
grid-area: brand;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -327,18 +564,44 @@ main {
|
|||||||
grid-area: actions;
|
grid-area: actions;
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
flex-wrap: nowrap;
|
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) {
|
@media screen and (min-width: 768px) {
|
||||||
.navbar-brand.slim {
|
.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.show .dropdown-toggle,
|
||||||
:host ::ng-deep .dropdown-toggle:hover {
|
:host ::ng-deep .dropdown-toggle:hover {
|
||||||
opacity: 0.7;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-toggle::after {
|
.dropdown-toggle::after {
|
||||||
|
|||||||
@@ -543,6 +543,27 @@ describe('AppFrameComponent', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should only flag scrollbars that take up layout width', () => {
|
||||||
|
const offsetWidth = jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get')
|
||||||
|
jest.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(100)
|
||||||
|
|
||||||
|
offsetWidth.mockReturnValue(115)
|
||||||
|
component['detectClassicScrollbars']()
|
||||||
|
expect(
|
||||||
|
window.document.documentElement.classList.contains(
|
||||||
|
'pngx-classic-scrollbars'
|
||||||
|
)
|
||||||
|
).toBeTruthy()
|
||||||
|
|
||||||
|
offsetWidth.mockReturnValue(100)
|
||||||
|
component['detectClassicScrollbars']()
|
||||||
|
expect(
|
||||||
|
window.document.documentElement.classList.contains(
|
||||||
|
'pngx-classic-scrollbars'
|
||||||
|
)
|
||||||
|
).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
it('should collapse attributes sections when enabling slim sidebar', () => {
|
it('should collapse attributes sections when enabling slim sidebar', () => {
|
||||||
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
|
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
|
||||||
settingsService.set(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED, [])
|
settingsService.set(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED, [])
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ import { TasksService } from 'src/app/services/tasks.service'
|
|||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { ChatComponent } from '../chat/chat/chat.component'
|
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 { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
||||||
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
||||||
@@ -59,6 +61,8 @@ const SCROLL_THRESHOLD = 16
|
|||||||
styleUrls: ['./app-frame.component.scss'],
|
styleUrls: ['./app-frame.component.scss'],
|
||||||
imports: [
|
imports: [
|
||||||
GlobalSearchComponent,
|
GlobalSearchComponent,
|
||||||
|
LogoComponent,
|
||||||
|
BrandMarkComponent,
|
||||||
DocumentTitlePipe,
|
DocumentTitlePipe,
|
||||||
IfPermissionsDirective,
|
IfPermissionsDirective,
|
||||||
ToastsDropdownComponent,
|
ToastsDropdownComponent,
|
||||||
@@ -114,6 +118,7 @@ export class AppFrameComponent
|
|||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.lastScrollY = window.scrollY
|
this.lastScrollY = window.scrollY
|
||||||
|
this.detectClassicScrollbars()
|
||||||
|
|
||||||
if (this.settingsService.get(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED)) {
|
if (this.settingsService.get(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED)) {
|
||||||
this.checkForUpdates()
|
this.checkForUpdates()
|
||||||
@@ -190,11 +195,34 @@ export class AppFrameComponent
|
|||||||
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
|
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get appTitle(): string {
|
||||||
|
this.settingsService.trackChanges()
|
||||||
|
return (
|
||||||
|
this.settingsService.get(SETTINGS_KEYS.APP_TITLE) || environment.appTitle
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
get customAppTitle(): string {
|
get customAppTitle(): string {
|
||||||
this.settingsService.trackChanges()
|
this.settingsService.trackChanges()
|
||||||
return this.settingsService.get(SETTINGS_KEYS.APP_TITLE)
|
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 {
|
get canSaveSettings(): boolean {
|
||||||
return (
|
return (
|
||||||
this.permissionsService.currentUserCan(
|
this.permissionsService.currentUserCan(
|
||||||
@@ -253,6 +281,10 @@ export class AppFrameComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get slimSidebarPopoversEnabled(): boolean {
|
||||||
|
return this.slimSidebarEnabled && !this.isMobileViewport()
|
||||||
|
}
|
||||||
|
|
||||||
get attributesSectionsCollapsed(): boolean {
|
get attributesSectionsCollapsed(): boolean {
|
||||||
this.settingsService.trackChanges()
|
this.settingsService.trackChanges()
|
||||||
return this.settingsService
|
return this.settingsService
|
||||||
@@ -312,6 +344,22 @@ export class AppFrameComponent
|
|||||||
this.lastScrollY = currentScrollY
|
this.lastScrollY = currentScrollY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flag for browsers whose scrollbars take up layout width. Remove me
|
||||||
|
* some day, I hope.
|
||||||
|
*/
|
||||||
|
private detectClassicScrollbars(): void {
|
||||||
|
const probe = document.createElement('div')
|
||||||
|
probe.style.cssText =
|
||||||
|
'position:absolute;top:-9999px;width:100px;height:100px;overflow:scroll'
|
||||||
|
document.body.appendChild(probe)
|
||||||
|
document.documentElement.classList.toggle(
|
||||||
|
'pngx-classic-scrollbars',
|
||||||
|
probe.offsetWidth > probe.clientWidth
|
||||||
|
)
|
||||||
|
probe.remove()
|
||||||
|
}
|
||||||
|
|
||||||
private isMobileViewport(): boolean {
|
private isMobileViewport(): boolean {
|
||||||
return window.innerWidth < 768
|
return window.innerWidth < 768
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
[ngModel]="query()"
|
[ngModel]="query()"
|
||||||
(ngModelChange)="queryDebounce.next($event)"
|
(ngModelChange)="onQueryChange($event)"
|
||||||
(keydown)="searchInputKeyDown($event)"
|
(keydown)="searchInputKeyDown($event)"
|
||||||
ngbDropdownAnchor>
|
ngbDropdownAnchor>
|
||||||
<div class="position-absolute top-50 end-0 translate-middle">
|
<div class="position-absolute top-50 end-0 translate-middle">
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ form {
|
|||||||
> i-bs[name="search"] {
|
> i-bs[name="search"] {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0.6rem;
|
left: 0.6rem;
|
||||||
top: .35rem;
|
top: .25rem;
|
||||||
color: rgba(255, 255, 255, 0.6);
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
|
||||||
@media screen and (min-width: 768px) {
|
@media screen and (min-width: 768px) {
|
||||||
// adjust for smaller font size on non-mobile
|
// adjust for smaller font size on non-mobile
|
||||||
top: 0.25rem;
|
top: .15rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,8 +37,9 @@ form {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.form-control {
|
.form-control {
|
||||||
color: rgba(255, 255, 255, 0.3);
|
min-height: 2.25rem;
|
||||||
background-color: rgba(0, 0, 0, 0.15);
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
background-color: rgba(0, 0, 0, 0.16);
|
||||||
padding-left: 1.8rem;
|
padding-left: 1.8rem;
|
||||||
border-color: rgba(255, 255, 255, 0.2);
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
transition: all .3s ease, padding-left 0s ease, background-color 0s ease; // Safari requires all
|
transition: all .3s ease, padding-left 0s ease, background-color 0s ease; // Safari requires all
|
||||||
@@ -52,7 +53,7 @@ form {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:focus-within {
|
&:focus-within {
|
||||||
background-color: rgba(0, 0, 0, 0.3);
|
background-color: rgba(0, 0, 0, 0.26);
|
||||||
color: var(--bs-light);
|
color: var(--bs-light);
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
padding-left: 0.5rem;
|
padding-left: 0.5rem;
|
||||||
|
|||||||
@@ -272,6 +272,19 @@ describe('GlobalSearchComponent', () => {
|
|||||||
expect(advancedSearchSpy).toHaveBeenCalled()
|
expect(advancedSearchSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should set query immediately and run full search on enter without waiting for debounce', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
|
const searchSpy = jest.spyOn(searchService, 'globalSearch')
|
||||||
|
searchSpy.mockReturnValue(of({} as any))
|
||||||
|
const fullSearchSpy = jest.spyOn(component, 'runFullSearch')
|
||||||
|
component.onQueryChange('test')
|
||||||
|
expect(component.query()).toBe('test')
|
||||||
|
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
|
||||||
|
expect(fullSearchSpy).toHaveBeenCalled()
|
||||||
|
expect(searchSpy).not.toHaveBeenCalled()
|
||||||
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
it('should search on query debounce', () => {
|
it('should search on query debounce', () => {
|
||||||
jest.useFakeTimers()
|
jest.useFakeTimers()
|
||||||
const query = 'test'
|
const query = 'test'
|
||||||
|
|||||||
@@ -119,6 +119,12 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onQueryChange(text: string) {
|
||||||
|
// set immediately so Enter / the full search button work without waiting for the debounce
|
||||||
|
this.query.set(text)
|
||||||
|
this.queryDebounce.next(text)
|
||||||
|
}
|
||||||
|
|
||||||
public ngOnInit() {
|
public ngOnInit() {
|
||||||
this.hotkeyService
|
this.hotkeyService
|
||||||
.addShortcut({ keys: '/', description: $localize`Global search` })
|
.addShortcut({ keys: '/', description: $localize`Global search` })
|
||||||
|
|||||||
@@ -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) {
|
@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>
|
<i-bs width="1.3em" height="1.3em" name="bell"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="notificationsDropdown">
|
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="notificationsDropdown">
|
||||||
|
|||||||
@@ -11,6 +11,16 @@
|
|||||||
display: none;
|
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 {
|
.dropdown-item {
|
||||||
white-space: initial;
|
white-space: initial;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
|
||||||
<li ngbDropdown class="nav-item me-n2" (openChange)="onOpenChange($event)">
|
<li ngbDropdown class="nav-item" (openChange)="onOpenChange($event)">
|
||||||
<button class="btn border-0" id="chatDropdown" ngbDropdownToggle>
|
<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>
|
<i-bs width="1.3em" height="1.3em" name="chatSquareDots"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
|
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.list-group-item {
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group mt-4">
|
<div class="form-group mt-4">
|
||||||
<label class="form-label" for="metadataDocumentID" i18n>Use metadata from:</label>
|
<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>
|
<option [ngValue]="-1" i18n>Regenerate all metadata</option>
|
||||||
@for (document of documents(); track document.id) {
|
@for (document of documents(); track document.id) {
|
||||||
<option [ngValue]="document.id">{{document.title}}</option>
|
<option [ngValue]="document.id">{{document.title}}</option>
|
||||||
|
|||||||