Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -185,22 +185,16 @@ jobs:
|
||||
flags: frontend-node-${{ matrix.node-version }}
|
||||
directory: src-ui/coverage/
|
||||
e2e-tests:
|
||||
name: "E2E Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})"
|
||||
name: E2E Tests
|
||||
needs: [changes, install-dependencies]
|
||||
if: needs.changes.outputs.frontend_changed == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
container: mcr.microsoft.com/playwright:v1.62.0-noble
|
||||
container: mcr.microsoft.com/playwright:v1.62.1-noble
|
||||
env:
|
||||
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node-version: [24.x]
|
||||
shard-index: [1, 2]
|
||||
shard-count: [2]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -216,6 +210,17 @@ jobs:
|
||||
node-version: 24.x
|
||||
cache: 'pnpm'
|
||||
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
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
@@ -225,8 +230,17 @@ jobs:
|
||||
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||
- name: Install dependencies
|
||||
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
|
||||
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:
|
||||
name: Frontend Build
|
||||
needs: [changes, unit-tests, e2e-tests]
|
||||
|
||||
@@ -115,6 +115,3 @@ celerybeat-schedule*
|
||||
|
||||
# Git worktree local folder
|
||||
.worktrees
|
||||
|
||||
# Agent workflow scratch (ledgers, briefs, review packages)
|
||||
.superpowers/
|
||||
|
||||
@@ -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
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
|
||||
## Transparency
|
||||
|
||||
@@ -521,8 +521,7 @@ Pass `--recreate` to wipe the existing index before rebuilding. Use this when th
|
||||
index is corrupted or you want a fully clean rebuild.
|
||||
|
||||
Pass `--if-needed` to skip the rebuild if the index is already up to date (schema
|
||||
version, schema fingerprint and search language all match). Safe to run on every
|
||||
startup or upgrade.
|
||||
version and search language match). Safe to run on every startup or upgrade.
|
||||
|
||||
Specify `optimize` to optimize the index. This command is regularly invoked by the
|
||||
task scheduler.
|
||||
|
||||
@@ -302,7 +302,8 @@ The following methods are supported:
|
||||
- `delete`
|
||||
- No `parameters` required
|
||||
- `reprocess`
|
||||
- No `parameters` required
|
||||
- Optional `parameters`: `{ "remote_ocr": true }` to send the documents to the
|
||||
remote OCR engine, see [Remote OCR](usage.md#remote-ocr). Defaults to false.
|
||||
- `set_permissions`
|
||||
- Requires `parameters`:
|
||||
- `"set_permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or
|
||||
|
||||
|
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,256 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
### 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
|
||||
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.
|
||||
Rather, configure paperless by copying necessary options to
|
||||
`docker-compose.env`.
|
||||
@@ -1125,6 +1131,10 @@ they use underscores instead of dashes.
|
||||
so specifying invalid options may prevent paperless from consuming
|
||||
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
|
||||
booleans and double quoted parameter names and strings. Examples:
|
||||
|
||||
@@ -2066,6 +2076,18 @@ password. All of these options come from their similarly-named [Django settings]
|
||||
|
||||
Defaults to None.
|
||||
|
||||
#### [`PAPERLESS_REMOTE_OCR_MODE=<str>`](#PAPERLESS_REMOTE_OCR_MODE) {#PAPERLESS_REMOTE_OCR_MODE}
|
||||
|
||||
: Which documents are sent to the remote OCR engine.
|
||||
|
||||
- `always`: every document of a supported file type is sent to the remote
|
||||
engine, bypassing the local OCR engine.
|
||||
- `workflow_only`: documents are processed locally unless a workflow
|
||||
explicitly enables remote OCR for them, letting you use the remote engine
|
||||
selectively.
|
||||
|
||||
Defaults to "always".
|
||||
|
||||
## AI {#ai}
|
||||
|
||||
#### [`PAPERLESS_AI_ENABLED=<bool>`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED}
|
||||
|
||||
@@ -224,13 +224,19 @@ respectively, can be run non-interactively with:
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
pnpm playwright test --ui
|
||||
pnpm e2e:ui
|
||||
```
|
||||
|
||||
### Building the frontend
|
||||
@@ -456,6 +462,20 @@ def score(
|
||||
return 10
|
||||
```
|
||||
|
||||
**Remote services**
|
||||
|
||||
If your parser sends document content to a remote service, declare it:
|
||||
|
||||
```python
|
||||
class MyCustomParser:
|
||||
uses_remote_service = True
|
||||
```
|
||||
|
||||
Paperless-ngx excludes such parsers when the document being consumed has not
|
||||
been marked for remote processing, so users can keep remote OCR off by default
|
||||
and enable it selectively with a workflow. Parsers that do not declare the
|
||||
attribute are treated as fully local and are always considered.
|
||||
|
||||
**Archive and rendition flags**
|
||||
|
||||
```python
|
||||
|
||||
@@ -427,27 +427,27 @@ Global permissions define what areas of the app and API endpoints users can acce
|
||||
determine if a user can create, edit, delete or view _any_ documents, but individual documents themselves
|
||||
still have "object-level" permissions.
|
||||
|
||||
| Type | Details |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| AppConfig | _Change_ or higher permissions grants access to the "Application Configuration" area. |
|
||||
| Correspondent | Add, edit, delete or view Correspondents. |
|
||||
| CustomField | Add, edit, delete or view Custom Fields. |
|
||||
| Document | Add, edit, delete or view Documents. |
|
||||
| DocumentType | Add, edit, delete or view Document Types. |
|
||||
| Group | Add, edit, delete or view Groups. |
|
||||
| GlobalStatistics | View aggregate object counts and statistics. This does not grant access to view individual documents. |
|
||||
| MailAccount | Add, edit, delete or view Mail Accounts. |
|
||||
| MailRule | Add, edit, delete or view Mail Rules. |
|
||||
| Note | Add, edit, delete or view Notes. |
|
||||
| PaperlessTask | View or dismiss (_Change_) File Tasks. |
|
||||
| SavedView | Add, edit, delete or view Saved Views. |
|
||||
| ShareLink | Add, delete or view Share Links. |
|
||||
| StoragePath | Add, edit, delete or view Storage Paths. |
|
||||
| SystemMonitoring | View the system status dialog, tasks summary and their API endpoints. Admin users also retain system status access. |
|
||||
| Tag | Add, edit, delete or view Tags. |
|
||||
| UISettings | Add, edit, delete or view the UI settings that are used by the web app.<br/>:warning: **Users that will access the web UI must be granted at least _View_ permissions.** |
|
||||
| User | Add, edit, delete or view other user accounts via Settings > Users & Groups and `/api/users/`. These permissions are not needed for users to edit their own profile via "My Profile" or `/api/profile/`. |
|
||||
| Workflow | Add, edit, delete or view Workflows.<br/>Note that Workflows are global; all users who can access workflows see the same set. Workflows have other permission implications — see [Workflow permissions](#workflow-permissions). |
|
||||
| Type | Details |
|
||||
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 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. |
|
||||
| CustomField | Add, edit, delete or view Custom Fields. |
|
||||
| Document | Add, edit, delete or view Documents. |
|
||||
| DocumentType | Add, edit, delete or view Document Types. |
|
||||
| Group | Add, edit, delete or view Groups. |
|
||||
| GlobalStatistics | View aggregate object counts and statistics. This does not grant access to view individual documents. |
|
||||
| MailAccount | Add, edit, delete or view Mail Accounts. |
|
||||
| MailRule | Add, edit, delete or view Mail Rules. |
|
||||
| Note | Add, edit, delete or view Notes. |
|
||||
| PaperlessTask | View or dismiss (_Change_) File Tasks. |
|
||||
| SavedView | Add, edit, delete or view Saved Views. |
|
||||
| ShareLink | Add, delete or view Share Links. |
|
||||
| StoragePath | Add, edit, delete or view Storage Paths. |
|
||||
| SystemMonitoring | View the system status dialog, tasks summary and their API endpoints. Admin users also retain system status access. |
|
||||
| Tag | Add, edit, delete or view Tags. |
|
||||
| UISettings | Add, edit, delete or view the UI settings that are used by the web app.<br/>:warning: **Users that will access the web UI must be granted at least _View_ permissions.** |
|
||||
| User | Add, edit, delete or view other user accounts via Settings > Users & Groups and `/api/users/`. These permissions are not needed for users to edit their own profile via "My Profile" or `/api/profile/`. |
|
||||
| Workflow | Add, edit, delete or view Workflows.<br/>Note that Workflows are global; all users who can access workflows see the same set. Workflows have other permission implications — see [Workflow permissions](#workflow-permissions). |
|
||||
|
||||
#### Detailed Explanation of Object Permissions {#object-permissions}
|
||||
|
||||
@@ -654,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
|
||||
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
|
||||
|
||||
Titles and webhook payloads can be generated by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
|
||||
@@ -886,19 +928,6 @@ Matching documents with logical expressions:
|
||||
|
||||
```
|
||||
shopname AND (product1 OR product2)
|
||||
invoice NOT draft
|
||||
```
|
||||
|
||||
`AND`, `OR` and `NOT` must be written in capitals, and parentheses group sub-expressions. Terms written next to each other with no operator between them are combined with `AND`.
|
||||
|
||||
!!! warning
|
||||
|
||||
A leading `-` does **not** exclude a term. Separators are stripped during indexing, so `invoice -secret` searches for `invoice` and `secret`, which is the opposite of what you probably intended. Use `NOT` to exclude a term: `invoice NOT secret`.
|
||||
|
||||
Matching an exact phrase, in order, by quoting it:
|
||||
|
||||
```
|
||||
"quick brown fox"
|
||||
```
|
||||
|
||||
Matching specific tags, correspondents or types:
|
||||
@@ -906,12 +935,8 @@ Matching specific tags, correspondents or types:
|
||||
```
|
||||
type:invoice tag:unpaid
|
||||
correspondent:university certificate
|
||||
tag:bills,unpaid
|
||||
```
|
||||
|
||||
- `document_type` may be abbreviated to `type`, and `storage_path` to `path`.
|
||||
- A comma-separated list after `tag:` requires **all** of the listed tags, so `tag:bills,unpaid` matches only documents tagged both `bills` and `unpaid`.
|
||||
|
||||
Matching dates:
|
||||
|
||||
```
|
||||
@@ -920,58 +945,14 @@ added:yesterday
|
||||
modified:today
|
||||
```
|
||||
|
||||
Matching by archive metadata:
|
||||
|
||||
```
|
||||
asn:100
|
||||
page_count:12
|
||||
num_notes:0
|
||||
checksum:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
|
||||
original_filename:invoice.pdf
|
||||
```
|
||||
|
||||
- `asn` matches a document's Archive Serial Number.
|
||||
- `page_count` matches a document's page count.
|
||||
- `num_notes` matches how many notes a document has.
|
||||
- `checksum` matches the checksum of the original document file (not the archived/processed version). Unlike the text fields, this one is stored verbatim rather than tokenized, so only a complete, lowercase checksum matches. To search by the first few characters instead, use a wildcard: `checksum:9f86d081*`. Wildcard patterns on the text fields are also tried stemmed, to line up with the stemmed index, but `checksum` is indexed without stemming, so its patterns are not stemmed either: a wildcard prefix is matched literally, apart from being lowercased first. `checksum:9F86D081*` therefore does find the document, even though the plain uppercase term does not.
|
||||
- `original_filename` matches the filename of the document as originally consumed.
|
||||
|
||||
`asn`, `page_count` and `num_notes` are numeric and also accept ranges, for example `asn:[50 to 150]`.
|
||||
|
||||
Matching inexact words:
|
||||
|
||||
```
|
||||
invoice*
|
||||
title:Invoice*
|
||||
produ*name
|
||||
```
|
||||
|
||||
Wildcards are matched against the _stemmed_ terms stored in the index, not
|
||||
against the words as they appear in the document. Each literal part of a
|
||||
pattern is tried both as you typed it and in its stemmed form, so a trailing
|
||||
`*` matches a word and its inflections (`invoice*` finds "invoice", "invoices"
|
||||
and "invoiced") as well as longer words whose stored term still begins with
|
||||
what you typed (`copy*` finds "copyright" alongside "copy" and "copies").
|
||||
|
||||
It is still not a plain prefix search over the original text. A trailing `*`
|
||||
matches a stored term when either the run you typed or its stemmed form is a
|
||||
prefix of that term, so a fragment that stops part-way between the two matches
|
||||
neither: `universities*` finds "university" and "universities", which are both
|
||||
stored as `univers`, while the shorter `universit*` finds nothing at all. For
|
||||
the same reason `happine*` does not find "happiness", which is stored as
|
||||
`happi`. And a pattern that requires letters after the wildcard which stemming
|
||||
has removed cannot match either: `productname` is stored as `productnam`, so
|
||||
`produ*name` finds nothing.
|
||||
|
||||
Matching natural date keywords:
|
||||
|
||||
The multi-word date keywords listed below work quoted or unquoted after a
|
||||
date field (`added:"previous month"` and `added:previous month` are
|
||||
equivalent); elsewhere in a query the same words are treated as ordinary
|
||||
search text. Other date expressions the parser accepts (relative offsets
|
||||
like `-1 week`, or specific dates like `12 december 2019`) must be quoted when
|
||||
they stand alone as a value; inside a range's brackets they work unquoted, as
|
||||
in `added:[-1 week to now]`.
|
||||
|
||||
```
|
||||
added:today
|
||||
modified:yesterday
|
||||
@@ -984,30 +965,6 @@ Supported date keywords: `today`, `yesterday`, `previous week`,
|
||||
`this month`, `previous month`, `this year`, `previous year`,
|
||||
`previous quarter`.
|
||||
|
||||
These other date forms also work after a date field:
|
||||
|
||||
```
|
||||
added:tomorrow
|
||||
created:2005-03-04
|
||||
added:january
|
||||
modified:"next monday"
|
||||
added:"last monday"
|
||||
added:"2005-01-01T00:00:00Z"
|
||||
created:[2005-01-01 to 2005-01-31]
|
||||
added:[2005-06-15T09:00:00Z to 2005-06-15T17:00:00Z]
|
||||
```
|
||||
|
||||
- `tomorrow`, like `today` and `yesterday`, covers that whole day.
|
||||
- An ISO date such as `2005-03-04` covers that whole day, and `2005-01` covers that whole month.
|
||||
- A month name such as `january` covers that whole month in the current year.
|
||||
- `next <weekday>` and `last <weekday>` each cover that whole day and must be quoted. A bare weekday name such as `monday` is not accepted.
|
||||
- A full timestamp such as `2005-01-01T00:00:00Z` matches that exact instant. Like the other expressions above, it has to be quoted when it stands on its own: `added:"2005-01-01T00:00:00Z"`. The unquoted spelling is rejected with an error rather than searched, because only part of it can be read as a date.
|
||||
- A range takes two of the above as its bounds, for example `created:[2005 to 2009]` or `added:[2005-01-01 to 2005-01-31]`. Bounds may carry a time of day. A bound is normally written without quotes; if you do quote one, use single quotes (`added:['-1 week' to now]`), because a double-quoted bound is rejected with an error.
|
||||
|
||||
!!! warning
|
||||
|
||||
As a value on its own, `now`, `noon`, `midnight` and relative offsets such as `"-3 days"` or `"-1 week"` are accepted by the parser but resolve to a single instant rather than to a span of time, so they match only a document whose timestamp is exactly that instant, which in practice means no documents at all. Quoting does not change this. As a *range bound* they are the opposite of a trap and are what you want: `added:['-1 week' to now]` covers the whole of the last seven days. Spellings like `now-3days` and `"3 days ago"` are rejected outright wherever they appear.
|
||||
|
||||
#### Searching custom fields
|
||||
|
||||
Custom field names and values are included in the full-text index, but they
|
||||
@@ -1023,7 +980,6 @@ custom_fields.name:Insurance custom_fields.value:policy
|
||||
- `custom_fields.value` matches against the value of any custom field.
|
||||
- `custom_fields.name` matches the name of the field (use quotes for multi-word names).
|
||||
- Combine both to find documents where a specific named field contains a specific value.
|
||||
- The bare `custom_fields:` prefix is shorthand for `custom_fields.value:`.
|
||||
|
||||
Because separators are stripped during indexing, individual parts of formatted
|
||||
codes are searchable on their own. A value stored as `A-1312/99.50` produces the
|
||||
@@ -1051,9 +1007,9 @@ notes.note:reminder
|
||||
notes.user:alice notes.note:insurance
|
||||
```
|
||||
|
||||
The bare `notes:` prefix is shorthand for `notes.note:`.
|
||||
|
||||
All of these constructs can be combined as you see fit. What is described above is the whole of the query language paperless supports. It resembles other search query languages without being identical to any of them, so a construct that is not documented here is most likely treated as ordinary search text rather than as syntax, and an unrecognized field name is searched as text too.
|
||||
All of these constructs can be combined as you see fit. If you want to
|
||||
learn more about the query language used by paperless, see the
|
||||
[Tantivy query language documentation](https://docs.rs/tantivy/latest/tantivy/query/struct.QueryParser.html).
|
||||
|
||||
!!! note
|
||||
|
||||
@@ -1176,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).
|
||||
This is of course a paid service (with a free tier) which requires an Azure account and subscription. Azure AI is not affiliated with
|
||||
Paperless-ngx in any way. When enabled, Paperless-ngx will automatically send appropriate documents to Azure for OCR processing, bypassing
|
||||
the local OCR engine. See the [configuration](configuration.md#PAPERLESS_REMOTE_OCR_ENGINE) options for more details.
|
||||
the local OCR engine. See the [configuration](configuration.md#PAPERLESS_REMOTE_OCR_ENGINE) options for more details. These
|
||||
settings can be supplied as environment variables or via **Application Configuration**.
|
||||
|
||||
Additionally, when using a commercial service with this feature, consider both potential costs as well as any associated file size
|
||||
or page limitations (e.g. with a free tier).
|
||||
|
||||
By default, every document of a supported file type is sent to the remote engine. To use it more selectively, set the
|
||||
[remote OCR mode](configuration.md#PAPERLESS_REMOTE_OCR_MODE) to `workflow_only`. Documents are then processed locally
|
||||
unless a [remote OCR workflow action](#workflow-action-remote-ocr) enables it for them, so you can limit the remote
|
||||
engine to particular documents.
|
||||
|
||||
Setting the mode to `workflow_only` also allows the **Reprocess** actions to selectively use remote OCR for individual documents.
|
||||
|
||||
## Architecture
|
||||
|
||||
Paperless-ngx consists of the following components:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
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"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -77,7 +77,6 @@ dependencies = [
|
||||
"torch~=2.13.0",
|
||||
"watchfiles>=1.2",
|
||||
"whitenoise~=6.11",
|
||||
"whoosh-compat[tantivy]==0.1",
|
||||
"zxing-cpp~=3.1.0",
|
||||
]
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -139,6 +139,17 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"e2e": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.e2e.ts"
|
||||
}
|
||||
],
|
||||
"localize": [
|
||||
"en-US"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"localize": [
|
||||
"en-US"
|
||||
@@ -155,6 +166,9 @@
|
||||
"configurations": {
|
||||
"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 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 ({
|
||||
page,
|
||||
}) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/settings')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
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 }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/settings')
|
||||
await page.getByLabel('Use system setting').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 }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/settings')
|
||||
await expect(page.locator('html')).toHaveAttribute('data-bs-theme', /auto/)
|
||||
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 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 }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR1, { notFound: 'fallback' })
|
||||
await page.goto('/dashboard')
|
||||
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/)
|
||||
})
|
||||
|
||||
test('dashboard total documents link', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR2, { notFound: 'fallback' })
|
||||
await page.goto('/dashboard')
|
||||
await page.getByRole('link').filter({ hasText: 'Total documents' }).click()
|
||||
await expect(page).toHaveURL(/documents/)
|
||||
@@ -24,7 +16,6 @@ test('dashboard total documents link', async ({ page }) => {
|
||||
})
|
||||
|
||||
test('dashboard saved view show all', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' })
|
||||
await page.goto('/dashboard')
|
||||
await page
|
||||
.locator('pngx-widget-frame')
|
||||
@@ -32,12 +23,11 @@ test('dashboard saved view show all', async ({ page }) => {
|
||||
.getByRole('link', { name: 'Show all' })
|
||||
.first()
|
||||
.click()
|
||||
await expect(page).toHaveURL(/view\/7/)
|
||||
await expect(page).toHaveURL(/view\/1/)
|
||||
await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
|
||||
})
|
||||
|
||||
test('dashboard saved view document links', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR4, { notFound: 'fallback' })
|
||||
await page.goto('/dashboard')
|
||||
await page
|
||||
.locator('pngx-widget-frame')
|
||||
@@ -46,11 +36,10 @@ test('dashboard saved view document links', async ({ page }) => {
|
||||
.getByRole('link', { name: /test/ })
|
||||
.first()
|
||||
.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 }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR1, { notFound: 'fallback' })
|
||||
await page.goto('/dashboard')
|
||||
await page.locator('.sidebar-slim-toggler').click()
|
||||
await expect(
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { expect, test, type WebSocketRoute } 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')
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test('should activate / deactivate save button when changes are saved', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/documents/175/')
|
||||
await page.goto('/documents/1/')
|
||||
await page.waitForSelector('pngx-document-detail pngx-input-text:first-child')
|
||||
await expect(page.getByTitle('Storage path', { exact: true })).toHaveText(
|
||||
/\w+/
|
||||
@@ -19,8 +14,7 @@ test('should activate / deactivate save button when changes are saved', async ({
|
||||
})
|
||||
|
||||
test('should warn on unsaved changes', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/documents/175/')
|
||||
await page.goto('/documents/1/')
|
||||
await expect(page.getByTitle('Correspondent', { exact: true })).toHaveText(
|
||||
/\w+/
|
||||
)
|
||||
@@ -38,28 +32,27 @@ test('should warn on unsaved changes', async ({ page }) => {
|
||||
})
|
||||
|
||||
test('should support tab direct navigation', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/documents/175/details')
|
||||
await page.goto('/documents/1/details')
|
||||
await expect(page.getByRole('tab', { name: 'Details' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
await page.goto('/documents/175/content')
|
||||
await page.goto('/documents/1/content')
|
||||
await expect(page.getByRole('tab', { name: 'Content' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
await page.goto('/documents/175/metadata')
|
||||
await page.goto('/documents/1/metadata')
|
||||
await expect(page.getByRole('tab', { name: 'Metadata' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
await page.goto('/documents/175/notes')
|
||||
await page.goto('/documents/1/notes')
|
||||
await expect(page.getByRole('tab', { name: 'Notes' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
await page.goto('/documents/175/permissions')
|
||||
await page.goto('/documents/1/permissions')
|
||||
await expect(page.getByRole('tab', { name: 'Permissions' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
@@ -67,8 +60,7 @@ test('should support tab direct navigation', async ({ page }) => {
|
||||
})
|
||||
|
||||
test('should show a mobile preview', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/documents/175/')
|
||||
await page.goto('/documents/1/')
|
||||
await page.setViewportSize({ width: 400, height: 1000 })
|
||||
await expect(page.getByRole('tab', { name: 'Preview' })).toBeVisible()
|
||||
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 }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/documents/175/notes')
|
||||
await page.goto('/documents/1/notes')
|
||||
await expect(page.locator('pngx-document-notes')).toBeVisible()
|
||||
await expect(
|
||||
await page.getByRole('button', {
|
||||
@@ -88,32 +79,25 @@ test('should show a list of notes', async ({ page }) => {
|
||||
})
|
||||
|
||||
test('should support quick filters', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR2, { notFound: 'fallback' })
|
||||
await page.goto('/documents/175/details')
|
||||
await page.goto('/documents/1/details')
|
||||
await page
|
||||
.getByRole('button', { name: 'Filter documents with these Tags' })
|
||||
.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 ({
|
||||
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
|
||||
page.on('request', (request) => {
|
||||
if (request.url().includes('/api/documents/175/preview/')) {
|
||||
if (request.url().includes('/api/documents/1/preview/')) {
|
||||
previewRequestCount++
|
||||
}
|
||||
})
|
||||
await page.goto('/documents/175/details')
|
||||
await page.goto('/documents/1/details')
|
||||
|
||||
await page.locator('pngx-document-detail').waitFor()
|
||||
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)
|
||||
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(
|
||||
(response) =>
|
||||
response.url().includes('/api/documents/175/?full_perms=true') &&
|
||||
response.url().includes('/api/documents/1/?full_perms=true') &&
|
||||
response.request().method() === 'GET'
|
||||
)
|
||||
statusSocket.send(
|
||||
JSON.stringify({
|
||||
type: 'document_updated',
|
||||
data: {
|
||||
document_id: 175,
|
||||
modified: '2026-07-26T20:00:00Z',
|
||||
},
|
||||
await page.evaluate(() => {
|
||||
const detail = document.querySelector('pngx-document-detail')
|
||||
const component = (window as any).ng.getComponent(detail)
|
||||
component.handleIncomingDocumentUpdated({
|
||||
document_id: 1,
|
||||
modified: '2099-07-26T20:00:00Z',
|
||||
})
|
||||
)
|
||||
})
|
||||
await documentReloaded
|
||||
|
||||
await expect(
|
||||
page.getByText('Document reloaded with latest changes.').first()
|
||||
).toBeVisible()
|
||||
await expect
|
||||
.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 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 }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR1, { notFound: 'fallback' })
|
||||
await page.goto('/documents')
|
||||
await page.getByRole('button', { name: 'Tags' }).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 page.getByRole('button', { name: 'Document type' }).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('menuitem', { name: 'Test Correspondent 1' }).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 page
|
||||
.locator('pngx-filter-editor')
|
||||
.getByTitle('Correspondent')
|
||||
.getByText('Exclude')
|
||||
.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/)
|
||||
// clear button
|
||||
await page.getByRole('button', { name: '2 selected', exact: true }).click()
|
||||
await expect(page.locator('pngx-document-list')).toHaveText(/61 documents/)
|
||||
await page.getByRole('button', { name: 'Storage path' }).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 page.getByRole('button', { name: 'Reset filters' }).first().click()
|
||||
await expect(page.locator('pngx-document-list')).toHaveText(/61 documents/)
|
||||
})
|
||||
|
||||
test('text filtering', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR2, { notFound: 'fallback' })
|
||||
await page.goto('/documents')
|
||||
await page.getByRole('main').getByRole('combobox').click()
|
||||
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: 'Advanced search' }).click()
|
||||
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: 'ASN' }).click()
|
||||
await page.getByRole('main').getByRole('combobox').nth(1).fill('1123')
|
||||
@@ -80,7 +70,6 @@ test('text filtering', async ({ page }) => {
|
||||
})
|
||||
|
||||
test('date filtering', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' })
|
||||
await page.goto('/documents')
|
||||
await page.getByRole('button', { name: 'Dates' }).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.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')
|
||||
await page.getByRole('combobox', { name: 'Select year' }).selectOption('2022')
|
||||
await page.getByText('11', { exact: true }).click()
|
||||
const createdFrom = page.getByRole('textbox', { name: 'mm/dd/yyyy' }).first()
|
||||
await createdFrom.fill('12/11/2022')
|
||||
await createdFrom.press('Enter')
|
||||
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 }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR4, { notFound: 'fallback' })
|
||||
await page.goto('/documents')
|
||||
await page.getByRole('button', { name: 'Sort' }).click()
|
||||
await page.getByRole('button', { name: 'ASN' }).click()
|
||||
@@ -140,7 +128,6 @@ test('sorting', async ({ page }) => {
|
||||
})
|
||||
|
||||
test('change views', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR5, { notFound: 'fallback' })
|
||||
await page.goto('/documents')
|
||||
await page.locator('.btn-group > label').first().click()
|
||||
await expect(page.locator('pngx-document-list table')).toBeVisible()
|
||||
@@ -151,12 +138,18 @@ test('change views', 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 uiSettingsLoaded
|
||||
|
||||
await page.locator('pngx-document-card-small').nth(0).click()
|
||||
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)
|
||||
.click({
|
||||
modifiers: ['Shift'],
|
||||
@@ -176,8 +169,14 @@ test('bulk edit', async ({ page }) => {
|
||||
)
|
||||
await page.getByRole('button', { name: 'None' }).click()
|
||||
|
||||
await page.locator('pngx-document-card-small').nth(1).click()
|
||||
await page.locator('pngx-document-card-small').nth(2).click()
|
||||
await page
|
||||
.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
|
||||
@@ -185,15 +184,26 @@ test('bulk edit', async ({ page }) => {
|
||||
.fill('TagWithPartial')
|
||||
await page.getByRole('menuitem', { name: 'TagWithPartial' }).click()
|
||||
|
||||
await page.getByRole('button', { name: 'Apply' }).click()
|
||||
|
||||
const bulkEditPromise = page.waitForRequest((request) => {
|
||||
if (
|
||||
!request.url().includes('/api/documents/bulk_edit/') ||
|
||||
request.method() !== 'POST'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const postData = request.postDataJSON()
|
||||
let isValid = postData['method'] == 'modify_tags'
|
||||
isValid = isValid && postData['parameters']['add_tags'].includes(5)
|
||||
return request.url().toString().includes('bulk_edit') && isValid
|
||||
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 bulkEditPromise
|
||||
})
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
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 }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/dashboard')
|
||||
await expect(page.getByRole('link', { name: 'Settings' })).not.toBeAttached()
|
||||
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 }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/dashboard')
|
||||
await expect(
|
||||
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 }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/dashboard')
|
||||
await expect(
|
||||
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 }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/dashboard')
|
||||
await expect(
|
||||
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 }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/dashboard')
|
||||
await expect(
|
||||
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 }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/dashboard')
|
||||
await expect(
|
||||
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 }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/dashboard')
|
||||
await expect(page.getByRole('link', { name: 'Logs' })).not.toBeAttached()
|
||||
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 }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/dashboard')
|
||||
await expect(page.getByRole('link', { name: 'Tasks' })).not.toBeAttached()
|
||||
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,11 +1,15 @@
|
||||
{
|
||||
"name": "paperless-ngx-ui",
|
||||
"version": "3.0.5",
|
||||
"version": "3.1.2",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"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",
|
||||
"lint": "ng lint"
|
||||
},
|
||||
|
||||
@@ -14,17 +14,25 @@ export default defineConfig({
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 3 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Keep parallelism modest for the shared SQLite backend. */
|
||||
workers: 2,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
port,
|
||||
command: 'pnpm run start',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 2 * 60 * 1000,
|
||||
},
|
||||
/* Run the disposable backend and local UI before starting the tests. */
|
||||
webServer: [
|
||||
{
|
||||
url: 'http://localhost:8001/accounts/login/',
|
||||
command: 'npm run e2e:backend',
|
||||
reuseExistingServer: false,
|
||||
timeout: 2 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
port,
|
||||
command: 'npm run start:e2e',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 2 * 60 * 1000,
|
||||
},
|
||||
],
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
infoLink="configuration">
|
||||
</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">
|
||||
@for (category of optionCategories; track category) {
|
||||
@@ -14,43 +14,48 @@
|
||||
<a ngbNavLink>{{category}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="p-3">
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
|
||||
@for (option of getCategoryOptions(category); track option.key) {
|
||||
<div class="col">
|
||||
<div class="card bg-light">
|
||||
<div class="card-body">
|
||||
<div class="card-title d-flex align-items-center">
|
||||
<h6 class="mb-0">
|
||||
{{option.title}}
|
||||
</h6>
|
||||
<a class="btn btn-sm btn-link" title="Read the documentation about this setting" i18n-title [href]="getDocsUrl(option.config_key)" target="_blank" referrerpolicy="no-referrer">
|
||||
<i-bs name="info-circle"></i-bs>
|
||||
</a>
|
||||
@if (isSet(option.key)) {
|
||||
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)">
|
||||
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
|
||||
</button>
|
||||
@for (section of getCategorySections(category); track section) {
|
||||
@if (section) {
|
||||
<h5 class="mt-4 mb-3">{{section}}</h5>
|
||||
}
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
|
||||
@for (option of getCategoryOptions(category, section); track option.key) {
|
||||
<div class="col">
|
||||
<div class="card bg-light">
|
||||
<div class="card-body">
|
||||
<div class="card-title d-flex align-items-center">
|
||||
<h6 class="mb-0">
|
||||
{{option.title}}
|
||||
</h6>
|
||||
<a class="btn btn-sm btn-link" title="Read the documentation about this setting" i18n-title [href]="getDocsUrl(option.config_key)" target="_blank" referrerpolicy="no-referrer">
|
||||
<i-bs name="info-circle"></i-bs>
|
||||
</a>
|
||||
@if (isSet(option.key)) {
|
||||
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)">
|
||||
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-n3">
|
||||
@switch (option.type) {
|
||||
@case (ConfigOptionType.Select) { <pngx-input-select [formControlName]="option.key" [error]="errors[option.key]" [items]="option.choices" [allowNull]="true"></pngx-input-select> }
|
||||
@case (ConfigOptionType.Number) { <pngx-input-number [formControlName]="option.key" [error]="errors[option.key]" [showAdd]="false"></pngx-input-number> }
|
||||
@case (ConfigOptionType.Boolean) { <pngx-input-switch [formControlName]="option.key" [error]="errors[option.key]" [showUnsetNote]="true" [horizontal]="true" title="Enable" i18n-title></pngx-input-switch> }
|
||||
@case (ConfigOptionType.String) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
||||
@case (ConfigOptionType.JSON) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
||||
@case (ConfigOptionType.File) { <pngx-input-file [formControlName]="option.key" (upload)="uploadFile($event, option.key)" [error]="errors[option.key]"></pngx-input-file> }
|
||||
@case (ConfigOptionType.Password) { <pngx-input-password [formControlName]="option.key" [error]="errors[option.key]" autocomplete="new-password"></pngx-input-password> }
|
||||
}
|
||||
</div>
|
||||
@if (option.note) {
|
||||
<div class="form-text fst-italic">{{option.note}}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-n3">
|
||||
@switch (option.type) {
|
||||
@case (ConfigOptionType.Select) { <pngx-input-select [formControlName]="option.key" [error]="errors[option.key]" [items]="option.choices" [allowNull]="true"></pngx-input-select> }
|
||||
@case (ConfigOptionType.Number) { <pngx-input-number [formControlName]="option.key" [error]="errors[option.key]" [showAdd]="false"></pngx-input-number> }
|
||||
@case (ConfigOptionType.Boolean) { <pngx-input-switch [formControlName]="option.key" [error]="errors[option.key]" [showUnsetNote]="true" [horizontal]="true" title="Enable" i18n-title></pngx-input-switch> }
|
||||
@case (ConfigOptionType.String) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
||||
@case (ConfigOptionType.JSON) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
||||
@case (ConfigOptionType.File) { <pngx-input-file [formControlName]="option.key" (upload)="uploadFile($event, option.key)" [error]="errors[option.key]"></pngx-input-file> }
|
||||
@case (ConfigOptionType.Password) { <pngx-input-password [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-password> }
|
||||
}
|
||||
</div>
|
||||
@if (option.note) {
|
||||
<div class="form-text fst-italic">{{option.note}}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
@@ -8,7 +8,11 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { OutputTypeConfig } from 'src/app/data/paperless-config'
|
||||
import {
|
||||
ConfigCategory,
|
||||
ConfigSection,
|
||||
OutputTypeConfig,
|
||||
} from 'src/app/data/paperless-config'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
@@ -158,4 +162,24 @@ describe('ConfigComponent', () => {
|
||||
component.resetOption('barcodes_enabled')
|
||||
expect(component.configForm.get('barcodes_enabled').value).toBeNull()
|
||||
})
|
||||
|
||||
it('should group options into sections within a category, or not', () => {
|
||||
const sections = component.getCategorySections(ConfigCategory.OCR)
|
||||
expect(sections).toEqual([null, ConfigSection.RemoteOCR])
|
||||
expect(
|
||||
component
|
||||
.getCategoryOptions(ConfigCategory.OCR)
|
||||
.map((option) => option.key)
|
||||
).toContain('output_type')
|
||||
expect(
|
||||
component
|
||||
.getCategoryOptions(ConfigCategory.OCR, ConfigSection.RemoteOCR)
|
||||
.map((option) => option.key)
|
||||
).toEqual([
|
||||
'remote_ocr_engine',
|
||||
'remote_ocr_api_key',
|
||||
'remote_ocr_endpoint',
|
||||
'remote_ocr_mode',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -74,8 +74,20 @@ export class ConfigComponent
|
||||
return Object.values(ConfigCategory)
|
||||
}
|
||||
|
||||
getCategoryOptions(category: string): ConfigOption[] {
|
||||
return PaperlessConfigOptions.filter((o) => o.category === category)
|
||||
getCategorySections(category: string): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
PaperlessConfigOptions.filter((o) => o.category === category).map(
|
||||
(o) => o.section ?? null // null means no section
|
||||
)
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
getCategoryOptions(category: string, section: string = null): ConfigOption[] {
|
||||
return PaperlessConfigOptions.filter(
|
||||
(o) => o.category === category && (o.section ?? null) === section
|
||||
)
|
||||
}
|
||||
|
||||
initialConfig: PaperlessConfig
|
||||
|
||||
@@ -2,36 +2,34 @@
|
||||
<button class="navbar-toggler d-md-none collapsed border-0" type="button" data-toggle="collapse"
|
||||
data-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation"
|
||||
(click)="closeMobileSearch(); toggleMenuCollapsed()">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
<i-bs width="1.5em" height="1.5em" name="list"></i-bs>
|
||||
</button>
|
||||
<a class="navbar-brand d-flex align-items-center me-0 ps-md-3 py-0 order-sm-0"
|
||||
<div class="navbar-brand-container order-sm-0">
|
||||
<a class="navbar-brand d-flex align-items-center me-0 ps-md-3 py-0"
|
||||
[ngClass]="{ 'slim': slimSidebarEnabled, '' : !slimSidebarEnabled }"
|
||||
routerLink="/dashboard"
|
||||
tourAnchor="tour.intro">
|
||||
@if (!hasCustomBranding) {
|
||||
<pngx-logo extra_classes="navbar-official-logo px-1" height="2.4rem"></pngx-logo>
|
||||
<svg class="brand-mark brand-mark-slim d-none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1.5em" height="1.5em" fill="currentColor">
|
||||
<path d="M341,949.1c-6.9-20.3-20.7-61.2-21.9-61-199.6-88.9-182.5-229.8-134.3-347.5,30,137.2,268.8,148.9,146.2,336-.9,2.2,10,27.8,19.5,51.3,22.7-51.9,58.6-115.5,55.8-120.8C178,398.7,724.9,299,807.1,18.5c83,251.5,53.1,659.8-377.4,814.9-2,1.4-63.5,148.6-66.9,150.2-.2-2.1-33.2,2.9-30.1-8.7,1.6-7,4.8-16.2,8.2-25.6h0v-.2h.1ZM323.1,846.2c48.3-71.9-12.7-120.8-56.9-152.2,81.2,107.4,66.4,120.8,56.9,152.2h0Z"/>
|
||||
</svg>
|
||||
} @else {
|
||||
@if (customAppLogo) {
|
||||
<img class="brand-logo" [src]="customAppLogo" alt="" />
|
||||
@if (!hasCustomBranding) {
|
||||
<pngx-logo extra_classes="navbar-official-logo px-1" height="2.4rem"></pngx-logo>
|
||||
<pngx-brand-mark class="brand-mark brand-mark-slim d-none"></pngx-brand-mark>
|
||||
} @else {
|
||||
<svg class="brand-mark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1.5em" height="1.5em" fill="currentColor">
|
||||
<path d="M341,949.1c-6.9-20.3-20.7-61.2-21.9-61-199.6-88.9-182.5-229.8-134.3-347.5,30,137.2,268.8,148.9,146.2,336-.9,2.2,10,27.8,19.5,51.3,22.7-51.9,58.6-115.5,55.8-120.8C178,398.7,724.9,299,807.1,18.5c83,251.5,53.1,659.8-377.4,814.9-2,1.4-63.5,148.6-66.9,150.2-.2-2.1-33.2,2.9-30.1-8.7,1.6-7,4.8-16.2,8.2-25.6h0v-.2h.1ZM323.1,846.2c48.3-71.9-12.7-120.8-56.9-152.2,81.2,107.4,66.4,120.8,56.9,152.2h0Z"/>
|
||||
</svg>
|
||||
}
|
||||
<div class="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>
|
||||
@if (customAppLogo) {
|
||||
<img class="brand-logo" [src]="customAppLogo" alt="" />
|
||||
} @else {
|
||||
<pngx-brand-mark class="brand-mark"></pngx-brand-mark>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</a>
|
||||
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-3 me-sm-auto order-3 order-sm-1"
|
||||
<div class="brand-copy ms-2 text-truncate" [class.d-md-none]="slimSidebarEnabled">
|
||||
<span class="brand-title text-truncate">{{ appTitle }}</span>
|
||||
@if (customAppTitle) {
|
||||
<span class="byline text-uppercase font-monospace" i18n>by Paperless-ngx</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</a>
|
||||
</div>
|
||||
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 me-sm-auto order-3 order-sm-1"
|
||||
[class.mobile-hidden]="mobileSearchHidden()">
|
||||
<div class="col-12 header-search">
|
||||
<div class="col-12 header-search mx-auto">
|
||||
<pngx-global-search></pngx-global-search>
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,7 +39,7 @@
|
||||
}
|
||||
<pngx-toasts-dropdown></pngx-toasts-dropdown>
|
||||
<li ngbDropdown class="nav-item dropdown">
|
||||
<button class="btn navbar-action border-0" id="userDropdown" ngbDropdownToggle aria-label="User menu" i18n-aria-label>
|
||||
<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>
|
||||
<span class="small ms-2 d-none d-sm-inline">
|
||||
{{this.settingsService.displayName}}
|
||||
@@ -75,7 +73,7 @@
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<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()">
|
||||
@if (canSaveSettings) {
|
||||
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()" [aria-label]="slimSidebarEnabled ? 'Expand sidebar' : 'Collapse sidebar'" i18n-aria-label>
|
||||
@@ -90,18 +88,18 @@
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item app-link">
|
||||
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="house"></i-bs><span><ng-container i18n>Dashboard</ng-container></span>
|
||||
<i-bs class="me-2" name="house"></i-bs><span class="nav-link-label"><ng-container i18n>Dashboard</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||
<a class="nav-link" routerLink="documents" routerLinkActive="active"
|
||||
[routerLinkActiveOptions]="{ paths: 'exact', queryParams: 'ignored', matrixParams: 'ignored', fragment: 'ignored' }"
|
||||
(click)="closeMenu()"
|
||||
ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="files"></i-bs><span><ng-container i18n>Documents</ng-container></span>
|
||||
<i-bs class="me-2" name="files"></i-bs><span class="nav-link-label"><ng-container i18n>Documents</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -118,7 +116,7 @@
|
||||
(cdkDragEnded)="onDragEnd($event)">
|
||||
<a class="nav-link" routerLink="view/{{view.id}}"
|
||||
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name"
|
||||
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
||||
[disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
||||
popoverClass="popover-slim">
|
||||
<i-bs class="me-2" [name]="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) {
|
||||
@@ -156,7 +154,7 @@
|
||||
<li class="nav-item w-100 app-link">
|
||||
<a class="nav-link app-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="documents/{{d.id}}"
|
||||
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="d.title | documentTitle"
|
||||
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
||||
[disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
||||
popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="file-text"></i-bs><span>{{d.title | documentTitle}}</span>
|
||||
<span class="close flex-column justify-content-center"
|
||||
@@ -170,9 +168,9 @@
|
||||
<li class="nav-item w-100 app-link">
|
||||
<button type="button" class="nav-link nav-link-action app-link w-100 text-start"
|
||||
[class.text-truncate]="!slimSidebarEnabled" (click)="closeAll()"
|
||||
ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="x"></i-bs><span><ng-container i18n>Close all</ng-container></span>
|
||||
<i-bs class="me-2" name="x"></i-bs><span class="nav-link-label"><ng-container i18n>Close all</ng-container></span>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
@@ -189,9 +187,9 @@
|
||||
<div class="d-flex align-items-center attributes-row">
|
||||
<a class="nav-link flex-fill" routerLink="attributes" routerLinkActive="active"
|
||||
[routerLinkActiveOptions]="{ exact: !(slimSidebarEnabled || attributesSectionsCollapsed) }" (click)="closeMenu()"
|
||||
ngbPopover="Attributes" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
ngbPopover="Attributes" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs name="stack"></i-bs><span class="ms-2"><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>
|
||||
@if (!slimSidebarEnabled && canSaveSettings) {
|
||||
<button
|
||||
@@ -212,27 +210,27 @@
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }">
|
||||
<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>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
||||
<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>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
|
||||
<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>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
|
||||
<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>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
|
||||
<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>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -241,33 +239,33 @@
|
||||
}
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
||||
<a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="window-stack"></i-bs><span><ng-container i18n>Saved Views</ng-container></span>
|
||||
<i-bs class="me-2" name="window-stack"></i-bs><span class="nav-link-label"><ng-container i18n>Saved Views</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link"
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
|
||||
tourAnchor="tour.workflows">
|
||||
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="boxes"></i-bs><span><ng-container i18n>Workflows</ng-container></span>
|
||||
<i-bs class="me-2" name="boxes"></i-bs><span class="nav-link-label"><ng-container i18n>Workflows</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
||||
tourAnchor="tour.mail">
|
||||
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
|
||||
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="envelope"></i-bs><span><ng-container i18n>Mail</ng-container></span>
|
||||
<i-bs class="me-2" name="envelope"></i-bs><span class="nav-link-label"><ng-container i18n>Mail</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
|
||||
<a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash"
|
||||
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="trash"></i-bs><span><ng-container i18n>Trash</ng-container></span>
|
||||
<i-bs class="me-2" name="trash"></i-bs><span class="nav-link-label"><ng-container i18n>Trash</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -281,34 +279,35 @@
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }"
|
||||
tourAnchor="tour.settings">
|
||||
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="gear"></i-bs><span><ng-container i18n>Settings</ng-container></span>
|
||||
<i-bs class="me-2" name="gear"></i-bs><span class="nav-link-label"><ng-container i18n>Settings</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }">
|
||||
<a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="sliders2-vertical"></i-bs><span><ng-container i18n>Configuration</ng-container></span>
|
||||
<i-bs class="me-2" name="sliders2-vertical"></i-bs><span class="nav-link-label"><ng-container i18n>Configuration</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
|
||||
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="people"></i-bs><span><ng-container i18n>Users & Groups</ng-container></span>
|
||||
<i-bs class="me-2" name="people"></i-bs><span class="nav-link-label"><ng-container i18n>Users & Groups</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link"
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }"
|
||||
tourAnchor="tour.file-tasks">
|
||||
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
ngbPopover="Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="list-task"></i-bs><span><ng-container i18n>Tasks</ng-container>@if (tasksService.needsAttentionTasks.length > 0) {
|
||||
<span><span class="badge bg-danger ms-2 d-inline">{{tasksService.needsAttentionTasks.length}}</span></span>
|
||||
}</span>
|
||||
<i-bs class="me-2" name="list-task"></i-bs><span class="nav-link-label"><ng-container i18n>Tasks</ng-container></span>
|
||||
@if (tasksService.needsAttentionTasks.length > 0 && !slimSidebarEnabled) {
|
||||
<span class="badge bg-danger ms-2 d-inline flex-shrink-0">{{tasksService.needsAttentionTasks.length}}</span>
|
||||
}
|
||||
@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>
|
||||
}
|
||||
@@ -317,26 +316,26 @@
|
||||
@if (permissionsService.isAdmin()) {
|
||||
<li class="nav-item app-link">
|
||||
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
|
||||
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="text-left"></i-bs><span><ng-container i18n>Logs</ng-container></span>
|
||||
<i-bs class="me-2" name="text-left"></i-bs><span class="nav-link-label"><ng-container i18n>Logs</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
<li class="nav-item mt-2" tourAnchor="tour.outro">
|
||||
<a class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap text-decoration-none"
|
||||
<a class="text-muted small d-flex align-items-center flex-wrap text-decoration-none nav-anchor"
|
||||
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
|
||||
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="d-flex me-2" name="question-circle"></i-bs><span><ng-container i18n>Documentation</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
||||
<div class="px-3 py-0 text-muted small d-flex align-items-center flex-wrap">
|
||||
<div class="text-muted small d-flex align-items-center flex-wrap nav-label">
|
||||
<div class="me-3">
|
||||
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer"
|
||||
href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover
|
||||
[disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||
[disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
{{ versionString }}
|
||||
</a>
|
||||
@@ -390,7 +389,7 @@
|
||||
</nav>
|
||||
|
||||
<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>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
border-right: 1px solid color-mix(in srgb, var(--bs-border-color) 65%, transparent);
|
||||
overflow-y: auto;
|
||||
--pngx-sidebar-width: 100%;
|
||||
width: var(--pngx-sidebar-width);
|
||||
max-width: var(--pngx-sidebar-width);
|
||||
transition: all .2s ease;
|
||||
|
||||
@@ -32,17 +33,10 @@
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
// These come from the col-* classes for non-slim sidebar, needed for animation
|
||||
@media (min-width: 768px) {
|
||||
--pngx-sidebar-width: 25%;
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
--pngx-sidebar-width: 16.66666667%;
|
||||
}
|
||||
|
||||
@media (min-width: 2400px) {
|
||||
--pngx-sidebar-width: 8.33333333%;
|
||||
&.expanded {
|
||||
--pngx-sidebar-width: var(--pngx-sidebar-expanded-width);
|
||||
}
|
||||
}
|
||||
}
|
||||
@media (max-width: 767.98px) {
|
||||
@@ -67,6 +61,23 @@
|
||||
main.mobile-search-hidden {
|
||||
padding-top: 56px;
|
||||
}
|
||||
|
||||
.navbar-toggler {
|
||||
padding-left: calc(12px - (1.5em * 2.5 / 16));
|
||||
}
|
||||
}
|
||||
|
||||
.search-container {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
// Below sm the search gets its own full-width row, so line it up with main's content edge
|
||||
@media (max-width: 575.98px) {
|
||||
.search-container {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
@@ -92,12 +103,22 @@ main {
|
||||
}
|
||||
|
||||
@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 {
|
||||
max-width: 55px;
|
||||
|
||||
li.nav-item span.badge {
|
||||
display: inline-block;
|
||||
margin-right: 2px;
|
||||
margin-right: -4px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +129,19 @@ main {
|
||||
.sidebar-heading span {
|
||||
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 {
|
||||
@@ -141,18 +175,11 @@ main {
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar.slim {
|
||||
li.nav-item span.badge {
|
||||
display: inline-block;
|
||||
margin-right: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-slim-toggler {
|
||||
display: block;
|
||||
position: fixed;
|
||||
left: calc(var(--pngx-sidebar-width) - 12px);
|
||||
top: 72px;
|
||||
bottom: 16px;
|
||||
z-index: 996;
|
||||
--bs-btn-padding-x: 0.35rem;
|
||||
--bs-btn-padding-y: 0.125rem;
|
||||
@@ -227,6 +254,30 @@ 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;
|
||||
@@ -348,6 +399,7 @@ main {
|
||||
}
|
||||
|
||||
::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;
|
||||
|
||||
@@ -360,6 +412,8 @@ main {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -392,6 +446,8 @@ main {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -403,7 +459,7 @@ main {
|
||||
|
||||
:host ::ng-deep .navbar-official-logo {
|
||||
.leaf {
|
||||
fill: color-mix(in srgb, var(--pngx-primary-text-contrast) 70%, var(--bs-primary)) !important;
|
||||
fill: color-mix(in srgb, var(--pngx-primary-text-contrast) 85%, var(--bs-primary)) !important;
|
||||
}
|
||||
|
||||
.text {
|
||||
@@ -422,6 +478,19 @@ main {
|
||||
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;
|
||||
@@ -444,7 +513,7 @@ main {
|
||||
|
||||
.header-search {
|
||||
width: 100%;
|
||||
max-width: 44rem;
|
||||
max-width: 55rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 575.98px) {
|
||||
@@ -458,9 +527,11 @@ main {
|
||||
|
||||
.navbar-toggler {
|
||||
grid-area: toggler;
|
||||
color: var(--pngx-primary-text-contrast);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.navbar-brand-container,
|
||||
.navbar-brand {
|
||||
grid-area: brand;
|
||||
min-width: 0;
|
||||
|
||||
@@ -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', () => {
|
||||
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
|
||||
settingsService.set(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED, [])
|
||||
|
||||
@@ -45,6 +45,7 @@ import { TasksService } from 'src/app/services/tasks.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { ChatComponent } from '../chat/chat/chat.component'
|
||||
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
|
||||
import { LogoComponent } from '../common/logo/logo.component'
|
||||
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
||||
@@ -61,6 +62,7 @@ const SCROLL_THRESHOLD = 16
|
||||
imports: [
|
||||
GlobalSearchComponent,
|
||||
LogoComponent,
|
||||
BrandMarkComponent,
|
||||
DocumentTitlePipe,
|
||||
IfPermissionsDirective,
|
||||
ToastsDropdownComponent,
|
||||
@@ -116,6 +118,7 @@ export class AppFrameComponent
|
||||
|
||||
ngOnInit(): void {
|
||||
this.lastScrollY = window.scrollY
|
||||
this.detectClassicScrollbars()
|
||||
|
||||
if (this.settingsService.get(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED)) {
|
||||
this.checkForUpdates()
|
||||
@@ -278,6 +281,10 @@ export class AppFrameComponent
|
||||
})
|
||||
}
|
||||
|
||||
get slimSidebarPopoversEnabled(): boolean {
|
||||
return this.slimSidebarEnabled && !this.isMobileViewport()
|
||||
}
|
||||
|
||||
get attributesSectionsCollapsed(): boolean {
|
||||
this.settingsService.trackChanges()
|
||||
return this.settingsService
|
||||
@@ -337,6 +344,22 @@ export class AppFrameComponent
|
||||
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 {
|
||||
return window.innerWidth < 768
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
[ngModel]="query()"
|
||||
(ngModelChange)="queryDebounce.next($event)"
|
||||
(ngModelChange)="onQueryChange($event)"
|
||||
(keydown)="searchInputKeyDown($event)"
|
||||
ngbDropdownAnchor>
|
||||
<div class="position-absolute top-50 end-0 translate-middle">
|
||||
|
||||
@@ -272,6 +272,19 @@ describe('GlobalSearchComponent', () => {
|
||||
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', () => {
|
||||
jest.useFakeTimers()
|
||||
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() {
|
||||
this.hotkeyService
|
||||
.addShortcut({ keys: '/', description: $localize`Global search` })
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (messageBold) {
|
||||
<p class="text-break"><b>{{messageBold}}</b></p>
|
||||
}
|
||||
@if (message) {
|
||||
<p class="mb-0 text-break" [innerHTML]="message"></p>
|
||||
}
|
||||
@if (showRemoteOcr) {
|
||||
<div class="form-check mt-3">
|
||||
<input class="form-check-input" type="checkbox" id="reprocessRemoteOcr" [(ngModel)]="remoteOcr" />
|
||||
<label class="form-check-label" for="reprocessRemoteOcr" i18n>Use remote OCR</label>
|
||||
<div class="form-text" i18n>Sends the document to the configured remote OCR service, which may incur costs.</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
|
||||
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
||||
</button>
|
||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
|
||||
{{btnCaption}}
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,72 @@
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { RemoteOCRModeConfig } from 'src/app/data/paperless-config'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ReprocessConfirmDialogComponent } from './reprocess-confirm-dialog.component'
|
||||
|
||||
describe('ReprocessConfirmDialogComponent', () => {
|
||||
let component: ReprocessConfirmDialogComponent
|
||||
let fixture: ComponentFixture<ReprocessConfirmDialogComponent>
|
||||
let settingsService: SettingsService
|
||||
|
||||
const createComponent = (configured: boolean, mode: string) => {
|
||||
settingsService.set(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED, configured)
|
||||
settingsService.set(SETTINGS_KEYS.REMOTE_OCR_MODE, mode)
|
||||
|
||||
fixture = TestBed.createComponent(ReprocessConfirmDialogComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
NgbActiveModal,
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
imports: [ReprocessConfirmDialogComponent],
|
||||
}).compileComponents()
|
||||
|
||||
settingsService = TestBed.inject(SettingsService)
|
||||
})
|
||||
|
||||
it('should not request remote OCR by default', () => {
|
||||
createComponent(true, RemoteOCRModeConfig.WORKFLOW_ONLY)
|
||||
|
||||
expect(component.remoteOcr).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should not offer remote OCR when no engine is configured', () => {
|
||||
createComponent(false, RemoteOCRModeConfig.WORKFLOW_ONLY)
|
||||
|
||||
expect(component.showRemoteOcr).toBeFalsy()
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('#reprocessRemoteOcr')
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('should not offer remote OCR when it already handles every document', () => {
|
||||
createComponent(true, RemoteOCRModeConfig.ALWAYS)
|
||||
|
||||
expect(component.showRemoteOcr).toBeFalsy()
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('#reprocessRemoteOcr')
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('should offer remote OCR when configured and selective', () => {
|
||||
createComponent(true, RemoteOCRModeConfig.WORKFLOW_ONLY)
|
||||
|
||||
expect(component.showRemoteOcr).toBeTruthy()
|
||||
const checkbox = fixture.nativeElement.querySelector('#reprocessRemoteOcr')
|
||||
expect(checkbox).not.toBeNull()
|
||||
|
||||
checkbox.click()
|
||||
fixture.detectChanges()
|
||||
expect(component.remoteOcr).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-reprocess-confirm-dialog',
|
||||
templateUrl: './reprocess-confirm-dialog.component.html',
|
||||
imports: [FormsModule],
|
||||
})
|
||||
export class ReprocessConfirmDialogComponent extends ConfirmDialogComponent {
|
||||
private settings = inject(SettingsService)
|
||||
|
||||
remoteOcr: boolean = false
|
||||
|
||||
public get showRemoteOcr(): boolean {
|
||||
// Hidden when it is not configured, or when it already handles every document anyway.
|
||||
return this.settings.remoteOCRIsSelectable
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,7 @@ export class MailAccountEditDialogComponent extends EditDialogComponent<MailAcco
|
||||
this.testActive = false
|
||||
this.testResult.set('danger')
|
||||
this.alertTimeout = setTimeout(() => this.testResultAlert.close(), 5000)
|
||||
this.error = e.error
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -455,6 +455,52 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@case (WorkflowActionType.RemoteOcr) {
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<p class="text-muted small" i18n>The document will be sent to the configured remote OCR service. May incur costs.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@case (WorkflowActionType.ApplyAiSuggestions) {
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<p class="text-muted small" i18n>The document will be sent to the configured AI service for suggestions. Consider costs and privacy.</p>
|
||||
<pngx-input-select
|
||||
i18n-title
|
||||
title="Apply suggestions for"
|
||||
[items]="aiSuggestionFieldOptions"
|
||||
[multiple]="true"
|
||||
formControlName="ai_suggestion_fields"
|
||||
[error]="error?.actions?.[i]?.ai_suggestion_fields"
|
||||
hint="Suggestions for fields that are not selected are discarded."
|
||||
i18n-hint
|
||||
></pngx-input-select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<pngx-input-switch
|
||||
[horizontal]="true"
|
||||
i18n-title
|
||||
title="Create missing items"
|
||||
formControlName="ai_create_missing"
|
||||
hint="Create suggested tags, correspondents and document types that do not exist yet."
|
||||
i18n-hint
|
||||
></pngx-input-switch>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<pngx-input-switch
|
||||
[horizontal]="true"
|
||||
i18n-title
|
||||
title="Overwrite existing values"
|
||||
formControlName="ai_overwrite_existing"
|
||||
hint="Apply suggestions even if the document already has a value. Tags are always added, never replaced."
|
||||
i18n-hint
|
||||
></pngx-input-switch>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from 'src/app/data/matching-model'
|
||||
import { Workflow } from 'src/app/data/workflow'
|
||||
import {
|
||||
AISuggestionField,
|
||||
WorkflowAction,
|
||||
WorkflowActionType,
|
||||
} from 'src/app/data/workflow-action'
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
DocumentSource,
|
||||
WorkflowTriggerType,
|
||||
} from 'src/app/data/workflow-trigger'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||
@@ -48,6 +50,7 @@ import { TagsComponent } from '../../input/tags/tags.component'
|
||||
import { TextComponent } from '../../input/text/text.component'
|
||||
import { EditDialogMode } from '../edit-dialog.component'
|
||||
import {
|
||||
AI_SUGGESTION_FIELD_OPTIONS,
|
||||
DOCUMENT_SOURCE_OPTIONS,
|
||||
SCHEDULE_DATE_FIELD_OPTIONS,
|
||||
TriggerFilterType,
|
||||
@@ -224,7 +227,12 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
).toEqual('Document Added')
|
||||
expect(component.getTriggerTypeOptionName(null)).toEqual('')
|
||||
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS)
|
||||
expect(component.actionTypeOptions).toEqual(WORKFLOW_ACTION_OPTIONS)
|
||||
// Remote OCR is absent until the workflow has a consumption trigger
|
||||
expect(component.actionTypeOptions).toEqual(
|
||||
WORKFLOW_ACTION_OPTIONS.filter(
|
||||
(a) => a.id !== WorkflowActionType.RemoteOcr
|
||||
)
|
||||
)
|
||||
expect(
|
||||
component.getActionTypeOptionName(WorkflowActionType.Assignment)
|
||||
).toEqual('Assignment')
|
||||
@@ -233,14 +241,231 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
SCHEDULE_DATE_FIELD_OPTIONS
|
||||
)
|
||||
|
||||
// Email disabled
|
||||
// Email, remote OCR and AI all disabled
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue(false)
|
||||
component.ngOnInit()
|
||||
expect(component.actionTypeOptions).toEqual(
|
||||
WORKFLOW_ACTION_OPTIONS.filter((a) => a.id !== WorkflowActionType.Email)
|
||||
WORKFLOW_ACTION_OPTIONS.filter(
|
||||
(a) =>
|
||||
a.id !== WorkflowActionType.Email &&
|
||||
a.id !== WorkflowActionType.RemoteOcr &&
|
||||
a.id !== WorkflowActionType.ApplyAiSuggestions
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('should offer remote OCR only for consumption workflows', () => {
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||
|
||||
// A consumption trigger makes the action reachable
|
||||
component.object = {
|
||||
name: 'Workflow 1',
|
||||
order: 0,
|
||||
enabled: true,
|
||||
triggers: [{ type: WorkflowTriggerType.Consumption }],
|
||||
actions: [],
|
||||
} as Workflow
|
||||
component.ngOnInit()
|
||||
expect(component.actionTypeOptions.map((a) => a.id)).toContain(
|
||||
WorkflowActionType.RemoteOcr
|
||||
)
|
||||
|
||||
// Any other trigger type runs after the document has been parsed
|
||||
component.object = {
|
||||
name: 'Workflow 2',
|
||||
order: 0,
|
||||
enabled: true,
|
||||
triggers: [{ type: WorkflowTriggerType.DocumentAdded }],
|
||||
actions: [],
|
||||
} as Workflow
|
||||
component.ngOnInit()
|
||||
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
|
||||
WorkflowActionType.RemoteOcr
|
||||
)
|
||||
})
|
||||
|
||||
it('should offer remote OCR on a trigger added to a new workflow', () => {
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||
component.ngOnInit()
|
||||
|
||||
// Nothing for the action to apply to yet
|
||||
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
|
||||
WorkflowActionType.RemoteOcr
|
||||
)
|
||||
|
||||
// addTrigger creates the form field with emitEvent false, so the options
|
||||
// have to be computed on read rather than cached from valueChanges
|
||||
component.addTrigger()
|
||||
expect(component.actionTypeOptions.map((a) => a.id)).toContain(
|
||||
WorkflowActionType.RemoteOcr
|
||||
)
|
||||
|
||||
// Switching that trigger to a type that runs after parsing removes it
|
||||
component.triggerFields
|
||||
.at(0)
|
||||
.get('type')
|
||||
.setValue(WorkflowTriggerType.DocumentAdded)
|
||||
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
|
||||
WorkflowActionType.RemoteOcr
|
||||
)
|
||||
})
|
||||
|
||||
it('should keep remote OCR listed when an action already uses it', () => {
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||
|
||||
// Otherwise changing the trigger would silently blank the selection
|
||||
component.object = {
|
||||
name: 'Workflow 1',
|
||||
order: 0,
|
||||
enabled: true,
|
||||
triggers: [{ type: WorkflowTriggerType.DocumentAdded }],
|
||||
actions: [{ type: WorkflowActionType.RemoteOcr }],
|
||||
} as Workflow
|
||||
component.ngOnInit()
|
||||
|
||||
expect(component.actionTypeOptions.map((a) => a.id)).toContain(
|
||||
WorkflowActionType.RemoteOcr
|
||||
)
|
||||
})
|
||||
|
||||
it('should not offer remote OCR when no engine is configured', () => {
|
||||
jest
|
||||
.spyOn(settingsService, 'get')
|
||||
.mockImplementation((key) => key !== SETTINGS_KEYS.REMOTE_OCR_CONFIGURED)
|
||||
|
||||
component.object = {
|
||||
name: 'Workflow 1',
|
||||
order: 0,
|
||||
enabled: true,
|
||||
triggers: [{ type: WorkflowTriggerType.Consumption }],
|
||||
actions: [],
|
||||
} as Workflow
|
||||
component.ngOnInit()
|
||||
|
||||
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
|
||||
WorkflowActionType.RemoteOcr
|
||||
)
|
||||
})
|
||||
|
||||
it('should offer apply AI suggestions unless every trigger is consumption', () => {
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||
|
||||
// Consumption runs before the document has been parsed, so there would be
|
||||
// no content to make suggestions from
|
||||
component.object = {
|
||||
name: 'Workflow 1',
|
||||
order: 0,
|
||||
enabled: true,
|
||||
triggers: [{ type: WorkflowTriggerType.Consumption }],
|
||||
actions: [],
|
||||
} as Workflow
|
||||
component.ngOnInit()
|
||||
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
|
||||
WorkflowActionType.ApplyAiSuggestions
|
||||
)
|
||||
|
||||
// A second, usable trigger is enough
|
||||
component.object = {
|
||||
name: 'Workflow 2',
|
||||
order: 0,
|
||||
enabled: true,
|
||||
triggers: [
|
||||
{ type: WorkflowTriggerType.Consumption },
|
||||
{ type: WorkflowTriggerType.DocumentAdded },
|
||||
],
|
||||
actions: [],
|
||||
} as Workflow
|
||||
component.ngOnInit()
|
||||
expect(component.actionTypeOptions.map((a) => a.id)).toContain(
|
||||
WorkflowActionType.ApplyAiSuggestions
|
||||
)
|
||||
})
|
||||
|
||||
it('should keep apply AI suggestions listed when an action already uses it', () => {
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||
|
||||
// Otherwise changing the trigger would silently blank the selection
|
||||
component.object = {
|
||||
name: 'Workflow 1',
|
||||
order: 0,
|
||||
enabled: true,
|
||||
triggers: [{ type: WorkflowTriggerType.Consumption }],
|
||||
actions: [{ type: WorkflowActionType.ApplyAiSuggestions }],
|
||||
} as Workflow
|
||||
component.ngOnInit()
|
||||
|
||||
expect(component.actionTypeOptions.map((a) => a.id)).toContain(
|
||||
WorkflowActionType.ApplyAiSuggestions
|
||||
)
|
||||
})
|
||||
|
||||
it('should not offer apply AI suggestions when AI is disabled', () => {
|
||||
jest
|
||||
.spyOn(settingsService, 'get')
|
||||
.mockImplementation((key) => key !== SETTINGS_KEYS.AI_ENABLED)
|
||||
|
||||
component.object = {
|
||||
name: 'Workflow 1',
|
||||
order: 0,
|
||||
enabled: true,
|
||||
triggers: [{ type: WorkflowTriggerType.DocumentAdded }],
|
||||
actions: [],
|
||||
} as Workflow
|
||||
component.ngOnInit()
|
||||
|
||||
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
|
||||
WorkflowActionType.ApplyAiSuggestions
|
||||
)
|
||||
})
|
||||
|
||||
it('should create form fields for apply AI suggestions options', () => {
|
||||
component.object = {
|
||||
name: 'Workflow 1',
|
||||
order: 0,
|
||||
enabled: true,
|
||||
triggers: [{ type: WorkflowTriggerType.DocumentAdded }],
|
||||
actions: [
|
||||
{
|
||||
type: WorkflowActionType.ApplyAiSuggestions,
|
||||
ai_suggestion_fields: [
|
||||
AISuggestionField.Title,
|
||||
AISuggestionField.Tags,
|
||||
],
|
||||
ai_create_missing: true,
|
||||
ai_overwrite_existing: true,
|
||||
},
|
||||
],
|
||||
} as Workflow
|
||||
component.ngOnInit()
|
||||
|
||||
const action = component.actionFields.at(0)
|
||||
expect(action.get('ai_suggestion_fields').value).toEqual([
|
||||
AISuggestionField.Title,
|
||||
AISuggestionField.Tags,
|
||||
])
|
||||
expect(action.get('ai_create_missing').value).toBeTruthy()
|
||||
expect(action.get('ai_overwrite_existing').value).toBeTruthy()
|
||||
expect(component.aiSuggestionFieldOptions).toEqual(
|
||||
AI_SUGGESTION_FIELD_OPTIONS
|
||||
)
|
||||
})
|
||||
|
||||
it('should default apply AI suggestions options on a new action', () => {
|
||||
component.object = {
|
||||
name: 'Workflow 1',
|
||||
order: 0,
|
||||
enabled: true,
|
||||
triggers: [{ type: WorkflowTriggerType.DocumentAdded }],
|
||||
actions: [],
|
||||
} as Workflow
|
||||
component.addAction()
|
||||
|
||||
const action = component.actionFields.at(component.actionFields.length - 1)
|
||||
expect(action.get('ai_suggestion_fields').value).toEqual([])
|
||||
expect(action.get('ai_create_missing').value).toBeFalsy()
|
||||
expect(action.get('ai_overwrite_existing').value).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should support add and remove triggers and actions', () => {
|
||||
component.object = workflow
|
||||
component.addTrigger()
|
||||
|
||||
@@ -30,6 +30,7 @@ import { StoragePath } from 'src/app/data/storage-path'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { Workflow } from 'src/app/data/workflow'
|
||||
import {
|
||||
AISuggestionField,
|
||||
WorkflowAction,
|
||||
WorkflowActionType,
|
||||
} from 'src/app/data/workflow-action'
|
||||
@@ -148,6 +149,41 @@ export const WORKFLOW_ACTION_OPTIONS = [
|
||||
id: WorkflowActionType.MoveToTrash,
|
||||
name: $localize`Move to trash`,
|
||||
},
|
||||
{
|
||||
id: WorkflowActionType.RemoteOcr,
|
||||
name: $localize`Remote OCR`,
|
||||
},
|
||||
{
|
||||
id: WorkflowActionType.ApplyAiSuggestions,
|
||||
name: $localize`Apply AI suggestions`,
|
||||
},
|
||||
]
|
||||
|
||||
export const AI_SUGGESTION_FIELD_OPTIONS = [
|
||||
{
|
||||
id: AISuggestionField.Title,
|
||||
name: $localize`Title`,
|
||||
},
|
||||
{
|
||||
id: AISuggestionField.Tags,
|
||||
name: $localize`Tags`,
|
||||
},
|
||||
{
|
||||
id: AISuggestionField.Correspondent,
|
||||
name: $localize`Correspondent`,
|
||||
},
|
||||
{
|
||||
id: AISuggestionField.DocumentType,
|
||||
name: $localize`Document type`,
|
||||
},
|
||||
{
|
||||
id: AISuggestionField.StoragePath,
|
||||
name: $localize`Storage path`,
|
||||
},
|
||||
{
|
||||
id: AISuggestionField.Created,
|
||||
name: $localize`Created date`,
|
||||
},
|
||||
]
|
||||
|
||||
export enum TriggerFilterType {
|
||||
@@ -504,8 +540,6 @@ export class WorkflowEditDialogComponent
|
||||
|
||||
expandedItem: number = null
|
||||
|
||||
readonly allowedActionTypes = signal([])
|
||||
|
||||
private readonly triggerFilterOptionsMap = new WeakMap<
|
||||
FormArray,
|
||||
TriggerFilterOption[]
|
||||
@@ -548,13 +582,58 @@ export class WorkflowEditDialogComponent
|
||||
this.checkRemovalActionFields.bind(this)
|
||||
)
|
||||
this.checkRemovalActionFields(this.objectForm.value)
|
||||
this.allowedActionTypes.set(
|
||||
this.settingsService.get(SETTINGS_KEYS.EMAIL_ENABLED)
|
||||
? WORKFLOW_ACTION_OPTIONS
|
||||
: WORKFLOW_ACTION_OPTIONS.filter(
|
||||
(a) => a.id !== WorkflowActionType.Email
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private allowedActionTypes: typeof WORKFLOW_ACTION_OPTIONS = null
|
||||
|
||||
private getAllowedActionTypes() {
|
||||
let allowed = WORKFLOW_ACTION_OPTIONS
|
||||
|
||||
if (!this.settingsService.get(SETTINGS_KEYS.EMAIL_ENABLED)) {
|
||||
allowed = allowed.filter((a) => a.id !== WorkflowActionType.Email)
|
||||
}
|
||||
|
||||
// Remote OCR is decided before the document is parsed, so it is only
|
||||
// offered for workflows that run at consumption.
|
||||
const formWorkflow: Workflow = this.objectForm?.value
|
||||
const remoteOcrUsable =
|
||||
this.settingsService.get(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED) &&
|
||||
(formWorkflow?.triggers?.some(
|
||||
(trigger) => trigger.type === WorkflowTriggerType.Consumption
|
||||
) ||
|
||||
formWorkflow?.actions?.some(
|
||||
(action) => action.type === WorkflowActionType.RemoteOcr
|
||||
))
|
||||
if (!remoteOcrUsable) {
|
||||
allowed = allowed.filter((a) => a.id !== WorkflowActionType.RemoteOcr)
|
||||
}
|
||||
|
||||
// Only available after consumption. Unlike remote OCR this is hidden only
|
||||
// once every trigger is consumption, so it stays offered on a workflow
|
||||
// that has no triggers yet.
|
||||
const aiSuggestionsUsable =
|
||||
this.settingsService.get(SETTINGS_KEYS.AI_ENABLED) &&
|
||||
(!formWorkflow?.triggers?.length ||
|
||||
formWorkflow.triggers.some(
|
||||
(trigger) => trigger.type !== WorkflowTriggerType.Consumption
|
||||
) ||
|
||||
formWorkflow.actions?.some(
|
||||
(action) => action.type === WorkflowActionType.ApplyAiSuggestions
|
||||
))
|
||||
if (!aiSuggestionsUsable) {
|
||||
allowed = allowed.filter(
|
||||
(a) => a.id !== WorkflowActionType.ApplyAiSuggestions
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
this.allowedActionTypes?.length === allowed.length &&
|
||||
this.allowedActionTypes.every((a, i) => a.id === allowed[i].id)
|
||||
) {
|
||||
return this.allowedActionTypes
|
||||
}
|
||||
this.allowedActionTypes = allowed
|
||||
return allowed
|
||||
}
|
||||
|
||||
private checkRemovalActionFields(formWorkflow: Workflow) {
|
||||
@@ -1198,6 +1277,11 @@ export class WorkflowEditDialogComponent
|
||||
passwords: new FormControl(
|
||||
this.formatPasswords(action.passwords ?? [])
|
||||
),
|
||||
ai_suggestion_fields: new FormControl(
|
||||
action.ai_suggestion_fields ?? []
|
||||
),
|
||||
ai_create_missing: new FormControl(!!action.ai_create_missing),
|
||||
ai_overwrite_existing: new FormControl(!!action.ai_overwrite_existing),
|
||||
}),
|
||||
{ emitEvent }
|
||||
)
|
||||
@@ -1279,13 +1363,18 @@ export class WorkflowEditDialogComponent
|
||||
|
||||
get actionTypeOptions() {
|
||||
this.settingsService.trackChanges()
|
||||
return this.allowedActionTypes()
|
||||
// Computed on read rather than cached
|
||||
return this.getAllowedActionTypes()
|
||||
}
|
||||
|
||||
getActionTypeOptionName(type: WorkflowActionType): string {
|
||||
return this.actionTypeOptions.find((t) => t.id === type)?.name ?? ''
|
||||
}
|
||||
|
||||
get aiSuggestionFieldOptions() {
|
||||
return AI_SUGGESTION_FIELD_OPTIONS
|
||||
}
|
||||
|
||||
addAction() {
|
||||
if (!this.object) {
|
||||
this.object = Object.assign({}, this.objectForm.value)
|
||||
@@ -1339,6 +1428,9 @@ export class WorkflowEditDialogComponent
|
||||
include_document: false,
|
||||
},
|
||||
passwords: [],
|
||||
ai_suggestion_fields: [],
|
||||
ai_create_missing: false,
|
||||
ai_overwrite_existing: false,
|
||||
}
|
||||
this.object.actions.push(action)
|
||||
this.createActionField(action)
|
||||
|
||||
@@ -47,8 +47,8 @@
|
||||
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-1"></i-bs><span>{{document.title}}</span>
|
||||
</a>
|
||||
} @else {
|
||||
<span class="badge bg-light text-muted" (click)="unselect(document)" (mousedown)="$event.stopImmediatePropagation()" type="button" title="Remove link" i18n-title>
|
||||
<i-bs width="0.9em" height="0.9em" name="exclamation-triangle-fill" class="me-1"></i-bs><span i18n>Not found</span>
|
||||
<span class="badge bg-light text-muted">
|
||||
<i-bs width="0.9em" height="0.9em" name="exclamation-triangle-fill" class="me-1"></i-bs><span i18n>Unavailable</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -151,6 +151,23 @@ describe('DocumentLinkComponent', () => {
|
||||
expect(component.selectedDocuments).toEqual([])
|
||||
})
|
||||
|
||||
it('should preserve and neutrally label unavailable document IDs', async () => {
|
||||
jest.spyOn(documentService, 'getFew').mockReturnValue(
|
||||
of({
|
||||
count: 0,
|
||||
all: [],
|
||||
results: [],
|
||||
})
|
||||
)
|
||||
|
||||
component.writeValue([99])
|
||||
await fixture.whenStable()
|
||||
|
||||
expect(component.selectedDocuments).toEqual([{ id: 99 }])
|
||||
expect(fixture.nativeElement.textContent).toContain('Unavailable')
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Not found')
|
||||
})
|
||||
|
||||
it('should support unselect', () => {
|
||||
const getSpy = jest.spyOn(documentService, 'getFew')
|
||||
getSpy.mockImplementation((ids) => {
|
||||
@@ -167,6 +184,15 @@ describe('DocumentLinkComponent', () => {
|
||||
expect(component.selectedDocuments).toEqual([documents[1]])
|
||||
})
|
||||
|
||||
it('should not unselect documents when disabled', () => {
|
||||
component.disabled = true
|
||||
component.selectedDocuments = [documents[0]]
|
||||
|
||||
component.unselect(documents[0])
|
||||
|
||||
expect(component.selectedDocuments).toEqual([documents[0]])
|
||||
})
|
||||
|
||||
it('should use correct compare, trackBy functions', () => {
|
||||
expect(component.compareDocuments(documents[0], { id: 1 })).toBeTruthy()
|
||||
expect(component.compareDocuments(documents[0], { id: 2 })).toBeFalsy()
|
||||
|
||||
@@ -101,7 +101,7 @@ export class DocumentLinkComponent
|
||||
.subscribe((documentResults) => {
|
||||
this.loading.set(false)
|
||||
this.selectedDocuments = documentIDs.map(
|
||||
(id) => documentResults.results.find((d) => d.id === id) ?? {}
|
||||
(id) => documentResults.results.find((d) => d.id === id) ?? { id }
|
||||
)
|
||||
super.writeValue(documentIDs)
|
||||
})
|
||||
@@ -142,6 +142,8 @@ export class DocumentLinkComponent
|
||||
}
|
||||
|
||||
unselect(document: Document): void {
|
||||
if (this.disabled) return
|
||||
|
||||
this.selectedDocuments = this.selectedDocuments.filter(
|
||||
(d) => d && d.id !== document.id
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<div class="d-flex flex-wrap flex-row gap-2 w-100 mh-1" style="min-height: 1em;"
|
||||
cdkDropList #selectedList="cdkDropList"
|
||||
cdkDropListOrientation="mixed"
|
||||
[cdkDropListDisabled]="disabled"
|
||||
(cdkDropListDropped)="drop($event)"
|
||||
[cdkDropListConnectedTo]="[unselectedList]">
|
||||
@for (item of selectedItems; track item.id) {
|
||||
@@ -17,6 +18,7 @@
|
||||
<div class="d-flex flex-wrap flex-row gap-2 w-100" style="min-height: 1em;"
|
||||
cdkDropList #unselectedList="cdkDropList"
|
||||
cdkDropListOrientation="mixed"
|
||||
[cdkDropListDisabled]="disabled"
|
||||
(cdkDropListDropped)="drop($event)"
|
||||
[cdkDropListConnectedTo]="[selectedList]">
|
||||
@for (item of unselectedItems; track item.id) {
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
.badge {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.cdk-drop-list-disabled {
|
||||
cursor: not-allowed !important;
|
||||
|
||||
* {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,4 +98,29 @@ describe('DragDropSelectComponent', () => {
|
||||
{ id: '3', name: 'Item 3' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should disable drag and drop when the control is disabled', () => {
|
||||
component.items = [
|
||||
{ id: '1', name: 'Item 1' },
|
||||
{ id: '2', name: 'Item 2' },
|
||||
]
|
||||
component.writeValue(['1', '2'])
|
||||
component.setDisabledState(true)
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(component.selectedList.disabled).toBe(true)
|
||||
expect(component.unselectedList.disabled).toBe(true)
|
||||
|
||||
component.drop({
|
||||
previousContainer: component.selectedList,
|
||||
container: component.selectedList,
|
||||
previousIndex: 0,
|
||||
currentIndex: 1,
|
||||
} as any)
|
||||
|
||||
expect(component.selectedItems).toEqual([
|
||||
{ id: '1', name: 'Item 1' },
|
||||
{ id: '2', name: 'Item 2' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -46,6 +46,8 @@ export class DragDropSelectComponent extends AbstractInputComponent<string[]> {
|
||||
}
|
||||
|
||||
public drop(event: CdkDragDrop<string[]>) {
|
||||
if (this.disabled) return
|
||||
|
||||
if (
|
||||
event.previousContainer === event.container &&
|
||||
event.container === this.selectedList
|
||||
|
||||
@@ -40,6 +40,10 @@ describe('PasswordComponent', () => {
|
||||
// expect(component.value).toEqual('foo')
|
||||
})
|
||||
|
||||
it('should not offer itself to browser autofill by default', () => {
|
||||
expect(input.getAttribute('autocomplete')).toEqual('off')
|
||||
})
|
||||
|
||||
it('should support toggling field visibility', () => {
|
||||
expect(input.type).toEqual('password')
|
||||
component.showReveal = true
|
||||
|
||||
@@ -25,7 +25,7 @@ export class PasswordComponent extends AbstractInputComponent<string> {
|
||||
showReveal: boolean = false
|
||||
|
||||
@Input()
|
||||
autocomplete: string
|
||||
autocomplete: string = 'off'
|
||||
|
||||
public textVisible: boolean = false
|
||||
|
||||
|
||||
@@ -36,11 +36,16 @@
|
||||
(focus)="clearLastSearchTerm()"
|
||||
(clear)="clearLastSearchTerm()"
|
||||
(blur)="onBlur()">
|
||||
<ng-template ng-label-tmp let-item="item">
|
||||
@if (iconField && item[iconField]) {
|
||||
<i-bs class="me-2" [name]="item[iconField]"></i-bs>
|
||||
<ng-template ng-label-tmp let-item="item" let-clear="clear">
|
||||
@if (multiple && !disabled) {
|
||||
<span class="ng-value-icon left" role="button" tabindex="0" (click)="clear(item)" (keydown.enter)="clear(item)" aria-label="Remove item" i18n-aria-label>×</span>
|
||||
}
|
||||
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
|
||||
<span class="ng-value-label" [title]="item[bindLabel]">
|
||||
@if (iconField && item[iconField]) {
|
||||
<i-bs class="me-2" [name]="item[iconField]"></i-bs>
|
||||
}
|
||||
{{item[bindLabel]}}
|
||||
</span>
|
||||
</ng-template>
|
||||
<ng-template ng-option-tmp let-item="item">
|
||||
@if (iconField && item[iconField]) {
|
||||
|
||||
@@ -24,6 +24,12 @@ describe('TextComponent', () => {
|
||||
input = component.inputField.nativeElement
|
||||
})
|
||||
|
||||
it('should not offer itself to browser autofill by default', () => {
|
||||
expect(
|
||||
component.inputField.nativeElement.getAttribute('autocomplete')
|
||||
).toEqual('off')
|
||||
})
|
||||
|
||||
it('should support use of input field', () => {
|
||||
expect(component.value).toBeUndefined()
|
||||
input.value = 'foo'
|
||||
|
||||
@@ -28,7 +28,7 @@ import { AbstractInputComponent } from '../abstract-input'
|
||||
})
|
||||
export class TextComponent extends AbstractInputComponent<string> {
|
||||
@Input()
|
||||
autocomplete: string
|
||||
autocomplete: string = 'off'
|
||||
|
||||
@Input()
|
||||
placeholder: string = ''
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg class="w-100 h-100 d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" fill="currentColor" aria-hidden="true">
|
||||
<path d="M341,949.1c-6.9-20.3-20.7-61.2-21.9-61-199.6-88.9-182.5-229.8-134.3-347.5,30,137.2,268.8,148.9,146.2,336-.9,2.2,10,27.8,19.5,51.3,22.7-51.9,58.6-115.5,55.8-120.8C178,398.7,724.9,299,807.1,18.5c83,251.5,53.1,659.8-377.4,814.9-2,1.4-63.5,148.6-66.9,150.2-.2-2.1-33.2,2.9-30.1-8.7,1.6-7,4.8-16.2,8.2-25.6h0v-.2h.1ZM323.1,846.2c48.3-71.9-12.7-120.8-56.9-152.2,81.2,107.4,66.4,120.8,56.9,152.2h0Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 545 B |
@@ -0,0 +1,14 @@
|
||||
import { Component, input } from '@angular/core'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-brand-mark',
|
||||
templateUrl: './brand-mark.component.html',
|
||||
host: {
|
||||
'[style.width]': 'width()',
|
||||
'[style.height]': 'height()',
|
||||
},
|
||||
})
|
||||
export class BrandMarkComponent {
|
||||
readonly width = input<string>(null)
|
||||
readonly height = input<string>(null)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="row pt-3 pb-3 pb-md-2 align-items-center">
|
||||
<div class="row pt-3 pb-2 align-items-center">
|
||||
<div class="col-md text-truncate">
|
||||
<h3 class="d-flex align-items-center mb-1" style="line-height: 1.4">
|
||||
<h3 class="d-flex align-items-center mb-2 mb-md-1" style="line-height: 1.4">
|
||||
<span class="text-truncate">{{title()}}</span>
|
||||
@if (id()) {
|
||||
<span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyID()">
|
||||
@@ -12,10 +12,10 @@
|
||||
</span>
|
||||
}
|
||||
@if (subTitle()) {
|
||||
<span class="h6 mb-0 mt-1 d-block d-md-inline fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle()}}</span>
|
||||
<span class="page-subtitle h6 mb-0 mt-1 fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle()}}</span>
|
||||
}
|
||||
@if (info()) {
|
||||
<button class="btn btn-sm btn-link text-muted p-0 p-md-2" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true">
|
||||
<button class="btn btn-sm btn-link text-muted p-0 ms-2 p-md-2 ms-md-0" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true">
|
||||
<i-bs name="question-circle"></i-bs>
|
||||
</button>
|
||||
<ng-template #infoPopover>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
h3 {
|
||||
min-height: calc(1.325rem + 0.9vw);
|
||||
flex-wrap: wrap;
|
||||
|
||||
.badge {
|
||||
font-size: 0.65rem;
|
||||
@@ -7,6 +8,16 @@ h3 {
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the subtitle onto its own and shrink it
|
||||
@media (max-width: 767.98px) {
|
||||
h3 > .page-subtitle {
|
||||
flex: 0 0 100%;
|
||||
margin-top: 0 !important;
|
||||
font-size: .7rem;
|
||||
line-height: 1.3 !important;
|
||||
}
|
||||
}
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
margin-bottom: .35rem;
|
||||
|
||||
@@ -25,29 +25,34 @@
|
||||
|
||||
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
|
||||
<div class="list-group list-group-flush small pb-0">
|
||||
@if (totalSuggestions === 0) {
|
||||
@if (novelSuggestions === 0 && reusableSuggestions === 0) {
|
||||
<div class="list-group-item text-muted fst-italic">
|
||||
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
|
||||
</div>
|
||||
}
|
||||
@if (suggestions()?.suggested_tags.length > 0) {
|
||||
@if (suggestions()?.suggested_tags?.length > 0) {
|
||||
<small class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="tags"></i-bs><ng-container i18n>Tags</ng-container></small>
|
||||
@for (tag of suggestions().suggested_tags; track tag) {
|
||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)">{{ tag }}</button>
|
||||
}
|
||||
}
|
||||
@if (suggestions()?.suggested_document_types.length > 0) {
|
||||
@if (suggestions()?.suggested_document_types?.length > 0) {
|
||||
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="hash"></i-bs><ng-container i18n>Document Types</ng-container></div>
|
||||
@for (type of suggestions().suggested_document_types; track type) {
|
||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)">{{ type }}</button>
|
||||
}
|
||||
}
|
||||
@if (suggestions()?.suggested_correspondents.length > 0) {
|
||||
@if (suggestions()?.suggested_correspondents?.length > 0) {
|
||||
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="person"></i-bs><ng-container i18n>Correspondents</ng-container></div>
|
||||
@for (correspondent of suggestions().suggested_correspondents; track correspondent) {
|
||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button>
|
||||
}
|
||||
}
|
||||
@if (reusableSuggestions > 0) {
|
||||
<div class="list-group-item text-muted fst-italic">
|
||||
<small class="text-muted small fst-italic" i18n>{reusableSuggestions, plural, =1 {1 existing value suggested below} other {{{reusableSuggestions}} existing values suggested below}}</small>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,15 +21,115 @@ describe('SuggestionsDropdownComponent', () => {
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should calculate totalSuggestions', () => {
|
||||
it('should exclude suggested storage path names from totalSuggestions', () => {
|
||||
fixture.componentRef.setInput('suggestions', {
|
||||
suggested_correspondents: ['John Doe'],
|
||||
suggested_tags: ['Tag1', 'Tag2'],
|
||||
suggested_document_types: ['Type1'],
|
||||
suggested_storage_paths: ['Finance/Invoices'],
|
||||
})
|
||||
expect(component.totalSuggestions).toBe(4)
|
||||
})
|
||||
|
||||
it('should count suggestions when a category is absent from the response', () => {
|
||||
fixture.componentRef.setInput('suggestions', {
|
||||
suggested_tags: ['Tag1'],
|
||||
})
|
||||
expect(component.totalSuggestions).toBe(1)
|
||||
})
|
||||
|
||||
it('should count reused values the document does not have yet', () => {
|
||||
fixture.componentRef.setInput('suggestions', {
|
||||
tags: [1, 2, 3],
|
||||
correspondents: [10],
|
||||
document_types: [20],
|
||||
suggested_tags: ['NewTag'],
|
||||
})
|
||||
fixture.componentRef.setInput('appliedTags', [2])
|
||||
fixture.componentRef.setInput('appliedDocumentType', 20)
|
||||
|
||||
// tags 1 and 3 are not applied yet, correspondent 10 is not set, tag 2 and
|
||||
// document type 20 already are.
|
||||
expect(component.reusableSuggestions).toBe(3)
|
||||
expect(component.novelSuggestions).toBe(1)
|
||||
expect(component.totalSuggestions).toBe(4)
|
||||
})
|
||||
|
||||
it('should not count reused values that are already applied', () => {
|
||||
fixture.componentRef.setInput('suggestions', {
|
||||
tags: [1],
|
||||
correspondents: [10],
|
||||
document_types: [20],
|
||||
})
|
||||
fixture.componentRef.setInput('appliedTags', [1])
|
||||
fixture.componentRef.setInput('appliedCorrespondent', 10)
|
||||
fixture.componentRef.setInput('appliedDocumentType', 20)
|
||||
|
||||
expect(component.totalSuggestions).toBe(0)
|
||||
})
|
||||
|
||||
it('should point at the fields when suggestions are all reused', () => {
|
||||
// The dropdown lists only values to create, so a response made entirely of
|
||||
// reused existing objects used to render as "No novel suggestions".
|
||||
fixture.componentRef.setInput('aiEnabled', true)
|
||||
fixture.componentRef.setInput('suggestions', {
|
||||
tags: [1, 2],
|
||||
suggested_tags: [],
|
||||
suggested_correspondents: [],
|
||||
suggested_document_types: [],
|
||||
})
|
||||
fixture.detectChanges()
|
||||
component.clickSuggest()
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'2 existing values suggested below'
|
||||
)
|
||||
expect(fixture.nativeElement.textContent).not.toContain(
|
||||
'No novel suggestions'
|
||||
)
|
||||
})
|
||||
|
||||
it('should account for reused values alongside values to create', () => {
|
||||
// The badge counts both, but only the novel names are listed here, so the
|
||||
// dropdown has to say where the rest of the count came from.
|
||||
fixture.componentRef.setInput('aiEnabled', true)
|
||||
fixture.componentRef.setInput('suggestions', {
|
||||
tags: [6, 3],
|
||||
suggested_tags: ['Arbitration', 'New York'],
|
||||
suggested_correspondents: [],
|
||||
suggested_document_types: [],
|
||||
})
|
||||
fixture.detectChanges()
|
||||
component.clickSuggest()
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(component.totalSuggestions).toBe(4)
|
||||
expect(fixture.nativeElement.textContent).toContain('Arbitration')
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'2 existing values suggested below'
|
||||
)
|
||||
})
|
||||
|
||||
it('should count classic (non-AI) suggestions, which are ids only', () => {
|
||||
// /api/documents/{id}/suggestions/ returns only id arrays and no
|
||||
// suggested_* keys at all, so every one of its suggestions is a reused
|
||||
// existing object - including storage paths.
|
||||
fixture.componentRef.setInput('suggestions', {
|
||||
correspondents: [4],
|
||||
tags: [6, 3],
|
||||
document_types: [2],
|
||||
storage_paths: [7],
|
||||
dates: ['2005-01-01'],
|
||||
})
|
||||
|
||||
expect(component.novelSuggestions).toBe(0)
|
||||
expect(component.totalSuggestions).toBe(5)
|
||||
|
||||
fixture.componentRef.setInput('appliedStoragePath', 7)
|
||||
expect(component.totalSuggestions).toBe(4)
|
||||
})
|
||||
|
||||
it('should show when a completed request returned no suggestions', () => {
|
||||
fixture.componentRef.setInput('suggestions', {
|
||||
correspondents: [],
|
||||
|
||||
@@ -25,6 +25,11 @@ export class SuggestionsDropdownComponent {
|
||||
readonly loading = input(false)
|
||||
readonly disabled = input(false)
|
||||
|
||||
readonly appliedTags = input<number[]>([])
|
||||
readonly appliedCorrespondent = input<number>(null)
|
||||
readonly appliedDocumentType = input<number>(null)
|
||||
readonly appliedStoragePath = input<number>(null)
|
||||
|
||||
@Output()
|
||||
getSuggestions: EventEmitter<SuggestionsDropdownComponent> =
|
||||
new EventEmitter()
|
||||
@@ -54,14 +59,46 @@ export class SuggestionsDropdownComponent {
|
||||
}
|
||||
}
|
||||
|
||||
get totalSuggestions(): number {
|
||||
get novelSuggestions(): number {
|
||||
return (
|
||||
this.suggestions()?.suggested_correspondents?.length +
|
||||
this.suggestions()?.suggested_tags?.length +
|
||||
this.suggestions()?.suggested_document_types?.length || 0
|
||||
(this.suggestions()?.suggested_correspondents?.length ?? 0) +
|
||||
(this.suggestions()?.suggested_tags?.length ?? 0) +
|
||||
(this.suggestions()?.suggested_document_types?.length ?? 0)
|
||||
)
|
||||
}
|
||||
|
||||
get reusableSuggestions(): number {
|
||||
const correspondent = this.appliedCorrespondent()
|
||||
const documentType = this.appliedDocumentType()
|
||||
const storagePath = this.appliedStoragePath()
|
||||
// Storage paths count here but not in novelSuggestions: an existing one
|
||||
// can be applied from the field, a suggested name cannot create one.
|
||||
return (
|
||||
this.countUnapplied(this.suggestions()?.tags, this.appliedTags()) +
|
||||
this.countUnapplied(
|
||||
this.suggestions()?.correspondents,
|
||||
correspondent ? [correspondent] : []
|
||||
) +
|
||||
this.countUnapplied(
|
||||
this.suggestions()?.document_types,
|
||||
documentType ? [documentType] : []
|
||||
) +
|
||||
this.countUnapplied(
|
||||
this.suggestions()?.storage_paths,
|
||||
storagePath ? [storagePath] : []
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
get totalSuggestions(): number {
|
||||
return this.novelSuggestions + this.reusableSuggestions
|
||||
}
|
||||
|
||||
private countUnapplied(suggested: number[], applied: number[]): number {
|
||||
return (suggested ?? []).filter((id) => !(applied ?? []).includes(id))
|
||||
.length
|
||||
}
|
||||
|
||||
get noSuggestions(): boolean {
|
||||
const suggestions = this.suggestions()
|
||||
return (
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload>
|
||||
</form>
|
||||
@if (getStatus().length > 0) {
|
||||
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none consumer-status-list" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-md-3 offset-lg-2'">
|
||||
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none consumer-status-list" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-sidebar-expanded'">
|
||||
<div class="col col-lg-4 col-xl-3 ps-0 pe-0 ps-lg-3 pe-lg-0 pe-auto overflow-y-scroll">
|
||||
<div class="card shadow-sm consumer-status-card">
|
||||
<div class="card-body">
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
<ngb-alert class="pe-3" type="primary" [dismissible]="true" (closed)="dismiss.emit(true)">
|
||||
<h4 class="alert-heading"><ng-container i18n>Paperless-ngx is running!</ng-container> 🎉</h4>
|
||||
<p i18n>You're ready to start uploading documents! Explore the various features of this web app on your own, or start a quick tour using the button below.</p>
|
||||
<p i18n>More detail on how to use and configure Paperless-ngx is always available in the <a href="https://docs.paperless-ngx.com" target="_blank">documentation</a>.</p>
|
||||
<hr>
|
||||
<div class="d-flex align-items-end">
|
||||
<p class="lead fs-6 m-0"><em i18n>Thanks for being a part of the Paperless-ngx community!</em></p>
|
||||
<button class="btn btn-primary ms-auto flex-shrink-0" (click)="tourService.start()"><ng-container i18n>Start the tour</ng-container> →</button>
|
||||
<ngb-alert class="welcome-widget bg-light shadow-sm pe-3 position-relative overflow-hidden" type="primary" [dismissible]="true" (closed)="dismiss.emit(true)">
|
||||
<pngx-brand-mark class="welcome-watermark d-none d-md-block position-absolute top-50 translate-middle-y text-primary pe-none" width="26rem" height="26rem"></pngx-brand-mark>
|
||||
<div class="d-flex align-items-start gap-3 gap-md-4 position-relative">
|
||||
<pngx-brand-mark class="d-none d-sm-block flex-shrink-0 text-primary" width="3rem" height="3rem"></pngx-brand-mark>
|
||||
<div class="flex-grow-1">
|
||||
<h4 class="alert-heading text-primary fw-semibold"><ng-container i18n>Paperless-ngx is running!</ng-container> 🎉</h4>
|
||||
<div class="welcome-copy text-dark">
|
||||
<p i18n>You're ready to start uploading documents! Explore the various features of this web app on your own, or start a quick tour using the button below.</p>
|
||||
<p class="mb-0" i18n>More detail on how to use and configure Paperless-ngx is always available in the <a href="https://docs.paperless-ngx.com" target="_blank">documentation</a>.</p>
|
||||
</div>
|
||||
<hr class="opacity-100">
|
||||
<div class="d-flex flex-column flex-sm-row align-items-sm-center gap-3">
|
||||
<p class="m-0 text-secondary"><em i18n>Thanks for being a part of the Paperless-ngx community!</em></p>
|
||||
<button class="btn btn-primary ms-sm-auto flex-shrink-0 align-self-start align-self-sm-auto" (click)="tourService.start()"><ng-container i18n>Start the tour</ng-container> →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ngb-alert>
|
||||
|
||||