Compare commits

..
Author SHA1 Message Date
stumpylog 8391b4c524 perf: batch body fetches when many new UIDs are pending 2026-07-21 10:32:05 -07:00
stumpylog c53a336ccd fix: diff UIDs against ProcessedMail before fetching message bodies 2026-07-21 10:01:14 -07:00
stumpylogandClaude Sonnet 5 ed37f667e4 fix: record ProcessedMail for attachment-less mail under attachments-only rules
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 09:52:01 -07:00
stumpylogandClaude Sonnet 5 9ede2fbd06 test: add BogusMailBox.uids() and UID criteria support
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 09:35:22 -07:00
Trenton Holmes a22b9fdbe4 Store ideas for making mail fetch more efficient 2026-07-20 19:31:36 -07:00
d8d8872414 Fix (beta): compute num_notes via a subquery instead of Count()+distinct() (#13182)
DocumentViewSet.get_queryset() annotated num_notes via a LEFT JOIN to
documents_note plus Count(), which requires the database to aggregate
every matching document's note count before it can even sort or paginate
the result -- on every list request, not just filtered ones.

Switched to the same correlated-subquery pattern already used two lines
above for effective_content (Subquery + OuterRef), which Django compiles
to portable SQL across sqlite/postgresql/mariadb rather than a join-based
aggregate.

Benchmarked against a 100k synthetic document corpus (Postgres): the
plain document list request dropped from ~2.2-2.4s to ~0.9-1.0s. Note
that a separate, larger cost remains in the same queryset's plain
.distinct() call, which still forces a full sort over every matching
row regardless of this fix -- tracked separately, not addressed here.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 13:59:44 -07:00
shamoonandGitHub ff609c2987 Change: update root modified timestamp on version changes (#13170) 2026-07-20 15:45:24 +00:00
Trenton HandGitHub 2b784e709b Fix: prevent tag assignment from reverting other pending workflow assignments (#13178)
apply_assignment_to_document() mutated tags directly on the shared document instance via add_nested_tags(), whose m2m_changed signal triggers update_filename_and_move_files() -> instance.refresh_from_db(), discarding any not-yet-saved fields (e.g. storage_path) staged by an earlier-ordered action in the same workflow. Apply tag changes to a freshly-fetched instance instead, matching the pattern already used in apply_removal_to_document().
2026-07-20 08:22:30 -07:00
Trenton HandGitHub c9716252f0 Performance: Add DB indexes for common query/sort patterns (#13167)
Adds indexes on Document.checksum, Document.page_count, and a composite
(owner, created) index on Document, plus composite (field, value_*)
indexes on CustomFieldInstance for each typed value column.

Benchmarked against a 100k-document / 200k-custom-field-instance SQLite
dataset: Document.checksum lookups ~69x faster, page_count sort ~40x,
owner+created list queries ~6x. CustomFieldInstance composite indexes
show large gains (10-24x) on realistic narrow-selectivity queries
matching the workflow scheduler and custom-field search code paths.
2026-07-20 13:12:54 +00:00
shamoon 5cf9152a40 Merge branch 'dev' into beta 2026-07-19 20:20:48 -07:00
4e52dd5710 Fix: cache per-request effective-document resolution for thumb/metadata/preview (#13166)
Django's condition() decorator invokes etag_func and last_modified_func
separately, and the view itself may resolve again -- each call to
resolve_effective_document_by_pk() was redoing the same root/version
DB lookups. Memoize the resolution on the request object so a single
thumb/metadata/preview request resolves the effective document once
instead of up to three times.

Related to paperless-ngx/paperless-ngx#13161.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 18:04:16 -07:00
109 changed files with 8148 additions and 7030 deletions
@@ -0,0 +1,662 @@
# Mail Fetch Re-download Fix Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Stop `paperless_mail` from re-downloading full IMAP message bodies for mail it has already handled, and make sure attachment-less mail under attachments-only rules is recorded so it stops matching the search forever.
**Architecture:** In `_handle_mail_rule`, replace the single "fetch every matching message's full body" call with a cheap `UID SEARCH` first, subtract UIDs already in `ProcessedMail`, and fetch bodies only for the remainder (batched). Separately, extract the existing "record `PROCESSED_WO_CONSUMPTION`" block into a shared helper and call it from the no-attachments-under-attachments-only-scope early return in `_handle_message`, which currently skips it entirely.
**Tech Stack:** Django (pytest-django), `imap_tools` (`MailBox.uids()`, `AND` query builder), existing `ProcessedMail` model — no new dependencies, no migrations.
## Global Constraints
- No new required settings and no DB migrations for existing installs (spec: "No new required settings/migrations for existing installs").
- Batch size for body fetches is a hardcoded module constant (`MAIL_FETCH_BATCH_SIZE = 500`), not user-configurable.
- No mailbox action (mark-read/flag/tag/move/delete) is applied for the no-attachment/no-consumption case — only a `ProcessedMail` row is written, matching the existing precedent for other no-consumption outcomes.
- Every path that currently produces a document or applies a mail action must behave exactly as before (no change to `_process_attachments`'/`_process_eml`'s consuming behavior, action application, or the existing per-message `already_processed` dedup check, which stays in place as a safety net).
- Follow the existing per-message `uid_validity` matching semantics exactly: when `self._current_uid_validity is not None`, only match `ProcessedMail` rows with the same `uid_validity` or `uid_validity IS NULL`; when it is `None` (server didn't report UIDVALIDITY), fall back to matching on `(rule, uid, folder)` alone, ignoring `uid_validity`. (This exact conditional already exists at `mail.py:722-726` for the per-message check — the new UID-diff query must replicate it or several existing uidvalidity tests will break.)
---
### Task 1: Add UID search support to the `BogusMailBox` test double
**Files:**
- Modify: `src/paperless_mail/tests/test_mail.py:137-171` (`BogusMailBox.fetch`)
- Test: `src/paperless_mail/tests/test_mail.py` (new test in `class TestMail`)
**Interfaces:**
- Produces: `BogusMailBox.uids(self, criteria, charset="") -> list[str]`, filtering with the same rules as `fetch()`, returning only UIDs (no message bodies). `BogusMailBox.fetch()` gains support for a `UID <comma-list>` criteria token (used by real code's `AND(uid=[...])` queries).
- Consumes: nothing new — this is test infrastructure only, no production code changes in this task.
- [ ] **Step 1: Write the failing test**
Add to `class TestMail` in `src/paperless_mail/tests/test_mail.py` (near the other `BogusMailBox`-adjacent tests, e.g. after `test_handle_message`):
```python
def test_bogus_mailbox_uids_and_uid_criteria(self) -> None:
mailbox = self.mailMocker.bogus_mailbox
all_messages = list(mailbox.messages)
# uids() returns the UIDs of unseen messages, no bodies needed to call it
unseen_uids = mailbox.uids("(UNSEEN)")
self.assertEqual(
set(unseen_uids),
{m.uid for m in all_messages if not m.seen},
)
# fetch() with an explicit UID criteria returns only the matching messages
target_uid = all_messages[0].uid
from imap_tools import AND
fetched = mailbox.fetch(AND(uid=[target_uid]), mark_seen=False)
self.assertEqual([m.uid for m in fetched], [target_uid])
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run pytest --override-ini="addopts=" src/paperless_mail/tests/test_mail.py::TestMail::test_bogus_mailbox_uids_and_uid_criteria -v`
Expected: FAIL with `AttributeError: 'BogusMailBox' object has no attribute 'uids'` (and/or the UID-criteria fetch returning all 3 default messages instead of 1, since `fetch()` doesn't understand a `UID` token yet).
- [ ] **Step 3: Implement `uids()` and `UID` criteria support**
Replace `src/paperless_mail/tests/test_mail.py:137-171`:
```python
def fetch(self, criteria, mark_seen, charset="", *, bulk=True):
return self._filter_messages(criteria)
def uids(self, criteria, charset="") -> list[str]:
return [m.uid for m in self._filter_messages(criteria)]
def _filter_messages(self, criteria):
msg = self.messages
criteria = str(criteria).strip("()").split(" ")
if "UNSEEN" in criteria:
msg = filter(lambda m: not m.seen, msg)
if "SUBJECT" in criteria:
subject = criteria[criteria.index("SUBJECT") + 1].strip('"')
msg = filter(lambda m: subject in m.subject, msg)
if "BODY" in criteria:
body = criteria[criteria.index("BODY") + 1].strip('"')
msg = filter(lambda m: body in m.text, msg)
if "FROM" in criteria:
from_ = criteria[criteria.index("FROM") + 1].strip('"')
msg = filter(lambda m: from_ in m.from_, msg)
if "TO" in criteria:
to_ = criteria[criteria.index("TO") + 1].strip('"')
msg = filter(lambda m: any(to_ in to_addr for to_addr in m.to), msg)
if "UNFLAGGED" in criteria:
msg = filter(lambda m: not m.flagged, msg)
if "UNKEYWORD" in criteria:
tag = criteria[criteria.index("UNKEYWORD") + 1].strip("'")
msg = filter(lambda m: tag not in m.flags, msg)
if "(X-GM-LABELS" in criteria: # ['NOT', '(X-GM-LABELS', '"processed"']
msg = filter(lambda m: "processed" not in m.flags, msg)
if "UID" in criteria:
uid_list = criteria[criteria.index("UID") + 1].split(",")
msg = filter(lambda m: m.uid in uid_list, msg)
return list(msg)
```
This is a pure refactor of the existing filtering logic into `_filter_messages`, reused by both `fetch()` (unchanged behavior) and the new `uids()`, plus one new `if "UID" in criteria` branch.
- [ ] **Step 4: Run test to verify it passes**
Run: `uv run pytest --override-ini="addopts=" src/paperless_mail/tests/test_mail.py::TestMail::test_bogus_mailbox_uids_and_uid_criteria -v`
Expected: PASS
- [ ] **Step 5: Run the full existing `test_mail.py` suite to confirm the refactor didn't change `fetch()` behavior**
Run: `uv run pytest --override-ini="addopts=" src/paperless_mail/tests/test_mail.py -v`
Expected: all tests PASS (same pass count as before this task)
- [ ] **Step 6: Commit**
```bash
git add src/paperless_mail/tests/test_mail.py
git commit -m "test: add BogusMailBox.uids() and UID criteria support"
```
---
### Task 2: Record `ProcessedMail` for attachment-less mail under attachments-only rules
**Files:**
- Modify: `src/paperless_mail/mail.py:749-793` (`_handle_message`)
- Modify: `src/paperless_mail/mail.py:952-979` (`_process_attachments`, tail)
- Test: `src/paperless_mail/tests/test_mail.py:540-548` (`test_handle_empty_message`, rewritten)
**Interfaces:**
- Produces: `MailAccountHandler._record_processed_without_consumption(self, message: MailMessage, rule: MailRule) -> None` — idempotently writes a `ProcessedMail(status="PROCESSED_WO_CONSUMPTION")` row for `(rule, message.uid, rule.folder)` if one doesn't already exist for the current `self._current_uid_validity`. No mailbox action is applied.
- Consumes: `self._current_uid_validity` (already set by `_handle_mail_rule` before `_handle_message` is called; `None` when called directly, e.g. in unit tests).
- [ ] **Step 1: Write the failing test**
Replace `src/paperless_mail/tests/test_mail.py:540-548` (`test_handle_empty_message`):
```python
def test_handle_empty_message(self) -> None:
message = self.mailMocker.messageBuilder.create_message(
subject="No attachments here",
attachments=[],
)
account = MailAccount.objects.create()
rule = MailRule.objects.create(
account=account,
consumption_scope=MailRule.ConsumptionScope.ATTACHMENTS_ONLY,
)
result = self.mail_account_handler._handle_message(message, rule)
self.mailMocker._queue_consumption_tasks_mock.assert_not_called()
self.assertEqual(result, 0)
processed = ProcessedMail.objects.get(
rule=rule,
uid=message.uid,
folder=rule.folder,
)
self.assertEqual(processed.status, "PROCESSED_WO_CONSUMPTION")
# Calling it again must not create a second row
self.mail_account_handler._handle_message(message, rule)
self.assertEqual(
ProcessedMail.objects.filter(
rule=rule,
uid=message.uid,
folder=rule.folder,
).count(),
1,
)
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run pytest --override-ini="addopts=" src/paperless_mail/tests/test_mail.py::TestMail::test_handle_empty_message -v`
Expected: FAIL — `ProcessedMail.DoesNotExist` (no row is created by the current early return).
- [ ] **Step 3: Add the shared helper and call it from both places**
Insert a new method right after `_handle_message` ends, i.e. after `src/paperless_mail/mail.py:793` (`return processed_elements`), before `def filename_inclusion_matches`:
```python
def _record_processed_without_consumption(
self,
message: MailMessage,
rule: MailRule,
) -> None:
if not ProcessedMail.objects.filter(
rule=rule,
uid=message.uid,
folder=rule.folder,
uid_validity=self._current_uid_validity,
).exists():
ProcessedMail.objects.create(
rule=rule,
folder=rule.folder,
uid=message.uid,
uid_validity=self._current_uid_validity,
subject=message.subject,
received=make_aware(message.date)
if is_naive(message.date)
else message.date,
status="PROCESSED_WO_CONSUMPTION",
)
```
Then modify `_handle_message`'s early return at `src/paperless_mail/mail.py:756-760`:
```python
# Skip Message handling when only attachments are to be processed but
# message doesn't have any.
if (
not message.attachments
and rule.consumption_scope == MailRule.ConsumptionScope.ATTACHMENTS_ONLY
):
return processed_elements
```
becomes:
```python
# Skip Message handling when only attachments are to be processed but
# message doesn't have any.
if (
not message.attachments
and rule.consumption_scope == MailRule.ConsumptionScope.ATTACHMENTS_ONLY
):
self._record_processed_without_consumption(message, rule)
return processed_elements
```
Then replace the tail of `_process_attachments` at `src/paperless_mail/mail.py:952-979`:
```python
if len(consume_tasks) > 0:
queue_consumption_tasks(
consume_tasks=consume_tasks,
rule=rule,
message=message,
uid_validity=self._current_uid_validity,
)
else:
# No files to consume, just mark as processed if it wasn't by .eml processing
if not ProcessedMail.objects.filter(
rule=rule,
uid=message.uid,
folder=rule.folder,
uid_validity=self._current_uid_validity,
).exists():
ProcessedMail.objects.create(
rule=rule,
folder=rule.folder,
uid=message.uid,
uid_validity=self._current_uid_validity,
subject=message.subject,
received=make_aware(message.date)
if is_naive(message.date)
else message.date,
status="PROCESSED_WO_CONSUMPTION",
)
return processed_attachments
```
with:
```python
if len(consume_tasks) > 0:
queue_consumption_tasks(
consume_tasks=consume_tasks,
rule=rule,
message=message,
uid_validity=self._current_uid_validity,
)
else:
# No files to consume, just mark as processed if it wasn't by .eml processing
self._record_processed_without_consumption(message, rule)
return processed_attachments
```
- [ ] **Step 4: Run test to verify it passes**
Run: `uv run pytest --override-ini="addopts=" src/paperless_mail/tests/test_mail.py::TestMail::test_handle_empty_message -v`
Expected: PASS
- [ ] **Step 5: Run the full `test_mail.py` suite (regression check for the `_process_attachments` refactor)**
Run: `uv run pytest --override-ini="addopts=" src/paperless_mail/tests/test_mail.py -v`
Expected: all PASS
- [ ] **Step 6: Commit**
```bash
git add src/paperless_mail/mail.py src/paperless_mail/tests/test_mail.py
git commit -m "fix: record ProcessedMail for attachment-less mail under attachments-only rules"
```
---
### Task 3: Diff UIDs against `ProcessedMail` before fetching message bodies
**Files:**
- Modify: `src/paperless_mail/mail.py:641-693` (`_handle_mail_rule`, the fetch section)
- Test: `src/paperless_mail/tests/test_mail.py` (new test in `class TestMail`)
**Interfaces:**
- Consumes: `MailAccountHandler._record_processed_without_consumption` (Task 2), `ProcessedMail` model, `imap_tools.AND`, `M.uids()` (Task 1's `BogusMailBox.uids()` in tests; `imap_tools.MailBox.uids()` in production).
- Produces: no new public interface — `_handle_mail_rule`'s external behavior (return value, exceptions raised) is unchanged; only its internal fetch strategy changes. `_handle_mail_rule` now returns `0` immediately, without calling `M.fetch()` at all, when every UID matching the search criteria already has a `ProcessedMail` row.
- [ ] **Step 1: Write the failing test**
Add to `class TestMail` in `src/paperless_mail/tests/test_mail.py`:
```python
def test_handle_mail_account_skips_body_fetch_for_already_processed_mail(
self,
) -> None:
"""
GIVEN:
- An attachment-less mail under an attachments-only mark-read rule,
already recorded as PROCESSED_WO_CONSUMPTION
WHEN:
- The mail account is processed again and the mail still matches the
search criteria (it was never marked read, since no mail action is
applied for the no-consumption case)
THEN:
- No IMAP body fetch happens for that mail; only the cheap UID search runs.
"""
account = MailAccount.objects.create(
name="test",
imap_server="",
username="admin",
password="secret",
)
rule = MailRule.objects.create(
name="testrule",
account=account,
action=MailRule.MailAction.MARK_READ,
consumption_scope=MailRule.ConsumptionScope.ATTACHMENTS_ONLY,
)
message = self.mailMocker.messageBuilder.create_message(
subject="No attachment",
attachments=[],
)
self.mailMocker.bogus_mailbox.messages = [message]
self.mailMocker.bogus_mailbox.updateClient()
# First run: records ProcessedMail without consuming anything.
self.mail_account_handler.handle_mail_account(account)
self.assertTrue(
ProcessedMail.objects.filter(
rule=rule,
uid=message.uid,
folder=rule.folder,
).exists(),
)
self.mailMocker._queue_consumption_tasks_mock.assert_not_called()
# Second run: message still matches UNSEEN (mark-read action never ran),
# but its body must not be downloaded again.
with mock.patch.object(
self.mailMocker.bogus_mailbox,
"fetch",
wraps=self.mailMocker.bogus_mailbox.fetch,
) as fetch_spy:
self.mail_account_handler.handle_mail_account(account)
fetch_spy.assert_not_called()
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run pytest --override-ini="addopts=" src/paperless_mail/tests/test_mail.py::TestMail::test_handle_mail_account_skips_body_fetch_for_already_processed_mail -v`
Expected: FAIL — `fetch_spy.assert_not_called()` fails because the current code always calls `M.fetch()` for every message matching the search criteria, regardless of `ProcessedMail`.
- [ ] **Step 3: Implement the UID diff**
Replace `src/paperless_mail/mail.py:683-693`:
```python
try:
messages = M.fetch(
criteria=criterias,
mark_seen=False,
charset=rule.account.character_set,
bulk=True,
)
except Exception as err:
raise MailError(
f"Rule {rule}: Error while fetching folder {rule.folder}",
) from err
```
with:
```python
try:
all_uids = set(
M.uids(criteria=criterias, charset=rule.account.character_set),
)
except Exception as err:
raise MailError(
f"Rule {rule}: Error while searching folder {rule.folder}",
) from err
processed_uids_qs = ProcessedMail.objects.filter(
rule=rule,
folder=rule.folder,
uid__in=all_uids,
)
if self._current_uid_validity is not None:
processed_uids_qs = processed_uids_qs.filter(
Q(uid_validity=self._current_uid_validity)
| Q(uid_validity__isnull=True),
)
processed_uids = set(processed_uids_qs.values_list("uid", flat=True))
new_uids = all_uids - processed_uids
if not new_uids:
self.log.debug(
f"Rule {rule}: No new mail matching criteria {criterias}",
)
return 0
try:
messages = M.fetch(
criteria=AND(uid=list(new_uids)),
mark_seen=False,
charset=rule.account.character_set,
bulk=True,
)
except Exception as err:
raise MailError(
f"Rule {rule}: Error while fetching folder {rule.folder}",
) from err
```
Note the `uid_validity` handling here deliberately mirrors the existing per-message check at `mail.py:722-726` exactly (see Global Constraints) — the extra `Q(...)` filter is applied only when `self._current_uid_validity is not None`.
- [ ] **Step 4: Run test to verify it passes**
Run: `uv run pytest --override-ini="addopts=" src/paperless_mail/tests/test_mail.py::TestMail::test_handle_mail_account_skips_body_fetch_for_already_processed_mail -v`
Expected: PASS
- [ ] **Step 5: Run the full `test_mail.py` suite (regression check, especially the uidvalidity tests)**
Run: `uv run pytest --override-ini="addopts=" src/paperless_mail/tests/test_mail.py -v`
Expected: all PASS, including:
- `test_handle_mail_account_skip_duplicate_uids_from_fetch`
- `test_handle_mail_account_skips_mail_already_processed_in_same_uidvalidity`
- `test_handle_mail_account_processes_mail_after_uidvalidity_change`
- `test_handle_mail_account_skips_mail_processed_before_uidvalidity_tracking`
- `test_handle_mail_account_processes_mail_when_uidvalidity_unavailable`
- `test_handle_mail_account_skips_mail_when_uidvalidity_unavailable_but_prior_record_exists`
- `test_handle_mail_account_overlapping_rules_only_first_consumes`
If any of these fail, the `uid_validity` branching in Step 3 does not match `mail.py:722-726` closely enough — re-check against the Global Constraints note before changing test expectations.
- [ ] **Step 6: Commit**
```bash
git add src/paperless_mail/mail.py src/paperless_mail/tests/test_mail.py
git commit -m "fix: diff UIDs against ProcessedMail before fetching message bodies"
```
---
### Task 4: Batch the body fetch for large backlogs
**Files:**
- Modify: `src/paperless_mail/mail.py:74-76` (module-level constant)
- Modify: `src/paperless_mail/mail.py` (the `M.fetch(...)` block added in Task 3)
- Test: `src/paperless_mail/tests/test_mail.py` (new test in `class TestMail`)
**Interfaces:**
- Produces: module-level constant `paperless_mail.mail.MAIL_FETCH_BATCH_SIZE: int = 500`.
- Consumes: `itertools` (already imported at `mail.py:2`), `sorted_new_uids` derived from Task 3's `new_uids` set.
- [ ] **Step 1: Write the failing test**
Add to `class TestMail` in `src/paperless_mail/tests/test_mail.py`. Add `from paperless_mail.mail import MAIL_FETCH_BATCH_SIZE` to the imports at the top of the file (alongside the other `from paperless_mail.mail import ...` lines at `test_mail.py:35-38`).
```python
@mock.patch("paperless_mail.mail.MAIL_FETCH_BATCH_SIZE", 5)
def test_handle_mail_account_batches_body_fetch_for_large_backlog(self) -> None:
"""
GIVEN:
- More new/unprocessed mail than MAIL_FETCH_BATCH_SIZE
WHEN:
- The mail account is processed
THEN:
- The body fetch is issued in multiple batches
- Every message is still processed (none dropped at a batch boundary)
"""
account = MailAccount.objects.create(
name="test",
imap_server="",
username="admin",
password="secret",
)
rule = MailRule.objects.create(
name="testrule",
account=account,
action=MailRule.MailAction.MARK_READ,
consumption_scope=MailRule.ConsumptionScope.ATTACHMENTS_ONLY,
)
message_count = 12 # more than the patched batch size of 5
self.mailMocker.bogus_mailbox.messages = [
self.mailMocker.messageBuilder.create_message(
subject=f"No attachment {i}",
attachments=[],
)
for i in range(message_count)
]
self.mailMocker.bogus_mailbox.updateClient()
with mock.patch.object(
self.mailMocker.bogus_mailbox,
"fetch",
wraps=self.mailMocker.bogus_mailbox.fetch,
) as fetch_spy:
self.mail_account_handler.handle_mail_account(account)
# ceil(12 / 5) == 3 batches
self.assertEqual(fetch_spy.call_count, 3)
self.assertEqual(
ProcessedMail.objects.filter(rule=rule).count(),
message_count,
)
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run pytest --override-ini="addopts=" src/paperless_mail/tests/test_mail.py::TestMail::test_handle_mail_account_batches_body_fetch_for_large_backlog -v`
Expected: FAIL — either an `ImportError` for `MAIL_FETCH_BATCH_SIZE` (doesn't exist yet) or, once that import is stubbed out, `fetch_spy.call_count == 1` instead of `3` (Task 3's implementation fetches all new UIDs in one call).
- [ ] **Step 3: Add the constant and batch the fetch**
Insert after `src/paperless_mail/mail.py:74` (right after the `APPLE_MAIL_TAG_COLORS` dict closes, before `class MailError`):
```python
MAIL_FETCH_BATCH_SIZE = 500
```
Replace the `M.fetch(...)` block added in Task 3 (Task 3 Step 3's final `try/except`):
```python
try:
messages = M.fetch(
criteria=AND(uid=list(new_uids)),
mark_seen=False,
charset=rule.account.character_set,
bulk=True,
)
except Exception as err:
raise MailError(
f"Rule {rule}: Error while fetching folder {rule.folder}",
) from err
```
with:
```python
sorted_new_uids = sorted(new_uids, key=int)
message_batches = []
for batch_start in range(0, len(sorted_new_uids), MAIL_FETCH_BATCH_SIZE):
batch = sorted_new_uids[batch_start : batch_start + MAIL_FETCH_BATCH_SIZE]
try:
message_batches.append(
M.fetch(
criteria=AND(uid=batch),
mark_seen=False,
charset=rule.account.character_set,
bulk=True,
),
)
except Exception as err:
raise MailError(
f"Rule {rule}: Error while fetching folder {rule.folder}",
) from err
messages = itertools.chain(*message_batches)
```
- [ ] **Step 4: Run test to verify it passes**
Run: `uv run pytest --override-ini="addopts=" src/paperless_mail/tests/test_mail.py::TestMail::test_handle_mail_account_batches_body_fetch_for_large_backlog -v`
Expected: PASS
- [ ] **Step 5: Run the full `test_mail.py` suite**
Run: `uv run pytest --override-ini="addopts=" src/paperless_mail/tests/test_mail.py -v`
Expected: all PASS
- [ ] **Step 6: Commit**
```bash
git add src/paperless_mail/mail.py src/paperless_mail/tests/test_mail.py
git commit -m "perf: batch body fetches when many new UIDs are pending"
```
---
### Task 5: Full regression pass and lint
**Files:** none (verification only, plus any fixups this step surfaces)
- [ ] **Step 1: Run the full backend test suite with coverage/parallelism as configured**
Run: `uv run pytest src/paperless_mail/`
Expected: all `paperless_mail` tests PASS
- [ ] **Step 2: Run the full project test suite**
Run: `uv run pytest`
Expected: all tests PASS (no regressions outside `paperless_mail`)
- [ ] **Step 3: Lint and format**
Run: `uv run ruff check src/paperless_mail/` and `uv run ruff format --check src/paperless_mail/`
Expected: no errors. If `ruff format --check` reports files needing formatting, run `uv run ruff format src/paperless_mail/` and re-check.
- [ ] **Step 4: Type check against the frozen baselines**
Run: `uv run mypy src/paperless_mail/mail.py` (or the project's standard mypy invocation) and confirm no new violations beyond `.mypy-baseline.txt`.
Expected: no new errors introduced by this change.
- [ ] **Step 5: Commit any fixups**
If Steps 1-4 required changes:
```bash
git add -u
git commit -m "chore: fix lint/type fallout from mail fetch fix"
```
If no changes were needed, skip this step — nothing to commit.
@@ -0,0 +1,216 @@
# Mail fetch fix: avoid re-downloading already-handled IMAP mail
## Problem
`paperless_mail`'s `_handle_mail_rule` (`src/paperless_mail/mail.py:641`) fetches
the full RFC822 body of every message an IMAP `SEARCH` matches (via
`M.fetch(criteria=criterias, ..., bulk=True)`), then does de-duplication in
Python afterward. Two things compound into a real bug (reported upstream in
[paperless-ngx#13175](https://github.com/paperless-ngx/paperless-ngx/issues/13175)):
1. **Full-body fetch happens before dedup.** The de-dup check against
`ProcessedMail` (`mail.py:717-731`) happens per-message, after the body has
already been downloaded for every matching message. Steady-state cost is
_O(everything the search matches)_, not _O(new since last run)_.
2. **Attachment-less mail under an attachments-only rule is never recorded.**
`_handle_message` (`mail.py:749`) returns early, before any attachment
processing, when a message has no attachments and
`rule.consumption_scope == ATTACHMENTS_ONLY`:
```python
if (
not message.attachments
and rule.consumption_scope == MailRule.ConsumptionScope.ATTACHMENTS_ONLY
):
return processed_elements # 0, no ProcessedMail row, no mail action applied
```
No `ProcessedMail` row is written and no mail action (mark-read/flag/tag)
is applied, since both only happen via `queue_consumption_tasks`, which is
only reached from the attachment/eml processing paths. The message keeps
matching the search (e.g. `UNSEEN`) forever.
Combined: an inbox where most mail has no attachments gets fully re-downloaded
on every scheduled run (default every 10 minutes), indefinitely, for large
mailboxes -- confirmed against the current codebase, not user
misconfiguration.
## Goals
- Stop re-downloading full message bodies for mail that has already been
handled (processed into a document, or determined to produce nothing).
- Record `ProcessedMail` for the no-attachment / attachments-only early-return
case, so it participates in dedup like every other terminal outcome.
- No new required settings or DB migrations for existing installs.
- Graceful behavior on first run against a large existing mailbox (no single
giant IMAP command, no giant in-memory batch).
- Preserve existing action/consumption semantics exactly for every path that
currently produces a document or applies a mail action.
## Non-goals
- Changing what mail actions (mark-read/flag/tag/move/delete) get applied, or
when.
- Applying a mail action to the no-attachment early-return case (explicitly
out of scope -- see Decisions).
- Making batch size user-configurable.
- Touching EML_ONLY / EVERYTHING consumption scopes' semantics (they already
don't hit the early-return branch described above).
## Decisions
These were settled during brainstorming and are load-bearing for the design
below:
- **Fix both halves together.** Recording `ProcessedMail` alone does not fix
the bandwidth problem: without a mail action applied, an attachment-less
message stays unseen and keeps matching the search criteria, so its body
gets re-downloaded every run regardless of whether it gets reprocessed.
Fixing only the bandwidth side without recording `ProcessedMail` would mean
the UID-diff step never has anything to exclude for these messages. They
must ship together.
- **No mailbox action for the no-attachment case.** Only a `ProcessedMail`
row is written; the message is not marked read/flagged/moved/deleted. This
matches the existing precedent for other no-consumption outcomes
(`mail.py:960-977`, e.g. an attachment present but filtered out or an
unsupported mime type) and avoids changing the user's mailbox state for
mail paperless previously never touched.
- **UID-diff-before-fetch, not a BODYSTRUCTURE probe or SEARCH-side
exclusion.** Considered and rejected:
- BODYSTRUCTURE probing (fetch structure only, decide whether to fetch full
body) only helps the attachments-only scope, not the general
already-processed case, and `imap_tools` doesn't cleanly expose a
structure-only fetch.
- Excluding already-processed UIDs directly in the IMAP `SEARCH` criteria
(`NOT UID (...)`) was rejected because for tens of thousands of
already-processed UIDs the excluded-UID list itself blows up the command
size -- worse than the two-step approach.
- **Batch size is a hardcoded constant**, not a new setting, per the
"no new required settings" goal.
## Design
### `_handle_mail_rule` (`mail.py:641`)
Replace the single `M.fetch(criteria=criterias, ...)` call with:
1. `self._current_uid_validity` computed as today (unchanged, already first).
2. `criterias = make_criterias(...)` (unchanged).
3. `all_uids = set(M.uids(criteria=criterias, charset=rule.account.character_set))`
-- a `UID SEARCH`, no bodies.
4. Query already-processed UIDs in one DB round trip, reusing the same
uid_validity matching already used per-message:
```python
processed_uids = set(
ProcessedMail.objects.filter(
rule=rule, folder=rule.folder, uid__in=all_uids,
).filter(
Q(uid_validity=self._current_uid_validity) | Q(uid_validity__isnull=True),
).values_list("uid", flat=True)
)
```
5. `new_uids = all_uids - processed_uids`. If empty: log at debug level and
`return 0` -- no body fetch at all. This is the steady-state case.
6. Otherwise, iterate `new_uids` in fixed-size batches of
`MAIL_FETCH_BATCH_SIZE = 500` (module-level constant), calling
`M.fetch(criteria=AND(uid=batch), mark_seen=False, charset=rule.account.character_set, bulk=True)`
per batch. Chain the resulting message iterators into the existing
per-message loop (`mail.py:699+`) unchanged.
7. The existing per-message `already_processed` DB check
(`mail.py:717-731`) stays in place unchanged, as a safety net against
races (e.g. concurrent rule runs against the same folder in this pass) and
to keep behavior identical if steps 3/4 ever disagree with it.
### New helper: `_record_processed_without_consumption`
Extract the existing dedup-and-create block at `mail.py:960-977` into a
method on the same class:
```python
def _record_processed_without_consumption(self, message, rule) -> None:
if not ProcessedMail.objects.filter(
rule=rule,
uid=message.uid,
folder=rule.folder,
uid_validity=self._current_uid_validity,
).exists():
ProcessedMail.objects.create(
rule=rule,
folder=rule.folder,
uid=message.uid,
uid_validity=self._current_uid_validity,
subject=message.subject,
received=make_aware(message.date) if is_naive(message.date) else message.date,
status="PROCESSED_WO_CONSUMPTION",
)
```
Call sites:
- `_process_attachments`'s existing no-consumables branch (replaces the
inline block, same behavior).
- `_handle_message`'s early return (`mail.py:756-760`), newly, for the
no-attachments-under-attachments-only-scope case.
### Data flow (steady state, nothing new)
search criteria (unchanged) -> `UID SEARCH` for matching UIDs (cheap) ->
subtract UIDs already in `ProcessedMail` (cheap, DB-only) -> zero new UIDs ->
no fetch, no body download.
### Data flow (new mail present)
... same as above -> some UIDs remain -> fetch bodies only for those,
batched -> existing per-message processing, action application, and
`ProcessedMail` recording, unchanged except the no-attachment early return
now also calls `_record_processed_without_consumption`.
## Error handling
- `M.uids(...)` raising is wrapped in the same `try/except` that currently
wraps `M.fetch(...)` (`mail.py:683-693`), surfacing as `MailError`
identically to today's search-failure behavior.
- A batch's `M.fetch(...)` raising mid-loop is caught by the same
`try/except`, applied per-batch. One failing batch fails the whole rule
run for this pass -- matching today's all-or-nothing semantics (currently
a single failed fetch already fails the whole rule).
- Empty `new_uids` short-circuits before any fetch, so it can't hit fetch
error paths at all for the common case.
- `_record_processed_without_consumption` reuses the existing
`ProcessedMail.objects.create` call already in production use; no new
failure mode introduced.
## Testing
In `src/paperless_mail/tests/test_mail.py`:
- Extend the `BogusMailBox` test double with a `uids()` method that mirrors
its existing `fetch()` criteria matching but returns only UIDs.
- New test: an attachment-less message under an attachments-only mark-read
rule is recorded as `PROCESSED_WO_CONSUMPTION` on the first run. On a
second run, with the same message still unseen server-side, assert no
body fetch happens (only `M.uids()` is called) -- i.e. steady state costs
one search and zero body downloads.
- New test: a folder with more new UIDs than `MAIL_FETCH_BATCH_SIZE` results
in multiple batched `fetch` calls, and all messages are still processed
(no messages dropped at a batch boundary).
- Update existing `test_handle_empty_message` to assert a
`PROCESSED_WO_CONSUMPTION` row now exists for the no-attachment case.
- Regression: existing tests for `EML_ONLY`/`EVERYTHING` scopes and the
multi-rule-same-folder dedup (`consumed_messages` set) should pass
unchanged, since neither the per-message `already_processed` check nor the
`consumed_messages` set logic is touched by this design.
## Open questions / risks
- `imap_tools`'s `AND(uid=batch)` criteria builder needs to be confirmed to
produce a valid `UID FETCH <list>` command at the batch sizes used here;
covered by the batching test above.
- If a mail server doesn't support `UID SEARCH` the same way it supports the
existing `fetch`'s implicit search, `M.uids()` could behave differently
from today's `M.fetch()` on some edge-case server. Existing project test
coverage (via `BogusMailBox`) won't catch server-specific quirks; this is
the same class of risk as any IMAP-behavior assumption already baked into
this module.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,9 +1,9 @@
from datetime import UTC
from datetime import datetime
from typing import Any
from django.conf import settings
from django.core.cache import cache
from rest_framework.request import Request
from documents.caching import CACHE_5_MINUTES
from documents.caching import CACHE_50_MINUTES
@@ -117,7 +117,7 @@ def preview_last_modified(request, pk: int) -> datetime | None:
return doc.modified
def thumbnail_etag(request: Any, pk: int) -> str | None:
def thumbnail_etag(request: Request, pk: int) -> str | None:
"""
Thumbnails are version-dependent, so use the effective document checksum as
the ETag to invalidate cache when the latest version changes.
@@ -128,7 +128,7 @@ def thumbnail_etag(request: Any, pk: int) -> str | None:
return doc.checksum
def thumbnail_last_modified(request: Any, pk: int) -> datetime | None:
def thumbnail_last_modified(request: Request, pk: int) -> datetime | None:
"""
Returns the filesystem last modified either from cache or from filesystem.
Cache should be (slightly?) faster than filesystem
+5
View File
@@ -621,6 +621,11 @@ class ConsumerPlugin(
else:
original_document.save()
# Adding a version changes the effective document, so update root modified
Document.objects.filter(pk=root_doc.pk).update(
modified=timezone.now(),
)
# Create a log entry for the version addition, if enabled
if settings.AUDIT_LOG_ENABLED:
from auditlog.models import ( # type: ignore[import-untyped]
@@ -0,0 +1,73 @@
# Generated by Django 5.2.16 on 2026-07-19 20:33
import django.core.validators
from django.conf import settings
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("documents", "0021_widen_workflow_integer_fields"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name="document",
name="checksum",
field=models.CharField(
db_index=True,
editable=False,
help_text="The checksum of the original document.",
max_length=64,
verbose_name="checksum",
),
),
migrations.AlterField(
model_name="document",
name="page_count",
field=models.PositiveIntegerField(
db_index=True,
help_text="The number of pages of the document.",
null=True,
validators=[django.core.validators.MinValueValidator(1)],
verbose_name="page count",
),
),
migrations.AddIndex(
model_name="customfieldinstance",
index=models.Index(
fields=["field", "value_date"],
name="documents_c_field_i_30b51d_idx",
),
),
migrations.AddIndex(
model_name="customfieldinstance",
index=models.Index(
fields=["field", "value_int"],
name="documents_c_field_i_d25ab0_idx",
),
),
migrations.AddIndex(
model_name="customfieldinstance",
index=models.Index(
fields=["field", "value_float"],
name="documents_c_field_i_c35174_idx",
),
),
migrations.AddIndex(
model_name="customfieldinstance",
index=models.Index(
fields=["field", "value_monetary_amount"],
name="documents_c_field_i_53ce4e_idx",
),
),
migrations.AddIndex(
model_name="document",
index=models.Index(
fields=["owner", "created"],
name="documents_d_owner_i_ec6ab3_idx",
),
),
]
+11 -1
View File
@@ -217,6 +217,7 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
_("checksum"),
max_length=64,
editable=False,
db_index=True,
help_text=_("The checksum of the original document."),
)
@@ -234,7 +235,7 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
blank=False,
null=True,
unique=False,
db_index=False,
db_index=True,
validators=[MinValueValidator(1)],
help_text=_(
"The number of pages of the document.",
@@ -338,6 +339,9 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
ordering = ("-created",)
verbose_name = _("document")
verbose_name_plural = _("documents")
indexes = [
models.Index(fields=["owner", "created"]),
]
constraints = [
models.UniqueConstraint(
fields=["root_document", "version_index"],
@@ -1190,6 +1194,12 @@ class CustomFieldInstance(SoftDeleteModel):
ordering = ("created",)
verbose_name = _("custom field instance")
verbose_name_plural = _("custom field instances")
indexes = [
models.Index(fields=["field", "value_date"]),
models.Index(fields=["field", "value_int"]),
models.Index(fields=["field", "value_float"]),
models.Index(fields=["field", "value_monetary_amount"]),
]
constraints = [
models.UniqueConstraint(
fields=["document", "field"],
@@ -1,5 +1,6 @@
from __future__ import annotations
import datetime
from typing import TYPE_CHECKING
from unittest import TestCase
from unittest import mock
@@ -10,6 +11,7 @@ from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldError
from django.core.files.uploadedfile import SimpleUploadedFile
from django.utils import timezone
from rest_framework import status
from rest_framework.test import APITestCase
@@ -137,6 +139,8 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
root_document=root,
content="v2-content",
)
original_modified = timezone.now() - datetime.timedelta(days=1)
Document.objects.filter(pk=root.pk).update(modified=original_modified)
with mock.patch("documents.search.get_backend"):
resp = self.client.delete(f"/api/documents/{root.id}/versions/{v2.id}/")
@@ -146,6 +150,7 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
self.assertEqual(resp.data["current_version_id"], v1.id)
root.refresh_from_db()
self.assertEqual(root.content, "root-content")
self.assertGreater(root.modified, original_modified)
with mock.patch("documents.search.get_backend"):
resp = self.client.delete(f"/api/documents/{root.id}/versions/{v1.id}/")
@@ -326,6 +331,8 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
root_document=root,
version_label="old",
)
original_modified = timezone.now() - datetime.timedelta(days=1)
Document.objects.filter(pk=root.pk).update(modified=original_modified)
resp = self.client.patch(
f"/api/documents/{root.id}/versions/{version.id}/",
@@ -339,6 +346,8 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
self.assertEqual(resp.data["version_label"], "Label 1")
self.assertEqual(resp.data["id"], version.id)
self.assertFalse(resp.data["is_root"])
root.refresh_from_db()
self.assertGreater(root.modified, original_modified)
def test_update_version_label_clears_on_blank(self) -> None:
root = Document.objects.create(
+4
View File
@@ -767,6 +767,8 @@ class TestConsumer(
root_doc.archive_serial_number = 42
root_doc.save()
original_modified = timezone.now() - datetime.timedelta(days=1)
Document.objects.filter(pk=root_doc.pk).update(modified=original_modified)
actor = User.objects.create_user(
username="actor",
email="actor@example.com",
@@ -818,6 +820,8 @@ class TestConsumer(
self.assertIsNone(version.archive_serial_number)
self.assertEqual(version.original_filename, version_file.name)
self.assertTrue(bool(version.content))
root_doc.refresh_from_db()
self.assertGreater(root_doc.modified, original_modified)
@override_settings(AUDIT_LOG_ENABLED=True)
@mock.patch("documents.consumer.load_classifier")
@@ -1,8 +1,12 @@
from datetime import timedelta
from types import SimpleNamespace
from typing import TYPE_CHECKING
from typing import cast
from unittest import mock
from django.db import connection
from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from django.utils import timezone
from documents.conditionals import metadata_etag
@@ -13,6 +17,9 @@ from documents.models import Document
from documents.tests.utils import DirectoriesMixin
from documents.versioning import resolve_effective_document_by_pk
if TYPE_CHECKING:
from rest_framework.request import Request
class TestConditionals(DirectoriesMixin, TestCase):
def test_metadata_etag_uses_latest_version_for_root_request(self) -> None:
@@ -29,14 +36,18 @@ class TestConditionals(DirectoriesMixin, TestCase):
mime_type="application/pdf",
root_document=root,
)
request = SimpleNamespace(query_params={})
request = cast("Request", SimpleNamespace(query_params={}))
self.assertEqual(
metadata_etag(request, root.id),
f"{latest.checksum}:{latest.modified.isoformat()}",
)
self.assertEqual(preview_etag(request, root.id), latest.archive_checksum)
self.assertEqual(thumbnail_etag(request, root.id), latest.checksum)
# preview_etag/thumbnail_etag resolve the same (pk, request) and should
# reuse metadata_etag's cached resolution instead of re-querying.
with CaptureQueriesContext(connection) as ctx:
self.assertEqual(preview_etag(request, root.id), latest.archive_checksum)
self.assertEqual(thumbnail_etag(request, root.id), latest.checksum)
self.assertEqual(len(ctx.captured_queries), 0)
def test_metadata_etag_changes_when_document_modified_changes(self) -> None:
doc = Document.objects.create(
@@ -44,15 +55,20 @@ class TestConditionals(DirectoriesMixin, TestCase):
checksum="same-checksum",
mime_type="application/pdf",
)
request = SimpleNamespace(query_params={})
original_etag = metadata_etag(request, doc.id)
# Each call simulates a separate incoming HTTP request, so it gets its
# own request object -- the per-request resolution cache must not leak
# a stale result across genuinely different requests.
original_etag = metadata_etag(
cast("Request", SimpleNamespace(query_params={})),
doc.id,
)
new_modified = timezone.now() + timedelta(seconds=5)
Document.objects.filter(id=doc.id).update(modified=new_modified)
self.assertNotEqual(metadata_etag(request, doc.id), original_etag)
second_request = cast("Request", SimpleNamespace(query_params={}))
self.assertNotEqual(metadata_etag(second_request, doc.id), original_etag)
self.assertEqual(
metadata_etag(request, doc.id),
metadata_etag(second_request, doc.id),
f"{doc.checksum}:{new_modified.isoformat()}",
)
@@ -76,9 +92,13 @@ class TestConditionals(DirectoriesMixin, TestCase):
root_document=other_root,
)
invalid_request = SimpleNamespace(query_params={"version": "not-a-number"})
unrelated_request = SimpleNamespace(
query_params={"version": str(other_version.id)},
invalid_request = cast(
"Request",
SimpleNamespace(query_params={"version": "not-a-number"}),
)
unrelated_request = cast(
"Request",
SimpleNamespace(query_params={"version": str(other_version.id)}),
)
self.assertIsNone(
@@ -105,7 +125,7 @@ class TestConditionals(DirectoriesMixin, TestCase):
latest.thumbnail_path.parent.mkdir(parents=True, exist_ok=True)
latest.thumbnail_path.write_bytes(b"thumb")
request = SimpleNamespace(query_params={})
request = cast("Request", SimpleNamespace(query_params={}))
with mock.patch(
"documents.conditionals.get_thumbnail_modified_key",
return_value="thumb-modified-key",
+63
View File
@@ -2938,6 +2938,69 @@ class TestWorkflows(
self.assertFalse(doc.tags.filter(pk=self.t1.pk).exists())
self.assertTrue(doc.tags.filter(pk=self.t2.pk).exists())
def test_document_updated_workflow_assignment_storage_path_persists_with_tag_assignment(
self,
) -> None:
"""
GIVEN:
- A document updated workflow filtered on a tag
- One assignment action assigns a storage path, a second (later-ordered)
assignment action adds a tag
WHEN:
- The document is updated and the workflow is triggered
THEN:
- Both the tag and the storage path are persisted
"""
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
)
trigger.filter_has_tags.add(self.t1)
assign_storage_path = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.ASSIGNMENT,
assign_storage_path=self.sp,
order=0,
)
assign_tag = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.ASSIGNMENT,
order=1,
)
assign_tag.assign_tags.add(self.t2)
assign_tag.save()
workflow = Workflow.objects.create(
name="Workflow assign storage path then tag",
order=0,
)
workflow.triggers.add(trigger)
workflow.actions.add(assign_storage_path, assign_tag)
workflow.save()
doc = Document.objects.create(
title="sample test",
mime_type="application/pdf",
checksum="assign-tag-and-storage-path",
original_filename="sample.pdf",
)
generated = generate_unique_filename(doc)
destination = (settings.ORIGINALS_DIR / generated).resolve()
create_source_path_directory(destination)
shutil.copy(self.SAMPLE_DIR / "simple.pdf", destination)
Document.objects.filter(pk=doc.pk).update(filename=generated.as_posix())
doc.refresh_from_db()
doc.tags.set([self.t1])
superuser = User.objects.create_superuser("superuser")
self.client.force_authenticate(user=superuser)
self.client.patch(
f"/api/documents/{doc.id}/",
{"title": "user update to trigger workflow"},
format="json",
)
doc.refresh_from_db()
self.assertEqual(doc.storage_path, self.sp)
self.assertTrue(doc.tags.filter(pk=self.t2.pk).exists())
def test_removal_action_document_updated_removeall(self) -> None:
"""
GIVEN:
+34 -11
View File
@@ -8,7 +8,7 @@ from typing import Any
from documents.models import Document
if TYPE_CHECKING:
from django.http import HttpRequest
from rest_framework.request import Request
class VersionResolutionError(StrEnum):
@@ -26,7 +26,7 @@ def _document_manager(*, include_deleted: bool) -> Any:
return Document.global_objects if include_deleted else Document.objects
def get_request_version_param(request: HttpRequest) -> str | None:
def get_request_version_param(request: Request) -> str | None:
if hasattr(request, "query_params"):
return request.query_params.get("version")
return None
@@ -57,7 +57,7 @@ def get_latest_version_for_root(
def resolve_requested_version_for_root(
root_doc: Document,
request: Any,
request: Request,
*,
include_deleted: bool = False,
) -> VersionResolution:
@@ -86,7 +86,7 @@ def resolve_requested_version_for_root(
def resolve_effective_document(
request_doc: Document,
request: Any,
request: Request,
*,
include_deleted: bool = False,
) -> VersionResolution:
@@ -107,18 +107,41 @@ def resolve_effective_document(
return VersionResolution(document=request_doc)
_EFFECTIVE_DOCUMENT_CACHE_ATTR = "_effective_document_resolution_cache"
def resolve_effective_document_by_pk(
pk: int,
request: Any,
request: Request,
*,
include_deleted: bool = False,
) -> VersionResolution:
# Django's `condition()` decorator (used for ETag/Last-Modified) invokes the
# etag_func and last_modified_func separately, and the view itself may resolve
# again -- all against the same request. Cache per-request so a single thumb/
# metadata/preview request doesn't redo this resolution multiple times.
cache = getattr(request, _EFFECTIVE_DOCUMENT_CACHE_ATTR, None)
if cache is None:
cache = {}
setattr(request, _EFFECTIVE_DOCUMENT_CACHE_ATTR, cache)
key = (pk, include_deleted)
if key in cache:
return cache[key]
manager = _document_manager(include_deleted=include_deleted)
request_doc = manager.only("id", "root_document_id").filter(pk=pk).first()
if request_doc is None:
return VersionResolution(document=None, error=VersionResolutionError.NOT_FOUND)
return resolve_effective_document(
request_doc,
request,
include_deleted=include_deleted,
)
resolution = VersionResolution(
document=None,
error=VersionResolutionError.NOT_FOUND,
)
else:
resolution = resolve_effective_document(
request_doc,
request,
include_deleted=include_deleted,
)
cache[key] = resolution
return resolution
+16 -1
View File
@@ -1040,12 +1040,23 @@ class DocumentViewSet(
.order_by("-id")
.values("content")[:1],
)
# A correlated subquery avoids the LEFT JOIN + Count() this used to
# be, which forced a GROUP BY aggregate over every matching document
# before the query could even be sorted or limited.
note_count = Subquery(
Note.objects.filter(document=OuterRef("pk"))
.order_by()
.values("document")
.annotate(count=Count("pk"))
.values("count"),
output_field=IntegerField(),
)
return (
Document.objects.filter(root_document__isnull=True)
.distinct()
.order_by("-created", "-id")
.annotate(effective_content=Coalesce(latest_version_content, F("content")))
.annotate(num_notes=Count("notes"))
.annotate(num_notes=Coalesce(note_count, 0))
.select_related("correspondent", "storage_path", "document_type", "owner")
.prefetch_related(
Prefetch(
@@ -2057,6 +2068,8 @@ class DocumentViewSet(
_backend.remove(version_doc.pk)
version_doc_id = version_doc.id
version_doc.delete()
root_doc.modified = timezone.now()
Document.objects.filter(pk=root_doc.pk).update(modified=root_doc.modified)
_backend.add_or_update(root_doc)
if settings.AUDIT_LOG_ENABLED:
actor = (
@@ -2136,6 +2149,8 @@ class DocumentViewSet(
old_label = version_doc.version_label
version_doc.version_label = serializer.validated_data["version_label"]
version_doc.save(update_fields=["version_label"])
root_doc.modified = timezone.now()
Document.objects.filter(pk=root_doc.pk).update(modified=root_doc.modified)
if settings.AUDIT_LOG_ENABLED and old_label != version_doc.version_label:
actor = (
+5 -1
View File
@@ -24,7 +24,11 @@ def apply_assignment_to_document(
action: WorkflowAction, annotated with 'has_assign_*' boolean fields
"""
if action.has_assign_tags:
document.add_nested_tags(action.assign_tags.all())
# Apply to a freshly-fetched instance rather than the shared `document`.
# Document.tags.add() fires an m2m_changed signal that ultimately calls
# instance.refresh_from_db(), which would discard any other unsaved
# assignment fields (e.g. storage_path) already staged on `document`.
Document.objects.get(pk=document.pk).add_nested_tags(action.assign_tags.all())
if action.assign_correspondent:
document.correspondent = action.assign_correspondent
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Afrikaans\n"
"Language: af_ZA\n"
@@ -53,7 +53,7 @@ msgstr ""
msgid "Maximum nesting depth exceeded."
msgstr ""
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr ""
@@ -1659,11 +1659,11 @@ msgstr ""
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr ""
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Amharic\n"
"Language: am_ET\n"
@@ -53,7 +53,7 @@ msgstr "ጥያቄን አይደግፍም expr {expr!r}."
msgid "Maximum nesting depth exceeded."
msgstr "ከፍተኛው የጥገኝነት ጥልቀት አልፏል።"
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr "ይህ ልማድ አልተገኘም"
@@ -1659,11 +1659,11 @@ msgstr ""
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr ""
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Arabic\n"
"Language: ar_SA\n"
@@ -53,7 +53,7 @@ msgstr "{data_type} لا يدعم تعبير الاستعلام {expr!r}."
msgid "Maximum nesting depth exceeded."
msgstr ""
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr "لم يتم العثور على حقل مخصص"
@@ -1660,11 +1660,11 @@ msgstr ""
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr ""
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Belarusian\n"
"Language: be_BY\n"
@@ -53,7 +53,7 @@ msgstr ""
msgid "Maximum nesting depth exceeded."
msgstr ""
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr ""
@@ -1659,11 +1659,11 @@ msgstr ""
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr ""
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Bulgarian\n"
"Language: bg_BG\n"
@@ -53,7 +53,7 @@ msgstr "{data_type} не поддържа заявка expr {expr!r}."
msgid "Maximum nesting depth exceeded."
msgstr "Надвишена е максималната дълбочина на вмъкване."
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr "Персонализирано поле не е намерено"
@@ -1660,11 +1660,11 @@ msgstr ""
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr ""
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Catalan\n"
"Language: ca_ES\n"
@@ -53,7 +53,7 @@ msgstr "{data_type} no suporta expressió de consulta {expr!r}."
msgid "Maximum nesting depth exceeded."
msgstr "Màxima profunditat anidada excedida."
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr "Camp personalitzat no trobat"
@@ -1660,11 +1660,11 @@ msgstr "Permisos insuficients per compartir document %(id)s."
msgid "Bundle is already being processed."
msgstr "Paquet ja s'està processant."
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr "El paquet de link encarà s'està preparant. Prova de nou més tard."
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr "El paquet d'enllaç no està disponible."
+6 -6
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-21 12:39\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Czech\n"
"Language: cs_CZ\n"
@@ -53,7 +53,7 @@ msgstr "{data_type} nepodporuje výraz dotazu {expr!r}."
msgid "Maximum nesting depth exceeded."
msgstr "Překročena maximální hloubka větvení."
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr "Vlastní pole nebylo nalezeno"
@@ -586,7 +586,7 @@ msgstr ""
#: documents/models.py:676 documents/models.py:753
msgid "Started"
msgstr "Zahájeno"
msgstr ""
#: documents/models.py:677
msgid "Success"
@@ -1661,11 +1661,11 @@ msgstr ""
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr ""
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Danish\n"
"Language: da_DK\n"
@@ -53,7 +53,7 @@ msgstr "{data_type} understøtter ikke forespørgsel expr {expr!r}."
msgid "Maximum nesting depth exceeded."
msgstr "Maksimal indlejringsdybde overskredet."
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr "Tilpasset felt ikke fundet"
@@ -1660,11 +1660,11 @@ msgstr ""
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr ""
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: German, Switzerland\n"
"Language: de_CH\n"
@@ -53,7 +53,7 @@ msgstr "{data_type} unterstützt den Abfrageausdruck {expr!r} nicht."
msgid "Maximum nesting depth exceeded."
msgstr "Maximale Verschachtelungstiefe überschritten."
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr "Benutzerdefiniertes Feld nicht gefunden"
@@ -1660,11 +1660,11 @@ msgstr "Unzureichende Berechtigungen, um Dokument %(id)s zu teilen."
msgid "Bundle is already being processed."
msgstr "Paket wird bereits verarbeitet."
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr "Das Freigabelink-Paket wird noch vorbereitet. Bitte versuchen Sie es später erneut."
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr "Das Freigabelink-Paket ist nicht verfügbar."
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-17 12:35\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
@@ -53,7 +53,7 @@ msgstr "{data_type} unterstützt den Abfrageausdruck {expr!r} nicht."
msgid "Maximum nesting depth exceeded."
msgstr "Maximale Verschachtelungstiefe überschritten."
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr "Zusatzfeld nicht gefunden"
@@ -1660,11 +1660,11 @@ msgstr "Unzureichende Berechtigungen, um Dokument %(id)s zu teilen."
msgid "Bundle is already being processed."
msgstr "Paket wird bereits verarbeitet."
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr "Das Freigabelink-Paket wird noch vorbereitet. Bitte versuchen Sie es später erneut."
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr "Das Freigabelink-Paket ist nicht verfügbar."
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Greek\n"
"Language: el_GR\n"
@@ -53,7 +53,7 @@ msgstr "Το {data_type} δεν υποστηρίζει το ερώτημα expr
msgid "Maximum nesting depth exceeded."
msgstr "Υπέρβαση μέγιστου βάθους εμφώλευσης."
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr "Το προσαρμοσμένο πεδίο δε βρέθηκε"
@@ -1660,11 +1660,11 @@ msgstr ""
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr ""
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Spanish\n"
"Language: es_ES\n"
@@ -53,7 +53,7 @@ msgstr "{data_type} no admite la consulta expr {expr!r}."
msgid "Maximum nesting depth exceeded."
msgstr "Profundidad máxima de nidificación superada."
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr "Campo personalizado no encontrado"
@@ -1660,11 +1660,11 @@ msgstr "Permisos insuficientes para compartir el documento %(id)s."
msgid "Bundle is already being processed."
msgstr "El paquete ya está siendo procesado."
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr "El paquete de enlace compartido aún está siendo preparado. Por favor, inténtalo de nuevo más tarde."
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr "El paquete de enlace compartido no está disponible."
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Estonian\n"
"Language: et_EE\n"
@@ -53,7 +53,7 @@ msgstr "{data_type} ei toeta päringu avaldist {expr!r}."
msgid "Maximum nesting depth exceeded."
msgstr "Suurim pesastamis sügavus ületatud."
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr "Kohandatud välja ei leitud"
@@ -1659,11 +1659,11 @@ msgstr ""
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr ""
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Persian\n"
"Language: fa_IR\n"
@@ -53,7 +53,7 @@ msgstr ""
msgid "Maximum nesting depth exceeded."
msgstr "حداکثر عمق تودرتویی بیش از حد مجاز است."
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr "زمینه سفارشی یافت نشد"
@@ -1659,11 +1659,11 @@ msgstr ""
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr ""
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Finnish\n"
"Language: fi_FI\n"
@@ -53,7 +53,7 @@ msgstr ""
msgid "Maximum nesting depth exceeded."
msgstr ""
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr ""
@@ -1660,11 +1660,11 @@ msgstr ""
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr ""
+11 -11
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-21 00:49\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -53,7 +53,7 @@ msgstr "{data_type} ne supporte pas l'expression {expr!r}."
msgid "Maximum nesting depth exceeded."
msgstr "Profondeur de récursion maximale dépassée."
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr "Champ personnalisé non trouvé"
@@ -1644,7 +1644,7 @@ msgstr "Configuration IA invalide."
#: documents/views.py:1522
msgid "AI backend request timed out."
msgstr "La requête d'arrière-plan IA a expiré."
msgstr ""
#: documents/views.py:2304 documents/views.py:2625
msgid "Specify only one of text, title_search, query, or more_like_id."
@@ -1659,11 +1659,11 @@ msgstr "Droits d'accès insuffisant pour partager %(id)s document."
msgid "Bundle is already being processed."
msgstr "Le paquet est déjà en cours de traitement."
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr ""
@@ -1705,7 +1705,7 @@ msgstr "rétablir"
#: paperless/models.py:42
msgid "off"
msgstr "off"
msgstr ""
#: paperless/models.py:51
msgid "always"
@@ -1909,11 +1909,11 @@ msgstr "Définit le endpoint LLM, optionnel"
#: paperless/models.py:363
msgid "Sets the LLM output language"
msgstr "Définit la langue de sortie du LLM"
msgstr ""
#: paperless/models.py:370
msgid "Sets the LLM timeout in seconds"
msgstr "Définit le délai d'attente du LLM en secondes"
msgstr ""
#: paperless/models.py:376
msgid "paperless application settings"
@@ -2373,11 +2373,11 @@ msgstr "Affecter le propriétaire de la règle aux documents"
#: paperless_mail/models.py:305
msgid "Stop processing further rules"
msgstr "Arrêter le traitement des règles suivantes"
msgstr ""
#: paperless_mail/models.py:308
msgid "If True, no further rules will be processed after this one if any document is queued."
msgstr "Si Vrai, aucune règle supplémentaire ne sera traitée après celle-ci si un document est mis en attente."
msgstr ""
#: paperless_mail/models.py:334
msgid "uid"
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Hebrew\n"
"Language: he_IL\n"
@@ -53,7 +53,7 @@ msgstr "{data_type} לא תומך בביטוי שאילתה {expr!r}."
msgid "Maximum nesting depth exceeded."
msgstr "חריגה מעומק הקינון המרבי."
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr "שדה מותאם אישית לא נמצא"
@@ -1661,11 +1661,11 @@ msgstr "הרשאות לא מספיקות לשיתוף מסמך %(id)s."
msgid "Bundle is already being processed."
msgstr "החבילה (Bundle) כבר נמצאת בתהליך עיבוד."
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr "חבילת קישור השיתוף עדיין בהכנה. נא לנסות שוב מאוחר יותר."
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr "חבילת קישור השיתוף אינה זמינה."
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Hindi\n"
"Language: hi_IN\n"
@@ -53,7 +53,7 @@ msgstr "{data_type} क्वेरी एक्सप्रेशन {expr!r}
msgid "Maximum nesting depth exceeded."
msgstr "अधिकतम नेस्टिंग डेप्थ पार हो गई है।"
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr "कस्टम फ़ील्ड नहीं मिला"
@@ -1659,11 +1659,11 @@ msgstr ""
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr ""
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Croatian\n"
"Language: hr_HR\n"
@@ -53,7 +53,7 @@ msgstr "{data_type} ne podržava upit izraz {expr!r}."
msgid "Maximum nesting depth exceeded."
msgstr "Premašena je najveća razina ugniježđivanja."
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr "Prilagođeno polje nije pronađeno"
@@ -1660,11 +1660,11 @@ msgstr "Nedovoljne ovlasti za dijeljenje dokumenta %(id)s."
msgid "Bundle is already being processed."
msgstr "Paket se već obrađuje."
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr "Paket linka za dijeljenje se još priprema. Pokušajte ponovo kasnije."
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr "Paket linka za dijeljenje nije dostupan."
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Hungarian\n"
"Language: hu_HU\n"
@@ -53,7 +53,7 @@ msgstr "A(z) {data_type} nem támogatja a {expr!r} kifejezés lekérdezést."
msgid "Maximum nesting depth exceeded."
msgstr "Maximum beágyazási mélység túllépve."
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr "Az egyéni mező nem található"
@@ -1660,11 +1660,11 @@ msgstr "Nincs megfelelő jogosultság a %(id)s dokumentum megosztásához."
msgid "Bundle is already being processed."
msgstr "A csomag feldolgozása már folyamatban van."
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr "A megosztási linkcsomag készítése folyamatban. Kérjük, próbálja meg később."
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr "A megosztási linkcsomag nem elérhető."
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-20 12:47\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Indonesian\n"
"Language: id_ID\n"
@@ -53,7 +53,7 @@ msgstr "{data_type} tidak mendukung ekspresi pencarian expr {expr!r}."
msgid "Maximum nesting depth exceeded."
msgstr "Kedalaman susunan maksimal terlampaui."
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr "Bidang khusus tidak ditemukan"
@@ -1660,11 +1660,11 @@ msgstr "Izin tidak mencukupi untuk berbagi dokumen %(id)s"
msgid "Bundle is already being processed."
msgstr "Paket sedang diproses."
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr "Bundel tautan berbagi masih dalam proses persiapan. Silakan coba lagi nanti."
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr "Bundel tautan berbagi tidak tersedia."
+6 -6
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Italian\n"
"Language: it_IT\n"
@@ -53,7 +53,7 @@ msgstr "{data_type} Non supporta la jQuery Expo {Expo!r}."
msgid "Maximum nesting depth exceeded."
msgstr "Profondità massima di nidificazione superata."
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr "Campo personalizzato non trovato"
@@ -1660,11 +1660,11 @@ msgstr "Autorizzazioni insufficienti per condividere il documento %(id)s."
msgid "Bundle is already being processed."
msgstr "Il pacchetto è già in fase di elaborazione."
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr "Il pacchetto di link di condivisione è ancora in fase di preparazione. Riprova più tardi."
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr "Il pacchetto di link di condivisione non è disponibile."
@@ -2386,7 +2386,7 @@ msgstr "uid"
#: paperless_mail/models.py:342
msgid "uid validity"
msgstr "validità uid"
msgstr ""
#: paperless_mail/models.py:350
msgid "subject"
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Japanese\n"
"Language: ja_JP\n"
@@ -53,7 +53,7 @@ msgstr "{data_type} はクエリ expr {expr!r} をサポートしていません
msgid "Maximum nesting depth exceeded."
msgstr "最大ネストの深さを超えました。"
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr "カスタムフィールドが見つかりません"
@@ -1660,11 +1660,11 @@ msgstr ""
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr ""
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Korean\n"
"Language: ko_KR\n"
@@ -53,7 +53,7 @@ msgstr "{data_type}은 쿼리 표현식 {expr!r}을(를) 지원하지 않습니
msgid "Maximum nesting depth exceeded."
msgstr "최대 중첩 깊이를 초과했습니다."
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr "사용자 지정 필드를 찾을 수 없음"
@@ -1660,11 +1660,11 @@ msgstr ""
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr ""
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Luxembourgish\n"
"Language: lb_LU\n"
@@ -53,7 +53,7 @@ msgstr ""
msgid "Maximum nesting depth exceeded."
msgstr ""
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr ""
@@ -1659,11 +1659,11 @@ msgstr ""
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr ""
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Lithuanian\n"
"Language: lt_LT\n"
@@ -53,7 +53,7 @@ msgstr "{data_type} nepalaiko užklausos išraiškos {expr!r}."
msgid "Maximum nesting depth exceeded."
msgstr "Viršytas maksimalus įdėjimo gylis."
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr "Pasirinktinis laukas nerastas"
@@ -1659,11 +1659,11 @@ msgstr "Nepakanka leidimų bendrinti dokumentą %(id)s."
msgid "Bundle is already being processed."
msgstr "Rinkinys jau apdorojamas."
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr "Bendrinimo nuorodų rinkinys vis dar ruošiamas. Bandykite dar kartą vėliau."
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr "Dalinimos nuorodų rinkinys yra nepasiekiamas."
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Latvian\n"
"Language: lv_LV\n"
@@ -53,7 +53,7 @@ msgstr ""
msgid "Maximum nesting depth exceeded."
msgstr ""
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr ""
@@ -1659,11 +1659,11 @@ msgstr ""
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr ""
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Macedonian\n"
"Language: mk_MK\n"
@@ -53,7 +53,7 @@ msgstr ""
msgid "Maximum nesting depth exceeded."
msgstr ""
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr ""
@@ -1659,11 +1659,11 @@ msgstr ""
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr ""
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Malay\n"
"Language: ms_MY\n"
@@ -53,7 +53,7 @@ msgstr ""
msgid "Maximum nesting depth exceeded."
msgstr ""
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr ""
@@ -1659,11 +1659,11 @@ msgstr ""
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr ""
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Language: nl_NL\n"
@@ -53,7 +53,7 @@ msgstr "{data_type} ondersteunt geen query expr {expr!r}."
msgid "Maximum nesting depth exceeded."
msgstr "Maximale nestdiepte overschreden."
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr "Aangepast veld niet gevonden"
@@ -1660,11 +1660,11 @@ msgstr ""
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr ""
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Norwegian\n"
"Language: no_NO\n"
@@ -53,7 +53,7 @@ msgstr "{data_type} støtter ikke spørring expr {expr!r}."
msgid "Maximum nesting depth exceeded."
msgstr "For mange nivåer med nøsting."
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr "Egendefinert felt ble ikke funnet"
@@ -1660,11 +1660,11 @@ msgstr "Insufficient permissions to share document %(id)s. (NO)"
msgid "Bundle is already being processed."
msgstr "Bundle is already being processed. (NO)"
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr "The share link bundle is still being prepared. Please try again later. (NO)"
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr "The share link bundle is unavailable. (NO)"
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Polish\n"
"Language: pl_PL\n"
@@ -53,7 +53,7 @@ msgstr "{data_type} nie obsługuje wyrażenia zapytania {expr!r}."
msgid "Maximum nesting depth exceeded."
msgstr "Przekroczono maksymalną głębokość zagnieżdżenia."
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr "Nie znaleziono pola niestandardowego"
@@ -1660,11 +1660,11 @@ msgstr "Brak uprawnień do udostępnienia dokumentu %(id)s."
msgid "Bundle is already being processed."
msgstr "Pakiet jest już przetwarzany."
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr "Pakiet do udostępnienia jest nadal przygotowywany. Spróbuj ponownie później."
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr "Pakiet do udostępnienia jest niedostępny."
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Portuguese, Brazilian\n"
"Language: pt_BR\n"
@@ -53,7 +53,7 @@ msgstr "{data_type} Não suporta a consulta expr {expr!r}."
msgid "Maximum nesting depth exceeded."
msgstr "Profundidade máxima do aninhamento excedida."
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr "Campo personalizado não encontrado"
@@ -1662,11 +1662,11 @@ msgstr ""
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr ""
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Portuguese\n"
"Language: pt_PT\n"
@@ -53,7 +53,7 @@ msgstr "{data_type} não aceita a expressão de consulta {expr!r}."
msgid "Maximum nesting depth exceeded."
msgstr ""
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr "Campo personalizado não encontrado"
@@ -1660,11 +1660,11 @@ msgstr ""
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr ""
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Romanian\n"
"Language: ro_RO\n"
@@ -53,7 +53,7 @@ msgstr "{data_type} nu acceptă interogare expr {expr!r}."
msgid "Maximum nesting depth exceeded."
msgstr "Adâncimea maximă depășită."
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr "Câmpul personalizat nu a fost găsit"
@@ -1660,11 +1660,11 @@ msgstr ""
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr ""
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Russian\n"
"Language: ru_RU\n"
@@ -53,7 +53,7 @@ msgstr "{data_type} не поддерживает запрос {expr!r}."
msgid "Maximum nesting depth exceeded."
msgstr "Превышена максимальная глубина вложения."
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr "Пользовательское поле не найдено"
@@ -1660,11 +1660,11 @@ msgstr ""
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr ""
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Slovak\n"
"Language: sk_SK\n"
@@ -53,7 +53,7 @@ msgstr "{data_type} nepodporuje výraz požiadavky {expr!r}."
msgid "Maximum nesting depth exceeded."
msgstr "Bola prekročená maximálna hĺbka vetvenia."
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr "Vlastné pole nebolo nájdené"
@@ -1660,11 +1660,11 @@ msgstr ""
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr ""
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Slovenian\n"
"Language: sl_SI\n"
@@ -53,7 +53,7 @@ msgstr "{data_type} ne podpira izraza poizvedbe {expr!r}."
msgid "Maximum nesting depth exceeded."
msgstr "Presežena je bila največja globina gnezdenja."
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr "Polja po meri ni bilo mogoče najti"
@@ -1660,11 +1660,11 @@ msgstr "Nezadostna dovoljenja za skupno rabo dokumenta %(id)s."
msgid "Bundle is already being processed."
msgstr "Paket se že obdeluje."
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr "Paket povezav za deljenje je še vedno v pripravi. Poskusite znova pozneje."
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr "Paket povezav za deljenje ni na voljo."
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Albanian\n"
"Language: sq_AL\n"
@@ -53,7 +53,7 @@ msgstr "[SQ] {data_type} does not support query expr {expr!r}."
msgid "Maximum nesting depth exceeded."
msgstr "[SQ] Maximum nesting depth exceeded."
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr "Custom field not found"
@@ -1660,11 +1660,11 @@ msgstr "[SQ] Insufficient permissions to share document %(id)s."
msgid "Bundle is already being processed."
msgstr "[SQ] Bundle is already being processed."
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr "[SQ] The share link bundle is still being prepared. Please try again later."
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr "[SQ] The share link bundle is unavailable."
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"Last-Translator: \n"
"Language-Team: Serbian (Latin)\n"
"Language: sr_CS\n"
@@ -53,7 +53,7 @@ msgstr "{data_type} ne podržava izraz u upitu {expr!r}."
msgid "Maximum nesting depth exceeded."
msgstr "Premašena je maksimalna dubina grananja."
#: documents/filters.py:1059
#: documents/filters.py:1052
msgid "Custom field not found"
msgstr "Nije pronađeno prilagođeno polje"
@@ -1660,11 +1660,11 @@ msgstr "Nedovoljne dozvole za deljenje dokumenta %(id)s."
msgid "Bundle is already being processed."
msgstr "Paket se već obrađuje."
#: documents/views.py:4555
#: documents/views.py:4554
msgid "The share link bundle is still being prepared. Please try again later."
msgstr "Paket linkova za deljenje se još uvek priprema. Molimo pokušajte ponovo kasnije."
#: documents/views.py:4565
#: documents/views.py:4564
msgid "The share link bundle is unavailable."
msgstr "Paket linkova za deljenje nije dostupan."

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