From 183cfd2da4fd00c1cd5280f078db7f30d8a646f3 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:11:34 -0700 Subject: [PATCH 01/18] Fix: keep counter and extension when shortening long public filenames (#14472) get_public_filename relied on sanitize_filename to truncate, which cuts from the end and so dropped the counter and extension of long titles. Multi-byte titles could also exceed the 255 byte filesystem limit. Shorten the title by UTF-8 bytes before appending the counter and extension. The exporter reserves room for its longer derived thumbnail and archive suffixes. --- .../management/commands/document_exporter.py | 19 ++++- src/documents/models.py | 35 +++++---- src/documents/tests/test_document_model.py | 72 +++++++++++++++++++ .../tests/test_management_exporter.py | 67 +++++++++++++++++ 4 files changed, 178 insertions(+), 15 deletions(-) diff --git a/src/documents/management/commands/document_exporter.py b/src/documents/management/commands/document_exporter.py index ea92f4e59..10b929679 100644 --- a/src/documents/management/commands/document_exporter.py +++ b/src/documents/management/commands/document_exporter.py @@ -3,6 +3,7 @@ from itertools import islice from pathlib import Path from typing import TYPE_CHECKING from typing import Any +from typing import Final from allauth.mfa.models import Authenticator from allauth.socialaccount.models import SocialAccount @@ -70,6 +71,15 @@ from paperless.models import ApplicationConfiguration from paperless_mail.models import MailAccount from paperless_mail.models import MailRule +# Derived names replace the original's extension with these suffixes +THUMBNAIL_SUFFIX: Final[str] = "-thumbnail.webp" +ARCHIVE_SUFFIX: Final[str] = "-archive.pdf" +# Leave room for the longest derived suffix, which replaces the original's extension +MAX_BASE_NAME_BYTES: Final[int] = Document.MAX_PUBLIC_FILENAME_BYTES - max( + len(THUMBNAIL_SUFFIX), + len(ARCHIVE_SUFFIX), +) + def serialize_queryset_batched( queryset: "QuerySet[Any]", @@ -512,7 +522,10 @@ class Command(CryptMixin, PaperlessCommand): counter=filename_counter, ) else: - base_name = document.get_public_filename(counter=filename_counter) + base_name = document.get_public_filename( + counter=filename_counter, + max_bytes=MAX_BASE_NAME_BYTES, + ) # The thumbnail and archive names are derived from the stem, so # uniqueness must hold without the original's extension @@ -541,7 +554,7 @@ class Command(CryptMixin, PaperlessCommand): document_dict[EXPORTER_FILE_NAME] = original_arc if not self.no_thumbnail: - thumbnail_name = base_name.parent / (base_name.stem + "-thumbnail.webp") + thumbnail_name = base_name.parent / (base_name.stem + THUMBNAIL_SUFFIX) if self.use_folder_prefix: thumbnail_name = Path("thumbnails") / thumbnail_name thumbnail_arc = thumbnail_name.as_posix() @@ -550,7 +563,7 @@ class Command(CryptMixin, PaperlessCommand): thumbnail_arc = None if not self.no_archive and document.has_archive_version: - archive_name = base_name.parent / (base_name.stem + "-archive.pdf") + archive_name = base_name.parent / (base_name.stem + ARCHIVE_SUFFIX) if self.use_folder_prefix: archive_name = Path("archive") / archive_name archive_arc = archive_name.as_posix() diff --git a/src/documents/models.py b/src/documents/models.py index ff8632b12..f5a9f6ddd 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -157,6 +157,8 @@ class StoragePath(MatchingModel): class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-missing] MAX_STORED_FILENAME_LENGTH: Final[int] = 1024 + # Per-component limit of common filesystems (ext4, XFS, btrfs, ZFS, APFS) + MAX_PUBLIC_FILENAME_BYTES: Final[int] = 255 correspondent = models.ForeignKey( Correspondent, @@ -484,28 +486,37 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager- def archive_file(self): return Path(self.archive_path).open("rb") - def get_public_filename(self, *, archive=False, counter=0, suffix=None) -> str: + def get_public_filename( + self, + *, + archive: bool = False, + counter: int = 0, + max_bytes: int = MAX_PUBLIC_FILENAME_BYTES, + ) -> str: """ Returns a sanitized filename for the document, not including any paths. + + The name fits in max_bytes (UTF-8). A long title is shortened before + the counter and extension are appended, because sanitize_filename + truncates from the end. The tail is cut only when max_bytes is + smaller than the tail itself. """ # Root owns metadata for all versions context_document = ( self.root_document if self.root_document_id is not None else self ) - result = str(context_document) - if counter: - result += f"_{counter:02}" + extension = ".pdf" if archive else self.file_type + tail = f"_{counter:02}{extension}" if counter else extension - if suffix: - result += suffix + head_bytes = max(0, max_bytes - len(tail.encode())) + head = str(context_document).encode()[:head_bytes].decode(errors="ignore") - if archive: - result += ".pdf" - else: - result += self.file_type - - return pathvalidate.sanitize_filename(result, replacement_text="-") + return pathvalidate.sanitize_filename( + head + tail, + replacement_text="-", + max_len=max_bytes, + ) @property def file_type(self): diff --git a/src/documents/tests/test_document_model.py b/src/documents/tests/test_document_model.py index a6fb0b3f6..3ff51711e 100644 --- a/src/documents/tests/test_document_model.py +++ b/src/documents/tests/test_document_model.py @@ -4,13 +4,16 @@ from datetime import date from pathlib import Path from unittest import mock +import pytest from django.test import TestCase from django.test import override_settings from faker import Faker +from pytest_mock import MockerFixture from documents.models import Correspondent from documents.models import Document from documents.tasks import empty_trash +from paperless_testing.factories import DocumentFactory class TestDocument(TestCase): @@ -257,3 +260,72 @@ def test_suggestion_content() -> None: ) doc.content = content_over_limit assert doc.suggestion_content == expected_cropped_content + + +class TestPublicFilenameLength: + @pytest.fixture(autouse=True) + def _no_parser_registry(self, mocker: MockerFixture) -> None: + # The parser registry reads ApplicationConfiguration from the database + extensions = {"application/pdf": ".pdf", "image/jpeg": ".jpg"} + mocker.patch( + "documents.models.get_default_file_extension", + side_effect=extensions.__getitem__, + ) + + LIMIT = Document.MAX_PUBLIC_FILENAME_BYTES + + @pytest.mark.parametrize( + ("title", "archive", "counter", "max_bytes", "tail"), + [ + pytest.param("a" * 300, False, 0, LIMIT, ".jpg", id="ascii"), + pytest.param("a" * 300, True, 0, LIMIT, ".pdf", id="archive"), + pytest.param("ä" * 200, False, 0, LIMIT, ".jpg", id="umlaut"), + pytest.param("ä" * 200, False, 1, LIMIT, "_01.jpg", id="count"), + pytest.param("a" * 300, False, 1, 100, "_01.jpg", id="max"), + ], + ) + def test_long_title_keeps_tail_within_limit( + self, + title: str, + *, + archive: bool, + counter: int, + max_bytes: int, + tail: str, + ) -> None: + """ + GIVEN: + - A document whose title alone is longer than the byte limit + WHEN: + - The public file name is generated + THEN: + - The name ends with the counter and extension + - The name fits in the limit + """ + name = DocumentFactory.build( + title=title, + mime_type="image/jpeg", + ).get_public_filename( + archive=archive, + counter=counter, + max_bytes=max_bytes, + ) + + assert name.endswith(tail) + assert len(name.encode()) <= max_bytes + + def test_long_title_counters_give_distinct_names(self) -> None: + """ + GIVEN: + - A document whose title alone is longer than the byte limit + WHEN: + - Public file names are generated with increasing counters + THEN: + - Each counter produces a distinct name + """ + doc = DocumentFactory.build(title="ä" * 200, mime_type="application/pdf") + + names = [doc.get_public_filename(counter=counter) for counter in range(3)] + + assert len(set(names)) == 3 + assert all(len(n.encode()) <= self.LIMIT for n in names) diff --git a/src/documents/tests/test_management_exporter.py b/src/documents/tests/test_management_exporter.py index 9a2c95e9e..3bfc909c8 100644 --- a/src/documents/tests/test_management_exporter.py +++ b/src/documents/tests/test_management_exporter.py @@ -1537,3 +1537,70 @@ class TestExportDerivedFileNameCollisions: assert ( target / record[EXPORTER_ARCHIVE_NAME] ).read_text() == f"archive {pk}" + + def test_long_titles_export_within_file_name_limit( + self, + paperless_dirs: PaperlessDirs, + tmp_path: Path, + ) -> None: + """ + GIVEN: + - Two documents with the same created date and the same title + - The title alone is longer than 255 bytes and contains umlauts + WHEN: + - The exporter runs + THEN: + - The export finishes with distinct original, thumbnail and archive names + - Every exported file name fits in the byte limit + - Each exported file holds its own document's content + """ + for pk in (1, 2): + (paperless_dirs.originals_dir / f"{pk:07}.pdf").write_bytes( + f"original {pk}".encode(), + ) + (paperless_dirs.archive_dir / f"{pk:07}.pdf").write_bytes( + f"archive {pk}".encode(), + ) + (paperless_dirs.thumbnail_dir / f"{pk:07}.webp").write_bytes( + f"thumbnail {pk}".encode(), + ) + DocumentFactory( + pk=pk, + title="Bestellbestätigung " * 15, + created=date(2026, 10, 15), + filename=f"{pk:07}.pdf", + archive_filename=f"{pk:07}.pdf", + mime_type="application/pdf", + ) + + target = tmp_path / "export" + target.mkdir() + call_command( + "document_exporter", + str(target), + "--no-progress-bar", + skip_checks=True, + ) + + manifest = json.loads((target / "manifest.json").read_text()) + records = {r["pk"]: r for r in manifest if r["model"] == "documents.document"} + + for key in ( + EXPORTER_FILE_NAME, + EXPORTER_THUMBNAIL_NAME, + EXPORTER_ARCHIVE_NAME, + ): + assert records[1][key] != records[2][key] + + for pk in (1, 2): + record = records[pk] + for key, content in ( + (EXPORTER_FILE_NAME, f"original {pk}"), + (EXPORTER_THUMBNAIL_NAME, f"thumbnail {pk}"), + (EXPORTER_ARCHIVE_NAME, f"archive {pk}"), + ): + assert ( + len(Path(record[key]).name.encode()) + <= Document.MAX_PUBLIC_FILENAME_BYTES + ) + assert (target / record[key]).read_text() == content From 1f3d451a3a4643bd486f5a1701781bcfd6a95a82 Mon Sep 17 00:00:00 2001 From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:12:50 +0000 Subject: [PATCH 02/18] Auto translate strings --- src/locale/en_US/LC_MESSAGES/django.po | 770 ++++++++++++------------- 1 file changed, 385 insertions(+), 385 deletions(-) diff --git a/src/locale/en_US/LC_MESSAGES/django.po b/src/locale/en_US/LC_MESSAGES/django.po index d264f4f74..2dbdfb4a7 100644 --- a/src/locale/en_US/LC_MESSAGES/django.po +++ b/src/locale/en_US/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: paperless-ngx\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-14 15:18+0000\n" +"POT-Creation-Date: 2026-10-16 17:12+0000\n" "PO-Revision-Date: 2022-02-17 04:17\n" "Last-Translator: \n" "Language-Team: English\n" @@ -57,31 +57,31 @@ msgstr "" msgid "Custom field not found" msgstr "" -#: documents/models.py:40 documents/models.py:1111 documents/models.py:1159 +#: documents/models.py:40 documents/models.py:1122 documents/models.py:1170 msgid "owner" msgstr "" -#: documents/models.py:57 documents/models.py:1457 +#: documents/models.py:57 documents/models.py:1468 msgid "None" msgstr "" -#: documents/models.py:58 documents/models.py:1458 +#: documents/models.py:58 documents/models.py:1469 msgid "Any word" msgstr "" -#: documents/models.py:59 documents/models.py:1459 +#: documents/models.py:59 documents/models.py:1470 msgid "All words" msgstr "" -#: documents/models.py:60 documents/models.py:1460 +#: documents/models.py:60 documents/models.py:1471 msgid "Exact match" msgstr "" -#: documents/models.py:61 documents/models.py:1461 +#: documents/models.py:61 documents/models.py:1472 msgid "Regular expression" msgstr "" -#: documents/models.py:62 documents/models.py:1462 +#: documents/models.py:62 documents/models.py:1473 msgid "Fuzzy word" msgstr "" @@ -89,24 +89,24 @@ msgstr "" msgid "Automatic" msgstr "" -#: documents/models.py:66 documents/models.py:643 documents/models.py:2083 +#: documents/models.py:66 documents/models.py:654 documents/models.py:2094 #: paperless_mail/models.py:23 paperless_mail/models.py:143 msgid "name" msgstr "" -#: documents/models.py:68 documents/models.py:1526 +#: documents/models.py:68 documents/models.py:1537 msgid "match" msgstr "" -#: documents/models.py:71 documents/models.py:1529 +#: documents/models.py:71 documents/models.py:1540 msgid "matching algorithm" msgstr "" -#: documents/models.py:76 documents/models.py:1534 +#: documents/models.py:76 documents/models.py:1545 msgid "is insensitive" msgstr "" -#: documents/models.py:99 documents/models.py:167 +#: documents/models.py:99 documents/models.py:169 msgid "correspondent" msgstr "" @@ -132,7 +132,7 @@ msgstr "" msgid "tag" msgstr "" -#: documents/models.py:119 documents/models.py:214 +#: documents/models.py:119 documents/models.py:216 msgid "tags" msgstr "" @@ -144,7 +144,7 @@ msgstr "" msgid "Cannot set parent to a descendant." msgstr "" -#: documents/models.py:144 documents/models.py:187 +#: documents/models.py:144 documents/models.py:189 msgid "document type" msgstr "" @@ -156,7 +156,7 @@ msgstr "" msgid "path" msgstr "" -#: documents/models.py:154 documents/models.py:176 +#: documents/models.py:154 documents/models.py:178 msgid "storage path" msgstr "" @@ -164,1491 +164,1491 @@ msgstr "" msgid "storage paths" msgstr "" -#: documents/models.py:179 +#: documents/models.py:181 msgid "title" msgstr "" -#: documents/models.py:191 documents/models.py:944 +#: documents/models.py:193 documents/models.py:955 msgid "content" msgstr "" -#: documents/models.py:194 +#: documents/models.py:196 msgid "" "The raw, text-only data of the document. This field is primarily used for " "searching." msgstr "" -#: documents/models.py:208 +#: documents/models.py:210 msgid "mime type" msgstr "" -#: documents/models.py:218 +#: documents/models.py:220 msgid "checksum" msgstr "" -#: documents/models.py:222 +#: documents/models.py:224 msgid "The checksum of the original document." msgstr "" -#: documents/models.py:226 +#: documents/models.py:228 msgid "archive checksum" msgstr "" -#: documents/models.py:232 +#: documents/models.py:234 msgid "The checksum of the archived document." msgstr "" -#: documents/models.py:236 +#: documents/models.py:238 msgid "page count" msgstr "" -#: documents/models.py:243 +#: documents/models.py:245 msgid "The number of pages of the document." msgstr "" -#: documents/models.py:248 documents/models.py:950 documents/models.py:1069 -#: documents/models.py:1131 documents/models.py:1259 documents/models.py:1318 +#: documents/models.py:250 documents/models.py:961 documents/models.py:1080 +#: documents/models.py:1142 documents/models.py:1270 documents/models.py:1329 msgid "created" msgstr "" -#: documents/models.py:254 +#: documents/models.py:256 msgid "modified" msgstr "" -#: documents/models.py:261 +#: documents/models.py:263 msgid "added" msgstr "" -#: documents/models.py:268 +#: documents/models.py:270 msgid "filename" msgstr "" -#: documents/models.py:274 +#: documents/models.py:276 msgid "Current filename in storage" msgstr "" -#: documents/models.py:278 +#: documents/models.py:280 msgid "archive filename" msgstr "" -#: documents/models.py:284 +#: documents/models.py:286 msgid "Current archive filename in storage" msgstr "" -#: documents/models.py:288 +#: documents/models.py:290 msgid "original filename" msgstr "" -#: documents/models.py:294 +#: documents/models.py:296 msgid "The original name of the file when it was uploaded" msgstr "" -#: documents/models.py:301 +#: documents/models.py:303 msgid "archive serial number" msgstr "" -#: documents/models.py:311 +#: documents/models.py:313 msgid "The position of this document in your physical document archive." msgstr "" -#: documents/models.py:321 +#: documents/models.py:323 msgid "root document for this version" msgstr "" -#: documents/models.py:325 +#: documents/models.py:327 msgid "version index" msgstr "" -#: documents/models.py:329 +#: documents/models.py:331 msgid "Index of this version within the root document." msgstr "" -#: documents/models.py:333 +#: documents/models.py:335 msgid "version label" msgstr "" -#: documents/models.py:337 +#: documents/models.py:339 msgid "Optional short label for a document version." msgstr "" -#: documents/models.py:342 documents/models.py:961 documents/models.py:992 -#: documents/models.py:1096 documents/models.py:2126 +#: documents/models.py:344 documents/models.py:972 documents/models.py:1003 +#: documents/models.py:1107 documents/models.py:2137 msgid "document" msgstr "" -#: documents/models.py:343 documents/models.py:1202 +#: documents/models.py:345 documents/models.py:1213 msgid "documents" msgstr "" -#: documents/models.py:562 documents/models.py:1065 +#: documents/models.py:573 documents/models.py:1076 msgid "Archive" msgstr "" -#: documents/models.py:563 +#: documents/models.py:574 msgid "Bank" msgstr "" -#: documents/models.py:564 +#: documents/models.py:575 msgid "Basket" msgstr "" -#: documents/models.py:565 +#: documents/models.py:576 msgid "Bell" msgstr "" -#: documents/models.py:566 +#: documents/models.py:577 msgid "Bookmark" msgstr "" -#: documents/models.py:567 +#: documents/models.py:578 msgid "Boxes" msgstr "" -#: documents/models.py:568 +#: documents/models.py:579 msgid "Briefcase" msgstr "" -#: documents/models.py:569 +#: documents/models.py:580 msgid "Building" msgstr "" -#: documents/models.py:570 +#: documents/models.py:581 msgid "Calculator" msgstr "" -#: documents/models.py:571 +#: documents/models.py:582 msgid "Calendar" msgstr "" -#: documents/models.py:572 +#: documents/models.py:583 msgid "Camera" msgstr "" -#: documents/models.py:573 +#: documents/models.py:584 msgid "Checklist" msgstr "" -#: documents/models.py:574 +#: documents/models.py:585 msgid "Cash" msgstr "" -#: documents/models.py:575 +#: documents/models.py:586 msgid "Chat" msgstr "" -#: documents/models.py:576 +#: documents/models.py:587 msgid "Check" msgstr "" -#: documents/models.py:577 +#: documents/models.py:588 msgid "Clipboard" msgstr "" -#: documents/models.py:578 +#: documents/models.py:589 msgid "Clock" msgstr "" -#: documents/models.py:579 +#: documents/models.py:590 msgid "Credit card" msgstr "" -#: documents/models.py:580 +#: documents/models.py:591 msgid "Download" msgstr "" -#: documents/models.py:581 +#: documents/models.py:592 msgid "Envelope" msgstr "" -#: documents/models.py:582 +#: documents/models.py:593 msgid "Warning" msgstr "" -#: documents/models.py:583 +#: documents/models.py:594 msgid "File" msgstr "" -#: documents/models.py:584 +#: documents/models.py:595 msgid "Checked file" msgstr "" -#: documents/models.py:585 +#: documents/models.py:596 msgid "Locked file" msgstr "" -#: documents/models.py:586 +#: documents/models.py:597 msgid "Medical file" msgstr "" -#: documents/models.py:587 +#: documents/models.py:598 msgid "Person file" msgstr "" -#: documents/models.py:590 +#: documents/models.py:601 msgid "Spreadsheet" msgstr "" -#: documents/models.py:592 +#: documents/models.py:603 msgid "Text file" msgstr "" -#: documents/models.py:593 +#: documents/models.py:604 msgid "Files" msgstr "" -#: documents/models.py:594 +#: documents/models.py:605 msgid "Folder" msgstr "" -#: documents/models.py:595 paperless_mail/admin.py:58 +#: documents/models.py:606 paperless_mail/admin.py:58 msgid "Filter" msgstr "" -#: documents/models.py:596 +#: documents/models.py:607 msgid "Gear" msgstr "" -#: documents/models.py:597 +#: documents/models.py:608 msgid "Globe" msgstr "" -#: documents/models.py:598 +#: documents/models.py:609 msgid "Hash" msgstr "" -#: documents/models.py:599 +#: documents/models.py:610 msgid "Heart" msgstr "" -#: documents/models.py:600 +#: documents/models.py:611 msgid "House" msgstr "" -#: documents/models.py:601 +#: documents/models.py:612 msgid "Inbox" msgstr "" -#: documents/models.py:602 +#: documents/models.py:613 msgid "Journals" msgstr "" -#: documents/models.py:603 +#: documents/models.py:614 msgid "Task list" msgstr "" -#: documents/models.py:604 +#: documents/models.py:615 msgid "Newspaper" msgstr "" -#: documents/models.py:605 +#: documents/models.py:616 msgid "Attachment" msgstr "" -#: documents/models.py:606 +#: documents/models.py:617 msgid "People" msgstr "" -#: documents/models.py:607 +#: documents/models.py:618 msgid "Person" msgstr "" -#: documents/models.py:608 +#: documents/models.py:619 msgid "Printer" msgstr "" -#: documents/models.py:609 +#: documents/models.py:620 msgid "Receipt" msgstr "" -#: documents/models.py:610 +#: documents/models.py:621 msgid "Safe" msgstr "" -#: documents/models.py:611 +#: documents/models.py:622 msgid "Search" msgstr "" -#: documents/models.py:612 +#: documents/models.py:623 msgid "Send" msgstr "" -#: documents/models.py:613 +#: documents/models.py:624 msgid "Shop" msgstr "" -#: documents/models.py:614 +#: documents/models.py:625 msgid "Stack" msgstr "" -#: documents/models.py:615 +#: documents/models.py:626 msgid "Stars" msgstr "" -#: documents/models.py:616 +#: documents/models.py:627 msgid "Tag" msgstr "" -#: documents/models.py:617 documents/models.py:632 documents/models.py:1804 +#: documents/models.py:628 documents/models.py:643 documents/models.py:1815 msgid "Tags" msgstr "" -#: documents/models.py:618 +#: documents/models.py:629 msgid "Telephone" msgstr "" -#: documents/models.py:619 +#: documents/models.py:630 msgid "Truck" msgstr "" -#: documents/models.py:620 +#: documents/models.py:631 msgid "Barcode" msgstr "" -#: documents/models.py:621 +#: documents/models.py:632 msgid "Wallet" msgstr "" -#: documents/models.py:624 +#: documents/models.py:635 msgid "Table" msgstr "" -#: documents/models.py:625 +#: documents/models.py:636 msgid "Small Cards" msgstr "" -#: documents/models.py:626 +#: documents/models.py:637 msgid "Large Cards" msgstr "" -#: documents/models.py:629 documents/models.py:1803 +#: documents/models.py:640 documents/models.py:1814 msgid "Title" msgstr "" -#: documents/models.py:630 documents/models.py:860 documents/models.py:1478 +#: documents/models.py:641 documents/models.py:871 documents/models.py:1489 msgid "Created" msgstr "" -#: documents/models.py:631 documents/models.py:1477 +#: documents/models.py:642 documents/models.py:1488 msgid "Added" msgstr "" -#: documents/models.py:633 documents/models.py:1805 +#: documents/models.py:644 documents/models.py:1816 msgid "Correspondent" msgstr "" -#: documents/models.py:634 +#: documents/models.py:645 msgid "Document Type" msgstr "" -#: documents/models.py:635 +#: documents/models.py:646 msgid "Storage Path" msgstr "" -#: documents/models.py:636 +#: documents/models.py:647 msgid "Note" msgstr "" -#: documents/models.py:637 +#: documents/models.py:648 msgid "Owner" msgstr "" -#: documents/models.py:638 +#: documents/models.py:649 msgid "Shared" msgstr "" -#: documents/models.py:639 +#: documents/models.py:650 msgid "ASN" msgstr "" -#: documents/models.py:640 +#: documents/models.py:651 msgid "Pages" msgstr "" -#: documents/models.py:646 +#: documents/models.py:657 msgid "icon" msgstr "" -#: documents/models.py:653 +#: documents/models.py:664 msgid "sort field" msgstr "" -#: documents/models.py:658 +#: documents/models.py:669 msgid "sort reverse" msgstr "" -#: documents/models.py:661 +#: documents/models.py:672 msgid "View page size" msgstr "" -#: documents/models.py:669 +#: documents/models.py:680 msgid "View display mode" msgstr "" -#: documents/models.py:676 +#: documents/models.py:687 msgid "Document display fields" msgstr "" -#: documents/models.py:683 documents/models.py:749 +#: documents/models.py:694 documents/models.py:760 msgid "saved view" msgstr "" -#: documents/models.py:684 +#: documents/models.py:695 msgid "saved views" msgstr "" -#: documents/models.py:692 +#: documents/models.py:703 msgid "title contains" msgstr "" -#: documents/models.py:693 +#: documents/models.py:704 msgid "content contains" msgstr "" -#: documents/models.py:694 +#: documents/models.py:705 msgid "ASN is" msgstr "" -#: documents/models.py:695 +#: documents/models.py:706 msgid "correspondent is" msgstr "" -#: documents/models.py:696 +#: documents/models.py:707 msgid "document type is" msgstr "" -#: documents/models.py:697 +#: documents/models.py:708 msgid "is in inbox" msgstr "" -#: documents/models.py:698 +#: documents/models.py:709 msgid "has tag" msgstr "" -#: documents/models.py:699 +#: documents/models.py:710 msgid "has any tag" msgstr "" -#: documents/models.py:700 +#: documents/models.py:711 msgid "created before" msgstr "" -#: documents/models.py:701 +#: documents/models.py:712 msgid "created after" msgstr "" -#: documents/models.py:702 +#: documents/models.py:713 msgid "created year is" msgstr "" -#: documents/models.py:703 +#: documents/models.py:714 msgid "created month is" msgstr "" -#: documents/models.py:704 +#: documents/models.py:715 msgid "created day is" msgstr "" -#: documents/models.py:705 +#: documents/models.py:716 msgid "added before" msgstr "" -#: documents/models.py:706 +#: documents/models.py:717 msgid "added after" msgstr "" -#: documents/models.py:707 +#: documents/models.py:718 msgid "modified before" msgstr "" -#: documents/models.py:708 +#: documents/models.py:719 msgid "modified after" msgstr "" -#: documents/models.py:709 +#: documents/models.py:720 msgid "does not have tag" msgstr "" -#: documents/models.py:710 +#: documents/models.py:721 msgid "does not have ASN" msgstr "" -#: documents/models.py:711 +#: documents/models.py:722 msgid "title or content contains" msgstr "" -#: documents/models.py:712 +#: documents/models.py:723 msgid "fulltext query" msgstr "" -#: documents/models.py:713 +#: documents/models.py:724 msgid "more like this" msgstr "" -#: documents/models.py:714 +#: documents/models.py:725 msgid "has tags in" msgstr "" -#: documents/models.py:715 +#: documents/models.py:726 msgid "ASN greater than" msgstr "" -#: documents/models.py:716 +#: documents/models.py:727 msgid "ASN less than" msgstr "" -#: documents/models.py:717 +#: documents/models.py:728 msgid "storage path is" msgstr "" -#: documents/models.py:718 +#: documents/models.py:729 msgid "has correspondent in" msgstr "" -#: documents/models.py:719 +#: documents/models.py:730 msgid "does not have correspondent in" msgstr "" -#: documents/models.py:720 +#: documents/models.py:731 msgid "has document type in" msgstr "" -#: documents/models.py:721 +#: documents/models.py:732 msgid "does not have document type in" msgstr "" -#: documents/models.py:722 +#: documents/models.py:733 msgid "has storage path in" msgstr "" -#: documents/models.py:723 +#: documents/models.py:734 msgid "does not have storage path in" msgstr "" -#: documents/models.py:724 +#: documents/models.py:735 msgid "owner is" msgstr "" -#: documents/models.py:725 +#: documents/models.py:736 msgid "has owner in" msgstr "" -#: documents/models.py:726 +#: documents/models.py:737 msgid "does not have owner" msgstr "" -#: documents/models.py:727 +#: documents/models.py:738 msgid "does not have owner in" msgstr "" -#: documents/models.py:728 +#: documents/models.py:739 msgid "has custom field value" msgstr "" -#: documents/models.py:729 +#: documents/models.py:740 msgid "is shared by me" msgstr "" -#: documents/models.py:730 +#: documents/models.py:741 msgid "has custom fields" msgstr "" -#: documents/models.py:731 +#: documents/models.py:742 msgid "has custom field in" msgstr "" -#: documents/models.py:732 +#: documents/models.py:743 msgid "does not have custom field in" msgstr "" -#: documents/models.py:733 +#: documents/models.py:744 msgid "does not have custom field" msgstr "" -#: documents/models.py:734 +#: documents/models.py:745 msgid "custom fields query" msgstr "" -#: documents/models.py:735 +#: documents/models.py:746 msgid "created to" msgstr "" -#: documents/models.py:736 +#: documents/models.py:747 msgid "created from" msgstr "" -#: documents/models.py:737 +#: documents/models.py:748 msgid "added to" msgstr "" -#: documents/models.py:738 +#: documents/models.py:749 msgid "added from" msgstr "" -#: documents/models.py:739 +#: documents/models.py:750 msgid "mime type is" msgstr "" -#: documents/models.py:740 +#: documents/models.py:751 msgid "simple title search" msgstr "" -#: documents/models.py:741 +#: documents/models.py:752 msgid "simple text search" msgstr "" -#: documents/models.py:742 +#: documents/models.py:753 msgid "has duplicates" msgstr "" -#: documents/models.py:752 +#: documents/models.py:763 msgid "rule type" msgstr "" -#: documents/models.py:754 documents/models.py:1000 +#: documents/models.py:765 documents/models.py:1011 msgid "value" msgstr "" -#: documents/models.py:757 +#: documents/models.py:768 msgid "filter rule" msgstr "" -#: documents/models.py:758 +#: documents/models.py:769 msgid "filter rules" msgstr "" -#: documents/models.py:788 documents/models.py:1125 +#: documents/models.py:799 documents/models.py:1136 msgid "Pending" msgstr "" -#: documents/models.py:789 documents/models.py:867 +#: documents/models.py:800 documents/models.py:878 msgid "Started" msgstr "" -#: documents/models.py:790 +#: documents/models.py:801 msgid "Success" msgstr "" -#: documents/models.py:791 +#: documents/models.py:802 msgid "Failure" msgstr "" -#: documents/models.py:792 +#: documents/models.py:803 msgid "Revoked" msgstr "" -#: documents/models.py:795 +#: documents/models.py:806 msgid "Consume File" msgstr "" -#: documents/models.py:796 +#: documents/models.py:807 msgid "Train Classifier" msgstr "" -#: documents/models.py:797 +#: documents/models.py:808 msgid "Sanity Check" msgstr "" -#: documents/models.py:798 +#: documents/models.py:809 msgid "Index Optimize" msgstr "" -#: documents/models.py:799 documents/models.py:1473 +#: documents/models.py:810 documents/models.py:1484 msgid "Mail Fetch" msgstr "" -#: documents/models.py:800 +#: documents/models.py:811 msgid "LLM Index" msgstr "" -#: documents/models.py:801 +#: documents/models.py:812 msgid "Empty Trash" msgstr "" -#: documents/models.py:802 +#: documents/models.py:813 msgid "Check Workflows" msgstr "" -#: documents/models.py:803 +#: documents/models.py:814 msgid "Bulk Update" msgstr "" -#: documents/models.py:804 +#: documents/models.py:815 msgid "Reprocess Document" msgstr "" -#: documents/models.py:805 +#: documents/models.py:816 msgid "Build Share Link" msgstr "" -#: documents/models.py:806 +#: documents/models.py:817 msgid "Bulk Delete" msgstr "" -#: documents/models.py:807 +#: documents/models.py:818 msgid "Apply AI Suggestions" msgstr "" -#: documents/models.py:816 documents/models.py:1468 +#: documents/models.py:827 documents/models.py:1479 msgid "Scheduled" msgstr "" -#: documents/models.py:817 documents/models.py:1474 +#: documents/models.py:828 documents/models.py:1485 msgid "Web UI" msgstr "" -#: documents/models.py:818 +#: documents/models.py:829 msgid "API Upload" msgstr "" -#: documents/models.py:819 +#: documents/models.py:830 msgid "Folder Consume" msgstr "" -#: documents/models.py:820 +#: documents/models.py:831 msgid "Email Consume" msgstr "" -#: documents/models.py:821 +#: documents/models.py:832 msgid "System" msgstr "" -#: documents/models.py:822 +#: documents/models.py:833 msgid "Manual" msgstr "" -#: documents/models.py:828 +#: documents/models.py:839 msgid "Task ID" msgstr "" -#: documents/models.py:829 +#: documents/models.py:840 msgid "Celery task ID" msgstr "" -#: documents/models.py:835 +#: documents/models.py:846 msgid "Task Type" msgstr "" -#: documents/models.py:836 +#: documents/models.py:847 msgid "The kind of work being performed" msgstr "" -#: documents/models.py:843 +#: documents/models.py:854 msgid "Trigger Source" msgstr "" -#: documents/models.py:844 +#: documents/models.py:855 msgid "What initiated this task" msgstr "" -#: documents/models.py:853 +#: documents/models.py:864 msgid "Status" msgstr "" -#: documents/models.py:873 +#: documents/models.py:884 msgid "Completed" msgstr "" -#: documents/models.py:881 +#: documents/models.py:892 msgid "Duration (seconds)" msgstr "" -#: documents/models.py:882 +#: documents/models.py:893 msgid "Elapsed time from start to completion" msgstr "" -#: documents/models.py:888 +#: documents/models.py:899 msgid "Wait Time (seconds)" msgstr "" -#: documents/models.py:889 +#: documents/models.py:900 msgid "Time from task creation to worker pickup" msgstr "" -#: documents/models.py:896 +#: documents/models.py:907 msgid "Input Data" msgstr "" -#: documents/models.py:897 +#: documents/models.py:908 msgid "Structured input parameters for the task" msgstr "" -#: documents/models.py:903 +#: documents/models.py:914 msgid "Result Data" msgstr "" -#: documents/models.py:904 +#: documents/models.py:915 msgid "Structured result data from task execution" msgstr "" -#: documents/models.py:910 +#: documents/models.py:921 msgid "Acknowledged" msgstr "" -#: documents/models.py:915 +#: documents/models.py:926 msgid "Task" msgstr "" -#: documents/models.py:916 +#: documents/models.py:927 msgid "Tasks" msgstr "" -#: documents/models.py:946 +#: documents/models.py:957 msgid "Note for the document" msgstr "" -#: documents/models.py:970 +#: documents/models.py:981 msgid "user" msgstr "" -#: documents/models.py:975 +#: documents/models.py:986 msgid "note" msgstr "" -#: documents/models.py:976 +#: documents/models.py:987 msgid "notes" msgstr "" -#: documents/models.py:996 +#: documents/models.py:1007 msgid "page" msgstr "" -#: documents/models.py:997 +#: documents/models.py:1008 msgid "Page of the original file, starting at 1" msgstr "" -#: documents/models.py:1052 +#: documents/models.py:1063 msgid "format" msgstr "" -#: documents/models.py:1056 +#: documents/models.py:1067 msgid "document barcode" msgstr "" -#: documents/models.py:1057 +#: documents/models.py:1068 msgid "document barcodes" msgstr "" -#: documents/models.py:1066 +#: documents/models.py:1077 msgid "Original" msgstr "" -#: documents/models.py:1077 documents/models.py:1139 +#: documents/models.py:1088 documents/models.py:1150 #: paperless_mail/models.py:75 msgid "expiration" msgstr "" -#: documents/models.py:1084 documents/models.py:1146 +#: documents/models.py:1095 documents/models.py:1157 msgid "slug" msgstr "" -#: documents/models.py:1116 +#: documents/models.py:1127 msgid "share link" msgstr "" -#: documents/models.py:1117 +#: documents/models.py:1128 msgid "share links" msgstr "" -#: documents/models.py:1126 +#: documents/models.py:1137 msgid "Processing" msgstr "" -#: documents/models.py:1127 +#: documents/models.py:1138 msgid "Ready" msgstr "" -#: documents/models.py:1128 +#: documents/models.py:1139 msgid "Failed" msgstr "" -#: documents/models.py:1175 +#: documents/models.py:1186 msgid "size (bytes)" msgstr "" -#: documents/models.py:1181 +#: documents/models.py:1192 msgid "last error" msgstr "" -#: documents/models.py:1188 +#: documents/models.py:1199 msgid "file path" msgstr "" -#: documents/models.py:1194 +#: documents/models.py:1205 msgid "built at" msgstr "" -#: documents/models.py:1207 +#: documents/models.py:1218 msgid "share link bundle" msgstr "" -#: documents/models.py:1208 +#: documents/models.py:1219 msgid "share link bundles" msgstr "" -#: documents/models.py:1211 +#: documents/models.py:1222 #, python-format msgid "Share link bundle %(slug)s" msgstr "" -#: documents/models.py:1247 +#: documents/models.py:1258 msgid "String" msgstr "" -#: documents/models.py:1248 +#: documents/models.py:1259 msgid "URL" msgstr "" -#: documents/models.py:1249 +#: documents/models.py:1260 msgid "Date" msgstr "" -#: documents/models.py:1250 +#: documents/models.py:1261 msgid "Boolean" msgstr "" -#: documents/models.py:1251 +#: documents/models.py:1262 msgid "Integer" msgstr "" -#: documents/models.py:1252 +#: documents/models.py:1263 msgid "Float" msgstr "" -#: documents/models.py:1253 +#: documents/models.py:1264 msgid "Monetary" msgstr "" -#: documents/models.py:1254 +#: documents/models.py:1265 msgid "Document Link" msgstr "" -#: documents/models.py:1255 +#: documents/models.py:1266 msgid "Select" msgstr "" -#: documents/models.py:1256 +#: documents/models.py:1267 msgid "Long Text" msgstr "" -#: documents/models.py:1268 +#: documents/models.py:1279 msgid "data type" msgstr "" -#: documents/models.py:1275 +#: documents/models.py:1286 msgid "extra data" msgstr "" -#: documents/models.py:1279 +#: documents/models.py:1290 msgid "Extra data for the custom field, such as select options" msgstr "" -#: documents/models.py:1285 +#: documents/models.py:1296 msgid "custom field" msgstr "" -#: documents/models.py:1286 +#: documents/models.py:1297 msgid "custom fields" msgstr "" -#: documents/models.py:1386 +#: documents/models.py:1397 msgid "custom field instance" msgstr "" -#: documents/models.py:1387 +#: documents/models.py:1398 msgid "custom field instances" msgstr "" -#: documents/models.py:1465 +#: documents/models.py:1476 msgid "Consumption Started" msgstr "" -#: documents/models.py:1466 +#: documents/models.py:1477 msgid "Document Added" msgstr "" -#: documents/models.py:1467 +#: documents/models.py:1478 msgid "Document Updated" msgstr "" -#: documents/models.py:1471 +#: documents/models.py:1482 msgid "Consume Folder" msgstr "" -#: documents/models.py:1472 +#: documents/models.py:1483 msgid "Api Upload" msgstr "" -#: documents/models.py:1479 +#: documents/models.py:1490 msgid "Modified" msgstr "" -#: documents/models.py:1480 +#: documents/models.py:1491 msgid "Custom Field" msgstr "" -#: documents/models.py:1483 +#: documents/models.py:1494 msgid "Workflow Trigger Type" msgstr "" -#: documents/models.py:1495 +#: documents/models.py:1506 msgid "filter path" msgstr "" -#: documents/models.py:1500 +#: documents/models.py:1511 msgid "" "Only consume documents with a path that matches this if specified. Wildcards " "specified as * are allowed. Case insensitive." msgstr "" -#: documents/models.py:1507 +#: documents/models.py:1518 msgid "filter filename" msgstr "" -#: documents/models.py:1512 paperless_mail/models.py:200 +#: documents/models.py:1523 paperless_mail/models.py:200 msgid "" "Only consume documents which entirely match this filename if specified. " "Wildcards such as *.pdf or *invoice* are allowed. Case insensitive." msgstr "" -#: documents/models.py:1523 +#: documents/models.py:1534 msgid "filter documents from this mail rule" msgstr "" -#: documents/models.py:1539 +#: documents/models.py:1550 msgid "has these tag(s)" msgstr "" -#: documents/models.py:1546 +#: documents/models.py:1557 msgid "has all of these tag(s)" msgstr "" -#: documents/models.py:1553 +#: documents/models.py:1564 msgid "does not have these tag(s)" msgstr "" -#: documents/models.py:1561 +#: documents/models.py:1572 msgid "has this document type" msgstr "" -#: documents/models.py:1568 +#: documents/models.py:1579 msgid "has one of these document types" msgstr "" -#: documents/models.py:1575 +#: documents/models.py:1586 msgid "does not have these document type(s)" msgstr "" -#: documents/models.py:1583 +#: documents/models.py:1594 msgid "has this correspondent" msgstr "" -#: documents/models.py:1590 +#: documents/models.py:1601 msgid "does not have these correspondent(s)" msgstr "" -#: documents/models.py:1597 +#: documents/models.py:1608 msgid "has one of these correspondents" msgstr "" -#: documents/models.py:1605 +#: documents/models.py:1616 msgid "has this storage path" msgstr "" -#: documents/models.py:1612 +#: documents/models.py:1623 msgid "has one of these storage paths" msgstr "" -#: documents/models.py:1619 +#: documents/models.py:1630 msgid "does not have these storage path(s)" msgstr "" -#: documents/models.py:1623 +#: documents/models.py:1634 msgid "filter custom field query" msgstr "" -#: documents/models.py:1626 +#: documents/models.py:1637 msgid "JSON-encoded custom field query expression." msgstr "" -#: documents/models.py:1630 +#: documents/models.py:1641 msgid "schedule offset days" msgstr "" -#: documents/models.py:1633 +#: documents/models.py:1644 msgid "The number of days to offset the schedule trigger by." msgstr "" -#: documents/models.py:1638 +#: documents/models.py:1649 msgid "schedule is recurring" msgstr "" -#: documents/models.py:1641 +#: documents/models.py:1652 msgid "If the schedule should be recurring." msgstr "" -#: documents/models.py:1646 +#: documents/models.py:1657 msgid "schedule recurring delay in days" msgstr "" -#: documents/models.py:1650 +#: documents/models.py:1661 msgid "The number of days between recurring schedule triggers." msgstr "" -#: documents/models.py:1655 +#: documents/models.py:1666 msgid "schedule date field" msgstr "" -#: documents/models.py:1660 +#: documents/models.py:1671 msgid "The field to check for a schedule trigger." msgstr "" -#: documents/models.py:1669 +#: documents/models.py:1680 msgid "schedule date custom field" msgstr "" -#: documents/models.py:1673 +#: documents/models.py:1684 msgid "workflow trigger" msgstr "" -#: documents/models.py:1674 +#: documents/models.py:1685 msgid "workflow triggers" msgstr "" -#: documents/models.py:1682 +#: documents/models.py:1693 msgid "email subject" msgstr "" -#: documents/models.py:1686 +#: documents/models.py:1697 msgid "" "The subject of the email, can include some placeholders, see documentation." msgstr "" -#: documents/models.py:1692 +#: documents/models.py:1703 msgid "email body" msgstr "" -#: documents/models.py:1695 +#: documents/models.py:1706 msgid "" "The body (message) of the email, can include some placeholders, see " "documentation." msgstr "" -#: documents/models.py:1701 +#: documents/models.py:1712 msgid "emails to" msgstr "" -#: documents/models.py:1704 +#: documents/models.py:1715 msgid "The destination email addresses, comma separated." msgstr "" -#: documents/models.py:1710 +#: documents/models.py:1721 msgid "include document in email" msgstr "" -#: documents/models.py:1721 +#: documents/models.py:1732 msgid "webhook url" msgstr "" -#: documents/models.py:1724 +#: documents/models.py:1735 msgid "The destination URL for the notification." msgstr "" -#: documents/models.py:1729 +#: documents/models.py:1740 msgid "use parameters" msgstr "" -#: documents/models.py:1734 +#: documents/models.py:1745 msgid "send as JSON" msgstr "" -#: documents/models.py:1738 +#: documents/models.py:1749 msgid "webhook parameters" msgstr "" -#: documents/models.py:1741 +#: documents/models.py:1752 msgid "The parameters to send with the webhook URL if body not used." msgstr "" -#: documents/models.py:1745 +#: documents/models.py:1756 msgid "webhook body" msgstr "" -#: documents/models.py:1748 +#: documents/models.py:1759 msgid "The body to send with the webhook URL if parameters not used." msgstr "" -#: documents/models.py:1752 +#: documents/models.py:1763 msgid "webhook headers" msgstr "" -#: documents/models.py:1755 +#: documents/models.py:1766 msgid "The headers to send with the webhook URL." msgstr "" -#: documents/models.py:1760 +#: documents/models.py:1771 msgid "include document in webhook" msgstr "" -#: documents/models.py:1771 +#: documents/models.py:1782 msgid "Assignment" msgstr "" -#: documents/models.py:1775 +#: documents/models.py:1786 msgid "Removal" msgstr "" -#: documents/models.py:1779 documents/templates/account/password_reset.html:15 +#: documents/models.py:1790 documents/templates/account/password_reset.html:15 msgid "Email" msgstr "" -#: documents/models.py:1783 +#: documents/models.py:1794 msgid "Webhook" msgstr "" -#: documents/models.py:1787 +#: documents/models.py:1798 msgid "Password removal" msgstr "" -#: documents/models.py:1791 +#: documents/models.py:1802 msgid "Move to trash" msgstr "" -#: documents/models.py:1795 +#: documents/models.py:1806 msgid "Remote OCR" msgstr "" -#: documents/models.py:1799 +#: documents/models.py:1810 msgid "Apply AI suggestions" msgstr "" -#: documents/models.py:1806 +#: documents/models.py:1817 msgid "Document type" msgstr "" -#: documents/models.py:1807 +#: documents/models.py:1818 msgid "Storage path" msgstr "" -#: documents/models.py:1808 +#: documents/models.py:1819 msgid "Created date" msgstr "" -#: documents/models.py:1811 +#: documents/models.py:1822 msgid "Workflow Action Type" msgstr "" -#: documents/models.py:1816 documents/models.py:2085 +#: documents/models.py:1827 documents/models.py:2096 #: paperless_mail/models.py:145 msgid "order" msgstr "" -#: documents/models.py:1819 +#: documents/models.py:1830 msgid "assign title" msgstr "" -#: documents/models.py:1823 +#: documents/models.py:1834 msgid "Assign a document title, must be a Jinja2 template, see documentation." msgstr "" -#: documents/models.py:1831 paperless_mail/models.py:274 +#: documents/models.py:1842 paperless_mail/models.py:274 msgid "assign this tag" msgstr "" -#: documents/models.py:1840 paperless_mail/models.py:282 +#: documents/models.py:1851 paperless_mail/models.py:282 msgid "assign this document type" msgstr "" -#: documents/models.py:1849 paperless_mail/models.py:296 +#: documents/models.py:1860 paperless_mail/models.py:296 msgid "assign this correspondent" msgstr "" -#: documents/models.py:1858 +#: documents/models.py:1869 msgid "assign this storage path" msgstr "" -#: documents/models.py:1867 +#: documents/models.py:1878 msgid "assign this owner" msgstr "" -#: documents/models.py:1874 +#: documents/models.py:1885 msgid "grant view permissions to these users" msgstr "" -#: documents/models.py:1881 +#: documents/models.py:1892 msgid "grant view permissions to these groups" msgstr "" -#: documents/models.py:1888 +#: documents/models.py:1899 msgid "grant change permissions to these users" msgstr "" -#: documents/models.py:1895 +#: documents/models.py:1906 msgid "grant change permissions to these groups" msgstr "" -#: documents/models.py:1902 +#: documents/models.py:1913 msgid "assign these custom fields" msgstr "" -#: documents/models.py:1906 +#: documents/models.py:1917 msgid "custom field values" msgstr "" -#: documents/models.py:1910 +#: documents/models.py:1921 msgid "Optional values to assign to the custom fields." msgstr "" -#: documents/models.py:1919 +#: documents/models.py:1930 msgid "remove these tag(s)" msgstr "" -#: documents/models.py:1924 +#: documents/models.py:1935 msgid "remove all tags" msgstr "" -#: documents/models.py:1931 +#: documents/models.py:1942 msgid "remove these document type(s)" msgstr "" -#: documents/models.py:1936 +#: documents/models.py:1947 msgid "remove all document types" msgstr "" -#: documents/models.py:1943 +#: documents/models.py:1954 msgid "remove these correspondent(s)" msgstr "" -#: documents/models.py:1948 +#: documents/models.py:1959 msgid "remove all correspondents" msgstr "" -#: documents/models.py:1955 +#: documents/models.py:1966 msgid "remove these storage path(s)" msgstr "" -#: documents/models.py:1960 +#: documents/models.py:1971 msgid "remove all storage paths" msgstr "" -#: documents/models.py:1967 +#: documents/models.py:1978 msgid "remove these owner(s)" msgstr "" -#: documents/models.py:1972 +#: documents/models.py:1983 msgid "remove all owners" msgstr "" -#: documents/models.py:1979 +#: documents/models.py:1990 msgid "remove view permissions for these users" msgstr "" -#: documents/models.py:1986 +#: documents/models.py:1997 msgid "remove view permissions for these groups" msgstr "" -#: documents/models.py:1993 +#: documents/models.py:2004 msgid "remove change permissions for these users" msgstr "" -#: documents/models.py:2000 +#: documents/models.py:2011 msgid "remove change permissions for these groups" msgstr "" -#: documents/models.py:2005 +#: documents/models.py:2016 msgid "remove all permissions" msgstr "" -#: documents/models.py:2012 +#: documents/models.py:2023 msgid "remove these custom fields" msgstr "" -#: documents/models.py:2017 +#: documents/models.py:2028 msgid "remove all custom fields" msgstr "" -#: documents/models.py:2026 +#: documents/models.py:2037 msgid "email" msgstr "" -#: documents/models.py:2035 +#: documents/models.py:2046 msgid "webhook" msgstr "" -#: documents/models.py:2039 +#: documents/models.py:2050 msgid "passwords" msgstr "" -#: documents/models.py:2043 +#: documents/models.py:2054 msgid "" "Passwords to try when removing PDF protection. Separate with commas or new " "lines." msgstr "" -#: documents/models.py:2048 +#: documents/models.py:2059 msgid "AI suggestion fields" msgstr "" -#: documents/models.py:2052 +#: documents/models.py:2063 msgid "Which of the AI-suggested fields to apply to the document." msgstr "" -#: documents/models.py:2057 +#: documents/models.py:2068 msgid "create missing objects" msgstr "" -#: documents/models.py:2060 +#: documents/models.py:2071 msgid "" "Create suggested tags, correspondents, document types and storage paths that " "do not already exist instead of skipping them." msgstr "" -#: documents/models.py:2066 +#: documents/models.py:2077 msgid "overwrite existing values" msgstr "" -#: documents/models.py:2069 +#: documents/models.py:2080 msgid "" "Apply suggestions even if the document already has a value for that field. " "Tags are always added to, never replaced." msgstr "" -#: documents/models.py:2075 +#: documents/models.py:2086 msgid "workflow action" msgstr "" -#: documents/models.py:2076 +#: documents/models.py:2087 msgid "workflow actions" msgstr "" -#: documents/models.py:2091 +#: documents/models.py:2102 msgid "triggers" msgstr "" -#: documents/models.py:2098 +#: documents/models.py:2109 msgid "actions" msgstr "" -#: documents/models.py:2101 paperless_mail/models.py:154 +#: documents/models.py:2112 paperless_mail/models.py:154 msgid "enabled" msgstr "" -#: documents/models.py:2112 +#: documents/models.py:2123 msgid "workflow" msgstr "" -#: documents/models.py:2116 +#: documents/models.py:2127 msgid "workflow trigger type" msgstr "" -#: documents/models.py:2130 +#: documents/models.py:2141 msgid "date run" msgstr "" -#: documents/models.py:2136 +#: documents/models.py:2147 msgid "workflow run" msgstr "" -#: documents/models.py:2137 +#: documents/models.py:2148 msgid "workflow runs" msgstr "" From 0cb2cdbfdd91a2078236f8851cd310261284591c Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:47:58 -0700 Subject: [PATCH 03/18] Fix: handle single-class classifier in threshold prediction (#14471) (#14473) scikit-learn treats a single-class fit as binary, so predict_proba returns [1 - p, p] for one class and argmax could index past classes_. Use only column 0, the real class, so the confidence threshold still applies. --- src/documents/classifier.py | 4 +++ src/documents/tests/test_classifier.py | 44 ++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/src/documents/classifier.py b/src/documents/classifier.py index e98b8b574..9c5ecaa62 100644 --- a/src/documents/classifier.py +++ b/src/documents/classifier.py @@ -54,6 +54,10 @@ def _predict_with_threshold(classifier, X, threshold: float) -> int | None: cutoff so that uncertain predictions are discarded rather than assigned. """ probas = classifier.predict_proba(X)[0] + if len(classifier.classes_) == 1: + # scikit-learn treats a single-class fit as binary, so predict_proba + # returns [1 - p, p] for one class. Column 0 is the real class. + probas = probas[:1] best_idx = int(probas.argmax()) best_class = int(classifier.classes_[best_idx]) diff --git a/src/documents/tests/test_classifier.py b/src/documents/tests/test_classifier.py index ae8921ad6..c8b832a6a 100644 --- a/src/documents/tests/test_classifier.py +++ b/src/documents/tests/test_classifier.py @@ -14,6 +14,7 @@ from django.test import override_settings from django.test.utils import CaptureQueriesContext from pytest_django.fixtures import Settings from pytest_mock import MockerFixture +from sklearn.neural_network import MLPClassifier from documents.classifier import ClassifierModelCorruptError from documents.classifier import DocumentClassifier @@ -1036,6 +1037,49 @@ def test_classifier_match_threshold_default() -> None: assert settings.CLASSIFIER_MATCH_THRESHOLD == 0.3 +class TestPredictWithThresholdSingleClass: + @pytest.fixture + def single_class_model(self) -> tuple[MLPClassifier, np.ndarray]: + X = np.random.default_rng(0).random((2, 5)) + return MLPClassifier(tol=0.01, random_state=0).fit(X, [3, 3]), X[:1] + + def test_returns_only_class( + self, + single_class_model: tuple[MLPClassifier, np.ndarray], + ) -> None: + """ + GIVEN: + - A classifier fitted on a single class (every document shares one label) + WHEN: + - A prediction is requested with the threshold disabled + THEN: + - No error is raised and the only class is returned + """ + classifier, X = single_class_model + + assert _predict_with_threshold(classifier, X, threshold=0.0) == 3 + + def test_applies_threshold( + self, + single_class_model: tuple[MLPClassifier, np.ndarray], + ) -> None: + """ + GIVEN: + - A classifier fitted on a single class + WHEN: + - A prediction is requested + THEN: + - The class's confidence is still compared against the threshold + """ + classifier, X = single_class_model + confidence = classifier.predict_proba(X)[0][0] + + assert _predict_with_threshold(classifier, X, threshold=confidence - 0.01) == 3 + assert ( + _predict_with_threshold(classifier, X, threshold=confidence + 0.01) is None + ) + + class TestPreprocessContent: @pytest.fixture def samples(self) -> Path: From de94657e170c72ad01b5fba7b9b7c93b7037dba7 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:29:29 -0700 Subject: [PATCH 04/18] Fix: dont tie suggestions button to ai enabled any more (#14478) --- .../suggestions-dropdown.component.html | 68 +++++++++---------- .../suggestions-dropdown.component.spec.ts | 31 +++++++++ .../suggestions-dropdown.component.ts | 6 +- 3 files changed, 65 insertions(+), 40 deletions(-) diff --git a/src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html b/src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html index aca9fd72e..87411b8c5 100644 --- a/src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html +++ b/src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html @@ -1,6 +1,6 @@