Compare commits

...
Author SHA1 Message Date
stumpylog 74abdf8ff8 Docs: remove stale drf-writable-nested references, DRY up audit-actor update()
Final-review cleanup on refactor/remove-drf-writable-nested: several
comments/docstrings still described drf-writable-nested's removed
NestedUpdateMixin behavior in the present tense. Rewrote them to explain
the shared context-cache and delete-query-count guarantees in terms of
the current code (bulk-edit's per-field CustomFieldInstanceSerializer
construction, and _sync_custom_fields' single hard-delete query).

Also collapsed the duplicated `if custom_fields_data is not None:
self._sync_custom_fields(...)` line in DocumentSerializer.update()'s
audit-log branches into a single code path using
contextlib.nullcontext(), removing the drift risk that caused the
earlier audit-actor bug.

No behavior change.
2026-08-25 11:53:42 -07:00
stumpylogandClaude Sonnet 5 ce5d5c33b0 chore: remove drf-writable-nested dependency
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 11:07:11 -07:00
stumpylog d9d44520bd test: add query-count regression coverage for custom_fields sync on update 2026-08-25 10:59:47 -07:00
stumpylog 500ff563cf refactor: replace drf-writable-nested's NestedUpdateMixin with explicit custom_fields sync 2026-08-25 10:38:51 -07:00
stumpylog 7f314b5149 test: characterize custom field instance delete-on-omit as a hard delete 2026-08-25 10:28:24 -07:00
stumpylog 132918821d Merge remote-tracking branch 'origin/perf/batch-custom-field-lookup' into tmp/perf-integration 2026-08-25 10:15:52 -07:00
stumpylog 512a3fe196 Merge remote-tracking branch 'origin/perf/batch-modify-custom-fields' into tmp/perf-integration 2026-08-25 10:15:48 -07:00
stumpylog b01e0368b7 Merge remote-tracking branch 'origin/perf/batch-tags-field-lookup' into tmp/perf-integration 2026-08-25 10:15:44 -07:00
stumpylog 1c82bd15c5 Merge branch 'perf/batch-set-permissions' into tmp/perf-integration 2026-08-25 10:15:39 -07:00
stumpylog cbac71c165 Perf: avoid unnecessary full-row fetches in batch permission assignment
set_permissions_for_objects now takes a model + pks instead of instances,
and identity filtering resolves straight to ids, so bulk-editing
permissions no longer materializes full Document/User/Group rows just to
read their pk/id. Row construction for bulk_create is also chunked to
bound peak memory for very large "apply to all" operations.
2026-08-25 09:27:24 -07:00
stumpylog 8780bcd5c7 Perf: batch guardian permission assignment in bulk-edit
bulk_edit.set_permissions and BulkEditObjectPermissionsView both
looped documents/objects and called set_permissions_for_object per
object, which itself calls guardian's assign_perm/remove_perm once
per (object, user) pair -- ~10-20+ queries per object, scaling with
selection size.

Added set_permissions_for_objects, a bulk equivalent that resolves
existing permission holders once across the whole batch (not once per
object) and applies changes with a small, batch-size-independent
number of queries per action instead of one per (object, user) pair.

Deliberately does not use guardian's queryset-aware assign_perm:
passing a list as the target routes to bulk_assign_perm, which skips
creating a direct permission row for anyone who already has the
permission via ANY group membership (checked via
ObjectPermissionChecker.has_perm, which is group-inheritance-aware) --
unlike the single-object assign_perm this replaces, which always
ensures a direct row via get_or_create. Losing that guarantee would
mean a later revocation of the group's grant silently strips access
an admin explicitly asked to be direct. Bulk-creates rows straight
against UserObjectPermission/GroupObjectPermission instead
(ignore_conflicts=True, relying on the existing (identity, permission,
object_pk) unique constraint), which preserves the original semantics
exactly while still batching every object and identity into one query
per action. Also raises Permission.DoesNotExist for an unrecognized
action name instead of silently no-op-ing, matching the original
per-object path -- BulkEditObjectsSerializer never actually validates
action keys against the raw client-supplied permissions dict, so this
is reachable from client input, not just internal callers.

Verified via CaptureQueriesContext: query count is now identical at 5
vs. 50 documents/objects (was 1,123 queries for 20 documents on the
Document path, 2,806 for 50 tags on the BulkEditObjectPermissionsView
path, both now flat). Full documents test suite green (2,148 passed,
1 skipped).
2026-08-24 20:04:58 -07:00
GitHub Actions 492424f7f2 Auto translate strings 2026-08-25 00:07:03 +00:00
shamoonandGitHub cd525819d7 Tweak: more misc UI tweaks (#13783) 2026-08-24 17:05:29 -07:00
stumpylog 9d2416c435 Perf: batch id resolution for TagsField and friends
TagsField/CorrespondentField/DocumentTypeField/StoragePathField were
plain PrimaryKeyRelatedField subclasses with no batching. When used
with many=True (only tags today: DocumentSerializer.tags,
WorkflowActionSerializer.assign_tags), DRF's ManyRelatedField resolves
each submitted id with its own query -- one query per tag on every
PATCH/PUT that sets tags.

Added BatchResolvingPrimaryKeyRelatedField as the shared base for all
four field classes and overrode many_init so the many=True form
(_BatchingManyRelatedField) resolves the whole id list with one
pk__in query, falling back to the child relation's normal per-item
validation for anything not found in that batch. Only TagsField uses
many=True today, but the fix isn't tag-specific -- if a future PR puts
many=True on one of the others, it inherits the same batching instead
of reintroducing this as a new bug.

Independent review caught a real regression: Django's IntegerFieldOverflow
guard (out-of-range int -> EmptyResultSet) only covers exact/gt/gte/lt/lte
lookups, not `in`, so an absurdly large tag id reached the batched
pk__in= query as-is and raised an unhandled OverflowError (SQLite) /
DataError (Postgres) instead of the normal 400 the original per-item
`exact` lookup produced. Guarded the batch query and fall through to
per-item resolution (which goes through the protected `exact` lookup)
on failure.

Verified via CaptureQueriesContext against a real API PATCH: 20 tags
dropped from 54 to 35 queries per request (exactly the 19 saved by
collapsing 20 individual lookups into one batched query). Full
documents/workflows/bulk-edit/retagger/custom-fields suites green
(443 passed).
2026-08-24 15:15:00 -07:00
GitHub Actions 2609327e9c Auto translate strings 2026-08-24 21:44:25 +00:00
shamoonandGitHub b90ccf910f Finally, the remote ocr workflow (#13637)
* Ok! Backend stuff for the remote ocr workflow

* Frotnend workflow stuff

* And docs

* Fix dynamic action fields thing

* Actually, fix the action dropdown thing

* Fix this validation thing, and we have to check existing actions

* Fix migration
2026-08-24 14:43:05 -07:00
shamoonandGitHub c93c996edf Remote ocr reprocess (#13636)
* Backend stuff for remote ocr reprocess, add to bulk edit pass in from ui settings

* Ok, frontend reprocess remote option

* Docs
2026-08-24 14:43:05 -07:00
shamoonandGitHub 7f1609332a Allow parsers to declare uses remote, and remote ocr_mode (#13634)
* uses_remote_service + allow_remote to allow opt-in / out of remote OCR

* Add to parser dev docs

* remote_ocr_mode config setting

* Checks for remote_ocr_mode and fix import

* Update config.component.spec.ts

* More tests for remote_ocr_mode

* Docs for remote_ocr_mode

* Ok, wire up the remote_ocr_mode with allow_remote for consumer

* Update consumer.py

* Format remote OCR mode check tests

* Use get_choice_from_env
2026-08-24 14:43:04 -07:00
stumpylog a98d0669e4 Perf: batch CustomField/Document lookups in modify_custom_fields
modify_custom_fields looped documents x fields, re-.get()-ing the
CustomField queryset per iteration and Document.objects.get() per doc
for DOCUMENTLINK fields -- same shape as the earlier custom_fields
serializer N+1 (#13779), just nested one level deeper. Resolve both
into dicts once up front instead. Also pass the resolved objects
(not bare ids) to update_or_create so newly-created CustomFieldInstance
rows cache their field/document FK, avoiding a re-fetch when auditlog's
post_save receiver calls str(instance) (which touches .field.name).

docs_by_id defers `content` (the one field guaranteed both large and
unused by this function or its receivers) rather than using .only(),
since .only() would just turn the filename-generation signal's other
field access into a deferred-reload N+1.

Verified via CaptureQueriesContext: 6 docs x 4 fields dropped from 48
CustomField queries to 1; DOCUMENTLINK per-doc Document lookups dropped
from N to 0 (single batched query instead).
2026-08-24 14:21:59 -07:00
GitHub Actions a1f20c9fe7 Auto translate strings 2026-08-24 21:19:19 +00:00
shamoonandGitHub 4fd1c60731 Enhancement: support using remote OCR engines selectively (#13633)
* Backend changes and migration for remote OCR Config

* Backend tests

* Frontend stuff, with sections

* Docs

* Update test_tesseract_parser.py

* Actually we cant use this any more, in case settings are in app config

* Dont mark entire test file for db, use a mock for empty engine settings
2026-08-24 14:17:52 -07:00
stumpylog bda506968b Handles a bad client sending malformed JSON or non-int primary keys 2026-08-24 12:35:06 -07:00
GitHub Actions ba83e5b39a Auto translate strings 2026-08-24 18:28:51 +00:00
shamoonandGitHub 1497bd33a1 Fix: fix bottom mobile nav buttons on Android (#13780) 2026-08-24 11:27:26 -07:00
shamoon 01c40aa0bd Tweak: subtler shadow, tweak against light color 2026-08-24 08:51:51 -07:00
shamoon 5f70b6eee7 Fix navbar button alignment + hover consistency 2026-08-24 08:47:27 -07:00
GitHub Actions e3e4b26944 Auto translate strings 2026-08-24 15:16:51 +00:00
a0908f6b4a Enhancement: websocket heartbeat (#13739)
---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-08-24 15:15:21 +00:00
bab9129ff8 Fix: lazy import guardian modules to fix search language setting (#13768)
Co-authored-by: Trenton H <797416+stumpylog@users.noreply.github.com>
2026-08-24 13:46:20 +00:00
GitHub Actions 1e13f86174 Auto translate strings 2026-08-24 09:13:06 +00:00
shamoonandGitHub 22c57fb392 Enhancement: more v3 ui tweaks (#13774) 2026-08-24 02:11:34 -07:00
GitHub Actions 08a7f6ccc0 Auto translate strings 2026-08-24 04:56:30 +00:00
shamoonandGitHub 3f4d327f64 Chore: add some missing UI accessibility labels (#13772) 2026-08-23 21:55:07 -07:00
GitHub Actions bd326540fa Auto translate strings 2026-08-24 02:43:34 +00:00
shamoonandGitHub 33f9adb05a Chore: refactor permission checkbox live changes (#13771) 2026-08-23 19:42:18 -07:00
Trenton Holmes 4ccb34a70b Perf: avoid per-instance CustomField reload in DocumentMetadataOverrides
send_websocket_document_updated calls document.refresh_from_db()
before building overrides, which drops the custom_fields prefetch
(and its select_related("field")) set up by the view's queryset.
DocumentMetadataOverrides.from_document() then lazily reloads field
once per custom field instance. Since from_document() can't rely on
the caller having a prefetched document, select_related explicitly at
the point of use instead.
2026-08-23 17:33:05 -07:00
Trenton Holmes 0a466c9fcf Perf: reuse resolved CustomField objects across drf-writable-nested's per-item revalidation
drf-writable-nested's update_or_create_reverse_relations rebuilds a
fresh serializer -- and fresh field instances -- per custom_fields item
while matching existing vs. new instances during save(), so the
per-instance lookup cache alone only helped the first validation pass.
It passes the same context dict (by reference) to every one of those
serializers, so stash resolved CustomField objects there instead:
later passes reuse them for free rather than re-querying.
2026-08-23 17:12:25 -07:00
shamoonandGitHub d24606a03b Tweak: better support long list of views in documents list (#13769) 2026-08-23 16:40:55 -07:00
shamoonandGitHub 294328f174 Fix: version indexing fixes (#13737) 2026-08-23 23:04:47 +00:00
Trenton Holmes c9cc4f427d Perf: batch CustomField lookups when validating a document's custom_fields
DocumentSerializer.custom_fields validates each item's field id via a
plain PrimaryKeyRelatedField, which issues one SELECT per custom field
per validation pass (discussion #13690). Batch-resolve all field ids in
one query and cache them on the field instance so per-item validation
is free instead of re-querying.
2026-08-23 14:53:46 -07:00
shamoonandGitHub 0458bad5f2 Fix: append charset to file response for text files (#13759) 2026-08-22 06:15:53 -07:00
shamoon a510d03c77 Merge branch 'main' into dev 2026-08-22 05:27:10 -07:00
shamoonandGitHub d7b3612a41 Chore: pin Apache Tika images to 3.3.1 (#13758) 2026-08-22 05:25:37 -07:00
shamoonandGitHub 7e4a644714 Fix: align bulk edit perms with document model (#13757) 2026-08-22 05:24:16 -07:00
GitHub Actions bbcd6af2fe Auto translate strings 2026-08-21 15:25:24 +00:00
shamoonandGitHub 0431939f18 QoL: add count badge to versions dropdown (#13753) 2026-08-21 08:23:47 -07:00
shamoonandGitHub bed95ea301 Tweakhancement: add jitter to IMAP polling schedule (#13734) 2026-08-20 17:19:47 +00:00
GitHub Actions 42034c3c77 Auto translate strings 2026-08-20 04:52:42 +00:00
shamoonandGitHub 705220fb5a Fix: use selected version for doc detail emailing (#13738) 2026-08-19 21:51:13 -07:00
shamoonandGitHub 2d084983c8 Documentation: clarify OCR mode changes in v3 (#13666) 2026-08-12 21:46:52 -07:00
shamoonandGitHub e2c284f64e Documentation: add wiki links for AI stuff and parser plugins (#13626) 2026-08-09 19:05:32 -07:00
136 changed files with 4871 additions and 945 deletions
@@ -68,7 +68,7 @@ services:
- "--chromium-disable-javascript=true" - "--chromium-disable-javascript=true"
- "--chromium-allow-list=file:///tmp/.*" - "--chromium-allow-list=file:///tmp/.*"
tika: tika:
image: docker.io/apache/tika:latest image: docker.io/apache/tika:3.3.1.0
restart: unless-stopped restart: unless-stopped
volumes: volumes:
data: data:
@@ -81,7 +81,7 @@ services:
- "--chromium-disable-javascript=true" - "--chromium-disable-javascript=true"
- "--chromium-allow-list=file:///tmp/.*" - "--chromium-allow-list=file:///tmp/.*"
tika: tika:
image: docker.io/apache/tika:latest image: docker.io/apache/tika:3.3.1.0
restart: unless-stopped restart: unless-stopped
volumes: volumes:
data: data:
@@ -76,7 +76,7 @@ services:
- "--chromium-disable-javascript=true" - "--chromium-disable-javascript=true"
- "--chromium-allow-list=file:///tmp/.*" - "--chromium-allow-list=file:///tmp/.*"
tika: tika:
image: docker.io/apache/tika:latest image: docker.io/apache/tika:3.3.1.0
restart: unless-stopped restart: unless-stopped
volumes: volumes:
data: data:
@@ -65,7 +65,7 @@ services:
- "--chromium-disable-javascript=true" - "--chromium-disable-javascript=true"
- "--chromium-allow-list=file:///tmp/.*" - "--chromium-allow-list=file:///tmp/.*"
tika: tika:
image: docker.io/apache/tika:latest image: docker.io/apache/tika:3.3.1.0
restart: unless-stopped restart: unless-stopped
volumes: volumes:
data: data:
+6 -1
View File
@@ -129,6 +129,10 @@ At a minimum you need to enable AI and choose an LLM backend:
and/or [`PAPERLESS_AI_LLM_ENDPOINT`](configuration.md#PAPERLESS_AI_LLM_ENDPOINT). Ollama and/or [`PAPERLESS_AI_LLM_ENDPOINT`](configuration.md#PAPERLESS_AI_LLM_ENDPOINT). Ollama
requires `PAPERLESS_AI_LLM_ENDPOINT` pointing at your Ollama server. requires `PAPERLESS_AI_LLM_ENDPOINT` pointing at your Ollama server.
See the community-maintained wiki page on
[choosing AI models](https://github.com/paperless-ngx/paperless-ngx/wiki/AI-Model-Recommendations)
for suggested generation and embedding models.
### AI-assisted suggestions ### AI-assisted suggestions
With AI enabled, Paperless-ngx can suggest a title, tags, correspondent, document type, With AI enabled, Paperless-ngx can suggest a title, tags, correspondent, document type,
@@ -808,7 +812,8 @@ Third-party parser plugins extend Paperless-ngx to support additional file
formats. A plugin is a Python package that advertises itself under the formats. A plugin is a Python package that advertises itself under the
`paperless_ngx.parsers` entry point group. Refer to the `paperless_ngx.parsers` entry point group. Refer to the
[developer documentation](development.md#making-custom-parsers) for how to [developer documentation](development.md#making-custom-parsers) for how to
create one. create one, or see the wiki for a community-maintained list of
[parser plugins](https://github.com/paperless-ngx/paperless-ngx/wiki/Related-Projects#parser-plugins).
!!! warning "Third-party plugins are not officially supported" !!! warning "Third-party plugins are not officially supported"
+2 -1
View File
@@ -302,7 +302,8 @@ The following methods are supported:
- `delete` - `delete`
- No `parameters` required - No `parameters` required
- `reprocess` - `reprocess`
- No `parameters` required - Optional `parameters`: `{ "remote_ocr": true }` to send the documents to the
remote OCR engine, see [Remote OCR](usage.md#remote-ocr). Defaults to false.
- `set_permissions` - `set_permissions`
- Requires `parameters`: - Requires `parameters`:
- `"set_permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or - `"set_permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or
+17 -1
View File
@@ -1215,7 +1215,7 @@ should be a valid crontab(5) expression describing when to run.
: If set to the string "disable", no emails will be fetched automatically. : If set to the string "disable", no emails will be fetched automatically.
Defaults to `*/10 * * * *` or every ten minutes. Defaults to every ten minutes, with an installation-specific minute offset.
#### [`PAPERLESS_TRAIN_TASK_CRON=<cron expression>`](#PAPERLESS_TRAIN_TASK_CRON) {#PAPERLESS_TRAIN_TASK_CRON} #### [`PAPERLESS_TRAIN_TASK_CRON=<cron expression>`](#PAPERLESS_TRAIN_TASK_CRON) {#PAPERLESS_TRAIN_TASK_CRON}
@@ -2066,6 +2066,18 @@ password. All of these options come from their similarly-named [Django settings]
Defaults to None. Defaults to None.
#### [`PAPERLESS_REMOTE_OCR_MODE=<str>`](#PAPERLESS_REMOTE_OCR_MODE) {#PAPERLESS_REMOTE_OCR_MODE}
: Which documents are sent to the remote OCR engine.
- `always`: every document of a supported file type is sent to the remote
engine, bypassing the local OCR engine.
- `workflow_only`: documents are processed locally unless a workflow
explicitly enables remote OCR for them, letting you use the remote engine
selectively.
Defaults to "always".
## AI {#ai} ## AI {#ai}
#### [`PAPERLESS_AI_ENABLED=<bool>`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED} #### [`PAPERLESS_AI_ENABLED=<bool>`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED}
@@ -2088,6 +2100,8 @@ suggestions. This setting is required to be set to true in order to use the AI f
models supported by the current embedding backend. If not supplied, defaults to models supported by the current embedding backend. If not supplied, defaults to
"text-embedding-3-small" for the OpenAI-compatible backend, "text-embedding-3-small" for the OpenAI-compatible backend,
"sentence-transformers/all-MiniLM-L6-v2" for Huggingface, and "embeddinggemma" for Ollama. "sentence-transformers/all-MiniLM-L6-v2" for Huggingface, and "embeddinggemma" for Ollama.
See [choosing AI models](https://github.com/paperless-ngx/paperless-ngx/wiki/AI-Model-Recommendations)
for language and resource considerations.
Defaults to None. Defaults to None.
@@ -2144,6 +2158,8 @@ setting is required to be set to use the AI features.
: The model to use for the AI backend, i.e. "gpt-3.5-turbo", "gpt-4" or any of the models supported : The model to use for the AI backend, i.e. "gpt-3.5-turbo", "gpt-4" or any of the models supported
by the current backend. If not supplied, defaults to "gpt-3.5-turbo" for the OpenAI-compatible by the current backend. If not supplied, defaults to "gpt-3.5-turbo" for the OpenAI-compatible
backend and "llama3.1" for Ollama. backend and "llama3.1" for Ollama.
See [choosing AI models](https://github.com/paperless-ngx/paperless-ngx/wiki/AI-Model-Recommendations)
for local versus remote and model-size considerations.
Defaults to None. Defaults to None.
+14
View File
@@ -456,6 +456,20 @@ def score(
return 10 return 10
``` ```
**Remote services**
If your parser sends document content to a remote service, declare it:
```python
class MyCustomParser:
uses_remote_service = True
```
Paperless-ngx excludes such parsers when the document being consumed has not
been marked for remote processing, so users can keep remote OCR off by default
and enable it selectively with a workflow. Parsers that do not declare the
attribute are treated as fully local and are always considered.
**Archive and rendition flags** **Archive and rendition flags**
```python ```python
+4 -3
View File
@@ -156,7 +156,7 @@ The new settings are independent:
### Database configuration ### Database configuration
If you changed OCR settings via the admin UI (ApplicationConfiguration), the database values are **migrated automatically** during the upgrade. `mode` values (`skip` / `skip_noarchive`) are mapped to their new equivalents and `skip_archive_file` values are converted to the new `archive_file_generation` field. After upgrading, review the OCR settings in the admin UI to confirm the migrated values match your intent. If you changed OCR settings via the admin UI (ApplicationConfiguration), the database values are **migrated automatically** during the upgrade. `mode` values (`skip` / `skip_noarchive`) are mapped to their new equivalents and explicit `skip_archive_file` values are converted to the new `archive_file_generation` field. Users who relied on the old defaults must set `archive_file_generation` to `always` to preserve the v2 behaviour of always creating an archive. After upgrading, review the OCR settings in the admin UI to confirm the migrated values match your intent.
### Action Required ### Action Required
@@ -165,8 +165,9 @@ Remove any `PAPERLESS_OCR_SKIP_ARCHIVE_FILE` variable from your environment. If
```bash ```bash
# v2: skip OCR when text present, always archive # v2: skip OCR when text present, always archive
PAPERLESS_OCR_MODE=skip PAPERLESS_OCR_MODE=skip
# v3: equivalent (auto is the new default) # v3: equivalent
# No change needed - auto is the default PAPERLESS_OCR_MODE=auto
PAPERLESS_ARCHIVE_FILE_GENERATION=always
# v2: skip OCR when text present, skip archive too # v2: skip OCR when text present, skip archive too
PAPERLESS_OCR_MODE=skip_noarchive PAPERLESS_OCR_MODE=skip_noarchive
+22 -1
View File
@@ -654,6 +654,19 @@ happened while it was still encrypted, that original version will likewise be mi
**Current limitation**: Passwords are stored as a simple list without descriptions. To handle **Current limitation**: Passwords are stored as a simple list without descriptions. To handle
multiple PDF types with different passwords, create separate workflows for each use case. multiple PDF types with different passwords, create separate workflows for each use case.
##### Remote OCR {#workflow-action-remote-ocr}
"Remote OCR" actions send the document to the configured remote OCR engine instead of processing it
locally. To use remote OCR selectively, set the [remote OCR mode](configuration.md#PAPERLESS_REMOTE_OCR_MODE)
to `workflow_only` then add this action to a workflow that matches only the documents you
want sent to the remote engine. See [Remote OCR](#remote-ocr) for the engine setup. The action only works with
a **Consumption Started** trigger.
The action takes no options, its presence is what enables remote OCR for a matching document.
If the remote engine is not configured, or does not support the document's file type, the document is
processed locally instead and a warning is written to the log.
#### Workflow placeholders #### Workflow placeholders
Titles and webhook payloads can be generated by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/). Titles and webhook payloads can be generated by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
@@ -1090,11 +1103,19 @@ Paperless-ngx supports performing OCR on documents using remote services. At the
[Microsoft's Azure "Document Intelligence" service](https://azure.microsoft.com/en-us/products/ai-services/ai-document-intelligence). [Microsoft's Azure "Document Intelligence" service](https://azure.microsoft.com/en-us/products/ai-services/ai-document-intelligence).
This is of course a paid service (with a free tier) which requires an Azure account and subscription. Azure AI is not affiliated with This is of course a paid service (with a free tier) which requires an Azure account and subscription. Azure AI is not affiliated with
Paperless-ngx in any way. When enabled, Paperless-ngx will automatically send appropriate documents to Azure for OCR processing, bypassing Paperless-ngx in any way. When enabled, Paperless-ngx will automatically send appropriate documents to Azure for OCR processing, bypassing
the local OCR engine. See the [configuration](configuration.md#PAPERLESS_REMOTE_OCR_ENGINE) options for more details. the local OCR engine. See the [configuration](configuration.md#PAPERLESS_REMOTE_OCR_ENGINE) options for more details. These
settings can be supplied as environment variables or via **Application Configuration**.
Additionally, when using a commercial service with this feature, consider both potential costs as well as any associated file size Additionally, when using a commercial service with this feature, consider both potential costs as well as any associated file size
or page limitations (e.g. with a free tier). or page limitations (e.g. with a free tier).
By default, every document of a supported file type is sent to the remote engine. To use it more selectively, set the
[remote OCR mode](configuration.md#PAPERLESS_REMOTE_OCR_MODE) to `workflow_only`. Documents are then processed locally
unless a [remote OCR workflow action](#workflow-action-remote-ocr) enables it for them, so you can limit the remote
engine to particular documents.
Setting the mode to `workflow_only` also allows the **Reprocess** actions to selectively use remote OCR for individual documents.
## Architecture ## Architecture
Paperless-ngx consists of the following components: Paperless-ngx consists of the following components:
-1
View File
@@ -40,7 +40,6 @@ dependencies = [
"djangorestframework~=3.16", "djangorestframework~=3.16",
"drf-spectacular~=0.30", "drf-spectacular~=0.30",
"drf-spectacular-sidecar~=2026.7.1", "drf-spectacular-sidecar~=2026.7.1",
"drf-writable-nested~=0.7.1",
"filelock~=3.32.0", "filelock~=3.32.0",
"flower~=2.0.1", "flower~=2.0.1",
"gotenberg-client~=0.14.0", "gotenberg-client~=0.14.0",
@@ -33,7 +33,7 @@ test('should warn on unsaved changes', async ({ page }) => {
await page.getByRole('button', { name: 'Close', exact: true }).click() await page.getByRole('button', { name: 'Close', exact: true }).click()
await expect(page.getByRole('dialog')).toHaveText(/unsaved changes/) await expect(page.getByRole('dialog')).toHaveText(/unsaved changes/)
await page.getByRole('button', { name: 'Cancel' }).click() await page.getByRole('button', { name: 'Cancel' }).click()
await page.getByRole('link', { name: 'Close all' }).click() await page.getByRole('button', { name: 'Close all' }).click()
await expect(page.getByRole('dialog')).toHaveText(/unsaved changes/) await expect(page.getByRole('dialog')).toHaveText(/unsaved changes/)
}) })
+726 -335
View File
File diff suppressed because it is too large Load Diff
@@ -14,43 +14,48 @@
<a ngbNavLink>{{category}}</a> <a ngbNavLink>{{category}}</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<div class="p-3"> <div class="p-3">
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2"> @for (section of getCategorySections(category); track section) {
@for (option of getCategoryOptions(category); track option.key) { @if (section) {
<div class="col"> <h5 class="mt-4 mb-3">{{section}}</h5>
<div class="card bg-light"> }
<div class="card-body"> <div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
<div class="card-title d-flex align-items-center"> @for (option of getCategoryOptions(category, section); track option.key) {
<h6 class="mb-0"> <div class="col">
{{option.title}} <div class="card bg-light">
</h6> <div class="card-body">
<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"> <div class="card-title d-flex align-items-center">
<i-bs name="info-circle"></i-bs> <h6 class="mb-0">
</a> {{option.title}}
@if (isSet(option.key)) { </h6>
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)"> <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 class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container> <i-bs name="info-circle"></i-bs>
</button> </a>
@if (isSet(option.key)) {
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)">
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
</button>
}
</div>
<div class="mb-n3">
@switch (option.type) {
@case (ConfigOptionType.Select) { <pngx-input-select [formControlName]="option.key" [error]="errors[option.key]" [items]="option.choices" [allowNull]="true"></pngx-input-select> }
@case (ConfigOptionType.Number) { <pngx-input-number [formControlName]="option.key" [error]="errors[option.key]" [showAdd]="false"></pngx-input-number> }
@case (ConfigOptionType.Boolean) { <pngx-input-switch [formControlName]="option.key" [error]="errors[option.key]" [showUnsetNote]="true" [horizontal]="true" title="Enable" i18n-title></pngx-input-switch> }
@case (ConfigOptionType.String) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
@case (ConfigOptionType.JSON) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
@case (ConfigOptionType.File) { <pngx-input-file [formControlName]="option.key" (upload)="uploadFile($event, option.key)" [error]="errors[option.key]"></pngx-input-file> }
@case (ConfigOptionType.Password) { <pngx-input-password [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-password> }
}
</div>
@if (option.note) {
<div class="form-text fst-italic">{{option.note}}</div>
} }
</div> </div>
<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>
</div> }
</div> </div>
</ng-template> </ng-template>
</li> </li>
@@ -8,7 +8,11 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select' import { NgSelectModule } from '@ng-select/ng-select'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of, throwError } from 'rxjs' import { of, throwError } from 'rxjs'
import { OutputTypeConfig } from 'src/app/data/paperless-config' import {
ConfigCategory,
ConfigSection,
OutputTypeConfig,
} from 'src/app/data/paperless-config'
import { ConfigService } from 'src/app/services/config.service' import { ConfigService } from 'src/app/services/config.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
@@ -158,4 +162,24 @@ describe('ConfigComponent', () => {
component.resetOption('barcodes_enabled') component.resetOption('barcodes_enabled')
expect(component.configForm.get('barcodes_enabled').value).toBeNull() expect(component.configForm.get('barcodes_enabled').value).toBeNull()
}) })
it('should group options into sections within a category, or not', () => {
const sections = component.getCategorySections(ConfigCategory.OCR)
expect(sections).toEqual([null, ConfigSection.RemoteOCR])
expect(
component
.getCategoryOptions(ConfigCategory.OCR)
.map((option) => option.key)
).toContain('output_type')
expect(
component
.getCategoryOptions(ConfigCategory.OCR, ConfigSection.RemoteOCR)
.map((option) => option.key)
).toEqual([
'remote_ocr_engine',
'remote_ocr_api_key',
'remote_ocr_endpoint',
'remote_ocr_mode',
])
})
}) })
@@ -74,8 +74,20 @@ export class ConfigComponent
return Object.values(ConfigCategory) return Object.values(ConfigCategory)
} }
getCategoryOptions(category: string): ConfigOption[] { getCategorySections(category: string): string[] {
return PaperlessConfigOptions.filter((o) => o.category === category) return [
...new Set(
PaperlessConfigOptions.filter((o) => o.category === category).map(
(o) => o.section ?? null // null means no section
)
),
]
}
getCategoryOptions(category: string, section: string = null): ConfigOption[] {
return PaperlessConfigOptions.filter(
(o) => o.category === category && (o.section ?? null) === section
)
} }
initialConfig: PaperlessConfig initialConfig: PaperlessConfig
@@ -89,7 +89,7 @@
</div> </div>
</div> </div>
@if (filterText?.length) { @if (filterText?.length) {
<button class="btn btn-link btn-sm px-2 position-absolute top-0 end-0 z-10" (click)="resetFilter()"> <button class="btn btn-link btn-sm px-2 position-absolute top-0 end-0 z-10" (click)="resetFilter()" aria-label="Clear search" i18n-aria-label>
<i-bs width="1em" height="1em" name="x"></i-bs> <i-bs width="1em" height="1em" name="x"></i-bs>
</button> </button>
} }
@@ -208,7 +208,7 @@
</td> </td>
} }
<td class="d-lg-none"> <td class="d-lg-none">
<button class="btn btn-link" (click)="expandTask(task); $event.stopPropagation();"> <button class="btn btn-link" (click)="expandTask(task); $event.stopPropagation();" aria-label="View task details" i18n-aria-label>
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs> <i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
</button> </button>
</td> </td>
@@ -18,9 +18,11 @@
</button> </button>
</pngx-page-header> </pngx-page-header>
<div class="row mb-3"> @if (totalDocuments() > 25) {
<ngb-pagination class="col-auto" [pageSize]="25" [collectionSize]="totalDocuments()" [page]="page()" [maxSize]="5" (pageChange)="page.set($event); reload()" size="sm" aria-label="Pagination"></ngb-pagination> <div class="row mb-3">
</div> <ngb-pagination class="col-auto" [pageSize]="25" [collectionSize]="totalDocuments()" [page]="page()" [maxSize]="5" (pageChange)="page.set($event); reload()" size="sm" aria-label="Pagination"></ngb-pagination>
</div>
}
<div class="card border table-responsive mb-3"> <div class="card border table-responsive mb-3">
<table class="table table-striped align-middle shadow-sm mb-0"> <table class="table table-striped align-middle shadow-sm mb-0">
@@ -64,7 +66,7 @@
<td scope="row"> <td scope="row">
<div class="btn-group d-block d-sm-none"> <div class="btn-group d-block d-sm-none">
<div ngbDropdown container="body" class="d-inline-block"> <div ngbDropdown container="body" class="d-inline-block">
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle> <button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle aria-label="Actions" i18n-aria-label>
<i-bs name="three-dots-vertical"></i-bs> <i-bs name="three-dots-vertical"></i-bs>
</button> </button>
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile"> <div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
@@ -2,29 +2,36 @@
<button class="navbar-toggler d-md-none collapsed border-0" type="button" data-toggle="collapse" <button class="navbar-toggler d-md-none collapsed border-0" type="button" data-toggle="collapse"
data-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation" data-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation"
(click)="closeMobileSearch(); toggleMenuCollapsed()"> (click)="closeMobileSearch(); toggleMenuCollapsed()">
<span class="navbar-toggler-icon"></span> <i-bs width="1.5em" height="1.5em" name="list"></i-bs>
</button> </button>
<a class="navbar-brand d-flex align-items-center me-0 px-3 py-3 order-sm-0" <a class="navbar-brand d-flex align-items-center me-0 ps-md-3 py-0 order-sm-0"
[ngClass]="{ 'slim': slimSidebarEnabled, 'col-auto col-md-3 col-lg-2 col-xxxl-1' : !slimSidebarEnabled, 'py-3' : !customAppTitle?.length || slimSidebarEnabled, 'py-2': customAppTitle?.length }" [ngClass]="{ 'slim': slimSidebarEnabled, '' : !slimSidebarEnabled }"
routerLink="/dashboard" routerLink="/dashboard"
tourAnchor="tour.intro"> tourAnchor="tour.intro">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1.5em" height="1.5em" fill="currentColor"> @if (!hasCustomBranding) {
<path d="M341,949.1c-6.9-20.3-20.7-61.2-21.9-61-199.6-88.9-182.5-229.8-134.3-347.5,30,137.2,268.8,148.9,146.2,336-.9,2.2,10,27.8,19.5,51.3,22.7-51.9,58.6-115.5,55.8-120.8C178,398.7,724.9,299,807.1,18.5c83,251.5,53.1,659.8-377.4,814.9-2,1.4-63.5,148.6-66.9,150.2-.2-2.1-33.2,2.9-30.1-8.7,1.6-7,4.8-16.2,8.2-25.6h0v-.2h.1ZM323.1,846.2c48.3-71.9-12.7-120.8-56.9-152.2,81.2,107.4,66.4,120.8,56.9,152.2h0Z"/> <pngx-logo extra_classes="navbar-official-logo px-1" height="2.4rem"></pngx-logo>
</svg> <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">
<div class="ms-2 ms-md-3 d-inline-block" [class.d-md-none]="slimSidebarEnabled"> <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"/>
@if (customAppTitle?.length) { </svg>
<div class="d-flex flex-column align-items-start custom-title"> } @else {
<span class="title">{{customAppTitle}}</span> @if (customAppLogo) {
<span class="byline text-uppercase font-monospace" i18n>by Paperless-ngx</span> <img class="brand-logo" [src]="customAppLogo" alt="" />
</div>
} @else { } @else {
Paperless-ngx <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> <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> </a>
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1" <div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 me-sm-auto order-3 order-sm-1"
[class.mobile-hidden]="mobileSearchHidden()"> [class.mobile-hidden]="mobileSearchHidden()">
<div class="col-12 col-md-7"> <div class="col-12 header-search mx-auto">
<pngx-global-search></pngx-global-search> <pngx-global-search></pngx-global-search>
</div> </div>
</div> </div>
@@ -34,7 +41,7 @@
} }
<pngx-toasts-dropdown></pngx-toasts-dropdown> <pngx-toasts-dropdown></pngx-toasts-dropdown>
<li ngbDropdown class="nav-item dropdown"> <li ngbDropdown class="nav-item dropdown">
<button class="btn ps-1 border-0" id="userDropdown" ngbDropdownToggle> <button class="btn navbar-action border-0" id="userDropdown" ngbDropdownToggle aria-label="User menu" i18n-aria-label>
<i-bs width="1.3em" height="1.3em" name="person-circle"></i-bs> <i-bs width="1.3em" height="1.3em" name="person-circle"></i-bs>
<span class="small ms-2 d-none d-sm-inline"> <span class="small ms-2 d-none d-sm-inline">
{{this.settingsService.displayName}} {{this.settingsService.displayName}}
@@ -71,7 +78,7 @@
[ngClass]="slimSidebarEnabled ? 'slim' : 'col-md-3 col-lg-2 col-xxxl-1'" [class.animating]="slimSidebarAnimating()" [ngClass]="slimSidebarEnabled ? 'slim' : 'col-md-3 col-lg-2 col-xxxl-1'" [class.animating]="slimSidebarAnimating()"
[ngbCollapse]="isMenuCollapsed()"> [ngbCollapse]="isMenuCollapsed()">
@if (canSaveSettings) { @if (canSaveSettings) {
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()"> <button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()" [aria-label]="slimSidebarEnabled ? 'Expand sidebar' : 'Collapse sidebar'" i18n-aria-label>
@if (slimSidebarEnabled) { @if (slimSidebarEnabled) {
<i-bs width="0.9em" height="0.9em" name="chevron-double-right"></i-bs> <i-bs width="0.9em" height="0.9em" name="chevron-double-right"></i-bs>
} @else { } @else {
@@ -79,18 +86,20 @@
} }
</button> </button>
} }
<div class="sidebar-sticky pt-3 d-flex flex-column justify-space-around"> <div class="sidebar-sticky pt-3 pb-1 d-flex flex-column justify-space-around">
<ul class="nav flex-column"> <ul class="nav flex-column">
<li class="nav-item app-link"> <li class="nav-item app-link">
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="house"></i-bs><span><ng-container i18n>Dashboard</ng-container></span> <i-bs class="me-2" name="house"></i-bs><span><ng-container i18n>Dashboard</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<a class="nav-link" routerLink="documents" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="documents" routerLinkActive="active"
ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" [routerLinkActiveOptions]="{ paths: 'exact', queryParams: 'ignored', matrixParams: 'ignored', fragment: 'ignored' }"
(click)="closeMenu()"
ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="files"></i-bs><span><ng-container i18n>Documents</ng-container></span> <i-bs class="me-2" name="files"></i-bs><span><ng-container i18n>Documents</ng-container></span>
</a> </a>
@@ -109,7 +118,7 @@
(cdkDragEnded)="onDragEnd($event)"> (cdkDragEnded)="onDragEnd($event)">
<a class="nav-link" routerLink="view/{{view.id}}" <a class="nav-link" routerLink="view/{{view.id}}"
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name" routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name"
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
popoverClass="popover-slim"> popoverClass="popover-slim">
<i-bs class="me-2" [name]="view.icon || 'funnel'"></i-bs><span><div class="d-inline-flex view-name"><span class="overflow-hidden" [class.text-wrap]="!slimSidebarEnabled">{{view.name}}</span></div> <i-bs class="me-2" [name]="view.icon || 'funnel'"></i-bs><span><div class="d-inline-flex view-name"><span class="overflow-hidden" [class.text-wrap]="!slimSidebarEnabled">{{view.name}}</span></div>
@if (showSidebarCounts && !slimSidebarEnabled) { @if (showSidebarCounts && !slimSidebarEnabled) {
@@ -147,7 +156,7 @@
<li class="nav-item w-100 app-link"> <li class="nav-item w-100 app-link">
<a class="nav-link app-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="documents/{{d.id}}" <a class="nav-link app-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="documents/{{d.id}}"
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="d.title | documentTitle" routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="d.title | documentTitle"
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
popoverClass="popover-slim"> popoverClass="popover-slim">
<i-bs class="me-2" name="file-text"></i-bs><span>{{d.title | documentTitle}}</span> <i-bs class="me-2" name="file-text"></i-bs><span>{{d.title | documentTitle}}</span>
<span class="close flex-column justify-content-center" <span class="close flex-column justify-content-center"
@@ -159,11 +168,12 @@
} }
@if (openDocuments.length >= 1) { @if (openDocuments.length >= 1) {
<li class="nav-item w-100 app-link"> <li class="nav-item w-100 app-link">
<a class="nav-link app-link" [class.text-truncate]="!slimSidebarEnabled" [routerLink]="[]" (click)="closeAll()" <button type="button" class="nav-link nav-link-action app-link w-100 text-start"
ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" [class.text-truncate]="!slimSidebarEnabled" (click)="closeAll()"
ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="x"></i-bs><span><ng-container i18n>Close all</ng-container></span> <i-bs class="me-2" name="x"></i-bs><span><ng-container i18n>Close all</ng-container></span>
</a> </button>
</li> </li>
} }
</ul> </ul>
@@ -177,10 +187,11 @@
@if (canManageAttributes) { @if (canManageAttributes) {
<li class="nav-item app-link" tourAnchor="tour.tags"> <li class="nav-item app-link" tourAnchor="tour.tags">
<div class="d-flex align-items-center attributes-row"> <div class="d-flex align-items-center attributes-row">
<a class="nav-link flex-fill" routerLink="attributes" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link flex-fill" routerLink="attributes" routerLinkActive="active"
ngbPopover="Attributes" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" [routerLinkActiveOptions]="{ exact: !(slimSidebarEnabled || attributesSectionsCollapsed) }" (click)="closeMenu()"
ngbPopover="Attributes" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="stack"></i-bs><span><ng-container i18n>Attributes</ng-container></span> <i-bs name="stack"></i-bs><span class="ms-2"><ng-container i18n>Attributes</ng-container></span>
</a> </a>
@if (!slimSidebarEnabled && canSaveSettings) { @if (!slimSidebarEnabled && canSaveSettings) {
<button <button
@@ -230,7 +241,7 @@
} }
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
<a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="window-stack"></i-bs><span><ng-container i18n>Saved Views</ng-container></span> <i-bs class="me-2" name="window-stack"></i-bs><span><ng-container i18n>Saved Views</ng-container></span>
</a> </a>
@@ -239,7 +250,7 @@
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
tourAnchor="tour.workflows"> tourAnchor="tour.workflows">
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="boxes"></i-bs><span><ng-container i18n>Workflows</ng-container></span> <i-bs class="me-2" name="boxes"></i-bs><span><ng-container i18n>Workflows</ng-container></span>
</a> </a>
@@ -247,14 +258,14 @@
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }" <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
tourAnchor="tour.mail"> tourAnchor="tour.mail">
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail" <a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim"> triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="envelope"></i-bs><span><ng-container i18n>Mail</ng-container></span> <i-bs class="me-2" name="envelope"></i-bs><span><ng-container i18n>Mail</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
<a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash" <a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim"> triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="trash"></i-bs><span><ng-container i18n>Trash</ng-container></span> <i-bs class="me-2" name="trash"></i-bs><span><ng-container i18n>Trash</ng-container></span>
</a> </a>
@@ -270,21 +281,21 @@
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }" <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }"
tourAnchor="tour.settings"> tourAnchor="tour.settings">
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="gear"></i-bs><span><ng-container i18n>Settings</ng-container></span> <i-bs class="me-2" name="gear"></i-bs><span><ng-container i18n>Settings</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }">
<a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="sliders2-vertical"></i-bs><span><ng-container i18n>Configuration</ng-container></span> <i-bs class="me-2" name="sliders2-vertical"></i-bs><span><ng-container i18n>Configuration</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="people"></i-bs><span><ng-container i18n>Users & Groups</ng-container></span> <i-bs class="me-2" name="people"></i-bs><span><ng-container i18n>Users & Groups</ng-container></span>
</a> </a>
@@ -293,7 +304,7 @@
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }"
tourAnchor="tour.file-tasks"> tourAnchor="tour.file-tasks">
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="list-task"></i-bs><span><ng-container i18n>Tasks</ng-container>@if (tasksService.needsAttentionTasks.length > 0) { <i-bs class="me-2" name="list-task"></i-bs><span><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><span class="badge bg-danger ms-2 d-inline">{{tasksService.needsAttentionTasks.length}}</span></span>
@@ -306,26 +317,26 @@
@if (permissionsService.isAdmin()) { @if (permissionsService.isAdmin()) {
<li class="nav-item app-link"> <li class="nav-item app-link">
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs" <a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim"> triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="text-left"></i-bs><span><ng-container i18n>Logs</ng-container></span> <i-bs class="me-2" name="text-left"></i-bs><span><ng-container i18n>Logs</ng-container></span>
</a> </a>
</li> </li>
} }
<li class="nav-item mt-2" tourAnchor="tour.outro"> <li class="nav-item mt-2" tourAnchor="tour.outro">
<a class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap text-decoration-none" <a class="text-muted small d-flex align-items-center flex-wrap text-decoration-none nav-anchor"
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation" target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim"> triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="d-flex me-2" name="question-circle"></i-bs><span><ng-container i18n>Documentation</ng-container></span> <i-bs class="d-flex me-2" name="question-circle"></i-bs><span><ng-container i18n>Documentation</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled"> <li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
<div class="px-3 py-0 text-muted small d-flex align-items-center flex-wrap"> <div class="text-muted small d-flex align-items-center flex-wrap nav-label">
<div class="me-3"> <div class="me-3">
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer" <a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer"
href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim"> triggers="mouseenter:mouseleave" popoverClass="popover-slim">
{{ versionString }} {{ versionString }}
</a> </a>
@@ -363,7 +374,7 @@
</a> </a>
} }
} @else { } @else {
<a *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }" class="small text-decoration-none" routerLink="/settings" fragment="update-checking" <a *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }" class="small text-decoration-none" routerLink="/settings" fragment="update-checking" aria-label="Configure update checking" i18n-aria-label
[ngbPopover]="updateCheckingNotEnabledPopContent" popoverClass="shadow" triggers="mouseenter" [ngbPopover]="updateCheckingNotEnabledPopContent" popoverClass="shadow" triggers="mouseenter"
container="body"> container="body">
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs> <i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
@@ -7,8 +7,8 @@
bottom: 0; bottom: 0;
left: 0; left: 0;
z-index: 995; /* Behind the navbar */ z-index: 995; /* Behind the navbar */
padding: 50px 0 0; /* Height of navbar */ padding: 64px 0 0; /* Height of navbar */
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1); border-right: 1px solid color-mix(in srgb, var(--bs-border-color) 65%, transparent);
overflow-y: auto; overflow-y: auto;
--pngx-sidebar-width: 100%; --pngx-sidebar-width: 100%;
max-width: var(--pngx-sidebar-width); max-width: var(--pngx-sidebar-width);
@@ -25,7 +25,7 @@
} }
.view-name { .view-name {
max-width: calc(100% - 50px) max-width: calc(100% - 55px)
} }
.nav-group:not(:has(.app-link)) .sidebar-heading { .nav-group:not(:has(.app-link)) .sidebar-heading {
@@ -47,7 +47,7 @@
} }
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
.sidebar { .sidebar {
top: 3.5rem; top: 4rem;
} }
.search-container { .search-container {
@@ -67,16 +67,35 @@
main.mobile-search-hidden { main.mobile-search-hidden {
padding-top: 56px; padding-top: 56px;
} }
.navbar-toggler {
padding-left: calc(12px - (1.5em * 2.5 / 16));
}
}
.search-container {
padding-left: 1rem;
padding-right: 1rem;
}
// Below sm the search gets its own full-width row, so line it up with main's content edge
@media (max-width: 575.98px) {
.search-container {
padding-left: 12px;
padding-right: 12px;
}
} }
main { main {
transition: all .2s ease; transition: all .2s ease;
padding-top: 110px; padding-top: 118px;
background: var(--pngx-bg-darker);
min-height: 100vh;
} }
@media (min-width: 768px) { @media (min-width: 768px) {
main { main {
padding-top: 56px; padding-top: 64px;
} }
} }
@@ -91,11 +110,11 @@ main {
@media(min-width: 768px) { @media(min-width: 768px) {
.sidebar.slim { .sidebar.slim {
max-width: 50px; max-width: 55px;
li.nav-item span.badge { li.nav-item span.badge {
display: inline-block; display: inline-block;
margin-right: 2px; margin-right: -4px;
} }
} }
@@ -139,18 +158,11 @@ main {
} }
} }
.sidebar.slim {
li.nav-item span.badge {
display: inline-block;
margin-right: 2px;
}
}
.sidebar-slim-toggler { .sidebar-slim-toggler {
display: block; display: block;
position: fixed; position: fixed;
left: calc(var(--pngx-sidebar-width) - 12px); left: calc(var(--pngx-sidebar-width) - 12px);
top: 60px; bottom: 16px;
z-index: 996; z-index: 996;
--bs-btn-padding-x: 0.35rem; --bs-btn-padding-x: 0.35rem;
--bs-btn-padding-y: 0.125rem; --bs-btn-padding-y: 0.125rem;
@@ -158,7 +170,7 @@ main {
} }
.sidebar.slim .sidebar-slim-toggler { .sidebar.slim .sidebar-slim-toggler {
--pngx-sidebar-width: 50px !important; --pngx-sidebar-width: 56px !important;
} }
} }
@@ -171,7 +183,7 @@ main {
position: relative; position: relative;
top: 0; top: 0;
height: 100%; height: 100%;
padding-top: 0.5rem; padding: .75rem .5rem 1rem;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */ overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */
min-height: min-content; min-height: min-content;
@@ -186,11 +198,14 @@ main {
.sidebar .nav-link { .sidebar .nav-link {
font-weight: 500; font-weight: 500;
white-space: nowrap; white-space: nowrap;
border-left: 2px solid transparent; border-radius: .55rem;
transition: color .15s ease-in-out; margin: 1px 0;
padding: .55rem .7rem;
transition: color .15s ease-in-out, background-color .15s ease-in-out;
&:hover, &.active, &:focus { &:hover, &:focus {
color: var(--bs-primary); color: var(--bs-primary);
background-color: color-mix(in srgb, var(--bs-primary) 8%, transparent);
} }
&:focus-visible { &:focus-visible {
@@ -199,8 +214,21 @@ main {
} }
&.active { &.active {
font-weight: bold; font-weight: 600;
border-left-color: var(--bs-primary); color: var(--bs-primary);
background-color: color-mix(in srgb, var(--bs-primary) 13%, transparent);
}
&.nav-link-action {
color: var(--bs-secondary-color);
font-weight: 400;
background-color: transparent;
&:hover,
&:focus {
color: var(--bs-primary);
background-color: transparent;
}
} }
i-bs { i-bs {
@@ -209,20 +237,49 @@ main {
} }
} }
// sub-page gets marker only .sidebar .nav-anchor, .sidebar .nav-label {
.nav-item:has(.attributes-submenu.show .nav-link.active) > .attributes-row > .nav-link.active { padding: .25rem .7rem;
border-left-color: transparent;
} }
// bring sub-menu markers back out to L edge .attributes-row {
.attributes-submenu .nav-link { border-radius: .55rem;
margin-left: -1rem; margin: .1rem 0;
padding-left: calc(var(--bs-nav-link-padding-x) + 1rem); transition: color .15s ease-in-out, background-color .15s ease-in-out;
> .nav-link {
margin: 0;
&:hover,
&:focus,
&.active {
background-color: transparent;
}
}
&:hover,
&:has(> .nav-link:focus-visible) {
background-color: color-mix(in srgb, var(--bs-primary) 8%, transparent);
}
&:has(> .nav-link.active) {
background-color: color-mix(in srgb, var(--bs-primary) 13%, transparent);
}
} }
.attributes-row .attributes-expand-btn { .attributes-row .attributes-expand-btn {
opacity: 0.2; opacity: 0.2;
width: 1.75rem;
height: 1.75rem;
margin-right: .35rem !important;
border-radius: 50%;
box-shadow: none !important;
transition: opacity 0.15s ease-in-out; transition: opacity 0.15s ease-in-out;
&:focus-visible {
outline: 2px solid color-mix(in srgb, var(--bs-primary) 55%, transparent);
outline-offset: 1px;
opacity: 1;
}
} }
.attributes-row:hover .attributes-expand-btn { .attributes-row:hover .attributes-expand-btn {
@@ -230,8 +287,10 @@ main {
} }
.sidebar-heading { .sidebar-heading {
font-size: 0.75rem; font-size: 0.68rem;
text-transform: uppercase; text-transform: uppercase;
font-weight: 700;
opacity: .75;
} }
.nav { .nav {
@@ -288,16 +347,118 @@ main {
*/ */
.navbar-brand { .navbar-brand {
font-size: 1rem; --pngx-navbar-brand-shadow-rgb: 0, 0, 0;
font-size: 1.0625rem;
min-height: 64px;
letter-spacing: -0.015em;
.flex-column { &:hover,
padding: 0.15rem 0; &:focus-visible {
::ng-deep .navbar-official-logo,
.brand-mark,
.brand-logo {
filter: drop-shadow(0 2px 3px rgba(var(--pngx-navbar-brand-shadow-rgb), .5));
}
}
::ng-deep .navbar-official-logo {
filter: drop-shadow(0 1px 2px rgba(var(--pngx-navbar-brand-shadow-rgb), .3));
transition: filter .15s ease-in-out;
@media screen and (max-width: 575.98px) {
max-height: 2rem;
}
}
.brand-mark {
width: 1.65rem;
height: 1.65rem;
flex: 0 0 auto;
transition: filter .15s ease-in-out;
}
.brand-copy {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
line-height: 1.1;
transition: transform .15s ease-in-out;
}
.brand-title {
font-weight: 600;
max-width: 100%;
min-width: 0;
} }
.byline { .byline {
font-size: 0.5rem; margin-top: .15rem;
letter-spacing: 0.1rem; font-size: .5rem;
font-weight: 500;
letter-spacing: .1rem;
opacity: .8;
} }
.brand-logo {
width: auto;
height: 2.75rem;
max-width: 5rem;
flex: 0 0 auto;
object-fit: contain;
transition: filter .15s ease-in-out, transform .15s ease-in-out;
}
}
:host-context(.primary-light) .navbar-brand {
--pngx-navbar-brand-shadow-rgb: 255, 255, 255; // Light app color, use white shadow for dark text
}
:host ::ng-deep .navbar-official-logo {
.leaf {
fill: color-mix(in srgb, var(--pngx-primary-text-contrast) 70%, var(--bs-primary)) !important;
}
.text {
fill: var(--pngx-primary-text-contrast) !important;
}
}
.navbar {
min-height: 64px;
box-shadow: 0 1px 0 rgba(0, 0, 0, .12), 0 4px 18px rgba(0, 0, 0, .08) !important;
}
.navbar > ul {
align-items: center;
gap: .125rem;
padding-right: .5rem;
}
:host ::ng-deep .navbar-action {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.5rem;
min-height: 2.5rem;
padding: .45rem .55rem;
border-radius: .6rem;
transition: background-color .15s ease-in-out, opacity .15s ease-in-out;
&:hover,
&:focus-visible {
background-color: rgba(0, 0, 0, .14);
}
}
#userDropdown {
padding-left: .75rem;
}
.header-search {
width: 100%;
max-width: 55rem;
} }
@media screen and (max-width: 575.98px) { @media screen and (max-width: 575.98px) {
@@ -311,6 +472,7 @@ main {
.navbar-toggler { .navbar-toggler {
grid-area: toggler; grid-area: toggler;
color: var(--pngx-primary-text-contrast);
text-align: left; text-align: left;
} }
@@ -346,18 +508,44 @@ main {
grid-area: actions; grid-area: actions;
justify-self: end; justify-self: end;
flex-wrap: nowrap; flex-wrap: nowrap;
gap: 0;
padding-right: .25rem;
}
:host ::ng-deep .navbar-action {
min-width: 2.25rem;
padding-right: .4rem;
padding-left: .4rem;
}
#userDropdown {
padding-right: .35rem;
padding-left: .45rem;
} }
} }
@media screen and (min-width: 768px) { @media screen and (min-width: 768px) {
.navbar-brand.slim { .navbar-brand.slim {
max-width: 50px; max-width: 55px;
.brand-logo {
width: 1.65rem;
max-width: 1.65rem;
}
.brand-mark-slim {
display: block !important;
}
}
:host ::ng-deep .navbar-brand.slim .navbar-official-logo {
display: none;
} }
} }
:host ::ng-deep .dropdown.show .dropdown-toggle, :host ::ng-deep .dropdown.show .dropdown-toggle,
:host ::ng-deep .dropdown-toggle:hover { :host ::ng-deep .dropdown-toggle:hover {
opacity: 0.7; opacity: 1;
} }
.dropdown-toggle::after { .dropdown-toggle::after {
@@ -45,6 +45,7 @@ import { TasksService } from 'src/app/services/tasks.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { ChatComponent } from '../chat/chat/chat.component' import { ChatComponent } from '../chat/chat/chat.component'
import { LogoComponent } from '../common/logo/logo.component'
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component' import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
import { DocumentDetailComponent } from '../document-detail/document-detail.component' import { DocumentDetailComponent } from '../document-detail/document-detail.component'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component' import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
@@ -59,6 +60,7 @@ const SCROLL_THRESHOLD = 16
styleUrls: ['./app-frame.component.scss'], styleUrls: ['./app-frame.component.scss'],
imports: [ imports: [
GlobalSearchComponent, GlobalSearchComponent,
LogoComponent,
DocumentTitlePipe, DocumentTitlePipe,
IfPermissionsDirective, IfPermissionsDirective,
ToastsDropdownComponent, ToastsDropdownComponent,
@@ -190,11 +192,34 @@ export class AppFrameComponent
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}` return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
} }
get appTitle(): string {
this.settingsService.trackChanges()
return (
this.settingsService.get(SETTINGS_KEYS.APP_TITLE) || environment.appTitle
)
}
get customAppTitle(): string { get customAppTitle(): string {
this.settingsService.trackChanges() this.settingsService.trackChanges()
return this.settingsService.get(SETTINGS_KEYS.APP_TITLE) return this.settingsService.get(SETTINGS_KEYS.APP_TITLE)
} }
get hasCustomBranding(): boolean {
this.settingsService.trackChanges()
return !!(
this.settingsService.get(SETTINGS_KEYS.APP_TITLE)?.length ||
this.settingsService.get(SETTINGS_KEYS.APP_LOGO)?.length
)
}
get customAppLogo(): string {
this.settingsService.trackChanges()
const logo = this.settingsService.get(SETTINGS_KEYS.APP_LOGO)
return logo?.length
? environment.apiBaseUrl.replace(/\/api\/$/, logo)
: null
}
get canSaveSettings(): boolean { get canSaveSettings(): boolean {
return ( return (
this.permissionsService.currentUserCan( this.permissionsService.currentUserCan(
@@ -253,6 +278,10 @@ export class AppFrameComponent
}) })
} }
get slimSidebarPopoversEnabled(): boolean {
return this.slimSidebarEnabled && !this.isMobileViewport()
}
get attributesSectionsCollapsed(): boolean { get attributesSectionsCollapsed(): boolean {
this.settingsService.trackChanges() this.settingsService.trackChanges()
return this.settingsService return this.settingsService
@@ -4,12 +4,12 @@ form {
> i-bs[name="search"] { > i-bs[name="search"] {
position: absolute; position: absolute;
left: 0.6rem; left: 0.6rem;
top: .35rem; top: .25rem;
color: rgba(255, 255, 255, 0.6); color: rgba(255, 255, 255, 0.6);
@media screen and (min-width: 768px) { @media screen and (min-width: 768px) {
// adjust for smaller font size on non-mobile // adjust for smaller font size on non-mobile
top: 0.25rem; top: .15rem;
} }
} }
@@ -37,8 +37,9 @@ form {
} }
.form-control { .form-control {
color: rgba(255, 255, 255, 0.3); min-height: 2.25rem;
background-color: rgba(0, 0, 0, 0.15); color: rgba(255, 255, 255, 0.55);
background-color: rgba(0, 0, 0, 0.16);
padding-left: 1.8rem; padding-left: 1.8rem;
border-color: rgba(255, 255, 255, 0.2); border-color: rgba(255, 255, 255, 0.2);
transition: all .3s ease, padding-left 0s ease, background-color 0s ease; // Safari requires all transition: all .3s ease, padding-left 0s ease, background-color 0s ease; // Safari requires all
@@ -52,7 +53,7 @@ form {
} }
&:focus-within { &:focus-within {
background-color: rgba(0, 0, 0, 0.3); background-color: rgba(0, 0, 0, 0.26);
color: var(--bs-light); color: var(--bs-light);
flex-grow: 1; flex-grow: 1;
padding-left: 0.5rem; padding-left: 0.5rem;
@@ -1,9 +1,9 @@
<li ngbDropdown class="nav-item mx-1" (openChange)="onOpenChange($event)"> <li ngbDropdown class="nav-item position-relative" (openChange)="onOpenChange($event)">
@if (toasts().length) { @if (toasts().length) {
<span class="badge rounded-pill z-3 pe-none bg-secondary me-2 position-absolute top-0 left-0">{{ toasts().length }}</span> <span class="notification-count badge rounded-pill z-3 pe-none bg-secondary position-absolute">{{ toasts().length }}</span>
} }
<button class="btn border-0" id="notificationsDropdown" ngbDropdownToggle> <button class="btn navbar-action border-0" id="notificationsDropdown" ngbDropdownToggle aria-label="Notifications" i18n-aria-label>
<i-bs width="1.3em" height="1.3em" name="bell"></i-bs> <i-bs width="1.3em" height="1.3em" name="bell"></i-bs>
</button> </button>
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="notificationsDropdown"> <div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="notificationsDropdown">
@@ -11,6 +11,16 @@
display: none; display: none;
} }
.notification-count {
top: -.2rem;
right: -.2rem;
min-width: 1.15rem;
height: 1.15rem;
padding: .18rem .32rem;
font-size: .68rem;
line-height: 1;
}
.dropdown-item { .dropdown-item {
white-space: initial; white-space: initial;
} }
@@ -1,6 +1,6 @@
<li ngbDropdown class="nav-item me-n2" (openChange)="onOpenChange($event)"> <li ngbDropdown class="nav-item" (openChange)="onOpenChange($event)">
<button class="btn border-0" id="chatDropdown" ngbDropdownToggle> <button class="btn navbar-action border-0" id="chatDropdown" ngbDropdownToggle aria-label="Chat" i18n-aria-label>
<i-bs width="1.3em" height="1.3em" name="chatSquareDots"></i-bs> <i-bs width="1.3em" height="1.3em" name="chatSquareDots"></i-bs>
</button> </button>
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown"> <div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
@@ -0,0 +1,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
}
}
@@ -6,7 +6,7 @@
<div class="modal-body"> <div class="modal-body">
<div class="row"> <div class="row">
<div class="col-2 d-flex justify-content-end"> <div class="col-2 d-flex justify-content-end">
<button class="btn btn-secondary mt-auto" (click)="rotate(false)"> <button class="btn btn-secondary mt-auto" (click)="rotate(false)" aria-label="Rotate counterclockwise" i18n-aria-label>
<i-bs name="arrow-counterclockwise"></i-bs> <i-bs name="arrow-counterclockwise"></i-bs>
</button> </button>
</div> </div>
@@ -16,7 +16,7 @@
} }
</div> </div>
<div class="col-2 d-flex"> <div class="col-2 d-flex">
<button class="btn btn-secondary mt-auto" (click)="rotate()"> <button class="btn btn-secondary mt-auto" (click)="rotate()" aria-label="Rotate clockwise" i18n-aria-label>
<i-bs name="arrow-clockwise"></i-bs> <i-bs name="arrow-clockwise"></i-bs>
</button> </button>
</div> </div>
@@ -1,5 +1,5 @@
<div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose($event)" [popperOptions]="popperOptions"> <div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose($event)" [popperOptions]="popperOptions">
<button type="button" class="btn btn-sm btn-outline-primary" id="customFieldsDropdown" [disabled]="disabled" ngbDropdownToggle> <button type="button" class="btn btn-sm btn-outline-primary" id="customFieldsDropdown" [disabled]="disabled" ngbDropdownToggle aria-label="Custom Fields" i18n-aria-label>
<i-bs name="ui-radios"></i-bs><div class="d-none d-lg-inline ms-1"><ng-container i18n>Custom Fields</ng-container></div> <i-bs name="ui-radios"></i-bs><div class="d-none d-lg-inline ms-1"><ng-container i18n>Custom Fields</ng-container></div>
</button> </button>
<div ngbDropdownMenu aria-labelledby="customFieldsDropdown" class="shadow custom-fields-dropdown"> <div ngbDropdownMenu aria-labelledby="customFieldsDropdown" class="shadow custom-fields-dropdown">
@@ -1,6 +1,6 @@
@if (useDropdown) { @if (useDropdown) {
<div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions"> <div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions">
<button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled"> <button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled" [aria-label]="title">
<i-bs name="{{icon}}"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div> <i-bs name="{{icon}}"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
@if (isActive) { @if (isActive) {
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge> <pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge>
@@ -38,7 +38,7 @@
ngbDatepicker ngbDatepicker
#d="ngbDatepicker" #d="ngbDatepicker"
[footerTemplate]="datePickerFooterTemplate" /> [footerTemplate]="datePickerFooterTemplate" />
<button class="btn btn-sm btn-outline-secondary rounded-end" (click)="d.toggle()" type="button"> <button class="btn btn-sm btn-outline-secondary rounded-end" (click)="d.toggle()" type="button" aria-label="Open date picker" i18n-aria-label>
<i-bs name="calendar-event"></i-bs> <i-bs name="calendar-event"></i-bs>
</button> </button>
<ng-template #datePickerFooterTemplate> <ng-template #datePickerFooterTemplate>
@@ -143,7 +143,7 @@
<input class="w-25 form-control rounded-end" type="text" [(ngModel)]="atom.value" [disabled]="disabled"> <input class="w-25 form-control rounded-end" type="text" [(ngModel)]="atom.value" [disabled]="disabled">
} }
} }
<button class="btn btn-link btn-sm text-danger pe-0" type="button" (click)="removeElement(atom)" [disabled]="disabled"> <button class="btn btn-link btn-sm text-danger pe-0" type="button" (click)="removeElement(atom)" [disabled]="disabled" aria-label="Remove query" i18n-aria-label>
<i-bs name="x-circle"></i-bs> <i-bs name="x-circle"></i-bs>
</button> </button>
</div> </div>
@@ -185,7 +185,7 @@
<i-bs name="braces"></i-bs> <i-bs name="braces"></i-bs>
</button> </button>
@if (expression.depth > 0) { @if (expression.depth > 0) {
<button type="button" class="btn btn-sm btn-outline-secondary text-danger" (click)="removeElement(expression)" [disabled]="disabled"> <button type="button" class="btn btn-sm btn-outline-secondary text-danger" (click)="removeElement(expression)" [disabled]="disabled" aria-label="Remove expression" i18n-aria-label>
<i-bs name="x-circle"></i-bs> <i-bs name="x-circle"></i-bs>
</button> </button>
} }
@@ -1,5 +1,5 @@
<div class="btn-group w-100" ngbDropdown role="group" [popperOptions]="popperOptions" [placement]="placement"> <div class="btn-group w-100" ngbDropdown role="group" [popperOptions]="popperOptions" [placement]="placement">
<button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="createdDateTo || createdDateFrom ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled"> <button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="createdDateTo || createdDateFrom ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled" [aria-label]="title">
<i-bs width="1em" height="1em" name="calendar-event-fill"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div> <i-bs width="1em" height="1em" name="calendar-event-fill"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span> <pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
</button> </button>
@@ -9,7 +9,7 @@
<div class="list-group-item d-flex p-2 select-item" role="menuitem"> <div class="list-group-item d-flex p-2 select-item" role="menuitem">
<div class="selected-icon"> <div class="selected-icon">
@if (createdRelativeDate) { @if (createdRelativeDate) {
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedRelativeDate()"> <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedRelativeDate()" aria-label="Clear created relative date" i18n-aria-label>
<i-bs width="1em" height="1em" name="check" class="variant-unfocused text-dark"></i-bs> <i-bs width="1em" height="1em" name="check" class="variant-unfocused text-dark"></i-bs>
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs> <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
</a> </a>
@@ -33,7 +33,7 @@
<div class="list-group-item d-flex p-2" role="menuitem"> <div class="list-group-item d-flex p-2" role="menuitem">
<div class="selected-icon"> <div class="selected-icon">
@if (createdDateFrom) { @if (createdDateFrom) {
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedFrom()"> <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedFrom()" aria-label="Clear created from date" i18n-aria-label>
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs> <i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs> <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
</a> </a>
@@ -43,7 +43,7 @@
<span class="input-group-text w-25 small text-muted" i18n>From</span> <span class="input-group-text w-25 small text-muted" i18n>From</span>
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)" <input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
maxlength="10" [(ngModel)]="createdDateFrom" ngbDatepicker #createdDateFromPicker="ngbDatepicker" [footerTemplate]="createdFromFooterTemplate"> maxlength="10" [(ngModel)]="createdDateFrom" ngbDatepicker #createdDateFromPicker="ngbDatepicker" [footerTemplate]="createdFromFooterTemplate">
<button class="btn btn-outline-secondary" (click)="createdDateFromPicker.toggle()" type="button"> <button class="btn btn-outline-secondary" (click)="createdDateFromPicker.toggle()" type="button" aria-label="Open created from date picker" i18n-aria-label>
<i-bs width="1em" height="1em" name="calendar"></i-bs> <i-bs width="1em" height="1em" name="calendar"></i-bs>
</button> </button>
<ng-template #createdFromFooterTemplate> <ng-template #createdFromFooterTemplate>
@@ -57,7 +57,7 @@
<div class="list-group-item d-flex p-2" role="menuitem"> <div class="list-group-item d-flex p-2" role="menuitem">
<div class="selected-icon"> <div class="selected-icon">
@if (createdDateTo) { @if (createdDateTo) {
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedTo()"> <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedTo()" aria-label="Clear created to date" i18n-aria-label>
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs> <i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs> <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
</a> </a>
@@ -67,7 +67,7 @@
<span class="input-group-text w-25 small text-muted" i18n>To</span> <span class="input-group-text w-25 small text-muted" i18n>To</span>
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)" <input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
maxlength="10" [(ngModel)]="createdDateTo" ngbDatepicker #createdDateToPicker="ngbDatepicker" [footerTemplate]="createdToFooterTemplate"> maxlength="10" [(ngModel)]="createdDateTo" ngbDatepicker #createdDateToPicker="ngbDatepicker" [footerTemplate]="createdToFooterTemplate">
<button class="btn btn-outline-secondary" (click)="createdDateToPicker.toggle()" type="button"> <button class="btn btn-outline-secondary" (click)="createdDateToPicker.toggle()" type="button" aria-label="Open created to date picker" i18n-aria-label>
<i-bs width="1em" height="1em" name="calendar"></i-bs> <i-bs width="1em" height="1em" name="calendar"></i-bs>
</button> </button>
<ng-template #createdToFooterTemplate> <ng-template #createdToFooterTemplate>
@@ -85,7 +85,7 @@
<div class="list-group-item d-flex p-2 select-item" role="menuitem"> <div class="list-group-item d-flex p-2 select-item" role="menuitem">
<div class="selected-icon"> <div class="selected-icon">
@if (addedRelativeDate) { @if (addedRelativeDate) {
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedRelativeDate()"> <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedRelativeDate()" aria-label="Clear added relative date" i18n-aria-label>
<i-bs width="1em" height="1em" name="check" class="variant-unfocused text-dark"></i-bs> <i-bs width="1em" height="1em" name="check" class="variant-unfocused text-dark"></i-bs>
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs> <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
</a> </a>
@@ -109,7 +109,7 @@
<div class="list-group-item d-flex p-2" role="menuitem"> <div class="list-group-item d-flex p-2" role="menuitem">
<div class="selected-icon"> <div class="selected-icon">
@if (addedDateFrom) { @if (addedDateFrom) {
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedFrom()"> <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedFrom()" aria-label="Clear added from date" i18n-aria-label>
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs> <i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs> <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
</a> </a>
@@ -119,7 +119,7 @@
<span class="input-group-text w-25 small text-muted" i18n>From</span> <span class="input-group-text w-25 small text-muted" i18n>From</span>
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)" <input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
maxlength="10" [(ngModel)]="addedDateFrom" ngbDatepicker #addedDateFromPicker="ngbDatepicker" [footerTemplate]="addedFromFooterTemplate"> maxlength="10" [(ngModel)]="addedDateFrom" ngbDatepicker #addedDateFromPicker="ngbDatepicker" [footerTemplate]="addedFromFooterTemplate">
<button class="btn btn-outline-secondary" (click)="addedDateFromPicker.toggle()" type="button"> <button class="btn btn-outline-secondary" (click)="addedDateFromPicker.toggle()" type="button" aria-label="Open added from date picker" i18n-aria-label>
<i-bs width="1em" height="1em" name="calendar"></i-bs> <i-bs width="1em" height="1em" name="calendar"></i-bs>
</button> </button>
<ng-template #addedFromFooterTemplate> <ng-template #addedFromFooterTemplate>
@@ -133,7 +133,7 @@
<div class="list-group-item d-flex p-2" role="menuitem"> <div class="list-group-item d-flex p-2" role="menuitem">
<div class="selected-icon"> <div class="selected-icon">
@if (addedDateTo) { @if (addedDateTo) {
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedTo()"> <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedTo()" aria-label="Clear added to date" i18n-aria-label>
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs> <i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs> <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
</a> </a>
@@ -143,7 +143,7 @@
<span class="input-group-text w-25 small text-muted" i18n>To</span> <span class="input-group-text w-25 small text-muted" i18n>To</span>
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)" <input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
maxlength="10" [(ngModel)]="addedDateTo" ngbDatepicker #addedDateToPicker="ngbDatepicker" [footerTemplate]="addedToFooterTemplate"> maxlength="10" [(ngModel)]="addedDateTo" ngbDatepicker #addedDateToPicker="ngbDatepicker" [footerTemplate]="addedToFooterTemplate">
<button class="btn btn-outline-secondary" (click)="addedDateToPicker.toggle()" type="button"> <button class="btn btn-outline-secondary" (click)="addedDateToPicker.toggle()" type="button" aria-label="Open added to date picker" i18n-aria-label>
<i-bs width="1em" height="1em" name="calendar"></i-bs> <i-bs width="1em" height="1em" name="calendar"></i-bs>
</button> </button>
<ng-template #addedToFooterTemplate> <ng-template #addedToFooterTemplate>
@@ -455,6 +455,13 @@
</div> </div>
</div> </div>
} }
@case (WorkflowActionType.RemoteOcr) {
<div class="row">
<div class="col">
<p class="text-muted small" i18n>The document will be sent to the configured remote OCR service. May incur costs.</p>
</div>
</div>
}
} }
</div> </div>
</ng-template> </ng-template>
@@ -29,6 +29,7 @@ import {
DocumentSource, DocumentSource,
WorkflowTriggerType, WorkflowTriggerType,
} from 'src/app/data/workflow-trigger' } 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 { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { CorrespondentService } from 'src/app/services/rest/correspondent.service' import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
@@ -224,7 +225,12 @@ describe('WorkflowEditDialogComponent', () => {
).toEqual('Document Added') ).toEqual('Document Added')
expect(component.getTriggerTypeOptionName(null)).toEqual('') expect(component.getTriggerTypeOptionName(null)).toEqual('')
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS) 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( expect(
component.getActionTypeOptionName(WorkflowActionType.Assignment) component.getActionTypeOptionName(WorkflowActionType.Assignment)
).toEqual('Assignment') ).toEqual('Assignment')
@@ -237,7 +243,104 @@ describe('WorkflowEditDialogComponent', () => {
jest.spyOn(settingsService, 'get').mockReturnValue(false) jest.spyOn(settingsService, 'get').mockReturnValue(false)
component.ngOnInit() component.ngOnInit()
expect(component.actionTypeOptions).toEqual( expect(component.actionTypeOptions).toEqual(
WORKFLOW_ACTION_OPTIONS.filter((a) => a.id !== WorkflowActionType.Email) WORKFLOW_ACTION_OPTIONS.filter(
(a) =>
a.id !== WorkflowActionType.Email &&
a.id !== WorkflowActionType.RemoteOcr
)
)
})
it('should offer remote OCR only for consumption workflows', () => {
jest.spyOn(settingsService, 'get').mockReturnValue(true)
// A consumption trigger makes the action reachable
component.object = {
name: 'Workflow 1',
order: 0,
enabled: true,
triggers: [{ type: WorkflowTriggerType.Consumption }],
actions: [],
} as Workflow
component.ngOnInit()
expect(component.actionTypeOptions.map((a) => a.id)).toContain(
WorkflowActionType.RemoteOcr
)
// Any other trigger type runs after the document has been parsed
component.object = {
name: 'Workflow 2',
order: 0,
enabled: true,
triggers: [{ type: WorkflowTriggerType.DocumentAdded }],
actions: [],
} as Workflow
component.ngOnInit()
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
WorkflowActionType.RemoteOcr
)
})
it('should offer remote OCR on a trigger added to a new workflow', () => {
jest.spyOn(settingsService, 'get').mockReturnValue(true)
component.ngOnInit()
// Nothing for the action to apply to yet
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
WorkflowActionType.RemoteOcr
)
// addTrigger creates the form field with emitEvent false, so the options
// have to be computed on read rather than cached from valueChanges
component.addTrigger()
expect(component.actionTypeOptions.map((a) => a.id)).toContain(
WorkflowActionType.RemoteOcr
)
// Switching that trigger to a type that runs after parsing removes it
component.triggerFields
.at(0)
.get('type')
.setValue(WorkflowTriggerType.DocumentAdded)
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
WorkflowActionType.RemoteOcr
)
})
it('should keep remote OCR listed when an action already uses it', () => {
jest.spyOn(settingsService, 'get').mockReturnValue(true)
// Otherwise changing the trigger would silently blank the selection
component.object = {
name: 'Workflow 1',
order: 0,
enabled: true,
triggers: [{ type: WorkflowTriggerType.DocumentAdded }],
actions: [{ type: WorkflowActionType.RemoteOcr }],
} as Workflow
component.ngOnInit()
expect(component.actionTypeOptions.map((a) => a.id)).toContain(
WorkflowActionType.RemoteOcr
)
})
it('should not offer remote OCR when no engine is configured', () => {
jest
.spyOn(settingsService, 'get')
.mockImplementation((key) => key !== SETTINGS_KEYS.REMOTE_OCR_CONFIGURED)
component.object = {
name: 'Workflow 1',
order: 0,
enabled: true,
triggers: [{ type: WorkflowTriggerType.Consumption }],
actions: [],
} as Workflow
component.ngOnInit()
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
WorkflowActionType.RemoteOcr
) )
}) })
@@ -148,6 +148,10 @@ export const WORKFLOW_ACTION_OPTIONS = [
id: WorkflowActionType.MoveToTrash, id: WorkflowActionType.MoveToTrash,
name: $localize`Move to trash`, name: $localize`Move to trash`,
}, },
{
id: WorkflowActionType.RemoteOcr,
name: $localize`Remote OCR`,
},
] ]
export enum TriggerFilterType { export enum TriggerFilterType {
@@ -504,8 +508,6 @@ export class WorkflowEditDialogComponent
expandedItem: number = null expandedItem: number = null
readonly allowedActionTypes = signal([])
private readonly triggerFilterOptionsMap = new WeakMap< private readonly triggerFilterOptionsMap = new WeakMap<
FormArray, FormArray,
TriggerFilterOption[] TriggerFilterOption[]
@@ -548,13 +550,40 @@ export class WorkflowEditDialogComponent
this.checkRemovalActionFields.bind(this) this.checkRemovalActionFields.bind(this)
) )
this.checkRemovalActionFields(this.objectForm.value) this.checkRemovalActionFields(this.objectForm.value)
this.allowedActionTypes.set( }
this.settingsService.get(SETTINGS_KEYS.EMAIL_ENABLED)
? WORKFLOW_ACTION_OPTIONS private allowedActionTypes: typeof WORKFLOW_ACTION_OPTIONS = null
: WORKFLOW_ACTION_OPTIONS.filter(
(a) => a.id !== WorkflowActionType.Email private getAllowedActionTypes() {
) let allowed = WORKFLOW_ACTION_OPTIONS
)
if (!this.settingsService.get(SETTINGS_KEYS.EMAIL_ENABLED)) {
allowed = allowed.filter((a) => a.id !== WorkflowActionType.Email)
}
// Remote OCR is decided before the document is parsed, so it is only
// offered for workflows that run at consumption.
const formWorkflow: Workflow = this.objectForm?.value
const remoteOcrUsable =
this.settingsService.get(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED) &&
(formWorkflow?.triggers?.some(
(trigger) => trigger.type === WorkflowTriggerType.Consumption
) ||
formWorkflow?.actions?.some(
(action) => action.type === WorkflowActionType.RemoteOcr
))
if (!remoteOcrUsable) {
allowed = allowed.filter((a) => a.id !== WorkflowActionType.RemoteOcr)
}
if (
this.allowedActionTypes?.length === allowed.length &&
this.allowedActionTypes.every((a, i) => a.id === allowed[i].id)
) {
return this.allowedActionTypes
}
this.allowedActionTypes = allowed
return allowed
} }
private checkRemovalActionFields(formWorkflow: Workflow) { private checkRemovalActionFields(formWorkflow: Workflow) {
@@ -1279,7 +1308,8 @@ export class WorkflowEditDialogComponent
get actionTypeOptions() { get actionTypeOptions() {
this.settingsService.trackChanges() this.settingsService.trackChanges()
return this.allowedActionTypes() // Computed on read rather than cached
return this.getAllowedActionTypes()
} }
getActionTypeOptionName(type: WorkflowActionType): string { getActionTypeOptionName(type: WorkflowActionType): string {
@@ -1,5 +1,5 @@
<div class="btn-group w-100" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown" (keydown)="listKeyDown($event)" [popperOptions]="popperOptions" [autoClose]="!creating()"> <div class="btn-group w-100" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown" (keydown)="listKeyDown($event)" [popperOptions]="popperOptions" [autoClose]="!creating()">
<button class="btn btn-sm" id="dropdown_{{name}}" ngbDropdownToggle [ngClass]="!editing && selectionModel.selectionSize() > 0 ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled"> <button class="btn btn-sm" id="dropdown_{{name}}" ngbDropdownToggle [ngClass]="!editing && selectionModel.selectionSize() > 0 ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled" [aria-label]="title">
<i-bs name="{{icon}}"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div> <i-bs name="{{icon}}"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
@if (!editing && selectionModel.totalCount > 0) { @if (!editing && selectionModel.totalCount > 0) {
<pngx-clearable-badge [number]="selectionModel.totalCount" [selected]="selectionModel.selectionSize() > 0" (cleared)="reset()"></pngx-clearable-badge> <pngx-clearable-badge [number]="selectionModel.totalCount" [selected]="selectionModel.selectionSize() > 0" (cleared)="reset()"></pngx-clearable-badge>
@@ -4,7 +4,7 @@
} }
<div class="input-group" [class.is-invalid]="error"> <div class="input-group" [class.is-invalid]="error">
<button type="button" class="input-group-text" [style.background-color]="value" (click)="colorPicker.toggle()">&nbsp;&nbsp;&nbsp;</button> <button type="button" class="input-group-text" [style.background-color]="value" (click)="colorPicker.toggle()" aria-label="Open color picker" i18n-aria-label>&nbsp;&nbsp;&nbsp;</button>
<ng-template #popContent> <ng-template #popContent>
<div style="min-width: 200px;" class="pb-3"> <div style="min-width: 200px;" class="pb-3">
@@ -14,7 +14,7 @@
<input #inputField class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [autoClose]="'outside'" [ngbPopover]="popContent" #colorPicker="ngbPopover" placement="bottom" popoverClass="shadow"> <input #inputField class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [autoClose]="'outside'" [ngbPopover]="popContent" #colorPicker="ngbPopover" placement="bottom" popoverClass="shadow">
<button class="btn btn-outline-secondary" type="button" (click)="randomize()"> <button class="btn btn-outline-secondary" type="button" (click)="randomize()" aria-label="Choose a random color" i18n-aria-label>
<i-bs name="dice5"></i-bs> <i-bs name="dice5"></i-bs>
</button> </button>
@@ -74,7 +74,7 @@
class="flex-grow-1"></pngx-input-textarea> class="flex-grow-1"></pngx-input-textarea>
} }
} }
<button type="button" class="btn btn-link text-danger" (click)="removeSelectedField.next(fieldId)"> <button type="button" class="btn btn-link text-danger" (click)="removeSelectedField.next(fieldId)" aria-label="Remove custom field" i18n-aria-label>
<i-bs name="trash"></i-bs> <i-bs name="trash"></i-bs>
</button> </button>
</div> </div>
@@ -13,7 +13,7 @@
<input #inputField class="form-control" [class.is-invalid]="error" [placeholder]="placeholder" [id]="inputId" maxlength="10" <input #inputField class="form-control" [class.is-invalid]="error" [placeholder]="placeholder" [id]="inputId" maxlength="10"
(dateSelect)="onChange(value)" (change)="onChange(value)" (keypress)="onKeyPress($event)" (paste)="onPaste($event)" (dateSelect)="onChange(value)" (change)="onChange(value)" (keypress)="onKeyPress($event)" (paste)="onPaste($event)"
name="dp" [(ngModel)]="value" ngbDatepicker #datePicker="ngbDatepicker" #datePickerContent="ngModel" [disabled]="disabled" [footerTemplate]="datePickerFooterTemplate"> name="dp" [(ngModel)]="value" ngbDatepicker #datePicker="ngbDatepicker" #datePickerContent="ngModel" [disabled]="disabled" [footerTemplate]="datePickerFooterTemplate">
<button class="btn btn-outline-secondary calendar" (click)="datePicker.toggle()" type="button" [disabled]="disabled"> <button class="btn btn-outline-secondary calendar" (click)="datePicker.toggle()" type="button" [disabled]="disabled" aria-label="Open date picker" i18n-aria-label>
<i-bs width="1.2em" height="1.2em" name="calendar"></i-bs> <i-bs width="1.2em" height="1.2em" name="calendar"></i-bs>
</button> </button>
<ng-template #datePickerFooterTemplate> <ng-template #datePickerFooterTemplate>
@@ -13,7 +13,7 @@
<div class="input-group mb-3"> <div class="input-group mb-3">
<input type="text" class="form-control" [(ngModel)]="entry[0]" (change)="inputChange()" [disabled]="disabled" autocomplete="off"> <input type="text" class="form-control" [(ngModel)]="entry[0]" (change)="inputChange()" [disabled]="disabled" autocomplete="off">
<input type="text" class="form-control" [(ngModel)]="entry[1]" (change)="inputChange()" [disabled]="disabled" autocomplete="off"> <input type="text" class="form-control" [(ngModel)]="entry[1]" (change)="inputChange()" [disabled]="disabled" autocomplete="off">
<button type="button" class="btn btn-outline-secondary" (click)="removeEntry(i)"> <button type="button" class="btn btn-outline-secondary" (click)="removeEntry(i)" aria-label="Remove entry" i18n-aria-label>
<i-bs class="text-danger" name="trash"></i-bs> <i-bs class="text-danger" name="trash"></i-bs>
</button> </button>
</div> </div>
@@ -50,7 +50,7 @@
</ng-template> </ng-template>
</ng-select> </ng-select>
@if (allowCreateNew && !hideAddButton) { @if (allowCreateNew && !hideAddButton) {
<button class="btn btn-outline-secondary" type="button" (click)="addItem()" [disabled]="disabled"> <button class="btn btn-outline-secondary" type="button" (click)="addItem()" [disabled]="disabled" aria-label="Create new item" i18n-aria-label>
<i-bs width="1.2em" height="1.2em" name="plus"></i-bs> <i-bs width="1.2em" height="1.2em" name="plus"></i-bs>
</button> </button>
} }
@@ -48,7 +48,7 @@
</ng-template> </ng-template>
</ng-select> </ng-select>
@if (allowCreate && !hideAddButton) { @if (allowCreate && !hideAddButton) {
<button class="btn btn-outline-secondary" type="button" (click)="createTag(null, true)" [disabled]="disabled"> <button class="btn btn-outline-secondary" type="button" (click)="createTag(null, true)" [disabled]="disabled" aria-label="Create new tag" i18n-aria-label>
<i-bs width="1.2em" height="1.2em" name="plus"></i-bs> <i-bs width="1.2em" height="1.2em" name="plus"></i-bs>
</button> </button>
} }
@@ -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"> <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> <span class="text-truncate">{{title()}}</span>
@if (id()) { @if (id()) {
<span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyID()"> <span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyID()">
@@ -12,10 +12,10 @@
</span> </span>
} }
@if (subTitle()) { @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()) { @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> <i-bs name="question-circle"></i-bs>
</button> </button>
<ng-template #infoPopover> <ng-template #infoPopover>
@@ -1,5 +1,6 @@
h3 { h3 {
min-height: calc(1.325rem + 0.9vw); min-height: calc(1.325rem + 0.9vw);
flex-wrap: wrap;
.badge { .badge {
font-size: 0.65rem; font-size: 0.65rem;
@@ -7,6 +8,25 @@ h3 {
} }
} }
// Drop the subtitle onto its own and shrink it
@media (max-width: 767.98px) {
h3 > .page-subtitle {
flex: 0 0 100%;
margin-top: 0 !important;
font-size: .7rem;
line-height: 1.3 !important;
}
}
:host {
display: block;
margin-bottom: .35rem;
}
h3 > .h6 {
color: var(--bs-secondary-color);
}
@media (min-width: 1200px) { @media (min-width: 1200px) {
h3 { h3 {
min-height: 2.8rem; min-height: 2.8rem;
@@ -1,5 +1,5 @@
<div class="btn-group w-100" ngbDropdown role="group"> <div class="btn-group w-100" ngbDropdown role="group">
<button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="isActive ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled"> <button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="isActive ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled" [aria-label]="title">
<i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div> <i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span> <pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
</button> </button>
@@ -107,6 +107,25 @@ describe('PermissionsSelectComponent', () => {
expect(component.form.get('Tag').get('Change').disabled).toBeTruthy() expect(component.form.get('Tag').get('Change').disabled).toBeTruthy()
}) })
it('should update checkboxes when inherited permissions change', () => {
component.ngOnInit()
component.inheritedPermissions = ['documents.change_document']
component.writeValue(['delete_document'])
expect(component.form.get('Document').get('Change').value).toBeTruthy()
expect(component.form.get('Document').get('Change').disabled).toBeTruthy()
// swap for a group with a different permission, but the same number of them
component.inheritedPermissions = ['documents.view_document']
// the no-longer-inherited permission is unchecked, the explicit one is kept
expect(component.permissions).toEqual(['delete_document'])
expect(component.form.get('Document').get('Change').value).toBeFalsy()
expect(component.form.get('Document').get('Change').disabled).toBeFalsy()
expect(component.form.get('Document').get('Delete').value).toBeTruthy()
expect(component.form.get('Document').get('View').value).toBeTruthy()
expect(component.form.get('Document').get('View').disabled).toBeTruthy()
})
it('should exclude history permissions if disabled', () => { it('should exclude history permissions if disabled', () => {
settingsService.set(SETTINGS_KEYS.AUDITLOG_ENABLED, false) settingsService.set(SETTINGS_KEYS.AUDITLOG_ENABLED, false)
fixture = TestBed.createComponent(PermissionsSelectComponent) fixture = TestBed.createComponent(PermissionsSelectComponent)
@@ -74,12 +74,22 @@ export class PermissionsSelectComponent
? inherited.map((p) => p.replace(/^\w+\./g, '')) ? inherited.map((p) => p.replace(/^\w+\./g, ''))
: [] : []
if (this._inheritedPermissions !== newInheritedPermissions) { const changed =
this._inheritedPermissions = newInheritedPermissions newInheritedPermissions.length !== this._inheritedPermissions.length ||
this.writeValue(this.permissions) // updates visual checks etc. newInheritedPermissions.some(
} (p) => !this._inheritedPermissions.includes(p)
)
this.updateDisabledStates() if (changed) {
// skip inherited permissions, these are the explicitly set ones
this.permissions = this.getSelectedPermissions(
this.form.getRawValue()
).filter((p) => !this._inheritedPermissions.includes(p))
this._inheritedPermissions = newInheritedPermissions
this.applyCheckedState()
} else {
this.updateDisabledStates()
}
} }
inheritedWarning: string = $localize`Inherited from group` inheritedWarning: string = $localize`Inherited from group`
@@ -106,20 +116,29 @@ export class PermissionsSelectComponent
} }
this.permissions = permissions ?? [] this.permissions = permissions ?? []
const allPerms = this._inheritedPermissions.concat(this.permissions) this.applyCheckedState()
}
allPerms.forEach((permissionStr) => { // sets every checkbox from inherited + own perms
const { actionKey, typeKey } = private applyCheckedState(): void {
this.permissionsService.getPermissionKeys(permissionStr) const allPerms = new Set(
this._inheritedPermissions.concat(this.permissions)
)
if (actionKey && typeKey) {
this.form
.get(typeKey)
?.get(actionKey)
?.patchValue(true, { emitEvent: false })
}
})
this.allowedTypes.forEach((type) => { this.allowedTypes.forEach((type) => {
const typeGroup = this.form.get(type)
for (const action of Object.keys(PermissionAction)) {
typeGroup.get(action)?.patchValue(
allPerms.has(
this.permissionsService.getPermissionCode(
PermissionAction[action],
PermissionType[type]
)
),
{ emitEvent: false } // don't trigger valueChanges now
)
}
if (this.typeHasAllActionsSelected(type)) { if (this.typeHasAllActionsSelected(type)) {
this.typesWithAllActions.add(type) this.typesWithAllActions.add(type)
} else { } else {
@@ -150,26 +169,9 @@ export class PermissionsSelectComponent
ngOnInit(): void { ngOnInit(): void {
this.form.valueChanges.subscribe((newValue) => { this.form.valueChanges.subscribe((newValue) => {
let permissions = [] const permissions = this.getSelectedPermissions(newValue)
Object.entries(newValue).forEach(([typeKey, typeValue]) => {
const selectedActions = Object.entries(typeValue).filter(
([actionKey, actionValue]) =>
actionValue &&
this.isActionSupported(
PermissionType[typeKey],
PermissionAction[actionKey]
)
)
selectedActions.forEach(([actionKey]) => {
permissions.push(
(PermissionType[typeKey] as string).replace(
'%s',
PermissionAction[actionKey]
)
)
})
Object.keys(newValue).forEach((typeKey) => {
if (this.typeHasAllActionsSelected(typeKey)) { if (this.typeHasAllActionsSelected(typeKey)) {
this.typesWithAllActions.add(typeKey) this.typesWithAllActions.add(typeKey)
} else { } else {
@@ -269,6 +271,30 @@ export class PermissionsSelectComponent
return true return true
} }
private getSelectedPermissions(formValue: object): string[] {
const permissions = []
Object.entries(formValue).forEach(([typeKey, typeValue]) => {
Object.entries(typeValue)
.filter(
([actionKey, actionValue]) =>
actionValue &&
this.isActionSupported(
PermissionType[typeKey],
PermissionAction[actionKey]
)
)
.forEach(([actionKey]) => {
permissions.push(
this.permissionsService.getPermissionCode(
PermissionAction[actionKey],
PermissionType[typeKey]
)
)
})
})
return permissions
}
private typeHasAllActionsSelected(typeKey: string): boolean { private typeHasAllActionsSelected(typeKey: string): boolean {
return Object.keys(PermissionAction) return Object.keys(PermissionAction)
.filter((action) => .filter((action) =>
@@ -1,5 +1,5 @@
<div class="btn-group"> <div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading() || (suggestions() && !aiEnabled())"> <button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading() || (suggestions() && !aiEnabled())" [aria-label]="noSuggestions ? 'No suggestions' : 'Suggest'" i18n-aria-label>
@if (loading()) { @if (loading()) {
<div class="spinner-border spinner-border-sm" role="status"></div> <div class="spinner-border spinner-border-sm" role="status"></div>
} @else if (noSuggestions) { } @else if (noSuggestions) {
@@ -23,7 +23,7 @@
<dd> <dd>
{{status().pngx_version}} {{status().pngx_version}}
@if (versionMismatch()) { @if (versionMismatch()) {
<button class="btn btn-sm d-inline align-items-center btn-dark text-uppercase small" [ngbPopover]="versionPopover" triggers="click mouseenter:mouseleave"> <button class="btn btn-sm d-inline align-items-center btn-dark text-uppercase small" [ngbPopover]="versionPopover" triggers="click mouseenter:mouseleave" aria-label="View version mismatch details" i18n-aria-label>
<i-bs name="exclamation-triangle-fill" class="text-danger lh-1"></i-bs> <i-bs name="exclamation-triangle-fill" class="text-danger lh-1"></i-bs>
</button> </button>
} }
@@ -1,9 +1,7 @@
<pngx-page-header title="Dashboard" [subTitle]="subtitle" i18n-title tourAnchor="tour.dashboard"> <pngx-page-header title="Dashboard" [subTitle]="subtitle" i18n-title tourAnchor="tour.dashboard"></pngx-page-header>
<pngx-logo extra_classes="d-none d-md-block mt-n2" height="3rem"></pngx-logo>
</pngx-page-header>
<div class="row"> <div class="row dashboard-grid g-4 pb-0">
<div class="col-12 col-lg-8 col-xl-9 mb-4"> <div class="col-12 col-lg-8 col-xl-9 mb-4 dashboard-main">
<div class="row row-cols-1 g-4" <div class="row row-cols-1 g-4"
cdkDropList cdkDropList
[cdkDropListDisabled]="settingsService.globalDropzoneActive()" [cdkDropListDisabled]="settingsService.globalDropzoneActive()"
@@ -58,7 +56,7 @@
</ng-container> </ng-container>
</div> </div>
</div> </div>
<div class="col-12 col-lg-4 col-xl-3 col-sidebar"> <div class="col-12 col-lg-4 col-xl-3 col-sidebar dashboard-aside">
<div class="row row-cols-1 g-4 mb-4 sticky-lg-top z-0"> <div class="row row-cols-1 g-4 mb-4 sticky-lg-top z-0">
<pngx-upload-file-widget></pngx-upload-file-widget> <pngx-upload-file-widget></pngx-upload-file-widget>
<pngx-statistics-widget *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.UISettings }"></pngx-statistics-widget> <pngx-statistics-widget *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.UISettings }"></pngx-statistics-widget>
@@ -1,3 +1,13 @@
.col-sidebar .row { .col-sidebar .row {
top: 3.5rem; top: 4.75rem;
}
.dashboard-grid {
padding-bottom: 2rem;
}
@media (min-width: 1200px) {
.dashboard-main {
padding-right: 1rem;
}
} }
@@ -15,7 +15,6 @@ import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { LogoComponent } from '../common/logo/logo.component'
import { PageHeaderComponent } from '../common/page-header/page-header.component' import { PageHeaderComponent } from '../common/page-header/page-header.component'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component' import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
import { SavedViewWidgetComponent } from './widgets/saved-view-widget/saved-view-widget.component' import { SavedViewWidgetComponent } from './widgets/saved-view-widget/saved-view-widget.component'
@@ -28,7 +27,6 @@ import { WelcomeWidgetComponent } from './widgets/welcome-widget/welcome-widget.
templateUrl: './dashboard.component.html', templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.scss'], styleUrls: ['./dashboard.component.scss'],
imports: [ imports: [
LogoComponent,
PageHeaderComponent, PageHeaderComponent,
SavedViewWidgetComponent, SavedViewWidgetComponent,
StatisticsWidgetComponent, StatisticsWidgetComponent,
@@ -8,3 +8,14 @@
width: 0.6rem; width: 0.6rem;
} }
} }
.list-group {
--bs-list-group-border-color: color-mix(in srgb, var(--bs-border-color) 58%, transparent);
--bs-list-group-bg: transparent;
border-radius: .6rem;
overflow: hidden;
}
.list-group-item {
padding: .7rem .8rem;
}
@@ -4,6 +4,16 @@
.btn-outline-dark { .btn-outline-dark {
--bs-btn-border-color: var(--bs-border-color-translucent); --bs-btn-border-color: var(--bs-border-color-translucent);
border-style: dashed;
border-width: 1px;
min-height: 5.25rem;
background: color-mix(in srgb, var(--bs-primary) 4%, var(--bs-light)) !important;
&:hover,
&:focus {
border-color: var(--bs-primary);
background: color-mix(in srgb, var(--bs-primary) 9%, var(--bs-light)) !important;
}
} }
.smaller { .smaller {
@@ -1,5 +1,5 @@
@if (!cardless()) { @if (!cardless()) {
<div class="card shadow-sm bg-light fade" [class.show]="show()" cdkDrag [cdkDragDisabled]="!draggable()" cdkDragPreviewContainer="parent"> <div class="card bg-light fade" [class.show]="show()" cdkDrag [cdkDragDisabled]="!draggable()" cdkDragPreviewContainer="parent">
<div class="card-header"> <div class="card-header">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
@@ -2,6 +2,20 @@ i-bs {
cursor: move; cursor: move;
} }
.card {
overflow: hidden;
border-color: color-mix(in srgb, var(--bs-border-color) 68%, transparent);
.card-header {
padding: .9rem 1rem;
border-bottom-color: color-mix(in srgb, var(--bs-border-color) 55%, transparent);
}
.card-body {
padding: 1rem;
}
}
.fade.show { .fade.show {
animation: pngx-entry-fade 160ms ease-out; animation: pngx-entry-fade 160ms ease-out;
} }
@@ -20,7 +20,7 @@
</div> </div>
} }
<button type="button" class="btn btn-sm btn-outline-danger me-md-4" (click)="delete()" [disabled]="!userIsOwner" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }"> <button type="button" class="btn btn-sm btn-outline-danger me-md-4" (click)="delete()" [disabled]="!userIsOwner" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }" aria-label="Delete" i18n-aria-label>
<i-bs width="1.2em" height="1.2em" name="trash"></i-bs><span class="d-none d-lg-inline ps-1" i18n>Delete</span> <i-bs width="1.2em" height="1.2em" name="trash"></i-bs><span class="d-none d-lg-inline ps-1" i18n>Delete</span>
</button> </button>
@@ -35,7 +35,7 @@
/> />
<div class="btn-group"> <div class="btn-group">
<button (click)="download()" class="btn btn-sm btn-outline-primary" [disabled]="downloading()"> <button (click)="download()" class="btn btn-sm btn-outline-primary" [disabled]="downloading()" aria-label="Download" i18n-aria-label>
@if (downloading()) { @if (downloading()) {
<div class="spinner-border spinner-border-sm" role="status"></div> <div class="spinner-border spinner-border-sm" role="status"></div>
} @else { } @else {
@@ -45,7 +45,7 @@
</button> </button>
<div class="btn-group" ngbDropdown role="group"> <div class="btn-group" ngbDropdown role="group">
<button class="btn btn-sm btn-outline-primary dropdown-toggle" [disabled]="downloading()" ngbDropdownToggle></button> <button class="btn btn-sm btn-outline-primary dropdown-toggle" [disabled]="downloading()" ngbDropdownToggle aria-label="Download options" i18n-aria-label></button>
<div class="dropdown-menu shadow" ngbDropdownMenu> <div class="dropdown-menu shadow" ngbDropdownMenu>
@if (metadata()?.has_archive_version) { @if (metadata()?.has_archive_version) {
<button ngbDropdownItem (click)="download(true)" [disabled]="downloading()" i18n>Download original</button> <button ngbDropdownItem (click)="download(true)" [disabled]="downloading()" i18n>Download original</button>
@@ -62,7 +62,7 @@
</div> </div>
<div class="ms-auto" ngbDropdown> <div class="ms-auto" ngbDropdown>
<button class="btn btn-sm btn-outline-primary" id="actionsDropdown" ngbDropdownToggle> <button class="btn btn-sm btn-outline-primary" id="actionsDropdown" ngbDropdownToggle aria-label="Actions" i18n-aria-label>
<i-bs name="three-dots"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Actions</ng-container></div> <i-bs name="three-dots"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Actions</ng-container></div>
</button> </button>
<div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow"> <div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow">
@@ -91,7 +91,7 @@
</div> </div>
<div class="ms-auto" ngbDropdown> <div class="ms-auto" ngbDropdown>
<button class="btn btn-sm btn-outline-primary" id="sendDropdown" ngbDropdownToggle> <button class="btn btn-sm btn-outline-primary" id="sendDropdown" ngbDropdownToggle aria-label="Send" i18n-aria-label>
<i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container></div> <i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container></div>
</button> </button>
<div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow"> <div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow">
@@ -963,12 +963,24 @@ describe('DocumentDetailComponent', () => {
component.reprocess() component.reprocess()
const modalCloseSpy = jest.spyOn(openModal, 'close') const modalCloseSpy = jest.spyOn(openModal, 'close')
openModal.componentInstance.confirmClicked.next() openModal.componentInstance.confirmClicked.next()
expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] }) expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] }, false)
expect(modalSpy).toHaveBeenCalled() expect(modalSpy).toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalled() expect(toastSpy).toHaveBeenCalled()
expect(modalCloseSpy).toHaveBeenCalled() expect(modalCloseSpy).toHaveBeenCalled()
}) })
it('should pass remote OCR choice when reprocessing', () => {
initNormally()
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
reprocessSpy.mockReturnValue(of(true))
let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
component.reprocess()
openModal.componentInstance.remoteOcr = true
openModal.componentInstance.confirmClicked.next()
expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] }, true)
})
it('should show error if redo ocr call fails', () => { it('should show error if redo ocr call fails', () => {
initNormally() initNormally()
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments') const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
@@ -2171,6 +2183,11 @@ describe('DocumentDetailComponent', () => {
).toBe(10) ).toBe(10)
component.openEmailDocument() component.openEmailDocument()
expect(modalSpy).toHaveBeenCalled() expect(modalSpy).toHaveBeenCalled()
expect(
(
modalSpy.mock.results[1].value as NgbModalRef
).componentInstance.documentIds()
).toEqual([10])
}) })
it('should set previewText', () => { it('should set previewText', () => {
@@ -97,6 +97,7 @@ import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
import * as UTIF from 'utif' import * as UTIF from 'utif'
import { DocumentDetailFieldID } from '../admin/settings/settings.component' import { DocumentDetailFieldID } from '../admin/settings/settings.component'
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
import { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component' import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component' import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component' import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
@@ -1398,7 +1399,7 @@ export class DocumentDetailComponent
} }
reprocess() { reprocess() {
let modal = this.modalService.open(ConfirmDialogComponent, { let modal = this.modalService.open(ReprocessConfirmDialogComponent, {
backdrop: 'static', backdrop: 'static',
}) })
modal.componentInstance.title = $localize`Reprocess confirm` modal.componentInstance.title = $localize`Reprocess confirm`
@@ -1409,7 +1410,10 @@ export class DocumentDetailComponent
modal.componentInstance.confirmClicked.subscribe(() => { modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled.set(false)
this.documentsService this.documentsService
.reprocessDocuments({ documents: [this.document().id] }) .reprocessDocuments(
{ documents: [this.document().id] },
modal.componentInstance.remoteOcr
)
.subscribe({ .subscribe({
next: () => { next: () => {
this.toastService.showInfo( this.toastService.showInfo(
@@ -1973,7 +1977,9 @@ export class DocumentDetailComponent
const modal = this.modalService.open(EmailDocumentDialogComponent, { const modal = this.modalService.open(EmailDocumentDialogComponent, {
backdrop: 'static', backdrop: 'static',
}) })
modal.componentInstance.documentIds.set([this.document().id]) modal.componentInstance.documentIds.set([
this.selectedVersionId() ?? this.document().id,
])
modal.componentInstance.hasArchiveVersion.set( modal.componentInstance.hasArchiveVersion.set(
this.metadata()?.has_archive_version ?? this.metadata()?.has_archive_version ??
!!this.document()?.archived_file_name !!this.document()?.archived_file_name
@@ -1,7 +1,10 @@
<div class="btn-group" ngbDropdown autoClose="outside"> <div class="btn-group" ngbDropdown autoClose="outside">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" ngbDropdownToggle> <button class="btn btn-sm btn-outline-secondary dropdown-toggle" ngbDropdownToggle aria-label="Versions" i18n-aria-label>
<i-bs name="file-earmark-diff"></i-bs> <i-bs name="file-earmark-diff"></i-bs>
<span class="d-none d-lg-inline ps-1" i18n>Versions</span> <span class="d-none d-lg-inline ps-1" i18n>Versions</span>
@if (versions.length > 1) {
<span class="badge text-bg-secondary ms-1">{{ versions.length }}</span>
}
</button> </button>
<div class="dropdown-menu shadow" ngbDropdownMenu> <div class="dropdown-menu shadow" ngbDropdownMenu>
<div class="px-3 py-2 mb-2"> <div class="px-3 py-2 mb-2">
@@ -1,6 +1,6 @@
<h6> <h6>
<button type="button" class="btn btn-outline-secondary btn-sm me-2" <button type="button" class="btn btn-outline-secondary btn-sm me-2"
(click)="expand = !expand"> (click)="expand = !expand" aria-label="Toggle document metadata" i18n-aria-label>
@if (!expand) { @if (!expand) {
<i-bs width="1.2em" height="1.2em" name="caret-down"></i-bs> <i-bs width="1.2em" height="1.2em" name="caret-down"></i-bs>
} }
@@ -74,7 +74,7 @@
</pngx-filterable-dropdown> </pngx-filterable-dropdown>
} }
<div class="btn-group"> <div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || !userCanEditAll"> <button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || !userCanEditAll" aria-label="Permissions" i18n-aria-label>
<i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Permissions</ng-container></div> <i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Permissions</ng-container></div>
</button> </button>
</div> </div>
@@ -82,7 +82,7 @@
<div class="d-flex align-items-center gap-2 ms-auto"> <div class="d-flex align-items-center gap-2 ms-auto">
<div class="btn-toolbar"> <div class="btn-toolbar">
<div ngbDropdown> <div ngbDropdown>
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" [disabled]="!userCanEdit && !userCanAdd" ngbDropdownToggle> <button class="btn btn-sm btn-outline-primary" id="dropdownSelect" [disabled]="!userCanEdit && !userCanAdd" ngbDropdownToggle aria-label="Actions" i18n-aria-label>
<i-bs name="three-dots"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Actions</ng-container></div> <i-bs name="three-dots"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Actions</ng-container></div>
</button> </button>
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow"> <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
@@ -107,6 +107,8 @@
id="dropdownSend" id="dropdownSend"
ngbDropdownToggle ngbDropdownToggle
[disabled]="disabled || !canSendSelection" [disabled]="disabled || !canSendSelection"
aria-label="Send"
i18n-aria-label
> >
<i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container> <i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container>
</div> </div>
@@ -127,7 +129,7 @@
</div> </div>
</div> </div>
<div class="btn-group btn-group-sm"> <div class="btn-group btn-group-sm">
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload()" (click)="downloadSelected()"> <button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload()" (click)="downloadSelected()" aria-label="Download" i18n-aria-label>
@if (!awaitingDownload()) { @if (!awaitingDownload()) {
<i-bs name="arrow-down"></i-bs> <i-bs name="arrow-down"></i-bs>
} }
@@ -139,7 +141,7 @@
<div class="d-none d-sm-inline ms-1"><ng-container i18n>Download</ng-container></div> <div class="d-none d-sm-inline ms-1"><ng-container i18n>Download</ng-container></div>
</button> </button>
<div ngbDropdown class="me-2 d-flex btn-group" role="group"> <div ngbDropdown class="me-2 d-flex btn-group" role="group">
<button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle-split rounded-end" ngbDropdownToggle></button> <button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle-split rounded-end" ngbDropdownToggle aria-label="Download options" i18n-aria-label></button>
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow"> <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
<form [formGroup]="downloadForm" class="px-3 py-1"> <form [formGroup]="downloadForm" class="px-3 py-1">
<p class="mb-1" i18n>Include:</p> <p class="mb-1" i18n>Include:</p>
@@ -1,5 +1,5 @@
.dropdown-toggle-split { .dropdown-toggle-split {
--bs-border-radius: .25rem; --bs-border-radius: .45rem;
} }
.dropdown-menu{ .dropdown-menu{
@@ -1122,6 +1122,7 @@ describe('BulkEditorComponent', () => {
req.flush(true) req.flush(true)
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: [3, 4], documents: [3, 4],
remote_ocr: false,
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
@@ -52,6 +52,7 @@ import { flattenTags } from 'src/app/utils/flatten-tags'
import { queryParamsFromFilterRules } from 'src/app/utils/query-params' import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
import { MergeAsVersionsConfirmDialogComponent } from '../../common/confirm-dialog/merge-as-versions-confirm-dialog/merge-as-versions-confirm-dialog.component' import { MergeAsVersionsConfirmDialogComponent } from '../../common/confirm-dialog/merge-as-versions-confirm-dialog/merge-as-versions-confirm-dialog.component'
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component' import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
import { ReprocessConfirmDialogComponent } from '../../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component' import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component' import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component' import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
@@ -916,7 +917,7 @@ export class BulkEditorComponent
} }
reprocessSelected() { reprocessSelected() {
let modal = this.modalService.open(ConfirmDialogComponent, { let modal = this.modalService.open(ReprocessConfirmDialogComponent, {
backdrop: 'static', backdrop: 'static',
}) })
modal.componentInstance.title = $localize`Reprocess confirm` modal.componentInstance.title = $localize`Reprocess confirm`
@@ -930,7 +931,10 @@ export class BulkEditorComponent
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled.set(false)
this.executeDocumentAction( this.executeDocumentAction(
modal, modal,
this.documentService.reprocessDocuments(this.getSelectionQuery()) this.documentService.reprocessDocuments(
this.getSelectionQuery(),
modal.componentInstance.remoteOcr
)
) )
}) })
} }
@@ -61,7 +61,7 @@
</pngx-input-textarea> </pngx-input-textarea>
} }
} }
<button type="button" class="btn btn-outline-danger mb-3" (click)="removeField(field.id)"> <button type="button" class="btn btn-outline-danger mb-3" (click)="removeField(field.id)" aria-label="Remove custom field" i18n-aria-label>
<i-bs name="x"></i-bs> <i-bs name="x"></i-bs>
</button> </button>
</div> </div>
@@ -154,9 +154,9 @@
<i-bs name="download"></i-bs> <i-bs name="download"></i-bs>
</a> </a>
} @else { } @else {
<button class="btn btn-sm btn-outline-secondary placeholder bg-secondary"></button> <span class="btn btn-sm btn-outline-secondary placeholder bg-secondary" aria-hidden="true"></span>
<button class="btn btn-sm btn-outline-secondary placeholder bg-secondary"></button> <span class="btn btn-sm btn-outline-secondary placeholder bg-secondary" aria-hidden="true"></span>
<button class="btn btn-sm btn-outline-secondary placeholder bg-secondary"></button> <span class="btn btn-sm btn-outline-secondary placeholder bg-secondary" aria-hidden="true"></span>
} }
</div> </div>
</div> </div>
@@ -1,6 +1,6 @@
<pngx-page-header [title]="getTitle()"> <pngx-page-header [title]="getTitle()">
<div ngbDropdown class="btn-group flex-fill d-sm-none"> <div ngbDropdown class="btn-group flex-fill d-sm-none">
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle> <button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle aria-label="Select" i18n-aria-label>
<i-bs name="text-indent-left"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Select</ng-container></div> <i-bs name="text-indent-left"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Select</ng-container></div>
@if (list.hasSelection) { @if (list.hasSelection) {
<pngx-clearable-badge [selected]="list.hasSelection" [number]="list.selectedCount" (cleared)="list.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span> <pngx-clearable-badge [selected]="list.hasSelection" [number]="list.selectedCount" (cleared)="list.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
@@ -31,7 +31,7 @@
</div> </div>
</div> </div>
<div ngbDropdown class="btn-group flex-fill"> <div ngbDropdown class="btn-group flex-fill">
<button class="btn btn-sm btn-outline-primary" id="dropdownDisplayFields" ngbDropdownToggle> <button class="btn btn-sm btn-outline-primary" id="dropdownDisplayFields" ngbDropdownToggle aria-label="Show" i18n-aria-label>
<i-bs name="card-heading"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Show</ng-container></div> <i-bs name="card-heading"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Show</ng-container></div>
</button> </button>
<div ngbDropdownMenu aria-labelledby="dropdownDisplayFields" class="shadow"> <div ngbDropdownMenu aria-labelledby="dropdownDisplayFields" class="shadow">
@@ -61,7 +61,7 @@
</div> </div>
<div ngbDropdown class="btn-group flex-fill"> <div ngbDropdown class="btn-group flex-fill">
<button class="btn btn-outline-primary btn-sm" id="dropdownBasic1" ngbDropdownToggle> <button class="btn btn-outline-primary btn-sm" id="dropdownBasic1" ngbDropdownToggle aria-label="Sort" i18n-aria-label>
<i-bs name="arrow-down-up"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Sort</ng-container></div> <i-bs name="arrow-down-up"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Sort</ng-container></div>
</button> </button>
<div ngbDropdownMenu aria-labelledby="dropdownBasic1" class="shadow dropdown-menu-right"> <div ngbDropdownMenu aria-labelledby="dropdownBasic1" class="shadow dropdown-menu-right">
@@ -85,8 +85,8 @@
</div> </div>
</div> </div>
<div class="btn-group flex-fill" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }" ngbDropdown role="group"> <div class="btn-group flex-fill" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }" ngbDropdown #viewsDropdown="ngbDropdown" role="group">
<button class="btn btn-sm btn-outline-primary dropdown-toggle flex-fill" tourAnchor="tour.documents-views" ngbDropdownToggle> <button class="btn btn-sm btn-outline-primary dropdown-toggle flex-fill" tourAnchor="tour.documents-views" ngbDropdownToggle aria-label="Views" i18n-aria-label>
<i-bs name="window-stack"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Views</ng-container></div> <i-bs name="window-stack"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Views</ng-container></div>
@if (savedViewIsModified) { @if (savedViewIsModified) {
<div class="position-absolute top-0 start-100 p-2 translate-middle badge bg-secondary border border-light rounded-circle"> <div class="position-absolute top-0 start-100 p-2 translate-middle badge bg-secondary border border-light rounded-circle">
@@ -95,15 +95,15 @@
} }
</button> </button>
<div class="dropdown-menu shadow dropdown-menu-right" ngbDropdownMenu> <div class="dropdown-menu shadow dropdown-menu-right" ngbDropdownMenu>
@if (!list.activeSavedViewId) { @if (viewsDropdown.isOpen() && !list.activeSavedViewId && savedViewService.allViews.length > 0) {
@for (view of savedViewService.allViews; track view) { <div class="views-list overflow-y-auto">
<button ngbDropdownItem (click)="loadViewConfig(view.id)"> @for (view of savedViewService.allViews; track view.id) {
<i-bs class="me-2" [name]="view.icon || 'funnel'"></i-bs>{{view.name}} <button ngbDropdownItem (click)="loadViewConfig(view.id)">
</button> <i-bs class="me-2" [name]="view.icon || 'funnel'"></i-bs>{{view.name}}
} </button>
@if (savedViewService.allViews.length > 0) { }
<div class="dropdown-divider"></div> </div>
} <div class="dropdown-divider"></div>
} }
@if (list.activeSavedViewId && activeSavedViewCanChange) { @if (list.activeSavedViewId && activeSavedViewCanChange) {
@@ -157,7 +157,7 @@
</div> </div>
</ng-template> </ng-template>
<div tourAnchor="tour.documents"> <div class="mt-3 mb-n2" tourAnchor="tour.documents">
<ng-container *ngTemplateOutlet="pagination"></ng-container> <ng-container *ngTemplateOutlet="pagination"></ng-container>
</div> </div>
@@ -56,11 +56,11 @@ $paperless-card-breakpoints: (
.sticky-top { .sticky-top {
z-index: 990; // below main navbar z-index: 990; // below main navbar
top: calc(7rem - 2px); // height of navbar + search row (mobile) top: calc(7.5rem - 2px); // height of navbar + search row (mobile)
transition: top 0.2s ease; transition: top 0.2s ease;
@media (min-width: 580px) { @media (min-width: 580px) {
top: 3.5rem; // height of navbar top: 4.5em; // height of navbar
} }
} }
@@ -73,7 +73,7 @@ $paperless-card-breakpoints: (
@media (max-width: 579.98px) { @media (max-width: 579.98px) {
:host-context(main.mobile-search-hidden) .sticky-top { :host-context(main.mobile-search-hidden) .sticky-top {
top: calc(3.5rem - 2px); // height of navbar only when search is hidden top: calc(4rem - 2px); // height of navbar only when search is hidden
} }
} }
@@ -93,4 +93,8 @@ a {
pngx-page-header .dropdown-menu { pngx-page-header .dropdown-menu {
--bs-dropdown-min-width: 12em; --bs-dropdown-min-width: 12em;
.views-list {
max-height: min(400px, calc(100vh - 260px)); // leave room for the header above and the actions below
}
} }
@@ -18,7 +18,7 @@
</select> </select>
} }
@if (_textFilter) { @if (_textFilter) {
<button class="btn btn-link btn-sm px-2 position-absolute top-0 end-0 z-10" (click)="resetTextField()"> <button class="btn btn-link btn-sm px-2 position-absolute top-0 end-0 z-10" (click)="resetTextField()" aria-label="Clear search" i18n-aria-label>
<i-bs width="1em" height="1em" name="x"></i-bs> <i-bs width="1em" height="1em" name="x"></i-bs>
</button> </button>
} }
@@ -24,7 +24,7 @@
<div class="btn-toolbar gap-2"> <div class="btn-toolbar gap-2">
<div class="btn-group d-block d-sm-none"> <div class="btn-group d-block d-sm-none">
<div ngbDropdown container="body" class="d-inline-block"> <div ngbDropdown container="body" class="d-inline-block">
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle> <button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle aria-label="Actions" i18n-aria-label>
<i-bs name="three-dots-vertical"></i-bs> <i-bs name="three-dots-vertical"></i-bs>
</button> </button>
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile"> <div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
@@ -7,7 +7,7 @@
> >
@if (activeManagementList) { @if (activeManagementList) {
<div ngbDropdown class="btn-group flex-fill d-sm-none"> <div ngbDropdown class="btn-group flex-fill d-sm-none">
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle> <button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle aria-label="Select" i18n-aria-label>
<i-bs name="text-indent-left"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Select</ng-container></div> <i-bs name="text-indent-left"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Select</ng-container></div>
@if (activeManagementList.hasSelection) { @if (activeManagementList.hasSelection) {
<pngx-clearable-badge [selected]="activeManagementList.hasSelection" [number]="activeManagementList.selectedCount" (cleared)="activeManagementList.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span> <pngx-clearable-badge [selected]="activeManagementList.hasSelection" [number]="activeManagementList.selectedCount" (cleared)="activeManagementList.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
@@ -1,7 +1,7 @@
<div class="row mb-3"> <div class="row mb-3">
<div class="col mb-2 mb-xl-0"> <div class="col mb-2 mb-xl-0">
<div class="form-inline d-flex align-items-center"> <div class="form-inline d-flex align-items-center">
<label class="text-muted me-2 mb-0" for="managementNameFilter" i18n>Filter by:</label> <label class="me-2 mb-0" for="managementNameFilter" i18n>Filter by:</label>
<input id="managementNameFilter" class="form-control form-control-sm flex-fill w-auto" type="text" autofocus [(ngModel)]="nameFilter" (keyup)="onNameFilterKeyUp($event)" placeholder="Name" i18n-placeholder> <input id="managementNameFilter" class="form-control form-control-sm flex-fill w-auto" type="text" autofocus [(ngModel)]="nameFilter" (keyup)="onNameFilterKeyUp($event)" placeholder="Name" i18n-placeholder>
</div> </div>
</div> </div>
@@ -9,7 +9,7 @@
<div class="col-auto mb-2 mb-xl-0"> <div class="col-auto mb-2 mb-xl-0">
<div class="form-inline d-flex align-items-center"> <div class="form-inline d-flex align-items-center">
<div class="input-group input-group-sm w-auto d-none d-md-flex"> <div class="input-group input-group-sm w-auto d-none d-md-flex">
<label class="input-group-text border-0" for="managementPageSize" i18n>Show:</label> <label class="input-group-text bg-transparent border-0" for="managementPageSize" i18n>Show:</label>
</div> </div>
<div class="input-group input-group-sm w-auto me-3"> <div class="input-group input-group-sm w-auto me-3">
<select id="managementPageSize" class="form-select form-select-sm small" [(ngModel)]="pageSize"> <select id="managementPageSize" class="form-select form-select-sm small" [(ngModel)]="pageSize">
@@ -113,7 +113,7 @@
<div class="btn-toolbar gap-2"> <div class="btn-toolbar gap-2">
<div class="btn-group d-block d-sm-none"> <div class="btn-group d-block d-sm-none">
<div ngbDropdown container="body" class="d-inline-block"> <div ngbDropdown container="body" class="d-inline-block">
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle> <button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle aria-label="Actions" i18n-aria-label>
<i-bs name="three-dots-vertical"></i-bs> <i-bs name="three-dots-vertical"></i-bs>
</button> </button>
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile"> <div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
@@ -58,7 +58,7 @@
<div class="col"> <div class="col">
<div class="btn-group d-block d-sm-none"> <div class="btn-group d-block d-sm-none">
<div ngbDropdown container="body" class="d-inline-block"> <div ngbDropdown container="body" class="d-inline-block">
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle> <button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle aria-label="Actions" i18n-aria-label>
<i-bs name="three-dots-vertical"></i-bs> <i-bs name="three-dots-vertical"></i-bs>
</button> </button>
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile"> <div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
@@ -146,7 +146,7 @@
<div class="col-3"> <div class="col-3">
<div class="btn-group d-block d-sm-none"> <div class="btn-group d-block d-sm-none">
<div ngbDropdown container="body" class="d-inline-block"> <div ngbDropdown container="body" class="d-inline-block">
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle> <button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle aria-label="Actions" i18n-aria-label>
<i-bs name="three-dots-vertical"></i-bs> <i-bs name="three-dots-vertical"></i-bs>
</button> </button>
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile"> <div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
@@ -47,7 +47,7 @@
<div class="btn-group d-block d-sm-none"> <div class="btn-group d-block d-sm-none">
<div ngbDropdown container="body" class="d-inline-block"> <div ngbDropdown container="body" class="d-inline-block">
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle> <button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle aria-label="Actions" i18n-aria-label>
<i-bs name="three-dots-vertical"></i-bs> <i-bs name="three-dots-vertical"></i-bs>
</button> </button>
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile"> <div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
+55
View File
@@ -54,6 +54,10 @@ export const ConfigCategory = {
AI: $localize`AI Settings`, AI: $localize`AI Settings`,
} }
export const ConfigSection = {
RemoteOCR: $localize`Remote OCR`,
}
export const LLMEmbeddingBackendConfig = { export const LLMEmbeddingBackendConfig = {
OPENAI_LIKE: 'openai-like', OPENAI_LIKE: 'openai-like',
HUGGINGFACE: 'huggingface', HUGGINGFACE: 'huggingface',
@@ -65,6 +69,15 @@ export const LLMBackendConfig = {
OLLAMA: 'ollama', OLLAMA: 'ollama',
} }
export const RemoteOCREngineConfig = {
AZURE_AI: 'azureai',
}
export const RemoteOCRModeConfig = {
ALWAYS: 'always',
WORKFLOW_ONLY: 'workflow_only',
}
export interface ConfigOption { export interface ConfigOption {
key: string key: string
title: string title: string
@@ -72,6 +85,7 @@ export interface ConfigOption {
choices?: Array<{ id: string; name: string }> choices?: Array<{ id: string; name: string }>
config_key?: string config_key?: string
category: string category: string
section?: string
note?: string note?: string
} }
@@ -181,6 +195,43 @@ export const PaperlessConfigOptions: ConfigOption[] = [
config_key: 'PAPERLESS_OCR_USER_ARGS', config_key: 'PAPERLESS_OCR_USER_ARGS',
category: ConfigCategory.OCR, category: ConfigCategory.OCR,
}, },
{
key: 'remote_ocr_engine',
title: $localize`Remote OCR Engine`,
type: ConfigOptionType.Select,
choices: mapToItems(RemoteOCREngineConfig),
config_key: 'PAPERLESS_REMOTE_OCR_ENGINE',
category: ConfigCategory.OCR,
section: ConfigSection.RemoteOCR,
note: $localize`Enabling remote OCR sends documents to a third-party service for processing. Consider the privacy implications as well as potential costs before enabling.`,
},
{
key: 'remote_ocr_api_key',
title: $localize`Remote OCR API Key`,
type: ConfigOptionType.Password,
config_key: 'PAPERLESS_REMOTE_OCR_API_KEY',
category: ConfigCategory.OCR,
section: ConfigSection.RemoteOCR,
},
{
key: 'remote_ocr_endpoint',
title: $localize`Remote OCR Endpoint`,
type: ConfigOptionType.String,
config_key: 'PAPERLESS_REMOTE_OCR_ENDPOINT',
category: ConfigCategory.OCR,
section: ConfigSection.RemoteOCR,
note: $localize`Required when using the Azure AI engine.`,
},
{
key: 'remote_ocr_mode',
title: $localize`Remote OCR Mode`,
type: ConfigOptionType.Select,
choices: mapToItems(RemoteOCRModeConfig),
config_key: 'PAPERLESS_REMOTE_OCR_MODE',
category: ConfigCategory.OCR,
section: ConfigSection.RemoteOCR,
note: $localize`Which documents are sent to the remote engine. Use 'workflow_only' to keep remote OCR off unless a workflow enables it for a document.`,
},
{ {
key: 'app_logo', key: 'app_logo',
title: $localize`Application Logo`, title: $localize`Application Logo`,
@@ -398,6 +449,10 @@ export interface PaperlessConfig extends ObjectWithId {
barcode_enable_tag: boolean barcode_enable_tag: boolean
barcode_tag_mapping: object barcode_tag_mapping: object
barcode_tag_split: boolean barcode_tag_split: boolean
remote_ocr_engine: string
remote_ocr_api_key: string
remote_ocr_endpoint: string
remote_ocr_mode: string
ai_enabled: boolean ai_enabled: boolean
llm_embedding_backend: string llm_embedding_backend: string
llm_embedding_model: string llm_embedding_model: string
+13
View File
@@ -1,5 +1,6 @@
import { PdfEditorEditMode } from '../components/common/pdf-editor/pdf-editor-edit-mode' import { PdfEditorEditMode } from '../components/common/pdf-editor/pdf-editor-edit-mode'
import { PdfZoomScale } from '../components/common/pdf-viewer/pdf-viewer.types' import { PdfZoomScale } from '../components/common/pdf-viewer/pdf-viewer.types'
import { RemoteOCRModeConfig } from './paperless-config'
import { User } from './user' import { User } from './user'
export interface UiSettings { export interface UiSettings {
@@ -94,6 +95,8 @@ export const SETTINGS_KEYS = {
OUTLOOK_OAUTH_URL: 'outlook_oauth_url', OUTLOOK_OAUTH_URL: 'outlook_oauth_url',
EMAIL_ENABLED: 'email_enabled', EMAIL_ENABLED: 'email_enabled',
AI_ENABLED: 'ai_enabled', AI_ENABLED: 'ai_enabled',
REMOTE_OCR_CONFIGURED: 'remote_ocr:configured',
REMOTE_OCR_MODE: 'remote_ocr:mode',
} }
export const SETTINGS: UiSetting[] = [ export const SETTINGS: UiSetting[] = [
@@ -347,4 +350,14 @@ export const SETTINGS: UiSetting[] = [
type: 'string', type: 'string',
default: PdfEditorEditMode.Create, default: PdfEditorEditMode.Create,
}, },
{
key: SETTINGS_KEYS.REMOTE_OCR_CONFIGURED,
type: 'boolean',
default: false,
},
{
key: SETTINGS_KEYS.REMOTE_OCR_MODE,
type: 'string',
default: RemoteOCRModeConfig.ALWAYS,
},
] ]
+1
View File
@@ -7,6 +7,7 @@ export enum WorkflowActionType {
Webhook = 4, Webhook = 4,
PasswordRemoval = 5, PasswordRemoval = 5,
MoveToTrash = 6, MoveToTrash = 6,
RemoteOcr = 7,
} }
export interface WorkflowActionEmail extends ObjectWithId { export interface WorkflowActionEmail extends ObjectWithId {
@@ -284,6 +284,21 @@ describe(`DocumentService`, () => {
expect(req.request.method).toEqual('POST') expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: ids, documents: ids,
remote_ocr: false,
})
})
it('should request remote OCR when reprocessing with it enabled', () => {
const ids = [1, 2, 3]
subscription = service
.reprocessDocuments({ documents: ids }, true)
.subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/reprocess/`
)
expect(req.request.body).toEqual({
documents: ids,
remote_ocr: true,
}) })
}) })
@@ -349,9 +349,13 @@ export class DocumentService extends AbstractPaperlessService<Document> {
}) })
} }
reprocessDocuments(selection: DocumentSelectionQuery) { reprocessDocuments(
selection: DocumentSelectionQuery,
remoteOcr: boolean = false
) {
return this.http.post(this.getResourceUrl(null, 'reprocess'), { return this.http.post(this.getResourceUrl(null, 'reprocess'), {
...selection, ...selection,
remote_ocr: remoteOcr,
}) })
} }
@@ -13,6 +13,7 @@ import { environment } from 'src/environments/environment'
import { CustomFieldDataType } from '../data/custom-field' import { CustomFieldDataType } from '../data/custom-field'
import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document' import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
import { SavedView } from '../data/saved-view' import { SavedView } from '../data/saved-view'
import { RemoteOCRModeConfig } from '../data/paperless-config'
import { SETTINGS_KEYS, UiSettings } from '../data/ui-settings' import { SETTINGS_KEYS, UiSettings } from '../data/ui-settings'
import { PermissionsService } from './permissions.service' import { PermissionsService } from './permissions.service'
import { CustomFieldsService } from './rest/custom-fields.service' import { CustomFieldsService } from './rest/custom-fields.service'
@@ -434,4 +435,26 @@ describe('SettingsService', () => {
).name ).name
).toEqual(customFields[0].name) ).toEqual(customFields[0].name)
}) })
it('should offer remote OCR only when configured and selective', () => {
settingsService.set(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED, false)
settingsService.set(
SETTINGS_KEYS.REMOTE_OCR_MODE,
RemoteOCRModeConfig.WORKFLOW_ONLY
)
expect(settingsService.remoteOCRIsSelectable).toBeFalsy()
// configured, but already handling every document
settingsService.set(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED, true)
settingsService.set(
SETTINGS_KEYS.REMOTE_OCR_MODE,
RemoteOCRModeConfig.ALWAYS
)
expect(settingsService.remoteOCRIsSelectable).toBeFalsy()
settingsService.set(
SETTINGS_KEYS.REMOTE_OCR_MODE,
RemoteOCRModeConfig.WORKFLOW_ONLY
)
expect(settingsService.remoteOCRIsSelectable).toBeTruthy()
})
}) })
@@ -19,6 +19,7 @@ import {
} from 'src/app/utils/color' } from 'src/app/utils/color'
import { DEFAULT_APP_TITLE, environment } from 'src/environments/environment' import { DEFAULT_APP_TITLE, environment } from 'src/environments/environment'
import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document' import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
import { RemoteOCRModeConfig } from '../data/paperless-config'
import { SavedView } from '../data/saved-view' import { SavedView } from '../data/saved-view'
import { import {
PAPERLESS_GREEN_HEX, PAPERLESS_GREEN_HEX,
@@ -687,6 +688,17 @@ export class SettingsService {
return this.settingIsSet(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED) return this.settingIsSet(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED)
} }
/**
* Offering remote OCR as a choice only makes sense when an engine
* is configured but is not already handling every document.
*/
get remoteOCRIsSelectable(): boolean {
return (
this.get(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED) &&
this.get(SETTINGS_KEYS.REMOTE_OCR_MODE) !== RemoteOCRModeConfig.ALWAYS
)
}
offerTour(): boolean { offerTour(): boolean {
return this.dashboardIsEmpty() && !this.get(SETTINGS_KEYS.TOUR_COMPLETE) return this.dashboardIsEmpty() && !this.get(SETTINGS_KEYS.TOUR_COMPLETE)
} }
@@ -438,6 +438,21 @@ describe('ConsumerStatusService', () => {
expect(updated).toBeTruthy() expect(updated).toBeTruthy()
}) })
it('should ignore keep-alive heartbeat messages from the server', () => {
let updated = false
let deleted = false
websocketStatusService.onDocumentUpdated().subscribe(() => (updated = true))
websocketStatusService.onDocumentDeleted().subscribe(() => (deleted = true))
websocketStatusService.connect()
server.send({ type: WebsocketStatusType.HEARTBEAT })
expect(updated).toBeFalsy()
expect(deleted).toBeFalsy()
expect(websocketStatusService.getConsumerStatus()).toHaveLength(0)
websocketStatusService.disconnect()
})
it('should ignore document updated events the user cannot view', () => { it('should ignore document updated events the user cannot view', () => {
let updated = false let updated = false
websocketStatusService.onDocumentUpdated().subscribe(() => { websocketStatusService.onDocumentUpdated().subscribe(() => {
@@ -11,6 +11,7 @@ export enum WebsocketStatusType {
STATUS_UPDATE = 'status_update', STATUS_UPDATE = 'status_update',
DOCUMENTS_DELETED = 'documents_deleted', DOCUMENTS_DELETED = 'documents_deleted',
DOCUMENT_UPDATED = 'document_updated', DOCUMENT_UPDATED = 'document_updated',
HEARTBEAT = 'heartbeat',
} }
// see ProgressStatusOptions in src/documents/plugins/helpers.py // see ProgressStatusOptions in src/documents/plugins/helpers.py
@@ -207,6 +208,10 @@ export class WebsocketStatusService {
case WebsocketStatusType.STATUS_UPDATE: case WebsocketStatusType.STATUS_UPDATE:
this.handleProgressUpdate(messageData as WebsocketProgressMessage) this.handleProgressUpdate(messageData as WebsocketProgressMessage)
break break
case WebsocketStatusType.HEARTBEAT:
// keep-alive from the server, see paperless.consumers.StatusConsumer
break
} }
} }
} }
+2
View File
@@ -118,6 +118,7 @@ import {
journalBookmarkFill, journalBookmarkFill,
journals, journals,
link, link,
list,
listNested, listNested,
listTask, listTask,
listUl, listUl,
@@ -365,6 +366,7 @@ const icons = {
journalBookmarkFill, journalBookmarkFill,
journals, journals,
link, link,
list,
listNested, listNested,
listTask, listTask,
listUl, listUl,
+38 -2
View File
@@ -1,7 +1,7 @@
@use "sass:meta"; @use "sass:meta";
// bs variables // bs variables
$grid-gutter-width: 1.5rem; $grid-gutter-width: 1.5rem;
$border-radius: .375rem; $border-radius: .425rem;
$btn-border-width: var(--bs-border-width); $btn-border-width: var(--bs-border-width);
$form-file-button-bg: var(--bs-body-bg); $form-file-button-bg: var(--bs-body-bg);
@@ -68,15 +68,44 @@ body {
--pngx-body-font-size: 0.875rem; --pngx-body-font-size: 0.875rem;
font-size: var(--pngx-body-font-size); font-size: var(--pngx-body-font-size);
height: 100vh; height: 100vh;
letter-spacing: -0.005em;
} }
* { * {
transition: background-color 0.3s ease, border-color 0.3s ease; transition: background-color 0.3s ease, border-color 0.3s ease;
} }
.card,
.dropdown-menu,
.modal-content,
.popover {
--bs-card-border-color: color-mix(in srgb, var(--bs-border-color) 72%, transparent);
border-radius: .55rem;
}
.card {
box-shadow: 0 1px 2px rgba(0, 0, 0, .04), 0 8px 24px rgba(0, 0, 0, .035);
}
.btn {
--bs-btn-border-radius: .425rem;
--bs-border-radius-sm: .425rem;
font-weight: 500;
}
.form-control,
.form-select,
.input-group-text {
border-radius: .425rem;
}
.pagination, .input-group {
--bs-border-radius-sm: .425rem;
}
@media(min-width: 768px) { @media(min-width: 768px) {
.col-slim { .col-slim {
padding-left: calc(50px + $grid-gutter-width) !important; padding-left: calc(56px + $grid-gutter-width) !important;
} }
} }
@@ -507,6 +536,7 @@ ul.pagination {
} }
.nav-underline { .nav-underline {
gap: calc(0.5 * var(--bs-nav-underline-gap)) var(--bs-nav-underline-gap);
.nav-link { .nav-link {
&.active, &:hover, &:focus { &.active, &:hover, &:focus {
color: var(--bs-primary); color: var(--bs-primary);
@@ -660,6 +690,10 @@ table.table {
--bs-toast-max-width: var(--pngx-toast-max-width); --bs-toast-max-width: var(--pngx-toast-max-width);
} }
.alert {
--bs-border-radius: .425rem;
}
.alert-primary { .alert-primary {
--bs-alert-color: var(--bs-primary); --bs-alert-color: var(--bs-primary);
--bs-alert-bg: var(--pngx-primary-faded); --bs-alert-bg: var(--pngx-primary-faded);
@@ -791,6 +825,8 @@ code {
--bs-accordion-bg: var(--bs-light); --bs-accordion-bg: var(--bs-light);
--bs-accordion-active-color: var(--bs-primary); --bs-accordion-active-color: var(--bs-primary);
--bs-accordion-active-bg: var(--pngx-bg-alt); --bs-accordion-active-bg: var(--pngx-bg-alt);
--bs-border-radius: .425rem;
--bs-accordion-inner-border-radius: calc(.425rem - 1px);
} }
.accordion-button::after { .accordion-button::after {
+3 -3
View File
@@ -20,7 +20,7 @@
--pngx-primary-darken-27: hsl(var(--pngx-primary), calc(var(--pngx-primary-lightness) - 27%)); --pngx-primary-darken-27: hsl(var(--pngx-primary), calc(var(--pngx-primary-lightness) - 27%));
--pngx-success-darken-10: hsl(152, 69%, 11%); // based on success #198754 --pngx-success-darken-10: hsl(152, 69%, 11%); // based on success #198754
--pngx-bg-alt: #fff; --pngx-bg-alt: #fff;
--pngx-bg-darker: var(--bs-gray-100); --pngx-bg-darker: #f4f6f5;
--pngx-bg-alt2: var(--bs-gray-200); // #e9ecef --pngx-bg-alt2: var(--bs-gray-200); // #e9ecef
--pngx-bg-disabled: #f7f7f7; --pngx-bg-disabled: #f7f7f7;
--pngx-card-hover-border: var(--bs-tertiary-color); --pngx-card-hover-border: var(--bs-tertiary-color);
@@ -91,7 +91,7 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,%3csvg xmlns='h
--pngx-body-color-accent: #{$text-color-dark-bg-accent}; --pngx-body-color-accent: #{$text-color-dark-bg-accent};
--pngx-bg-alt: #242529; --pngx-bg-alt: #242529;
--pngx-bg-alt2: #232323; --pngx-bg-alt2: #232323;
--pngx-bg-darker: #101216; --pngx-bg-darker: #121315;
--pngx-bg-disabled: var(--pngx-bg-alt); --pngx-bg-disabled: var(--pngx-bg-alt);
--pngx-card-hover-border: var(--bs-border-color); --pngx-card-hover-border: var(--bs-border-color);
--pngx-focus-alpha: 0.6; --pngx-focus-alpha: 0.6;
@@ -107,7 +107,7 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,%3csvg xmlns='h
--bs-light-rgb: 28, 28, 31; --bs-light-rgb: 28, 28, 31;
--bs-info: var(--pngx-bg-alt); --bs-info: var(--pngx-bg-alt);
--bs-info-rgb: 36, 36, 39; --bs-info-rgb: 36, 36, 39;
--bs-border-color: #47494f; --bs-border-color: #34373d;
--bs-tertiary-bg: var(--pngx-bg-darker); --bs-tertiary-bg: var(--pngx-bg-darker);
--bs-dark-border-subtle: var(--pngx-bg-darker); --bs-dark-border-subtle: var(--pngx-bg-darker);
--bs-border-color-translucent: rgba(0, 0, 0, .175); // override bs --bs-border-color-translucent: rgba(0, 0, 0, .175); // override bs
+54 -31
View File
@@ -27,7 +27,7 @@ from documents.models import DocumentType
from documents.models import PaperlessTask from documents.models import PaperlessTask
from documents.models import StoragePath from documents.models import StoragePath
from documents.models import Tag from documents.models import Tag
from documents.permissions import set_permissions_for_object from documents.permissions import set_permissions_for_objects
from documents.plugins.helpers import DocumentsStatusManager from documents.plugins.helpers import DocumentsStatusManager
from documents.tasks import bulk_update_documents from documents.tasks import bulk_update_documents
from documents.tasks import consume_file from documents.tasks import consume_file
@@ -305,33 +305,49 @@ def modify_custom_fields(
else [(field, None) for field in add_custom_fields] else [(field, None) for field in add_custom_fields]
) )
custom_fields = CustomField.objects.filter( custom_fields_by_id: dict[int, CustomField] = {
id__in=[int(field) for field, _ in add_custom_fields], cf.id: cf
).distinct() for cf in CustomField.objects.filter(
id__in=[int(field) for field, _ in add_custom_fields],
)
}
# Deferred, not `.only()`: these objects get cached onto the FK
# descriptor of newly-created CustomFieldInstance rows below, and
# downstream post_save receivers (e.g. the filename-generation signal)
# touch other Document fields -- `.only("pk")` would just turn that into
# a deferred-field reload per document, trading one N+1 for another.
# `content` is the one field guaranteed to be both large (full OCR text)
# and unused by anything this function or its receivers touch.
docs_by_id: dict[int, Document] = {
doc.id: doc
for doc in Document.objects.filter(id__in=affected_docs).defer("content")
}
for field_id, value in add_custom_fields: for field_id, value in add_custom_fields:
custom_field = custom_fields_by_id[field_id]
value_field = CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[
custom_field.data_type
]
for doc_id in affected_docs: for doc_id in affected_docs:
defaults = {} defaults = {value_field: value}
custom_field = custom_fields.get(id=field_id) if (
if custom_field: custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK
value_field = CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[ and value
custom_field.data_type and doc_id in value
] ):
defaults[value_field] = value # Prevent self-linking
if ( continue
custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK # Pass the already-resolved objects, not bare ids: this caches
and value # them on the FK descriptor of any newly-created instance, so a
and doc_id in value # later `.field`/`.document` access (e.g. auditlog's post_save
): # receiver calling `str(instance)`, which touches `.field.name`)
# Prevent self-linking # doesn't trigger its own per-instance re-fetch.
continue
CustomFieldInstance.objects.update_or_create( CustomFieldInstance.objects.update_or_create(
document_id=doc_id, document=docs_by_id[doc_id],
field_id=field_id, field=custom_field,
defaults=defaults, defaults=defaults,
) )
if custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK: if custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
doc = Document.objects.get(id=doc_id) reflect_doclinks(docs_by_id[doc_id], custom_field, value)
reflect_doclinks(doc, custom_field, value)
# For doc link fields that are being removed, remove symmetrical links # For doc link fields that are being removed, remove symmetrical links
for doclink_being_removed_instance in CustomFieldInstance.objects.filter( for doclink_being_removed_instance in CustomFieldInstance.objects.filter(
@@ -339,12 +355,10 @@ def modify_custom_fields(
field__id__in=remove_custom_fields, field__id__in=remove_custom_fields,
field__data_type=CustomField.FieldDataType.DOCUMENTLINK, field__data_type=CustomField.FieldDataType.DOCUMENTLINK,
value_document_ids__isnull=False, value_document_ids__isnull=False,
): ).select_related("field"):
for target_doc_id in doclink_being_removed_instance.value: for target_doc_id in doclink_being_removed_instance.value:
remove_doclink( remove_doclink(
document=Document.objects.get( document=docs_by_id[doclink_being_removed_instance.document_id],
id=doclink_being_removed_instance.document.id,
),
field=doclink_being_removed_instance.field, field=doclink_being_removed_instance.field,
target_doc_id=target_doc_id, target_doc_id=target_doc_id,
) )
@@ -399,10 +413,16 @@ def delete(doc_ids: list[int]) -> Literal["OK"]:
return "OK" return "OK"
def reprocess(doc_ids: list[int]) -> Literal["OK"]: def reprocess(doc_ids: list[int], *, remote_ocr: bool = False) -> Literal["OK"]:
"""
Re-run parsing for the given documents.
Consumption workflows do not run here, so ``remote_ocr`` is how the user
asks for the remote engine when it is not configured to handle everything.
"""
for document_id in doc_ids: for document_id in doc_ids:
update_document_content_maybe_archive_file.apply_async( update_document_content_maybe_archive_file.apply_async(
kwargs={"document_id": document_id}, kwargs={"document_id": document_id, "remote_ocr": remote_ocr},
headers={"trigger_source": PaperlessTask.TriggerSource.MANUAL}, headers={"trigger_source": PaperlessTask.TriggerSource.MANUAL},
) )
@@ -424,10 +444,13 @@ def set_permissions(
else: else:
qs.update(owner=owner) qs.update(owner=owner)
for doc in qs:
set_permissions_for_object(permissions=set_permissions, object=doc, merge=merge)
affected_docs = list(qs.values_list("pk", flat=True)) affected_docs = list(qs.values_list("pk", flat=True))
set_permissions_for_objects(
permissions=set_permissions,
model=Document,
pks=affected_docs,
merge=merge,
)
bulk_update_documents.apply_async( bulk_update_documents.apply_async(
kwargs={"document_ids": affected_docs}, kwargs={"document_ids": affected_docs},
+18
View File
@@ -53,6 +53,7 @@ from documents.utils import copy_basic_file_stats
from documents.utils import copy_file_with_basic_stats from documents.utils import copy_file_with_basic_stats
from documents.utils import run_subprocess from documents.utils import run_subprocess
from paperless.config import OcrConfig from paperless.config import OcrConfig
from paperless.config import RemoteOCRConfig
from paperless.models import ArchiveFileGenerationChoices from paperless.models import ArchiveFileGenerationChoices
from paperless.parsers import ParserContext from paperless.parsers import ParserContext
from paperless.parsers import ParserProtocol from paperless.parsers import ParserProtocol
@@ -451,12 +452,19 @@ class ConsumerPlugin(
except Exception as e: except Exception as e:
self.log.error(f"Error attempting to clean PDF: {e}") self.log.error(f"Error attempting to clean PDF: {e}")
# Workflows have already run at this point, so the metadata knows
# whether this document was singled out for remote OCR
allow_remote = (
self.metadata.remote_ocr or RemoteOCRConfig().remote_ocr_by_default
)
# Based on the mime type, get the parser for that type # Based on the mime type, get the parser for that type
parser_class: type[ParserProtocol] | None = ( parser_class: type[ParserProtocol] | None = (
get_parser_registry().get_parser_for_file( get_parser_registry().get_parser_for_file(
mime_type, mime_type,
self.filename, self.filename,
self.working_copy, self.working_copy,
allow_remote=allow_remote,
) )
) )
if not parser_class: if not parser_class:
@@ -465,6 +473,16 @@ class ConsumerPlugin(
f"Unsupported mime type {mime_type}", f"Unsupported mime type {mime_type}",
) )
if self.metadata.remote_ocr and not getattr(
parser_class,
"uses_remote_service",
False,
):
self.log.warning(
"Remote OCR was requested for this document but no remote "
"parser is available for it, processing locally instead.",
)
# Notify all listeners that we're going to do some work. # Notify all listeners that we're going to do some work.
document_consumption_started.send( document_consumption_started.send(
+4 -1
View File
@@ -34,6 +34,7 @@ class DocumentMetadataOverrides:
skip_asn_if_exists: bool = False skip_asn_if_exists: bool = False
version_label: str | None = None version_label: str | None = None
actor_id: int | None = None actor_id: int | None = None
remote_ocr: bool = False
def update(self, other: "DocumentMetadataOverrides") -> "DocumentMetadataOverrides": def update(self, other: "DocumentMetadataOverrides") -> "DocumentMetadataOverrides":
""" """
@@ -57,6 +58,8 @@ class DocumentMetadataOverrides:
self.actor_id = other.actor_id self.actor_id = other.actor_id
if other.skip_asn_if_exists: if other.skip_asn_if_exists:
self.skip_asn_if_exists = True self.skip_asn_if_exists = True
if other.remote_ocr:
self.remote_ocr = True
if other.version_label is not None: if other.version_label is not None:
self.version_label = other.version_label self.version_label = other.version_label
@@ -126,7 +129,7 @@ class DocumentMetadataOverrides:
) )
overrides.custom_fields = { overrides.custom_fields = {
custom_field.field.id: custom_field.value custom_field.field.id: custom_field.value
for custom_field in doc.custom_fields.all() for custom_field in doc.custom_fields.select_related("field").all()
} }
groups_with_perms = get_groups_with_perms( groups_with_perms = get_groups_with_perms(
@@ -0,0 +1,30 @@
# Generated by Django 5.2.16 on 2026-08-10 17:27
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("documents", "0023_savedview_icon"),
]
operations = [
migrations.AlterField(
model_name="workflowaction",
name="type",
field=models.PositiveSmallIntegerField(
choices=[
(1, "Assignment"),
(2, "Removal"),
(3, "Email"),
(4, "Webhook"),
(5, "Password removal"),
(6, "Move to trash"),
(7, "Remote OCR"),
],
default=1,
verbose_name="Workflow Action Type",
),
),
]
+4
View File
@@ -1670,6 +1670,10 @@ class WorkflowAction(models.Model):
6, 6,
_("Move to trash"), _("Move to trash"),
) )
REMOTE_OCR = (
7,
_("Remote OCR"),
)
type = models.PositiveSmallIntegerField( type = models.PositiveSmallIntegerField(
_("Workflow Action Type"), _("Workflow Action Type"),
+176
View File
@@ -173,6 +173,182 @@ def set_permissions_for_object(
) )
def _resolve_permissions(codenames: set[str], ctype: ContentType) -> list[Permission]:
"""
Resolves `codenames` to Permission rows, raising like the single-object
assign_perm() this bulk path replaces does (via a `.get()` internally)
if any codename doesn't exist -- e.g. a client-supplied action name that
was never validated (BulkEditObjectsSerializer._validate_permissions
calls validate_set_permissions() only for its side-effecting id checks
and discards the filtered dict it returns, so an unrecognized action key
reaches this function as-is). A plain `.filter()` with no existence
check would otherwise silently build zero rows and no-op instead of
reporting the bad input.
"""
permission_objs = list(
Permission.objects.filter(content_type=ctype, codename__in=codenames),
)
missing = codenames - {p.codename for p in permission_objs}
if missing:
raise Permission.DoesNotExist(
f"Permission matching query does not exist for codename(s): "
f"{', '.join(sorted(missing))}",
)
return permission_objs
# Target number of permission rows to build in Python before handing them to
# bulk_create -- keeps peak memory bounded for a large "apply to all" call,
# independent of bulk_create's own batch_size (which only caps the size of
# each INSERT statement, not how many row objects exist in memory at once).
_PERMISSION_ROW_CHUNK_SIZE = 5000
def _apply_bulk_permission_entry(
*,
perm_model: type[UserObjectPermission] | type[GroupObjectPermission],
identity_model: type[User] | type[Group],
identity_field: str,
ids: list[int],
codename: str,
permission_objs: list[Permission],
ctype: ContentType,
object_pks: list[str],
merge: bool,
) -> None:
# Only the ids are needed to build permission rows (via `<field>_id=`),
# so avoid fetching full User/Group rows for identities that may not
# even end up being granted anything new.
add_ids = set(
identity_model.objects.filter(id__in=ids).values_list("id", flat=True),
)
if not merge:
existing_ids = set(
perm_model.objects.filter(
content_type=ctype,
object_pk__in=object_pks,
permission__codename=codename,
).values_list(f"{identity_field}_id", flat=True),
)
remove_ids = existing_ids - add_ids
if remove_ids:
perm_model.objects.filter(
content_type=ctype,
object_pk__in=object_pks,
permission__codename=codename,
**{f"{identity_field}_id__in": remove_ids},
).delete()
if not add_ids:
return
rows_per_pk = len(permission_objs) * len(add_ids)
pks_per_chunk = max(1, _PERMISSION_ROW_CHUNK_SIZE // rows_per_pk)
for start in range(0, len(object_pks), pks_per_chunk):
pk_chunk = object_pks[start : start + pks_per_chunk]
rows = [
perm_model(
content_type=ctype,
object_pk=pk,
permission=permission_obj,
**{f"{identity_field}_id": identity_id},
)
for permission_obj in permission_objs
for pk in pk_chunk
for identity_id in add_ids
]
# ignore_conflicts skips only rows that already exist as an exact
# (identity, permission, object) match -- the same de-dup the
# underlying (user|group, permission, object_pk) unique constraint
# already enforces for the single-object assign_perm() this
# replaces, so it doesn't change what counts as "already granted".
# batch_size caps how many rows go into a single INSERT so a huge
# chunk doesn't build one enormous statement.
perm_model.objects.bulk_create(rows, ignore_conflicts=True, batch_size=1000)
def set_permissions_for_objects(
permissions: dict,
model: type[Model],
pks: QuerySet | list,
*,
merge: bool = False,
) -> None:
"""
Bulk equivalent of set_permissions_for_object: applies the same
permission changes to every object identified by `pks` at once.
Takes a model + pks (rather than model instances) deliberately -- the
permission rows built below only ever need `pk`, `content_type`, and
identity ids, so callers shouldn't have to fetch full rows (with every
other field) just to hand them to this function.
Deliberately does not use guardian's queryset/list-aware assign_perm:
passing a list as the object routes to bulk_assign_perm, which skips
creating a direct permission row for anyone who already has the
permission via ANY group membership (it checks
ObjectPermissionChecker.has_perm, which is group-inheritance-aware) --
unlike the single-object assign_perm this replaces, which always
ensures a direct row via get_or_create regardless of group-derived
access. Losing that guarantee would mean a later revocation of the
group's grant silently strips access an admin explicitly asked to be
direct. Bulk-creating rows straight against the permission models
instead (see _apply_bulk_permission_entry) preserves the original
always-create-a-direct-row semantics while still batching every object
and every identity into one query per action, rather than one query per
(object, user) pair.
"""
object_pks = [str(pk) for pk in pks]
if not object_pks:
return
model_name = model.__name__.lower()
ctype = ContentType.objects.get_for_model(model)
for action, entry in permissions.items():
codename = f"{action}_{model_name}"
implied_codenames = {codename}
if action == "change":
# change gives view too
implied_codenames.add(f"view_{model_name}")
# Resolved once per action (not once per users/groups branch) and
# shared between both below -- also where an unrecognized action
# name (see _resolve_permissions) is caught.
permission_objs = (
_resolve_permissions(implied_codenames, ctype)
if "users" in entry or "groups" in entry
else []
)
if "users" in entry:
_apply_bulk_permission_entry(
perm_model=UserObjectPermission,
identity_model=User,
identity_field="user",
ids=entry["users"],
codename=codename,
permission_objs=permission_objs,
ctype=ctype,
object_pks=object_pks,
merge=merge,
)
if "groups" in entry:
_apply_bulk_permission_entry(
perm_model=GroupObjectPermission,
identity_model=Group,
identity_field="group",
ids=entry["groups"],
codename=codename,
permission_objs=permission_objs,
ctype=ctype,
object_pks=object_pks,
merge=merge,
)
def permitted_object_ids( def permitted_object_ids(
user: User | None, user: User | None,
model: type[Model], model: type[Model],
+13 -25
View File
@@ -20,10 +20,7 @@ from typing import cast
import filelock import filelock
import tantivy import tantivy
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.utils.timezone import get_current_timezone from django.utils.timezone import get_current_timezone
from guardian.shortcuts import get_groups_with_perms
from guardian.shortcuts import get_users_with_perms
from documents.search._query import build_permission_filter from documents.search._query import build_permission_filter
from documents.search._query import extract_cjk_text from documents.search._query import extract_cjk_text
@@ -266,11 +263,7 @@ class WriteBatch:
if self._lock is not None: if self._lock is not None:
self._lock.release() self._lock.release()
def add_or_update( def add_or_update(self, document: Document) -> None:
self,
document: Document,
effective_content: str | None = None,
) -> None:
""" """
Add or update a document in the batch. Add or update a document in the batch.
@@ -280,11 +273,9 @@ class WriteBatch:
Args: Args:
document: Django Document instance to index document: Django Document instance to index
effective_content: Override document.content for indexing (used when
re-indexing with newer OCR text from document versions)
""" """
self.remove(document.pk) self.remove(document.pk)
doc = self._backend._build_tantivy_doc(document, effective_content) doc = self._backend._build_tantivy_doc(document)
self._writer.add_document(doc) self._writer.add_document(doc)
def remove(self, doc_id: int) -> None: def remove(self, doc_id: int) -> None:
@@ -425,18 +416,20 @@ class TantivyBackend:
def _build_tantivy_doc( def _build_tantivy_doc(
self, self,
document: Document, document: Document,
effective_content: str | None = None,
viewer_ids: list[int] | None = None, viewer_ids: list[int] | None = None,
viewer_group_ids: list[int] | None = None, viewer_group_ids: list[int] | None = None,
) -> tantivy.Document: ) -> tantivy.Document:
"""Build a tantivy Document from a Django Document instance. """Build a tantivy Document from a Django Document instance.
``effective_content`` overrides ``document.content`` for indexing A root document is indexed with its effective content, i.e. the newest
used when re-indexing a root document with a newer version's OCR text. version's OCR text, so it is never indexed with its own outdated text.
Annotate the queryset with ``annotate_effective_content`` when indexing
more than a couple of documents, to resolve that without a query each.
""" """
content = ( from guardian.shortcuts import get_groups_with_perms
effective_content if effective_content is not None else document.content from guardian.shortcuts import get_users_with_perms
)
content = document.get_effective_content() or ""
doc = tantivy.Document() doc = tantivy.Document()
@@ -584,11 +577,7 @@ class TantivyBackend:
return doc return doc
def add_or_update( def add_or_update(self, document: Document) -> None:
self,
document: Document,
effective_content: str | None = None,
) -> None:
""" """
Add or update a single document with file locking. Add or update a single document with file locking.
@@ -601,12 +590,11 @@ class TantivyBackend:
Args: Args:
document: Django Document instance to index document: Django Document instance to index
effective_content: Override document.content for indexing
""" """
self._ensure_open() self._ensure_open()
try: try:
with self.batch_update(lock_timeout=_LOCK_TIMEOUT_SECONDS) as batch: with self.batch_update(lock_timeout=_LOCK_TIMEOUT_SECONDS) as batch:
batch.add_or_update(document, effective_content) batch.add_or_update(document)
except SearchIndexLockError: except SearchIndexLockError:
logger.error( logger.error(
"Search index lock exhausted for document %d after %d attempts; " "Search index lock exhausted for document %d after %d attempts; "
@@ -1027,7 +1015,6 @@ class TantivyBackend:
): ):
doc = self._build_tantivy_doc( doc = self._build_tantivy_doc(
document, document,
document.get_effective_content(),
viewer_ids=viewer_ids, viewer_ids=viewer_ids,
viewer_group_ids=viewer_group_ids, viewer_group_ids=viewer_group_ids,
) )
@@ -1095,6 +1082,7 @@ def _bulk_get_viewer_permissions(
""" """
from collections import defaultdict from collections import defaultdict
from django.contrib.contenttypes.models import ContentType
from guardian.models import GroupObjectPermission from guardian.models import GroupObjectPermission
from guardian.models import UserObjectPermission from guardian.models import UserObjectPermission
+281 -14
View File
@@ -1,8 +1,10 @@
from __future__ import annotations from __future__ import annotations
import contextlib
import logging import logging
import math import math
import re import re
from collections.abc import Iterable
from datetime import datetime from datetime import datetime
from datetime import timedelta from datetime import timedelta
from decimal import Decimal from decimal import Decimal
@@ -24,6 +26,7 @@ from django.core.validators import MaxValueValidator
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from django.core.validators import integer_validator from django.core.validators import integer_validator
from django.db import DataError
from django.db.models import Count from django.db.models import Count
from django.db.models import Q from django.db.models import Q
from django.db.models.functions import Lower from django.db.models.functions import Lower
@@ -37,12 +40,12 @@ from django.utils.timezone import make_aware
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from drf_spectacular.utils import extend_schema_field from drf_spectacular.utils import extend_schema_field
from drf_spectacular.utils import extend_schema_serializer from drf_spectacular.utils import extend_schema_serializer
from drf_writable_nested.serializers import NestedUpdateMixin
from guardian.core import ObjectPermissionChecker from guardian.core import ObjectPermissionChecker
from guardian.shortcuts import get_users_with_perms from guardian.shortcuts import get_users_with_perms
from guardian.utils import get_group_obj_perms_model from guardian.utils import get_group_obj_perms_model
from guardian.utils import get_user_obj_perms_model from guardian.utils import get_user_obj_perms_model
from rest_framework import fields from rest_framework import fields
from rest_framework import relations
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
from rest_framework.fields import SerializerMethodField from rest_framework.fields import SerializerMethodField
@@ -742,22 +745,100 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
return super().validate(attrs) return super().validate(attrs)
class CorrespondentField(serializers.PrimaryKeyRelatedField[Correspondent]): class _BatchingManyRelatedField(serializers.ManyRelatedField):
"""
`ManyRelatedField.to_internal_value` resolves each id in the submitted
list with its own `child_relation.to_internal_value()` call -- one query
per item on every PATCH/PUT that sets a `many=True` relation field.
Batch-resolve them instead, falling back to the child relation's normal
(query-per-item) validation for anything that isn't a plausible int pk,
so bad input still gets the usual DRF validation error rather than being
silently dropped.
"""
@staticmethod
def _normalize_pk(item) -> int | None:
# Excludes bool: DRF's own PrimaryKeyRelatedField rejects it too
# (True == 1 would otherwise silently match pk 1).
if isinstance(item, bool):
return None
try:
return int(item)
except (TypeError, ValueError):
return None
def to_internal_value(self, data):
if isinstance(data, str) or not hasattr(data, "__iter__"):
self.fail("not_a_list", input_type=type(data).__name__)
if not self.allow_empty and len(data) == 0:
self.fail("empty")
item_pks = [(item, self._normalize_pk(item)) for item in data]
candidate_pks = {pk for _, pk in item_pks if pk is not None}
# Django's IntegerFieldOverflow guard (-> EmptyResultSet, i.e. no
# match) only covers exact/gt/gte/lt/lte lookups, not `in` -- an
# out-of-range int in `pk__in=` reaches the DB driver as-is and
# raises OverflowError (SQLite) / DataError (Postgres) instead of
# cleanly matching nothing. The per-item `exact`-lookup fallback
# below IS covered, so on that failure just skip the batch and let
# every item resolve individually -- each still costs one query,
# but reports the normal validation error instead of a raw 500.
try:
resolved_by_pk = {
obj.pk: obj
for obj in self.child_relation.get_queryset().filter(
pk__in=candidate_pks,
)
}
except (OverflowError, DataError):
resolved_by_pk = {}
result = []
for item, pk in item_pks:
obj = resolved_by_pk.get(pk) if pk is not None else None
result.append(
obj if obj is not None else self.child_relation.to_internal_value(item),
)
return result
class BatchResolvingPrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
"""
A PrimaryKeyRelatedField whose `many=True` form (a DRF ManyRelatedField)
resolves all submitted ids with one batched query instead of one query
per id. Subclasses only need to implement `get_queryset()` as usual --
only `TagsField` is used with `many=True` today, but this is the base
for all four so the fix isn't tag-specific: if a future PR puts
`many=True` on correspondent/document_type/storage_path, it inherits the
same batching instead of reintroducing this as a new bug to rediscover.
"""
@classmethod
def many_init(cls, *args, **kwargs):
list_kwargs = {"child_relation": cls(*args, **kwargs)}
for key, value in kwargs.items():
if key in relations.MANY_RELATION_KWARGS:
list_kwargs[key] = value
return _BatchingManyRelatedField(**list_kwargs)
class CorrespondentField(BatchResolvingPrimaryKeyRelatedField[Correspondent]):
def get_queryset(self): def get_queryset(self):
return Correspondent.objects.all() return Correspondent.objects.all()
class TagsField(serializers.PrimaryKeyRelatedField[Tag]): class TagsField(BatchResolvingPrimaryKeyRelatedField[Tag]):
def get_queryset(self): def get_queryset(self):
return Tag.objects.all() return Tag.objects.all()
class DocumentTypeField(serializers.PrimaryKeyRelatedField[DocumentType]): class DocumentTypeField(BatchResolvingPrimaryKeyRelatedField[DocumentType]):
def get_queryset(self): def get_queryset(self):
return DocumentType.objects.all() return DocumentType.objects.all()
class StoragePathField(serializers.PrimaryKeyRelatedField[StoragePath]): class StoragePathField(BatchResolvingPrimaryKeyRelatedField[StoragePath]):
def get_queryset(self): def get_queryset(self):
return StoragePath.objects.all() return StoragePath.objects.all()
@@ -876,8 +957,106 @@ def validate_documentlink_targets(user, doc_ids):
) )
# A CustomField lookup cache scoped to a single field/serializer instance
# only helps within that one instance's own validation pass. Several call
# sites, though, build more than one CustomFieldInstanceSerializer (or its
# CustomFieldInstanceListSerializer/field) for the same request and pass
# each of them `context=self.context` -- the *same* dict object, not a
# copy -- e.g. bulk-edit's _validate_custom_field_values() constructing a
# fresh CustomFieldInstanceSerializer per submitted field. That context
# dict is already request-scoped (DRF builds it fresh per request via
# get_serializer_context()), so stashing the resolved CustomField objects
# there -- rather than in some new global/thread-local cache -- lets every
# one of those separately-instantiated serializers reuse them for free
# while staying entirely within DRF's existing, already-request-scoped
# machinery.
_CUSTOM_FIELD_CONTEXT_CACHE_KEY = "_custom_field_lookup_cache"
class _CachingCustomFieldPrimaryKeyField(serializers.PrimaryKeyRelatedField):
"""
Resolves CustomField ids with as few queries as possible: a per-instance
cache for repeat lookups on this exact field instance, backed by a
shared cache on the serializer context (see _CUSTOM_FIELD_CONTEXT_CACHE_KEY
above) so later, separately-instantiated fields for the same request
reuse what was already resolved instead of re-querying.
"""
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self._cache: dict[int, CustomField] = {}
def _shared_cache(self) -> dict[int, CustomField]:
return self.context.setdefault(_CUSTOM_FIELD_CONTEXT_CACHE_KEY, {})
@staticmethod
def _normalize_pk(data: Any) -> int | None:
"""
Returns `data` coerced to the int a valid CustomField pk would be,
or None if `data` isn't a plausible pk (wrong type, unhashable,
non-numeric, or a bool -- DRF itself rejects bools as pks since
`True == 1` would otherwise silently match). None tells callers to
leave `data` alone and let `super().to_internal_value()` report the
normal validation error instead of touching the cache/queryset with
it directly.
"""
if isinstance(data, bool):
return None
try:
return int(data)
except (TypeError, ValueError):
return None
def prefetch(self, ids: Iterable[Any]) -> None:
shared_cache = self._shared_cache()
candidates = {pk for i in ids if (pk := self._normalize_pk(i)) is not None}
missing = {
i for i in candidates if i not in self._cache and i not in shared_cache
}
if missing:
for obj in self.get_queryset().filter(pk__in=missing):
shared_cache[obj.pk] = obj
for i in candidates:
obj = shared_cache.get(i)
if obj is not None:
self._cache[i] = obj
def to_internal_value(self, data: Any) -> CustomField:
pk = self._normalize_pk(data)
if pk is None:
return super().to_internal_value(data)
if pk in self._cache:
return self._cache[pk]
shared_cache = self._shared_cache()
if pk in shared_cache:
obj = shared_cache[pk]
self._cache[pk] = obj
return obj
obj: CustomField = super().to_internal_value(data)
self._cache[obj.pk] = obj
shared_cache[obj.pk] = obj
return obj
class CustomFieldInstanceListSerializer(serializers.ListSerializer):
def to_internal_value(self, data: Any) -> list[Any]:
if isinstance(data, list):
field_ids = []
for item in data:
if not isinstance(item, dict) or "field" not in item:
continue
try:
hash(item["field"])
except TypeError:
continue
field_ids.append(item["field"])
if field_ids:
self.child.fields["field"].prefetch(field_ids)
return super().to_internal_value(data)
class CustomFieldInstanceSerializer(serializers.ModelSerializer[CustomFieldInstance]): class CustomFieldInstanceSerializer(serializers.ModelSerializer[CustomFieldInstance]):
field = serializers.PrimaryKeyRelatedField(queryset=CustomField.objects.all()) field = _CachingCustomFieldPrimaryKeyField(queryset=CustomField.objects.all())
value = ReadWriteSerializerMethodField(allow_null=True) value = ReadWriteSerializerMethodField(allow_null=True)
def create(self, validated_data): def create(self, validated_data):
@@ -978,6 +1157,7 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer[CustomFieldInsta
class Meta: class Meta:
model = CustomFieldInstance model = CustomFieldInstance
list_serializer_class = CustomFieldInstanceListSerializer
fields = [ fields = [
"value", "value",
"field", "field",
@@ -1043,7 +1223,6 @@ class DocumentVersionInfoSerializer(serializers.Serializer[_DocumentVersionInfo]
) )
class DocumentSerializer( class DocumentSerializer(
OwnedObjectSerializer, OwnedObjectSerializer,
NestedUpdateMixin,
DocumentUpdateFieldsModelSerializer, DocumentUpdateFieldsModelSerializer,
): ):
correspondent = CorrespondentField(allow_null=True) correspondent = CorrespondentField(allow_null=True)
@@ -1258,16 +1437,60 @@ class DocumentSerializer(
if tag not in inbox_tags_not_being_added if tag not in inbox_tags_not_being_added
] ]
if settings.AUDIT_LOG_ENABLED: custom_fields_data = validated_data.pop("custom_fields", None)
with set_actor(self.user):
super().update(instance, validated_data) actor_context = (
else: set_actor(self.user)
if settings.AUDIT_LOG_ENABLED
else contextlib.nullcontext()
)
with actor_context:
super().update(instance, validated_data) super().update(instance, validated_data)
if custom_fields_data is not None:
self._sync_custom_fields(instance, custom_fields_data)
# hard delete custom field instances that were soft deleted
CustomFieldInstance.deleted_objects.filter(document=instance).delete()
return instance return instance
def _sync_custom_fields(
self,
instance: Document,
custom_fields_data: list[dict],
) -> None:
"""
Create/update a CustomFieldInstance for every (field, value) pair in
custom_fields_data, then hard-delete any of the document's existing
instances whose field wasn't included.
Replaces drf-writable-nested's generic
update_or_create_reverse_relations()/delete_reverse_relations_if_need():
that machinery always matched submitted items by an instance "id"
this client payload never sends, so its own pk-matching never did
anything for this field -- the real upsert semantics were always
CustomFieldInstanceSerializer.create()'s update_or_create() below.
On a partial (PATCH) update, DRF skips the "value" field's required
check on the first validation pass because it's absent entirely from
the payload item, not merely null. drf-writable-nested happened to
re-enforce that check itself, by re-validating each item against a
freshly built, non-partial child serializer before saving. Reproduce
that specific guarantee explicitly here, since CustomFieldInstance's
"value" is not optional.
"""
for item in custom_fields_data:
if "value" not in item:
raise serializers.ValidationError(
{"custom_fields": [{"value": ["This field is required."]}]},
)
kept_field_ids: set[int] = set()
serializer = CustomFieldInstanceSerializer()
for item in custom_fields_data:
kept_field_ids.add(item["field"].pk)
serializer.create({**item, "document": instance})
CustomFieldInstance.objects.filter(document=instance).exclude(
field_id__in=kept_field_ids,
).hard_delete()
def __init__(self, *args, **kwargs) -> None: def __init__(self, *args, **kwargs) -> None:
self.truncate_content = kwargs.pop("truncate_content", False) self.truncate_content = kwargs.pop("truncate_content", False)
@@ -1795,7 +2018,7 @@ class DeleteDocumentsSerializer(DocumentSelectionSerializer):
class ReprocessDocumentsSerializer(DocumentSelectionSerializer): class ReprocessDocumentsSerializer(DocumentSelectionSerializer):
pass remote_ocr = serializers.BooleanField(required=False, default=False)
class BulkEditSerializer( class BulkEditSerializer(
@@ -2137,6 +2360,13 @@ class BulkEditSerializer(
f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.", f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.",
) )
def _validate_parameters_reprocess(self, parameters) -> None:
if "remote_ocr" in parameters:
if not isinstance(parameters["remote_ocr"], bool):
raise serializers.ValidationError("remote_ocr must be a boolean")
else:
parameters["remote_ocr"] = False
def validate_parameters_remove_password(self, parameters): def validate_parameters_remove_password(self, parameters):
if "password" not in parameters: if "password" not in parameters:
raise serializers.ValidationError("password not specified") raise serializers.ValidationError("password not specified")
@@ -2201,6 +2431,8 @@ class BulkEditSerializer(
self._validate_parameters_edit_pdf(parameters, attrs["documents"][0]) self._validate_parameters_edit_pdf(parameters, attrs["documents"][0])
elif method == bulk_edit.remove_password: elif method == bulk_edit.remove_password:
self.validate_parameters_remove_password(parameters) self.validate_parameters_remove_password(parameters)
elif method == bulk_edit.reprocess:
self._validate_parameters_reprocess(parameters)
return attrs return attrs
@@ -3303,6 +3535,41 @@ class WorkflowSerializer(serializers.ModelSerializer[Workflow]):
"actions", "actions",
] ]
def validate(self, attrs):
attrs = super().validate(attrs)
if "actions" in attrs:
has_remote_ocr_action = any(
action.get("type") == WorkflowAction.WorkflowActionType.REMOTE_OCR
for action in attrs["actions"]
)
else:
has_remote_ocr_action = self.instance is not None and (
self.instance.actions.filter(
type=WorkflowAction.WorkflowActionType.REMOTE_OCR,
).exists()
)
if "triggers" in attrs:
has_consumption_trigger = any(
trigger.get("type") == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION
for trigger in attrs["triggers"]
)
else:
has_consumption_trigger = self.instance is not None and (
self.instance.triggers.filter(
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
).exists()
)
# Remote OCR can only work with consumption triggers
if has_remote_ocr_action and not has_consumption_trigger:
raise serializers.ValidationError(
"Remote OCR actions require a consumption started trigger",
)
return attrs
def update_triggers_and_actions( def update_triggers_and_actions(
self, self,
instance: Workflow, instance: Workflow,
+17 -4
View File
@@ -794,10 +794,12 @@ def cleanup_user_deletion(sender, instance: User | Group, **kwargs) -> None:
def add_to_index(sender, document, **kwargs) -> None: def add_to_index(sender, document, **kwargs) -> None:
from documents.search import get_backend from documents.search import get_backend
get_backend().add_or_update( # A newly consumed version is not searchable on its own, its content
document, # becomes the effective_content of the root document
effective_content=document.get_effective_content(), if document.root_document_id:
) document = document.root_document
get_backend().add_or_update(document)
def run_workflows_added( def run_workflows_added(
@@ -971,6 +973,17 @@ def run_workflows(
) )
elif action.type == WorkflowAction.WorkflowActionType.MOVE_TO_TRASH: elif action.type == WorkflowAction.WorkflowActionType.MOVE_TO_TRASH:
has_move_to_trash_action = True has_move_to_trash_action = True
elif action.type == WorkflowAction.WorkflowActionType.REMOTE_OCR:
if use_overrides and overrides:
overrides.remote_ocr = True
else:
# If a workflow has a consumption trigger *and* another type,
# the document has already been parsed by the time the other one fires
logger.debug(
"Remote OCR action only applies to consumption "
"triggers, ignoring",
extra={"group": logging_group},
)
if not use_overrides: if not use_overrides:
# limit title to 128 characters # limit title to 128 characters
+16 -6
View File
@@ -64,8 +64,10 @@ from documents.signals.handlers import send_websocket_document_updated
from documents.utils import IterWrapper from documents.utils import IterWrapper
from documents.utils import compute_checksum from documents.utils import compute_checksum
from documents.utils import identity from documents.utils import identity
from documents.versioning import annotate_effective_content
from documents.workflows.utils import get_workflows_for_trigger from documents.workflows.utils import get_workflows_for_trigger
from paperless.config import AIConfig from paperless.config import AIConfig
from paperless.config import RemoteOCRConfig
from paperless.logging import consume_task_id from paperless.logging import consume_task_id
from paperless.parsers import ParserContext from paperless.parsers import ParserContext
from paperless.parsers.registry import get_parser_registry from paperless.parsers.registry import get_parser_registry
@@ -114,10 +116,7 @@ def index_document(self, document_id: int) -> None:
) )
return return
with get_backend().batch_update() as batch: with get_backend().batch_update() as batch:
batch.add_or_update( batch.add_or_update(document)
document,
effective_content=document.get_effective_content(),
)
@shared_task( @shared_task(
@@ -312,7 +311,10 @@ def bulk_update_documents(document_ids) -> None:
from documents.search import get_backend from documents.search import get_backend
document_ids = list(document_ids) document_ids = list(document_ids)
documents = Document.objects.filter(id__in=document_ids) # Annotated so indexing below doesn't query the versions of each document
documents = annotate_effective_content(
Document.objects.filter(id__in=document_ids),
)
for doc in documents: for doc in documents:
clear_document_caches(doc.pk) clear_document_caches(doc.pk)
@@ -337,10 +339,17 @@ def bulk_update_documents(document_ids) -> None:
@shared_task @shared_task
def update_document_content_maybe_archive_file(document_id) -> None: def update_document_content_maybe_archive_file(
document_id,
*,
remote_ocr: bool = False,
) -> None:
""" """
Re-creates OCR content and thumbnail for a document, and archive file if Re-creates OCR content and thumbnail for a document, and archive file if
it exists. it exists.
Remote OCR is used only when the engine is configured to handle everything
or if explicitly asked for via ``remote_ocr``.
""" """
document = Document.objects.get(id=document_id) document = Document.objects.get(id=document_id)
@@ -350,6 +359,7 @@ def update_document_content_maybe_archive_file(document_id) -> None:
mime_type, mime_type,
document.original_filename or "", document.original_filename or "",
document.source_path, document.source_path,
allow_remote=remote_ocr or RemoteOCRConfig().remote_ocr_by_default,
) )
if not parser_class: if not parser_class:
@@ -16,6 +16,7 @@ from documents.search._backend import TantivyBackend
from documents.search._backend import WriteBatch from documents.search._backend import WriteBatch
from documents.search._backend import get_backend from documents.search._backend import get_backend
from documents.search._backend import reset_backend from documents.search._backend import reset_backend
from documents.signals.handlers import add_to_index
from documents.tests.factories import CorrespondentFactory from documents.tests.factories import CorrespondentFactory
from documents.tests.factories import DocumentFactory from documents.tests.factories import DocumentFactory
from documents.tests.factories import DocumentTypeFactory from documents.tests.factories import DocumentTypeFactory
@@ -1030,6 +1031,81 @@ class TestHighlightHits:
assert len(hits) == 0 assert len(hits) == 0
class TestVersionIndexing:
"""
GIVEN:
- A root document whose new version has just been consumed, e.g. by
the password removal workflow action
WHEN:
- The consumption finished signal is handled
THEN:
- The root document is indexed with the new version's content, since
versions are not searchable on their own
"""
def test_consumed_version_updates_root_entry(
self,
backend: TantivyBackend,
mocker: MockerFixture,
) -> None:
root = Document.objects.create(
title="Statement",
content="",
checksum="VER1",
pk=90,
)
backend.add_or_update(root)
version = Document.objects.create(
title="Statement",
content="unprotected statement text",
checksum="VER2",
pk=91,
root_document=root,
version_index=1,
)
mocker.patch("documents.search.get_backend", return_value=backend)
add_to_index(sender=None, document=version)
assert backend.search_ids("unprotected", user=None) == [root.pk]
class TestEffectiveContentIndexing:
"""
GIVEN:
- A root document with a newer version
WHEN:
- The root document is indexed
THEN:
- The newest version's content is indexed, never the root's own
outdated text
"""
def test_root_is_indexed_with_latest_version_content(
self,
backend: TantivyBackend,
) -> None:
root = Document.objects.create(
title="Statement",
content="stale original text",
checksum="EFF1",
pk=95,
)
Document.objects.create(
title="Statement",
content="latest version text",
checksum="EFF2",
pk=96,
root_document=root,
version_index=1,
)
backend.add_or_update(root)
assert backend.search_ids("latest", user=None) == [root.pk]
assert backend.search_ids("stale", user=None) == []
class TestIndexDirectoryGarbageCollection: class TestIndexDirectoryGarbageCollection:
"""Regression tests for Tantivy segment files leaking on disk when """Regression tests for Tantivy segment files leaking on disk when
multiple long-lived worker processes (Granian/Celery) take turns writing multiple long-lived worker processes (Granian/Celery) take turns writing
@@ -72,6 +72,10 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
"barcode_enable_tag": None, "barcode_enable_tag": None,
"barcode_tag_mapping": None, "barcode_tag_mapping": None,
"barcode_tag_split": None, "barcode_tag_split": None,
"remote_ocr_engine": None,
"remote_ocr_api_key": None,
"remote_ocr_endpoint": None,
"remote_ocr_mode": None,
"ai_enabled": False, "ai_enabled": False,
"llm_embedding_backend": None, "llm_embedding_backend": None,
"llm_embedding_model": None, "llm_embedding_model": None,
@@ -870,6 +874,49 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
config.refresh_from_db() config.refresh_from_db()
self.assertEqual(config.llm_api_key, None) self.assertEqual(config.llm_api_key, None)
def test_update_remote_ocr_api_key(self) -> None:
"""
GIVEN:
- Existing config with remote_ocr_api_key specified
WHEN:
- API to update remote_ocr_api_key is called with all *s
- API to update remote_ocr_api_key is called with empty string
THEN:
- remote_ocr_api_key is unchanged
- remote_ocr_api_key is set to None
"""
config = ApplicationConfiguration.objects.first()
assert config is not None
config.remote_ocr_api_key = "1234567890"
config.save()
# Test with all *
response = self.client.patch(
f"{self.ENDPOINT}1/",
json.dumps(
{
"remote_ocr_api_key": "*" * 32,
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
config.refresh_from_db()
self.assertEqual(config.remote_ocr_api_key, "1234567890")
# Test with empty string
response = self.client.patch(
f"{self.ENDPOINT}1/",
json.dumps(
{
"remote_ocr_api_key": "",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
config.refresh_from_db()
self.assertEqual(config.remote_ocr_api_key, None)
def test_enable_ai_index_triggers_update(self) -> None: def test_enable_ai_index_triggers_update(self) -> None:
""" """
GIVEN: GIVEN:
+46 -1
View File
@@ -532,7 +532,29 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
m.assert_called_once() m.assert_called_once()
args, kwargs = m.call_args args, kwargs = m.call_args
self.assertEqual(args[0], [self.doc1.id]) self.assertEqual(args[0], [self.doc1.id])
self.assertEqual(len(kwargs), 0) self.assertEqual(kwargs, {"remote_ocr": False})
@mock.patch("documents.views.bulk_edit.reprocess")
def test_reprocess_documents_endpoint_remote_ocr(self, m) -> None:
"""
GIVEN:
- API data to reprocess a document with remote OCR requested
WHEN:
- API is called
THEN:
- reprocess is called with remote_ocr=True
"""
self.setup_mock(m, "reprocess")
response = self.client.post(
"/api/documents/reprocess/",
json.dumps({"documents": [self.doc1.id], "remote_ocr": True}),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
args, kwargs = m.call_args
self.assertEqual(args[0], [self.doc1.id])
self.assertEqual(kwargs, {"remote_ocr": True})
@mock.patch("documents.serialisers.bulk_edit.set_storage_path") @mock.patch("documents.serialisers.bulk_edit.set_storage_path")
def test_api_set_storage_path(self, m) -> None: def test_api_set_storage_path(self, m) -> None:
@@ -1553,6 +1575,29 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
), ),
) )
def test_legacy_bulk_edit_reprocess_invalid_remote_ocr(self) -> None:
"""
GIVEN:
- The deprecated bulk_edit endpoint with a non-boolean remote_ocr
WHEN:
- API is called
THEN:
- The request is rejected rather than passed through to the task
"""
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc1.id],
"method": "reprocess",
"parameters": {"remote_ocr": "yes please"},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
@mock.patch("documents.views.bulk_edit.edit_pdf") @mock.patch("documents.views.bulk_edit.edit_pdf")
def test_edit_pdf(self, m) -> None: def test_edit_pdf(self, m) -> None:
self.setup_mock(m, "edit_pdf") self.setup_mock(m, "edit_pdf")

Some files were not shown because too many files have changed in this diff Show More