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 + 276 src/app/components/manage/mail/mail.component.html @@ -2450,11 +2450,11 @@ src/app/components/manage/document-attributes/management-list/management-list.component.ts - 261 + 272 src/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 + 455 src/app/components/manage/mail/mail.component.ts @@ -2815,7 +2815,7 @@ src/app/components/manage/document-attributes/management-list/management-list.component.ts - 426 + 457 src/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 + 143 src/app/data/matching-model.ts @@ -9536,7 +9536,7 @@ Automatic src/app/components/manage/document-attributes/management-list/management-list.component.ts - 130 + 141 src/app/data/matching-model.ts @@ -9547,63 +9547,63 @@ Successfully created . src/app/components/manage/document-attributes/management-list/management-list.component.ts - 218 + 229 Error occurred while creating . src/app/components/manage/document-attributes/management-list/management-list.component.ts - 223 + 234 Successfully updated "". src/app/components/manage/document-attributes/management-list/management-list.component.ts - 238 + 249 Error occurred while saving . src/app/components/manage/document-attributes/management-list/management-list.component.ts - 243 + 254 Associated documents will not be deleted. src/app/components/manage/document-attributes/management-list/management-list.component.ts - 263 + 274 Error while deleting element src/app/components/manage/document-attributes/management-list/management-list.component.ts - 279 + 290 Error saving settings src/app/components/manage/document-attributes/management-list/management-list.component.ts - 318 + 329 Permissions updated successfully src/app/components/manage/document-attributes/management-list/management-list.component.ts - 402 + 433 Error updating permissions src/app/components/manage/document-attributes/management-list/management-list.component.ts - 409 + 440 src/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 + 454 Objects deleted successfully src/app/components/manage/document-attributes/management-list/management-list.component.ts - 437 + 472 Error deleting objects src/app/components/manage/document-attributes/management-list/management-list.component.ts - 443 + 478 @@ -9660,21 +9660,21 @@ tag src/app/components/manage/document-attributes/management-list/tag-list/tag-list.component.ts - 43 + 42 tags src/app/components/manage/document-attributes/management-list/tag-list/tag-list.component.ts - 44 + 43 Do 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 @@ - @@ -103,13 +103,13 @@ class="btn btn-sm btn-outline-primary" id="dropdownSend" ngbDropdownToggle - [disabled]="disabled || list.selected.size === 0" + [disabled]="disabled || !list.hasSelection || list.allSelected" >
Send
- @if (emailEnabled) { - } 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 @@
@@ -17,7 +17,7 @@ Select:
- @if (list.selected.size > 0) { + @if (list.hasSelection) { @@ -127,11 +127,11 @@
Loading... } - @if (list.selected.size > 0) { - {list.collectionSize, plural, =1 {Selected {{list.selected.size}} of one document} other {Selected {{list.selected.size}} of {{list.collectionSize || 0}} documents}} + @if (list.hasSelection) { + {list.collectionSize, plural, =1 {Selected {{list.selectedCount}} of one document} other {Selected {{list.selectedCount}} of {{list.collectionSize || 0}} documents}} } @if (!list.isReloading) { - @if (list.selected.size === 0) { + @if (!list.hasSelection) { {list.collectionSize, plural, =1 {One document} other {{{list.collectionSize || 0}} documents}} } @if (isFiltered) {  (filtered) @@ -142,7 +142,7 @@ Reset filters } - @if (!list.isReloading && list.selected.size > 0) { + @if (!list.isReloading && list.hasSelection) { diff --git a/src-ui/src/app/components/document-list/document-list.component.spec.ts b/src-ui/src/app/components/document-list/document-list.component.spec.ts index 3ea39ccb0..9ea7f27de 100644 --- a/src-ui/src/app/components/document-list/document-list.component.spec.ts +++ b/src-ui/src/app/components/document-list/document-list.component.spec.ts @@ -388,8 +388,8 @@ describe('DocumentListComponent', () => { it('should support select all, none, page & range', () => { jest.spyOn(documentListService, 'documents', 'get').mockReturnValue(docs) jest - .spyOn(documentService, 'listAllFilteredIds') - .mockReturnValue(of(docs.map((d) => d.id))) + .spyOn(documentListService, 'collectionSize', 'get') + .mockReturnValue(docs.length) fixture.detectChanges() expect(documentListService.selected.size).toEqual(0) const docCards = fixture.debugElement.queryAll( @@ -403,7 +403,8 @@ describe('DocumentListComponent', () => { displayModeButtons[2].triggerEventHandler('click') expect(selectAllSpy).toHaveBeenCalled() fixture.detectChanges() - expect(documentListService.selected.size).toEqual(3) + expect(documentListService.allSelected).toBeTruthy() + expect(documentListService.selectedCount).toEqual(3) docCards.forEach((card) => { expect(card.context.selected).toBeTruthy() }) diff --git a/src-ui/src/app/components/document-list/document-list.component.ts b/src-ui/src/app/components/document-list/document-list.component.ts index 2cd2ccaf3..eb453d4dc 100644 --- a/src-ui/src/app/components/document-list/document-list.component.ts +++ b/src-ui/src/app/components/document-list/document-list.component.ts @@ -240,7 +240,7 @@ export class DocumentListComponent } get isBulkEditing(): boolean { - return this.list.selected.size > 0 + return this.list.hasSelection } toggleDisplayField(field: DisplayField) { @@ -327,7 +327,7 @@ export class DocumentListComponent }) .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe(() => { - if (this.list.selected.size > 0) { + if (this.list.hasSelection) { this.list.selectNone() } else if (this.isFiltered) { this.resetFilters() @@ -356,7 +356,7 @@ export class DocumentListComponent .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe(() => { if (this.list.documents.length > 0) { - if (this.list.selected.size > 0) { + if (this.list.hasSelection) { this.openDocumentDetail(Array.from(this.list.selected)[0]) } else { this.openDocumentDetail(this.list.documents[0]) diff --git a/src-ui/src/app/components/manage/document-attributes/document-attributes.component.html b/src-ui/src/app/components/manage/document-attributes/document-attributes.component.html index bee9a29aa..118b61ce3 100644 --- a/src-ui/src/app/components/manage/document-attributes/document-attributes.component.html +++ b/src-ui/src/app/components/manage/document-attributes/document-attributes.component.html @@ -9,8 +9,8 @@
@@ -25,7 +25,7 @@ Select:
- @if (activeManagementList.selectedObjects.size > 0) { + @if (activeManagementList.hasSelection) { @@ -40,11 +40,11 @@