diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml
index b6baf49bf..e87c3e0c6 100644
--- a/.github/ISSUE_TEMPLATE/bug-report.yml
+++ b/.github/ISSUE_TEMPLATE/bug-report.yml
@@ -21,6 +21,7 @@ body:
- [The installation instructions](https://docs.paperless-ngx.com/setup/#installation).
- [Existing issues and discussions](https://github.com/paperless-ngx/paperless-ngx/search?q=&type=issues).
- Disable any custom container initialization scripts, if using
+ - Remove any third-party parser plugins — issues caused by or requiring changes to a third-party plugin will be closed without investigation.
If you encounter issues while installing or configuring Paperless-ngx, please post in the ["Support" section of the discussions](https://github.com/paperless-ngx/paperless-ngx/discussions/new?category=support).
- type: textarea
diff --git a/docs/advanced_usage.md b/docs/advanced_usage.md
index 989604000..ee0cfddce 100644
--- a/docs/advanced_usage.md
+++ b/docs/advanced_usage.md
@@ -723,6 +723,81 @@ services:
1. Note the `:ro` tag means the folder will be mounted as read only. This is for extra security against changes
+## Installing third-party parser plugins {#parser-plugins}
+
+Third-party parser plugins extend Paperless-ngx to support additional file
+formats. A plugin is a Python package that advertises itself under the
+`paperless_ngx.parsers` entry point group. Refer to the
+[developer documentation](development.md#making-custom-parsers) for how to
+create one.
+
+!!! warning "Third-party plugins are not officially supported"
+
+ The Paperless-ngx maintainers do not provide support for third-party
+ plugins. Issues caused by or requiring changes to a third-party plugin
+ will be closed without further investigation. Always reproduce problems
+ with all plugins removed before filing a bug report.
+
+### Docker
+
+Use a [custom container initialization script](#custom-container-initialization)
+to install the package before the webserver starts. Create a shell script and
+mount it into `/custom-cont-init.d`:
+
+```bash
+#!/bin/bash
+# /path/to/my/scripts/install-parsers.sh
+
+pip install my-paperless-parser-package
+```
+
+Mount it in your `docker-compose.yml`:
+
+```yaml
+services:
+ webserver:
+ # ...
+ volumes:
+ - /path/to/my/scripts:/custom-cont-init.d:ro
+```
+
+The script runs as `root` before the webserver starts, so the package will be
+available when Paperless-ngx discovers plugins at startup.
+
+### Bare metal
+
+Install the package into the same Python environment that runs Paperless-ngx.
+If you followed the standard bare-metal install guide, that is the `paperless`
+user's environment:
+
+```bash
+sudo -Hu paperless pip3 install my-paperless-parser-package
+```
+
+If you are using `uv` or a virtual environment, activate it first and then run:
+
+```bash
+uv pip install my-paperless-parser-package
+# or
+pip install my-paperless-parser-package
+```
+
+Restart all Paperless-ngx services after installation so the new plugin is
+discovered.
+
+### Verifying installation
+
+On the next startup, check the application logs for a line confirming
+discovery:
+
+```
+Loaded third-party parser 'My Parser' v1.0.0 by Acme Corp (entrypoint: 'my_parser').
+```
+
+If this line does not appear, verify that the package is installed in the
+correct environment and that its `pyproject.toml` declares the
+`paperless_ngx.parsers` entry point.
+
## MySQL Caveats {#mysql-caveats}
### Case Sensitivity
diff --git a/docs/api.md b/docs/api.md
index 414fe16da..2284d9d29 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -436,3 +436,6 @@ Initial API version.
moved from the bulk edit endpoint to their own individual endpoints. Using these methods via
the bulk edit endpoint is still supported for compatibility with versions < 10 until support
for API v9 is dropped.
+- The `all` parameter of list endpoints is now deprecated and will be removed in a future version.
+- The bulk edit objects endpoint now supports `all` and `filters` parameters to avoid having to send
+ large lists of object IDs for operations affecting many objects.
diff --git a/docs/development.md b/docs/development.md
index e6b9955e8..11e078a67 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -370,121 +370,367 @@ docker build --file Dockerfile --tag paperless:local .
## Extending Paperless-ngx
-Paperless-ngx does not have any fancy plugin systems and will probably never
-have. However, some parts of the application have been designed to allow
-easy integration of additional features without any modification to the
-base code.
+Paperless-ngx supports third-party document parsers via a Python entry point
+plugin system. Plugins are distributed as ordinary Python packages and
+discovered automatically at startup — no changes to the Paperless-ngx source
+are required.
+
+!!! warning "Third-party plugins are not officially supported"
+
+ The Paperless-ngx maintainers do not provide support for third-party
+ plugins. Issues that are caused by or require changes to a third-party
+ plugin will be closed without further investigation. If you believe you
+ have found a bug in Paperless-ngx itself (not in a plugin), please
+ reproduce it with all third-party plugins removed before filing an issue.
### Making custom parsers
-Paperless-ngx uses parsers to add documents. A parser is
-responsible for:
+Paperless-ngx uses parsers to add documents. A parser is responsible for:
-- Retrieving the content from the original
-- Creating a thumbnail
-- _optional:_ Retrieving a created date from the original
-- _optional:_ Creating an archived document from the original
+- Extracting plain-text content from the document
+- Generating a thumbnail image
+- _optional:_ Detecting the document's creation date
+- _optional:_ Producing a searchable PDF archive copy
-Custom parsers can be added to Paperless-ngx to support more file types. In
-order to do that, you need to write the parser itself and announce its
-existence to Paperless-ngx.
+Custom parsers are distributed as ordinary Python packages and registered
+via a [setuptools entry point](https://setuptools.pypa.io/en/latest/userguide/entry_point.html).
+No changes to the Paperless-ngx source are required.
-The parser itself must extend `documents.parsers.DocumentParser` and
-must implement the methods `parse` and `get_thumbnail`. You can provide
-your own implementation to `get_date` if you don't want to rely on
-Paperless-ngx' default date guessing mechanisms.
+#### 1. Implementing the parser class
+
+Your parser must satisfy the `ParserProtocol` structural interface defined in
+`paperless.parsers`. The simplest approach is to write a plain class — no base
+class is required, only the right attributes and methods.
+
+**Class-level identity attributes**
+
+The registry reads these before instantiating the parser, so they must be
+plain class attributes (not instance attributes or properties):
```python
-class MyCustomParser(DocumentParser):
-
- def parse(self, document_path, mime_type):
- # This method does not return anything. Rather, you should assign
- # whatever you got from the document to the following fields:
-
- # The content of the document.
- self.text = "content"
-
- # Optional: path to a PDF document that you created from the original.
- self.archive_path = os.path.join(self.tempdir, "archived.pdf")
-
- # Optional: "created" date of the document.
- self.date = get_created_from_metadata(document_path)
-
- def get_thumbnail(self, document_path, mime_type):
- # This should return the path to a thumbnail you created for this
- # document.
- return os.path.join(self.tempdir, "thumb.webp")
+class MyCustomParser:
+ name = "My Format Parser" # human-readable name shown in logs
+ version = "1.0.0" # semantic version string
+ author = "Acme Corp" # author / organisation
+ url = "https://example.com/my-parser" # docs or issue tracker
```
-If you encounter any issues during parsing, raise a
-`documents.parsers.ParseError`.
+**Declaring supported MIME types**
-The `self.tempdir` directory is a temporary directory that is guaranteed
-to be empty and removed after consumption finished. You can use that
-directory to store any intermediate files and also use it to store the
-thumbnail / archived document.
-
-After that, you need to announce your parser to Paperless-ngx. You need to
-connect a handler to the `document_consumer_declaration` signal. Have a
-look in the file `src/paperless_tesseract/apps.py` on how that's done.
-The handler is a method that returns information about your parser:
+Return a `dict` mapping MIME type strings to preferred file extensions
+(including the leading dot). Paperless-ngx uses the extension when storing
+archive copies and serving files for download.
```python
-def myparser_consumer_declaration(sender, **kwargs):
+@classmethod
+def supported_mime_types(cls) -> dict[str, str]:
return {
- "parser": MyCustomParser,
- "weight": 0,
- "mime_types": {
- "application/pdf": ".pdf",
- "image/jpeg": ".jpg",
- }
+ "application/x-my-format": ".myf",
+ "application/x-my-format-alt": ".myf",
}
```
-- `parser` is a reference to a class that extends `DocumentParser`.
-- `weight` is used whenever two or more parsers are able to parse a
- file: The parser with the higher weight wins. This can be used to
- override the parsers provided by Paperless-ngx.
-- `mime_types` is a dictionary. The keys are the mime types your
- parser supports and the value is the default file extension that
- Paperless-ngx should use when storing files and serving them for
- download. We could guess that from the file extensions, but some
- mime types have many extensions associated with them and the Python
- methods responsible for guessing the extension do not always return
- the same value.
+**Scoring**
-## Using Visual Studio Code devcontainer
+When more than one parser can handle a file, the registry calls `score()` on
+each candidate and picks the one with the highest result and equal scores favor third-party parsers over built-ins. Return `None` to
+decline handling a file even though the MIME type is listed as supported (for
+example, when a required external service is not configured).
-Another easy way to get started with development is to use Visual Studio
-Code devcontainers. This approach will create a preconfigured development
-environment with all of the required tools and dependencies.
-[Learn more about devcontainers](https://code.visualstudio.com/docs/devcontainers/containers).
-The .devcontainer/vscode/tasks.json and .devcontainer/vscode/launch.json files
-contain more information about the specific tasks and launch configurations (see the
-non-standard "description" field).
+| Score | Meaning |
+| ------ | --------------------------------------------------------------------------------- |
+| `None` | Decline — do not handle this file |
+| `10` | Default priority used by all built-in parsers |
+| `20` | Priority used by the remote OCR built-in parser, allowing it to replace Tesseract |
+| `> 10` | Override a built-in parser for the same MIME type |
-To get started:
+```python
+@classmethod
+def score(
+ cls,
+ mime_type: str,
+ filename: str,
+ path: "Path | None" = None,
+) -> int | None:
+ # Inspect filename or file bytes here if needed.
+ return 10
+```
-1. Clone the repository on your machine and open the Paperless-ngx folder in VS Code.
+**Archive and rendition flags**
-2. VS Code will prompt you with "Reopen in container". Do so and wait for the environment to start.
+```python
+@property
+def can_produce_archive(self) -> bool:
+ """True if parse() can produce a searchable PDF archive copy."""
+ return True # or False if your parser doesn't produce PDFs
-3. In case your host operating system is Windows:
- - The Source Control view in Visual Studio Code might show: "The detected Git repository is potentially unsafe as the folder is owned by someone other than the current user." Use "Manage Unsafe Repositories" to fix this.
- - Git might have detecteded modifications for all files, because Windows is using CRLF line endings. Run `git checkout .` in the containers terminal to fix this issue.
+@property
+def requires_pdf_rendition(self) -> bool:
+ """True if the original format cannot be displayed by a browser
+ (e.g. DOCX, ODT) and the PDF output must always be kept."""
+ return False
+```
-4. Initialize the project by running the task **Project Setup: Run all Init Tasks**. This
- will initialize the database tables and create a superuser. Then you can compile the front end
- for production or run the frontend in debug mode.
+**Context manager — temp directory lifecycle**
-5. The project is ready for debugging, start either run the fullstack debug or individual debug
- processes. Yo spin up the project without debugging run the task **Project Start: Run all Services**
+Paperless-ngx always uses parsers as context managers. Create a temporary
+working directory in `__enter__` (or `__init__`) and remove it in `__exit__`
+regardless of whether an exception occurred. Store intermediate files,
+thumbnails, and archive PDFs inside this directory.
-## Developing Date Parser Plugins
+```python
+import shutil
+import tempfile
+from pathlib import Path
+from typing import Self
+from types import TracebackType
+
+from django.conf import settings
+
+class MyCustomParser:
+ ...
+
+ def __init__(self, logging_group: object = None) -> None:
+ settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
+ self._tempdir = Path(
+ tempfile.mkdtemp(prefix="paperless-", dir=settings.SCRATCH_DIR)
+ )
+ self._text: str | None = None
+ self._archive_path: Path | None = None
+
+ def __enter__(self) -> Self:
+ return self
+
+ def __exit__(
+ self,
+ exc_type: type[BaseException] | None,
+ exc_val: BaseException | None,
+ exc_tb: TracebackType | None,
+ ) -> None:
+ shutil.rmtree(self._tempdir, ignore_errors=True)
+```
+
+**Optional context — `configure()`**
+
+The consumer calls `configure()` with a `ParserContext` after instantiation
+and before `parse()`. If your parser doesn't need context, a no-op
+implementation is fine:
+
+```python
+from paperless.parsers import ParserContext
+
+def configure(self, context: ParserContext) -> None:
+ pass # override if you need context.mailrule_id, etc.
+```
+
+**Parsing**
+
+`parse()` is the core method. It must not return a value; instead, store
+results in instance attributes and expose them via the accessor methods below.
+Raise `documents.parsers.ParseError` on any unrecoverable failure.
+
+```python
+from documents.parsers import ParseError
+
+def parse(
+ self,
+ document_path: Path,
+ mime_type: str,
+ *,
+ produce_archive: bool = True,
+) -> None:
+ try:
+ self._text = extract_text_from_my_format(document_path)
+ except Exception as e:
+ raise ParseError(f"Failed to parse {document_path}: {e}") from e
+
+ if produce_archive and self.can_produce_archive:
+ archive = self._tempdir / "archived.pdf"
+ convert_to_pdf(document_path, archive)
+ self._archive_path = archive
+```
+
+**Result accessors**
+
+```python
+def get_text(self) -> str | None:
+ return self._text
+
+def get_date(self) -> "datetime.datetime | None":
+ # Return a datetime extracted from the document, or None to let
+ # Paperless-ngx use its default date-guessing logic.
+ return None
+
+def get_archive_path(self) -> Path | None:
+ return self._archive_path
+
+def get_page_count(self, document_path: Path, mime_type: str) -> int | None:
+ # If the format doesn't have the concept of pages, return None
+ return count_pages(document_path)
+
+```
+
+**Thumbnail**
+
+`get_thumbnail()` may be called independently of `parse()`. Return the path
+to a WebP image inside `self._tempdir`. The image should be roughly 500 × 700
+pixels.
+
+```python
+def get_thumbnail(self, document_path: Path, mime_type: str) -> Path:
+ thumb = self._tempdir / "thumb.webp"
+ render_thumbnail(document_path, thumb)
+ return thumb
+```
+
+**Optional methods**
+
+These are called by the API on demand, not during the consumption pipeline.
+Implement them if your format supports the information; otherwise return
+`None` / `[]`.
+
+```python
+
+def extract_metadata(
+ self,
+ document_path: Path,
+ mime_type: str,
+) -> "list[MetadataEntry]":
+ # Must never raise. Return [] if metadata cannot be read.
+ from paperless.parsers import MetadataEntry
+ return [
+ MetadataEntry(
+ namespace="https://example.com/ns/",
+ prefix="ex",
+ key="Author",
+ value="Alice",
+ )
+ ]
+```
+
+#### 2. Registering via entry point
+
+Add the following to your package's `pyproject.toml`. The key (left of `=`)
+is an arbitrary name used only in log output; the value is the
+`module:ClassName` import path.
+
+```toml
+[project.entry-points."paperless_ngx.parsers"]
+my_parser = "my_package.parsers:MyCustomParser"
+```
+
+Install your package into the same Python environment as Paperless-ngx (or
+add it to the Docker image), and the parser will be discovered automatically
+on the next startup. No configuration changes are needed.
+
+To verify discovery, check the application logs at startup for a line like:
+
+```
+Loaded third-party parser 'My Format Parser' v1.0.0 by Acme Corp (entrypoint: 'my_parser').
+```
+
+#### 3. Utilities
+
+`paperless.parsers.utils` provides helpers you can import directly:
+
+| Function | Description |
+| --------------------------------------- | ---------------------------------------------------------------- |
+| `read_file_handle_unicode_errors(path)` | Read a file as UTF-8, replacing invalid bytes instead of raising |
+| `get_page_count_for_pdf(path)` | Count pages in a PDF using pikepdf |
+| `extract_pdf_metadata(path)` | Extract XMP metadata from a PDF as a `list[MetadataEntry]` |
+
+#### Minimal example
+
+A complete, working parser for a hypothetical plain-XML format:
+
+```python
+from __future__ import annotations
+
+import shutil
+import tempfile
+from pathlib import Path
+from typing import Self
+from types import TracebackType
+import xml.etree.ElementTree as ET
+
+from django.conf import settings
+
+from documents.parsers import ParseError
+from paperless.parsers import ParserContext
+
+
+class XmlDocumentParser:
+ name = "XML Parser"
+ version = "1.0.0"
+ author = "Acme Corp"
+ url = "https://example.com/xml-parser"
+
+ @classmethod
+ def supported_mime_types(cls) -> dict[str, str]:
+ return {"application/xml": ".xml", "text/xml": ".xml"}
+
+ @classmethod
+ def score(cls, mime_type: str, filename: str, path: Path | None = None) -> int | None:
+ return 10
+
+ @property
+ def can_produce_archive(self) -> bool:
+ return False
+
+ @property
+ def requires_pdf_rendition(self) -> bool:
+ return False
+
+ def __init__(self, logging_group: object = None) -> None:
+ settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
+ self._tempdir = Path(tempfile.mkdtemp(prefix="paperless-", dir=settings.SCRATCH_DIR))
+ self._text: str | None = None
+
+ def __enter__(self) -> Self:
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
+ shutil.rmtree(self._tempdir, ignore_errors=True)
+
+ def configure(self, context: ParserContext) -> None:
+ pass
+
+ def parse(self, document_path: Path, mime_type: str, *, produce_archive: bool = True) -> None:
+ try:
+ tree = ET.parse(document_path)
+ self._text = " ".join(tree.getroot().itertext())
+ except ET.ParseError as e:
+ raise ParseError(f"XML parse error: {e}") from e
+
+ def get_text(self) -> str | None:
+ return self._text
+
+ def get_date(self):
+ return None
+
+ def get_archive_path(self) -> Path | None:
+ return None
+
+ def get_thumbnail(self, document_path: Path, mime_type: str) -> Path:
+ from PIL import Image, ImageDraw
+ img = Image.new("RGB", (500, 700), color="white")
+ ImageDraw.Draw(img).text((10, 10), "XML Document", fill="black")
+ out = self._tempdir / "thumb.webp"
+ img.save(out, format="WEBP")
+ return out
+
+ def get_page_count(self, document_path: Path, mime_type: str) -> int | None:
+ return None
+
+ def extract_metadata(self, document_path: Path, mime_type: str) -> list:
+ return []
+```
+
+### Developing date parser plugins
Paperless-ngx uses a plugin system for date parsing, allowing you to extend or replace the default date parsing behavior. Plugins are discovered using [Python entry points](https://setuptools.pypa.io/en/latest/userguide/entry_point.html).
-### Creating a Date Parser Plugin
+#### Creating a Date Parser Plugin
To create a custom date parser plugin, you need to:
@@ -492,7 +738,7 @@ To create a custom date parser plugin, you need to:
2. Implement the required abstract method
3. Register your plugin via an entry point
-#### 1. Implementing the Parser Class
+##### 1. Implementing the Parser Class
Your parser must extend `documents.plugins.date_parsing.DateParserPluginBase` and implement the `parse` method:
@@ -532,7 +778,7 @@ class MyDateParserPlugin(DateParserPluginBase):
yield another_datetime
```
-#### 2. Configuration and Helper Methods
+##### 2. Configuration and Helper Methods
Your parser instance is initialized with a `DateParserConfig` object accessible via `self.config`. This provides:
@@ -565,11 +811,11 @@ def _filter_date(
"""
```
-#### 3. Resource Management (Optional)
+##### 3. Resource Management (Optional)
If your plugin needs to acquire or release resources (database connections, API clients, etc.), override the context manager methods. Paperless-ngx will always use plugins as context managers, ensuring resources can be released even in the event of errors.
-#### 4. Registering Your Plugin
+##### 4. Registering Your Plugin
Register your plugin using a setuptools entry point in your package's `pyproject.toml`:
@@ -580,7 +826,7 @@ my_parser = "my_package.parsers:MyDateParserPlugin"
The entry point name (e.g., `"my_parser"`) is used for sorting when multiple plugins are found. Paperless-ngx will use the first plugin alphabetically by name if multiple plugins are discovered.
-### Plugin Discovery
+#### Plugin Discovery
Paperless-ngx automatically discovers and loads date parser plugins at runtime. The discovery process:
@@ -591,7 +837,7 @@ Paperless-ngx automatically discovers and loads date parser plugins at runtime.
If multiple plugins are installed, a warning is logged indicating which plugin was selected.
-### Example: Simple Date Parser
+#### Example: Simple Date Parser
Here's a minimal example that only looks for ISO 8601 dates:
@@ -623,3 +869,30 @@ class ISODateParserPlugin(DateParserPluginBase):
if filtered_date is not None:
yield filtered_date
```
+
+## Using Visual Studio Code devcontainer
+
+Another easy way to get started with development is to use Visual Studio
+Code devcontainers. This approach will create a preconfigured development
+environment with all of the required tools and dependencies.
+[Learn more about devcontainers](https://code.visualstudio.com/docs/devcontainers/containers).
+The .devcontainer/vscode/tasks.json and .devcontainer/vscode/launch.json files
+contain more information about the specific tasks and launch configurations (see the
+non-standard "description" field).
+
+To get started:
+
+1. Clone the repository on your machine and open the Paperless-ngx folder in VS Code.
+
+2. VS Code will prompt you with "Reopen in container". Do so and wait for the environment to start.
+
+3. In case your host operating system is Windows:
+ - The Source Control view in Visual Studio Code might show: "The detected Git repository is potentially unsafe as the folder is owned by someone other than the current user." Use "Manage Unsafe Repositories" to fix this.
+ - Git might have detecteded modifications for all files, because Windows is using CRLF line endings. Run `git checkout .` in the containers terminal to fix this issue.
+
+4. Initialize the project by running the task **Project Setup: Run all Init Tasks**. This
+ will initialize the database tables and create a superuser. Then you can compile the front end
+ for production or run the frontend in debug mode.
+
+5. The project is ready for debugging, start either run the fullstack debug or individual debug
+ processes. Yo spin up the project without debugging run the task **Project Start: Run all Services**
diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf
index 1db6e0e86..38fabebe4 100644
--- a/src-ui/messages.xlf
+++ b/src-ui/messages.xlf
@@ -2400,7 +2400,7 @@
src/app/components/manage/document-attributes/management-list/management-list.component.ts
- 265
+ 276src/app/components/manage/mail/mail.component.html
@@ -2450,11 +2450,11 @@
src/app/components/manage/document-attributes/management-list/management-list.component.ts
- 261
+ 272src/app/components/manage/document-attributes/management-list/management-list.component.ts
- 422
+ 453
@@ -2488,7 +2488,7 @@
src/app/components/manage/document-attributes/management-list/management-list.component.ts
- 424
+ 455src/app/components/manage/mail/mail.component.ts
@@ -2815,7 +2815,7 @@
src/app/components/manage/document-attributes/management-list/management-list.component.ts
- 426
+ 457src/app/components/manage/mail/mail.component.ts
@@ -4707,7 +4707,7 @@
src/app/components/manage/document-attributes/management-list/tag-list/tag-list.component.ts
- 49
+ 48
@@ -8721,7 +8721,7 @@
src/app/components/manage/document-attributes/management-list/management-list.component.ts
- 132
+ 143src/app/data/matching-model.ts
@@ -9536,7 +9536,7 @@
Automaticsrc/app/components/manage/document-attributes/management-list/management-list.component.ts
- 130
+ 141src/app/data/matching-model.ts
@@ -9547,63 +9547,63 @@
Successfully created .src/app/components/manage/document-attributes/management-list/management-list.component.ts
- 218
+ 229Error occurred while creating .src/app/components/manage/document-attributes/management-list/management-list.component.ts
- 223
+ 234Successfully updated "".src/app/components/manage/document-attributes/management-list/management-list.component.ts
- 238
+ 249Error occurred while saving .src/app/components/manage/document-attributes/management-list/management-list.component.ts
- 243
+ 254Associated documents will not be deleted.src/app/components/manage/document-attributes/management-list/management-list.component.ts
- 263
+ 274Error while deleting elementsrc/app/components/manage/document-attributes/management-list/management-list.component.ts
- 279
+ 290Error saving settingssrc/app/components/manage/document-attributes/management-list/management-list.component.ts
- 318
+ 329Permissions updated successfullysrc/app/components/manage/document-attributes/management-list/management-list.component.ts
- 402
+ 433Error updating permissionssrc/app/components/manage/document-attributes/management-list/management-list.component.ts
- 409
+ 440src/app/components/manage/mail/mail.component.ts
@@ -9618,21 +9618,21 @@
This operation will permanently delete the selected .src/app/components/manage/document-attributes/management-list/management-list.component.ts
- 423
+ 454Objects deleted successfullysrc/app/components/manage/document-attributes/management-list/management-list.component.ts
- 437
+ 472Error deleting objectssrc/app/components/manage/document-attributes/management-list/management-list.component.ts
- 443
+ 478
@@ -9660,21 +9660,21 @@
tagsrc/app/components/manage/document-attributes/management-list/tag-list/tag-list.component.ts
- 43
+ 42tagssrc/app/components/manage/document-attributes/management-list/tag-list/tag-list.component.ts
- 44
+ 43Do you really want to delete the tag ""?src/app/components/manage/document-attributes/management-list/tag-list/tag-list.component.ts
- 60
+ 59
diff --git a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts
index 5b7d7ef13..504ec79ee 100644
--- a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts
+++ b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts
@@ -959,7 +959,7 @@ describe('DocumentDetailComponent', () => {
component.reprocess()
const modalCloseSpy = jest.spyOn(openModal, 'close')
openModal.componentInstance.confirmClicked.next()
- expect(reprocessSpy).toHaveBeenCalledWith([doc.id])
+ expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] })
expect(modalSpy).toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalled()
expect(modalCloseSpy).toHaveBeenCalled()
diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts
index 25c854e53..91f448056 100644
--- a/src-ui/src/app/components/document-detail/document-detail.component.ts
+++ b/src-ui/src/app/components/document-detail/document-detail.component.ts
@@ -1379,25 +1379,27 @@ export class DocumentDetailComponent
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled = false
- this.documentsService.reprocessDocuments([this.document.id]).subscribe({
- next: () => {
- this.toastService.showInfo(
- $localize`Reprocess operation for "${this.document.title}" will begin in the background.`
- )
- if (modal) {
- modal.close()
- }
- },
- error: (error) => {
- if (modal) {
- modal.componentInstance.buttonsEnabled = true
- }
- this.toastService.showError(
- $localize`Error executing operation`,
- error
- )
- },
- })
+ this.documentsService
+ .reprocessDocuments({ documents: [this.document.id] })
+ .subscribe({
+ next: () => {
+ this.toastService.showInfo(
+ $localize`Reprocess operation for "${this.document.title}" will begin in the background.`
+ )
+ if (modal) {
+ modal.close()
+ }
+ },
+ error: (error) => {
+ if (modal) {
+ modal.componentInstance.buttonsEnabled = true
+ }
+ this.toastService.showError(
+ $localize`Error executing operation`,
+ error
+ )
+ },
+ })
})
}
diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html
index 19934ffb8..52b56287a 100644
--- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html
+++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html
@@ -92,7 +92,7 @@
-
-
+ Create a share link bundle
@@ -117,7 +117,7 @@
@if (emailEnabled) {
-
+ Email
}
diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts
index da74da98a..f283a75f3 100644
--- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts
+++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts
@@ -13,6 +13,7 @@ import { of, throwError } from 'rxjs'
import { Correspondent } from 'src/app/data/correspondent'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { DocumentType } from 'src/app/data/document-type'
+import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
import { Results } from 'src/app/data/results'
import { StoragePath } from 'src/app/data/storage-path'
import { Tag } from 'src/app/data/tag'
@@ -273,6 +274,92 @@ describe('BulkEditorComponent', () => {
expect(component.customFieldsSelectionModel.selectionSize()).toEqual(1)
})
+ it('should apply list selection data to tags menu when all filtered documents are selected', () => {
+ jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+ fixture.detectChanges()
+ jest
+ .spyOn(documentListViewService, 'allSelected', 'get')
+ .mockReturnValue(true)
+ jest
+ .spyOn(documentListViewService, 'selectedCount', 'get')
+ .mockReturnValue(3)
+ documentListViewService.selectionData = selectionData
+ const getSelectionDataSpy = jest.spyOn(documentService, 'getSelectionData')
+
+ component.openTagsDropdown()
+
+ expect(getSelectionDataSpy).not.toHaveBeenCalled()
+ expect(component.tagSelectionModel.selectionSize()).toEqual(1)
+ })
+
+ it('should apply list selection data to document types menu when all filtered documents are selected', () => {
+ jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+ fixture.detectChanges()
+ jest
+ .spyOn(documentListViewService, 'allSelected', 'get')
+ .mockReturnValue(true)
+ documentListViewService.selectionData = selectionData
+ const getSelectionDataSpy = jest.spyOn(documentService, 'getSelectionData')
+
+ component.openDocumentTypeDropdown()
+
+ expect(getSelectionDataSpy).not.toHaveBeenCalled()
+ expect(component.documentTypeDocumentCounts).toEqual(
+ selectionData.selected_document_types
+ )
+ })
+
+ it('should apply list selection data to correspondents menu when all filtered documents are selected', () => {
+ jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+ fixture.detectChanges()
+ jest
+ .spyOn(documentListViewService, 'allSelected', 'get')
+ .mockReturnValue(true)
+ documentListViewService.selectionData = selectionData
+ const getSelectionDataSpy = jest.spyOn(documentService, 'getSelectionData')
+
+ component.openCorrespondentDropdown()
+
+ expect(getSelectionDataSpy).not.toHaveBeenCalled()
+ expect(component.correspondentDocumentCounts).toEqual(
+ selectionData.selected_correspondents
+ )
+ })
+
+ it('should apply list selection data to storage paths menu when all filtered documents are selected', () => {
+ jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+ fixture.detectChanges()
+ jest
+ .spyOn(documentListViewService, 'allSelected', 'get')
+ .mockReturnValue(true)
+ documentListViewService.selectionData = selectionData
+ const getSelectionDataSpy = jest.spyOn(documentService, 'getSelectionData')
+
+ component.openStoragePathDropdown()
+
+ expect(getSelectionDataSpy).not.toHaveBeenCalled()
+ expect(component.storagePathDocumentCounts).toEqual(
+ selectionData.selected_storage_paths
+ )
+ })
+
+ it('should apply list selection data to custom fields menu when all filtered documents are selected', () => {
+ jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+ fixture.detectChanges()
+ jest
+ .spyOn(documentListViewService, 'allSelected', 'get')
+ .mockReturnValue(true)
+ documentListViewService.selectionData = selectionData
+ const getSelectionDataSpy = jest.spyOn(documentService, 'getSelectionData')
+
+ component.openCustomFieldsDropdown()
+
+ expect(getSelectionDataSpy).not.toHaveBeenCalled()
+ expect(component.customFieldDocumentCounts).toEqual(
+ selectionData.selected_custom_fields
+ )
+ })
+
it('should execute modify tags bulk operation', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
@@ -307,6 +394,49 @@ describe('BulkEditorComponent', () => {
) // listAllFilteredIds
})
+ it('should execute modify tags bulk operation for all filtered documents', () => {
+ jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+ jest
+ .spyOn(documentListViewService, 'documents', 'get')
+ .mockReturnValue([{ id: 3 }, { id: 4 }])
+ jest
+ .spyOn(documentListViewService, 'selected', 'get')
+ .mockReturnValue(new Set([3, 4]))
+ jest
+ .spyOn(documentListViewService, 'allSelected', 'get')
+ .mockReturnValue(true)
+ jest
+ .spyOn(documentListViewService, 'filterRules', 'get')
+ .mockReturnValue([{ rule_type: FILTER_TITLE, value: 'apple' }])
+ jest
+ .spyOn(documentListViewService, 'selectedCount', 'get')
+ .mockReturnValue(25)
+ jest
+ .spyOn(permissionsService, 'currentUserHasObjectPermissions')
+ .mockReturnValue(true)
+ component.showConfirmationDialogs = false
+ fixture.detectChanges()
+
+ component.setTags({
+ itemsToAdd: [{ id: 101 }],
+ itemsToRemove: [],
+ })
+
+ const req = httpTestingController.expectOne(
+ `${environment.apiBaseUrl}documents/bulk_edit/`
+ )
+ req.flush(true)
+ expect(req.request.body).toEqual({
+ all: true,
+ filters: { title__icontains: 'apple' },
+ method: 'modify_tags',
+ parameters: { add_tags: [101], remove_tags: [] },
+ })
+ httpTestingController.match(
+ `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
+ ) // list reload
+ })
+
it('should execute modify tags bulk operation with confirmation dialog if enabled', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0]))
@@ -1089,22 +1219,39 @@ describe('BulkEditorComponent', () => {
component.downloadForm.get('downloadFileTypeArchive').patchValue(true)
fixture.detectChanges()
let downloadSpy = jest.spyOn(documentService, 'bulkDownload')
+ downloadSpy.mockReturnValue(of(new Blob()))
//archive
component.downloadSelected()
- expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'archive', false)
+ expect(downloadSpy).toHaveBeenCalledWith(
+ { documents: [3, 4] },
+ 'archive',
+ false
+ )
//originals
component.downloadForm.get('downloadFileTypeArchive').patchValue(false)
component.downloadForm.get('downloadFileTypeOriginals').patchValue(true)
component.downloadSelected()
- expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'originals', false)
+ expect(downloadSpy).toHaveBeenCalledWith(
+ { documents: [3, 4] },
+ 'originals',
+ false
+ )
//both
component.downloadForm.get('downloadFileTypeArchive').patchValue(true)
component.downloadSelected()
- expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'both', false)
+ expect(downloadSpy).toHaveBeenCalledWith(
+ { documents: [3, 4] },
+ 'both',
+ false
+ )
//formatting
component.downloadForm.get('downloadUseFormatting').patchValue(true)
component.downloadSelected()
- expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'both', true)
+ expect(downloadSpy).toHaveBeenCalledWith(
+ { documents: [3, 4] },
+ 'both',
+ true
+ )
httpTestingController.match(
`${environment.apiBaseUrl}documents/bulk_download/`
@@ -1450,6 +1597,7 @@ describe('BulkEditorComponent', () => {
expect(modal.componentInstance.customFields.length).toEqual(2)
expect(modal.componentInstance.fieldsToAddIds).toEqual([1, 2])
+ expect(modal.componentInstance.selection).toEqual({ documents: [3, 4] })
expect(modal.componentInstance.documents).toEqual([3, 4])
modal.componentInstance.failed.emit()
diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts
index b97cc76c4..a456ec2cb 100644
--- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts
+++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts
@@ -31,6 +31,7 @@ import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import {
DocumentBulkEditMethod,
+ DocumentSelectionQuery,
DocumentService,
MergeDocumentsRequest,
} from 'src/app/services/rest/document.service'
@@ -41,6 +42,7 @@ import { TagService } from 'src/app/services/rest/tag.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { flattenTags } from 'src/app/utils/flatten-tags'
+import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-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'
@@ -261,17 +263,13 @@ export class BulkEditorComponent
modal: NgbModalRef,
method: DocumentBulkEditMethod,
args: any,
- overrideDocumentIDs?: number[]
+ overrideSelection?: DocumentSelectionQuery
) {
if (modal) {
modal.componentInstance.buttonsEnabled = false
}
this.documentService
- .bulkEdit(
- overrideDocumentIDs ?? Array.from(this.list.selected),
- method,
- args
- )
+ .bulkEdit(overrideSelection ?? this.getSelectionQuery(), method, args)
.pipe(first())
.subscribe({
next: () => this.handleOperationSuccess(modal),
@@ -329,7 +327,7 @@ export class BulkEditorComponent
) {
let selectionData = new Map()
items.forEach((i) => {
- if (i.document_count == this.list.selected.size) {
+ if (i.document_count == this.list.selectedCount) {
selectionData.set(i.id, ToggleableItemState.Selected)
} else if (i.document_count > 0) {
selectionData.set(i.id, ToggleableItemState.PartiallySelected)
@@ -338,7 +336,31 @@ export class BulkEditorComponent
selectionModel.init(selectionData)
}
+ private getSelectionQuery(): DocumentSelectionQuery {
+ if (this.list.allSelected) {
+ return {
+ all: true,
+ filters: queryParamsFromFilterRules(this.list.filterRules),
+ }
+ }
+
+ return {
+ documents: Array.from(this.list.selected),
+ }
+ }
+
+ private getSelectionSize(): number {
+ return this.list.selectedCount
+ }
+
openTagsDropdown() {
+ if (this.list.allSelected) {
+ const selectionData = this.list.selectionData
+ this.tagDocumentCounts = selectionData?.selected_tags ?? []
+ this.applySelectionData(this.tagDocumentCounts, this.tagSelectionModel)
+ return
+ }
+
this.documentService
.getSelectionData(Array.from(this.list.selected))
.pipe(first())
@@ -349,6 +371,17 @@ export class BulkEditorComponent
}
openDocumentTypeDropdown() {
+ if (this.list.allSelected) {
+ const selectionData = this.list.selectionData
+ this.documentTypeDocumentCounts =
+ selectionData?.selected_document_types ?? []
+ this.applySelectionData(
+ this.documentTypeDocumentCounts,
+ this.documentTypeSelectionModel
+ )
+ return
+ }
+
this.documentService
.getSelectionData(Array.from(this.list.selected))
.pipe(first())
@@ -362,6 +395,17 @@ export class BulkEditorComponent
}
openCorrespondentDropdown() {
+ if (this.list.allSelected) {
+ const selectionData = this.list.selectionData
+ this.correspondentDocumentCounts =
+ selectionData?.selected_correspondents ?? []
+ this.applySelectionData(
+ this.correspondentDocumentCounts,
+ this.correspondentSelectionModel
+ )
+ return
+ }
+
this.documentService
.getSelectionData(Array.from(this.list.selected))
.pipe(first())
@@ -375,6 +419,17 @@ export class BulkEditorComponent
}
openStoragePathDropdown() {
+ if (this.list.allSelected) {
+ const selectionData = this.list.selectionData
+ this.storagePathDocumentCounts =
+ selectionData?.selected_storage_paths ?? []
+ this.applySelectionData(
+ this.storagePathDocumentCounts,
+ this.storagePathsSelectionModel
+ )
+ return
+ }
+
this.documentService
.getSelectionData(Array.from(this.list.selected))
.pipe(first())
@@ -388,6 +443,17 @@ export class BulkEditorComponent
}
openCustomFieldsDropdown() {
+ if (this.list.allSelected) {
+ const selectionData = this.list.selectionData
+ this.customFieldDocumentCounts =
+ selectionData?.selected_custom_fields ?? []
+ this.applySelectionData(
+ this.customFieldDocumentCounts,
+ this.customFieldsSelectionModel
+ )
+ return
+ }
+
this.documentService
.getSelectionData(Array.from(this.list.selected))
.pipe(first())
@@ -437,33 +503,33 @@ export class BulkEditorComponent
changedTags.itemsToRemove.length == 0
) {
let tag = changedTags.itemsToAdd[0]
- modal.componentInstance.message = $localize`This operation will add the tag "${tag.name}" to ${this.list.selected.size} selected document(s).`
+ modal.componentInstance.message = $localize`This operation will add the tag "${tag.name}" to ${this.getSelectionSize()} selected document(s).`
} else if (
changedTags.itemsToAdd.length > 1 &&
changedTags.itemsToRemove.length == 0
) {
modal.componentInstance.message = $localize`This operation will add the tags ${this._localizeList(
changedTags.itemsToAdd
- )} to ${this.list.selected.size} selected document(s).`
+ )} to ${this.getSelectionSize()} selected document(s).`
} else if (
changedTags.itemsToAdd.length == 0 &&
changedTags.itemsToRemove.length == 1
) {
let tag = changedTags.itemsToRemove[0]
- modal.componentInstance.message = $localize`This operation will remove the tag "${tag.name}" from ${this.list.selected.size} selected document(s).`
+ modal.componentInstance.message = $localize`This operation will remove the tag "${tag.name}" from ${this.getSelectionSize()} selected document(s).`
} else if (
changedTags.itemsToAdd.length == 0 &&
changedTags.itemsToRemove.length > 1
) {
modal.componentInstance.message = $localize`This operation will remove the tags ${this._localizeList(
changedTags.itemsToRemove
- )} from ${this.list.selected.size} selected document(s).`
+ )} from ${this.getSelectionSize()} selected document(s).`
} else {
modal.componentInstance.message = $localize`This operation will add the tags ${this._localizeList(
changedTags.itemsToAdd
)} and remove the tags ${this._localizeList(
changedTags.itemsToRemove
- )} on ${this.list.selected.size} selected document(s).`
+ )} on ${this.getSelectionSize()} selected document(s).`
}
modal.componentInstance.btnClass = 'btn-warning'
@@ -502,9 +568,9 @@ export class BulkEditorComponent
})
modal.componentInstance.title = $localize`Confirm correspondent assignment`
if (correspondent) {
- modal.componentInstance.message = $localize`This operation will assign the correspondent "${correspondent.name}" to ${this.list.selected.size} selected document(s).`
+ modal.componentInstance.message = $localize`This operation will assign the correspondent "${correspondent.name}" to ${this.getSelectionSize()} selected document(s).`
} else {
- modal.componentInstance.message = $localize`This operation will remove the correspondent from ${this.list.selected.size} selected document(s).`
+ modal.componentInstance.message = $localize`This operation will remove the correspondent from ${this.getSelectionSize()} selected document(s).`
}
modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Confirm`
@@ -540,9 +606,9 @@ export class BulkEditorComponent
})
modal.componentInstance.title = $localize`Confirm document type assignment`
if (documentType) {
- modal.componentInstance.message = $localize`This operation will assign the document type "${documentType.name}" to ${this.list.selected.size} selected document(s).`
+ modal.componentInstance.message = $localize`This operation will assign the document type "${documentType.name}" to ${this.getSelectionSize()} selected document(s).`
} else {
- modal.componentInstance.message = $localize`This operation will remove the document type from ${this.list.selected.size} selected document(s).`
+ modal.componentInstance.message = $localize`This operation will remove the document type from ${this.getSelectionSize()} selected document(s).`
}
modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Confirm`
@@ -578,9 +644,9 @@ export class BulkEditorComponent
})
modal.componentInstance.title = $localize`Confirm storage path assignment`
if (storagePath) {
- modal.componentInstance.message = $localize`This operation will assign the storage path "${storagePath.name}" to ${this.list.selected.size} selected document(s).`
+ modal.componentInstance.message = $localize`This operation will assign the storage path "${storagePath.name}" to ${this.getSelectionSize()} selected document(s).`
} else {
- modal.componentInstance.message = $localize`This operation will remove the storage path from ${this.list.selected.size} selected document(s).`
+ modal.componentInstance.message = $localize`This operation will remove the storage path from ${this.getSelectionSize()} selected document(s).`
}
modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Confirm`
@@ -615,33 +681,33 @@ export class BulkEditorComponent
changedCustomFields.itemsToRemove.length == 0
) {
let customField = changedCustomFields.itemsToAdd[0]
- modal.componentInstance.message = $localize`This operation will assign the custom field "${customField.name}" to ${this.list.selected.size} selected document(s).`
+ modal.componentInstance.message = $localize`This operation will assign the custom field "${customField.name}" to ${this.getSelectionSize()} selected document(s).`
} else if (
changedCustomFields.itemsToAdd.length > 1 &&
changedCustomFields.itemsToRemove.length == 0
) {
modal.componentInstance.message = $localize`This operation will assign the custom fields ${this._localizeList(
changedCustomFields.itemsToAdd
- )} to ${this.list.selected.size} selected document(s).`
+ )} to ${this.getSelectionSize()} selected document(s).`
} else if (
changedCustomFields.itemsToAdd.length == 0 &&
changedCustomFields.itemsToRemove.length == 1
) {
let customField = changedCustomFields.itemsToRemove[0]
- modal.componentInstance.message = $localize`This operation will remove the custom field "${customField.name}" from ${this.list.selected.size} selected document(s).`
+ modal.componentInstance.message = $localize`This operation will remove the custom field "${customField.name}" from ${this.getSelectionSize()} selected document(s).`
} else if (
changedCustomFields.itemsToAdd.length == 0 &&
changedCustomFields.itemsToRemove.length > 1
) {
modal.componentInstance.message = $localize`This operation will remove the custom fields ${this._localizeList(
changedCustomFields.itemsToRemove
- )} from ${this.list.selected.size} selected document(s).`
+ )} from ${this.getSelectionSize()} selected document(s).`
} else {
modal.componentInstance.message = $localize`This operation will assign the custom fields ${this._localizeList(
changedCustomFields.itemsToAdd
)} and remove the custom fields ${this._localizeList(
changedCustomFields.itemsToRemove
- )} on ${this.list.selected.size} selected document(s).`
+ )} on ${this.getSelectionSize()} selected document(s).`
}
modal.componentInstance.btnClass = 'btn-warning'
@@ -779,7 +845,7 @@ export class BulkEditorComponent
backdrop: 'static',
})
modal.componentInstance.title = $localize`Confirm`
- modal.componentInstance.messageBold = $localize`Move ${this.list.selected.size} selected document(s) to the trash?`
+ modal.componentInstance.messageBold = $localize`Move ${this.getSelectionSize()} selected document(s) to the trash?`
modal.componentInstance.message = $localize`Documents can be restored prior to permanent deletion.`
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Move to trash`
@@ -789,13 +855,13 @@ export class BulkEditorComponent
modal.componentInstance.buttonsEnabled = false
this.executeDocumentAction(
modal,
- this.documentService.deleteDocuments(Array.from(this.list.selected))
+ this.documentService.deleteDocuments(this.getSelectionQuery())
)
})
} else {
this.executeDocumentAction(
null,
- this.documentService.deleteDocuments(Array.from(this.list.selected))
+ this.documentService.deleteDocuments(this.getSelectionQuery())
)
}
}
@@ -811,7 +877,7 @@ export class BulkEditorComponent
: 'originals'
this.documentService
.bulkDownload(
- Array.from(this.list.selected),
+ this.getSelectionQuery(),
downloadFileType,
this.downloadForm.get('downloadUseFormatting').value
)
@@ -827,7 +893,7 @@ export class BulkEditorComponent
backdrop: 'static',
})
modal.componentInstance.title = $localize`Reprocess confirm`
- modal.componentInstance.messageBold = $localize`This operation will permanently recreate the archive files for ${this.list.selected.size} selected document(s).`
+ modal.componentInstance.messageBold = $localize`This operation will permanently recreate the archive files for ${this.getSelectionSize()} selected document(s).`
modal.componentInstance.message = $localize`The archive files will be re-generated with the current settings.`
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed`
@@ -837,9 +903,7 @@ export class BulkEditorComponent
modal.componentInstance.buttonsEnabled = false
this.executeDocumentAction(
modal,
- this.documentService.reprocessDocuments(
- Array.from(this.list.selected)
- )
+ this.documentService.reprocessDocuments(this.getSelectionQuery())
)
})
}
@@ -866,7 +930,7 @@ export class BulkEditorComponent
})
const rotateDialog = modal.componentInstance as RotateConfirmDialogComponent
rotateDialog.title = $localize`Rotate confirm`
- rotateDialog.messageBold = $localize`This operation will add rotated versions of the ${this.list.selected.size} document(s).`
+ rotateDialog.messageBold = $localize`This operation will add rotated versions of the ${this.getSelectionSize()} document(s).`
rotateDialog.btnClass = 'btn-danger'
rotateDialog.btnCaption = $localize`Proceed`
rotateDialog.documentID = Array.from(this.list.selected)[0]
@@ -877,7 +941,7 @@ export class BulkEditorComponent
this.executeDocumentAction(
modal,
this.documentService.rotateDocuments(
- Array.from(this.list.selected),
+ this.getSelectionQuery(),
rotateDialog.degrees
)
)
@@ -890,7 +954,7 @@ export class BulkEditorComponent
})
const mergeDialog = modal.componentInstance as MergeConfirmDialogComponent
mergeDialog.title = $localize`Merge confirm`
- mergeDialog.messageBold = $localize`This operation will merge ${this.list.selected.size} selected documents into a new document.`
+ mergeDialog.messageBold = $localize`This operation will merge ${this.getSelectionSize()} selected documents into a new document.`
mergeDialog.btnCaption = $localize`Proceed`
mergeDialog.documentIDs = Array.from(this.list.selected)
mergeDialog.confirmClicked
@@ -935,7 +999,7 @@ export class BulkEditorComponent
(item) => item.id
)
- dialog.documents = Array.from(this.list.selected)
+ dialog.selection = this.getSelectionQuery()
dialog.succeeded.subscribe((result) => {
this.toastService.showInfo($localize`Custom fields updated.`)
this.list.reload()
diff --git a/src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.spec.ts b/src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.spec.ts
index 40ec327ba..1e31c0e05 100644
--- a/src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.spec.ts
+++ b/src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.spec.ts
@@ -48,7 +48,7 @@ describe('CustomFieldsBulkEditDialogComponent', () => {
.mockReturnValue(of('Success'))
const successSpy = jest.spyOn(component.succeeded, 'emit')
- component.documents = [1, 2]
+ component.selection = [1, 2]
component.fieldsToAddIds = [1]
component.form.controls['1'].setValue('Value 1')
component.save()
@@ -63,7 +63,7 @@ describe('CustomFieldsBulkEditDialogComponent', () => {
.mockReturnValue(throwError(new Error('Error')))
const failSpy = jest.spyOn(component.failed, 'emit')
- component.documents = [1, 2]
+ component.selection = [1, 2]
component.fieldsToAddIds = [1]
component.form.controls['1'].setValue('Value 1')
component.save()
diff --git a/src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.ts b/src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.ts
index 8452e5388..7d3878c59 100644
--- a/src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.ts
+++ b/src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.ts
@@ -17,7 +17,10 @@ import { SelectComponent } from 'src/app/components/common/input/select/select.c
import { TextComponent } from 'src/app/components/common/input/text/text.component'
import { UrlComponent } from 'src/app/components/common/input/url/url.component'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
-import { DocumentService } from 'src/app/services/rest/document.service'
+import {
+ DocumentSelectionQuery,
+ DocumentService,
+} from 'src/app/services/rest/document.service'
import { TextAreaComponent } from '../../../common/input/textarea/textarea.component'
@Component({
@@ -76,7 +79,11 @@ export class CustomFieldsBulkEditDialogComponent {
public form: FormGroup = new FormGroup({})
- public documents: number[] = []
+ public selection: DocumentSelectionQuery = { documents: [] }
+
+ public get documents(): number[] {
+ return this.selection.documents
+ }
initForm() {
Object.keys(this.form.controls).forEach((key) => {
@@ -91,7 +98,7 @@ export class CustomFieldsBulkEditDialogComponent {
public save() {
this.documentService
- .bulkEdit(this.documents, 'modify_custom_fields', {
+ .bulkEdit(this.selection, 'modify_custom_fields', {
add_custom_fields: this.form.value,
remove_custom_fields: this.fieldsToRemoveIds,
})
diff --git a/src-ui/src/app/components/document-list/document-list.component.html b/src-ui/src/app/components/document-list/document-list.component.html
index 176513663..aadec7d77 100644
--- a/src-ui/src/app/components/document-list/document-list.component.html
+++ b/src-ui/src/app/components/document-list/document-list.component.html
@@ -2,8 +2,8 @@